Compare commits
6 commits
95304d90df
...
95887e56ce
| Author | SHA1 | Date | |
|---|---|---|---|
| 95887e56ce | |||
| 0944d3c84f | |||
| d9d0f8f7ae | |||
| 0674371536 | |||
| 399f7f0bcf | |||
| 8d6405247e |
10 changed files with 23452 additions and 11 deletions
|
|
@ -359,7 +359,7 @@ shadcn/ui components live in `src/components/ui/`. Use `bunx shadcn@latest add <
|
|||
|
||||
`bun run deploy` bumps the patch version (via `bun pm version patch`) and runs the production build. Push the resulting version commit to deploy through Coolify; the legacy `build/deploy.sh` path is no longer used.
|
||||
|
||||
**Version bump after git commands**: whenever a `/git` or `/git-master` workflow creates commits, also bump the patch version with `bun pm version patch` and push it together with the work. The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits — but never use a bare `git push --tags`, which sends only tags and not the branch. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump — or pushing the commit without its tag — makes releases indistinguishable and breaks version history.
|
||||
**Version bump after git commands**: whenever a `/git` or `/git-master` workflow creates commits, also bump the patch version with `bun pm version patch --message "v%s - <descriptor>"` and push it together with the work. `<descriptor>` is a short comma-separated summary of what the release ships (derived from the commits just created), e.g. `v0.2.4 - add voicelines, update git rules` — never a bare version number. The command itself creates the `vX.Y.Z` version commit **and** a local git tag. `push.followTags` is set in this repo, so a plain `git push` automatically carries the annotated release tag with the commits — but never use a bare `git push --tags`, which sends only tags and not the branch. The deployed site's version overlay reads `package.json`'s `version` (Coolify builds have no git metadata, so the displayed version is `<semver>+<commit sha>`), and an unbumped semver means every deploy shows the same `0.2.0`-style number even though the commit-hash suffix changes. Skipping the bump — or pushing the commit without its tag — makes releases indistinguishable and breaks version history.
|
||||
|
||||
## Gotchas
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "polaris-task-force",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"description": "Web app for the Arma 3 unit Polaris Task Force. Uses Next.js for client- and server-side rendering, with a Postgres database and some other neat libraries.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -87,7 +87,10 @@ export async function submitVoiceRecording(
|
|||
if (!(file instanceof File)) {
|
||||
return { success: false, error: "No audio file provided." };
|
||||
}
|
||||
if (!file.type.startsWith("audio/")) {
|
||||
// Browsers report MediaRecorder blobs as e.g. "audio/webm;codecs=opus" —
|
||||
// strip parameters so the base type matches the collection allowlist.
|
||||
const mimetype = file.type.split(";")[0]?.trim().toLowerCase() ?? "";
|
||||
if (!mimetype.startsWith("audio/")) {
|
||||
return { success: false, error: "Only audio files can be submitted." };
|
||||
}
|
||||
if (file.size > MAX_AUDIO_BYTES) {
|
||||
|
|
@ -103,7 +106,7 @@ export async function submitVoiceRecording(
|
|||
},
|
||||
file: {
|
||||
data: Buffer.from(await file.arrayBuffer()),
|
||||
mimetype: file.type,
|
||||
mimetype,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import {
|
||||
ActionRowBuilder,
|
||||
AttachmentBuilder,
|
||||
ButtonBuilder,
|
||||
ButtonStyle,
|
||||
type Client,
|
||||
EmbedBuilder,
|
||||
type TextBasedChannelFields,
|
||||
} from "discord.js";
|
||||
import fsPromises from "fs/promises";
|
||||
import path from "path";
|
||||
import type { Payload } from "payload";
|
||||
import type { Mission, MissionAttendance, User } from "@/payload-types";
|
||||
import type { Mission, MissionAttendance, ModList, User } from "@/payload-types";
|
||||
import { getMissionAttendance, type MissionAttendanceSummary } from "@/lib/attendance";
|
||||
import { ATTENDANCE_CHANGED_CHANNEL } from "@/lib/attendance/realtime";
|
||||
import { botConfig } from "@/bot/config";
|
||||
|
|
@ -16,6 +19,11 @@ const EMBED_COLOR = 0x3b82f6;
|
|||
const CANCELLED_COLOR = 0x6b7280;
|
||||
const MAX_ELIGIBLE_MISSIONS = 10;
|
||||
const ROLL_CALL_LEAD_TIME_MS = 3 * 24 * 60 * 60 * 1000;
|
||||
// Discord attachment cap (8 MiB) — larger modlists fall back to a link.
|
||||
const MAX_ATTACHMENT_BYTES = 8 * 1024 * 1024;
|
||||
// Payload's default staticDir for an upload collection is its slug, resolved
|
||||
// against the process cwd — the bot shares the web app's filesystem.
|
||||
const MODLIST_STATIC_DIR = "mod-lists";
|
||||
|
||||
let opsChannelWarned = false;
|
||||
let reconcileRunning = false;
|
||||
|
|
@ -223,6 +231,141 @@ const postEmbed = async (
|
|||
payload.logger.info(
|
||||
`[Bot] Posted attendance embed for mission #${mission.id} (${mission.name}).`,
|
||||
);
|
||||
await syncModlistMessage(client, payload, opsChannelId, {
|
||||
...mission,
|
||||
discordAttendanceSentAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
interface ModlistInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
filename: string;
|
||||
url: string | null;
|
||||
filesize: number | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const resolveModlist = async (
|
||||
payload: Payload,
|
||||
mission: Mission,
|
||||
): Promise<ModlistInfo | null> => {
|
||||
const raw = mission.gameDetails.gameSetup?.requiredMods;
|
||||
if (raw == null || typeof raw === "number") return null;
|
||||
const modlist = raw as ModList;
|
||||
if (!modlist.filename) return null;
|
||||
return {
|
||||
id: modlist.id,
|
||||
name: modlist.modlistName,
|
||||
filename: modlist.filename,
|
||||
url: modlist.url ?? null,
|
||||
filesize: modlist.filesize ?? null,
|
||||
updatedAt: modlist.updatedAt,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Fingerprint of the attached modlist so reconcile can detect changes and
|
||||
* announce re-downloads without looping (same pattern as attendanceHash).
|
||||
*/
|
||||
const modlistHash = (modlist: ModlistInfo): string =>
|
||||
JSON.stringify({
|
||||
id: modlist.id,
|
||||
filename: modlist.filename,
|
||||
filesize: modlist.filesize,
|
||||
updatedAt: modlist.updatedAt,
|
||||
});
|
||||
|
||||
const renderModlistEmbed = (
|
||||
mission: Mission,
|
||||
modlist: ModlistInfo,
|
||||
kind: "new" | "updated",
|
||||
): EmbedBuilder => {
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(kind === "new" ? `Modlist — ${modlist.name}` : `Modlist updated — ${modlist.name}`)
|
||||
.setColor(EMBED_COLOR)
|
||||
.setTimestamp();
|
||||
embed.setDescription(
|
||||
kind === "new"
|
||||
? `The modlist for **${missionTitle(mission)}** is attached. Please download it before the operation.`
|
||||
: `The modlist for **${missionTitle(mission)}** has changed. Please re-download the attached file before the operation.`,
|
||||
);
|
||||
return embed;
|
||||
};
|
||||
|
||||
const postModlistMessage = async (
|
||||
client: Client,
|
||||
payload: Payload,
|
||||
opsChannelId: string,
|
||||
mission: Mission,
|
||||
modlist: ModlistInfo,
|
||||
kind: "new" | "updated",
|
||||
): Promise<void> => {
|
||||
const channel = await getOpsChannel(client, opsChannelId);
|
||||
const embed = renderModlistEmbed(mission, modlist, kind);
|
||||
|
||||
let files: AttachmentBuilder[] | undefined;
|
||||
const tooBig = modlist.filesize != null && modlist.filesize > MAX_ATTACHMENT_BYTES;
|
||||
if (!tooBig) {
|
||||
try {
|
||||
const dir = path.resolve(process.cwd(), MODLIST_STATIC_DIR);
|
||||
const filePath = path.resolve(dir, modlist.filename);
|
||||
if (!filePath.startsWith(dir + path.sep)) throw new Error("invalid modlist filename");
|
||||
files = [
|
||||
new AttachmentBuilder(await fsPromises.readFile(filePath), { name: modlist.filename }),
|
||||
];
|
||||
} catch (error) {
|
||||
payload.logger.warn(
|
||||
`[Bot] Could not read modlist file for mission #${mission.id}: ${errorMessage(error)}; falling back to a link.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
payload.logger.warn(
|
||||
`[Bot] Modlist for mission #${mission.id} exceeds the attachment size cap; linking instead.`,
|
||||
);
|
||||
}
|
||||
if (!files && modlist.url) {
|
||||
embed.addFields({ name: "Download", value: `${botConfig.appUrl}${modlist.url}` });
|
||||
}
|
||||
|
||||
await channel.send({ embeds: [embed], files });
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs once a roll call has been sent: announces a newly attached modlist,
|
||||
* announces changes with a re-download reminder, and clears the marker when
|
||||
* the modlist is removed. The hash marker keeps this idempotent.
|
||||
*/
|
||||
const syncModlistMessage = async (
|
||||
client: Client,
|
||||
payload: Payload,
|
||||
opsChannelId: string,
|
||||
mission: Mission,
|
||||
): Promise<void> => {
|
||||
if (!mission.discordAttendanceSentAt) return;
|
||||
|
||||
const modlist = await resolveModlist(payload, mission);
|
||||
const currentHash = modlist ? modlistHash(modlist) : null;
|
||||
const storedHash = mission.discordModlistHash ?? null;
|
||||
if (currentHash === storedHash) return;
|
||||
|
||||
if (modlist) {
|
||||
const kind = storedHash ? "updated" : "new";
|
||||
await postModlistMessage(client, payload, opsChannelId, mission, modlist, kind);
|
||||
payload.logger.info(
|
||||
`[Bot] Posted ${kind} modlist message for mission #${mission.id} (${mission.name}).`,
|
||||
);
|
||||
} else if (storedHash) {
|
||||
payload.logger.info(
|
||||
`[Bot] Modlist removed from mission #${mission.id} (${mission.name}); marker cleared.`,
|
||||
);
|
||||
}
|
||||
await payload.update({
|
||||
collection: "missions",
|
||||
id: mission.id,
|
||||
data: { discordModlistHash: currentHash },
|
||||
overrideAccess: true,
|
||||
});
|
||||
};
|
||||
|
||||
const manageMission = async (
|
||||
|
|
@ -231,9 +374,12 @@ const manageMission = async (
|
|||
opsChannelId: string,
|
||||
mission: Mission,
|
||||
): Promise<void> => {
|
||||
if (!isRollCallDue(mission)) {
|
||||
// A sent roll call may still need its existing message reconciled.
|
||||
if (!mission.discordAttendanceSentAt) return;
|
||||
if (!isRollCallDue(mission) && !mission.discordAttendanceSentAt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mission.discordAttendanceSentAt) {
|
||||
await syncModlistMessage(client, payload, opsChannelId, mission);
|
||||
}
|
||||
|
||||
if (!mission.discordMessageId) {
|
||||
|
|
@ -304,7 +450,7 @@ export const reconcileMission = async (
|
|||
if (
|
||||
mission.ownershipAndStatus.visibility !== "unit" ||
|
||||
!["Ready", "Scheduled"].includes(mission.ownershipAndStatus.status) ||
|
||||
(!isRollCallDue(mission) && !mission.discordMessageId)
|
||||
(!isRollCallDue(mission) && !mission.discordMessageId && !mission.discordAttendanceSentAt)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -359,7 +505,7 @@ export const reconcile = async (client: Client, payload: Payload): Promise<void>
|
|||
}
|
||||
|
||||
const eligible = (await getEligibleMissions(payload)).filter(
|
||||
(mission) => isRollCallDue(mission) || !!mission.discordMessageId,
|
||||
(mission) => isRollCallDue(mission) || !!mission.discordMessageId || !!mission.discordAttendanceSentAt,
|
||||
);
|
||||
for (const mission of eligible) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -197,6 +197,15 @@ export const Missions: CollectionConfig = {
|
|||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "discordModlistHash",
|
||||
type: "text",
|
||||
admin: {
|
||||
description:
|
||||
"Fingerprint of the attached modlist last announced to Discord (managed by the bot).",
|
||||
hidden: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "evaluationRemindersSentAt",
|
||||
type: "date",
|
||||
|
|
|
|||
|
|
@ -367,6 +367,25 @@ export function TeleprompterReader({ script }: { readonly script: string }) {
|
|||
<GaugeIcon className="size-3.5" />
|
||||
<span>Constant scroll speed · {scrollSpeed} px/s</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 rounded-md border border-border bg-background/50 p-3 text-xs text-muted-foreground">
|
||||
<p>
|
||||
<span className="font-medium text-foreground">How to read:</span> read every word shown
|
||||
in large text — underlined and plain alike.
|
||||
</p>
|
||||
<p>
|
||||
The small colored labels above the text (
|
||||
<span className="italic text-sky-300/80">like this</span>) are directions for how to
|
||||
speak — never read them aloud. Each underlined stretch is the wording its label above
|
||||
applies to.
|
||||
</p>
|
||||
<p>
|
||||
Rows marked with an icon (
|
||||
<AudioLinesIcon className="mx-0.5 inline size-3 align-baseline" aria-hidden /> sound
|
||||
cues,{" "}
|
||||
<SlidersHorizontalIcon className="mx-0.5 inline size-3 align-baseline" aria-hidden />{" "}
|
||||
technical notes) are not spoken either.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
23242
src/migrations/20260907_044934_add_missions_discord_modlist_hash.json
Normal file
23242
src/migrations/20260907_044934_add_missions_discord_modlist_hash.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,11 @@
|
|||
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
|
||||
|
||||
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "missions" ADD COLUMN "discord_modlist_hash" varchar;`)
|
||||
}
|
||||
|
||||
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "missions" DROP COLUMN "discord_modlist_hash";`)
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ import * as migration_20260907_012326_add_missions_created_by from './20260907_0
|
|||
import * as migration_20260907_013724_make_missions_campaign_optional from './20260907_013724_make_missions_campaign_optional';
|
||||
import * as migration_20260907_041051_add_voice_script_spoiler from './20260907_041051_add_voice_script_spoiler';
|
||||
import * as migration_20260907_043339_add_voice_script_description from './20260907_043339_add_voice_script_description';
|
||||
import * as migration_20260907_044934_add_missions_discord_modlist_hash from './20260907_044934_add_missions_discord_modlist_hash';
|
||||
|
||||
export const migrations = [
|
||||
{
|
||||
|
|
@ -244,6 +245,11 @@ export const migrations = [
|
|||
{
|
||||
up: migration_20260907_043339_add_voice_script_description.up,
|
||||
down: migration_20260907_043339_add_voice_script_description.down,
|
||||
name: '20260907_043339_add_voice_script_description'
|
||||
name: '20260907_043339_add_voice_script_description',
|
||||
},
|
||||
{
|
||||
up: migration_20260907_044934_add_missions_discord_modlist_hash.up,
|
||||
down: migration_20260907_044934_add_missions_discord_modlist_hash.down,
|
||||
name: '20260907_044934_add_missions_discord_modlist_hash'
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -2372,6 +2372,10 @@ export interface Mission {
|
|||
* When the attendance roll-call was first posted to Discord (managed by the bot).
|
||||
*/
|
||||
discordAttendanceSentAt?: string | null;
|
||||
/**
|
||||
* Fingerprint of the attached modlist last announced to Discord (managed by the bot).
|
||||
*/
|
||||
discordModlistHash?: string | null;
|
||||
/**
|
||||
* When evaluation reminder DMs were sent for this mission (managed by the bot, one-shot).
|
||||
*/
|
||||
|
|
@ -4479,6 +4483,7 @@ export interface MissionsSelect<T extends boolean = true> {
|
|||
discordMessageId?: T;
|
||||
discordAttendanceHash?: T;
|
||||
discordAttendanceSentAt?: T;
|
||||
discordModlistHash?: T;
|
||||
evaluationRemindersSentAt?: T;
|
||||
createdBy?: T;
|
||||
coverImage?: T;
|
||||
|
|
|
|||
Loading…
Reference in a new issue