1
0
Fork 0

Compare commits

..

7 commits

Author SHA1 Message Date
3af17928e8 v0.2.6 - add voiceover take descriptions, reviewer feedback, mp3 transcoding and take limit, expand command palette 2026-09-07 04:37:18 -04:00
6bcd38aff6 feat(palette): add account and display quick actions
Add provisioning a personal bank account, toggling background texture and callsign display, and copying the page link; the layout now passes user preferences and personal-account state so the palette can hide or handle them.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-07 04:37:14 -04:00
c4a24483f6 feat(palette): add missing navigation entries
Cover transfers, projects, Friday Ops calendar, wiki, NPC personnel, awards, voiceover, and the training minigames.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-07 04:37:12 -04:00
962607b90e feat(voiceover): transcode submissions to mp3 via ffmpeg
Normalize every submission to mono 96 kbps MP3 so takes play everywhere regardless of the recording codec; falls back to storing the original file when ffmpeg is unavailable or fails.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-07 04:37:10 -04:00
1b422ba1c2 feat(voiceover): collect take descriptions and surface reviewer feedback
Submitters can attach an optional 300-char note to each take, and their own takes list now shows the description plus the reviewer feedback that was previously admin-only.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-07 04:37:08 -04:00
ea386b9a33 feat(voiceover): limit takes to five per script
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-07 04:37:01 -04:00
a0fa8889fc feat(voiceover): add take description field to voice submissions
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-07 04:36:52 -04:00
17 changed files with 23849 additions and 163 deletions

View file

@ -134,7 +134,8 @@ RUN bun run build
# `node --import tsx src/bot/index.ts` (tsx resolves the @/ + @payload-config # `node --import tsx src/bot/index.ts` (tsx resolves the @/ + @payload-config
# tsconfig path aliases that plain Node type-stripping cannot). # tsconfig path aliases that plain Node type-stripping cannot).
FROM node:22-alpine AS runner FROM node:22-alpine AS runner
RUN apk add --no-cache libc6-compat wget # ffmpeg: voiceover submissions are transcoded to MP3 in submitVoiceRecording.
RUN apk add --no-cache libc6-compat wget ffmpeg
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production \ ENV NODE_ENV=production \

View file

