1
0
Fork 0

Compare commits

..

6 commits

10 changed files with 23452 additions and 11 deletions

View file

@ -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

View file

@ -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",

View file

@ -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,
},

View file

@ -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 {

View file

@ -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",

View file

@ -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>
);

File diff suppressed because it is too large Load diff

View file

@ -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";`)
}

View file

@ -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'
},
];

View file

@ -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;