From ef1ffc7a8c96299149a4e06443fbab829d553bfc Mon Sep 17 00:00:00 2001 From: Lunasa Date: Sun, 27 Sep 2026 02:25:08 +0530 Subject: [PATCH 01/88] fix(discord): stop task creation from making duplicate forum threads --- apps/discord/src/Api/PlatformApiClient.ts | 4 ++++ .../src/Services/NotificationDispatcher.ts | 2 +- apps/discord/src/Services/TaskForum.ts | 18 +++++++++++++++++- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/discord/src/Api/PlatformApiClient.ts b/apps/discord/src/Api/PlatformApiClient.ts index 507735f..0910cbc 100644 --- a/apps/discord/src/Api/PlatformApiClient.ts +++ b/apps/discord/src/Api/PlatformApiClient.ts @@ -130,6 +130,10 @@ export class PlatformApiClient { return shots; } + public async getShot(shotId: string): Promise { + return (await this.send(`/shots/${shotId}`, dataEnvelope(shotDetailSchema))).data; + } + public async isHealthy(): Promise<{ healthy: boolean; latencyMs: number }> { const started = performance.now(); try { diff --git a/apps/discord/src/Services/NotificationDispatcher.ts b/apps/discord/src/Services/NotificationDispatcher.ts index 8f0e695..f26de3e 100644 --- a/apps/discord/src/Services/NotificationDispatcher.ts +++ b/apps/discord/src/Services/NotificationDispatcher.ts @@ -30,7 +30,7 @@ export class NotificationDispatcher { private async applySideEffects(notification: PlatformNotification): Promise { switch (notification.type) { case NotificationType.ShotCreated: - await this.forum.ensureThread(notification.shot, null, ShotStatus.Available); + await this.forum.ensureThreadFor(notification.shot.id); return; case NotificationType.ShotUpdated: await this.forum.rename(notification.shot); diff --git a/apps/discord/src/Services/TaskForum.ts b/apps/discord/src/Services/TaskForum.ts index 940e195..36c64df 100644 --- a/apps/discord/src/Services/TaskForum.ts +++ b/apps/discord/src/Services/TaskForum.ts @@ -26,6 +26,7 @@ export function referenceOf(shot: Pick(); private readonly shotsByThread = new Map(); + private readonly pendingThreads = new Map>(); public constructor( private readonly client: Client, @@ -58,7 +59,22 @@ export class TaskForum { return channel !== null && channel.type === ChannelType.GuildForum && channel.guildId === this.guildId ? channel : null; } - public async ensureThread(shot: ShotReference, description: string | null, status: ShotStatus): Promise { + public ensureThread(shot: ShotReference, description: string | null, status: ShotStatus): Promise { + const inFlight = this.pendingThreads.get(shot.id); + if (inFlight !== undefined) { + return inFlight; + } + const creation = this.createThread(shot, description, status).finally(() => this.pendingThreads.delete(shot.id)); + this.pendingThreads.set(shot.id, creation); + return creation; + } + + public async ensureThreadFor(shotId: string): Promise { + const shot = await this.api.getShot(shotId); + return this.ensureThread(referenceOf(shot), shot.description, shot.status); + } + + private async createThread(shot: ShotReference, description: string | null, status: ShotStatus): Promise { const existing = await this.thread(shot.id); if (existing !== null) { return existing.id; From f457bfd6afc4428631aebab208ce408bede45b05 Mon Sep 17 00:00:00 2001 From: Lunasa Date: Sun, 27 Sep 2026 02:26:52 +0530 Subject: [PATCH 02/88] style(discord): replace middle-dot separators in bot UI --- apps/discord/src/Commands/InfoCommands.ts | 8 ++++---- apps/discord/src/Services/TaskForum.ts | 2 +- apps/discord/src/Views/NotificationViews.ts | 2 +- apps/discord/src/Views/RoundViews.ts | 18 +++++++++--------- apps/discord/src/Views/TaskViews.ts | 2 +- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/discord/src/Commands/InfoCommands.ts b/apps/discord/src/Commands/InfoCommands.ts index 81aae61..2905e26 100644 --- a/apps/discord/src/Commands/InfoCommands.ts +++ b/apps/discord/src/Commands/InfoCommands.ts @@ -6,13 +6,13 @@ import { requireManageGuild, type BotContext, type SlashCommand } from "./Comman const helpText = [ "## Commands", "**Voting**", - "/rounds · /round · /entries · /leaderboard · /results", + "/rounds, /round, /entries, /leaderboard, /results", "**Tasks** (run inside a task post)", - "/take-task · /release-task · /submit-task", + "/take-task, /release-task, /submit-task", "**Staff**", - "/create-task · /sync-task · /telemetry · /blacklist · /assign-role", + "/create-task, /sync-task, /telemetry, /blacklist, /assign-role", "**Server setup** (Manage Server)", - "/bot-setup · /setup-forum · /set-announcement-channel · /bot-status" + "/bot-setup, /setup-forum, /set-announcement-channel, /bot-status" ].join("\n"); const docsText = [ diff --git a/apps/discord/src/Services/TaskForum.ts b/apps/discord/src/Services/TaskForum.ts index 36c64df..ed9bbfa 100644 --- a/apps/discord/src/Services/TaskForum.ts +++ b/apps/discord/src/Services/TaskForum.ts @@ -218,6 +218,6 @@ export class TaskForum { } private threadName(shot: ShotReference): string { - return `${shot.code} · ${shot.title}`.slice(0, 100); + return `${shot.code} - ${shot.title}`.slice(0, 100); } } diff --git a/apps/discord/src/Views/NotificationViews.ts b/apps/discord/src/Views/NotificationViews.ts index 69f30d4..d0369b7 100644 --- a/apps/discord/src/Views/NotificationViews.ts +++ b/apps/discord/src/Views/NotificationViews.ts @@ -30,7 +30,7 @@ function reason(value: string | null): string | null { } function shotName(shot: ShotReference): string { - return `${plain(shot.code, 50)} · ${plain(shot.title, 120)}`; + return `${plain(shot.code, 50)} - ${plain(shot.title, 120)}`; } function shotLink(shot: ShotReference, context: NotificationContext): string { diff --git a/apps/discord/src/Views/RoundViews.ts b/apps/discord/src/Views/RoundViews.ts index ffdee60..edef28e 100644 --- a/apps/discord/src/Views/RoundViews.ts +++ b/apps/discord/src/Views/RoundViews.ts @@ -79,7 +79,7 @@ export function roundList(rounds: readonly RoundDto[]): V2Message { const shown = sortRounds(rounds).slice(0, 15); const lines = shown.map((round) => { const suffix = schedule(round); - return `**${plain(round.title, 100)}**\n-# ${statusLabels[round.status]} · ${pollLabel(round.pollType)}${suffix.length > 0 ? ` · ${suffix}` : ""}`; + return `**${plain(round.title, 100)}**\n-# ${statusLabels[round.status]}, ${pollLabel(round.pollType)}${suffix.length > 0 ? `, ${suffix}` : ""}`; }); const more = rounds.length > shown.length ? `\n-# and ${rounds.length - shown.length} more` : ""; return message(panel(null, "## Rounds", divider(), lines.join("\n\n") + more)); @@ -94,7 +94,7 @@ export function roundSelect(action: RoundView, rounds: readonly RoundDto[], prom .slice(0, 25) .map((round) => ({ label: truncate(round.title, 90), - description: `${statusLabels[round.status]} · ${pollLabel(round.pollType)}`, + description: `${statusLabels[round.status]}, ${pollLabel(round.pollType)}`, value: round.id })) ); @@ -108,9 +108,9 @@ export function roundDetail(round: RoundDetailDto, webAppUrl: string): V2Message ].filter((line): line is string => line !== null); const lines = [ `## ${plain(round.title, 200)}`, - `${statusLabels[round.status]} · ${pollLabel(round.pollType)}${round.isAcceptingVotes ? " · accepting votes" : ""}`, + `${statusLabels[round.status]}, ${pollLabel(round.pollType)}${round.isAcceptingVotes ? ", accepting votes" : ""}`, ...times, - `${pluralize(round.eligibleEntryCount, "entry", "entries")} · ${pluralize(round.ballotCount, "ballot")}`, + `${pluralize(round.eligibleEntryCount, "entry", "entries")}, ${pluralize(round.ballotCount, "ballot")}`, ...round.warnings.map((warning) => `-# ${plain(warning, 200)}`) ]; const components = round.isAcceptingVotes ? [buttons(linkButton("Vote on the web", `${webAppUrl}/studio`))] : []; @@ -126,7 +126,7 @@ export function entryPage(round: RoundDetailDto, entries: readonly EntryDto[], i const blocks = [ `## ${plain(entry.title, 200)}`, entry.description === null ? "-# No description" : plain(entry.description, 1500), - `-# ${plain(round.title, 100)} · entry ${index + 1} of ${entries.length} · submitted ${when(entry.createdAt)}${status === null ? "" : ` · ${status}`}` + `-# ${plain(round.title, 100)}, entry ${index + 1} of ${entries.length}, submitted ${when(entry.createdAt)}${status === null ? "" : `, ${status}`}` ].join("\n"); const media = entry.mediaUrl !== null && /\.(png|jpe?g|gif|webp)$/iu.test(entry.mediaUrl) ? [image(entry.mediaUrl, entry.title)] : []; const navigation = buttons( @@ -143,7 +143,7 @@ export function entryPage(round: RoundDetailDto, entries: readonly EntryDto[], i function standingLine(item: LeaderboardItemDto, pollType: PollType): string { if (pollType === PollType.Binary) { - return `${item.position}. **${plain(item.title, 100)}** ${item.voteSharePercentage.toFixed(1)}% · ${pluralize(item.rawScore, "vote")}`; + return `${item.position}. **${plain(item.title, 100)}** ${item.voteSharePercentage.toFixed(1)}%, ${pluralize(item.rawScore, "vote")}`; } const points = (item.regularizedTotalScore ?? item.rawScore).toFixed(1); const counts = item.rankCounts.map((count, position) => `${count}× ${["1st", "2nd", "3rd"][position] ?? ""}`).join(", "); @@ -155,7 +155,7 @@ export function leaderboard(round: RoundDto, board: LeaderboardDto): V2Message { return message(panel(null, `## ${plain(round.title)}`, "No votes yet.")); } const lines = board.items.slice(0, 10).map((item) => standingLine(item, board.pollType)); - const footer = `-# ${pluralize(board.totalBallots, "ballot")} · updated ${when(board.computedAt)}${board.isConserved ? "" : " · point totals don't add up, check the audit"}`; + const footer = `-# ${pluralize(board.totalBallots, "ballot")}, updated ${when(board.computedAt)}${board.isConserved ? "" : ", point totals don't add up, check the audit"}`; return message(panel(null, `## ${plain(round.title)}`, lines.join("\n"), footer)); } @@ -176,7 +176,7 @@ export function results(round: RoundDto, result: RoundResultDto): V2Message { `## ${plain(round.title)}: results`, standings.join("\n"), verdict, - `-# ${pluralize(result.totalBallots, "ballot")} · finalized ${when(result.finalizedAt, "D")}` + `-# ${pluralize(result.totalBallots, "ballot")}, finalized ${when(result.finalizedAt, "D")}` ] .filter((line): line is string => line !== null) .join("\n") @@ -205,7 +205,7 @@ export function telemetry(round: RoundDto, snapshots: readonly RaidTelemetryDto[ : snapshot.severity === RaidSeverity.Suspicious ? "suspicious" : "critical"; - return `**${plain(titles.get(snapshot.entryId) ?? "Unknown entry", 100)}** ${state}\n-# ${flags.length > 0 ? flags.join(", ") : "no anomalies"} · velocity ${snapshot.velocityZScore.toFixed(1)}σ`; + return `**${plain(titles.get(snapshot.entryId) ?? "Unknown entry", 100)}** ${state}\n-# ${flags.length > 0 ? flags.join(", ") : "no anomalies"}, velocity ${snapshot.velocityZScore.toFixed(1)}σ`; }); const worst = ordered[0]?.severity ?? RaidSeverity.Normal; const accent = worst === RaidSeverity.CriticalRaid ? Accent.Danger : worst === RaidSeverity.Suspicious ? Accent.Warning : null; diff --git a/apps/discord/src/Views/TaskViews.ts b/apps/discord/src/Views/TaskViews.ts index be4119f..b796912 100644 --- a/apps/discord/src/Views/TaskViews.ts +++ b/apps/discord/src/Views/TaskViews.ts @@ -35,7 +35,7 @@ const tierDays: Readonly> = { }; export function threadStarter(shot: ShotReference, description: string | null): V2Message { - const details = `${capitalize(shot.difficulty)} · scene ${shot.sceneNumber} · ${tierDays[shot.difficulty]} days once claimed`; + const details = `${capitalize(shot.difficulty)}, scene ${shot.sceneNumber}, ${tierDays[shot.difficulty]} days once claimed`; return message( panel( null, From d9c6be8ea3c73af4a34c3bddc6ecb875424408c0 Mon Sep 17 00:00:00 2001 From: Yierke Date: Sun, 27 Sep 2026 10:30:20 +0530 Subject: [PATCH 03/88] feat(discord): restrict task release/submit to claimant & staff, add /available-tasks --- apps/discord/src/Commands/Command.ts | 5 +- .../src/Commands/ModerationCommands.ts | 6 +- apps/discord/src/Commands/SetupCommands.ts | 10 ++- apps/discord/src/Commands/TaskCommands.ts | 75 ++++++++++++++++++- .../src/Interactions/InteractionRouter.ts | 2 + .../scoring/src/EvaluateRankSeparation.ts | 4 +- packages/scoring/src/RaidDetection.ts | 1 + 7 files changed, 95 insertions(+), 8 deletions(-) diff --git a/apps/discord/src/Commands/Command.ts b/apps/discord/src/Commands/Command.ts index 1fd419e..89dfe8d 100644 --- a/apps/discord/src/Commands/Command.ts +++ b/apps/discord/src/Commands/Command.ts @@ -91,7 +91,10 @@ export async function replyWithError( logger: Logger ): Promise { const payload = notice(describeError(error, logger), Accent.Danger); - if (interaction.deferred || interaction.replied) { + if (interaction.deferred && !interaction.replied) { + // edit existing deferred placeholder so the spinner terminates + await interaction.editReply(payload).catch(() => undefined); + } else if (interaction.replied) { await interaction.followUp(payload).catch(() => undefined); } else { await interaction.reply(payload).catch(() => undefined); diff --git a/apps/discord/src/Commands/ModerationCommands.ts b/apps/discord/src/Commands/ModerationCommands.ts index 0eedb31..0c15a06 100644 --- a/apps/discord/src/Commands/ModerationCommands.ts +++ b/apps/discord/src/Commands/ModerationCommands.ts @@ -64,8 +64,10 @@ export const assignRoleCommand: SlashCommand = { const api = actingAs(interaction, context); const reason = `${interaction.options.getString("reason") ?? "Assigned with /assign-role"} (by ${interaction.user.username})`; - const specialtiesOf = (holder: GuildMember, extra?: Specialty): Specialty[] => { + const specialtiesOf = (holder: GuildMember, extra?: Specialty, excludeRoleId?: string): Specialty[] => { + // exclude role being stripped from specialty list const specialties = holder.roles.cache + .filter((held) => held.id !== excludeRoleId) .map((held) => findStudioRole(held.name)?.specialty) .filter((value): value is Specialty => value !== undefined); return [...new Set(extra === undefined ? specialties : [extra, ...specialties])].slice(0, 2); @@ -82,7 +84,7 @@ export const assignRoleCommand: SlashCommand = { (held) => held.id !== role.id && isLeadership(findStudioRole(held.name)?.tier ?? 3) ); if (!keepsLeadership) { - await api.setRole(holder.id, Role.Contributor, specialtiesOf(holder), holder.user.globalName ?? holder.user.username); + await api.setRole(holder.id, Role.Contributor, specialtiesOf(holder, undefined, role.id), holder.user.globalName ?? holder.user.username); } previous.push(holder); } diff --git a/apps/discord/src/Commands/SetupCommands.ts b/apps/discord/src/Commands/SetupCommands.ts index 092c973..086f488 100644 --- a/apps/discord/src/Commands/SetupCommands.ts +++ b/apps/discord/src/Commands/SetupCommands.ts @@ -49,10 +49,18 @@ export const setupForumCommand: SlashCommand = { .toJSON(), async execute(interaction: ChatInputCommandInteraction, context: BotContext): Promise { requireManageGuild(interaction); + if (interaction.guild === null) { + throw new UserFacingError("Run this inside the server."); + } await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const forum = interaction.options.getChannel("forum", true, [ChannelType.GuildForum]); + const forumOption = interaction.options.getChannel("forum", true, [ChannelType.GuildForum]); const submissions = interaction.options.getChannel("submissions", true, [ChannelType.GuildText]); const log = interaction.options.getChannel("log", true, [ChannelType.GuildText]); + // fetch the full guild forum channel instance so tag mutations succeed + const forum = await interaction.guild.channels.fetch(forumOption.id); + if (forum === null || forum.type !== ChannelType.GuildForum) { + throw new UserFacingError("The specified forum channel could not be found."); + } await context.provisioner.ensureForumTags(forum); await context.settings.update((settings) => { settings.channels[ChannelPurpose.TaskSubmissions] = submissions.id; diff --git a/apps/discord/src/Commands/TaskCommands.ts b/apps/discord/src/Commands/TaskCommands.ts index 2895c92..d4645de 100644 --- a/apps/discord/src/Commands/TaskCommands.ts +++ b/apps/discord/src/Commands/TaskCommands.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { DeliverableKind, deliverableContentTypes, DifficultyTier, ReviewDecision, Role } from "@platform/contracts"; +import { DeliverableKind, deliverableContentTypes, DifficultyTier, ReviewDecision, Role, ShotStatus } from "@platform/contracts"; import { ChannelType, LabelBuilder, @@ -14,11 +14,12 @@ import { type ModalSubmitInteraction } from "discord.js"; import type { ActingApiClient } from "../Api/PlatformApiClient.js"; -import { asEdit, ephemeral, panel, plain, pluralize, text, when } from "../Discord/Ui.js"; +import { Accent, asEdit, capitalize, ephemeral, panel, plain, pluralize, text, when } from "../Discord/Ui.js"; import { referenceOf } from "../Services/TaskForum.js"; import { deliverableLinks, deliverablesButtonPrefix, reviewButtonPrefix, reviewOutcome } from "../Views/TaskViews.js"; import { actingAs, + hasAtLeast, requirePlatformRole, UserFacingError, type BotContext, @@ -112,6 +113,7 @@ export const createTaskCommand: SlashCommand = { .toJSON(), async execute(interaction: ChatInputCommandInteraction, context: BotContext): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await requirePlatformRole(interaction, context, Role.Supervisor); const scene = interaction.options.getInteger("scene", true); const code = interaction.options.getString("code")?.trim() ?? @@ -150,7 +152,14 @@ export const releaseTaskCommand: SlashCommand = { async execute(interaction: ChatInputCommandInteraction, context: BotContext): Promise { const shotId = shotInThread(interaction, context); await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - await actingAs(interaction, context).releaseShot(shotId, interaction.options.getString("reason")); + const shot = await context.api.getShot(shotId); + const me = await actingAs(interaction, context).me(); + const isStaff = hasAtLeast(me.role, Role.Supervisor); + const isClaimant = shot.claimer?.id === me.id; + if (!isStaff && !isClaimant) { + throw new UserFacingError("Only the task claimant, department supervisors, or admins can release this task."); + } + await actingAs(interaction, context).releaseShot(shotId, interaction.options.getString("reason")?.trim() ?? null); await interaction.editReply(asEdit(ephemeral(panel(null, "Released. The task is open for someone else.")))); } }; @@ -166,6 +175,13 @@ export const submitTaskCommand: SlashCommand = { async execute(interaction: ChatInputCommandInteraction, context: BotContext): Promise { const shotId = shotInThread(interaction, context); await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const shot = await context.api.getShot(shotId); + const me = await actingAs(interaction, context).me(); + const isStaff = hasAtLeast(me.role, Role.Supervisor); + const isClaimant = shot.claimer?.id === me.id; + if (!isStaff && !isClaimant) { + throw new UserFacingError("Only the contributor who claimed this task can submit work for it."); + } const api = actingAs(interaction, context); const videoKey = await uploadAttachment(api, shotId, interaction.options.getAttachment("video", true), DeliverableKind.Video); const blend = interaction.options.getAttachment("blend"); @@ -181,6 +197,59 @@ export const submitTaskCommand: SlashCommand = { } }; +export const availableTasksCommand: SlashCommand = { + definition: new SlashCommandBuilder() + .setName("available-tasks") + .setDescription("View all tasks currently available in the grab-box") + .addStringOption((option) => + option + .setName("difficulty") + .setDescription("Filter by difficulty tier") + .setRequired(false) + .addChoices( + { name: "Easy (5 days)", value: DifficultyTier.Easy }, + { name: "Medium (7 days)", value: DifficultyTier.Medium }, + { name: "Hard (10 days)", value: DifficultyTier.Hard }, + { name: "Complex (14 days)", value: DifficultyTier.Complex } + ) + ) + .toJSON(), + async execute(interaction: ChatInputCommandInteraction, context: BotContext): Promise { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const difficulty = interaction.options.getString("difficulty") as DifficultyTier | null; + const allShots = await context.api.listAllShots(); + const available = allShots.filter( + (shot) => shot.status === ShotStatus.Available && (difficulty === null || shot.difficultyTier === difficulty) + ); + + if (available.length === 0) { + await interaction.editReply( + asEdit( + ephemeral( + panel( + null, + difficulty + ? `No available **${difficulty}** tasks in the grab-box right now.` + : "No available tasks in the grab-box right now." + ) + ) + ) + ); + return; + } + + const lines = available.slice(0, 15).map((shot) => { + const threadId = context.forum.threadFor(shot.id); + const destination = threadId !== undefined ? `<#${threadId}>` : `Scene ${shot.sceneNumber}`; + return `• **${plain(shot.shotCode, 50)}** - ${plain(shot.title, 80)} (${capitalize(shot.difficultyTier)})\n ↳ ${destination}`; + }); + + const overflow = available.length > 15 ? `\n\n-# …and ${available.length - 15} more available tasks.` : ""; + const body = `### Available Tasks (${available.length})\n${lines.join("\n")}${overflow}\n\n-# Use \`/take-task\` in a task post to claim it.`; + await interaction.editReply(asEdit(ephemeral(panel(Accent.Info, body)))); + } +}; + export const syncTaskCommand: SlashCommand = { definition: new SlashCommandBuilder() .setName("sync-task") diff --git a/apps/discord/src/Interactions/InteractionRouter.ts b/apps/discord/src/Interactions/InteractionRouter.ts index e7e7eb8..0c2c8d6 100644 --- a/apps/discord/src/Interactions/InteractionRouter.ts +++ b/apps/discord/src/Interactions/InteractionRouter.ts @@ -5,6 +5,7 @@ import { assignRoleCommand, blacklistCommand } from "../Commands/ModerationComma import { entryPageHandler, roundCommands, roundSelectHandler } from "../Commands/RoundCommands.js"; import { botSetupCommand, setChannelCommand, setupForumCommand } from "../Commands/SetupCommands.js"; import { + availableTasksCommand, createTaskCommand, deliverablesButtonHandler, releaseTaskCommand, @@ -22,6 +23,7 @@ export const slashCommands: readonly SlashCommand[] = [ ...roundCommands, blacklistCommand, assignRoleCommand, + availableTasksCommand, createTaskCommand, takeTaskCommand, releaseTaskCommand, diff --git a/packages/scoring/src/EvaluateRankSeparation.ts b/packages/scoring/src/EvaluateRankSeparation.ts index 7f99792..46535bf 100644 --- a/packages/scoring/src/EvaluateRankSeparation.ts +++ b/packages/scoring/src/EvaluateRankSeparation.ts @@ -16,12 +16,14 @@ export function evaluateRankSeparation( const momentsB = calculateMoments(breakdownB, totalBallots, scheme); const covariance = calculatePairwiseCovariance(breakdownA.entryId, breakdownB.entryId, ballots, scheme); + // delta evaluates score difference between entry a and entry b const deltaScore = breakdownA.rawScore - breakdownB.rawScore; const deltaVariance = Math.max(0, momentsA.totalVariance + momentsB.totalVariance - 2 * covariance.totalCovariance); const standardError = Math.sqrt(deltaVariance); const zScore = standardError > 1e-9 ? deltaScore / standardError : 0; const pValue = approximateTwoTailedPValue(zScore); - const isDecisive = Math.abs(zScore) >= zThreshold; + // a decisive lead requires positive separation exceeding the threshold + const isDecisive = zScore >= zThreshold; return { entryA: breakdownA.entryId, diff --git a/packages/scoring/src/RaidDetection.ts b/packages/scoring/src/RaidDetection.ts index bc77490..fe6c517 100644 --- a/packages/scoring/src/RaidDetection.ts +++ b/packages/scoring/src/RaidDetection.ts @@ -105,6 +105,7 @@ export function analyzeRaidRisk( export function calculateVelocityZScore(recentCount: number, trailingHourCount: number): number { const baselinePerWindow = Math.max(0, trailingHourCount - recentCount) / (windowsPerHour - 1); if (baselinePerWindow === 0) { + // no preceding baseline history to evaluate velocity burst against return 0; } return (recentCount - baselinePerWindow) / Math.max(1, Math.sqrt(baselinePerWindow)); From 42d5320e33c55afb08f06b342fa6926d209cd9fd Mon Sep 17 00:00:00 2001 From: Yierke Date: Sun, 27 Sep 2026 10:44:31 +0530 Subject: [PATCH 04/88] feat(discord): auto-discover forum task threads and refresh /help menu --- apps/discord/src/Api/PlatformApiClient.ts | 8 +++ apps/discord/src/Commands/InfoCommands.ts | 14 ++--- apps/discord/src/Commands/TaskCommands.ts | 10 ++-- apps/discord/src/Services/TaskForum.ts | 63 ++++++++++++++++++++++- 4 files changed, 81 insertions(+), 14 deletions(-) diff --git a/apps/discord/src/Api/PlatformApiClient.ts b/apps/discord/src/Api/PlatformApiClient.ts index 0910cbc..a859a6d 100644 --- a/apps/discord/src/Api/PlatformApiClient.ts +++ b/apps/discord/src/Api/PlatformApiClient.ts @@ -110,6 +110,14 @@ export class PlatformApiClient { return (await this.send("/shot-thread-maps", dataEnvelope(z.array(shotThreadMapSchema)))).data; } + public async getThreadMapByThread(discordThreadId: string): Promise { + try { + return (await this.send(`/shot-thread-maps/by-thread/${discordThreadId}`, dataEnvelope(shotThreadMapSchema))).data; + } catch { + return null; + } + } + public async bindThread(shotId: string, discordThreadId: string): Promise { await this.send(`/shot-thread-maps/${shotId}`, dataEnvelope(shotThreadMapSchema), { method: "PUT", body: { discordThreadId } }); } diff --git a/apps/discord/src/Commands/InfoCommands.ts b/apps/discord/src/Commands/InfoCommands.ts index 2905e26..b0c6f51 100644 --- a/apps/discord/src/Commands/InfoCommands.ts +++ b/apps/discord/src/Commands/InfoCommands.ts @@ -6,13 +6,13 @@ import { requireManageGuild, type BotContext, type SlashCommand } from "./Comman const helpText = [ "## Commands", "**Voting**", - "/rounds, /round, /entries, /leaderboard, /results", - "**Tasks** (run inside a task post)", - "/take-task, /release-task, /submit-task", - "**Staff**", - "/create-task, /sync-task, /telemetry, /blacklist, /assign-role", - "**Server setup** (Manage Server)", - "/bot-setup, /setup-forum, /set-announcement-channel, /bot-status" + "`/rounds` • `/round` • `/vote` • `/ballot` • `/results` • `/telemetry`", + "**Tasks & Grab-Box**", + "`/available-tasks` • `/take-task` • `/release-task` • `/submit-task`", + "**Staff & Production**", + "`/create-task` • `/sync-task` • `/assign-role` • `/blacklist`", + "**Server Administration**", + "`/bot-setup` • `/setup-forum` • `/set-channel` • `/bot-status` • `/help` • `/docs`" ].join("\n"); const docsText = [ diff --git a/apps/discord/src/Commands/TaskCommands.ts b/apps/discord/src/Commands/TaskCommands.ts index d4645de..2d9bddf 100644 --- a/apps/discord/src/Commands/TaskCommands.ts +++ b/apps/discord/src/Commands/TaskCommands.ts @@ -33,10 +33,10 @@ const maximumAttachmentBytes = 100 * 1024 * 1024; const attachmentHosts = new Set(["cdn.discordapp.com", "media.discordapp.net"]); const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; -function shotInThread(interaction: ChatInputCommandInteraction, context: BotContext): string { +async function shotInThread(interaction: ChatInputCommandInteraction, context: BotContext): Promise { const channel = interaction.channel; const inForum = channel !== null && channel.isThread() && channel.parent?.type === ChannelType.GuildForum; - const shotId = inForum ? context.forum.shotFor(channel.id) : undefined; + const shotId = inForum ? await context.forum.resolveShotFor(channel.id) : undefined; if (shotId === undefined) { throw new UserFacingError("Run this inside a task post in the task forum."); } @@ -135,8 +135,8 @@ export const createTaskCommand: SlashCommand = { export const takeTaskCommand: SlashCommand = { definition: new SlashCommandBuilder().setName("take-task").setDescription("Claim the task in this post").toJSON(), async execute(interaction: ChatInputCommandInteraction, context: BotContext): Promise { - const shotId = shotInThread(interaction, context); await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const shotId = await shotInThread(interaction, context); const shot = await actingAs(interaction, context).claimShot(shotId); const due = shot.deadlineAt === null ? "" : ` It's due ${when(shot.deadlineAt)}.`; await interaction.editReply(asEdit(ephemeral(panel(null, `It's yours.${due} Use /submit-task here when you're done.`)))); @@ -150,8 +150,8 @@ export const releaseTaskCommand: SlashCommand = { .addStringOption((option) => option.setName("reason").setDescription("Optional note for the team").setMaxLength(500)) .toJSON(), async execute(interaction: ChatInputCommandInteraction, context: BotContext): Promise { - const shotId = shotInThread(interaction, context); await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const shotId = await shotInThread(interaction, context); const shot = await context.api.getShot(shotId); const me = await actingAs(interaction, context).me(); const isStaff = hasAtLeast(me.role, Role.Supervisor); @@ -173,8 +173,8 @@ export const submitTaskCommand: SlashCommand = { .addStringOption((option) => option.setName("notes").setDescription("Anything the reviewer should know").setMaxLength(2000)) .toJSON(), async execute(interaction: ChatInputCommandInteraction, context: BotContext): Promise { - const shotId = shotInThread(interaction, context); await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const shotId = await shotInThread(interaction, context); const shot = await context.api.getShot(shotId); const me = await actingAs(interaction, context).me(); const isStaff = hasAtLeast(me.role, Role.Supervisor); diff --git a/apps/discord/src/Services/TaskForum.ts b/apps/discord/src/Services/TaskForum.ts index ed9bbfa..b90875e 100644 --- a/apps/discord/src/Services/TaskForum.ts +++ b/apps/discord/src/Services/TaskForum.ts @@ -37,8 +37,34 @@ export class TaskForum { ) {} public async load(): Promise { - for (const map of await this.api.listThreadMaps()) { - this.remember(map.shotId, map.discordThreadId); + try { + const maps = await this.api.listThreadMaps(); + for (const map of maps) { + this.remember(map.shotId, map.discordThreadId); + } + // auto-discover any active threads in forum not yet in thread map + const forum = await this.forum(); + if (forum !== null) { + const active = await forum.threads.fetchActive().catch(() => null); + if (active !== null) { + const allShots = await this.api.listAllShots().catch(() => []); + const byCode = new Map(allShots.map((s) => [s.shotCode.toUpperCase(), s])); + for (const [threadId, thread] of active.threads) { + if (thread.parentId === forum.id && !this.shotsByThread.has(threadId)) { + const match = /^([A-Z0-9_]+)\s*-\s*/iu.exec(thread.name); + if (match && match[1]) { + const shot = byCode.get(match[1].trim().toUpperCase()); + if (shot) { + this.remember(shot.id, threadId); + await this.api.bindThread(shot.id, threadId).catch(() => undefined); + } + } + } + } + } + } + } catch (error: unknown) { + this.logger.warn({ err: error }, "failed to load task thread bindings"); } } @@ -50,6 +76,39 @@ export class TaskForum { return this.shotsByThread.get(threadId); } + public async resolveShotFor(threadId: string): Promise { + const cached = this.shotsByThread.get(threadId); + if (cached !== undefined) { + return cached; + } + // fallback to querying platform api for thread binding + const remote = await this.api.getThreadMapByThread(threadId); + if (remote !== null) { + this.remember(remote.shotId, remote.discordThreadId); + return remote.shotId; + } + // fallback to matching thread title against shot codes + try { + const channel = await this.client.channels.fetch(threadId).catch(() => null); + if (channel !== null && channel.isThread()) { + const match = /^([A-Z0-9_]+)\s*-\s*/iu.exec(channel.name); + if (match && match[1]) { + const code = match[1].trim().toUpperCase(); + const allShots = await this.api.listAllShots(); + const shot = allShots.find((s) => s.shotCode.toUpperCase() === code); + if (shot) { + this.remember(shot.id, threadId); + await this.api.bindThread(shot.id, threadId).catch(() => undefined); + return shot.id; + } + } + } + } catch { + // ignore resolution errors + } + return undefined; + } + public async forum(): Promise { const id = this.settings.channel(ChannelPurpose.TaskForum); if (id === undefined) { From bdd2f016f4b37939867cbbac626cfc24ffe8845e Mon Sep 17 00:00:00 2001 From: Yierke Date: Sun, 27 Sep 2026 11:30:39 +0530 Subject: [PATCH 05/88] refactor(discord): route /submit-task deliverable submissions to web app --- apps/discord/src/Commands/TaskCommands.ts | 76 ++++++----------------- 1 file changed, 18 insertions(+), 58 deletions(-) diff --git a/apps/discord/src/Commands/TaskCommands.ts b/apps/discord/src/Commands/TaskCommands.ts index 2d9bddf..cbda1e6 100644 --- a/apps/discord/src/Commands/TaskCommands.ts +++ b/apps/discord/src/Commands/TaskCommands.ts @@ -1,5 +1,5 @@ import { randomBytes } from "node:crypto"; -import { DeliverableKind, deliverableContentTypes, DifficultyTier, ReviewDecision, Role, ShotStatus } from "@platform/contracts"; +import { DifficultyTier, ReviewDecision, Role, ShotStatus } from "@platform/contracts"; import { ChannelType, LabelBuilder, @@ -8,13 +8,11 @@ import { SlashCommandBuilder, TextInputBuilder, TextInputStyle, - type Attachment, type ButtonInteraction, type ChatInputCommandInteraction, type ModalSubmitInteraction } from "discord.js"; -import type { ActingApiClient } from "../Api/PlatformApiClient.js"; -import { Accent, asEdit, capitalize, ephemeral, panel, plain, pluralize, text, when } from "../Discord/Ui.js"; +import { Accent, asEdit, buttons, capitalize, divider, ephemeral, linkButton, panel, plain, pluralize, text, when } from "../Discord/Ui.js"; import { referenceOf } from "../Services/TaskForum.js"; import { deliverableLinks, deliverablesButtonPrefix, reviewButtonPrefix, reviewOutcome } from "../Views/TaskViews.js"; import { @@ -29,8 +27,6 @@ import { } from "./Command.js"; const reviewModalPrefix = "review-modal"; -const maximumAttachmentBytes = 100 * 1024 * 1024; -const attachmentHosts = new Set(["cdn.discordapp.com", "media.discordapp.net"]); const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; async function shotInThread(interaction: ChatInputCommandInteraction, context: BotContext): Promise { @@ -43,44 +39,6 @@ async function shotInThread(interaction: ChatInputCommandInteraction, context: B return shotId; } -async function uploadAttachment(api: ActingApiClient, shotId: string, attachment: Attachment, kind: DeliverableKind): Promise { - const url = new URL(attachment.url); - if (url.protocol !== "https:" || !attachmentHosts.has(url.hostname)) { - throw new UserFacingError("Attachments must be uploaded to Discord directly."); - } - if (attachment.size <= 0 || attachment.size > maximumAttachmentBytes) { - throw new UserFacingError("That file is too large to relay through Discord. Upload it on the web app instead."); - } - const contentType = kind === DeliverableKind.Blend ? "application/octet-stream" : (attachment.contentType?.split(";")[0]?.trim() ?? ""); - if (!deliverableContentTypes[kind].includes(contentType)) { - throw new UserFacingError( - kind === DeliverableKind.Video ? "The video must be MP4, WebM or MOV." : "The project file must be a .blend file." - ); - } - - const upload = await api.requestDeliverableUpload(shotId, { kind, fileName: attachment.name, contentType, sizeBytes: attachment.size }); - const download = await fetch(url, { redirect: "error", signal: AbortSignal.timeout(120_000) }); - if (!download.ok) { - throw new UserFacingError("Discord wouldn't hand over the attachment. Try uploading it again."); - } - const body = new Uint8Array(await download.arrayBuffer()); - if (body.byteLength !== attachment.size) { - throw new UserFacingError("The attachment changed while it was being uploaded. Try again."); - } - const stored = await fetch(upload.url, { - method: upload.method, - headers: upload.headers, - body, - redirect: "error", - signal: AbortSignal.timeout(300_000) - }); - await stored.body?.cancel(); - if (!stored.ok) { - throw new UserFacingError("Storage rejected the upload. Try again, or use the web app."); - } - return upload.key; -} - export const createTaskCommand: SlashCommand = { definition: new SlashCommandBuilder() .setName("create-task") @@ -167,10 +125,7 @@ export const releaseTaskCommand: SlashCommand = { export const submitTaskCommand: SlashCommand = { definition: new SlashCommandBuilder() .setName("submit-task") - .setDescription("Submit your work on the task in this post") - .addAttachmentOption((option) => option.setName("video").setDescription("Rendered video (MP4, WebM or MOV)").setRequired(true)) - .addAttachmentOption((option) => option.setName("blend").setDescription("Project file (.blend)")) - .addStringOption((option) => option.setName("notes").setDescription("Anything the reviewer should know").setMaxLength(2000)) + .setDescription("Submit deliverables for the task in this post via the Grab-Box UI") .toJSON(), async execute(interaction: ChatInputCommandInteraction, context: BotContext): Promise { await interaction.deferReply({ flags: MessageFlags.Ephemeral }); @@ -182,17 +137,22 @@ export const submitTaskCommand: SlashCommand = { if (!isStaff && !isClaimant) { throw new UserFacingError("Only the contributor who claimed this task can submit work for it."); } - const api = actingAs(interaction, context); - const videoKey = await uploadAttachment(api, shotId, interaction.options.getAttachment("video", true), DeliverableKind.Video); - const blend = interaction.options.getAttachment("blend"); - const blendKey = blend === null ? null : await uploadAttachment(api, shotId, blend, DeliverableKind.Blend); - const submission = await api.submitWork(shotId, { - videoKey, - blendKey, - notes: interaction.options.getString("notes")?.trim() ?? null - }); + const grabboxUrl = `${context.configuration.webAppUrl}/grabbox?shotId=${shot.id}`; await interaction.editReply( - asEdit(ephemeral(panel(null, `Submitted version ${submission.version}. A supervisor will review it.`))) + asEdit( + ephemeral( + panel( + Accent.Info, + `### Deliverable Submission: **${plain(shot.shotCode, 50)}**\n` + + `To prevent Discord file size limits and relay failures, submit full video renders and Blender project files directly through the Grab-Box UI.\n\n` + + `• **Task:** ${plain(shot.title, 100)}\n` + + `• **Status:** ${capitalize(shot.status)}\n` + + `• **Claimant:** <@${interaction.user.id}>`, + divider(), + buttons(linkButton("Open Grab-Box & Submit", grabboxUrl)) + ) + ) + ) ); } }; From 5cb9948e164d05017e42fa566619cffc11b5cbc4 Mon Sep 17 00:00:00 2001 From: Yierke Date: Sun, 27 Sep 2026 13:11:06 +0530 Subject: [PATCH 06/88] fix(auth): support localhost redirects and cors origins on development --- apps/api/src/App.ts | 16 +++++++++++++++- .../Infrastructure/Discord/DiscordOAuthClient.ts | 2 +- apps/api/src/Modules/Auth/AuthService.ts | 7 +++++-- 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/api/src/App.ts b/apps/api/src/App.ts index 06b42f3..d3159e1 100644 --- a/apps/api/src/App.ts +++ b/apps/api/src/App.ts @@ -144,7 +144,21 @@ export async function buildApplication(services: ServiceContainer, logger: Fasti }); await application.register(cors, { - origin: [...configuration.security.corsOrigins], + origin: (origin, callback) => { + if (!origin) { + callback(null, true); + return; + } + try { + const url = new URL(origin); + const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1"; + if (configuration.security.corsOrigins.includes(origin) || isLocal) { + callback(null, true); + return; + } + } catch {} + callback(new Error("CORS origin not allowed"), false); + }, methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"], allowedHeaders: ["Authorization", "Content-Type", "X-Request-Id"], exposedHeaders: ["X-Request-Id", "RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Reset", "Retry-After"], diff --git a/apps/api/src/Infrastructure/Discord/DiscordOAuthClient.ts b/apps/api/src/Infrastructure/Discord/DiscordOAuthClient.ts index 0835d34..d9fd86b 100644 --- a/apps/api/src/Infrastructure/Discord/DiscordOAuthClient.ts +++ b/apps/api/src/Infrastructure/Discord/DiscordOAuthClient.ts @@ -52,7 +52,7 @@ export class HttpDiscordOAuthClient implements DiscordOAuthClient { response_type: "code", scope: "identify", state, - prompt: "none" + prompt: "consent" }).toString(); return url.toString(); } diff --git a/apps/api/src/Modules/Auth/AuthService.ts b/apps/api/src/Modules/Auth/AuthService.ts index 6d33ba9..559579a 100644 --- a/apps/api/src/Modules/Auth/AuthService.ts +++ b/apps/api/src/Modules/Auth/AuthService.ts @@ -161,7 +161,8 @@ export class AuthService { try { const isRelative = returnTo.startsWith("/") && !returnTo.startsWith("//") && !returnTo.startsWith("/\\"); const url = isRelative ? new URL(returnTo, fallback.origin) : new URL(returnTo); - const allowed = this.security.corsOrigins.includes(url.origin); + const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1"; + const allowed = this.security.corsOrigins.includes(url.origin) || isLocal; if (!allowed || url.username !== "" || url.password !== "" || !safePathPattern.test(url.pathname)) { return fallback; } @@ -199,7 +200,9 @@ export class AuthService { typeof parsed.origin === "string" && typeof parsed.path === "string" && typeof parsed.codeChallenge === "string" && - this.security.corsOrigins.includes(parsed.origin) && + (this.security.corsOrigins.includes(parsed.origin) || + new URL(parsed.origin).hostname === "localhost" || + new URL(parsed.origin).hostname === "127.0.0.1") && safePathPattern.test(parsed.path) && codeChallengePattern.test(parsed.codeChallenge) ) { From 99a3d710f9dc304787687eed9cea10c9c8953bbb Mon Sep 17 00:00:00 2001 From: Yierke Date: Sun, 27 Sep 2026 16:59:12 +0530 Subject: [PATCH 07/88] feat: add pipeline progress endpoints, discord embeds, and local media storage --- apps/api/src/App.ts | 9 ++ .../Composition/ProductionInfrastructure.ts | 4 +- apps/api/src/Composition/ServiceContainer.ts | 2 +- apps/api/src/Domain/Roles.ts | 9 +- .../Infrastructure/Storage/LocalStorage.ts | 118 ++++++++++++++++++ apps/api/src/Modules/Entries/EntriesRoutes.ts | 14 ++- .../api/src/Modules/Entries/EntriesService.ts | 17 ++- .../Leaderboards/LeaderboardService.ts | 16 ++- .../src/Modules/Pipeline/PipelineRoutes.ts | 104 +++++++++++++++ apps/api/src/Modules/Shots/ReviewsService.ts | 2 + apps/api/src/Modules/Shots/ShotsService.ts | 2 + apps/api/src/Modules/Uploads/UploadsRoutes.ts | 35 ++++++ apps/api/test/Notifications.test.ts | 28 ++++- apps/api/test/Voting.test.ts | 21 +++- apps/discord/src/Discord/Ui.ts | 5 +- .../src/Services/NotificationDispatcher.ts | 46 ++++++- apps/discord/src/Services/StudioRoles.ts | 32 ++--- apps/discord/src/Views/NotificationViews.ts | 70 +++++++++-- apps/discord/src/Views/TaskViews.ts | 13 +- apps/discord/test/NotificationViews.test.ts | 102 +++++++++++++++ packages/contracts/src/Enums.ts | 27 +++- packages/contracts/src/Notifications.ts | 21 +++- packages/contracts/src/Resources.ts | 14 +++ 23 files changed, 649 insertions(+), 62 deletions(-) create mode 100644 apps/api/src/Infrastructure/Storage/LocalStorage.ts create mode 100644 apps/api/src/Modules/Pipeline/PipelineRoutes.ts diff --git a/apps/api/src/App.ts b/apps/api/src/App.ts index d3159e1..10454d5 100644 --- a/apps/api/src/App.ts +++ b/apps/api/src/App.ts @@ -25,6 +25,7 @@ import { ballotsRoutes } from "./Modules/Ballots/BallotsRoutes.js"; import { entriesRoutes } from "./Modules/Entries/EntriesRoutes.js"; import { healthRoutes } from "./Modules/Health/HealthRoutes.js"; import { notificationRoutes } from "./Modules/Notifications/NotificationRoutes.js"; +import { pipelineRoutes } from "./Modules/Pipeline/PipelineRoutes.js"; import { roundsRoutes } from "./Modules/Rounds/RoundsRoutes.js"; import { shotsRoutes } from "./Modules/Shots/ShotsRoutes.js"; import { telemetryRoutes } from "./Modules/Telemetry/TelemetryRoutes.js"; @@ -107,6 +108,13 @@ export async function buildApplication(services: ServiceContainer, logger: Fasti application.setValidatorCompiler(validatorCompiler); application.setSerializerCompiler(serializerCompiler); application.removeContentTypeParser("text/plain"); + application.addContentTypeParser( + ["image/png", "image/jpeg", "image/gif", "image/webp", "video/mp4", "video/webm", "video/quicktime", "application/octet-stream"], + { parseAs: "buffer", bodyLimit: 10 * 1024 * 1024 }, + (_req, body, done) => { + done(null, body); + } + ); application.decorateRequest("principal", null); application.addHook("onRequest", async (request, reply) => { @@ -210,6 +218,7 @@ export async function buildApplication(services: ServiceContainer, logger: Fasti await api.register(shotsRoutes, { services }); await api.register(uploadsRoutes, { services }); await api.register(threadMapsRoutes, { services }); + await api.register(pipelineRoutes, { services }); await api.register(notificationRoutes, { services }); }, { prefix: apiPrefix } diff --git a/apps/api/src/Composition/ProductionInfrastructure.ts b/apps/api/src/Composition/ProductionInfrastructure.ts index 738b16c..e7f78ed 100644 --- a/apps/api/src/Composition/ProductionInfrastructure.ts +++ b/apps/api/src/Composition/ProductionInfrastructure.ts @@ -8,7 +8,7 @@ import { LanyardPresenceProvider } from "../Infrastructure/Discord/PresenceProvi import { RedisEventBus } from "../Infrastructure/Events/RedisEventBus.js"; import { StreamNotifier } from "../Infrastructure/Notifications/NotificationLog.js"; import { RedisNotificationLog } from "../Infrastructure/Notifications/RedisNotificationLog.js"; -import { S3ObjectStorage } from "../Infrastructure/Storage/S3ObjectStorage.js"; +import { LocalMediaStorage } from "../Infrastructure/Storage/LocalStorage.js"; import type { Infrastructure } from "./ServiceContainer.js"; export function createProductionInfrastructure(configuration: ApplicationConfiguration, logger: FastifyBaseLogger): Infrastructure { @@ -46,7 +46,7 @@ export function createProductionInfrastructure(configuration: ApplicationConfigu databasePing: () => connection.ping(), keyValueStore, eventBus, - objectStorage: new S3ObjectStorage(configuration.storage), + objectStorage: new LocalMediaStorage(configuration.storage, configuration.security.appUrl), discordOAuth: new HttpDiscordOAuthClient(configuration.discord), presenceProvider: new LanyardPresenceProvider(keyValueStore), notifier: new StreamNotifier(notificationLog, logger), diff --git a/apps/api/src/Composition/ServiceContainer.ts b/apps/api/src/Composition/ServiceContainer.ts index 7a88ff4..a556f10 100644 --- a/apps/api/src/Composition/ServiceContainer.ts +++ b/apps/api/src/Composition/ServiceContainer.ts @@ -83,7 +83,7 @@ export function createServiceContainer( ), usersService: new UsersService(database, notifier, leaderboardCache), roundsService: new RoundsService(database, notifier, leaderboardCache), - leaderboardService: new LeaderboardService(database, leaderboardCache, eventBus, notifier), + leaderboardService: new LeaderboardService(database, leaderboardCache, eventBus, notifier, objectStorage), entriesService: new EntriesService(database, notifier, objectStorage, configuration.storage, leaderboardCache), ballotsService: new BallotsService(database, keyValueStore, eventBus, notifier, raidMonitor, configuration.security.appKey), raidMonitor, diff --git a/apps/api/src/Domain/Roles.ts b/apps/api/src/Domain/Roles.ts index 99dbdd0..c199d03 100644 --- a/apps/api/src/Domain/Roles.ts +++ b/apps/api/src/Domain/Roles.ts @@ -11,7 +11,14 @@ const roleRanks: Readonly> = Object.freeze({ [Role.Admin]: 6 }); -const specialtyRoles: ReadonlySet = new Set([Role.Contributor, Role.SeniorContributor, Role.Supervisor]); +const specialtyRoles: ReadonlySet = new Set([ + Role.Admin, + Role.Supervisor, + Role.Moderator, + Role.SeniorContributor, + Role.Contributor, + Role.Voter +]); export function rankOf(role: Role): number { return roleRanks[role]; diff --git a/apps/api/src/Infrastructure/Storage/LocalStorage.ts b/apps/api/src/Infrastructure/Storage/LocalStorage.ts new file mode 100644 index 0000000..c5ea13c --- /dev/null +++ b/apps/api/src/Infrastructure/Storage/LocalStorage.ts @@ -0,0 +1,118 @@ +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { dirname, extname, resolve } from "node:path"; +import type { StorageConfiguration } from "../../Configuration/ApplicationConfiguration.js"; +import { StorageBucket, type ObjectStorage, type PresignedUpload, type StoredObjectMetadata } from "./ObjectStorage.js"; +import { S3ObjectStorage } from "./S3ObjectStorage.js"; + +const extensionMimeTypes: Readonly> = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mov": "video/quicktime" +}; + +export class LocalMediaStorage implements ObjectStorage { + private readonly s3Storage: S3ObjectStorage; + private readonly localDir: string; + private readonly baseUrl: string; + + public constructor( + private readonly configuration: StorageConfiguration, + appUrl = "" + ) { + this.s3Storage = new S3ObjectStorage(configuration); + this.localDir = resolve(process.cwd(), "storage", "media"); + if (!existsSync(this.localDir)) { + mkdirSync(this.localDir, { recursive: true }); + } + this.baseUrl = (configuration.mediaPublicUrl || appUrl).replace(/\/+$/u, ""); + } + + public isEnabled(bucket: StorageBucket): boolean { + if (bucket === StorageBucket.Media) { + return true; + } + return this.s3Storage.isEnabled(bucket); + } + + public async createUpload(bucket: StorageBucket, key: string, contentType: string, sizeBytes: number): Promise { + if (bucket === StorageBucket.Media) { + const uploadUrl = `${this.baseUrl}/api/v1/uploads/media/file/${encodeURIComponent(key)}`; + return { + method: "PUT", + url: uploadUrl, + headers: { "Content-Type": contentType, "Content-Length": String(sizeBytes) }, + key, + expiresAt: new Date(Date.now() + 15 * 60 * 1000) + }; + } + return this.s3Storage.createUpload(bucket, key, contentType, sizeBytes); + } + + public async getMetadata(bucket: StorageBucket, key: string): Promise { + if (bucket === StorageBucket.Media) { + const target = this.resolvePath(key); + if (target === null || !existsSync(target)) { + return null; + } + try { + const stat = statSync(target); + const ext = extname(target).toLowerCase(); + return { + sizeBytes: stat.size, + contentType: extensionMimeTypes[ext] || "application/octet-stream" + }; + } catch { + return null; + } + } + return this.s3Storage.getMetadata(bucket, key); + } + + public async createDownloadUrl(bucket: StorageBucket, key: string): Promise { + if (bucket === StorageBucket.Media) { + return `${this.baseUrl}/api/v1/uploads/media/file/${encodeURIComponent(key)}`; + } + return this.s3Storage.createDownloadUrl(bucket, key); + } + + public getPublicUrl(bucket: StorageBucket, key: string): string | null { + if (bucket === StorageBucket.Media) { + return `${this.baseUrl}/api/v1/uploads/media/file/${encodeURIComponent(key)}`; + } + return this.s3Storage.getPublicUrl(bucket, key); + } + + public saveMedia(key: string, buffer: Buffer): void { + const target = this.resolvePath(key); + if (target === null) { + throw new Error("Invalid media storage path"); + } + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, buffer); + } + + public readMedia(key: string): { buffer: Buffer; contentType: string } | null { + const target = this.resolvePath(key); + if (target === null || !existsSync(target)) { + return null; + } + const ext = extname(target).toLowerCase(); + return { + buffer: readFileSync(target), + contentType: extensionMimeTypes[ext] || "application/octet-stream" + }; + } + + public resolvePath(key: string): string | null { + const sanitized = key.replace(/\\/gu, "/"); + if (sanitized.includes("..")) { + return null; + } + return resolve(this.localDir, sanitized); + } +} diff --git a/apps/api/src/Modules/Entries/EntriesRoutes.ts b/apps/api/src/Modules/Entries/EntriesRoutes.ts index c506589..de5d3e3 100644 --- a/apps/api/src/Modules/Entries/EntriesRoutes.ts +++ b/apps/api/src/Modules/Entries/EntriesRoutes.ts @@ -21,6 +21,12 @@ const roundParams = z.object({ roundId: uuidSchema }); const entryParams = z.object({ roundId: uuidSchema, entryId: uuidSchema }); const mediaKeySchema = z.string().max(255); +const pitchSchema = trimmedText(255).refine( + (text) => text.trim().split(/\s+/u).filter(Boolean).length <= 30, + { message: "Pitch must not exceed 30 words." } +); +const descriptionSchema = trimmedText(250).nullable(); + export const entriesRoutes: FastifyPluginAsyncZod<{ services: ServiceContainer }> = async (application, { services }) => { const { entriesService, objectStorage } = services; const security = [{ bearer: [] }]; @@ -69,8 +75,8 @@ export const entriesRoutes: FastifyPluginAsyncZod<{ services: ServiceContainer } security, params: roundParams, body: z.object({ - title: trimmedText(255), - description: trimmedText(1500).nullable().default(null), + title: pitchSchema, + description: descriptionSchema.default(null), mediaKey: mediaKeySchema.nullable().default(null) }), response: { 201: dataEnvelope(entrySchema), ...errorResponses } @@ -93,8 +99,8 @@ export const entriesRoutes: FastifyPluginAsyncZod<{ services: ServiceContainer } params: entryParams, body: z .object({ - title: trimmedText(255).optional(), - description: trimmedText(1500).nullable().optional(), + title: pitchSchema.optional(), + description: descriptionSchema.optional(), mediaKey: mediaKeySchema.nullable().optional() }) .refine((body) => Object.keys(body).length > 0, "at least one field is required"), diff --git a/apps/api/src/Modules/Entries/EntriesService.ts b/apps/api/src/Modules/Entries/EntriesService.ts index e88496d..607754c 100644 --- a/apps/api/src/Modules/Entries/EntriesService.ts +++ b/apps/api/src/Modules/Entries/EntriesService.ts @@ -14,7 +14,7 @@ import { roundReferenceOf, type Notifier } from "../../Infrastructure/Notifications/Notification.js"; -import type { ObjectStorage } from "../../Infrastructure/Storage/ObjectStorage.js"; +import { StorageBucket, type ObjectStorage } from "../../Infrastructure/Storage/ObjectStorage.js"; import type { LeaderboardCache } from "../Leaderboards/LeaderboardCache.js"; import { canSeeDrafts } from "../Rounds/RoundVisibility.js"; import { assertUploadedMedia } from "../Uploads/MediaPolicy.js"; @@ -93,7 +93,6 @@ export class EntriesService { if (input.mediaKey !== null) { await assertUploadedMedia(this.storage, input.mediaKey, author.id, this.storageConfiguration.mediaMaxBytes); } - const isStaff = hasAtLeast(author.role, Role.Supervisor); const { entry, round } = await this.database.transaction(async (transaction) => { const lockedRound = await this.requireRound(transaction, roundId, true); if (!submissionStatuses.includes(lockedRound.status)) { @@ -107,7 +106,7 @@ export class EntriesService { description: input.description, mediaKey: input.mediaKey, submittedBy: author.id, - status: isStaff ? EntryStatus.Approved : EntryStatus.PendingReview + status: EntryStatus.PendingReview }) .returning(); if (created === undefined) { @@ -116,13 +115,11 @@ export class EntriesService { return { entry: created, round: lockedRound }; }); - if (entry.status === EntryStatus.Approved) { - await this.leaderboardCache.invalidateRound(roundId); - } + const mediaUrl = entry.mediaKey === null ? null : this.storage.getPublicUrl(StorageBucket.Media, entry.mediaKey); this.notifier.notify({ type: NotificationType.EntrySubmitted, round: roundReferenceOf(round), - entry: { id: entry.id, title: entry.title }, + entry: { id: entry.id, title: entry.title, mediaUrl }, status: entry.status, author: personOfUser(author) }); @@ -156,10 +153,11 @@ export class EntriesService { }); await this.leaderboardCache.invalidateRound(roundId); const author = await this.authorOf(entry); + const mediaUrl = entry.mediaKey === null ? null : this.storage.getPublicUrl(StorageBucket.Media, entry.mediaKey); this.notifier.notify({ type: NotificationType.EntryStatusChanged, round: roundReferenceOf(round), - entry: { id: entry.id, title: entry.title }, + entry: { id: entry.id, title: entry.title, mediaUrl }, status, author: author === null ? null : personOfUser(author), actor: personOfActor(actor) @@ -177,10 +175,11 @@ export class EntriesService { return updated; }); await this.leaderboardCache.invalidateRound(roundId); + const mediaUrl = entry.mediaKey === null ? null : this.storage.getPublicUrl(StorageBucket.Media, entry.mediaKey); this.notifier.notify({ type: NotificationType.EntryReinstated, round: roundReferenceOf(round), - entry: { id: entry.id, title: entry.title }, + entry: { id: entry.id, title: entry.title, mediaUrl }, actor: personOfActor(actor) }); return entry; diff --git a/apps/api/src/Modules/Leaderboards/LeaderboardService.ts b/apps/api/src/Modules/Leaderboards/LeaderboardService.ts index ef7fcb4..bc75818 100644 --- a/apps/api/src/Modules/Leaderboards/LeaderboardService.ts +++ b/apps/api/src/Modules/Leaderboards/LeaderboardService.ts @@ -24,6 +24,7 @@ import { } from "../../Infrastructure/Database/Schema.js"; import { RoundEventType, type EventBus } from "../../Infrastructure/Events/EventBus.js"; import { NotificationType, personOfActor, roundReferenceOf, type Notifier } from "../../Infrastructure/Notifications/Notification.js"; +import { StorageBucket, type ObjectStorage } from "../../Infrastructure/Storage/ObjectStorage.js"; import type { LeaderboardCache } from "./LeaderboardCache.js"; import type { Leaderboard, LeaderboardItem } from "./LeaderboardTypes.js"; @@ -41,7 +42,8 @@ export class LeaderboardService { private readonly database: Database, private readonly cache: LeaderboardCache, private readonly eventBus: EventBus, - private readonly notifier: Notifier + private readonly notifier: Notifier, + private readonly storage?: ObjectStorage ) {} public async getLive(roundId: string, viewer: AuthenticatedUser | null): Promise { @@ -125,6 +127,17 @@ export class LeaderboardService { }); const winner = result.leaderboard[0]; + let winnerMediaUrl: string | null = null; + if (winner !== undefined && this.storage !== undefined) { + const [winnerEntry] = await this.database + .select({ mediaKey: entries.mediaKey }) + .from(entries) + .where(eq(entries.id, winner.entryId)) + .limit(1); + if (winnerEntry?.mediaKey) { + winnerMediaUrl = this.storage.getPublicUrl(StorageBucket.Media, winnerEntry.mediaKey); + } + } this.notifier.notify({ type: NotificationType.RoundFinalized, round: roundReferenceOf(round), @@ -135,6 +148,7 @@ export class LeaderboardService { : { entryId: winner.entryId, title: winner.title, + mediaUrl: winnerMediaUrl, rawScore: winner.rawScore, voteSharePercentage: winner.voteSharePercentage, regularizedTotalScore: winner.regularizedTotalScore diff --git a/apps/api/src/Modules/Pipeline/PipelineRoutes.ts b/apps/api/src/Modules/Pipeline/PipelineRoutes.ts new file mode 100644 index 0000000..fd08a78 --- /dev/null +++ b/apps/api/src/Modules/Pipeline/PipelineRoutes.ts @@ -0,0 +1,104 @@ +import { NotificationType, pipelineProgressSchema } from "@platform/contracts"; +import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"; +import { z } from "zod"; +import { dataEnvelope, errorResponses } from "../../Common/Http/Schemas.js"; +import { actorOf, requireRoleOrService } from "../../Common/Security/Authorization.js"; +import type { ServiceContainer } from "../../Composition/ServiceContainer.js"; +import { Role } from "../../Domain/Roles.js"; +import { personOfActor } from "../../Infrastructure/Notifications/Notification.js"; + +const cacheKey = "pipeline:progress"; +const ttlOneYear = 365 * 86400; + +const defaultProgress = { + stepIndex: 0, + stepId: "0.1", + stepTitle: "Story Pitching", + phaseNumber: 0, + phaseTitle: "Phase 0: Pre-Production", + progressPercent: 3, + isPhaseTransition: false, + updatedAt: null +}; + +export const updatePipelineProgressSchema = z.object({ + stepIndex: z.number().int().min(0), + stepId: z.string().max(32), + stepTitle: z.string().max(128), + phaseNumber: z.number().int().min(0), + phaseTitle: z.string().max(128), + progressPercent: z.number().min(0).max(100), + isPhaseTransition: z.boolean() +}); + +export const pipelineRoutes: FastifyPluginAsyncZod<{ services: ServiceContainer }> = async (application, { services }) => { + const { keyValueStore, notifier } = services; + const security = [{ bearer: [] }]; + const supervisor = requireRoleOrService(Role.Supervisor); + + application.get( + "/pipeline", + { + schema: { + tags: ["Pipeline"], + summary: "Current production roadmap progress.", + response: { 200: dataEnvelope(pipelineProgressSchema), ...errorResponses } + } + }, + async () => { + const raw = await keyValueStore.get(cacheKey); + if (raw === null) { + return { data: defaultProgress }; + } + try { + const parsed = JSON.parse(raw); + return { data: pipelineProgressSchema.parse(parsed) }; + } catch { + // fallback to initial baseline on stale cache structure + return { data: defaultProgress }; + } + } + ); + + application.post( + "/pipeline/progress", + { + preHandler: supervisor, + schema: { + tags: ["Pipeline"], + summary: "Advance the production pipeline stage and broadcast Discord announcement.", + security, + body: updatePipelineProgressSchema, + response: { 200: dataEnvelope(pipelineProgressSchema), ...errorResponses } + } + }, + async (request) => { + const now = new Date().toISOString(); + const record = { + stepIndex: request.body.stepIndex, + stepId: request.body.stepId, + stepTitle: request.body.stepTitle, + phaseNumber: request.body.phaseNumber, + phaseTitle: request.body.phaseTitle, + progressPercent: request.body.progressPercent, + isPhaseTransition: request.body.isPhaseTransition, + updatedAt: now + }; + + await keyValueStore.set(cacheKey, JSON.stringify(record), ttlOneYear); + + notifier.notify({ + type: NotificationType.PipelineUpdated, + stepId: record.stepId, + stepTitle: record.stepTitle, + phaseNumber: record.phaseNumber, + phaseTitle: record.phaseTitle, + progressPercent: record.progressPercent, + isPhaseTransition: record.isPhaseTransition, + actor: personOfActor(actorOf(request)) + }); + + return { data: record }; + } + ); +}; diff --git a/apps/api/src/Modules/Shots/ReviewsService.ts b/apps/api/src/Modules/Shots/ReviewsService.ts index a4c5afd..efb0ac2 100644 --- a/apps/api/src/Modules/Shots/ReviewsService.ts +++ b/apps/api/src/Modules/Shots/ReviewsService.ts @@ -127,11 +127,13 @@ export class ReviewsService { .from(users) .where(eq(users.id, submission.contributorId)) .limit(1); + const videoUrl = this.storage.getPublicUrl(StorageBucket.Deliverables, submission.videoKey); this.notifier.notify({ type: NotificationType.SubmissionReviewed, shot: shotReferenceOf(shot), submissionId: submission.id, version: submission.version, + videoUrl, decision, contributor: contributor === undefined ? { discordId: null, username: "Unknown" } : personOfUser(contributor), reviewer: personOfActor(reviewer), diff --git a/apps/api/src/Modules/Shots/ShotsService.ts b/apps/api/src/Modules/Shots/ShotsService.ts index 66a767e..7e78a95 100644 --- a/apps/api/src/Modules/Shots/ShotsService.ts +++ b/apps/api/src/Modules/Shots/ShotsService.ts @@ -409,11 +409,13 @@ export class ShotsService { return { submission: created, shot: locked }; }); + const videoUrl = this.storage.getPublicUrl(StorageBucket.Deliverables, submission.videoKey); this.notifier.notify({ type: NotificationType.SubmissionCreated, shot: shotReferenceOf(shot), submissionId: submission.id, version: submission.version, + videoUrl, contributor: personOfUser(user), notes: submission.notes }); diff --git a/apps/api/src/Modules/Uploads/UploadsRoutes.ts b/apps/api/src/Modules/Uploads/UploadsRoutes.ts index 3c7a3ee..a00b0c7 100644 --- a/apps/api/src/Modules/Uploads/UploadsRoutes.ts +++ b/apps/api/src/Modules/Uploads/UploadsRoutes.ts @@ -49,4 +49,39 @@ export const uploadsRoutes: FastifyPluginAsyncZod<{ services: ServiceContainer } return reply.status(201).send({ data: toPresignedUploadResponse(upload) }); } ); + + application.put( + "/uploads/media/file/*", + { + config: { rateLimit: { max: 60, timeWindow: "1 minute" } } + }, + async (request, reply) => { + const rawKey = decodeURIComponent((request.params as { "*": string })["*"] || ""); + const buffer = request.body as Buffer; + if (!buffer || buffer.length === 0) { + return reply.status(400).send({ message: "Missing file payload" }); + } + if ("saveMedia" in objectStorage && typeof (objectStorage as { saveMedia: unknown }).saveMedia === "function") { + (objectStorage as { saveMedia: (k: string, b: Buffer) => void }).saveMedia(rawKey, buffer); + } + return reply.status(200).send({ status: "ok", key: rawKey }); + } + ); + + application.get( + "/uploads/media/file/*", + async (request, reply) => { + const rawKey = decodeURIComponent((request.params as { "*": string })["*"] || ""); + if ("readMedia" in objectStorage && typeof (objectStorage as { readMedia: unknown }).readMedia === "function") { + const item = (objectStorage as { readMedia: (k: string) => { buffer: Buffer; contentType: string } | null }).readMedia(rawKey); + if (item === null) { + return reply.status(404).send({ message: "Media file not found" }); + } + void reply.header("Content-Type", item.contentType); + void reply.header("Cache-Control", "public, max-age=31536000, immutable"); + return reply.send(item.buffer); + } + return reply.status(404).send({ message: "Media storage unavailable" }); + } + ); }; diff --git a/apps/api/test/Notifications.test.ts b/apps/api/test/Notifications.test.ts index a2bd6a5..181a2ea 100644 --- a/apps/api/test/Notifications.test.ts +++ b/apps/api/test/Notifications.test.ts @@ -1,6 +1,6 @@ import { actingUserHeader, NotificationType, platformNotificationSchema } from "@platform/contracts"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { PollType, RoundStatus } from "../src/Domain/Enums.js"; +import { EntryStatus, PollType, RoundStatus } from "../src/Domain/Enums.js"; import { Role } from "../src/Domain/Roles.js"; import { createTestContext, json, nextSnowflake, serviceToken, type TestContext } from "./Support/TestApplication.js"; @@ -151,7 +151,14 @@ describe("platform service integration", () => { headers: supervisor.headers, payload: { title } }); - entryIds.push(json>(entry).data.id); + const entryId = json>(entry).data.id; + entryIds.push(entryId); + await context.application.inject({ + method: "PATCH", + url: `/api/v1/rounds/${round.id}/entries/${entryId}/status`, + headers: supervisor.headers, + payload: { status: EntryStatus.Approved } + }); } await context.application.inject({ method: "PATCH", @@ -184,6 +191,20 @@ describe("platform service integration", () => { headers: supervisor.headers, payload: { sceneNumber: 1, shotCode: "NT-1", title: "Notify", difficultyTier: "easy" } }); + await context.application.inject({ + method: "POST", + url: "/api/v1/pipeline/progress", + headers: supervisor.headers, + payload: { + stepIndex: 1, + stepId: "0.2", + stepTitle: "Story Vote", + phaseNumber: 0, + phaseTitle: "Phase 0: Pre-Production", + progressPercent: 7, + isPhaseTransition: false + } + }); await new Promise((resolve) => setTimeout(resolve, 10)); const types = new Set(); @@ -201,7 +222,8 @@ describe("platform service integration", () => { NotificationType.BallotSubmitted, NotificationType.RoundFinalized, NotificationType.UserBlacklisted, - NotificationType.ShotCreated + NotificationType.ShotCreated, + NotificationType.PipelineUpdated ]) { expect(types.has(expected), expected).toBe(true); } diff --git a/apps/api/test/Voting.test.ts b/apps/api/test/Voting.test.ts index 150dd22..e4a27ac 100644 --- a/apps/api/test/Voting.test.ts +++ b/apps/api/test/Voting.test.ts @@ -48,6 +48,16 @@ describe("voting rounds", () => { return json>(response).data.id; } + async function approveEntry(roundId: string, approver: TestUser, entryId: string): Promise { + const response = await context.application.inject({ + method: "PATCH", + url: `/api/v1/rounds/${roundId}/entries/${entryId}/status`, + headers: approver.headers, + payload: { status: EntryStatus.Approved } + }); + expect(response.statusCode).toBe(200); + } + async function setRoundStatus(roundId: string, status: RoundStatus): Promise { const response = await context.application.inject({ method: "PATCH", @@ -75,6 +85,9 @@ describe("voting rounds", () => { const a = await addEntry(roundId, supervisor, "A"); const b = await addEntry(roundId, supervisor, "B"); const c = await addEntry(roundId, supervisor, "C"); + await approveEntry(roundId, supervisor, a); + await approveEntry(roundId, supervisor, b); + await approveEntry(roundId, supervisor, c); const draftForPublic = await context.application.inject({ method: "GET", url: `/api/v1/rounds/${roundId}` }); expect(draftForPublic.statusCode).toBe(404); @@ -164,6 +177,8 @@ describe("voting rounds", () => { const roundId = await createRound(PollType.Binary); const yes = await addEntry(roundId, supervisor, "Yes"); const no = await addEntry(roundId, supervisor, "No"); + await approveEntry(roundId, supervisor, yes); + await approveEntry(roundId, supervisor, no); await setRoundStatus(roundId, RoundStatus.Open); const honest = await context.createUser(Role.Voter); const brigader = await context.createUser(Role.Voter); @@ -187,7 +202,9 @@ describe("voting rounds", () => { it("does not quarantine a popular binary option", async () => { const roundId = await createRound(PollType.Binary); const popular = await addEntry(roundId, supervisor, "Popular"); - await addEntry(roundId, supervisor, "Other"); + const other = await addEntry(roundId, supervisor, "Other"); + await approveEntry(roundId, supervisor, popular); + await approveEntry(roundId, supervisor, other); await setRoundStatus(roundId, RoundStatus.Open); for (let index = 0; index < 12; index++) { const voter = await context.createUser(Role.Voter); @@ -222,6 +239,8 @@ describe("voting rounds", () => { const b = await addEntry(roundId, supervisor, "B"); const c = await addEntry(roundId, supervisor, "C"); + await approveEntry(roundId, supervisor, b); + await approveEntry(roundId, supervisor, c); await setRoundStatus(roundId, RoundStatus.Open); expect(await vote(voter, roundId, [entryId, b, c])).toBe(200); diff --git a/apps/discord/src/Discord/Ui.ts b/apps/discord/src/Discord/Ui.ts index e56846d..6b3c0eb 100644 --- a/apps/discord/src/Discord/Ui.ts +++ b/apps/discord/src/Discord/Ui.ts @@ -21,7 +21,7 @@ export enum Accent { Danger = 0xd83c3e } -export type Block = string | SeparatorBuilder | MediaGalleryBuilder | ActionRowBuilder; +export type Block = string | SeparatorBuilder | MediaGalleryBuilder | ActionRowBuilder | null | undefined; export type TopLevelComponent = ContainerBuilder | TextDisplayBuilder | ActionRowBuilder; @@ -50,6 +50,9 @@ export function panel(accent: Accent | null, ...blocks: readonly Block[]): Conta container.setAccentColor(accent); } for (const block of blocks) { + if (block === null || block === undefined) { + continue; + } if (typeof block === "string") { container.addTextDisplayComponents(text(block)); } else if (block instanceof SeparatorBuilder) { diff --git a/apps/discord/src/Services/NotificationDispatcher.ts b/apps/discord/src/Services/NotificationDispatcher.ts index f26de3e..d3f07cc 100644 --- a/apps/discord/src/Services/NotificationDispatcher.ts +++ b/apps/discord/src/Services/NotificationDispatcher.ts @@ -92,13 +92,47 @@ export class NotificationDispatcher { private async channel(purpose: ChannelPurpose): Promise { const id = this.settings.channel(purpose); - if (id === undefined) { - return null; + if (id !== undefined) { + const channel = await this.client.channels.fetch(id).catch(() => null); + if (channel !== null && channel.isSendable() && !channel.isDMBased() && channel.guildId === this.guildId) { + return channel; + } } - const channel = await this.client.channels.fetch(id).catch(() => null); - if (channel === null || !channel.isSendable() || channel.isDMBased() || channel.guildId !== this.guildId) { - return null; + // fallback to finding matching text channel on the guild + try { + const guild = await this.client.guilds.fetch(this.guildId).catch(() => null); + if (guild) { + const channels = await guild.channels.fetch().catch(() => null); + if (channels) { + const expectedKeywords: Record = { + [ChannelPurpose.Announcements]: ["announcement", "news", "updates", "general"], + [ChannelPurpose.Telemetry]: ["telemetry", "alerts", "bot-logs", "logs"], + [ChannelPurpose.TaskLogs]: ["task-logs", "tasks", "activity"], + [ChannelPurpose.TaskSubmissions]: ["task-submissions", "submissions", "reviews"], + [ChannelPurpose.TaskForum]: ["tasks", "forum"] + }; + const keywords = expectedKeywords[purpose] || [purpose.toLowerCase()]; + const match = channels.find( + (ch) => + ch !== null && + ch.isTextBased() && + !ch.isDMBased() && + keywords.some((kw) => ch.name.toLowerCase().includes(kw)) + ); + if (match && match.isSendable() && !match.isDMBased()) { + await this.settings.update((s) => { + s.channels[purpose] = match.id; + }); + return match as SendableChannels; + } + if (purpose === ChannelPurpose.Announcements && guild.systemChannel && guild.systemChannel.isSendable()) { + return guild.systemChannel; + } + } + } + } catch (err) { + this.logger.warn({ err, purpose }, "dynamic channel lookup failed"); } - return channel; + return null; } } diff --git a/apps/discord/src/Services/StudioRoles.ts b/apps/discord/src/Services/StudioRoles.ts index 0269006..62cdc8d 100644 --- a/apps/discord/src/Services/StudioRoles.ts +++ b/apps/discord/src/Services/StudioRoles.ts @@ -46,20 +46,20 @@ const communityPermissions = [ ]; export const studioRoles: readonly StudioRole[] = Object.freeze([ - { name: "Producer", tier: StudioTier.Executive, color: 0xe74c3c }, - { name: "Creative Director", tier: StudioTier.Executive, color: 0x9b59b6 }, - { name: "Production Manager", tier: StudioTier.Executive, color: 0xc0392b }, - { name: "Technical Director", tier: StudioTier.Department, color: 0x3498db }, - { name: "Art Director", tier: StudioTier.Department, color: 0xe67e22 }, - { name: "Editorial Supervisor", tier: StudioTier.Department, color: 0x8e44ad }, - { name: "Layout / Previs Lead", tier: StudioTier.Department, color: 0x9c27b0 }, - { name: "Modelling Supervisor", tier: StudioTier.Department, color: 0x1abc9c }, - { name: "Rigging Supervisor", tier: StudioTier.Department, color: 0x2ecc71 }, - { name: "Surfacing / LookDev Lead", tier: StudioTier.Department, color: 0x16a085 }, - { name: "Animation Supervisor", tier: StudioTier.Department, color: 0xf39c12 }, - { name: "CFX and VFX Supervisor", tier: StudioTier.Department, color: 0xe91e63 }, - { name: "Lighting and Compositing Supervisor", tier: StudioTier.Department, color: 0xf1c40f }, - { name: "Sound Director", tier: StudioTier.Department, color: 0x00bcd4 }, + { name: "Producer", tier: StudioTier.Executive, color: 0xe74c3c, specialty: Specialty.Producer }, + { name: "Creative Director", tier: StudioTier.Executive, color: 0x9b59b6, specialty: Specialty.CreativeDirector }, + { name: "Production Manager", tier: StudioTier.Executive, color: 0xc0392b, specialty: Specialty.ProductionManager }, + { name: "Technical Director", tier: StudioTier.Department, color: 0x3498db, specialty: Specialty.TechnicalDirector }, + { name: "Art Director", tier: StudioTier.Department, color: 0xe67e22, specialty: Specialty.ArtDirector }, + { name: "Editorial Supervisor", tier: StudioTier.Department, color: 0x8e44ad, specialty: Specialty.EditorialSupervisor }, + { name: "Layout / Previs Lead", tier: StudioTier.Department, color: 0x9c27b0, specialty: Specialty.LayoutPrevisLead }, + { name: "Modelling Supervisor", tier: StudioTier.Department, color: 0x1abc9c, specialty: Specialty.ModellingSupervisor }, + { name: "Rigging Supervisor", tier: StudioTier.Department, color: 0x2ecc71, specialty: Specialty.RiggingSupervisor }, + { name: "Surfacing / LookDev Lead", tier: StudioTier.Department, color: 0x16a085, specialty: Specialty.SurfacingLookDevLead }, + { name: "Animation Supervisor", tier: StudioTier.Department, color: 0xf39c12, specialty: Specialty.AnimationSupervisor }, + { name: "CFX and VFX Supervisor", tier: StudioTier.Department, color: 0xe91e63, specialty: Specialty.CfxVfxSupervisor }, + { name: "Lighting and Compositing Supervisor", tier: StudioTier.Department, color: 0xf1c40f, specialty: Specialty.LightingCompositingSupervisor }, + { name: "Sound Director", tier: StudioTier.Department, color: 0x00bcd4, specialty: Specialty.SoundDirector }, { name: "Animators", tier: StudioTier.Contributor, color: 0x2980b9, specialty: Specialty.Animator }, { name: "Layout Artists", tier: StudioTier.Contributor, color: 0x673ab7, specialty: Specialty.LayoutArtist }, { name: "3D Modelers", tier: StudioTier.Contributor, color: 0x009688, specialty: Specialty.Modeler3d }, @@ -71,8 +71,8 @@ export const studioRoles: readonly StudioRole[] = Object.freeze([ { name: "Voice Actors", tier: StudioTier.Contributor, color: 0x0097a7, specialty: Specialty.VoiceActor }, { name: "Sound Designers", tier: StudioTier.Contributor, color: 0x17a2b8, specialty: Specialty.SoundDesigner }, { name: "Video Editors", tier: StudioTier.Contributor, color: 0x6f42c1, specialty: Specialty.VideoEditor }, - { name: "General Contributors", tier: StudioTier.Contributor, color: 0x3498db }, - { name: "Voters", tier: StudioTier.Community, color: 0x34495e }, + { name: "General Contributors", tier: StudioTier.Contributor, color: 0x3498db, specialty: Specialty.GeneralContributor }, + { name: "Voters", tier: StudioTier.Community, color: 0x34495e, specialty: Specialty.Voter }, { name: "Observer", tier: StudioTier.Community, color: 0x95a5a6 } ]); diff --git a/apps/discord/src/Views/NotificationViews.ts b/apps/discord/src/Views/NotificationViews.ts index d0369b7..2716dd2 100644 --- a/apps/discord/src/Views/NotificationViews.ts +++ b/apps/discord/src/Views/NotificationViews.ts @@ -10,7 +10,7 @@ import { type ShotReference } from "@platform/contracts"; import { ChannelPurpose } from "../State/SettingsStore.js"; -import { Accent, message, panel, person, plain, quote, when, type V2Message } from "../Discord/Ui.js"; +import { Accent, image, message, panel, person, plain, quote, when, type Block, type V2Message } from "../Discord/Ui.js"; export interface RenderedNotification { readonly purpose: ChannelPurpose; @@ -21,8 +21,27 @@ export interface NotificationContext { threadFor(shotId: string): string | undefined; } -function post(purpose: ChannelPurpose, accent: Accent | null, ...lines: readonly (string | null)[]): RenderedNotification { - return { purpose, message: message(panel(accent, lines.filter((line): line is string => line !== null).join("\n"))) }; +function post(purpose: ChannelPurpose, accent: Accent | null, ...blocks: readonly (Block | string | null | undefined)[]): RenderedNotification { + const stringLines: string[] = []; + const panelBlocks: Block[] = []; + for (const b of blocks) { + if (b === null || b === undefined) { + continue; + } + if (typeof b === "string") { + stringLines.push(b); + } else { + if (stringLines.length > 0) { + panelBlocks.push(stringLines.join("\n")); + stringLines.length = 0; + } + panelBlocks.push(b); + } + } + if (stringLines.length > 0) { + panelBlocks.push(stringLines.join("\n")); + } + return { purpose, message: message(panel(accent, ...panelBlocks)) }; } function reason(value: string | null): string | null { @@ -159,7 +178,8 @@ export function renderNotification(notification: PlatformNotification, context: Accent.Info, `## ${plain(notification.round.title)}`, describeWinner(notification), - `-# ${notification.totalBallots.toLocaleString("en-US")} ${notification.totalBallots === 1 ? "ballot" : "ballots"} counted` + `-# ${notification.totalBallots.toLocaleString("en-US")} ${notification.totalBallots === 1 ? "ballot" : "ballots"} counted`, + notification.winner?.mediaUrl ? image(notification.winner.mediaUrl, notification.winner.title) : null ) ]; case NotificationType.EntrySubmitted: @@ -169,7 +189,8 @@ export function renderNotification(notification: PlatformNotification, context: null, `**New entry in ${plain(notification.round.title)}**`, `${plain(notification.entry.title)} by ${person(notification.author)}`, - notification.status === EntryStatus.PendingReview ? "-# Waiting for review" : "-# Approved automatically" + notification.status === EntryStatus.PendingReview ? "-# Waiting for review" : "-# Approved automatically", + notification.entry.mediaUrl ? image(notification.entry.mediaUrl, notification.entry.title) : null ) ]; case NotificationType.EntryStatusChanged: { @@ -180,7 +201,8 @@ export function renderNotification(notification: PlatformNotification, context: headline.accent, `**${headline.title}**`, `${plain(notification.entry.title)} in ${plain(notification.round.title)}`, - `-# By ${person(notification.actor)}` + `-# By ${person(notification.actor)}`, + notification.entry.mediaUrl ? image(notification.entry.mediaUrl, notification.entry.title) : null ) ]; } @@ -191,7 +213,8 @@ export function renderNotification(notification: PlatformNotification, context: Accent.Success, "**Entry reinstated**", `${plain(notification.entry.title)} is back on the ballot in ${plain(notification.round.title)}.`, - `-# By ${person(notification.actor)}` + `-# By ${person(notification.actor)}`, + notification.entry.mediaUrl ? image(notification.entry.mediaUrl, notification.entry.title) : null ) ]; case NotificationType.ShotCreated: @@ -236,7 +259,9 @@ export function renderNotification(notification: PlatformNotification, context: taskLogs, null, `**Submitted:** ${shotLink(notification.shot, context)}, version ${notification.version}`, - `By ${person(notification.contributor)}` + `By ${person(notification.contributor)}`, + notification.notes === null ? null : quote(notification.notes, 500), + notification.videoUrl ? image(notification.videoUrl, `${notification.shot.code} v${notification.version}`) : null ) ]; case NotificationType.SubmissionReviewed: { @@ -247,7 +272,8 @@ export function renderNotification(notification: PlatformNotification, context: approved ? Accent.Success : Accent.Warning, `**${approved ? "Approved" : "Changes requested"}:** ${shotLink(notification.shot, context)}, version ${notification.version}`, `Reviewed by ${person(notification.reviewer)}`, - notification.notes === null ? null : quote(notification.notes, 600) + notification.notes === null ? null : quote(notification.notes, 600), + notification.videoUrl ? image(notification.videoUrl, `${notification.shot.code} v${notification.version}`) : null ) ]; if (approved) { @@ -255,11 +281,35 @@ export function renderNotification(notification: PlatformNotification, context: post( ChannelPurpose.Announcements, Accent.Success, - `**${shotName(notification.shot)} is finished.** Delivered by ${person(notification.contributor)}.` + `**${shotName(notification.shot)} is finished.** Delivered by ${person(notification.contributor)}.`, + notification.videoUrl ? image(notification.videoUrl, shotName(notification.shot)) : null ) ); } return rendered; } + case NotificationType.PipelineUpdated: { + if (notification.isPhaseTransition) { + return [ + post( + ChannelPurpose.Announcements, + Accent.Success, + `@everyone`, + `## Phase ${notification.phaseNumber} Unlocked: ${plain(notification.phaseTitle)}`, + `Now entering **Step ${plain(notification.stepId)}: ${plain(notification.stepTitle)}** (${notification.progressPercent}% overall completed).`, + `-# Updated by ${person(notification.actor)}` + ) + ]; + } + return [ + post( + ChannelPurpose.Announcements, + Accent.Info, + `**Pipeline Update: Step ${plain(notification.stepId)} - ${plain(notification.stepTitle)}**`, + `In **${plain(notification.phaseTitle)}** (${notification.progressPercent}% completed).`, + `-# Updated by ${person(notification.actor)}` + ) + ]; + } } } diff --git a/apps/discord/src/Views/TaskViews.ts b/apps/discord/src/Views/TaskViews.ts index b796912..a29f692 100644 --- a/apps/discord/src/Views/TaskViews.ts +++ b/apps/discord/src/Views/TaskViews.ts @@ -14,6 +14,7 @@ import { capitalize, divider, ephemeral, + image, linkButton, message, panel, @@ -99,7 +100,8 @@ export function threadUpdate( notification.notes === null ? null : quote(notification.notes, 800) ] .filter((line): line is string => line !== null) - .join("\n") + .join("\n"), + notification.videoUrl ? image(notification.videoUrl, `${notification.shot.code} v${notification.version}`) : null ) ); case NotificationType.SubmissionReviewed: { @@ -113,7 +115,8 @@ export function threadUpdate( approved ? Accent.Success : Accent.Warning, [headline, notification.notes === null ? null : quote(notification.notes, 1000), next] .filter((line): line is string => line !== null) - .join("\n") + .join("\n"), + notification.videoUrl ? image(notification.videoUrl, `${notification.shot.code} v${notification.version}`) : null ) ); } @@ -128,7 +131,11 @@ export function reviewCard(notification: NotificationOf line !== null); return message( - panel(Accent.Info, lines.join("\n")), + panel( + Accent.Info, + lines.join("\n"), + notification.videoUrl ? image(notification.videoUrl, `${notification.shot.code} v${notification.version}`) : null + ), buttons( actionButton(`${reviewButtonPrefix}:${notification.submissionId}:${ReviewDecision.Approved}`, "Approve", ButtonStyle.Success), actionButton( diff --git a/apps/discord/test/NotificationViews.test.ts b/apps/discord/test/NotificationViews.test.ts index 5e7ef65..629d1f7 100644 --- a/apps/discord/test/NotificationViews.test.ts +++ b/apps/discord/test/NotificationViews.test.ts @@ -239,6 +239,17 @@ describe("every notification", () => { contributor: voter, reviewer: supervisor, notes: null + }, + { + type: NotificationType.PipelineUpdated, + occurredAt, + stepId: "1.1", + stepTitle: "Art Style", + phaseNumber: 1, + phaseTitle: "Phase 1: Animatic", + progressPercent: 18, + isPhaseTransition: true, + actor: supervisor } ]; @@ -264,6 +275,97 @@ describe("every notification", () => { expect(rendered?.purpose).toBe(ChannelPurpose.Announcements); expect(texts(rendered?.message as V2Message).join("\n")).toContain("**Option Alpha** wins with 62.5% of the vote."); }); + + it("announces phase unlocks and step progress to announcements channel", () => { + const phaseUnlock = only({ + type: NotificationType.PipelineUpdated, + occurredAt, + stepId: "2.1", + stepTitle: "3D Modelling", + phaseNumber: 2, + phaseTitle: "Phase 2: LookDev", + progressPercent: 36, + isPhaseTransition: true, + actor: supervisor + }); + expect(phaseUnlock.purpose).toBe(ChannelPurpose.Announcements); + expect(phaseUnlock.text).toContain("## Phase 2 Unlocked: Phase 2: LookDev"); + expect(phaseUnlock.text).toContain("3D Modelling"); + expect(phaseUnlock.text).toContain("36% overall completed"); + + const stepProgress = only({ + type: NotificationType.PipelineUpdated, + occurredAt, + stepId: "2.2", + stepTitle: "Rigging and Deformation", + phaseNumber: 2, + phaseTitle: "Phase 2: LookDev", + progressPercent: 41, + isPhaseTransition: false, + actor: supervisor + }); + expect(stepProgress.purpose).toBe(ChannelPurpose.Announcements); + expect(stepProgress.text).toContain("Rigging and Deformation"); + expect(stepProgress.text).toContain("Phase 2: LookDev"); + expect(stepProgress.text).toContain("41% completed"); + }); + + it("embeds image and video media thumbnails when mediaUrl or videoUrl is present", () => { + const entryNotification = only({ + type: NotificationType.EntrySubmitted, + occurredAt, + round, + entry: { + id: shot.id, + title: "Hero Design", + mediaUrl: "https://dev-api.seraphinteractive.com/api/v1/uploads/media/file/hero.png" + }, + status: EntryStatus.PendingReview, + author: voter + }); + const entryJson = entryNotification.payload.components.map((c) => c.toJSON() as ComponentJson); + const hasMedia = entryJson.some( + (c) => (c.components ?? []).some((child: unknown) => (child as { type?: number })?.type === 12) + ); + expect(hasMedia).toBe(true); + + const winnerNotification = only({ + type: NotificationType.RoundFinalized, + occurredAt, + round, + totalBallots: 40, + winner: { + entryId: shot.id, + title: "Winning Art", + mediaUrl: "https://dev-api.seraphinteractive.com/api/v1/uploads/media/file/winner.png", + rawScore: 25, + voteSharePercentage: 62.5, + regularizedTotalScore: null + }, + actor: admin + }); + const winnerJson = winnerNotification.payload.components.map((c) => c.toJSON() as ComponentJson); + const winnerHasMedia = winnerJson.some( + (c) => (c.components ?? []).some((child: unknown) => (child as { type?: number })?.type === 12) + ); + expect(winnerHasMedia).toBe(true); + + const submissionNotification = only({ + type: NotificationType.SubmissionCreated, + occurredAt, + shot, + submissionId: shot.id, + version: 1, + videoUrl: "https://dev-api.seraphinteractive.com/api/v1/uploads/media/file/shot_v1.mp4", + contributor: voter, + notes: "Initial blockout" + }); + const submissionJson = submissionNotification.payload.components.map((c) => c.toJSON() as ComponentJson); + const subHasMedia = submissionJson.some( + (c) => (c.components ?? []).some((child: unknown) => (child as { type?: number })?.type === 12) + ); + expect(subHasMedia).toBe(true); + }); }); describe("task views", () => { diff --git a/packages/contracts/src/Enums.ts b/packages/contracts/src/Enums.ts index ca0d39a..1512fc4 100644 --- a/packages/contracts/src/Enums.ts +++ b/packages/contracts/src/Enums.ts @@ -10,6 +10,25 @@ export enum Role { } export enum Specialty { + // Executive Tier (Admin 0) + Producer = "producer", + CreativeDirector = "creative_director", + ProductionManager = "production_manager", + + // Department Tier (Supervisors 1) + TechnicalDirector = "technical_director", + ArtDirector = "art_director", + EditorialSupervisor = "editorial_supervisor", + LayoutPrevisLead = "layout_previs_lead", + ModellingSupervisor = "modelling_supervisor", + RiggingSupervisor = "rigging_supervisor", + SurfacingLookDevLead = "surfacing_lookdev_lead", + AnimationSupervisor = "animation_supervisor", + CfxVfxSupervisor = "cfx_vfx_supervisor", + LightingCompositingSupervisor = "lighting_compositing_supervisor", + SoundDirector = "sound_director", + + // Contributor Tier (Contributors 2) Animator = "animator", LayoutArtist = "layout_artist", Modeler3d = "3d_modeler", @@ -18,10 +37,14 @@ export enum Specialty { LightingArtist = "lighting_artist", VfxArtist = "vfx_artist", ConceptArtist = "concept_artist", - Screenwriter = "screenwriter", VoiceActor = "voice_actor", SoundDesigner = "sound_designer", - VideoEditor = "video_editor" + VideoEditor = "video_editor", + GeneralContributor = "general_contributor", + Screenwriter = "screenwriter", + + // Community Tier (Voters 3) + Voter = "voter" } export const maximumSpecialties = 2; diff --git a/packages/contracts/src/Notifications.ts b/packages/contracts/src/Notifications.ts index bbec26a..08c544f 100644 --- a/packages/contracts/src/Notifications.ts +++ b/packages/contracts/src/Notifications.ts @@ -22,7 +22,8 @@ export enum NotificationType { ShotReleased = "shot.released", ShotExpired = "shot.expired", SubmissionCreated = "submission.created", - SubmissionReviewed = "submission.reviewed" + SubmissionReviewed = "submission.reviewed", + PipelineUpdated = "pipeline.updated" } export const notificationPersonSchema = z.object({ @@ -31,7 +32,11 @@ export const notificationPersonSchema = z.object({ }); const roundReference = z.object({ id: uuidSchema, title: z.string(), pollType: z.enum(PollType) }); -const entryReference = z.object({ id: uuidSchema, title: z.string() }); +const entryReference = z.object({ + id: uuidSchema, + title: z.string(), + mediaUrl: z.string().nullable().optional() +}); const shotReference = z.object({ id: uuidSchema, code: z.string(), @@ -93,6 +98,7 @@ export const platformNotificationSchema = z.discriminatedUnion("type", [ .object({ entryId: uuidSchema, title: z.string(), + mediaUrl: z.string().nullable().optional(), rawScore: z.number(), voteSharePercentage: z.number(), regularizedTotalScore: z.number().nullable() @@ -132,6 +138,7 @@ export const platformNotificationSchema = z.discriminatedUnion("type", [ shot: shotReference, submissionId: uuidSchema, version: z.number().int(), + videoUrl: z.string().nullable().optional(), contributor: notificationPersonSchema, notes: z.string().nullable() }), @@ -139,10 +146,20 @@ export const platformNotificationSchema = z.discriminatedUnion("type", [ shot: shotReference, submissionId: uuidSchema, version: z.number().int(), + videoUrl: z.string().nullable().optional(), decision: z.enum(ReviewDecision), contributor: notificationPersonSchema, reviewer: notificationPersonSchema, notes: z.string().nullable() + }), + notification(NotificationType.PipelineUpdated, { + stepId: z.string(), + stepTitle: z.string(), + phaseNumber: z.number().int(), + phaseTitle: z.string(), + progressPercent: z.number(), + isPhaseTransition: z.boolean(), + actor: notificationPersonSchema }) ]); diff --git a/packages/contracts/src/Resources.ts b/packages/contracts/src/Resources.ts index 7b4463b..5074ade 100644 --- a/packages/contracts/src/Resources.ts +++ b/packages/contracts/src/Resources.ts @@ -246,6 +246,19 @@ export const shotThreadMapSchema = z export const reclaimResultSchema = z.object({ reclaimedCount: z.number().int(), shotCodes: z.array(z.string()) }); +export const pipelineProgressSchema = z + .object({ + stepIndex: z.number().int().min(0), + stepId: z.string().max(32), + stepTitle: z.string().max(128), + phaseNumber: z.number().int().min(0), + phaseTitle: z.string().max(128), + progressPercent: z.number().min(0).max(100), + isPhaseTransition: z.boolean(), + updatedAt: timestampSchema.nullable() + }) + .meta({ id: "PipelineProgress" }); + export type UserSummaryDto = z.infer; export type UserDto = z.infer; export type ModeratedUserDto = z.infer; @@ -267,3 +280,4 @@ export type ReviewQueueItemDto = z.infer; export type PresignedUploadDto = z.infer; export type ShotThreadMapDto = z.infer; export type ReclaimResultDto = z.infer; +export type PipelineProgressDto = z.infer; From 2b8d9045841ccb9762967b516592f4e97cd91530 Mon Sep 17 00:00:00 2001 From: Yierke Date: Sun, 27 Sep 2026 17:09:41 +0530 Subject: [PATCH 08/88] feat(notifications): add round.updated notification and open round announcement --- apps/api/src/Modules/Rounds/RoundsService.ts | 15 ++++++++++++ apps/api/test/Notifications.test.ts | 7 ++++++ apps/discord/src/Views/NotificationViews.ts | 25 +++++++++++++++++++- apps/discord/test/NotificationViews.test.ts | 13 +++++++++- packages/contracts/src/Notifications.ts | 7 ++++++ 5 files changed, 65 insertions(+), 2 deletions(-) diff --git a/apps/api/src/Modules/Rounds/RoundsService.ts b/apps/api/src/Modules/Rounds/RoundsService.ts index 87ea50a..f581b59 100644 --- a/apps/api/src/Modules/Rounds/RoundsService.ts +++ b/apps/api/src/Modules/Rounds/RoundsService.ts @@ -176,6 +176,21 @@ export class RoundsService { }); await this.leaderboardCache.invalidateRound(roundId); + const metadataChanged = + before.title !== after.title || + before.pollType !== after.pollType || + before.opensAt?.getTime() !== after.opensAt?.getTime() || + before.closesAt?.getTime() !== after.closesAt?.getTime(); + + if (metadataChanged) { + this.notifier.notify({ + type: NotificationType.RoundUpdated, + round: roundReferenceOf(after), + actor: personOfActor(actor), + opensAt: toIso(after.opensAt), + closesAt: toIso(after.closesAt) + }); + } if (before.status !== after.status) { this.notifier.notify({ type: NotificationType.RoundStatusChanged, diff --git a/apps/api/test/Notifications.test.ts b/apps/api/test/Notifications.test.ts index 181a2ea..c79fa63 100644 --- a/apps/api/test/Notifications.test.ts +++ b/apps/api/test/Notifications.test.ts @@ -160,6 +160,12 @@ describe("platform service integration", () => { payload: { status: EntryStatus.Approved } }); } + await context.application.inject({ + method: "PATCH", + url: `/api/v1/rounds/${round.id}`, + headers: supervisor.headers, + payload: { title: "Contract Renamed" } + }); await context.application.inject({ method: "PATCH", url: `/api/v1/rounds/${round.id}`, @@ -217,6 +223,7 @@ describe("platform service integration", () => { } for (const expected of [ NotificationType.RoundCreated, + NotificationType.RoundUpdated, NotificationType.EntrySubmitted, NotificationType.RoundStatusChanged, NotificationType.BallotSubmitted, diff --git a/apps/discord/src/Views/NotificationViews.ts b/apps/discord/src/Views/NotificationViews.ts index 2716dd2..223abed 100644 --- a/apps/discord/src/Views/NotificationViews.ts +++ b/apps/discord/src/Views/NotificationViews.ts @@ -162,8 +162,18 @@ export function renderNotification(notification: PlatformNotification, context: `-# Created by ${person(notification.actor)}` ) ]; - case NotificationType.RoundStatusChanged: + case NotificationType.RoundUpdated: return [ + post( + telemetry, + Accent.Info, + `**Round updated: ${plain(notification.round.title)}**`, + `${notification.round.pollType === PollType.Binary ? "Binary" : "Ranked choice"}, ${describeSchedule(notification.opensAt, notification.closesAt)}`, + `-# Updated by ${person(notification.actor)}` + ) + ]; + case NotificationType.RoundStatusChanged: { + const posts: RenderedNotification[] = [ post( telemetry, notification.to === RoundStatus.Open ? Accent.Success : null, @@ -171,6 +181,19 @@ export function renderNotification(notification: PlatformNotification, context: `-# By ${person(notification.actor)}` ) ]; + if (notification.to === RoundStatus.Open) { + posts.push( + post( + ChannelPurpose.Announcements, + Accent.Success, + `## Voting is now open for ${plain(notification.round.title)}!`, + `Cast your vote on the platform: ${notification.round.pollType === PollType.Binary ? "Binary vote" : "Ranked choice vote"}.`, + `-# Opened by ${person(notification.actor)}` + ) + ); + } + return posts; + } case NotificationType.RoundFinalized: return [ post( diff --git a/apps/discord/test/NotificationViews.test.ts b/apps/discord/test/NotificationViews.test.ts index 629d1f7..9857cf1 100644 --- a/apps/discord/test/NotificationViews.test.ts +++ b/apps/discord/test/NotificationViews.test.ts @@ -187,6 +187,7 @@ describe("every notification", () => { quarantined: false }, { type: NotificationType.RoundCreated, occurredAt, round, actor: admin, opensAt: null, closesAt: occurredAt }, + { type: NotificationType.RoundUpdated, occurredAt, round, actor: admin, opensAt: null, closesAt: occurredAt }, { type: NotificationType.RoundStatusChanged, occurredAt, round, from: RoundStatus.Closed, to: RoundStatus.Open, actor: admin }, { type: NotificationType.RoundFinalized, @@ -271,11 +272,21 @@ describe("every notification", () => { }); it("announces winners publicly", () => { - const [rendered] = renderNotification(samples[8] as PlatformNotification, context); + const sample = samples.find((s) => s.type === NotificationType.RoundFinalized)!; + const [rendered] = renderNotification(sample, context); expect(rendered?.purpose).toBe(ChannelPurpose.Announcements); expect(texts(rendered?.message as V2Message).join("\n")).toContain("**Option Alpha** wins with 62.5% of the vote."); }); + it("announces when a round is opened for voting", () => { + const sample = samples.find((s) => s.type === NotificationType.RoundStatusChanged)!; + const rendered = renderNotification(sample, context); + expect(rendered).toHaveLength(2); + const announcement = rendered.find((r) => r.purpose === ChannelPurpose.Announcements); + expect(announcement).toBeDefined(); + expect(texts(announcement!.message).join("\n")).toContain("## Voting is now open for Binary Test Round!"); + }); + it("announces phase unlocks and step progress to announcements channel", () => { const phaseUnlock = only({ type: NotificationType.PipelineUpdated, diff --git a/packages/contracts/src/Notifications.ts b/packages/contracts/src/Notifications.ts index 08c544f..e93c2ac 100644 --- a/packages/contracts/src/Notifications.ts +++ b/packages/contracts/src/Notifications.ts @@ -10,6 +10,7 @@ export enum NotificationType { ContributorPromoted = "contributor.promoted", RaidAlert = "raid.alert", RoundCreated = "round.created", + RoundUpdated = "round.updated", RoundStatusChanged = "round.status_changed", RoundFinalized = "round.finalized", EntrySubmitted = "entry.submitted", @@ -85,6 +86,12 @@ export const platformNotificationSchema = z.discriminatedUnion("type", [ opensAt: timestampSchema.nullable(), closesAt: timestampSchema.nullable() }), + notification(NotificationType.RoundUpdated, { + round: roundReference, + actor: notificationPersonSchema, + opensAt: timestampSchema.nullable(), + closesAt: timestampSchema.nullable() + }), notification(NotificationType.RoundStatusChanged, { round: roundReference, from: z.enum(RoundStatus), From 1a5d9acd0efbec69aa5843c7e975399e0aaa3206 Mon Sep 17 00:00:00 2001 From: Yierke Date: Sun, 27 Sep 2026 17:22:58 +0530 Subject: [PATCH 09/88] fix(storage): remove unused property modifier in LocalStorage constructor --- apps/api/src/Infrastructure/Storage/LocalStorage.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/src/Infrastructure/Storage/LocalStorage.ts b/apps/api/src/Infrastructure/Storage/LocalStorage.ts index c5ea13c..0483d99 100644 --- a/apps/api/src/Infrastructure/Storage/LocalStorage.ts +++ b/apps/api/src/Infrastructure/Storage/LocalStorage.ts @@ -21,7 +21,7 @@ export class LocalMediaStorage implements ObjectStorage { private readonly baseUrl: string; public constructor( - private readonly configuration: StorageConfiguration, + configuration: StorageConfiguration, appUrl = "" ) { this.s3Storage = new S3ObjectStorage(configuration); From c57e1315378d532b766d033cf9dd3a799a98af6b Mon Sep 17 00:00:00 2001 From: Yierke Date: Sun, 27 Sep 2026 17:25:20 +0530 Subject: [PATCH 10/88] build(docker): use standard npm ci for build compatibility --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 06e8aee..1a7c815 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ COPY apps/api/package.json apps/api/ COPY apps/discord/package.json apps/discord/ FROM base AS build -RUN --mount=type=cache,target=/root/.npm npm ci --ignore-scripts +RUN npm ci --ignore-scripts COPY tsconfig.base.json tsconfig.json ./ COPY packages/scoring/tsconfig.json packages/scoring/ COPY packages/scoring/src packages/scoring/src @@ -28,10 +28,10 @@ COPY apps/discord/src apps/discord/src RUN npx tsc -b FROM base AS api-dependencies -RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --ignore-scripts --workspace @platform/api +RUN npm ci --omit=dev --ignore-scripts --workspace @platform/api FROM base AS discord-dependencies -RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --ignore-scripts --workspace @platform/discord +RUN npm ci --omit=dev --ignore-scripts --workspace @platform/discord FROM ${NODE_IMAGE} AS runtime RUN apk upgrade --no-cache \ From 7913ad56fa8b0523d93958f2d68e1ec1b679a285 Mon Sep 17 00:00:00 2001 From: Yierke Date: Sun, 27 Sep 2026 17:27:37 +0530 Subject: [PATCH 11/88] fix(storage): fallback to /tmp/storage/media for read-only containers --- apps/api/src/Infrastructure/Storage/LocalStorage.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/api/src/Infrastructure/Storage/LocalStorage.ts b/apps/api/src/Infrastructure/Storage/LocalStorage.ts index 0483d99..735b2ee 100644 --- a/apps/api/src/Infrastructure/Storage/LocalStorage.ts +++ b/apps/api/src/Infrastructure/Storage/LocalStorage.ts @@ -25,9 +25,13 @@ export class LocalMediaStorage implements ObjectStorage { appUrl = "" ) { this.s3Storage = new S3ObjectStorage(configuration); - this.localDir = resolve(process.cwd(), "storage", "media"); - if (!existsSync(this.localDir)) { - mkdirSync(this.localDir, { recursive: true }); + this.localDir = process.env.STORAGE_MEDIA_DIR || resolve("/tmp", "storage", "media"); + try { + if (!existsSync(this.localDir)) { + mkdirSync(this.localDir, { recursive: true }); + } + } catch { + // ignore if read-only during initialization } this.baseUrl = (configuration.mediaPublicUrl || appUrl).replace(/\/+$/u, ""); } From d27d2f3330ab5f1e36e4e25bef2f82cecd206b87 Mon Sep 17 00:00:00 2001 From: Lunasa Date: Sun, 27 Sep 2026 17:48:18 +0530 Subject: [PATCH 12/88] ci: fix branch name in workflow --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 643927b..2cf4a35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [master] pull_request: - branches: [main] + branches: [master] workflow_dispatch: permissions: From ca3d4d74dc3634c1585a483dece231717b916316 Mon Sep 17 00:00:00 2001 From: Lunasa Date: Sun, 27 Sep 2026 18:00:58 +0530 Subject: [PATCH 13/88] fix: harden storage, CORS and task threads; persist pipeline in db --- ...0001_pipeline_progress_and_specialties.sql | 33 + apps/api/drizzle/meta/0001_snapshot.json | 1622 +++++++++++++++++ apps/api/drizzle/meta/_journal.json | 7 + apps/api/src/App.ts | 23 +- .../Composition/ProductionInfrastructure.ts | 4 +- .../api/src/Infrastructure/Database/Schema.ts | 21 + .../Infrastructure/Storage/LocalStorage.ts | 122 -- apps/api/src/Modules/Auth/AuthService.ts | 7 +- apps/api/src/Modules/Entries/EntriesRoutes.ts | 7 +- .../api/src/Modules/Entries/EntriesService.ts | 1 + .../src/Modules/Pipeline/PipelineRoutes.ts | 58 +- apps/api/src/Modules/Shots/ReviewsService.ts | 2 - apps/api/src/Modules/Shots/ShotsService.ts | 2 - apps/api/src/Modules/Uploads/UploadsRoutes.ts | 35 - apps/api/test/Notifications.test.ts | 5 + apps/discord/src/Api/PlatformApiClient.ts | 7 +- .../src/Commands/ModerationCommands.ts | 7 +- apps/discord/src/Commands/SetupCommands.ts | 2 +- apps/discord/src/Discord/Ui.ts | 5 +- .../src/Services/NotificationDispatcher.ts | 46 +- apps/discord/src/Services/StudioRoles.ts | 7 +- apps/discord/src/Services/TaskForum.ts | 42 +- apps/discord/src/Views/NotificationViews.ts | 54 +- apps/discord/src/Views/TaskViews.ts | 13 +- apps/discord/test/NotificationViews.test.ts | 64 +- packages/contracts/src/Enums.ts | 34 +- packages/contracts/src/Notifications.ts | 8 +- 27 files changed, 1821 insertions(+), 417 deletions(-) create mode 100644 apps/api/drizzle/0001_pipeline_progress_and_specialties.sql create mode 100644 apps/api/drizzle/meta/0001_snapshot.json delete mode 100644 apps/api/src/Infrastructure/Storage/LocalStorage.ts diff --git a/apps/api/drizzle/0001_pipeline_progress_and_specialties.sql b/apps/api/drizzle/0001_pipeline_progress_and_specialties.sql new file mode 100644 index 0000000..d9ddcc7 --- /dev/null +++ b/apps/api/drizzle/0001_pipeline_progress_and_specialties.sql @@ -0,0 +1,33 @@ +ALTER TYPE "public"."user_specialty" ADD VALUE 'general_contributor';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'producer';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'creative_director';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'production_manager';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'technical_director';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'art_director';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'editorial_supervisor';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'layout_previs_lead';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'modelling_supervisor';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'rigging_supervisor';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'surfacing_lookdev_lead';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'animation_supervisor';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'cfx_vfx_supervisor';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'lighting_compositing_supervisor';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'sound_director';--> statement-breakpoint +ALTER TYPE "public"."user_specialty" ADD VALUE 'voter';--> statement-breakpoint +CREATE TABLE "pipeline_progress" ( + "id" integer PRIMARY KEY DEFAULT 1 NOT NULL, + "step_index" integer NOT NULL, + "step_id" varchar(32) NOT NULL, + "step_title" varchar(128) NOT NULL, + "phase_number" integer NOT NULL, + "phase_title" varchar(128) NOT NULL, + "progress_percent" double precision NOT NULL, + "is_phase_transition" boolean NOT NULL, + "updated_by" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "pipeline_progress_singleton" CHECK ("pipeline_progress"."id" = 1), + CONSTRAINT "pipeline_progress_percent_range" CHECK ("pipeline_progress"."progress_percent" BETWEEN 0 AND 100) +); +--> statement-breakpoint +ALTER TABLE "pipeline_progress" ADD CONSTRAINT "pipeline_progress_updated_by_users_id_fk" FOREIGN KEY ("updated_by") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/apps/api/drizzle/meta/0001_snapshot.json b/apps/api/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..aa31dde --- /dev/null +++ b/apps/api/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1622 @@ +{ + "id": "b153c180-7b67-40e1-b8a0-0cf6c5dd34aa", + "prevId": "8a027183-fb76-4c38-932b-d131b6a615c5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_tokens": { + "name": "access_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "access_tokens_user_id_idx": { + "name": "access_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_tokens_expires_at_idx": { + "name": "access_tokens_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_tokens_user_id_users_id_fk": { + "name": "access_tokens_user_id_users_id_fk", + "tableFrom": "access_tokens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "access_tokens_token_hash_unique": { + "name": "access_tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ballots": { + "name": "ballots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "round_id": { + "name": "round_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "voter_id": { + "name": "voter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rank1_entry_id": { + "name": "rank1_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "rank2_entry_id": { + "name": "rank2_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "rank3_entry_id": { + "name": "rank3_entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ballots_round_created_at_idx": { + "name": "ballots_round_created_at_idx", + "columns": [ + { + "expression": "round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ballots_rank1_idx": { + "name": "ballots_rank1_idx", + "columns": [ + { + "expression": "rank1_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ballots_rank2_idx": { + "name": "ballots_rank2_idx", + "columns": [ + { + "expression": "rank2_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ballots_rank3_idx": { + "name": "ballots_rank3_idx", + "columns": [ + { + "expression": "rank3_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ballots_round_id_voting_rounds_id_fk": { + "name": "ballots_round_id_voting_rounds_id_fk", + "tableFrom": "ballots", + "tableTo": "voting_rounds", + "columnsFrom": [ + "round_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ballots_voter_id_users_id_fk": { + "name": "ballots_voter_id_users_id_fk", + "tableFrom": "ballots", + "tableTo": "users", + "columnsFrom": [ + "voter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ballots_rank1_entry_id_entries_id_fk": { + "name": "ballots_rank1_entry_id_entries_id_fk", + "tableFrom": "ballots", + "tableTo": "entries", + "columnsFrom": [ + "rank1_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ballots_rank2_entry_id_entries_id_fk": { + "name": "ballots_rank2_entry_id_entries_id_fk", + "tableFrom": "ballots", + "tableTo": "entries", + "columnsFrom": [ + "rank2_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "ballots_rank3_entry_id_entries_id_fk": { + "name": "ballots_rank3_entry_id_entries_id_fk", + "tableFrom": "ballots", + "tableTo": "entries", + "columnsFrom": [ + "rank3_entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ballots_round_voter_unique": { + "name": "ballots_round_voter_unique", + "nullsNotDistinct": false, + "columns": [ + "round_id", + "voter_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "ballots_distinct_picks": { + "name": "ballots_distinct_picks", + "value": "\"ballots\".\"rank1_entry_id\" IS DISTINCT FROM \"ballots\".\"rank2_entry_id\" AND \"ballots\".\"rank1_entry_id\" IS DISTINCT FROM \"ballots\".\"rank3_entry_id\" AND (\"ballots\".\"rank2_entry_id\" IS NULL OR \"ballots\".\"rank2_entry_id\" IS DISTINCT FROM \"ballots\".\"rank3_entry_id\")" + }, + "ballots_pick_shape": { + "name": "ballots_pick_shape", + "value": "(\"ballots\".\"rank2_entry_id\" IS NULL) = (\"ballots\".\"rank3_entry_id\" IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.entries": { + "name": "entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "round_id": { + "name": "round_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1500)", + "primaryKey": false, + "notNull": false + }, + "submitted_by": { + "name": "submitted_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "entry_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_review'" + }, + "media_key": { + "name": "media_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "is_quarantined": { + "name": "is_quarantined", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "entries_round_status_idx": { + "name": "entries_round_status_idx", + "columns": [ + { + "expression": "round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entries_round_id_voting_rounds_id_fk": { + "name": "entries_round_id_voting_rounds_id_fk", + "tableFrom": "entries", + "tableTo": "voting_rounds", + "columnsFrom": [ + "round_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "entries_submitted_by_users_id_fk": { + "name": "entries_submitted_by_users_id_fk", + "tableFrom": "entries", + "tableTo": "users", + "columnsFrom": [ + "submitted_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pipeline_progress": { + "name": "pipeline_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": 1 + }, + "step_index": { + "name": "step_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "step_title": { + "name": "step_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "phase_number": { + "name": "phase_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "phase_title": { + "name": "phase_title", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "is_phase_transition": { + "name": "is_phase_transition", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pipeline_progress_updated_by_users_id_fk": { + "name": "pipeline_progress_updated_by_users_id_fk", + "tableFrom": "pipeline_progress", + "tableTo": "users", + "columnsFrom": [ + "updated_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "pipeline_progress_singleton": { + "name": "pipeline_progress_singleton", + "value": "\"pipeline_progress\".\"id\" = 1" + }, + "pipeline_progress_percent_range": { + "name": "pipeline_progress_percent_range", + "value": "\"pipeline_progress\".\"progress_percent\" BETWEEN 0 AND 100" + } + }, + "isRLSEnabled": false + }, + "public.raid_telemetry": { + "name": "raid_telemetry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "entry_id": { + "name": "entry_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "round_id": { + "name": "round_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "composite_score": { + "name": "composite_score", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "severity": { + "name": "severity", + "type": "raid_severity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "skew_ratio": { + "name": "skew_ratio", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "rank_entropy": { + "name": "rank_entropy", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "velocity_z_score": { + "name": "velocity_z_score", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "flags": { + "name": "flags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "breakdown": { + "name": "breakdown", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "raid_telemetry_round_entry_created_idx": { + "name": "raid_telemetry_round_entry_created_idx", + "columns": [ + { + "expression": "round_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "raid_telemetry_entry_id_entries_id_fk": { + "name": "raid_telemetry_entry_id_entries_id_fk", + "tableFrom": "raid_telemetry", + "tableTo": "entries", + "columnsFrom": [ + "entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "raid_telemetry_round_id_voting_rounds_id_fk": { + "name": "raid_telemetry_round_id_voting_rounds_id_fk", + "tableFrom": "raid_telemetry", + "tableTo": "voting_rounds", + "columnsFrom": [ + "round_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.round_results": { + "name": "round_results", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "round_id": { + "name": "round_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "total_ballots": { + "name": "total_ballots", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_points": { + "name": "total_points", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_conserved": { + "name": "is_conserved", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "leaderboard": { + "name": "leaderboard", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "separation_results": { + "name": "separation_results", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "finalized_at": { + "name": "finalized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finalized_by": { + "name": "finalized_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "round_results_round_id_voting_rounds_id_fk": { + "name": "round_results_round_id_voting_rounds_id_fk", + "tableFrom": "round_results", + "tableTo": "voting_rounds", + "columnsFrom": [ + "round_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "round_results_finalized_by_users_id_fk": { + "name": "round_results_finalized_by_users_id_fk", + "tableFrom": "round_results", + "tableTo": "users", + "columnsFrom": [ + "finalized_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "round_results_round_id_unique": { + "name": "round_results_round_id_unique", + "nullsNotDistinct": false, + "columns": [ + "round_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shot_thread_maps": { + "name": "shot_thread_maps", + "schema": "", + "columns": { + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "discord_thread_id": { + "name": "discord_thread_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shot_thread_maps_shot_id_shots_id_fk": { + "name": "shot_thread_maps_shot_id_shots_id_fk", + "tableFrom": "shot_thread_maps", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shot_thread_maps_discord_thread_id_unique": { + "name": "shot_thread_maps_discord_thread_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_thread_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shots": { + "name": "shots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "round_id": { + "name": "round_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "scene_number": { + "name": "scene_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "shot_code": { + "name": "shot_code", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(5000)", + "primaryKey": false, + "notNull": false + }, + "difficulty_tier": { + "name": "difficulty_tier", + "type": "difficulty_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "shot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'available'" + }, + "claimed_by": { + "name": "claimed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deadline_at": { + "name": "deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "senior_priority_until": { + "name": "senior_priority_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shots_listing_idx": { + "name": "shots_listing_idx", + "columns": [ + { + "expression": "scene_number", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "shot_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_status_idx": { + "name": "shots_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_deadline_idx": { + "name": "shots_deadline_idx", + "columns": [ + { + "expression": "deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"shots\".\"status\" = 'claimed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "shots_one_active_claim_per_user": { + "name": "shots_one_active_claim_per_user", + "columns": [ + { + "expression": "claimed_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"shots\".\"status\" IN ('claimed', 'submitted')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shots_round_id_voting_rounds_id_fk": { + "name": "shots_round_id_voting_rounds_id_fk", + "tableFrom": "shots", + "tableTo": "voting_rounds", + "columnsFrom": [ + "round_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "shots_claimed_by_users_id_fk": { + "name": "shots_claimed_by_users_id_fk", + "tableFrom": "shots", + "tableTo": "users", + "columnsFrom": [ + "claimed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "shots_shot_code_unique": { + "name": "shots_shot_code_unique", + "nullsNotDistinct": false, + "columns": [ + "shot_code" + ] + } + }, + "policies": {}, + "checkConstraints": { + "shots_scene_number_positive": { + "name": "shots_scene_number_positive", + "value": "\"shots\".\"scene_number\" > 0" + }, + "shots_active_claim_consistency": { + "name": "shots_active_claim_consistency", + "value": "\"shots\".\"status\" NOT IN ('claimed', 'submitted') OR (\"shots\".\"claimed_by\" IS NOT NULL AND \"shots\".\"claimed_at\" IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.submissions": { + "name": "submissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "shot_id": { + "name": "shot_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "contributor_id": { + "name": "contributor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "video_key": { + "name": "video_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": true + }, + "blend_key": { + "name": "blend_key", + "type": "varchar(512)", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "submission_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending_review'" + }, + "supervisor_notes": { + "name": "supervisor_notes", + "type": "varchar(2000)", + "primaryKey": false, + "notNull": false + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "submissions_status_created_idx": { + "name": "submissions_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submissions_contributor_idx": { + "name": "submissions_contributor_idx", + "columns": [ + { + "expression": "contributor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submissions_shot_id_shots_id_fk": { + "name": "submissions_shot_id_shots_id_fk", + "tableFrom": "submissions", + "tableTo": "shots", + "columnsFrom": [ + "shot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submissions_contributor_id_users_id_fk": { + "name": "submissions_contributor_id_users_id_fk", + "tableFrom": "submissions", + "tableTo": "users", + "columnsFrom": [ + "contributor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submissions_reviewed_by_users_id_fk": { + "name": "submissions_reviewed_by_users_id_fk", + "tableFrom": "submissions", + "tableTo": "users", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "submissions_shot_version_unique": { + "name": "submissions_shot_version_unique", + "nullsNotDistinct": false, + "columns": [ + "shot_id", + "version" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "discord_id": { + "name": "discord_id", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "discord_username": { + "name": "discord_username", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "discord_avatar": { + "name": "discord_avatar", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'voter'" + }, + "specialties": { + "name": "specialties", + "type": "user_specialty[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "is_blacklisted": { + "name": "is_blacklisted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "blacklist_reason": { + "name": "blacklist_reason", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "blacklisted_at": { + "name": "blacklisted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_created_at_idx": { + "name": "users_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_discord_id_unique": { + "name": "users_discord_id_unique", + "nullsNotDistinct": false, + "columns": [ + "discord_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "users_specialties_limit": { + "name": "users_specialties_limit", + "value": "cardinality(\"users\".\"specialties\") <= 2" + } + }, + "isRLSEnabled": false + }, + "public.voting_rounds": { + "name": "voting_rounds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "round_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "poll_type": { + "name": "poll_type", + "type": "poll_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ranked_choice'" + }, + "opens_at": { + "name": "opens_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closes_at": { + "name": "closes_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "voting_rounds_status_created_at_idx": { + "name": "voting_rounds_status_created_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "voting_rounds_created_by_users_id_fk": { + "name": "voting_rounds_created_by_users_id_fk", + "tableFrom": "voting_rounds", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "voting_rounds_window": { + "name": "voting_rounds_window", + "value": "\"voting_rounds\".\"opens_at\" IS NULL OR \"voting_rounds\".\"closes_at\" IS NULL OR \"voting_rounds\".\"closes_at\" > \"voting_rounds\".\"opens_at\"" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.difficulty_tier": { + "name": "difficulty_tier", + "schema": "public", + "values": [ + "easy", + "medium", + "hard", + "complex" + ] + }, + "public.entry_status": { + "name": "entry_status", + "schema": "public", + "values": [ + "pending_review", + "approved", + "rejected", + "flagged" + ] + }, + "public.poll_type": { + "name": "poll_type", + "schema": "public", + "values": [ + "ranked_choice", + "binary" + ] + }, + "public.raid_severity": { + "name": "raid_severity", + "schema": "public", + "values": [ + "NORMAL", + "SUSPICIOUS", + "CRITICAL_RAID" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "voter", + "contributor", + "senior_contributor", + "moderator", + "supervisor", + "admin" + ] + }, + "public.round_status": { + "name": "round_status", + "schema": "public", + "values": [ + "draft", + "open", + "closed", + "finalized" + ] + }, + "public.shot_status": { + "name": "shot_status", + "schema": "public", + "values": [ + "available", + "claimed", + "submitted", + "approved" + ] + }, + "public.user_specialty": { + "name": "user_specialty", + "schema": "public", + "values": [ + "animator", + "layout_artist", + "3d_modeler", + "rigger", + "surface_texture_artist", + "lighting_artist", + "vfx_artist", + "concept_artist", + "screenwriter", + "voice_actor", + "sound_designer", + "video_editor", + "general_contributor", + "producer", + "creative_director", + "production_manager", + "technical_director", + "art_director", + "editorial_supervisor", + "layout_previs_lead", + "modelling_supervisor", + "rigging_supervisor", + "surfacing_lookdev_lead", + "animation_supervisor", + "cfx_vfx_supervisor", + "lighting_compositing_supervisor", + "sound_director", + "voter" + ] + }, + "public.submission_status": { + "name": "submission_status", + "schema": "public", + "values": [ + "pending_review", + "revision_requested", + "approved" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index ed9cd0c..d405c26 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1790449811057, "tag": "0000_initial_schema", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1790511795849, + "tag": "0001_pipeline_progress_and_specialties", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/App.ts b/apps/api/src/App.ts index 10454d5..25d1d65 100644 --- a/apps/api/src/App.ts +++ b/apps/api/src/App.ts @@ -108,13 +108,6 @@ export async function buildApplication(services: ServiceContainer, logger: Fasti application.setValidatorCompiler(validatorCompiler); application.setSerializerCompiler(serializerCompiler); application.removeContentTypeParser("text/plain"); - application.addContentTypeParser( - ["image/png", "image/jpeg", "image/gif", "image/webp", "video/mp4", "video/webm", "video/quicktime", "application/octet-stream"], - { parseAs: "buffer", bodyLimit: 10 * 1024 * 1024 }, - (_req, body, done) => { - done(null, body); - } - ); application.decorateRequest("principal", null); application.addHook("onRequest", async (request, reply) => { @@ -152,21 +145,7 @@ export async function buildApplication(services: ServiceContainer, logger: Fasti }); await application.register(cors, { - origin: (origin, callback) => { - if (!origin) { - callback(null, true); - return; - } - try { - const url = new URL(origin); - const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1"; - if (configuration.security.corsOrigins.includes(origin) || isLocal) { - callback(null, true); - return; - } - } catch {} - callback(new Error("CORS origin not allowed"), false); - }, + origin: [...configuration.security.corsOrigins], methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"], allowedHeaders: ["Authorization", "Content-Type", "X-Request-Id"], exposedHeaders: ["X-Request-Id", "RateLimit-Limit", "RateLimit-Remaining", "RateLimit-Reset", "Retry-After"], diff --git a/apps/api/src/Composition/ProductionInfrastructure.ts b/apps/api/src/Composition/ProductionInfrastructure.ts index e7f78ed..738b16c 100644 --- a/apps/api/src/Composition/ProductionInfrastructure.ts +++ b/apps/api/src/Composition/ProductionInfrastructure.ts @@ -8,7 +8,7 @@ import { LanyardPresenceProvider } from "../Infrastructure/Discord/PresenceProvi import { RedisEventBus } from "../Infrastructure/Events/RedisEventBus.js"; import { StreamNotifier } from "../Infrastructure/Notifications/NotificationLog.js"; import { RedisNotificationLog } from "../Infrastructure/Notifications/RedisNotificationLog.js"; -import { LocalMediaStorage } from "../Infrastructure/Storage/LocalStorage.js"; +import { S3ObjectStorage } from "../Infrastructure/Storage/S3ObjectStorage.js"; import type { Infrastructure } from "./ServiceContainer.js"; export function createProductionInfrastructure(configuration: ApplicationConfiguration, logger: FastifyBaseLogger): Infrastructure { @@ -46,7 +46,7 @@ export function createProductionInfrastructure(configuration: ApplicationConfigu databasePing: () => connection.ping(), keyValueStore, eventBus, - objectStorage: new LocalMediaStorage(configuration.storage, configuration.security.appUrl), + objectStorage: new S3ObjectStorage(configuration.storage), discordOAuth: new HttpDiscordOAuthClient(configuration.discord), presenceProvider: new LanyardPresenceProvider(keyValueStore), notifier: new StreamNotifier(notificationLog, logger), diff --git a/apps/api/src/Infrastructure/Database/Schema.ts b/apps/api/src/Infrastructure/Database/Schema.ts index 4419017..48954f7 100644 --- a/apps/api/src/Infrastructure/Database/Schema.ts +++ b/apps/api/src/Infrastructure/Database/Schema.ts @@ -257,6 +257,26 @@ export const shotThreadMaps = pgTable("shot_thread_maps", { ...timestamps }); +export const pipelineProgress = pgTable( + "pipeline_progress", + { + id: integer("id").primaryKey().default(1), + stepIndex: integer("step_index").notNull(), + stepId: varchar("step_id", { length: 32 }).notNull(), + stepTitle: varchar("step_title", { length: 128 }).notNull(), + phaseNumber: integer("phase_number").notNull(), + phaseTitle: varchar("phase_title", { length: 128 }).notNull(), + progressPercent: doublePrecision("progress_percent").notNull(), + isPhaseTransition: boolean("is_phase_transition").notNull(), + updatedBy: uuid("updated_by").references(() => users.id, { onDelete: "set null" }), + ...timestamps + }, + (table) => [ + check("pipeline_progress_singleton", sql`${table.id} = 1`), + check("pipeline_progress_percent_range", sql`${table.progressPercent} BETWEEN 0 AND 100`) + ] +); + export type UserRecord = typeof users.$inferSelect; export type VotingRoundRecord = typeof votingRounds.$inferSelect; export type EntryRecord = typeof entries.$inferSelect; @@ -266,3 +286,4 @@ export type RoundResultRecord = typeof roundResults.$inferSelect; export type ShotRecord = typeof shots.$inferSelect; export type SubmissionRecord = typeof submissions.$inferSelect; export type ShotThreadMapRecord = typeof shotThreadMaps.$inferSelect; +export type PipelineProgressRecord = typeof pipelineProgress.$inferSelect; diff --git a/apps/api/src/Infrastructure/Storage/LocalStorage.ts b/apps/api/src/Infrastructure/Storage/LocalStorage.ts deleted file mode 100644 index 735b2ee..0000000 --- a/apps/api/src/Infrastructure/Storage/LocalStorage.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; -import { dirname, extname, resolve } from "node:path"; -import type { StorageConfiguration } from "../../Configuration/ApplicationConfiguration.js"; -import { StorageBucket, type ObjectStorage, type PresignedUpload, type StoredObjectMetadata } from "./ObjectStorage.js"; -import { S3ObjectStorage } from "./S3ObjectStorage.js"; - -const extensionMimeTypes: Readonly> = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".mp4": "video/mp4", - ".webm": "video/webm", - ".mov": "video/quicktime" -}; - -export class LocalMediaStorage implements ObjectStorage { - private readonly s3Storage: S3ObjectStorage; - private readonly localDir: string; - private readonly baseUrl: string; - - public constructor( - configuration: StorageConfiguration, - appUrl = "" - ) { - this.s3Storage = new S3ObjectStorage(configuration); - this.localDir = process.env.STORAGE_MEDIA_DIR || resolve("/tmp", "storage", "media"); - try { - if (!existsSync(this.localDir)) { - mkdirSync(this.localDir, { recursive: true }); - } - } catch { - // ignore if read-only during initialization - } - this.baseUrl = (configuration.mediaPublicUrl || appUrl).replace(/\/+$/u, ""); - } - - public isEnabled(bucket: StorageBucket): boolean { - if (bucket === StorageBucket.Media) { - return true; - } - return this.s3Storage.isEnabled(bucket); - } - - public async createUpload(bucket: StorageBucket, key: string, contentType: string, sizeBytes: number): Promise { - if (bucket === StorageBucket.Media) { - const uploadUrl = `${this.baseUrl}/api/v1/uploads/media/file/${encodeURIComponent(key)}`; - return { - method: "PUT", - url: uploadUrl, - headers: { "Content-Type": contentType, "Content-Length": String(sizeBytes) }, - key, - expiresAt: new Date(Date.now() + 15 * 60 * 1000) - }; - } - return this.s3Storage.createUpload(bucket, key, contentType, sizeBytes); - } - - public async getMetadata(bucket: StorageBucket, key: string): Promise { - if (bucket === StorageBucket.Media) { - const target = this.resolvePath(key); - if (target === null || !existsSync(target)) { - return null; - } - try { - const stat = statSync(target); - const ext = extname(target).toLowerCase(); - return { - sizeBytes: stat.size, - contentType: extensionMimeTypes[ext] || "application/octet-stream" - }; - } catch { - return null; - } - } - return this.s3Storage.getMetadata(bucket, key); - } - - public async createDownloadUrl(bucket: StorageBucket, key: string): Promise { - if (bucket === StorageBucket.Media) { - return `${this.baseUrl}/api/v1/uploads/media/file/${encodeURIComponent(key)}`; - } - return this.s3Storage.createDownloadUrl(bucket, key); - } - - public getPublicUrl(bucket: StorageBucket, key: string): string | null { - if (bucket === StorageBucket.Media) { - return `${this.baseUrl}/api/v1/uploads/media/file/${encodeURIComponent(key)}`; - } - return this.s3Storage.getPublicUrl(bucket, key); - } - - public saveMedia(key: string, buffer: Buffer): void { - const target = this.resolvePath(key); - if (target === null) { - throw new Error("Invalid media storage path"); - } - mkdirSync(dirname(target), { recursive: true }); - writeFileSync(target, buffer); - } - - public readMedia(key: string): { buffer: Buffer; contentType: string } | null { - const target = this.resolvePath(key); - if (target === null || !existsSync(target)) { - return null; - } - const ext = extname(target).toLowerCase(); - return { - buffer: readFileSync(target), - contentType: extensionMimeTypes[ext] || "application/octet-stream" - }; - } - - public resolvePath(key: string): string | null { - const sanitized = key.replace(/\\/gu, "/"); - if (sanitized.includes("..")) { - return null; - } - return resolve(this.localDir, sanitized); - } -} diff --git a/apps/api/src/Modules/Auth/AuthService.ts b/apps/api/src/Modules/Auth/AuthService.ts index 559579a..6d33ba9 100644 --- a/apps/api/src/Modules/Auth/AuthService.ts +++ b/apps/api/src/Modules/Auth/AuthService.ts @@ -161,8 +161,7 @@ export class AuthService { try { const isRelative = returnTo.startsWith("/") && !returnTo.startsWith("//") && !returnTo.startsWith("/\\"); const url = isRelative ? new URL(returnTo, fallback.origin) : new URL(returnTo); - const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1"; - const allowed = this.security.corsOrigins.includes(url.origin) || isLocal; + const allowed = this.security.corsOrigins.includes(url.origin); if (!allowed || url.username !== "" || url.password !== "" || !safePathPattern.test(url.pathname)) { return fallback; } @@ -200,9 +199,7 @@ export class AuthService { typeof parsed.origin === "string" && typeof parsed.path === "string" && typeof parsed.codeChallenge === "string" && - (this.security.corsOrigins.includes(parsed.origin) || - new URL(parsed.origin).hostname === "localhost" || - new URL(parsed.origin).hostname === "127.0.0.1") && + this.security.corsOrigins.includes(parsed.origin) && safePathPattern.test(parsed.path) && codeChallengePattern.test(parsed.codeChallenge) ) { diff --git a/apps/api/src/Modules/Entries/EntriesRoutes.ts b/apps/api/src/Modules/Entries/EntriesRoutes.ts index de5d3e3..7d1925c 100644 --- a/apps/api/src/Modules/Entries/EntriesRoutes.ts +++ b/apps/api/src/Modules/Entries/EntriesRoutes.ts @@ -21,10 +21,9 @@ const roundParams = z.object({ roundId: uuidSchema }); const entryParams = z.object({ roundId: uuidSchema, entryId: uuidSchema }); const mediaKeySchema = z.string().max(255); -const pitchSchema = trimmedText(255).refine( - (text) => text.trim().split(/\s+/u).filter(Boolean).length <= 30, - { message: "Pitch must not exceed 30 words." } -); +const pitchSchema = trimmedText(255).refine((text) => text.trim().split(/\s+/u).filter(Boolean).length <= 30, { + message: "Pitch must not exceed 30 words." +}); const descriptionSchema = trimmedText(250).nullable(); export const entriesRoutes: FastifyPluginAsyncZod<{ services: ServiceContainer }> = async (application, { services }) => { diff --git a/apps/api/src/Modules/Entries/EntriesService.ts b/apps/api/src/Modules/Entries/EntriesService.ts index 607754c..f625e1a 100644 --- a/apps/api/src/Modules/Entries/EntriesService.ts +++ b/apps/api/src/Modules/Entries/EntriesService.ts @@ -116,6 +116,7 @@ export class EntriesService { }); const mediaUrl = entry.mediaKey === null ? null : this.storage.getPublicUrl(StorageBucket.Media, entry.mediaKey); + this.notifier.notify({ type: NotificationType.EntrySubmitted, round: roundReferenceOf(round), diff --git a/apps/api/src/Modules/Pipeline/PipelineRoutes.ts b/apps/api/src/Modules/Pipeline/PipelineRoutes.ts index fd08a78..768c886 100644 --- a/apps/api/src/Modules/Pipeline/PipelineRoutes.ts +++ b/apps/api/src/Modules/Pipeline/PipelineRoutes.ts @@ -2,14 +2,12 @@ import { NotificationType, pipelineProgressSchema } from "@platform/contracts"; import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"; import { z } from "zod"; import { dataEnvelope, errorResponses } from "../../Common/Http/Schemas.js"; -import { actorOf, requireRoleOrService } from "../../Common/Security/Authorization.js"; +import { actorOf, optionalUser, requireRoleOrService } from "../../Common/Security/Authorization.js"; import type { ServiceContainer } from "../../Composition/ServiceContainer.js"; import { Role } from "../../Domain/Roles.js"; +import { pipelineProgress, type PipelineProgressRecord } from "../../Infrastructure/Database/Schema.js"; import { personOfActor } from "../../Infrastructure/Notifications/Notification.js"; -const cacheKey = "pipeline:progress"; -const ttlOneYear = 365 * 86400; - const defaultProgress = { stepIndex: 0, stepId: "0.1", @@ -21,6 +19,19 @@ const defaultProgress = { updatedAt: null }; +function toPipelineProgressResponse(record: PipelineProgressRecord): z.infer { + return { + stepIndex: record.stepIndex, + stepId: record.stepId, + stepTitle: record.stepTitle, + phaseNumber: record.phaseNumber, + phaseTitle: record.phaseTitle, + progressPercent: record.progressPercent, + isPhaseTransition: record.isPhaseTransition, + updatedAt: record.updatedAt.toISOString() + }; +} + export const updatePipelineProgressSchema = z.object({ stepIndex: z.number().int().min(0), stepId: z.string().max(32), @@ -32,7 +43,7 @@ export const updatePipelineProgressSchema = z.object({ }); export const pipelineRoutes: FastifyPluginAsyncZod<{ services: ServiceContainer }> = async (application, { services }) => { - const { keyValueStore, notifier } = services; + const { database, notifier } = services; const security = [{ bearer: [] }]; const supervisor = requireRoleOrService(Role.Supervisor); @@ -46,17 +57,8 @@ export const pipelineRoutes: FastifyPluginAsyncZod<{ services: ServiceContainer } }, async () => { - const raw = await keyValueStore.get(cacheKey); - if (raw === null) { - return { data: defaultProgress }; - } - try { - const parsed = JSON.parse(raw); - return { data: pipelineProgressSchema.parse(parsed) }; - } catch { - // fallback to initial baseline on stale cache structure - return { data: defaultProgress }; - } + const [record] = await database.select().from(pipelineProgress).limit(1); + return { data: record === undefined ? defaultProgress : toPipelineProgressResponse(record) }; } ); @@ -73,19 +75,15 @@ export const pipelineRoutes: FastifyPluginAsyncZod<{ services: ServiceContainer } }, async (request) => { - const now = new Date().toISOString(); - const record = { - stepIndex: request.body.stepIndex, - stepId: request.body.stepId, - stepTitle: request.body.stepTitle, - phaseNumber: request.body.phaseNumber, - phaseTitle: request.body.phaseTitle, - progressPercent: request.body.progressPercent, - isPhaseTransition: request.body.isPhaseTransition, - updatedAt: now - }; - - await keyValueStore.set(cacheKey, JSON.stringify(record), ttlOneYear); + const values = { ...request.body, updatedBy: optionalUser(request)?.id ?? null }; + const [record] = await database + .insert(pipelineProgress) + .values({ id: 1, ...values }) + .onConflictDoUpdate({ target: pipelineProgress.id, set: { ...values, updatedAt: new Date() } }) + .returning(); + if (record === undefined) { + throw new Error("Pipeline progress upsert returned no row."); + } notifier.notify({ type: NotificationType.PipelineUpdated, @@ -98,7 +96,7 @@ export const pipelineRoutes: FastifyPluginAsyncZod<{ services: ServiceContainer actor: personOfActor(actorOf(request)) }); - return { data: record }; + return { data: toPipelineProgressResponse(record) }; } ); }; diff --git a/apps/api/src/Modules/Shots/ReviewsService.ts b/apps/api/src/Modules/Shots/ReviewsService.ts index efb0ac2..a4c5afd 100644 --- a/apps/api/src/Modules/Shots/ReviewsService.ts +++ b/apps/api/src/Modules/Shots/ReviewsService.ts @@ -127,13 +127,11 @@ export class ReviewsService { .from(users) .where(eq(users.id, submission.contributorId)) .limit(1); - const videoUrl = this.storage.getPublicUrl(StorageBucket.Deliverables, submission.videoKey); this.notifier.notify({ type: NotificationType.SubmissionReviewed, shot: shotReferenceOf(shot), submissionId: submission.id, version: submission.version, - videoUrl, decision, contributor: contributor === undefined ? { discordId: null, username: "Unknown" } : personOfUser(contributor), reviewer: personOfActor(reviewer), diff --git a/apps/api/src/Modules/Shots/ShotsService.ts b/apps/api/src/Modules/Shots/ShotsService.ts index 7e78a95..66a767e 100644 --- a/apps/api/src/Modules/Shots/ShotsService.ts +++ b/apps/api/src/Modules/Shots/ShotsService.ts @@ -409,13 +409,11 @@ export class ShotsService { return { submission: created, shot: locked }; }); - const videoUrl = this.storage.getPublicUrl(StorageBucket.Deliverables, submission.videoKey); this.notifier.notify({ type: NotificationType.SubmissionCreated, shot: shotReferenceOf(shot), submissionId: submission.id, version: submission.version, - videoUrl, contributor: personOfUser(user), notes: submission.notes }); diff --git a/apps/api/src/Modules/Uploads/UploadsRoutes.ts b/apps/api/src/Modules/Uploads/UploadsRoutes.ts index a00b0c7..3c7a3ee 100644 --- a/apps/api/src/Modules/Uploads/UploadsRoutes.ts +++ b/apps/api/src/Modules/Uploads/UploadsRoutes.ts @@ -49,39 +49,4 @@ export const uploadsRoutes: FastifyPluginAsyncZod<{ services: ServiceContainer } return reply.status(201).send({ data: toPresignedUploadResponse(upload) }); } ); - - application.put( - "/uploads/media/file/*", - { - config: { rateLimit: { max: 60, timeWindow: "1 minute" } } - }, - async (request, reply) => { - const rawKey = decodeURIComponent((request.params as { "*": string })["*"] || ""); - const buffer = request.body as Buffer; - if (!buffer || buffer.length === 0) { - return reply.status(400).send({ message: "Missing file payload" }); - } - if ("saveMedia" in objectStorage && typeof (objectStorage as { saveMedia: unknown }).saveMedia === "function") { - (objectStorage as { saveMedia: (k: string, b: Buffer) => void }).saveMedia(rawKey, buffer); - } - return reply.status(200).send({ status: "ok", key: rawKey }); - } - ); - - application.get( - "/uploads/media/file/*", - async (request, reply) => { - const rawKey = decodeURIComponent((request.params as { "*": string })["*"] || ""); - if ("readMedia" in objectStorage && typeof (objectStorage as { readMedia: unknown }).readMedia === "function") { - const item = (objectStorage as { readMedia: (k: string) => { buffer: Buffer; contentType: string } | null }).readMedia(rawKey); - if (item === null) { - return reply.status(404).send({ message: "Media file not found" }); - } - void reply.header("Content-Type", item.contentType); - void reply.header("Cache-Control", "public, max-age=31536000, immutable"); - return reply.send(item.buffer); - } - return reply.status(404).send({ message: "Media storage unavailable" }); - } - ); }; diff --git a/apps/api/test/Notifications.test.ts b/apps/api/test/Notifications.test.ts index c79fa63..a9b7f69 100644 --- a/apps/api/test/Notifications.test.ts +++ b/apps/api/test/Notifications.test.ts @@ -211,6 +211,11 @@ describe("platform service integration", () => { isPhaseTransition: false } }); + const pipeline = await context.application.inject({ method: "GET", url: "/api/v1/pipeline" }); + expect(json>(pipeline).data).toMatchObject({ + stepId: "0.2", + progressPercent: 7 + }); await new Promise((resolve) => setTimeout(resolve, 10)); const types = new Set(); diff --git a/apps/discord/src/Api/PlatformApiClient.ts b/apps/discord/src/Api/PlatformApiClient.ts index a859a6d..be06f5c 100644 --- a/apps/discord/src/Api/PlatformApiClient.ts +++ b/apps/discord/src/Api/PlatformApiClient.ts @@ -113,8 +113,11 @@ export class PlatformApiClient { public async getThreadMapByThread(discordThreadId: string): Promise { try { return (await this.send(`/shot-thread-maps/by-thread/${discordThreadId}`, dataEnvelope(shotThreadMapSchema))).data; - } catch { - return null; + } catch (error: unknown) { + if (error instanceof PlatformApiError && error.status === 404) { + return null; + } + throw error; } } diff --git a/apps/discord/src/Commands/ModerationCommands.ts b/apps/discord/src/Commands/ModerationCommands.ts index 0c15a06..8be4762 100644 --- a/apps/discord/src/Commands/ModerationCommands.ts +++ b/apps/discord/src/Commands/ModerationCommands.ts @@ -84,7 +84,12 @@ export const assignRoleCommand: SlashCommand = { (held) => held.id !== role.id && isLeadership(findStudioRole(held.name)?.tier ?? 3) ); if (!keepsLeadership) { - await api.setRole(holder.id, Role.Contributor, specialtiesOf(holder, undefined, role.id), holder.user.globalName ?? holder.user.username); + await api.setRole( + holder.id, + Role.Contributor, + specialtiesOf(holder, undefined, role.id), + holder.user.globalName ?? holder.user.username + ); } previous.push(holder); } diff --git a/apps/discord/src/Commands/SetupCommands.ts b/apps/discord/src/Commands/SetupCommands.ts index 086f488..dc64fad 100644 --- a/apps/discord/src/Commands/SetupCommands.ts +++ b/apps/discord/src/Commands/SetupCommands.ts @@ -58,7 +58,7 @@ export const setupForumCommand: SlashCommand = { const log = interaction.options.getChannel("log", true, [ChannelType.GuildText]); // fetch the full guild forum channel instance so tag mutations succeed const forum = await interaction.guild.channels.fetch(forumOption.id); - if (forum === null || forum.type !== ChannelType.GuildForum) { + if (forum?.type !== ChannelType.GuildForum) { throw new UserFacingError("The specified forum channel could not be found."); } await context.provisioner.ensureForumTags(forum); diff --git a/apps/discord/src/Discord/Ui.ts b/apps/discord/src/Discord/Ui.ts index 6b3c0eb..e56846d 100644 --- a/apps/discord/src/Discord/Ui.ts +++ b/apps/discord/src/Discord/Ui.ts @@ -21,7 +21,7 @@ export enum Accent { Danger = 0xd83c3e } -export type Block = string | SeparatorBuilder | MediaGalleryBuilder | ActionRowBuilder | null | undefined; +export type Block = string | SeparatorBuilder | MediaGalleryBuilder | ActionRowBuilder; export type TopLevelComponent = ContainerBuilder | TextDisplayBuilder | ActionRowBuilder; @@ -50,9 +50,6 @@ export function panel(accent: Accent | null, ...blocks: readonly Block[]): Conta container.setAccentColor(accent); } for (const block of blocks) { - if (block === null || block === undefined) { - continue; - } if (typeof block === "string") { container.addTextDisplayComponents(text(block)); } else if (block instanceof SeparatorBuilder) { diff --git a/apps/discord/src/Services/NotificationDispatcher.ts b/apps/discord/src/Services/NotificationDispatcher.ts index d3f07cc..f26de3e 100644 --- a/apps/discord/src/Services/NotificationDispatcher.ts +++ b/apps/discord/src/Services/NotificationDispatcher.ts @@ -92,47 +92,13 @@ export class NotificationDispatcher { private async channel(purpose: ChannelPurpose): Promise { const id = this.settings.channel(purpose); - if (id !== undefined) { - const channel = await this.client.channels.fetch(id).catch(() => null); - if (channel !== null && channel.isSendable() && !channel.isDMBased() && channel.guildId === this.guildId) { - return channel; - } + if (id === undefined) { + return null; } - // fallback to finding matching text channel on the guild - try { - const guild = await this.client.guilds.fetch(this.guildId).catch(() => null); - if (guild) { - const channels = await guild.channels.fetch().catch(() => null); - if (channels) { - const expectedKeywords: Record = { - [ChannelPurpose.Announcements]: ["announcement", "news", "updates", "general"], - [ChannelPurpose.Telemetry]: ["telemetry", "alerts", "bot-logs", "logs"], - [ChannelPurpose.TaskLogs]: ["task-logs", "tasks", "activity"], - [ChannelPurpose.TaskSubmissions]: ["task-submissions", "submissions", "reviews"], - [ChannelPurpose.TaskForum]: ["tasks", "forum"] - }; - const keywords = expectedKeywords[purpose] || [purpose.toLowerCase()]; - const match = channels.find( - (ch) => - ch !== null && - ch.isTextBased() && - !ch.isDMBased() && - keywords.some((kw) => ch.name.toLowerCase().includes(kw)) - ); - if (match && match.isSendable() && !match.isDMBased()) { - await this.settings.update((s) => { - s.channels[purpose] = match.id; - }); - return match as SendableChannels; - } - if (purpose === ChannelPurpose.Announcements && guild.systemChannel && guild.systemChannel.isSendable()) { - return guild.systemChannel; - } - } - } - } catch (err) { - this.logger.warn({ err, purpose }, "dynamic channel lookup failed"); + const channel = await this.client.channels.fetch(id).catch(() => null); + if (channel === null || !channel.isSendable() || channel.isDMBased() || channel.guildId !== this.guildId) { + return null; } - return null; + return channel; } } diff --git a/apps/discord/src/Services/StudioRoles.ts b/apps/discord/src/Services/StudioRoles.ts index 62cdc8d..70025ed 100644 --- a/apps/discord/src/Services/StudioRoles.ts +++ b/apps/discord/src/Services/StudioRoles.ts @@ -58,7 +58,12 @@ export const studioRoles: readonly StudioRole[] = Object.freeze([ { name: "Surfacing / LookDev Lead", tier: StudioTier.Department, color: 0x16a085, specialty: Specialty.SurfacingLookDevLead }, { name: "Animation Supervisor", tier: StudioTier.Department, color: 0xf39c12, specialty: Specialty.AnimationSupervisor }, { name: "CFX and VFX Supervisor", tier: StudioTier.Department, color: 0xe91e63, specialty: Specialty.CfxVfxSupervisor }, - { name: "Lighting and Compositing Supervisor", tier: StudioTier.Department, color: 0xf1c40f, specialty: Specialty.LightingCompositingSupervisor }, + { + name: "Lighting and Compositing Supervisor", + tier: StudioTier.Department, + color: 0xf1c40f, + specialty: Specialty.LightingCompositingSupervisor + }, { name: "Sound Director", tier: StudioTier.Department, color: 0x00bcd4, specialty: Specialty.SoundDirector }, { name: "Animators", tier: StudioTier.Contributor, color: 0x2980b9, specialty: Specialty.Animator }, { name: "Layout Artists", tier: StudioTier.Contributor, color: 0x673ab7, specialty: Specialty.LayoutArtist }, diff --git a/apps/discord/src/Services/TaskForum.ts b/apps/discord/src/Services/TaskForum.ts index b90875e..1aa1174 100644 --- a/apps/discord/src/Services/TaskForum.ts +++ b/apps/discord/src/Services/TaskForum.ts @@ -42,27 +42,6 @@ export class TaskForum { for (const map of maps) { this.remember(map.shotId, map.discordThreadId); } - // auto-discover any active threads in forum not yet in thread map - const forum = await this.forum(); - if (forum !== null) { - const active = await forum.threads.fetchActive().catch(() => null); - if (active !== null) { - const allShots = await this.api.listAllShots().catch(() => []); - const byCode = new Map(allShots.map((s) => [s.shotCode.toUpperCase(), s])); - for (const [threadId, thread] of active.threads) { - if (thread.parentId === forum.id && !this.shotsByThread.has(threadId)) { - const match = /^([A-Z0-9_]+)\s*-\s*/iu.exec(thread.name); - if (match && match[1]) { - const shot = byCode.get(match[1].trim().toUpperCase()); - if (shot) { - this.remember(shot.id, threadId); - await this.api.bindThread(shot.id, threadId).catch(() => undefined); - } - } - } - } - } - } } catch (error: unknown) { this.logger.warn({ err: error }, "failed to load task thread bindings"); } @@ -81,31 +60,12 @@ export class TaskForum { if (cached !== undefined) { return cached; } - // fallback to querying platform api for thread binding + // fall back to the persisted binding when the cache is cold const remote = await this.api.getThreadMapByThread(threadId); if (remote !== null) { this.remember(remote.shotId, remote.discordThreadId); return remote.shotId; } - // fallback to matching thread title against shot codes - try { - const channel = await this.client.channels.fetch(threadId).catch(() => null); - if (channel !== null && channel.isThread()) { - const match = /^([A-Z0-9_]+)\s*-\s*/iu.exec(channel.name); - if (match && match[1]) { - const code = match[1].trim().toUpperCase(); - const allShots = await this.api.listAllShots(); - const shot = allShots.find((s) => s.shotCode.toUpperCase() === code); - if (shot) { - this.remember(shot.id, threadId); - await this.api.bindThread(shot.id, threadId).catch(() => undefined); - return shot.id; - } - } - } - } catch { - // ignore resolution errors - } return undefined; } diff --git a/apps/discord/src/Views/NotificationViews.ts b/apps/discord/src/Views/NotificationViews.ts index 223abed..c216ed0 100644 --- a/apps/discord/src/Views/NotificationViews.ts +++ b/apps/discord/src/Views/NotificationViews.ts @@ -9,8 +9,9 @@ import { type PlatformNotification, type ShotReference } from "@platform/contracts"; +import type { MediaGalleryBuilder } from "discord.js"; import { ChannelPurpose } from "../State/SettingsStore.js"; -import { Accent, image, message, panel, person, plain, quote, when, type Block, type V2Message } from "../Discord/Ui.js"; +import { Accent, image, message, panel, person, plain, quote, when, type V2Message } from "../Discord/Ui.js"; export interface RenderedNotification { readonly purpose: ChannelPurpose; @@ -21,27 +22,28 @@ export interface NotificationContext { threadFor(shotId: string): string | undefined; } -function post(purpose: ChannelPurpose, accent: Accent | null, ...blocks: readonly (Block | string | null | undefined)[]): RenderedNotification { - const stringLines: string[] = []; - const panelBlocks: Block[] = []; - for (const b of blocks) { - if (b === null || b === undefined) { +type Line = string | MediaGalleryBuilder | null; + +// consecutive text lines share one text display; media galleries sit between them +function post(purpose: ChannelPurpose, accent: Accent | null, ...lines: readonly Line[]): RenderedNotification { + const blocks: (string | MediaGalleryBuilder)[] = []; + for (const line of lines) { + const previous = blocks.at(-1); + if (line === null) { continue; } - if (typeof b === "string") { - stringLines.push(b); + if (typeof line === "string" && typeof previous === "string") { + blocks[blocks.length - 1] = `${previous} +${line}`; } else { - if (stringLines.length > 0) { - panelBlocks.push(stringLines.join("\n")); - stringLines.length = 0; - } - panelBlocks.push(b); + blocks.push(line); } } - if (stringLines.length > 0) { - panelBlocks.push(stringLines.join("\n")); - } - return { purpose, message: message(panel(accent, ...panelBlocks)) }; + return { purpose, message: message(panel(accent, ...blocks)) }; +} + +function preview(url: string | null | undefined, title: string): MediaGalleryBuilder | null { + return url === null || url === undefined || !/\.(png|jpe?g|gif|webp)$/iu.test(url) ? null : image(url, title); } function reason(value: string | null): string | null { @@ -202,7 +204,7 @@ export function renderNotification(notification: PlatformNotification, context: `## ${plain(notification.round.title)}`, describeWinner(notification), `-# ${notification.totalBallots.toLocaleString("en-US")} ${notification.totalBallots === 1 ? "ballot" : "ballots"} counted`, - notification.winner?.mediaUrl ? image(notification.winner.mediaUrl, notification.winner.title) : null + preview(notification.winner?.mediaUrl, notification.winner?.title ?? notification.round.title) ) ]; case NotificationType.EntrySubmitted: @@ -213,7 +215,7 @@ export function renderNotification(notification: PlatformNotification, context: `**New entry in ${plain(notification.round.title)}**`, `${plain(notification.entry.title)} by ${person(notification.author)}`, notification.status === EntryStatus.PendingReview ? "-# Waiting for review" : "-# Approved automatically", - notification.entry.mediaUrl ? image(notification.entry.mediaUrl, notification.entry.title) : null + preview(notification.entry.mediaUrl, notification.entry.title) ) ]; case NotificationType.EntryStatusChanged: { @@ -225,7 +227,7 @@ export function renderNotification(notification: PlatformNotification, context: `**${headline.title}**`, `${plain(notification.entry.title)} in ${plain(notification.round.title)}`, `-# By ${person(notification.actor)}`, - notification.entry.mediaUrl ? image(notification.entry.mediaUrl, notification.entry.title) : null + preview(notification.entry.mediaUrl, notification.entry.title) ) ]; } @@ -237,7 +239,7 @@ export function renderNotification(notification: PlatformNotification, context: "**Entry reinstated**", `${plain(notification.entry.title)} is back on the ballot in ${plain(notification.round.title)}.`, `-# By ${person(notification.actor)}`, - notification.entry.mediaUrl ? image(notification.entry.mediaUrl, notification.entry.title) : null + preview(notification.entry.mediaUrl, notification.entry.title) ) ]; case NotificationType.ShotCreated: @@ -283,8 +285,7 @@ export function renderNotification(notification: PlatformNotification, context: null, `**Submitted:** ${shotLink(notification.shot, context)}, version ${notification.version}`, `By ${person(notification.contributor)}`, - notification.notes === null ? null : quote(notification.notes, 500), - notification.videoUrl ? image(notification.videoUrl, `${notification.shot.code} v${notification.version}`) : null + notification.notes === null ? null : quote(notification.notes, 500) ) ]; case NotificationType.SubmissionReviewed: { @@ -295,8 +296,7 @@ export function renderNotification(notification: PlatformNotification, context: approved ? Accent.Success : Accent.Warning, `**${approved ? "Approved" : "Changes requested"}:** ${shotLink(notification.shot, context)}, version ${notification.version}`, `Reviewed by ${person(notification.reviewer)}`, - notification.notes === null ? null : quote(notification.notes, 600), - notification.videoUrl ? image(notification.videoUrl, `${notification.shot.code} v${notification.version}`) : null + notification.notes === null ? null : quote(notification.notes, 600) ) ]; if (approved) { @@ -304,8 +304,7 @@ export function renderNotification(notification: PlatformNotification, context: post( ChannelPurpose.Announcements, Accent.Success, - `**${shotName(notification.shot)} is finished.** Delivered by ${person(notification.contributor)}.`, - notification.videoUrl ? image(notification.videoUrl, shotName(notification.shot)) : null + `**${shotName(notification.shot)} is finished.** Delivered by ${person(notification.contributor)}.` ) ); } @@ -317,7 +316,6 @@ export function renderNotification(notification: PlatformNotification, context: post( ChannelPurpose.Announcements, Accent.Success, - `@everyone`, `## Phase ${notification.phaseNumber} Unlocked: ${plain(notification.phaseTitle)}`, `Now entering **Step ${plain(notification.stepId)}: ${plain(notification.stepTitle)}** (${notification.progressPercent}% overall completed).`, `-# Updated by ${person(notification.actor)}` diff --git a/apps/discord/src/Views/TaskViews.ts b/apps/discord/src/Views/TaskViews.ts index a29f692..b796912 100644 --- a/apps/discord/src/Views/TaskViews.ts +++ b/apps/discord/src/Views/TaskViews.ts @@ -14,7 +14,6 @@ import { capitalize, divider, ephemeral, - image, linkButton, message, panel, @@ -100,8 +99,7 @@ export function threadUpdate( notification.notes === null ? null : quote(notification.notes, 800) ] .filter((line): line is string => line !== null) - .join("\n"), - notification.videoUrl ? image(notification.videoUrl, `${notification.shot.code} v${notification.version}`) : null + .join("\n") ) ); case NotificationType.SubmissionReviewed: { @@ -115,8 +113,7 @@ export function threadUpdate( approved ? Accent.Success : Accent.Warning, [headline, notification.notes === null ? null : quote(notification.notes, 1000), next] .filter((line): line is string => line !== null) - .join("\n"), - notification.videoUrl ? image(notification.videoUrl, `${notification.shot.code} v${notification.version}`) : null + .join("\n") ) ); } @@ -131,11 +128,7 @@ export function reviewCard(notification: NotificationOf line !== null); return message( - panel( - Accent.Info, - lines.join("\n"), - notification.videoUrl ? image(notification.videoUrl, `${notification.shot.code} v${notification.version}`) : null - ), + panel(Accent.Info, lines.join("\n")), buttons( actionButton(`${reviewButtonPrefix}:${notification.submissionId}:${ReviewDecision.Approved}`, "Approve", ButtonStyle.Success), actionButton( diff --git a/apps/discord/test/NotificationViews.test.ts b/apps/discord/test/NotificationViews.test.ts index 9857cf1..403cc83 100644 --- a/apps/discord/test/NotificationViews.test.ts +++ b/apps/discord/test/NotificationViews.test.ts @@ -14,7 +14,7 @@ import { MessageFlags } from "discord.js"; import { describe, expect, it } from "vitest"; import type { V2Message } from "../src/Discord/Ui.js"; import { ChannelPurpose } from "../src/State/SettingsStore.js"; -import { renderNotification } from "../src/Views/NotificationViews.js"; +import { renderNotification, type RenderedNotification } from "../src/Views/NotificationViews.js"; import { reviewCard, threadUpdate } from "../src/Views/TaskViews.js"; const occurredAt = "2026-09-27T01:08:00.000Z"; @@ -272,19 +272,21 @@ describe("every notification", () => { }); it("announces winners publicly", () => { - const sample = samples.find((s) => s.type === NotificationType.RoundFinalized)!; - const [rendered] = renderNotification(sample, context); + const sample = samples.find((s) => s.type === NotificationType.RoundFinalized); + expect(sample).toBeDefined(); + const [rendered] = renderNotification(sample as PlatformNotification, context); expect(rendered?.purpose).toBe(ChannelPurpose.Announcements); expect(texts(rendered?.message as V2Message).join("\n")).toContain("**Option Alpha** wins with 62.5% of the vote."); }); it("announces when a round is opened for voting", () => { - const sample = samples.find((s) => s.type === NotificationType.RoundStatusChanged)!; - const rendered = renderNotification(sample, context); + const sample = samples.find((s) => s.type === NotificationType.RoundStatusChanged); + expect(sample).toBeDefined(); + const rendered = renderNotification(sample as PlatformNotification, context); expect(rendered).toHaveLength(2); const announcement = rendered.find((r) => r.purpose === ChannelPurpose.Announcements); expect(announcement).toBeDefined(); - expect(texts(announcement!.message).join("\n")).toContain("## Voting is now open for Binary Test Round!"); + expect(texts((announcement as RenderedNotification).message).join("\n")).toContain("## Voting is now open for Binary Test Round!"); }); it("announces phase unlocks and step progress to announcements channel", () => { @@ -321,26 +323,22 @@ describe("every notification", () => { expect(stepProgress.text).toContain("41% completed"); }); - it("embeds image and video media thumbnails when mediaUrl or videoUrl is present", () => { - const entryNotification = only({ + it("shows entry and winner media as a components v2 gallery", () => { + const hasGallery = (payload: V2Message): boolean => + payload.components.some((c) => ((c.toJSON() as ComponentJson).components ?? []).some((child) => child.type === 12)); + const entry = only({ type: NotificationType.EntrySubmitted, occurredAt, round, - entry: { - id: shot.id, - title: "Hero Design", - mediaUrl: "https://dev-api.seraphinteractive.com/api/v1/uploads/media/file/hero.png" - }, + entry: { id: shot.id, title: "Hero Design", mediaUrl: "https://media.example.test/hero.png" }, status: EntryStatus.PendingReview, author: voter }); - const entryJson = entryNotification.payload.components.map((c) => c.toJSON() as ComponentJson); - const hasMedia = entryJson.some( - (c) => (c.components ?? []).some((child: unknown) => (child as { type?: number })?.type === 12) - ); - expect(hasMedia).toBe(true); + expect(hasGallery(entry.payload)).toBe(true); + expect(entry.payload).not.toHaveProperty("embeds"); + expect(entry.text).toContain("Hero Design"); - const winnerNotification = only({ + const winner = only({ type: NotificationType.RoundFinalized, occurredAt, round, @@ -348,34 +346,24 @@ describe("every notification", () => { winner: { entryId: shot.id, title: "Winning Art", - mediaUrl: "https://dev-api.seraphinteractive.com/api/v1/uploads/media/file/winner.png", + mediaUrl: "https://media.example.test/winner.webp", rawScore: 25, voteSharePercentage: 62.5, regularizedTotalScore: null }, actor: admin }); - const winnerJson = winnerNotification.payload.components.map((c) => c.toJSON() as ComponentJson); - const winnerHasMedia = winnerJson.some( - (c) => (c.components ?? []).some((child: unknown) => (child as { type?: number })?.type === 12) - ); - expect(winnerHasMedia).toBe(true); + expect(hasGallery(winner.payload)).toBe(true); - const submissionNotification = only({ - type: NotificationType.SubmissionCreated, + const noMedia = only({ + type: NotificationType.EntrySubmitted, occurredAt, - shot, - submissionId: shot.id, - version: 1, - videoUrl: "https://dev-api.seraphinteractive.com/api/v1/uploads/media/file/shot_v1.mp4", - contributor: voter, - notes: "Initial blockout" + round, + entry: { id: shot.id, title: "Text Only", mediaUrl: null }, + status: EntryStatus.PendingReview, + author: voter }); - const submissionJson = submissionNotification.payload.components.map((c) => c.toJSON() as ComponentJson); - const subHasMedia = submissionJson.some( - (c) => (c.components ?? []).some((child: unknown) => (child as { type?: number })?.type === 12) - ); - expect(subHasMedia).toBe(true); + expect(hasGallery(noMedia.payload)).toBe(false); }); }); diff --git a/packages/contracts/src/Enums.ts b/packages/contracts/src/Enums.ts index 1512fc4..b1da24d 100644 --- a/packages/contracts/src/Enums.ts +++ b/packages/contracts/src/Enums.ts @@ -10,12 +10,23 @@ export enum Role { } export enum Specialty { - // Executive Tier (Admin 0) + // Postgres enum order is append-only: add new values at the end. + Animator = "animator", + LayoutArtist = "layout_artist", + Modeler3d = "3d_modeler", + Rigger = "rigger", + SurfaceTextureArtist = "surface_texture_artist", + LightingArtist = "lighting_artist", + VfxArtist = "vfx_artist", + ConceptArtist = "concept_artist", + Screenwriter = "screenwriter", + VoiceActor = "voice_actor", + SoundDesigner = "sound_designer", + VideoEditor = "video_editor", + GeneralContributor = "general_contributor", Producer = "producer", CreativeDirector = "creative_director", ProductionManager = "production_manager", - - // Department Tier (Supervisors 1) TechnicalDirector = "technical_director", ArtDirector = "art_director", EditorialSupervisor = "editorial_supervisor", @@ -27,23 +38,6 @@ export enum Specialty { CfxVfxSupervisor = "cfx_vfx_supervisor", LightingCompositingSupervisor = "lighting_compositing_supervisor", SoundDirector = "sound_director", - - // Contributor Tier (Contributors 2) - Animator = "animator", - LayoutArtist = "layout_artist", - Modeler3d = "3d_modeler", - Rigger = "rigger", - SurfaceTextureArtist = "surface_texture_artist", - LightingArtist = "lighting_artist", - VfxArtist = "vfx_artist", - ConceptArtist = "concept_artist", - VoiceActor = "voice_actor", - SoundDesigner = "sound_designer", - VideoEditor = "video_editor", - GeneralContributor = "general_contributor", - Screenwriter = "screenwriter", - - // Community Tier (Voters 3) Voter = "voter" } diff --git a/packages/contracts/src/Notifications.ts b/packages/contracts/src/Notifications.ts index e93c2ac..4bbf49a 100644 --- a/packages/contracts/src/Notifications.ts +++ b/packages/contracts/src/Notifications.ts @@ -33,11 +33,7 @@ export const notificationPersonSchema = z.object({ }); const roundReference = z.object({ id: uuidSchema, title: z.string(), pollType: z.enum(PollType) }); -const entryReference = z.object({ - id: uuidSchema, - title: z.string(), - mediaUrl: z.string().nullable().optional() -}); +const entryReference = z.object({ id: uuidSchema, title: z.string(), mediaUrl: z.string().nullable().optional() }); const shotReference = z.object({ id: uuidSchema, code: z.string(), @@ -145,7 +141,6 @@ export const platformNotificationSchema = z.discriminatedUnion("type", [ shot: shotReference, submissionId: uuidSchema, version: z.number().int(), - videoUrl: z.string().nullable().optional(), contributor: notificationPersonSchema, notes: z.string().nullable() }), @@ -153,7 +148,6 @@ export const platformNotificationSchema = z.discriminatedUnion("type", [ shot: shotReference, submissionId: uuidSchema, version: z.number().int(), - videoUrl: z.string().nullable().optional(), decision: z.enum(ReviewDecision), contributor: notificationPersonSchema, reviewer: notificationPersonSchema, From f3e9254eb3390b787b852e8b562553a08c4bd8ff Mon Sep 17 00:00:00 2001 From: Lunasa Date: Sun, 27 Sep 2026 18:03:35 +0530 Subject: [PATCH 14/88] refactor: platform-backend -> platform --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1e000a5..8bd8b8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,10 +1,10 @@ { - "name": "platform-backend", + "name": "platform", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "platform-backend", + "name": "platform", "license": "AGPL-3.0-only", "workspaces": [ "packages/*", diff --git a/package.json b/package.json index f0b3fd2..543ccaa 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "platform-backend", + "name": "platform", "private": true, "license": "AGPL-3.0-only", "type": "module", From b3024fbb7be5fe6ed1a4fa7cf708782b42466f40 Mon Sep 17 00:00:00 2001 From: Yierke Date: Sun, 27 Sep 2026 18:44:39 +0530 Subject: [PATCH 15/88] fix(sec): resolve role sync escalation, gate openapi and thread maps, allow video extensions --- apps/api/src/App.ts | 3 ++- apps/api/src/Modules/Auth/AuthService.ts | 4 ++-- apps/api/src/Modules/ThreadMaps/ThreadMapsRoutes.ts | 5 +++++ apps/api/test/Http.test.ts | 3 ++- apps/api/test/Shots.test.ts | 2 +- apps/discord/src/Commands/ModerationCommands.ts | 3 ++- apps/discord/src/Views/NotificationViews.ts | 2 +- 7 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/api/src/App.ts b/apps/api/src/App.ts index 25d1d65..80619ac 100644 --- a/apps/api/src/App.ts +++ b/apps/api/src/App.ts @@ -16,6 +16,7 @@ import { import { actingUserHeader } from "@platform/contracts"; import { ApplicationError, BadRequestError, ErrorCode, ForbiddenError, UnauthorizedError } from "./Common/Errors/ApplicationError.js"; import type { Principal } from "./Common/Security/Principal.js"; +import { requireRole } from "./Common/Security/Authorization.js"; import type { TokenService } from "./Modules/Auth/TokenService.js"; import { registerErrorHandling } from "./Common/Http/ErrorHandling.js"; import type { ServiceContainer } from "./Composition/ServiceContainer.js"; @@ -187,7 +188,7 @@ export async function buildApplication(services: ServiceContainer, logger: Fasti await application.register( async (api) => { - api.get("/openapi.json", { schema: { hide: true } }, async () => application.swagger()); + api.get("/openapi.json", { schema: { hide: true }, preHandler: requireRole(Role.Moderator) }, async () => application.swagger()); await api.register(authRoutes, { services }); await api.register(usersRoutes, { services }); await api.register(roundsRoutes, { services }); diff --git a/apps/api/src/Modules/Auth/AuthService.ts b/apps/api/src/Modules/Auth/AuthService.ts index 6d33ba9..aee8d62 100644 --- a/apps/api/src/Modules/Auth/AuthService.ts +++ b/apps/api/src/Modules/Auth/AuthService.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { eq, sql } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import type { FastifyBaseLogger } from "fastify"; import type { RoleAssignmentConfiguration, SecurityConfiguration } from "../../Configuration/ApplicationConfiguration.js"; import { BadRequestError, ErrorCode } from "../../Common/Errors/ApplicationError.js"; @@ -142,7 +142,7 @@ export class AuthService { set: { discordUsername: profile.username, discordAvatar: profile.avatar, - role: sql`GREATEST(${users.role}, excluded.role)`, + role: assignedRole, updatedAt: new Date() } }) diff --git a/apps/api/src/Modules/ThreadMaps/ThreadMapsRoutes.ts b/apps/api/src/Modules/ThreadMaps/ThreadMapsRoutes.ts index 8120d2e..751a369 100644 --- a/apps/api/src/Modules/ThreadMaps/ThreadMapsRoutes.ts +++ b/apps/api/src/Modules/ThreadMaps/ThreadMapsRoutes.ts @@ -17,14 +17,17 @@ function toThreadMapResponse(map: ShotThreadMapRecord): z.infer = async (application, { services }) => { const { database } = services; const security = [{ bearer: [] }]; + const reader = requireRoleOrService(Role.Moderator); const writer = requireRoleOrService(Role.Supervisor); application.get( "/shot-thread-maps", { + preHandler: reader, schema: { tags: ["Shot threads"], summary: "All shot to Discord thread bindings.", + security, response: { 200: dataEnvelope(z.array(threadMapSchema)), ...errorResponses } } }, @@ -37,9 +40,11 @@ export const threadMapsRoutes: FastifyPluginAsyncZod<{ services: ServiceContaine application.get( "/shot-thread-maps/by-thread/:threadId", { + preHandler: reader, schema: { tags: ["Shot threads"], summary: "Resolve the shot bound to a Discord thread.", + security, params: z.object({ threadId: snowflakeSchema }), response: { 200: dataEnvelope(threadMapSchema), ...errorResponses } } diff --git a/apps/api/test/Http.test.ts b/apps/api/test/Http.test.ts index 1bd70dc..65569be 100644 --- a/apps/api/test/Http.test.ts +++ b/apps/api/test/Http.test.ts @@ -70,7 +70,8 @@ describe("http contract", () => { it("reports readiness and publishes an OpenAPI document", async () => { expect(json(await context.application.inject({ method: "GET", url: "/health/ready" }))).toEqual({ status: "ok" }); - const spec = await context.application.inject({ method: "GET", url: "/api/v1/openapi.json" }); + const moderator = await context.createUser(Role.Moderator); + const spec = await context.application.inject({ method: "GET", url: "/api/v1/openapi.json", headers: moderator.headers }); expect(spec.statusCode).toBe(200); const document = json<{ openapi: string; paths: Record }>(spec); expect(document.openapi).toBe("3.1.0"); diff --git a/apps/api/test/Shots.test.ts b/apps/api/test/Shots.test.ts index 77c3c3e..de39e6f 100644 --- a/apps/api/test/Shots.test.ts +++ b/apps/api/test/Shots.test.ts @@ -208,7 +208,7 @@ describe("shot grab-box", () => { payload: { discordThreadId: threadId } }); expect(bound.statusCode).toBe(200); - const lookup = await context.application.inject({ method: "GET", url: `/api/v1/shot-thread-maps/by-thread/${threadId}` }); + const lookup = await context.application.inject({ method: "GET", url: `/api/v1/shot-thread-maps/by-thread/${threadId}`, headers: { authorization: `Bearer ${serviceToken}` } }); expect(json>(lookup).data.shotId).toBe(shotId); const unknownShot = await context.application.inject({ method: "PUT", diff --git a/apps/discord/src/Commands/ModerationCommands.ts b/apps/discord/src/Commands/ModerationCommands.ts index 8be4762..4496e43 100644 --- a/apps/discord/src/Commands/ModerationCommands.ts +++ b/apps/discord/src/Commands/ModerationCommands.ts @@ -2,7 +2,7 @@ import { Role, type Specialty } from "@platform/contracts"; import { MessageFlags, SlashCommandBuilder, type ChatInputCommandInteraction, type GuildMember } from "discord.js"; import { asEdit, ephemeral, message, panel, plain } from "../Discord/Ui.js"; import { findStudioRole, isLeadership, platformRoleFor } from "../Services/StudioRoles.js"; -import { actingAs, UserFacingError, type BotContext, type SlashCommand } from "./Command.js"; +import { actingAs, requirePlatformRole, UserFacingError, type BotContext, type SlashCommand } from "./Command.js"; export const blacklistCommand: SlashCommand = { definition: new SlashCommandBuilder() @@ -25,6 +25,7 @@ export const blacklistCommand: SlashCommand = { throw new UserFacingError("Pick a member or give a valid Discord ID."); } await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await requirePlatformRole(interaction, context, Role.Supervisor); const api = actingAs(interaction, context); const ban = interaction.options.getString("action", true) === "ban"; const user = ban ? await api.blacklist(target, interaction.options.getString("reason")) : await api.reinstate(target); diff --git a/apps/discord/src/Views/NotificationViews.ts b/apps/discord/src/Views/NotificationViews.ts index c216ed0..68dd1ad 100644 --- a/apps/discord/src/Views/NotificationViews.ts +++ b/apps/discord/src/Views/NotificationViews.ts @@ -43,7 +43,7 @@ ${line}`; } function preview(url: string | null | undefined, title: string): MediaGalleryBuilder | null { - return url === null || url === undefined || !/\.(png|jpe?g|gif|webp)$/iu.test(url) ? null : image(url, title); + return url === null || url === undefined || !/\.(png|jpe?g|gif|webp|mp4|webm|mov)$/iu.test(url) ? null : image(url, title); } function reason(value: string | null): string | null { From aac789d117bc92ac3c79df17196662fcceb81fb5 Mon Sep 17 00:00:00 2001 From: Lunasa Date: Sun, 27 Sep 2026 19:51:08 +0530 Subject: [PATCH 16/88] feat: initial web app implementation --- .claude/launch.json | 20 + .dockerignore | 2 + .env.example | 4 + .github/workflows/ci.yml | 3 + .gitignore | 2 + .prettierignore | 2 + Dockerfile | 15 + README.md | 3 +- apps/web/components.json | 9 + apps/web/eslint.config.mjs | 33 + apps/web/next.config.ts | 32 + apps/web/package.json | 39 + apps/web/postcss.config.mjs | 1 + apps/web/src/Api/ApiClient.ts | 111 + apps/web/src/Api/PlatformApi.ts | 317 + apps/web/src/Api/QueryKeys.ts | 27 + apps/web/src/Api/Uploads.ts | 61 + .../src/Components/Common/ConfirmButton.tsx | 68 + apps/web/src/Components/Common/DataList.tsx | 47 + apps/web/src/Components/Common/PageHeader.tsx | 21 + apps/web/src/Components/Common/Pagination.tsx | 46 + .../src/Components/Common/RelativeTime.tsx | 15 + apps/web/src/Components/Common/Section.tsx | 23 + apps/web/src/Components/Common/StatTile.tsx | 35 + apps/web/src/Components/Common/States.tsx | 81 + .../web/src/Components/Common/StatusBadge.tsx | 72 + apps/web/src/Components/Layout/AppShell.tsx | 131 + apps/web/src/Components/Layout/AppSidebar.tsx | 96 + .../web/src/Components/Layout/Breadcrumbs.tsx | 89 + .../web/src/Components/Layout/CommandMenu.tsx | 174 + apps/web/src/Components/Layout/Navigation.ts | 108 + apps/web/src/Components/Providers.tsx | 63 + apps/web/src/Components/SiteConfig.tsx | 22 + apps/web/src/Components/Ui/alert-dialog.tsx | 158 + apps/web/src/Components/Ui/alert.tsx | 40 + apps/web/src/Components/Ui/avatar.tsx | 83 + apps/web/src/Components/Ui/badge.tsx | 37 + apps/web/src/Components/Ui/breadcrumb.tsx | 78 + apps/web/src/Components/Ui/button.tsx | 61 + apps/web/src/Components/Ui/card.tsx | 53 + apps/web/src/Components/Ui/checkbox.tsx | 25 + apps/web/src/Components/Ui/collapsible.tsx | 17 + apps/web/src/Components/Ui/command.tsx | 114 + apps/web/src/Components/Ui/dialog.tsx | 118 + apps/web/src/Components/Ui/dropdown-menu.tsx | 207 + apps/web/src/Components/Ui/hover-card.tsx | 37 + apps/web/src/Components/Ui/input.tsx | 20 + apps/web/src/Components/Ui/kbd.tsx | 22 + apps/web/src/Components/Ui/label.tsx | 20 + apps/web/src/Components/Ui/popover.tsx | 48 + apps/web/src/Components/Ui/progress.tsx | 23 + apps/web/src/Components/Ui/scroll-area.tsx | 44 + apps/web/src/Components/Ui/select.tsx | 154 + apps/web/src/Components/Ui/separator.tsx | 27 + apps/web/src/Components/Ui/sheet.tsx | 96 + apps/web/src/Components/Ui/sidebar.tsx | 631 + apps/web/src/Components/Ui/skeleton.tsx | 7 + apps/web/src/Components/Ui/sonner.tsx | 34 + apps/web/src/Components/Ui/table.tsx | 71 + apps/web/src/Components/Ui/tabs.tsx | 70 + apps/web/src/Components/Ui/textarea.tsx | 17 + apps/web/src/Components/Ui/toggle-group.tsx | 80 + apps/web/src/Components/Ui/toggle.tsx | 38 + apps/web/src/Components/Ui/tooltip.tsx | 38 + apps/web/src/Features/Grabbox/GrabboxView.tsx | 194 + apps/web/src/Features/Grabbox/ShotMeta.tsx | 44 + apps/web/src/Features/Grabbox/ShotView.tsx | 356 + .../src/Features/Grabbox/SubmitWorkForm.tsx | 155 + apps/web/src/Features/Grabbox/UseMyShots.ts | 43 + .../Features/Guidelines/GuidelinesView.tsx | 252 + apps/web/src/Features/Home/HomeView.tsx | 135 + apps/web/src/Features/Me/MeView.tsx | 183 + apps/web/src/Features/Roadmap/RoadmapView.tsx | 200 + .../web/src/Features/Studio/DashboardPage.tsx | 171 + .../src/Features/Studio/EntryModeration.tsx | 264 + apps/web/src/Features/Studio/PeoplePage.tsx | 400 + apps/web/src/Features/Studio/ReviewsPage.tsx | 167 + .../web/src/Features/Studio/RoundControls.tsx | 262 + .../src/Features/Studio/RoundIntegrity.tsx | 253 + apps/web/src/Features/Studio/RoundPage.tsx | 124 + apps/web/src/Features/Studio/RoundsPage.tsx | 134 + apps/web/src/Features/Studio/TasksPage.tsx | 521 + apps/web/src/Features/Voting/BallotPanel.tsx | 225 + apps/web/src/Features/Voting/EntryMedia.tsx | 43 + apps/web/src/Features/Voting/RoundView.tsx | 192 + apps/web/src/Features/Voting/RoundsView.tsx | 124 + apps/web/src/Features/Voting/Standings.tsx | 125 + .../src/Features/Voting/SubmitEntryDialog.tsx | 184 + .../src/Features/Voting/UseApprovedEntries.ts | 36 + apps/web/src/Hooks/UseMobile.ts | 21 + apps/web/src/Hooks/UseNow.ts | 16 + apps/web/src/Hooks/UseRoundEvents.ts | 72 + apps/web/src/Hooks/UseSession.ts | 47 + apps/web/src/Lib/Format.ts | 120 + apps/web/src/Lib/Pipeline.ts | 99 + apps/web/src/Lib/Ranking.ts | 26 + apps/web/src/Lib/Roles.ts | 29 + apps/web/src/Lib/SafePath.ts | 9 + apps/web/src/Lib/SafeUrl.ts | 11 + apps/web/src/Lib/Utils.ts | 6 + apps/web/src/Server/Environment.ts | 46 + apps/web/src/Server/ProxyPolicy.ts | 10 + apps/web/src/Server/Session.ts | 67 + apps/web/src/Server/Upstream.ts | 49 + apps/web/src/app/api/session/route.ts | 86 + apps/web/src/app/api/v1/[...path]/route.ts | 106 + apps/web/src/app/auth/callback/page.tsx | 97 + apps/web/src/app/auth/login/route.ts | 21 + apps/web/src/app/error.tsx | 25 + apps/web/src/app/globals.css | 120 + apps/web/src/app/grabbox/[shotId]/page.tsx | 15 + apps/web/src/app/grabbox/page.tsx | 14 + apps/web/src/app/guidelines/page.tsx | 9 + apps/web/src/app/layout.tsx | 43 + apps/web/src/app/me/page.tsx | 9 + apps/web/src/app/not-found.tsx | 18 + apps/web/src/app/page.tsx | 6 + apps/web/src/app/roadmap/page.tsx | 9 + apps/web/src/app/studio/page.tsx | 9 + apps/web/src/app/studio/rounds/page.tsx | 9 + apps/web/src/app/voting/[roundId]/page.tsx | 15 + apps/web/src/app/voting/page.tsx | 9 + apps/web/src/proxy.ts | 53 + apps/web/test/Logic.test.ts | 117 + apps/web/test/Security.test.ts | 77 + apps/web/tsconfig.json | 24 + apps/web/vitest.config.ts | 7 + docker-compose.yml | 23 + eslint.config.js | 2 +- package-lock.json | 10907 +++++++++++++--- package.json | 6 +- tsconfig.eslint.json | 3 +- 132 files changed, 19608 insertions(+), 1886 deletions(-) create mode 100644 .claude/launch.json create mode 100644 apps/web/components.json create mode 100644 apps/web/eslint.config.mjs create mode 100644 apps/web/next.config.ts create mode 100644 apps/web/package.json create mode 100644 apps/web/postcss.config.mjs create mode 100644 apps/web/src/Api/ApiClient.ts create mode 100644 apps/web/src/Api/PlatformApi.ts create mode 100644 apps/web/src/Api/QueryKeys.ts create mode 100644 apps/web/src/Api/Uploads.ts create mode 100644 apps/web/src/Components/Common/ConfirmButton.tsx create mode 100644 apps/web/src/Components/Common/DataList.tsx create mode 100644 apps/web/src/Components/Common/PageHeader.tsx create mode 100644 apps/web/src/Components/Common/Pagination.tsx create mode 100644 apps/web/src/Components/Common/RelativeTime.tsx create mode 100644 apps/web/src/Components/Common/Section.tsx create mode 100644 apps/web/src/Components/Common/StatTile.tsx create mode 100644 apps/web/src/Components/Common/States.tsx create mode 100644 apps/web/src/Components/Common/StatusBadge.tsx create mode 100644 apps/web/src/Components/Layout/AppShell.tsx create mode 100644 apps/web/src/Components/Layout/AppSidebar.tsx create mode 100644 apps/web/src/Components/Layout/Breadcrumbs.tsx create mode 100644 apps/web/src/Components/Layout/CommandMenu.tsx create mode 100644 apps/web/src/Components/Layout/Navigation.ts create mode 100644 apps/web/src/Components/Providers.tsx create mode 100644 apps/web/src/Components/SiteConfig.tsx create mode 100644 apps/web/src/Components/Ui/alert-dialog.tsx create mode 100644 apps/web/src/Components/Ui/alert.tsx create mode 100644 apps/web/src/Components/Ui/avatar.tsx create mode 100644 apps/web/src/Components/Ui/badge.tsx create mode 100644 apps/web/src/Components/Ui/breadcrumb.tsx create mode 100644 apps/web/src/Components/Ui/button.tsx create mode 100644 apps/web/src/Components/Ui/card.tsx create mode 100644 apps/web/src/Components/Ui/checkbox.tsx create mode 100644 apps/web/src/Components/Ui/collapsible.tsx create mode 100644 apps/web/src/Components/Ui/command.tsx create mode 100644 apps/web/src/Components/Ui/dialog.tsx create mode 100644 apps/web/src/Components/Ui/dropdown-menu.tsx create mode 100644 apps/web/src/Components/Ui/hover-card.tsx create mode 100644 apps/web/src/Components/Ui/input.tsx create mode 100644 apps/web/src/Components/Ui/kbd.tsx create mode 100644 apps/web/src/Components/Ui/label.tsx create mode 100644 apps/web/src/Components/Ui/popover.tsx create mode 100644 apps/web/src/Components/Ui/progress.tsx create mode 100644 apps/web/src/Components/Ui/scroll-area.tsx create mode 100644 apps/web/src/Components/Ui/select.tsx create mode 100644 apps/web/src/Components/Ui/separator.tsx create mode 100644 apps/web/src/Components/Ui/sheet.tsx create mode 100644 apps/web/src/Components/Ui/sidebar.tsx create mode 100644 apps/web/src/Components/Ui/skeleton.tsx create mode 100644 apps/web/src/Components/Ui/sonner.tsx create mode 100644 apps/web/src/Components/Ui/table.tsx create mode 100644 apps/web/src/Components/Ui/tabs.tsx create mode 100644 apps/web/src/Components/Ui/textarea.tsx create mode 100644 apps/web/src/Components/Ui/toggle-group.tsx create mode 100644 apps/web/src/Components/Ui/toggle.tsx create mode 100644 apps/web/src/Components/Ui/tooltip.tsx create mode 100644 apps/web/src/Features/Grabbox/GrabboxView.tsx create mode 100644 apps/web/src/Features/Grabbox/ShotMeta.tsx create mode 100644 apps/web/src/Features/Grabbox/ShotView.tsx create mode 100644 apps/web/src/Features/Grabbox/SubmitWorkForm.tsx create mode 100644 apps/web/src/Features/Grabbox/UseMyShots.ts create mode 100644 apps/web/src/Features/Guidelines/GuidelinesView.tsx create mode 100644 apps/web/src/Features/Home/HomeView.tsx create mode 100644 apps/web/src/Features/Me/MeView.tsx create mode 100644 apps/web/src/Features/Roadmap/RoadmapView.tsx create mode 100644 apps/web/src/Features/Studio/DashboardPage.tsx create mode 100644 apps/web/src/Features/Studio/EntryModeration.tsx create mode 100644 apps/web/src/Features/Studio/PeoplePage.tsx create mode 100644 apps/web/src/Features/Studio/ReviewsPage.tsx create mode 100644 apps/web/src/Features/Studio/RoundControls.tsx create mode 100644 apps/web/src/Features/Studio/RoundIntegrity.tsx create mode 100644 apps/web/src/Features/Studio/RoundPage.tsx create mode 100644 apps/web/src/Features/Studio/RoundsPage.tsx create mode 100644 apps/web/src/Features/Studio/TasksPage.tsx create mode 100644 apps/web/src/Features/Voting/BallotPanel.tsx create mode 100644 apps/web/src/Features/Voting/EntryMedia.tsx create mode 100644 apps/web/src/Features/Voting/RoundView.tsx create mode 100644 apps/web/src/Features/Voting/RoundsView.tsx create mode 100644 apps/web/src/Features/Voting/Standings.tsx create mode 100644 apps/web/src/Features/Voting/SubmitEntryDialog.tsx create mode 100644 apps/web/src/Features/Voting/UseApprovedEntries.ts create mode 100644 apps/web/src/Hooks/UseMobile.ts create mode 100644 apps/web/src/Hooks/UseNow.ts create mode 100644 apps/web/src/Hooks/UseRoundEvents.ts create mode 100644 apps/web/src/Hooks/UseSession.ts create mode 100644 apps/web/src/Lib/Format.ts create mode 100644 apps/web/src/Lib/Pipeline.ts create mode 100644 apps/web/src/Lib/Ranking.ts create mode 100644 apps/web/src/Lib/Roles.ts create mode 100644 apps/web/src/Lib/SafePath.ts create mode 100644 apps/web/src/Lib/SafeUrl.ts create mode 100644 apps/web/src/Lib/Utils.ts create mode 100644 apps/web/src/Server/Environment.ts create mode 100644 apps/web/src/Server/ProxyPolicy.ts create mode 100644 apps/web/src/Server/Session.ts create mode 100644 apps/web/src/Server/Upstream.ts create mode 100644 apps/web/src/app/api/session/route.ts create mode 100644 apps/web/src/app/api/v1/[...path]/route.ts create mode 100644 apps/web/src/app/auth/callback/page.tsx create mode 100644 apps/web/src/app/auth/login/route.ts create mode 100644 apps/web/src/app/error.tsx create mode 100644 apps/web/src/app/globals.css create mode 100644 apps/web/src/app/grabbox/[shotId]/page.tsx create mode 100644 apps/web/src/app/grabbox/page.tsx create mode 100644 apps/web/src/app/guidelines/page.tsx create mode 100644 apps/web/src/app/layout.tsx create mode 100644 apps/web/src/app/me/page.tsx create mode 100644 apps/web/src/app/not-found.tsx create mode 100644 apps/web/src/app/page.tsx create mode 100644 apps/web/src/app/roadmap/page.tsx create mode 100644 apps/web/src/app/studio/page.tsx create mode 100644 apps/web/src/app/studio/rounds/page.tsx create mode 100644 apps/web/src/app/voting/[roundId]/page.tsx create mode 100644 apps/web/src/app/voting/page.tsx create mode 100644 apps/web/src/proxy.ts create mode 100644 apps/web/test/Logic.test.ts create mode 100644 apps/web/test/Security.test.ts create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vitest.config.ts diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..3a1ea6d --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,20 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "web", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:web"], + "port": 3001, + "env": { + "WEB_APP_URL": "http://localhost:3001", + "API_INTERNAL_URL": "http://127.0.0.1:3333", + "API_PUBLIC_URL": "http://localhost:3333", + "STORAGE_ORIGINS": "", + "NEXT_TELEMETRY_DISABLED": "1", + "PORT": "3001", + "DISCORD_GUILD_ID": "1553756639138680833" + } + } + ] +} diff --git a/.dockerignore b/.dockerignore index 07dced6..aed14a8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,7 @@ **/node_modules **/dist +**/.next +**/next-env.d.ts **/coverage **/*.tsbuildinfo **/test diff --git a/.env.example b/.env.example index 81afc16..ec89cc7 100644 --- a/.env.example +++ b/.env.example @@ -63,6 +63,10 @@ DISCORD_BOT_TOKEN= DISCORD_GUILD_ID= # (required by docker compose) web app origin used for "vote on the web" and docs links WEB_APP_URL=https://app.example.com +# (optional) storage/CDN origins the web app may load media from and upload to, comma separated +WEB_STORAGE_ORIGINS=https://media.example.com,https://bucket.s3.example.com +# (optional) public Discord invite shown in the web app sidebar +DISCORD_INVITE_URL= # ---- role assignment on login: comma separated Discord user ids --------------------------- ADMIN_DISCORD_IDS= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cf4a35..f0944ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,9 @@ jobs: - run: npm run lint - run: npm run typecheck - run: npm test + - run: npm run build:web + env: + NEXT_TELEMETRY_DISABLED: "1" - name: Migrations are in sync with the schema run: | diff --git a/.gitignore b/.gitignore index 9939ec3..c58bb01 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ npm-debug.log* .vscode/ .fleet/ .DS_Store +.next/ +next-env.d.ts diff --git a/.prettierignore b/.prettierignore index 21e7d67..06ff7af 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,5 @@ coverage apps/api/drizzle package-lock.json LICENSE +.next +next-env.d.ts diff --git a/Dockerfile b/Dockerfile index 1a7c815..9923826 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,7 @@ COPY packages/scoring/package.json packages/scoring/ COPY packages/contracts/package.json packages/contracts/ COPY apps/api/package.json apps/api/ COPY apps/discord/package.json apps/discord/ +COPY apps/web/package.json apps/web/ FROM base AS build RUN npm ci --ignore-scripts @@ -27,6 +28,11 @@ COPY apps/discord/tsconfig.json apps/discord/ COPY apps/discord/src apps/discord/src RUN npx tsc -b +FROM build AS web-build +ENV NEXT_TELEMETRY_DISABLED=1 +COPY apps/web apps/web +RUN npm run build -w @platform/web + FROM base AS api-dependencies RUN npm ci --omit=dev --ignore-scripts --workspace @platform/api @@ -69,3 +75,12 @@ EXPOSE 3333 HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --start-interval=2s --retries=3 \ CMD ["node", "-e", "fetch(`http://127.0.0.1:${process.env.PORT}/health/ready`).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"] CMD ["node", "apps/api/dist/Main.js"] + +FROM runtime AS web +ENV HOSTNAME=0.0.0.0 PORT=3000 NEXT_TELEMETRY_DISABLED=1 +COPY --from=web-build /app/apps/web/.next/standalone ./ +COPY --from=web-build /app/apps/web/.next/static apps/web/.next/static +USER node +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --start-interval=2s --retries=3 CMD ["node", "-e", "fetch(`http://127.0.0.1:${process.env.PORT}/docs`).then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"] +CMD ["node", "apps/web/server.js"] diff --git a/README.md b/README.md index c304502..39554f6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Platform Backend -Backend for community voting rounds, raid telemetry, the shot grab-box production pipeline and the studio Discord bot. +Community voting rounds, raid telemetry, the shot grab-box production pipeline, the studio Discord bot and the web app. ## Getting started @@ -10,6 +10,7 @@ cp .env.example .env npm run db:migrate npm run dev # set LOG_FORMAT=pretty for readable logs npm run dev:discord # the Discord bot +npm run dev:web # the web app ``` ## Docker diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..abdfcbc --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { "config": "", "css": "src/app/globals.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", + "aliases": { "components": "@/Components", "utils": "@/Lib/Utils", "ui": "@/Components/Ui", "lib": "@/Lib", "hooks": "@/Hooks" } +} diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..0c64b9d --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -0,0 +1,33 @@ +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTypeScript from "eslint-config-next/typescript"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { ignores: [".next/**", "node_modules/**", "next-env.d.ts", "src/Components/Ui/**", "*.config.*", "eslint.config.mjs"] }, + ...nextVitals, + ...nextTypeScript, + ...tseslint.configs.strictTypeChecked, + ...tseslint.configs.stylisticTypeChecked, + { + settings: { react: { version: "19.3" } }, + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname + } + }, + rules: { + "@typescript-eslint/explicit-function-return-type": ["error", { allowExpressions: true, allowTypedFunctionExpressions: true }], + "@typescript-eslint/consistent-type-imports": ["error", { fixStyle: "inline-type-imports" }], + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/require-await": "off", + "@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }], + "@typescript-eslint/no-non-null-assertion": "error", + "@typescript-eslint/non-nullable-type-assertion-style": "off", + "@next/next/no-img-element": "off", + eqeqeq: ["error", "always"], + "no-console": "error", + curly: ["error", "all"] + } + } +); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts new file mode 100644 index 0000000..ad45c48 --- /dev/null +++ b/apps/web/next.config.ts @@ -0,0 +1,32 @@ +import { fileURLToPath } from "node:url"; +import type { NextConfig } from "next"; + +const securityHeaders = [ + { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Cross-Origin-Opener-Policy", value: "same-origin" }, + { key: "Cross-Origin-Resource-Policy", value: "same-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()" } +]; + +const nextConfig: NextConfig = { + output: "standalone", + outputFileTracingRoot: fileURLToPath(new URL("../..", import.meta.url)), + poweredByHeader: false, + reactStrictMode: true, + typedRoutes: true, + images: { unoptimized: true }, + async redirects() { + return [ + { source: "/docs", destination: "/guidelines", permanent: false }, + { source: "/progress", destination: "/roadmap", permanent: false } + ]; + }, + async headers() { + return [{ source: "/:path*", headers: securityHeaders }]; + } +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..4af9baf --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,39 @@ +{ + "name": "@platform/web", + "version": "2.0.0", + "private": true, + "license": "AGPL-3.0-only", + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next typegen && eslint .", + "typecheck": "next typegen && tsc --noEmit" + }, + "dependencies": { + "@platform/contracts": "2.0.0", + "@tanstack/react-query": "^5.104.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "lucide-react": "^1.48.0", + "next": "^16.3.6", + "next-themes": "^0.4.6", + "radix-ui": "^1.6.7", + "react": "^19.3.0", + "react-dom": "^19.3.0", + "server-only": "^0.0.1", + "sonner": "^2.0.8", + "tailwind-merge": "^3.7.0", + "zod": "^4.6.5" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.3.3", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "eslint-config-next": "^16.3.6", + "tailwindcss": "^4.3.3", + "tw-animate-css": "^1.4.0" + } +} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs new file mode 100644 index 0000000..a9b5c43 --- /dev/null +++ b/apps/web/postcss.config.mjs @@ -0,0 +1 @@ +export default { plugins: { "@tailwindcss/postcss": {} } }; diff --git a/apps/web/src/Api/ApiClient.ts b/apps/web/src/Api/ApiClient.ts new file mode 100644 index 0000000..e46fbf1 --- /dev/null +++ b/apps/web/src/Api/ApiClient.ts @@ -0,0 +1,111 @@ +import { problemSchema } from "@platform/contracts"; +import type { z } from "zod"; + +export interface FieldError { + readonly path: string | undefined; + readonly message: string; +} + +export class ApiError extends Error { + public constructor( + public readonly status: number, + public readonly code: string, + message: string, + public readonly fieldErrors: readonly FieldError[] = [] + ) { + super(message); + this.name = "ApiError"; + } +} + +export type QueryValue = string | number | boolean | null | undefined; + +export interface RequestOptions { + readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + readonly body?: unknown; + readonly query?: Readonly>; + readonly signal?: AbortSignal; +} + +export function buildQuery(query: Readonly> | undefined): string { + if (query === undefined) { + return ""; + } + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(query)) { + if (value !== null && value !== undefined && value !== "") { + params.set(key, String(value)); + } + } + const serialized = params.toString(); + return serialized.length === 0 ? "" : `?${serialized}`; +} + +async function toApiError(response: Response): Promise { + const payload: unknown = await response.json().catch(() => null); + const problem = problemSchema.partial().safeParse(payload); + if (problem.success && typeof problem.data.detail === "string") { + return new ApiError( + response.status, + problem.data.code ?? "UNKNOWN", + problem.data.detail, + (problem.data.errors ?? []).map((error) => ({ path: error.path, message: error.message })) + ); + } + return new ApiError(response.status, "UNKNOWN", `The request failed with status ${response.status}.`); +} + +export async function sendRequest(path: string, options: RequestOptions = {}): Promise { + const headers: Record = { Accept: "application/json" }; + let body: string | undefined; + if (options.body !== undefined) { + headers["Content-Type"] = "application/json"; + body = JSON.stringify(options.body); + } + let response: Response; + try { + response = await fetch(`/api/v1${path}${buildQuery(options.query)}`, { + method: options.method ?? "GET", + headers, + body, + credentials: "same-origin", + cache: "no-store", + signal: options.signal + }); + } catch (error: unknown) { + if (error instanceof DOMException && error.name === "AbortError") { + throw error; + } + throw new ApiError(0, "NETWORK_ERROR", "Could not reach the server. Check your connection."); + } + if (!response.ok) { + throw await toApiError(response); + } + return response; +} + +export async function request( + path: string, + schema: TSchema, + options: RequestOptions = {} +): Promise> { + const response = await sendRequest(path, options); + const payload: unknown = await response.json().catch(() => null); + const parsed = schema.safeParse(payload); + if (!parsed.success) { + throw new ApiError(response.status, "INVALID_RESPONSE", "The server sent an unexpected response."); + } + return parsed.data; +} + +export async function requestNoContent(path: string, options: RequestOptions = {}): Promise { + const response = await sendRequest(path, options); + await response.body?.cancel(); +} + +export function describeError(error: unknown): string { + if (error instanceof ApiError) { + return error.message; + } + return "Something went wrong. Try again."; +} diff --git a/apps/web/src/Api/PlatformApi.ts b/apps/web/src/Api/PlatformApi.ts new file mode 100644 index 0000000..f016b4d --- /dev/null +++ b/apps/web/src/Api/PlatformApi.ts @@ -0,0 +1,317 @@ +import { + ballotSchema, + PresenceStatus, + shotThreadMapSchema, + dataEnvelope, + entrySchema, + leaderboardSchema, + ledgerBallotSchema, + moderatedUserSchema, + pageEnvelope, + pipelineProgressSchema, + presignedUploadSchema, + raidTelemetrySchema, + reclaimResultSchema, + reviewQueueItemSchema, + roundDetailSchema, + roundResultSchema, + roundSchema, + shotDetailSchema, + shotSchema, + submissionSchema, + userSchema, + type BallotDto, + type DeliverableKind, + type DifficultyTier, + type EntryDto, + type EntryStatus, + type LeaderboardDto, + type LedgerBallotDto, + type MediaContentType, + type ModeratedUserDto, + type PaginationMeta, + type PipelineProgressDto, + type PollType, + type PresignedUploadDto, + type RaidTelemetryDto, + type ReclaimResultDto, + type ReviewDecision, + type ReviewQueueItemDto, + type Role, + type RoundDetailDto, + type RoundDto, + type RoundResultDto, + type RoundStatus, + type ShotDetailDto, + type ShotDto, + type ShotStatus, + type Specialty, + type SubmissionDto, + type ShotThreadMapDto, + type UserDto +} from "@platform/contracts"; +import { z } from "zod"; +import { request, requestNoContent } from "./ApiClient"; + +export interface Page { + readonly data: T[]; + readonly meta: PaginationMeta; +} + +export interface PageQuery { + readonly page?: number; + readonly perPage?: number; +} + +export interface CreateRoundInput { + readonly title: string; + readonly pollType: PollType; + readonly opensAt: string | null; + readonly closesAt: string | null; +} + +export interface UpdateRoundInput { + readonly title?: string; + readonly pollType?: PollType; + readonly status?: RoundStatus.Draft | RoundStatus.Open | RoundStatus.Closed; + readonly opensAt?: string | null; + readonly closesAt?: string | null; +} + +export interface EntryInput { + readonly title: string; + readonly description: string | null; + readonly mediaKey: string | null; +} + +export interface UpdateEntryInput { + readonly title?: string; + readonly description?: string | null; + readonly mediaKey?: string | null; +} + +export type UpdateShotInput = Partial>; + +export interface CreateShotInput { + readonly roundId: string | null; + readonly sceneNumber: number; + readonly shotCode: string; + readonly title: string; + readonly description: string | null; + readonly difficultyTier: DifficultyTier; + readonly seniorPriorityHours: number; +} + +export interface ShotQuery extends PageQuery { + readonly status?: ShotStatus; + readonly difficultyTier?: DifficultyTier; + readonly sceneNumber?: number; +} + +export interface SubmitWorkInput { + readonly videoKey: string; + readonly blendKey: string | null; + readonly notes: string | null; +} + +export interface DeliverableUploadInput { + readonly kind: DeliverableKind; + readonly fileName: string; + readonly contentType: string; + readonly sizeBytes: number; +} + +export type PipelineUpdateInput = Omit; + +function segment(value: string): string { + return encodeURIComponent(value); +} + +export class PlatformApi { + public async pipeline(): Promise { + return (await request("/pipeline", dataEnvelope(pipelineProgressSchema))).data; + } + + public async updatePipeline(input: PipelineUpdateInput): Promise { + return (await request("/pipeline/progress", dataEnvelope(pipelineProgressSchema), { method: "POST", body: input })).data; + } + + public async rounds(query: PageQuery & { readonly status?: RoundStatus }): Promise> { + return request("/rounds", pageEnvelope(roundSchema), { query: { ...query } }); + } + + public async round(roundId: string): Promise { + return (await request(`/rounds/${segment(roundId)}`, dataEnvelope(roundDetailSchema))).data; + } + + public async createRound(input: CreateRoundInput): Promise { + return (await request("/rounds", dataEnvelope(roundSchema), { method: "POST", body: input })).data; + } + + public async updateRound(roundId: string, input: UpdateRoundInput): Promise { + return (await request(`/rounds/${segment(roundId)}`, dataEnvelope(roundSchema), { method: "PATCH", body: input })).data; + } + + public async deleteRound(roundId: string): Promise { + await requestNoContent(`/rounds/${segment(roundId)}`, { method: "DELETE" }); + } + + public async finalizeRound(roundId: string): Promise { + return (await request(`/rounds/${segment(roundId)}/finalize`, dataEnvelope(roundResultSchema), { method: "POST" })).data; + } + + public async leaderboard(roundId: string): Promise { + return (await request(`/rounds/${segment(roundId)}/leaderboard`, dataEnvelope(leaderboardSchema))).data; + } + + public async results(roundId: string): Promise { + return (await request(`/rounds/${segment(roundId)}/results`, dataEnvelope(roundResultSchema))).data; + } + + public async entries(roundId: string, query: PageQuery & { readonly status?: EntryStatus }): Promise> { + return request(`/rounds/${segment(roundId)}/entries`, pageEnvelope(entrySchema), { query: { ...query } }); + } + + public async createEntry(roundId: string, input: EntryInput): Promise { + return (await request(`/rounds/${segment(roundId)}/entries`, dataEnvelope(entrySchema), { method: "POST", body: input })).data; + } + + public async entry(roundId: string, entryId: string): Promise { + return (await request(`/rounds/${segment(roundId)}/entries/${segment(entryId)}`, dataEnvelope(entrySchema))).data; + } + + public async updateEntry(roundId: string, entryId: string, input: UpdateEntryInput): Promise { + const path = `/rounds/${segment(roundId)}/entries/${segment(entryId)}`; + return (await request(path, dataEnvelope(entrySchema), { method: "PATCH", body: input })).data; + } + + public async reviewEntry(roundId: string, entryId: string, status: EntryStatus): Promise { + const path = `/rounds/${segment(roundId)}/entries/${segment(entryId)}/status`; + return (await request(path, dataEnvelope(entrySchema), { method: "PATCH", body: { status } })).data; + } + + public async reinstateEntry(roundId: string, entryId: string): Promise { + const path = `/rounds/${segment(roundId)}/entries/${segment(entryId)}/reinstate`; + return (await request(path, dataEnvelope(entrySchema), { method: "POST" })).data; + } + + public async deleteEntry(roundId: string, entryId: string): Promise { + await requestNoContent(`/rounds/${segment(roundId)}/entries/${segment(entryId)}`, { method: "DELETE" }); + } + + public async myBallot(roundId: string): Promise { + return (await request(`/rounds/${segment(roundId)}/ballots/me`, dataEnvelope(ballotSchema))).data; + } + + public async castBallot(roundId: string, picks: readonly string[]): Promise { + return (await request(`/rounds/${segment(roundId)}/ballots/me`, dataEnvelope(ballotSchema), { method: "PUT", body: { picks } })) + .data; + } + + public async ledger(roundId: string, query: PageQuery): Promise> { + return request(`/rounds/${segment(roundId)}/ballots`, pageEnvelope(ledgerBallotSchema), { query: { ...query } }); + } + + public async requestMediaUpload(contentType: MediaContentType, sizeBytes: number): Promise { + return (await request("/uploads/media", dataEnvelope(presignedUploadSchema), { method: "POST", body: { contentType, sizeBytes } })) + .data; + } + + public async shots(query: ShotQuery): Promise> { + return request("/shots", pageEnvelope(shotSchema), { query: { ...query } }); + } + + public async shot(shotId: string): Promise { + return (await request(`/shots/${segment(shotId)}`, dataEnvelope(shotDetailSchema))).data; + } + + public async createShot(input: CreateShotInput): Promise { + return (await request("/shots", dataEnvelope(shotSchema), { method: "POST", body: input })).data; + } + + public async updateShot(shotId: string, input: UpdateShotInput): Promise { + return (await request(`/shots/${segment(shotId)}`, dataEnvelope(shotSchema), { method: "PATCH", body: input })).data; + } + + public async deleteShot(shotId: string): Promise { + await requestNoContent(`/shots/${segment(shotId)}`, { method: "DELETE" }); + } + + public async claimShot(shotId: string): Promise { + return (await request(`/shots/${segment(shotId)}/claim`, dataEnvelope(shotSchema), { method: "POST" })).data; + } + + public async releaseShot(shotId: string, reason: string | null): Promise { + return (await request(`/shots/${segment(shotId)}/release`, dataEnvelope(shotSchema), { method: "POST", body: { reason } })).data; + } + + public async requestDeliverableUpload(shotId: string, input: DeliverableUploadInput): Promise { + return (await request(`/shots/${segment(shotId)}/uploads`, dataEnvelope(presignedUploadSchema), { method: "POST", body: input })) + .data; + } + + public async submitWork(shotId: string, input: SubmitWorkInput): Promise { + return (await request(`/shots/${segment(shotId)}/submissions`, dataEnvelope(submissionSchema), { method: "POST", body: input })) + .data; + } + + public async reclaimExpired(): Promise { + return (await request("/shots/reclaim-expired", dataEnvelope(reclaimResultSchema), { method: "POST" })).data; + } + + public async reviewQueue(query: PageQuery): Promise> { + return request("/reviews", pageEnvelope(reviewQueueItemSchema), { query: { ...query } }); + } + + public async reviewSubmission(submissionId: string, decision: ReviewDecision, notes: string | null): Promise { + const path = `/submissions/${segment(submissionId)}/review`; + return (await request(path, dataEnvelope(submissionSchema), { method: "POST", body: { decision, notes } })).data; + } + + public async users(query: PageQuery & { readonly role?: Role }): Promise> { + return request("/users", pageEnvelope(moderatedUserSchema), { query: { ...query } }); + } + + public async setRole(userId: string, role: Role, specialties?: readonly Specialty[]): Promise { + return (await request(`/users/${segment(userId)}/role`, dataEnvelope(userSchema), { method: "PATCH", body: { role, specialties } })) + .data; + } + + public async promote(userId: string): Promise { + return (await request(`/users/${segment(userId)}/promote`, dataEnvelope(userSchema), { method: "POST" })).data; + } + + public async blacklist(userId: string, reason: string | null): Promise { + return ( + await request(`/users/${segment(userId)}/blacklist`, dataEnvelope(moderatedUserSchema), { method: "PUT", body: { reason } }) + ).data; + } + + public async liftBlacklist(userId: string): Promise { + return (await request(`/users/${segment(userId)}/blacklist`, dataEnvelope(moderatedUserSchema), { method: "DELETE" })).data; + } + + public async entryTelemetry(roundId: string, entryId: string, limit = 100): Promise { + const path = `/rounds/${segment(roundId)}/entries/${segment(entryId)}/telemetry`; + return (await request(path, dataEnvelope(z.array(raidTelemetrySchema)), { query: { limit } })).data; + } + + public async presence(discordIds: readonly string[]): Promise> { + const query = { ids: discordIds.slice(0, 20).join(",") }; + return (await request("/users/presence", dataEnvelope(z.record(z.string(), z.enum(PresenceStatus))), { query })).data; + } + + public async threadMaps(): Promise { + return (await request("/shot-thread-maps", dataEnvelope(z.array(shotThreadMapSchema)))).data; + } + + public async unbindThread(shotId: string): Promise { + await requestNoContent(`/shot-thread-maps/${segment(shotId)}`, { method: "DELETE" }); + } + + public async telemetry(roundId: string): Promise { + return (await request(`/rounds/${segment(roundId)}/telemetry`, dataEnvelope(z.array(raidTelemetrySchema)))).data; + } +} + +export const platformApi = new PlatformApi(); diff --git a/apps/web/src/Api/QueryKeys.ts b/apps/web/src/Api/QueryKeys.ts new file mode 100644 index 0000000..b36baa6 --- /dev/null +++ b/apps/web/src/Api/QueryKeys.ts @@ -0,0 +1,27 @@ +import type { PageQuery, ShotQuery } from "./PlatformApi"; + +export const queryKeys = { + me: ["me"] as const, + pipeline: ["pipeline"] as const, + rounds: (query: PageQuery & { readonly status?: string }) => ["rounds", query] as const, + roundsAll: ["rounds"] as const, + round: (roundId: string) => ["round", roundId] as const, + entries: (roundId: string, query: PageQuery & { readonly status?: string }) => ["round", roundId, "entries", query] as const, + approvedEntries: (roundId: string) => ["round", roundId, "entries", "approved"] as const, + leaderboard: (roundId: string) => ["round", roundId, "leaderboard"] as const, + results: (roundId: string) => ["round", roundId, "results"] as const, + myBallot: (roundId: string) => ["round", roundId, "ballot"] as const, + ledger: (roundId: string, page: number) => ["round", roundId, "ledger", page] as const, + telemetry: (roundId: string) => ["round", roundId, "telemetry"] as const, + shots: (query: ShotQuery) => ["shots", query] as const, + shotsAll: ["shots"] as const, + shot: (shotId: string) => ["shot", shotId] as const, + reviewQueue: (query: PageQuery) => ["reviews", query] as const, + reviewsAll: ["reviews"] as const, + users: (query: PageQuery & { readonly role?: string }) => ["users", query] as const, + usersAll: ["users"] as const, + entry: (roundId: string, entryId: string) => ["round", roundId, "entry", entryId] as const, + entryTelemetry: (roundId: string, entryId: string) => ["round", roundId, "telemetry", entryId] as const, + presence: (discordIds: readonly string[]) => ["presence", [...discordIds].sort().join(",")] as const, + threadMaps: ["thread-maps"] as const +}; diff --git a/apps/web/src/Api/Uploads.ts b/apps/web/src/Api/Uploads.ts new file mode 100644 index 0000000..4ef479e --- /dev/null +++ b/apps/web/src/Api/Uploads.ts @@ -0,0 +1,61 @@ +import type { PresignedUploadDto } from "@platform/contracts"; +import { ApiError } from "./ApiClient"; + +const browserManagedHeaders = new Set(["content-length", "host", "connection"]); + +export type UploadProgressHandler = (fraction: number) => void; + +export async function uploadToStorage( + upload: PresignedUploadDto, + file: Blob, + onProgress?: UploadProgressHandler, + signal?: AbortSignal +): Promise { + const target = new URL(upload.url); + if (target.protocol !== "https:" && target.protocol !== window.location.protocol) { + throw new ApiError(0, "INSECURE_UPLOAD", "Refusing to upload over an insecure connection."); + } + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open(upload.method, target.toString()); + for (const [name, value] of Object.entries(upload.headers)) { + if (!browserManagedHeaders.has(name.toLowerCase())) { + xhr.setRequestHeader(name, value); + } + } + xhr.upload.onprogress = (event: ProgressEvent): void => { + if (event.lengthComputable && onProgress !== undefined) { + onProgress(event.loaded / event.total); + } + }; + xhr.onload = (): void => { + if (xhr.status >= 200 && xhr.status < 300) { + onProgress?.(1); + resolve(); + } else { + reject(new ApiError(xhr.status, "UPLOAD_FAILED", "Storage rejected the upload. Try again.")); + } + }; + xhr.onerror = (): void => { + reject(new ApiError(0, "UPLOAD_FAILED", "The upload failed. Check your connection and try again.")); + }; + xhr.onabort = (): void => { + reject(new DOMException("Upload cancelled.", "AbortError")); + }; + if (signal !== undefined) { + if (signal.aborted) { + reject(new DOMException("Upload cancelled.", "AbortError")); + return; + } + signal.addEventListener( + "abort", + () => { + xhr.abort(); + }, + { once: true } + ); + } + xhr.send(file); + }); +} diff --git a/apps/web/src/Components/Common/ConfirmButton.tsx b/apps/web/src/Components/Common/ConfirmButton.tsx new file mode 100644 index 0000000..5184523 --- /dev/null +++ b/apps/web/src/Components/Common/ConfirmButton.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useState, type ComponentProps, type ReactNode } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger +} from "@/Components/Ui/alert-dialog"; +import { Button } from "@/Components/Ui/button"; + +interface ConfirmButtonProps { + readonly title: string; + readonly description: ReactNode; + readonly confirmLabel: string; + readonly onConfirm: () => void; + readonly disabled?: boolean; + readonly destructive?: boolean; + readonly size?: ComponentProps["size"]; + readonly variant?: ComponentProps["variant"]; + readonly children: ReactNode; +} + +export function ConfirmButton({ + title, + description, + confirmLabel, + onConfirm, + disabled = false, + destructive = false, + size = "sm", + variant = "outline", + children +}: ConfirmButtonProps): ReactNode { + const [open, setOpen] = useState(false); + return ( + + + + + + + {title} + {description} + + + Cancel + { + setOpen(false); + onConfirm(); + }} + > + {confirmLabel} + + + + + ); +} diff --git a/apps/web/src/Components/Common/DataList.tsx b/apps/web/src/Components/Common/DataList.tsx new file mode 100644 index 0000000..f1a84e6 --- /dev/null +++ b/apps/web/src/Components/Common/DataList.tsx @@ -0,0 +1,47 @@ +import type { Route } from "next"; +import Link from "next/link"; +import type { ReactNode } from "react"; +import { cn } from "@/Lib/Utils"; + +export function Toolbar({ children, trailing }: { readonly children: ReactNode; readonly trailing?: ReactNode }): ReactNode { + return ( +
+ {children} + {trailing !== undefined &&
{trailing}
} +
+ ); +} + +export function DataList({ children }: { readonly children: ReactNode }): ReactNode { + return
    {children}
; +} + +interface DataRowProps { + readonly href: Route; + readonly code?: ReactNode; + readonly title: ReactNode; + readonly meta?: ReactNode; + readonly fields?: ReactNode; + readonly highlight?: boolean; +} + +export function DataRow({ href, code, title, meta, fields, highlight = false }: DataRowProps): ReactNode { + return ( +
  • + + {code !== undefined && {code}} + + {title} + {meta !== undefined && {meta}} + + {fields !== undefined && {fields}} + +
  • + ); +} diff --git a/apps/web/src/Components/Common/PageHeader.tsx b/apps/web/src/Components/Common/PageHeader.tsx new file mode 100644 index 0000000..4462dec --- /dev/null +++ b/apps/web/src/Components/Common/PageHeader.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +interface PageHeaderProps { + readonly title: string; + readonly eyebrow?: string; + readonly description?: ReactNode; + readonly actions?: ReactNode; +} + +export function PageHeader({ title, eyebrow, description, actions }: PageHeaderProps): ReactNode { + return ( +
    +
    + {eyebrow !== undefined &&

    {eyebrow}

    } +

    {title}

    + {description !== undefined &&
    {description}
    } +
    + {actions !== undefined &&
    {actions}
    } +
    + ); +} diff --git a/apps/web/src/Components/Common/Pagination.tsx b/apps/web/src/Components/Common/Pagination.tsx new file mode 100644 index 0000000..1afdbde --- /dev/null +++ b/apps/web/src/Components/Common/Pagination.tsx @@ -0,0 +1,46 @@ +import type { PaginationMeta } from "@platform/contracts"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import type { ReactNode } from "react"; +import { Button } from "@/Components/Ui/button"; + +interface PaginationProps { + readonly meta: PaginationMeta; + readonly onPageChange: (page: number) => void; +} + +export function Pagination({ meta, onPageChange }: PaginationProps): ReactNode { + if (meta.totalPages <= 1) { + return null; + } + return ( + + ); +} diff --git a/apps/web/src/Components/Common/RelativeTime.tsx b/apps/web/src/Components/Common/RelativeTime.tsx new file mode 100644 index 0000000..551d8b9 --- /dev/null +++ b/apps/web/src/Components/Common/RelativeTime.tsx @@ -0,0 +1,15 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useNow } from "@/Hooks/UseNow"; +import { formatDateTime, formatRelative } from "@/Lib/Format"; + +export function RelativeTime({ value, prefix }: { readonly value: string; readonly prefix?: string }): ReactNode { + const now = useNow(); + return ( + + ); +} diff --git a/apps/web/src/Components/Common/Section.tsx b/apps/web/src/Components/Common/Section.tsx new file mode 100644 index 0000000..a045c71 --- /dev/null +++ b/apps/web/src/Components/Common/Section.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; + +interface SectionProps { + readonly title: string; + readonly description?: ReactNode; + readonly actions?: ReactNode; + readonly children: ReactNode; +} + +export function Section({ title, description, actions, children }: SectionProps): ReactNode { + return ( +
    +
    +
    +

    {title}

    + {description !== undefined &&

    {description}

    } +
    + {actions} +
    + {children} +
    + ); +} diff --git a/apps/web/src/Components/Common/StatTile.tsx b/apps/web/src/Components/Common/StatTile.tsx new file mode 100644 index 0000000..2b6c5ae --- /dev/null +++ b/apps/web/src/Components/Common/StatTile.tsx @@ -0,0 +1,35 @@ +import type { Route } from "next"; +import Link from "next/link"; +import type { ReactNode } from "react"; +import { cn } from "@/Lib/Utils"; + +interface StatTileProps { + readonly label: string; + readonly value: ReactNode; + readonly hint?: ReactNode; + readonly href?: Route; + readonly emphasis?: boolean; +} + +export function StatTile({ label, value, hint, href, emphasis = false }: StatTileProps): ReactNode { + const body = ( +
    + {label} + {value} + {hint !== undefined && {hint}} +
    + ); + return href === undefined ? ( + body + ) : ( + + {body} + + ); +} diff --git a/apps/web/src/Components/Common/States.tsx b/apps/web/src/Components/Common/States.tsx new file mode 100644 index 0000000..aecff66 --- /dev/null +++ b/apps/web/src/Components/Common/States.tsx @@ -0,0 +1,81 @@ +"use client"; + +import type { Role } from "@platform/contracts"; +import { AlertTriangle, Inbox, Lock } from "lucide-react"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; +import { describeError } from "@/Api/ApiClient"; +import { Alert, AlertDescription, AlertTitle } from "@/Components/Ui/alert"; +import { Button } from "@/Components/Ui/button"; +import { Skeleton } from "@/Components/Ui/skeleton"; +import { loginHref, useSession } from "@/Hooks/UseSession"; +import { hasAtLeast, roleLabels } from "@/Lib/Roles"; + +export function LoadingRows({ rows = 3 }: { readonly rows?: number }): ReactNode { + return ( +
    + {Array.from({ length: rows }, (_, index) => ( + + ))} +
    + ); +} + +export function ErrorState({ error, onRetry }: { readonly error: unknown; readonly onRetry?: () => void }): ReactNode { + return ( + + + Could not load this + + {describeError(error)} + {onRetry !== undefined && ( + + )} + + + ); +} + +export function EmptyState({ title, children }: { readonly title: string; readonly children?: ReactNode }): ReactNode { + return ( +
    + +

    {title}

    + {children} +
    + ); +} + +export function SignInPrompt({ message }: { readonly message: string }): ReactNode { + const pathname = usePathname(); + return ( +
    + +

    {message}

    + +
    + ); +} + +interface RequireRoleProps { + readonly role: Role; + readonly children: ReactNode; +} + +export function RequireRole({ role, children }: RequireRoleProps): ReactNode { + const { user, isLoading } = useSession(); + if (isLoading) { + return ; + } + if (user === null) { + return ; + } + if (!hasAtLeast(user, role)) { + return This area needs the {roleLabels[role]} role or higher.; + } + return children; +} diff --git a/apps/web/src/Components/Common/StatusBadge.tsx b/apps/web/src/Components/Common/StatusBadge.tsx new file mode 100644 index 0000000..6ed6f48 --- /dev/null +++ b/apps/web/src/Components/Common/StatusBadge.tsx @@ -0,0 +1,72 @@ +import { EntryStatus, RoundStatus, ShotStatus, SubmissionStatus } from "@platform/contracts"; +import type { ReactNode } from "react"; +import { Badge } from "@/Components/Ui/badge"; +import { entryStatusLabels, roundStatusLabels, shotStatusLabels, submissionStatusLabels } from "@/Lib/Format"; +import { cn } from "@/Lib/Utils"; + +export enum Tone { + Neutral = "neutral", + Positive = "positive", + Warning = "warning", + Negative = "negative", + Info = "info" +} + +const toneClasses: Readonly> = { + [Tone.Neutral]: "bg-muted text-muted-foreground", + [Tone.Positive]: "bg-success/15 text-success", + [Tone.Warning]: "bg-warning/15 text-warning", + [Tone.Negative]: "bg-destructive/12 text-destructive", + [Tone.Info]: "bg-primary/12 text-primary" +}; + +export function ToneBadge({ tone, children }: { readonly tone: Tone; readonly children: ReactNode }): ReactNode { + return ( + + {children} + + ); +} + +const roundTones: Readonly> = { + [RoundStatus.Draft]: Tone.Neutral, + [RoundStatus.Open]: Tone.Positive, + [RoundStatus.Closed]: Tone.Warning, + [RoundStatus.Finalized]: Tone.Info +}; + +const entryTones: Readonly> = { + [EntryStatus.PendingReview]: Tone.Warning, + [EntryStatus.Approved]: Tone.Positive, + [EntryStatus.Rejected]: Tone.Negative, + [EntryStatus.Flagged]: Tone.Negative +}; + +const shotTones: Readonly> = { + [ShotStatus.Available]: Tone.Positive, + [ShotStatus.Claimed]: Tone.Info, + [ShotStatus.Submitted]: Tone.Warning, + [ShotStatus.Approved]: Tone.Neutral +}; + +const submissionTones: Readonly> = { + [SubmissionStatus.PendingReview]: Tone.Warning, + [SubmissionStatus.RevisionRequested]: Tone.Negative, + [SubmissionStatus.Approved]: Tone.Positive +}; + +export function RoundStatusBadge({ status }: { readonly status: RoundStatus }): ReactNode { + return {roundStatusLabels[status]}; +} + +export function EntryStatusBadge({ status }: { readonly status: EntryStatus }): ReactNode { + return {entryStatusLabels[status]}; +} + +export function ShotStatusBadge({ status }: { readonly status: ShotStatus }): ReactNode { + return {shotStatusLabels[status]}; +} + +export function SubmissionStatusBadge({ status }: { readonly status: SubmissionStatus }): ReactNode { + return {submissionStatusLabels[status]}; +} diff --git a/apps/web/src/Components/Layout/AppShell.tsx b/apps/web/src/Components/Layout/AppShell.tsx new file mode 100644 index 0000000..aec99ff --- /dev/null +++ b/apps/web/src/Components/Layout/AppShell.tsx @@ -0,0 +1,131 @@ +"use client"; + +import { LogIn, LogOut, Moon, Sun, UserRound } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useTheme } from "next-themes"; +import type { ReactNode } from "react"; +import { Avatar, AvatarFallback, AvatarImage } from "@/Components/Ui/avatar"; +import { Button } from "@/Components/Ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@/Components/Ui/dropdown-menu"; +import { Separator } from "@/Components/Ui/separator"; +import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/Components/Ui/sidebar"; +import { Skeleton } from "@/Components/Ui/skeleton"; +import { loginHref, useLogout, useSession } from "@/Hooks/UseSession"; +import { roleLabels } from "@/Lib/Roles"; +import { safeHttpUrl } from "@/Lib/SafeUrl"; +import { AppSidebar } from "./AppSidebar"; +import { BreadcrumbProvider, Breadcrumbs } from "./Breadcrumbs"; +import { CommandMenu } from "./CommandMenu"; + +function ThemeToggle(): ReactNode { + const { resolvedTheme, setTheme } = useTheme(); + return ( + + ); +} + +function UserMenu(): ReactNode { + const pathname = usePathname(); + const { user, isLoading } = useSession(); + const logout = useLogout(); + + if (isLoading) { + return ; + } + if (user === null) { + return ( + + ); + } + const avatar = safeHttpUrl(user.avatarUrl); + return ( + + + + + + + {user.username} + {roleLabels[user.role]} + + + + + + My work + + + { + logout.mutate(); + }} + > + + Sign out + + + + ); +} + +export function AppShell({ defaultOpen, children }: { readonly defaultOpen: boolean; readonly children: ReactNode }): ReactNode { + return ( + + + + Skip to content + + + +
    + + +
    + +
    + + +
    + +
    +
    +
    + {children} +
    +
    +
    +
    + ); +} diff --git a/apps/web/src/Components/Layout/AppSidebar.tsx b/apps/web/src/Components/Layout/AppSidebar.tsx new file mode 100644 index 0000000..3bbb929 --- /dev/null +++ b/apps/web/src/Components/Layout/AppSidebar.tsx @@ -0,0 +1,96 @@ +"use client"; + +import type { UserDto } from "@platform/contracts"; +import { ExternalLink } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import type { ReactNode } from "react"; +import { useSiteConfig } from "@/Components/SiteConfig"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, + useSidebar +} from "@/Components/Ui/sidebar"; +import { useSession } from "@/Hooks/UseSession"; +import { hasAtLeast } from "@/Lib/Roles"; +import { isActivePath, navigationGroups, type NavigationItem } from "./Navigation"; + +export function isVisible(item: NavigationItem, user: UserDto | null): boolean { + if (item.requiresSession === true && user === null) { + return false; + } + return item.minimumRole === undefined || hasAtLeast(user, item.minimumRole); +} + +export function AppSidebar(): ReactNode { + const pathname = usePathname(); + const { user } = useSession(); + const { discordInviteUrl } = useSiteConfig(); + const { setOpenMobile } = useSidebar(); + + return ( + + + + Project Stairway + + + + {navigationGroups.map((group) => { + const items = group.items.filter((item) => isVisible(item, user)); + if (items.length === 0) { + return null; + } + return ( + + {group.label} + + + {items.map((item) => ( + + + { + setOpenMobile(false); + }} + > + + {item.label} + + + + ))} + + + + ); + })} + + {discordInviteUrl !== null && ( + + + + + + + Join the Discord + + + + + + )} + + + ); +} diff --git a/apps/web/src/Components/Layout/Breadcrumbs.tsx b/apps/web/src/Components/Layout/Breadcrumbs.tsx new file mode 100644 index 0000000..449b713 --- /dev/null +++ b/apps/web/src/Components/Layout/Breadcrumbs.tsx @@ -0,0 +1,89 @@ +"use client"; + +import type { Route } from "next"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { createContext, Fragment, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@/Components/Ui/breadcrumb"; +import { segmentLabels } from "./Navigation"; + +interface BreadcrumbLabels { + readonly labels: ReadonlyMap; + readonly setLabel: (segment: string, label: string | null) => void; +} + +const BreadcrumbContext = createContext({ labels: new Map(), setLabel: () => undefined }); + +export function BreadcrumbProvider({ children }: { readonly children: ReactNode }): ReactNode { + const [labels, setLabels] = useState>(new Map()); + const setLabel = useCallback((segment: string, label: string | null): void => { + setLabels((current) => { + if ((current.get(segment) ?? null) === label) { + return current; + } + const next = new Map(current); + if (label === null) { + next.delete(segment); + } else { + next.set(segment, label); + } + return next; + }); + }, []); + const value = useMemo(() => ({ labels, setLabel }), [labels, setLabel]); + return {children}; +} + +export function useBreadcrumbLabel(segment: string, label: string | undefined): void { + const { setLabel } = useContext(BreadcrumbContext); + useEffect(() => { + if (label === undefined) { + return; + } + setLabel(segment, label); + return () => { + setLabel(segment, null); + }; + }, [segment, label, setLabel]); +} + +export function Breadcrumbs(): ReactNode { + const pathname = usePathname(); + const { labels } = useContext(BreadcrumbContext); + const segments = pathname.split("/").filter((segment) => segment.length > 0); + if (segments.length === 0) { + return Overview; + } + return ( + + + {segments.map((segment, index) => { + const href = `/${segments.slice(0, index + 1).join("/")}`; + const label = labels.get(segment) ?? segmentLabels[segment] ?? "…"; + const isLast = index === segments.length - 1; + return ( + + {index > 0 && } + + {isLast ? ( + {label} + ) : ( + + {label} + + )} + + + ); + })} + + + ); +} diff --git a/apps/web/src/Components/Layout/CommandMenu.tsx b/apps/web/src/Components/Layout/CommandMenu.tsx new file mode 100644 index 0000000..ec302a2 --- /dev/null +++ b/apps/web/src/Components/Layout/CommandMenu.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Hammer, LogIn, LogOut, Moon, Search, Vote } from "lucide-react"; +import type { Route } from "next"; +import { usePathname, useRouter } from "next/navigation"; +import { useTheme } from "next-themes"; +import { useEffect, useState, type ReactNode } from "react"; +import { platformApi } from "@/Api/PlatformApi"; +import { queryKeys } from "@/Api/QueryKeys"; +import { Button } from "@/Components/Ui/button"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator +} from "@/Components/Ui/command"; +import { Kbd } from "@/Components/Ui/kbd"; +import { loginHref, useLogout, useSession } from "@/Hooks/UseSession"; +import { roundStatusLabels, shotStatusLabels } from "@/Lib/Format"; +import { isVisible } from "./AppSidebar"; +import { navigationGroups } from "./Navigation"; + +export function CommandMenu(): ReactNode { + const router = useRouter(); + const pathname = usePathname(); + const { user } = useSession(); + const logout = useLogout(); + const { resolvedTheme, setTheme } = useTheme(); + const [open, setOpen] = useState(false); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + setOpen((current) => !current); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => { + window.removeEventListener("keydown", onKeyDown); + }; + }, []); + + const roundsQuery = { page: 1, perPage: 50 }; + const rounds = useQuery({ queryKey: queryKeys.rounds(roundsQuery), queryFn: () => platformApi.rounds(roundsQuery), enabled: open }); + const shotsQuery = { page: 1, perPage: 50 }; + const shots = useQuery({ queryKey: queryKeys.shots(shotsQuery), queryFn: () => platformApi.shots(shotsQuery), enabled: open }); + + const go = (href: string): void => { + setOpen(false); + router.push(href as Route); + }; + + return ( + <> + + + + + Nothing matches that. + {navigationGroups.map((group) => { + const items = group.items.filter((item) => isVisible(item, user)); + return items.length === 0 ? null : ( + + {items.map((item) => ( + { + go(item.href); + }} + > + + {item.label} + + {item.description} + + + ))} + + ); + })} + {(rounds.data?.data.length ?? 0) > 0 && ( + + {rounds.data?.data.map((round) => ( + { + go(`/voting/${round.id}`); + }} + > + + {round.title} + {roundStatusLabels[round.status]} + + ))} + + )} + {(shots.data?.data.length ?? 0) > 0 && ( + + {shots.data?.data.map((shot) => ( + { + go(`/grabbox/${shot.id}`); + }} + > + + + {shot.shotCode} · {shot.title} + + {shotStatusLabels[shot.status]} + + ))} + + )} + + + { + setTheme(resolvedTheme === "dark" ? "light" : "dark"); + setOpen(false); + }} + > + + Toggle light and dark + + {user === null ? ( + { + window.location.assign(loginHref(pathname)); + }} + > + + Sign in with Discord + + ) : ( + { + setOpen(false); + logout.mutate(); + }} + > + + Sign out + + )} + + + + + ); +} diff --git a/apps/web/src/Components/Layout/Navigation.ts b/apps/web/src/Components/Layout/Navigation.ts new file mode 100644 index 0000000..ab9c729 --- /dev/null +++ b/apps/web/src/Components/Layout/Navigation.ts @@ -0,0 +1,108 @@ +import { Role } from "@platform/contracts"; +import { + BookOpen, + ClipboardCheck, + Gauge, + Hammer, + Home, + LayoutDashboard, + ListChecks, + Map, + UserRound, + Users, + Vote, + type LucideIcon +} from "lucide-react"; +import type { Route } from "next"; + +export interface NavigationItem { + readonly href: Route; + readonly label: string; + readonly description: string; + readonly icon: LucideIcon; + readonly minimumRole?: Role; + readonly requiresSession?: boolean; + readonly exact?: boolean; +} + +export interface NavigationGroup { + readonly label: string; + readonly items: readonly NavigationItem[]; +} + +export const navigationGroups: readonly NavigationGroup[] = [ + { + label: "Take part", + items: [ + { href: "/", label: "Overview", description: "What's happening on the project right now", icon: Home, exact: true }, + { href: "/voting", label: "Vote", description: "Pitch ideas and rank your favourites", icon: Vote }, + { href: "/grabbox", label: "Grab-box", description: "Claim a production task and deliver it", icon: Hammer }, + { href: "/roadmap", label: "Roadmap", description: "The road from pitch to final mix", icon: Map }, + { href: "/me", label: "My work", description: "Your role, claims and submissions", icon: UserRound, requiresSession: true } + ] + }, + { + label: "Learn", + items: [{ href: "/guidelines", label: "Guidelines", description: "How voting, contributing and fair play work", icon: BookOpen }] + }, + { + label: "Studio", + items: [ + { + href: "/studio", + label: "Dashboard", + description: "Everything waiting on staff", + icon: LayoutDashboard, + minimumRole: Role.Moderator, + exact: true + }, + { + href: "/studio/rounds", + label: "Rounds", + description: "Run voting rounds and moderate entries", + icon: ListChecks, + minimumRole: Role.Moderator + }, + { + href: "/studio/reviews", + label: "Reviews", + description: "Approve delivered work", + icon: ClipboardCheck, + minimumRole: Role.Supervisor + }, + { + href: "/studio/tasks", + label: "Tasks", + description: "Create and manage grab-box tasks", + icon: Gauge, + minimumRole: Role.Supervisor + }, + { + href: "/studio/people", + label: "People", + description: "Roles, specialties and voting access", + icon: Users, + minimumRole: Role.Moderator + } + ] + } +]; + +export const segmentLabels: Readonly> = { + voting: "Vote", + grabbox: "Grab-box", + roadmap: "Roadmap", + me: "My work", + guidelines: "Guidelines", + studio: "Studio", + rounds: "Rounds", + reviews: "Reviews", + tasks: "Tasks", + people: "People", + auth: "Sign in", + callback: "Signing in" +}; + +export function isActivePath(pathname: string, item: NavigationItem): boolean { + return item.exact === true ? pathname === item.href : pathname === item.href || pathname.startsWith(`${item.href}/`); +} diff --git a/apps/web/src/Components/Providers.tsx b/apps/web/src/Components/Providers.tsx new file mode 100644 index 0000000..aa019ad --- /dev/null +++ b/apps/web/src/Components/Providers.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ThemeProvider } from "next-themes"; +import { useState, type ReactNode } from "react"; +import { toast } from "sonner"; +import { ApiError, describeError } from "@/Api/ApiClient"; +import { queryKeys } from "@/Api/QueryKeys"; +import { Toaster } from "@/Components/Ui/sonner"; +import { TooltipProvider } from "@/Components/Ui/tooltip"; + +interface ProvidersProps { + readonly nonce: string | undefined; + readonly children: ReactNode; +} + +function shouldRetry(failureCount: number, error: unknown): boolean { + if (error instanceof ApiError && error.status >= 400 && error.status < 500) { + return false; + } + return failureCount < 2; +} + +function createQueryClient(): QueryClient { + const client: QueryClient = new QueryClient({ + queryCache: new QueryCache({ + onError: (error) => { + if (error instanceof ApiError && error.status === 401) { + client.setQueryData(queryKeys.me, null); + } + } + }), + mutationCache: new MutationCache({ + onError: (error, _variables, _context, mutation) => { + if (error instanceof ApiError && error.status === 401) { + client.setQueryData(queryKeys.me, null); + } + if (mutation.options.onError === undefined) { + toast.error(describeError(error)); + } + } + }), + defaultOptions: { + queries: { staleTime: 15_000, retry: shouldRetry, refetchOnWindowFocus: true }, + mutations: { retry: false } + } + }); + return client; +} + +export function Providers({ nonce, children }: ProvidersProps): ReactNode { + const [queryClient] = useState(createQueryClient); + return ( + + + + {children} + + + + + ); +} diff --git a/apps/web/src/Components/SiteConfig.tsx b/apps/web/src/Components/SiteConfig.tsx new file mode 100644 index 0000000..cf6aa9b --- /dev/null +++ b/apps/web/src/Components/SiteConfig.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { createContext, useContext, type ReactNode } from "react"; + +export interface SiteConfig { + readonly discordGuildId: string | null; + readonly discordInviteUrl: string | null; +} + +const SiteConfigContext = createContext({ discordGuildId: null, discordInviteUrl: null }); + +export function SiteConfigProvider({ value, children }: { readonly value: SiteConfig; readonly children: ReactNode }): ReactNode { + return {children}; +} + +export function useSiteConfig(): SiteConfig { + return useContext(SiteConfigContext); +} + +export function discordThreadUrl(config: SiteConfig, threadId: string): string | null { + return config.discordGuildId === null ? null : `https://discord.com/channels/${config.discordGuildId}/${threadId}`; +} diff --git a/apps/web/src/Components/Ui/alert-dialog.tsx b/apps/web/src/Components/Ui/alert-dialog.tsx new file mode 100644 index 0000000..d40e49c --- /dev/null +++ b/apps/web/src/Components/Ui/alert-dialog.tsx @@ -0,0 +1,158 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/Lib/Utils"; +import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; + +import { Button } from "@/Components/Ui/button"; + +function AlertDialog({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogPortal({ ...props }: React.ComponentProps) { + return ; +} + +function AlertDialogOverlay({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm"; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function AlertDialogTitle({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogMedia({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & Pick, "variant" | "size">) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & Pick, "variant" | "size">) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger +}; diff --git a/apps/web/src/Components/Ui/alert.tsx b/apps/web/src/Components/Ui/alert.tsx new file mode 100644 index 0000000..9877471 --- /dev/null +++ b/apps/web/src/Components/Ui/alert.tsx @@ -0,0 +1,40 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/Lib/Utils"; + +const alertVariants = cva( + "relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current" + } + }, + defaultVariants: { + variant: "default" + } + } +); + +function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps) { + return
    ; +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +export { Alert, AlertTitle, AlertDescription }; diff --git a/apps/web/src/Components/Ui/avatar.tsx b/apps/web/src/Components/Ui/avatar.tsx new file mode 100644 index 0000000..fca1be4 --- /dev/null +++ b/apps/web/src/Components/Ui/avatar.tsx @@ -0,0 +1,83 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/Lib/Utils"; +import { Avatar as AvatarPrimitive } from "radix-ui"; + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg"; +}) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: React.ComponentProps) { + return ; +} + +function AvatarFallback({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ); +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ); +} + +function AvatarGroupCount({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ); +} + +export { Avatar, AvatarImage, AvatarFallback, AvatarBadge, AvatarGroup, AvatarGroupCount }; diff --git a/apps/web/src/Components/Ui/badge.tsx b/apps/web/src/Components/Ui/badge.tsx new file mode 100644 index 0000000..2ecd781 --- /dev/null +++ b/apps/web/src/Components/Ui/badge.tsx @@ -0,0 +1,37 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/Lib/Utils"; +import { Slot } from "radix-ui"; + +const badgeVariants = cva( + "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", + outline: "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]:hover:underline" + } + }, + defaultVariants: { + variant: "default" + } + } +); + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span"; + + return ; +} + +export { Badge, badgeVariants }; diff --git a/apps/web/src/Components/Ui/breadcrumb.tsx b/apps/web/src/Components/Ui/breadcrumb.tsx new file mode 100644 index 0000000..4628cbe --- /dev/null +++ b/apps/web/src/Components/Ui/breadcrumb.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import { cn } from "@/Lib/Utils"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; +import { Slot } from "radix-ui"; + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return