@ -1,6 +1,6 @@
{ {
"name": "polaris-task-force", "name": "polaris-task-force",
"version": "0.2.5", "version": "0.2.6",
"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.", "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", "license": "MIT",
"type": "module", "type": "module",

View file

@ -72,16 +72,16 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
let isIntelligence = false; let isIntelligence = false;
let isLogistics = false; let isLogistics = false;
let currentRank = ""; let currentRank = "";
let hasPersonalBankAccount = false;
const shimsGlobal = await payload.findGlobal({ slug: "shims" }); const shimsGlobal = await payload.findGlobal({ slug: "shims" });
const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>; const shimEntries = (shimsGlobal.shims ?? []) as NonNullable<Shim["shims"]>;
if (user) { if (user) {
[isIntelligence, isLogistics] = await Promise.all([ const [intelligenceQual, logisticsQual, profileRes, experienceRes, personalAccountRes] =
await Promise.all([
hasIntelligenceQualification(payload, user).catch(() => false), hasIntelligenceQualification(payload, user).catch(() => false),
hasLogisticsQualification(payload, user).catch(() => false), hasLogisticsQualification(payload, user).catch(() => false),
]);
const [profileRes, experienceRes] = await Promise.all([
payload.find({ payload.find({
collection: "profiles", collection: "profiles",
where: { user: { equals: user.id } }, where: { user: { equals: user.id } },
@ -105,7 +105,17 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
requiredPoints: true, requiredPoints: true,
}, },
}), }),
payload.find({
collection: "bank-accounts",
where: {
and: [{ accountType: { equals: "personal" } }, { ownerUser: { equals: user.id } }],
},
limit: 1,
}),
]); ]);
isIntelligence = intelligenceQual;
isLogistics = logisticsQual;
hasPersonalBankAccount = personalAccountRes.docs.length > 0;
const userXp = profileRes.docs[0]?.progression?.xp ?? 0; const userXp = profileRes.docs[0]?.progression?.xp ?? 0;
xpLevel = resolveLevel(userXp, experienceRes.docs); xpLevel = resolveLevel(userXp, experienceRes.docs);
@ -155,6 +165,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) {
hasIntelligence={isIntelligence} hasIntelligence={isIntelligence}
hasLogistics={isLogistics} hasLogistics={isLogistics}
username={user.username} username={user.username}
preferences={user.preferences}
hasPersonalBankAccount={hasPersonalBankAccount}
/> />
</CommandPaletteProvider> </CommandPaletteProvider>
</KeyboardShortcutsProvider> </KeyboardShortcutsProvider>

View file

@ -8,6 +8,7 @@ import { broadcastToUser } from "@/lib/realtime/bus";
import { notifyUser } from "@/lib/notifications"; import { notifyUser } from "@/lib/notifications";
import hasRoles from "@/utils/access-control/hasRoles"; import hasRoles from "@/utils/access-control/hasRoles";
import { isPayloadUser } from "@/utils/access-control/isPayloadUser"; import { isPayloadUser } from "@/utils/access-control/isPayloadUser";
import { transcodeToMp3 } from "@/lib/voiceover/transcode";
import type { VoiceScript, VoiceSubmission } from "@/payload-types"; import type { VoiceScript, VoiceSubmission } from "@/payload-types";
interface ActionResult<T = undefined> { interface ActionResult<T = undefined> {
@ -19,6 +20,10 @@ interface ActionResult<T = undefined> {
/** Server actions body limit is 10mb — enforce a 9 MB cap server-side with a clear error. */ /** Server actions body limit is 10mb — enforce a 9 MB cap server-side with a clear error. */
const MAX_AUDIO_BYTES = 9 * 1024 * 1024; const MAX_AUDIO_BYTES = 9 * 1024 * 1024;
const MAX_TAKES_PER_SCRIPT = 5;
const MAX_DESCRIPTION_CHARS = 300;
async function authenticate() { async function authenticate() {
const payloadConfig = await config; const payloadConfig = await config;
const payload = await getPayload({ config: payloadConfig }); const payload = await getPayload({ config: payloadConfig });
@ -83,6 +88,26 @@ export async function submitVoiceRecording(
return { success: false, error: "This script is closed to new submissions." }; return { success: false, error: "This script is closed to new submissions." };
} }
const takeCount = await payload.count({
collection: "voice-submissions",
where: {
and: [{ user: { equals: user.id } }, { script: { equals: scriptId } }],
},
overrideAccess: true,
});
if (takeCount.totalDocs >= MAX_TAKES_PER_SCRIPT) {
return {
success: false,
error: `You have reached the limit of ${MAX_TAKES_PER_SCRIPT} takes for this script.`,
};
}
const rawDescription = formData.get("description");
const description =
typeof rawDescription === "string" && rawDescription.trim().length > 0
? rawDescription.trim().slice(0, MAX_DESCRIPTION_CHARS)
: null;
const file = formData.get("file"); const file = formData.get("file");
if (!(file instanceof File)) { if (!(file instanceof File)) {
return { success: false, error: "No audio file provided." }; return { success: false, error: "No audio file provided." };
@ -97,18 +122,41 @@ export async function submitVoiceRecording(
return { success: false, error: "Audio recordings must be 9 MB or smaller." }; return { success: false, error: "Audio recordings must be 9 MB or smaller." };
} }
const originalBuffer = Buffer.from(await file.arrayBuffer());
// Normalize every submission to MP3 (mono 96 kbps) so stored takes play
// everywhere regardless of which codec the browser recorded with. If the
// transcode fails (no ffmpeg binary, unsupported input, timeout) the
// original file is stored untouched instead of rejecting the submission.
const mp3 = await transcodeToMp3(originalBuffer, file.name);
if (mp3) {
payload.logger.info(
`Voiceover submission for script ${scriptId}: converted ${file.name} (${originalBuffer.length} bytes) to ${mp3.name} (${mp3.size} bytes)`,
);
} else {
payload.logger.warn(
`Voiceover submission for script ${scriptId}: MP3 conversion failed, storing original file ${file.name}`,
);
}
const audio = mp3 ?? {
buffer: originalBuffer,
name: file.name,
mimetype,
size: originalBuffer.length,
};
const submission = await payload.create({ const submission = await payload.create({
collection: "voice-submissions", collection: "voice-submissions",
data: { data: {
script: scriptId, script: scriptId,
user: user.id, user: user.id,
status: "pending", status: "pending",
description,
}, },
file: { file: {
data: Buffer.from(await file.arrayBuffer()), data: audio.buffer,
mimetype, mimetype: audio.mimetype,
name: file.name, name: audio.name,
size: file.size, size: audio.size,
}, },
overrideAccess: true, overrideAccess: true,
depth: 0, depth: 0,

View file

@ -52,6 +52,14 @@ export const VoiceSubmissions: CollectionConfig = {
description: "Submitter", description: "Submitter",
}, },
}, },
{
name: "description",
type: "textarea",
maxLength: 300,
admin: {
description: "Optional short note from the submitter about this take.",
},
},
{ {
name: "status", name: "status",
type: "select", type: "select",
@ -67,7 +75,7 @@ export const VoiceSubmissions: CollectionConfig = {
name: "reviewNote", name: "reviewNote",
type: "textarea", type: "textarea",
admin: { admin: {
description: "Optional admin review note.", description: "Optional reviewer feedback. Shown to the submitter alongside their take.",
}, },
}, },
], ],

View file

@ -24,12 +24,17 @@ import {
} from "./CommandPaletteContext"; } from "./CommandPaletteContext";
import { markAllNotificationsRead } from "@/app/(frontend)/notifications/actions"; import { markAllNotificationsRead } from "@/app/(frontend)/notifications/actions";
import { autoArrangeLocker } from "@/app/(frontend)/locker/actions"; import { autoArrangeLocker } from "@/app/(frontend)/locker/actions";
import { ensureMyAccount } from "@/app/(frontend)/logistics/banking/actions";
import { updatePreferences } from "@/app/(frontend)/account/actions";
import type { User as PayloadUser } from "@/payload-types";
interface CommandPaletteProps { interface CommandPaletteProps {
userRoles: string[]; userRoles: string[];
hasIntelligence: boolean; hasIntelligence: boolean;
hasLogistics: boolean; hasLogistics: boolean;
username?: string; username?: string;
preferences?: NonNullable<PayloadUser["preferences"]>;
hasPersonalBankAccount?: boolean;
} }
export function CommandPalette({ export function CommandPalette({
@ -37,6 +42,8 @@ export function CommandPalette({
hasIntelligence, hasIntelligence,
hasLogistics, hasLogistics,
username, username,
preferences,
hasPersonalBankAccount,
}: CommandPaletteProps) { }: CommandPaletteProps) {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
@ -90,6 +97,64 @@ export function CommandPalette({
} }
break; break;
} }
case "ensureBankAccount": {
const result = await ensureMyAccount();
if (result.success) {
toast.success("Personal bank account ready");
} else {
toast.error(result.error ?? "Failed to provision bank account");
}
break;
}
case "toggleBackgroundTexture": {
const result = await updatePreferences({
mutedAll: preferences?.notifications?.mutedAll ?? false,
mutedTypes: preferences?.notifications?.mutedTypes ?? [],
showCallsign: preferences?.display?.showCallsign ?? false,
showVersionOverlay: preferences?.display?.showVersionOverlay ?? true,
showBackgroundTexture: !(preferences?.display?.showBackgroundTexture ?? true),
leadOrActual: preferences?.display?.leadOrActual ?? "lead",
discordEnabled: preferences?.discord?.enabled ?? false,
discordMutedTypes: preferences?.discord?.mutedTypes ?? [],
xpDisplayMini: preferences?.display?.xpDisplayMini ?? false,
});
if (result.success) {
toast.success("Background texture toggled");
router.refresh();
} else {
toast.error(result.error ?? "Failed to update preferences");
}
break;
}
case "toggleCallsign": {
const result = await updatePreferences({
mutedAll: preferences?.notifications?.mutedAll ?? false,
mutedTypes: preferences?.notifications?.mutedTypes ?? [],
showCallsign: !(preferences?.display?.showCallsign ?? false),
showVersionOverlay: preferences?.display?.showVersionOverlay ?? true,
showBackgroundTexture: preferences?.display?.showBackgroundTexture ?? true,
leadOrActual: preferences?.display?.leadOrActual ?? "lead",
discordEnabled: preferences?.discord?.enabled ?? false,
discordMutedTypes: preferences?.discord?.mutedTypes ?? [],
xpDisplayMini: preferences?.display?.xpDisplayMini ?? false,
});
if (result.success) {
toast.success("Callsign display toggled");
router.refresh();
} else {
toast.error(result.error ?? "Failed to update preferences");
}
break;
}
case "copyPageLink": {
try {
await navigator.clipboard.writeText(window.location.href);
toast.success("Page link copied to clipboard");
} catch {
toast.error("Failed to copy page link");
}
break;
}
case "refreshPage": { case "refreshPage": {
router.refresh(); router.refresh();
toast.success("Page refreshed"); toast.success("Page refreshed");
@ -105,7 +170,7 @@ export function CommandPalette({
setLoading(null); setLoading(null);
} }
}, },
[router], [router, preferences],
); );
const filteredNavCommands = React.useMemo<NavigationCommand[]>(() => { const filteredNavCommands = React.useMemo<NavigationCommand[]>(() => {
@ -154,9 +219,12 @@ export function CommandPalette({
if (cmd.requires === "logistics" && !hasLogistics && !userRoles.includes("developer")) { if (cmd.requires === "logistics" && !hasLogistics && !userRoles.includes("developer")) {
return false; return false;
} }
if (cmd.actionKey === "ensureBankAccount" && hasPersonalBankAccount) {
return false;
}
return true; return true;
}); });
}, [userRoles, hasIntelligence, hasLogistics]); }, [userRoles, hasIntelligence, hasLogistics, hasPersonalBankAccount]);
const groupedContextual = React.useMemo(() => { const groupedContextual = React.useMemo(() => {
const groups = new Map<string, ContextualCommand[]>(); const groups = new Map<string, ContextualCommand[]>();

View file

@ -3,10 +3,11 @@ import {
Bell, Bell,
Backpack, Backpack,
LogOut, LogOut,
PlaneTakeoff,
User,
RefreshCw, RefreshCw,
Shield, Wallet,
Wallpaper,
Tag,
Link2,
} from "lucide-react"; } from "lucide-react";
export interface ActionCommand { export interface ActionCommand {
@ -28,39 +29,37 @@ export interface ActionCommand {
export const actionCommands: ActionCommand[] = [ export const actionCommands: ActionCommand[] = [
{ {
id: "action:flight-simulator", id: "action:provision-bank-account",
label: "Flight Simulator", label: "Provision Personal Bank Account",
icon: PlaneTakeoff, icon: Wallet,
keywords: ["flappy", "game", "minigame", "fly"], keywords: ["banking", "wallet", "money", "create", "open"],
actionType: "navigate", actionType: "callback",
href: "/flappy", actionKey: "ensureBankAccount",
}, },
{ {
id: "action:view-notifications", id: "action:toggle-background-texture",
label: "View Notifications", label: "Toggle Background Texture",
icon: Bell, icon: Wallpaper,
keywords: ["alerts", "messages", "inbox"], keywords: ["display", "appearance", "theme", "texture", "background"],
actionType: "navigate", actionType: "callback",
href: "/notifications", actionKey: "toggleBackgroundTexture",
}, },
{ {
id: "action:create-ticket", id: "action:toggle-callsign",
label: "Create Support Ticket", label: "Toggle Callsign Display",
icon: UserPlus, icon: Tag,
keywords: ["help", "support", "report"], keywords: ["display", "callsign", "name", "sidebar"],
actionType: "navigate", actionType: "callback",
href: "/helpdesk", actionKey: "toggleCallsign",
}, },
{ {
id: "action:admin-panel", id: "action:copy-page-link",
label: "Open Admin Panel", label: "Copy Page Link",
icon: Shield, icon: Link2,
keywords: ["cms", "manage", "backend"], keywords: ["share", "url", "clipboard", "copy", "link"],
actionType: "navigate", actionType: "callback",
href: "/admin", actionKey: "copyPageLink",
roles: ["admin", "developer"],
}, },
{ {
id: "action:mark-all-read", id: "action:mark-all-read",
label: "Mark All Notifications Read", label: "Mark All Notifications Read",
@ -93,4 +92,12 @@ export const actionCommands: ActionCommand[] = [
actionType: "callback", actionType: "callback",
actionKey: "logout", actionKey: "logout",
}, },
{
id: "action:create-ticket",
label: "Create Support Ticket",
icon: UserPlus,
keywords: ["help", "support", "report"],
actionType: "navigate",
href: "/helpdesk",
},
]; ];

View file

@ -11,6 +11,19 @@ import {
UserCog, UserCog,
Bell, Bell,
Shield, Shield,
ArrowLeftRight,
CalendarDays,
BookOpen,
FilePlus2,
Contact,
BarChart3,
FolderKanban,
Search,
Award,
Mic,
PlaneTakeoff,
Radio,
Bomb,
} from "lucide-react"; } from "lucide-react";
export interface NavigationCommand { export interface NavigationCommand {
@ -27,6 +40,13 @@ export interface NavigationCommand {
export const navigationCommands: NavigationCommand[] = [ export const navigationCommands: NavigationCommand[] = [
{ id: "nav:dashboard", label: "Dashboard", icon: Bolt, href: "/", keywords: ["home", "main"], shortcut: "G D" }, { id: "nav:dashboard", label: "Dashboard", icon: Bolt, href: "/", keywords: ["home", "main"], shortcut: "G D" },
{ id: "nav:roster", label: "Roster", icon: Users, href: "/roster", keywords: ["members", "personnel"], shortcut: "G R" }, { id: "nav:roster", label: "Roster", icon: Users, href: "/roster", keywords: ["members", "personnel"], shortcut: "G R" },
{
id: "nav:transfers",
label: "Transfers",
icon: ArrowLeftRight,
href: "/transfers",
keywords: ["assignments", "requests", "moves"],
},
{ {
id: "nav:helpdesk", id: "nav:helpdesk",
label: "Helpdesk", label: "Helpdesk",
@ -35,6 +55,20 @@ export const navigationCommands: NavigationCommand[] = [
keywords: ["support", "tickets"], keywords: ["support", "tickets"],
shortcut: "G H", shortcut: "G H",
}, },
{
id: "nav:projects",
label: "Projects",
icon: FolderKanban,
href: "/projects",
keywords: ["boards", "sprints", "releases"],
},
{
id: "nav:project-search",
label: "Project Search",
icon: Search,
href: "/projects/search",
keywords: ["find", "query", "issues"],
},
{ {
id: "nav:campaigns", id: "nav:campaigns",
@ -62,6 +96,30 @@ export const navigationCommands: NavigationCommand[] = [
keywords: ["groups", "teams"], keywords: ["groups", "teams"],
requires: "intelligence", requires: "intelligence",
}, },
{
id: "nav:friday-ops",
label: "Friday Ops",
icon: CalendarDays,
href: "/intelligence/missions/calendar",
keywords: ["calendar", "side ops", "schedule", "events"],
},
{
id: "nav:wiki",
label: "Wiki",
icon: BookOpen,
href: "/wiki",
keywords: ["guide", "lore", "field manual", "pages"],
requires: "intelligence",
},
{
id: "nav:wiki-new",
label: "Create Wiki Page",
icon: FilePlus2,
href: "/wiki/new",
keywords: ["write", "new page", "documentation"],
requires: "intelligence",
},
{ {
id: "nav:structures", id: "nav:structures",
@ -124,6 +182,21 @@ export const navigationCommands: NavigationCommand[] = [
shortcut: "G K", shortcut: "G K",
}, },
{
id: "nav:contacts",
label: "NPC Contacts",
icon: Contact,
href: "/personnel/contacts",
keywords: ["personnel", "directory", "npcs", "vendors"],
},
{
id: "nav:npc-statistics",
label: "NPC Statistics",
icon: BarChart3,
href: "/personnel/statistics",
keywords: ["personnel", "stats", "vendors", "distribution"],
},
{ {
id: "nav:locker", id: "nav:locker",
label: "Locker", label: "Locker",
@ -132,6 +205,48 @@ export const navigationCommands: NavigationCommand[] = [
keywords: ["inventory", "gear", "equipment"], keywords: ["inventory", "gear", "equipment"],
shortcut: "G L", shortcut: "G L",
}, },
{
id: "nav:awards",
label: "Awards",
icon: Award,
href: "/awards",
keywords: ["medals", "decorations", "commendations"],
},
{
id: "nav:voiceover",
label: "Voiceover Scripts",
icon: Mic,
href: "/voiceover",
keywords: ["teleprompter", "recordings", "voice acting"],
},
{
id: "nav:qualification-course",
label: "Qualification Course",
icon: Crosshair,
href: "/qualification",
keywords: ["aim trainer", "minigame", "shooting", "practice"],
},
{
id: "nav:radio-traffic",
label: "Radio Traffic",
icon: Radio,
href: "/radio",
keywords: ["minigame", "comms", "messages", "training"],
},
{
id: "nav:flight-simulator",
label: "Flight Simulator",
icon: PlaneTakeoff,
href: "/flappy",
keywords: ["minigame", "flappy", "fly", "training"],
},
{
id: "nav:minefield-clearance",
label: "Minefield Clearance",
icon: Bomb,
href: "/eod",
keywords: ["minigame", "eod", "mines", "training"],
},
{ {
id: "nav:account", id: "nav:account",
label: "Account", label: "Account",

View file

@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const MAX_AUDIO_BYTES = 9 * 1024 * 1024; const MAX_AUDIO_BYTES = 9 * 1024 * 1024;
@ -16,7 +17,7 @@ const PREFERRED_MIME_TYPE = "audio/webm;codecs=opus";
export type VoiceCaptureSource = Blob | File; export type VoiceCaptureSource = Blob | File;
interface VoiceCaptureProps { interface VoiceCaptureProps {
readonly onSubmitTake: (source: VoiceCaptureSource) => Promise<boolean>; readonly onSubmitTake: (source: VoiceCaptureSource, description: string) => Promise<boolean>;
} }
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
@ -56,6 +57,7 @@ export function VoiceCapture({ onSubmitTake }: VoiceCaptureProps) {
const [reviewBlob, setReviewBlob] = useState<Blob | null>(null); const [reviewBlob, setReviewBlob] = useState<Blob | null>(null);
const [reviewUrl, setReviewUrl] = useState<string | null>(null); const [reviewUrl, setReviewUrl] = useState<string | null>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [description, setDescription] = useState("");
const stopLevelMeter = useCallback(() => { const stopLevelMeter = useCallback(() => {
if (meterAnimationRef.current !== null) { if (meterAnimationRef.current !== null) {
@ -221,12 +223,13 @@ export function VoiceCapture({ onSubmitTake }: VoiceCaptureProps) {
} }
setIsSubmitting(true); setIsSubmitting(true);
const submitted = await onSubmitTake(source); const submitted = await onSubmitTake(source, description.trim());
setIsSubmitting(false); setIsSubmitting(false);
if (!submitted) return; if (!submitted) return;
clearReview(); clearReview();
setSelectedFile(null); setSelectedFile(null);
setDescription("");
if (fileInputRef.current) fileInputRef.current.value = ""; if (fileInputRef.current) fileInputRef.current.value = "";
} }
@ -277,6 +280,19 @@ export function VoiceCapture({ onSubmitTake }: VoiceCaptureProps) {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-5"> <CardContent className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label htmlFor="voiceover-take-description">Describe your take (optional)</Label>
<Textarea
id="voiceover-take-description"
value={description}
onChange={(event) => setDescription(event.target.value)}
maxLength={300}
rows={2}
placeholder="Anything the reviewer should know about this take."
disabled={isSubmitting || isRecording}
/>
</div>
{support === "unsupported" && ( {support === "unsupported" && (
<div <div
role="alert" role="alert"

View file

@ -19,13 +19,17 @@ export function VoiceoverRecorder({ script }: { readonly script: VoiceoverScript
const router = useRouter(); const router = useRouter();
const isOpen = script.status === "open"; const isOpen = script.status === "open";
async function handleSubmitTake(source: VoiceCaptureSource): Promise<boolean> { async function handleSubmitTake(
source: VoiceCaptureSource,
description: string,
): Promise<boolean> {
const formData = new FormData(); const formData = new FormData();
if (source instanceof File) { if (source instanceof File) {
formData.append("file", source, source.name); formData.append("file", source, source.name);
} else { } else {
formData.append("file", source, "take.webm"); formData.append("file", source, "take.webm");
} }
if (description) formData.append("description", description);
try { try {
const result = await submitVoiceRecording(script.id, formData); const result = await submitVoiceRecording(script.id, formData);

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { CheckIcon, Clock3Icon, Trash2Icon, XIcon } from "lucide-react"; import { CheckIcon, Clock3Icon, MessageSquareQuoteIcon, Trash2Icon, XIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -81,6 +81,10 @@ export function VoiceoverTakes({ submissions, onWithdraw }: VoiceoverTakesProps)
</time> </time>
</div> </div>
{submission.description && (
<p className="text-sm text-muted-foreground">{submission.description}</p>
)}
{submission.audioUrl ? ( {submission.audioUrl ? (
<audio <audio
controls controls
@ -93,6 +97,18 @@ export function VoiceoverTakes({ submissions, onWithdraw }: VoiceoverTakesProps)
<p className="text-sm text-muted-foreground">Audio playback is unavailable.</p> <p className="text-sm text-muted-foreground">Audio playback is unavailable.</p>
)} )}
{submission.reviewNote && (
<div className="flex gap-2 rounded-md border border-border bg-background/50 p-3">
<MessageSquareQuoteIcon className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-col gap-0.5">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Reviewer feedback
</span>
<p className="text-sm">{submission.reviewNote}</p>
</div>
</div>
)}
{canWithdraw && ( {canWithdraw && (
<Button <Button
type="button" type="button"

View file

@ -7,6 +7,8 @@ export interface VoiceoverSubmissionSummary {
status: string; status: string;
createdAt: string; createdAt: string;
audioUrl: string; audioUrl: string;
description: string | null;
reviewNote: string | null;
} }
export interface VoiceoverScriptView { export interface VoiceoverScriptView {
@ -38,6 +40,8 @@ function toSubmissionSummary(submission: VoiceSubmission): VoiceoverSubmissionSu
status: submission.status, status: submission.status,
createdAt: submission.createdAt, createdAt: submission.createdAt,
audioUrl: submissionAudioUrl(submission), audioUrl: submissionAudioUrl(submission),
description: submission.description ?? null,
reviewNote: submission.reviewNote ?? null,
}; };
} }

View file

@ -0,0 +1,117 @@
import { spawn } from "node:child_process";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
export const MP3_MIMETYPE = "audio/mpeg";
/** Voice-appropriate MP3 settings: mono, 44.1 kHz, 96 kbps. */
const FFMPEG_ARGS = [
"-hide_banner",
"-loglevel",
"error",
"-y",
"-i",
"input",
"-vn",
"-ac",
"1",
"-ar",
"44100",
"-codec:a",
"libmp3lame",
"-b:a",
"96k",
"-f",
"mp3",
"output.mp3",
];
const FFMPEG_TIMEOUT_MS = 30_000;
export interface Mp3Result {
buffer: Buffer;
name: string;
mimetype: string;
size: number;
}
/**
* Convert an uploaded audio recording (WebM/Opus, MP4/AAC, etc.) to MP3 using
* the system ffmpeg binary. Returns null when ffmpeg is unavailable or fails,
* so callers can fall back to storing the original file untouched.
*/
export async function transcodeToMp3(
buffer: Buffer,
originalName: string,
): Promise<Mp3Result | null> {
let dir: string | null = null;
try {
dir = await mkdtemp(path.join(tmpdir(), "voiceover-"));
const inputPath = path.join(dir, "input");
const outputPath = path.join(dir, "output.mp3");
await writeFile(inputPath, buffer);
const ok = await runFfmpeg(inputPath, outputPath);
if (!ok) {
return null;
}
const mp3 = await readFile(outputPath);
if (mp3.length === 0) {
return null;
}
return {
buffer: mp3,
name: `${stripExtension(originalName)}.mp3`,
mimetype: MP3_MIMETYPE,
size: mp3.length,
};
} catch {
return null;
} finally {
if (dir) {
await rm(dir, { recursive: true, force: true });
}
}
}
function stripExtension(name: string): string {
const dot = name.lastIndexOf(".");
return dot > 0 ? name.slice(0, dot) : name;
}
function runFfmpeg(inputPath: string, outputPath: string): Promise<boolean> {
// Rewrite the two path placeholders into the real temp paths.
const args = FFMPEG_ARGS.map((arg) =>
arg === "input" ? inputPath : arg === "output.mp3" ? outputPath : arg,
);
return new Promise((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn("ffmpeg", args, { stdio: "ignore" });
} catch {
resolve(false);
return;
}
let settled = false;
const finish = (result: boolean) => {
if (!settled) {
settled = true;
clearTimeout(timer);
resolve(result);
}
};
const timer = setTimeout(() => {
child.kill("SIGKILL");
finish(false);
}, FFMPEG_TIMEOUT_MS);
child.on("error", () => finish(false));
child.on("close", (code) => finish(code === 0));
});
}

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 "voice_submissions" ADD COLUMN "description" varchar;`);
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
ALTER TABLE "voice_submissions" DROP COLUMN "description";`);
}

View file

@ -1,255 +1,261 @@
import * as migration_20260820_004157 from './20260820_004157'; import * as migration_20260820_004157 from "./20260820_004157";
import * as migration_20260825_220000 from './20260825_220000'; import * as migration_20260825_220000 from "./20260825_220000";
import * as migration_20260826_050000_repair_shims_auxiliary_tables from './20260826_050000_repair_shims_auxiliary_tables'; import * as migration_20260826_050000_repair_shims_auxiliary_tables from "./20260826_050000_repair_shims_auxiliary_tables";
import * as migration_20260826_060000_add_game_servers_event_target from './20260826_060000_add_game_servers_event_target'; import * as migration_20260826_060000_add_game_servers_event_target from "./20260826_060000_add_game_servers_event_target";
import * as migration_20260827_005757 from './20260827_005757'; import * as migration_20260827_005757 from "./20260827_005757";
import * as migration_20260827_022334_add_eod_minesweeper_best_flags from './20260827_022334_add_eod_minesweeper_best_flags'; import * as migration_20260827_022334_add_eod_minesweeper_best_flags from "./20260827_022334_add_eod_minesweeper_best_flags";
import * as migration_20260827_213857_add_evaluations_collection from './20260827_213857_add_evaluations_collection'; import * as migration_20260827_213857_add_evaluations_collection from "./20260827_213857_add_evaluations_collection";
import * as migration_20260827_233224_add_mission_completed_at from './20260827_233224_add_mission_completed_at'; import * as migration_20260827_233224_add_mission_completed_at from "./20260827_233224_add_mission_completed_at";
import * as migration_20260828_011500_add_background_texture_preference from './20260828_011500_add_background_texture_preference'; import * as migration_20260828_011500_add_background_texture_preference from "./20260828_011500_add_background_texture_preference";
import * as migration_20260828_041615_add_campaign_mode from './20260828_041615_add_campaign_mode'; import * as migration_20260828_041615_add_campaign_mode from "./20260828_041615_add_campaign_mode";
import * as migration_20260828_044506 from './20260828_044506'; import * as migration_20260828_044506 from "./20260828_044506";
import * as migration_20260831_142503 from './20260831_142503'; import * as migration_20260831_142503 from "./20260831_142503";
import * as migration_20260831_180233_assignment_transfers from './20260831_180233_assignment_transfers'; import * as migration_20260831_180233_assignment_transfers from "./20260831_180233_assignment_transfers";
import * as migration_20260901_014006_add_assignment_transfer_event_target from './20260901_014006_add_assignment_transfer_event_target'; import * as migration_20260901_014006_add_assignment_transfer_event_target from "./20260901_014006_add_assignment_transfer_event_target";
import * as migration_20260901_213108_add_ticket_votes_and_edit_history from './20260901_213108_add_ticket_votes_and_edit_history'; import * as migration_20260901_213108_add_ticket_votes_and_edit_history from "./20260901_213108_add_ticket_votes_and_edit_history";
import * as migration_20260901_222200_add_admin_page_permissions from './20260901_222200_add_admin_page_permissions'; import * as migration_20260901_222200_add_admin_page_permissions from "./20260901_222200_add_admin_page_permissions";
import * as migration_20260902_195336_add_medal_backgrounds from './20260902_195336_add_medal_backgrounds'; import * as migration_20260902_195336_add_medal_backgrounds from "./20260902_195336_add_medal_backgrounds";
import * as migration_20260903_035544 from './20260903_035544'; import * as migration_20260903_035544 from "./20260903_035544";
import * as migration_20260903_044047 from './20260903_044047'; import * as migration_20260903_044047 from "./20260903_044047";
import * as migration_20260903_053419 from './20260903_053419'; import * as migration_20260903_053419 from "./20260903_053419";
import * as migration_20260903_060000_add_radio_mode_stats from './20260903_060000_add_radio_mode_stats'; import * as migration_20260903_060000_add_radio_mode_stats from "./20260903_060000_add_radio_mode_stats";
import * as migration_20260903_061000_add_radio_incorrect_counts from './20260903_061000_add_radio_incorrect_counts'; import * as migration_20260903_061000_add_radio_incorrect_counts from "./20260903_061000_add_radio_incorrect_counts";
import * as migration_20260903_062000_add_aim_trainer_stats from './20260903_062000_add_aim_trainer_stats'; import * as migration_20260903_062000_add_aim_trainer_stats from "./20260903_062000_add_aim_trainer_stats";
import * as migration_20260904_230838_add_wiki_collections from './20260904_230838_add_wiki_collections'; import * as migration_20260904_230838_add_wiki_collections from "./20260904_230838_add_wiki_collections";
import * as migration_20260905_183940_base_management from './20260905_183940_base_management'; import * as migration_20260905_183940_base_management from "./20260905_183940_base_management";
import * as migration_20260905_195456_labor_headcount from './20260905_195456_labor_headcount'; import * as migration_20260905_195456_labor_headcount from "./20260905_195456_labor_headcount";
import * as migration_20260905_210310_labor_classifications from './20260905_210310_labor_classifications'; import * as migration_20260905_210310_labor_classifications from "./20260905_210310_labor_classifications";
import * as migration_20260905_213824 from './20260905_213824'; import * as migration_20260905_213824 from "./20260905_213824";
import * as migration_20260905_215658 from './20260905_215658'; import * as migration_20260905_215658 from "./20260905_215658";
import * as migration_20260906_045502 from './20260906_045502'; import * as migration_20260906_045502 from "./20260906_045502";
import * as migration_20260906_050900_add_evaluation_reminders_field from './20260906_050900_add_evaluation_reminders_field'; import * as migration_20260906_050900_add_evaluation_reminders_field from "./20260906_050900_add_evaluation_reminders_field";
import * as migration_20260906_061451_add_wiki_template_css from './20260906_061451_add_wiki_template_css'; import * as migration_20260906_061451_add_wiki_template_css from "./20260906_061451_add_wiki_template_css";
import * as migration_20260906_063753_add_personal_excerpt_richtext from './20260906_063753_add_personal_excerpt_richtext'; import * as migration_20260906_063753_add_personal_excerpt_richtext from "./20260906_063753_add_personal_excerpt_richtext";
import * as migration_20260906_065405_add_wiki_template_html from './20260906_065405_add_wiki_template_html'; import * as migration_20260906_065405_add_wiki_template_html from "./20260906_065405_add_wiki_template_html";
import * as migration_20260906_100908_add_market_economy_state from './20260906_100908_add_market_economy_state'; import * as migration_20260906_100908_add_market_economy_state from "./20260906_100908_add_market_economy_state";
import * as migration_20260906_165837_add_market_state_event_target from './20260906_165837_add_market_state_event_target'; import * as migration_20260906_165837_add_market_state_event_target from "./20260906_165837_add_market_state_event_target";
import * as migration_20260906_202925_add_voice_submissions from './20260906_202925_add_voice_submissions'; import * as migration_20260906_202925_add_voice_submissions from "./20260906_202925_add_voice_submissions";
import * as migration_20260907_012326_add_missions_created_by from './20260907_012326_add_missions_created_by'; import * as migration_20260907_012326_add_missions_created_by from "./20260907_012326_add_missions_created_by";
import * as migration_20260907_013724_make_missions_campaign_optional from './20260907_013724_make_missions_campaign_optional'; 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_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_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'; import * as migration_20260907_044934_add_missions_discord_modlist_hash from "./20260907_044934_add_missions_discord_modlist_hash";
import * as migration_20260907_083010_add_voice_submission_description from "./20260907_083010_add_voice_submission_description";
export const migrations = [ export const migrations = [
{ {
up: migration_20260820_004157.up, up: migration_20260820_004157.up,
down: migration_20260820_004157.down, down: migration_20260820_004157.down,
name: '20260820_004157', name: "20260820_004157",
}, },
{ {
up: migration_20260825_220000.up, up: migration_20260825_220000.up,
down: migration_20260825_220000.down, down: migration_20260825_220000.down,
name: '20260825_220000', name: "20260825_220000",
}, },
{ {
up: migration_20260826_050000_repair_shims_auxiliary_tables.up, up: migration_20260826_050000_repair_shims_auxiliary_tables.up,
down: migration_20260826_050000_repair_shims_auxiliary_tables.down, down: migration_20260826_050000_repair_shims_auxiliary_tables.down,
name: '20260826_050000_repair_shims_auxiliary_tables', name: "20260826_050000_repair_shims_auxiliary_tables",
}, },
{ {
up: migration_20260826_060000_add_game_servers_event_target.up, up: migration_20260826_060000_add_game_servers_event_target.up,
down: migration_20260826_060000_add_game_servers_event_target.down, down: migration_20260826_060000_add_game_servers_event_target.down,
name: '20260826_060000_add_game_servers_event_target', name: "20260826_060000_add_game_servers_event_target",
}, },
{ {
up: migration_20260827_005757.up, up: migration_20260827_005757.up,
down: migration_20260827_005757.down, down: migration_20260827_005757.down,
name: '20260827_005757', name: "20260827_005757",
}, },
{ {
up: migration_20260827_022334_add_eod_minesweeper_best_flags.up, up: migration_20260827_022334_add_eod_minesweeper_best_flags.up,
down: migration_20260827_022334_add_eod_minesweeper_best_flags.down, down: migration_20260827_022334_add_eod_minesweeper_best_flags.down,
name: '20260827_022334_add_eod_minesweeper_best_flags', name: "20260827_022334_add_eod_minesweeper_best_flags",
}, },
{ {
up: migration_20260827_213857_add_evaluations_collection.up, up: migration_20260827_213857_add_evaluations_collection.up,
down: migration_20260827_213857_add_evaluations_collection.down, down: migration_20260827_213857_add_evaluations_collection.down,
name: '20260827_213857_add_evaluations_collection', name: "20260827_213857_add_evaluations_collection",
}, },
{ {
up: migration_20260827_233224_add_mission_completed_at.up, up: migration_20260827_233224_add_mission_completed_at.up,
down: migration_20260827_233224_add_mission_completed_at.down, down: migration_20260827_233224_add_mission_completed_at.down,
name: '20260827_233224_add_mission_completed_at', name: "20260827_233224_add_mission_completed_at",
}, },
{ {
up: migration_20260828_011500_add_background_texture_preference.up, up: migration_20260828_011500_add_background_texture_preference.up,
down: migration_20260828_011500_add_background_texture_preference.down, down: migration_20260828_011500_add_background_texture_preference.down,
name: '20260828_011500_add_background_texture_preference', name: "20260828_011500_add_background_texture_preference",
}, },
{ {
up: migration_20260828_041615_add_campaign_mode.up, up: migration_20260828_041615_add_campaign_mode.up,
down: migration_20260828_041615_add_campaign_mode.down, down: migration_20260828_041615_add_campaign_mode.down,
name: '20260828_041615_add_campaign_mode', name: "20260828_041615_add_campaign_mode",
}, },
{ {
up: migration_20260828_044506.up, up: migration_20260828_044506.up,
down: migration_20260828_044506.down, down: migration_20260828_044506.down,
name: '20260828_044506', name: "20260828_044506",
}, },
{ {
up: migration_20260831_142503.up, up: migration_20260831_142503.up,
down: migration_20260831_142503.down, down: migration_20260831_142503.down,
name: '20260831_142503', name: "20260831_142503",
}, },
{ {
up: migration_20260831_180233_assignment_transfers.up, up: migration_20260831_180233_assignment_transfers.up,
down: migration_20260831_180233_assignment_transfers.down, down: migration_20260831_180233_assignment_transfers.down,
name: '20260831_180233_assignment_transfers', name: "20260831_180233_assignment_transfers",
}, },
{ {
up: migration_20260901_014006_add_assignment_transfer_event_target.up, up: migration_20260901_014006_add_assignment_transfer_event_target.up,
down: migration_20260901_014006_add_assignment_transfer_event_target.down, down: migration_20260901_014006_add_assignment_transfer_event_target.down,
name: '20260901_014006_add_assignment_transfer_event_target', name: "20260901_014006_add_assignment_transfer_event_target",
}, },
{ {
up: migration_20260901_213108_add_ticket_votes_and_edit_history.up, up: migration_20260901_213108_add_ticket_votes_and_edit_history.up,
down: migration_20260901_213108_add_ticket_votes_and_edit_history.down, down: migration_20260901_213108_add_ticket_votes_and_edit_history.down,
name: '20260901_213108_add_ticket_votes_and_edit_history', name: "20260901_213108_add_ticket_votes_and_edit_history",
}, },
{ {
up: migration_20260901_222200_add_admin_page_permissions.up, up: migration_20260901_222200_add_admin_page_permissions.up,
down: migration_20260901_222200_add_admin_page_permissions.down, down: migration_20260901_222200_add_admin_page_permissions.down,
name: '20260901_222200_add_admin_page_permissions', name: "20260901_222200_add_admin_page_permissions",
}, },
{ {
up: migration_20260902_195336_add_medal_backgrounds.up, up: migration_20260902_195336_add_medal_backgrounds.up,
down: migration_20260902_195336_add_medal_backgrounds.down, down: migration_20260902_195336_add_medal_backgrounds.down,
name: '20260902_195336_add_medal_backgrounds', name: "20260902_195336_add_medal_backgrounds",
}, },
{ {
up: migration_20260903_035544.up, up: migration_20260903_035544.up,
down: migration_20260903_035544.down, down: migration_20260903_035544.down,
name: '20260903_035544', name: "20260903_035544",
}, },
{ {
up: migration_20260903_044047.up, up: migration_20260903_044047.up,
down: migration_20260903_044047.down, down: migration_20260903_044047.down,
name: '20260903_044047', name: "20260903_044047",
}, },
{ {
up: migration_20260903_053419.up, up: migration_20260903_053419.up,
down: migration_20260903_053419.down, down: migration_20260903_053419.down,
name: '20260903_053419', name: "20260903_053419",
}, },
{ {
up: migration_20260903_060000_add_radio_mode_stats.up, up: migration_20260903_060000_add_radio_mode_stats.up,
down: migration_20260903_060000_add_radio_mode_stats.down, down: migration_20260903_060000_add_radio_mode_stats.down,
name: '20260903_060000_add_radio_mode_stats', name: "20260903_060000_add_radio_mode_stats",
}, },
{ {
up: migration_20260903_061000_add_radio_incorrect_counts.up, up: migration_20260903_061000_add_radio_incorrect_counts.up,
down: migration_20260903_061000_add_radio_incorrect_counts.down, down: migration_20260903_061000_add_radio_incorrect_counts.down,
name: '20260903_061000_add_radio_incorrect_counts', name: "20260903_061000_add_radio_incorrect_counts",
}, },
{ {
up: migration_20260903_062000_add_aim_trainer_stats.up, up: migration_20260903_062000_add_aim_trainer_stats.up,
down: migration_20260903_062000_add_aim_trainer_stats.down, down: migration_20260903_062000_add_aim_trainer_stats.down,
name: '20260903_062000_add_aim_trainer_stats', name: "20260903_062000_add_aim_trainer_stats",
}, },
{ {
up: migration_20260904_230838_add_wiki_collections.up, up: migration_20260904_230838_add_wiki_collections.up,
down: migration_20260904_230838_add_wiki_collections.down, down: migration_20260904_230838_add_wiki_collections.down,
name: '20260904_230838_add_wiki_collections', name: "20260904_230838_add_wiki_collections",
}, },
{ {
up: migration_20260905_183940_base_management.up, up: migration_20260905_183940_base_management.up,
down: migration_20260905_183940_base_management.down, down: migration_20260905_183940_base_management.down,
name: '20260905_183940_base_management', name: "20260905_183940_base_management",
}, },
{ {
up: migration_20260905_195456_labor_headcount.up, up: migration_20260905_195456_labor_headcount.up,
down: migration_20260905_195456_labor_headcount.down, down: migration_20260905_195456_labor_headcount.down,
name: '20260905_195456_labor_headcount', name: "20260905_195456_labor_headcount",
}, },
{ {
up: migration_20260905_210310_labor_classifications.up, up: migration_20260905_210310_labor_classifications.up,
down: migration_20260905_210310_labor_classifications.down, down: migration_20260905_210310_labor_classifications.down,
name: '20260905_210310_labor_classifications', name: "20260905_210310_labor_classifications",
}, },
{ {
up: migration_20260905_213824.up, up: migration_20260905_213824.up,
down: migration_20260905_213824.down, down: migration_20260905_213824.down,
name: '20260905_213824', name: "20260905_213824",
}, },
{ {
up: migration_20260905_215658.up, up: migration_20260905_215658.up,
down: migration_20260905_215658.down, down: migration_20260905_215658.down,
name: '20260905_215658', name: "20260905_215658",
}, },
{ {
up: migration_20260906_045502.up, up: migration_20260906_045502.up,
down: migration_20260906_045502.down, down: migration_20260906_045502.down,
name: '20260906_045502', name: "20260906_045502",
}, },
{ {
up: migration_20260906_050900_add_evaluation_reminders_field.up, up: migration_20260906_050900_add_evaluation_reminders_field.up,
down: migration_20260906_050900_add_evaluation_reminders_field.down, down: migration_20260906_050900_add_evaluation_reminders_field.down,
name: '20260906_050900_add_evaluation_reminders_field', name: "20260906_050900_add_evaluation_reminders_field",
}, },
{ {
up: migration_20260906_061451_add_wiki_template_css.up, up: migration_20260906_061451_add_wiki_template_css.up,
down: migration_20260906_061451_add_wiki_template_css.down, down: migration_20260906_061451_add_wiki_template_css.down,
name: '20260906_061451_add_wiki_template_css', name: "20260906_061451_add_wiki_template_css",
}, },
{ {
up: migration_20260906_063753_add_personal_excerpt_richtext.up, up: migration_20260906_063753_add_personal_excerpt_richtext.up,
down: migration_20260906_063753_add_personal_excerpt_richtext.down, down: migration_20260906_063753_add_personal_excerpt_richtext.down,
name: '20260906_063753_add_personal_excerpt_richtext', name: "20260906_063753_add_personal_excerpt_richtext",
}, },
{ {
up: migration_20260906_065405_add_wiki_template_html.up, up: migration_20260906_065405_add_wiki_template_html.up,
down: migration_20260906_065405_add_wiki_template_html.down, down: migration_20260906_065405_add_wiki_template_html.down,
name: '20260906_065405_add_wiki_template_html', name: "20260906_065405_add_wiki_template_html",
}, },
{ {
up: migration_20260906_100908_add_market_economy_state.up, up: migration_20260906_100908_add_market_economy_state.up,
down: migration_20260906_100908_add_market_economy_state.down, down: migration_20260906_100908_add_market_economy_state.down,
name: '20260906_100908_add_market_economy_state', name: "20260906_100908_add_market_economy_state",
}, },
{ {
up: migration_20260906_165837_add_market_state_event_target.up, up: migration_20260906_165837_add_market_state_event_target.up,
down: migration_20260906_165837_add_market_state_event_target.down, down: migration_20260906_165837_add_market_state_event_target.down,
name: '20260906_165837_add_market_state_event_target', name: "20260906_165837_add_market_state_event_target",
}, },
{ {
up: migration_20260906_202925_add_voice_submissions.up, up: migration_20260906_202925_add_voice_submissions.up,
down: migration_20260906_202925_add_voice_submissions.down, down: migration_20260906_202925_add_voice_submissions.down,
name: '20260906_202925_add_voice_submissions', name: "20260906_202925_add_voice_submissions",
}, },
{ {
up: migration_20260907_012326_add_missions_created_by.up, up: migration_20260907_012326_add_missions_created_by.up,
down: migration_20260907_012326_add_missions_created_by.down, down: migration_20260907_012326_add_missions_created_by.down,
name: '20260907_012326_add_missions_created_by', name: "20260907_012326_add_missions_created_by",
}, },
{ {
up: migration_20260907_013724_make_missions_campaign_optional.up, up: migration_20260907_013724_make_missions_campaign_optional.up,
down: migration_20260907_013724_make_missions_campaign_optional.down, down: migration_20260907_013724_make_missions_campaign_optional.down,
name: '20260907_013724_make_missions_campaign_optional', name: "20260907_013724_make_missions_campaign_optional",
}, },
{ {
up: migration_20260907_041051_add_voice_script_spoiler.up, up: migration_20260907_041051_add_voice_script_spoiler.up,
down: migration_20260907_041051_add_voice_script_spoiler.down, down: migration_20260907_041051_add_voice_script_spoiler.down,
name: '20260907_041051_add_voice_script_spoiler', name: "20260907_041051_add_voice_script_spoiler",
}, },
{ {
up: migration_20260907_043339_add_voice_script_description.up, up: migration_20260907_043339_add_voice_script_description.up,
down: migration_20260907_043339_add_voice_script_description.down, 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, up: migration_20260907_044934_add_missions_discord_modlist_hash.up,
down: migration_20260907_044934_add_missions_discord_modlist_hash.down, down: migration_20260907_044934_add_missions_discord_modlist_hash.down,
name: '20260907_044934_add_missions_discord_modlist_hash' name: "20260907_044934_add_missions_discord_modlist_hash",
},
{
up: migration_20260907_083010_add_voice_submission_description.up,
down: migration_20260907_083010_add_voice_submission_description.down,
name: "20260907_083010_add_voice_submission_description",
}, },
]; ];

View file

@ -598,7 +598,7 @@ export interface Structure {
*/ */
maxCapacityMass?: number | null; maxCapacityMass?: number | null;
/** /**
* When enabled, ONLY the items listed in Allowed Storage can be stored here — everything else is rejected. When disabled, the Allowed Storage list still enforces per-item quantity caps, but other items are permitted up to the mass capacity. * When enabled, ONLY the items listed in Allowed Storage can be stored here. Everything else is rejected. When disabled, the Allowed Storage list still enforces per-item quantity caps, but other items are permitted up to the mass capacity.
*/ */
restrictToAllowed?: boolean | null; restrictToAllowed?: boolean | null;
xSize?: number | null; xSize?: number | null;
@ -1315,7 +1315,7 @@ export interface User {
payloadDisplayName?: string | null; payloadDisplayName?: string | null;
steamId: string; steamId: string;
/** /**
* Legacy role enum — being replaced by the dynamic RBAC system (see 'Role Assignments' below). * Legacy role enum, being replaced by the dynamic RBAC system (see 'Role Assignments' below).
*/ */
roles?: ('guest' | 'user' | 'trusted' | 'admin' | 'developer')[] | null; roles?: ('guest' | 'user' | 'trusted' | 'admin' | 'developer')[] | null;
/** /**
@ -1693,7 +1693,7 @@ export interface Role {
*/ */
isSystem?: boolean | null; isSystem?: boolean | null;
/** /**
* Superuser roles bypass ALL permission checks. Use with extreme caution — this grants unrestricted access. * Superuser roles bypass ALL permission checks. Use with extreme caution: this grants unrestricted access.
*/ */
isSuperuser?: boolean | null; isSuperuser?: boolean | null;
updatedAt: string; updatedAt: string;
@ -2330,7 +2330,7 @@ export interface UserNotification {
export interface Evaluation { export interface Evaluation {
id: number; id: number;
/** /**
* Who submitted this evaluation. Private — only visible to users with evaluations:read. * Who submitted this evaluation. Private, only visible to users with evaluations:read.
*/ */
rater: number | User; rater: number | User;
ratee: number | User; ratee: number | User;
@ -2405,7 +2405,7 @@ export interface Mission {
status: 'Concept' | 'Planning' | 'Ready' | 'Scheduled' | 'Active' | 'Completed' | 'Cancelled'; status: 'Concept' | 'Planning' | 'Ready' | 'Scheduled' | 'Active' | 'Completed' | 'Cancelled';
visibility: 'unit' | 'leadership' | 'intel' | 'private'; visibility: 'unit' | 'leadership' | 'intel' | 'private';
/** /**
* When the mission transitioned to Completed. Set automatically by the lifecycle (auto-complete tick or manual status change) — managed here, not editable in the form. * When the mission transitioned to Completed. Set automatically by the lifecycle (auto-complete tick or manual status change); managed here, not editable in the form.
*/ */
completedAt?: string | null; completedAt?: string | null;
}; };
@ -3130,9 +3130,13 @@ export interface VoiceSubmission {
* Submitter * Submitter
*/ */
user: number | User; user: number | User;
/**
* Optional short note from the submitter about this take.
*/
description?: string | null;
status: 'pending' | 'accepted' | 'rejected'; status: 'pending' | 'accepted' | 'rejected';
/** /**
* Optional admin review note. * Optional reviewer feedback. Shown to the submitter alongside their take.
*/ */
reviewNote?: string | null; reviewNote?: string | null;
updatedAt: string; updatedAt: string;
@ -3472,11 +3476,11 @@ export interface WikiTemplate {
body: string; body: string;
description?: string | null; description?: string | null;
/** /**
* Optional CSS injected on wiki pages while this template exists. Write plain selectors — they are automatically prefixed with a hash-based scope class (e.g. .ptf-tpl-3afb1c .my-class) so templates never collide with each other or the app shell. * Optional CSS injected on wiki pages while this template exists. Write plain selectors, which are automatically prefixed with a hash-based scope class (e.g. .ptf-tpl-3afb1c .my-class) so templates never collide with each other or the app shell.
*/ */
css?: string | null; css?: string | null;
/** /**
* Developer-only. Raw HTML rendered verbatim (sanitized) when this template is transcluded — replaces the markdown body. * Developer-only. Raw HTML rendered verbatim (sanitized) when this template is transcluded, replacing the markdown body.
*/ */
html?: string | null; html?: string | null;
updatedAt: string; updatedAt: string;
@ -5235,6 +5239,7 @@ export interface VoiceScriptsSelect<T extends boolean = true> {
export interface VoiceSubmissionsSelect<T extends boolean = true> { export interface VoiceSubmissionsSelect<T extends boolean = true> {
script?: T; script?: T;
user?: T; user?: T;
description?: T;
status?: T; status?: T;
reviewNote?: T; reviewNote?: T;
updatedAt?: T; updatedAt?: T;
@ -5684,7 +5689,7 @@ export interface GameRule {
*/ */
coldStartObservations?: number | null; coldStartObservations?: number | null;
/** /**
* Suggested economy tick cadence in minutes, for cron documentation only — the tick itself is triggered externally. * Suggested economy tick cadence in minutes, for cron documentation only. The tick itself is triggered externally.
*/ */
tickIntervalHintMinutes?: number | null; tickIntervalHintMinutes?: number | null;
}; };