diff --git a/.env.example b/.env.example index 4d55dd8..f55098c 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ DISCORD_GUILD_ID= SAL_OPERATOR_ROLE_IDS= # Comma-separated Discord admin role IDs that override the operator allowlist. SAL_ADMIN_ROLE_IDS= +# Optional narrower allowlist for the /report-result Enter stats button. +# Falls back to SAL_OPERATOR_ROLE_IDS when unset; admins always retain access. +SAL_MATCH_STATS_ROLE_IDS= # Railway injects PORT; 3000 is the local default for /healthz. PORT=3000 @@ -26,11 +29,9 @@ SUPABASE_URL= SUPABASE_ANON_KEY= SUPABASE_SERVICE_ROLE_KEY= -# sal-site standings recalculation (audit F-01). After a Discord-approved -# match result, the bot calls back into sal-site so standings recalculate the -# same way an admin save does. Set SAL_SITE_INTERNAL_TOKEN to the same value -# as sal-site's INTERNAL_SERVICE_TOKEN. If either is unset, the bot logs a -# warning and skips recalculation — an admin can still recalculate manually. +# sal-site internal boundary for host match-stat links, scouter ingestion, and +# standings recalculation. SAL_SITE_INTERNAL_TOKEN must match sal-site's +# INTERNAL_SERVICE_TOKEN. SAL_SITE_URL= SAL_SITE_INTERNAL_TOKEN= diff --git a/README.md b/README.md index d23f3bf..47e3e45 100644 --- a/README.md +++ b/README.md @@ -72,12 +72,15 @@ This distinction matters for: An authorized Discord operator selects a current-season match from a Supabase-driven dropdown → enters winner + score → system creates: 1. Public embed in `#match-results-[division]` -2. Dedicated proof thread for screenshot upload +2. Dedicated proof thread with an **Enter stats** button 3. Admin review card in `#admin-review` 4. Pending action in Supabase 5. Audit log entry -Screenshots are uploaded to the proof thread, not inline to the command. This supports 6–10 screenshots per match without degrading UX. +The host uploads screenshots once in the sal-site correction flow. After host +submission, SALBot mirrors the durable stored images into the proof thread and +posts an idempotent admin stats-review card. Official stats remain +site/database approval-only. `/report-result` and `/log-scouter` authorize from the centrally configured `SAL_OPERATOR_ROLE_IDS` and `SAL_ADMIN_ROLE_IDS`. OAuth/player linkage supplies diff --git a/apps/bot/src/commands/report-result-stats.test.ts b/apps/bot/src/commands/report-result-stats.test.ts new file mode 100644 index 0000000..3f82bff --- /dev/null +++ b/apps/bot/src/commands/report-result-stats.test.ts @@ -0,0 +1,408 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { Collection, EmbedBuilder } from 'discord.js'; + +vi.mock('../lib/db', () => ({ db: { from: vi.fn() } })); +vi.mock('@salbot/db', () => ({ + createPendingAction: vi.fn(), + createMatchResultActionWithReport: vi.fn(), + ensureMatchReportForPendingAction: vi.fn(), + getActiveMatchResultPendingAction: vi.fn(), + getEligibleMatchesForOperator: vi.fn(), + setProofThread: vi.fn(), + updatePendingActionMessages: vi.fn(), +})); +vi.mock('../lib/channels', () => ({ + getAdminReviewChannelId: () => 'admin-channel', + getResultsChannelId: () => 'results-channel', +})); +vi.mock('../lib/proof-thread', () => ({ createProofThread: vi.fn() })); +vi.mock('../lib/command-access', () => ({ hasCommandAccess: vi.fn() })); +vi.mock('../lib/match-report-site', () => ({ + issueMatchReportHostReviewLink: vi.fn(), +})); + +import { + createPendingAction, + createMatchResultActionWithReport, + ensureMatchReportForPendingAction, + getActiveMatchResultPendingAction, + updatePendingActionMessages, +} from '@salbot/db'; +import { db } from '../lib/db'; +import { createProofThread } from '../lib/proof-thread'; +import { hasCommandAccess } from '../lib/command-access'; +import { issueMatchReportHostReviewLink } from '../lib/match-report-site'; +import { handleEnterStatsButton, handleScoreModal } from './report-result'; + +const match = { + id: 'match-1', + week: 2, + scheduled_date: '2026-08-20', + scheduled_time: '20:00', + division_id: 'division-1', + home_org_id: 'org-home', + away_org_id: 'org-away', + home_org: { id: 'org-home', name: 'Home Org', tag: 'HOME' }, + away_org: { id: 'org-away', name: 'Away Org', tag: 'AWAY' }, + division: { id: 'division-1', name: 'Solar' }, + proof_thread_id: null as string | null, + proof_thread_url: null, +}; +let currentMatch = { ...match }; + +describe('/report-result match stats entry', () => { + beforeEach(() => { + vi.resetAllMocks(); + currentMatch = { ...match }; + vi.mocked(db.from).mockReturnValue({ + select: () => ({ + eq: () => ({ single: vi.fn(async () => ({ data: currentMatch, error: null })) }), + }), + } as never); + }); + + it('links one canonical match report and posts a durable Enter stats button', async () => { + vi.mocked(createMatchResultActionWithReport).mockResolvedValue({ + code: 'created', + created: true, + actionId: 'action-1', + reportId: 'report-1', + pendingActionId: 'action-1', + matchId: 'match-1', + hostDiscordId: 'host-1', + status: 'pending', + revision: 0, + }); + vi.mocked(getActiveMatchResultPendingAction).mockResolvedValue({ + id: 'action-1', + matchId: 'match-1', + requestedByDiscordId: 'host-1', + status: 'pending', + payloadJson: { winnerOrgId: 'org-home', score: '2-1', parsed: { winnerGames: 2, loserGames: 1, gamesPlayed: 3, expectedScreenshots: 3 } }, + adminReviewMessageId: null, + publicReceiptMessageId: null, + }); + const receiptSend = vi.fn().mockResolvedValue({ id: 'receipt-1' }); + const adminSend = vi.fn().mockResolvedValue({ id: 'review-1' }); + const proofSend = vi.fn().mockResolvedValue({ id: 'stats-entry-1' }); + vi.mocked(createProofThread).mockResolvedValue({ + id: 'thread-1', + url: 'https://discord.example/thread-1', + send: proofSend, + } as never); + const interaction = scoreInteraction(receiptSend, adminSend); + + await handleScoreModal(interaction as never); + + expect(createMatchResultActionWithReport).toHaveBeenCalledWith( + db, + 'match-1', + 'host-1', + { winnerOrgId: 'org-home', score: '2-1', parsed: { winnerGames: 2, loserGames: 1, gamesPlayed: 3, expectedScreenshots: 3 } }, + ); + expect(createPendingAction).not.toHaveBeenCalled(); + expect(ensureMatchReportForPendingAction).not.toHaveBeenCalled(); + expect(createProofThread).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + 'match-1', + 'home-vs-away', + 2, + 3, + ); + const entryMessage = proofSend.mock.calls[0][0]; + expect(entryMessage.content).toContain('Enter stats'); + expect(entryMessage.components[0].toJSON().components[0]).toMatchObject({ + custom_id: 'mr_stats:report-1', + label: 'Enter stats', + }); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.stringContaining('Enter stats'), + ); + const adminCard = adminSend.mock.calls[0][0]; + expect(adminCard.embeds[0].toJSON().title).toContain('Waiting for Host Stats'); + expect(adminCard.components[0].toJSON().components).toEqual([ + expect.objectContaining({ custom_id: 'deny:action-1', label: 'Deny' }), + expect.objectContaining({ custom_id: 'needs_info:action-1', label: '⚠️ Needs Info' }), + ]); + }); + + it('repairs a retried submission without reposting the receipt or admin review', async () => { + vi.mocked(createMatchResultActionWithReport).mockResolvedValue({ + code: 'existing', created: false, actionId: 'action-1', pendingActionId: 'action-1', + reportId: 'report-1', matchId: 'match-1', hostDiscordId: 'host-1', status: 'pending', revision: 0, + }); + vi.mocked(getActiveMatchResultPendingAction).mockResolvedValue({ + id: 'action-1', + matchId: 'match-1', + requestedByDiscordId: 'host-1', + status: 'pending', + payloadJson: { winnerOrgId: 'org-home', score: '2-1', parsed: { winnerGames: 2, loserGames: 1, gamesPlayed: 3, expectedScreenshots: 3 } }, + adminReviewMessageId: 'review-1', + publicReceiptMessageId: 'receipt-1', + }); + const receiptSend = vi.fn(); + const adminSend = vi.fn(); + const proofSend = vi.fn(); + const receiptMessage = { id: 'receipt-1', embeds: [], thread: null }; + const adminMessage = { id: 'review-1', embeds: [] }; + const proofMessages = new Collection(); + proofMessages.set('stats-entry-1', { + components: [{ components: [{ customId: 'mr_stats:report-1' }] }], + } as never); + const proofThread = { + id: 'thread-1', + url: 'https://discord.example/thread-1', + isThread: () => true, + archived: false, + messages: { fetch: vi.fn().mockResolvedValue(proofMessages) }, + send: proofSend, + }; + receiptMessage.thread = proofThread as never; + currentMatch = { ...match, proof_thread_id: 'thread-1' }; + vi.mocked(db.from).mockReturnValue({ + select: () => ({ + eq: () => ({ single: vi.fn().mockResolvedValue({ data: currentMatch, error: null }) }), + }), + } as never); + const interaction = scoreInteraction(receiptSend, adminSend, { + receiptMessage, + adminMessage, + proofThread, + }); + + await handleScoreModal(interaction as never); + + expect(getActiveMatchResultPendingAction).toHaveBeenCalledWith(db, 'match-1'); + expect(createPendingAction).not.toHaveBeenCalled(); + expect(ensureMatchReportForPendingAction).not.toHaveBeenCalled(); + expect(receiptSend).not.toHaveBeenCalled(); + expect(adminSend).not.toHaveBeenCalled(); + expect(createProofThread).not.toHaveBeenCalled(); + expect(proofSend).not.toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith(expect.objectContaining({ + content: expect.stringContaining('already awaiting review'), + })); + }); + + it('recreates missing Discord artifacts after a crash and persists their IDs', async () => { + vi.mocked(createMatchResultActionWithReport).mockResolvedValue({ + code: 'existing', created: false, actionId: 'action-1', pendingActionId: 'action-1', + reportId: 'report-1', matchId: 'match-1', hostDiscordId: 'host-1', status: 'pending', revision: 0, + }); + vi.mocked(getActiveMatchResultPendingAction).mockResolvedValue({ + id: 'action-1', + matchId: 'match-1', + requestedByDiscordId: 'host-1', + status: 'pending', + payloadJson: { winnerOrgId: 'org-home', score: '2-1', parsed: { winnerGames: 2, loserGames: 1, gamesPlayed: 3, expectedScreenshots: 3 } }, + adminReviewMessageId: null, + publicReceiptMessageId: null, + }); + const receiptSend = vi.fn().mockResolvedValue({ id: 'receipt-recovered' }); + const adminSend = vi.fn().mockResolvedValue({ id: 'review-recovered' }); + const proofSend = vi.fn().mockResolvedValue({ id: 'stats-entry-recovered' }); + vi.mocked(createProofThread).mockResolvedValue({ + id: 'thread-recovered', + url: 'https://discord.example/thread-recovered', + messages: { fetch: vi.fn().mockResolvedValue(new Collection()) }, + send: proofSend, + } as never); + const interaction = scoreInteraction(receiptSend, adminSend); + + await handleScoreModal(interaction as never); + + expect(receiptSend).toHaveBeenCalledTimes(1); + expect(createProofThread).toHaveBeenCalledTimes(1); + expect(proofSend).toHaveBeenCalledWith(expect.objectContaining({ + components: expect.any(Array), + })); + expect(adminSend).toHaveBeenCalledTimes(1); + expect(updatePendingActionMessages).toHaveBeenCalledWith(db, 'action-1', { + adminReviewMessageId: 'review-recovered', + publicReceiptMessageId: 'receipt-recovered', + }); + }); + + it('recovers orphaned receipt and admin messages by their stable action marker', async () => { + vi.mocked(createMatchResultActionWithReport).mockResolvedValue({ + code: 'existing', created: false, actionId: 'action-1', pendingActionId: 'action-1', + reportId: 'report-1', matchId: 'match-1', hostDiscordId: 'host-1', status: 'pending', revision: 0, + }); + vi.mocked(getActiveMatchResultPendingAction).mockResolvedValue({ + id: 'action-1', + matchId: 'match-1', + requestedByDiscordId: 'host-1', + status: 'pending', + payloadJson: { winnerOrgId: 'org-home', score: '2-1', parsed: { winnerGames: 2, loserGames: 1, gamesPlayed: 3, expectedScreenshots: 3 } }, + adminReviewMessageId: null, + publicReceiptMessageId: null, + }); + const receiptSend = vi.fn(); + const adminSend = vi.fn(); + const proofSend = vi.fn(); + const proofMessages = new Collection(); + proofMessages.set('stats-entry-1', { + components: [{ components: [{ customId: 'mr_stats:report-1' }] }], + } as never); + const proofThread = { + id: 'thread-1', + url: 'https://discord.example/thread-1', + isThread: () => true, + archived: false, + messages: { fetch: vi.fn().mockResolvedValue(proofMessages) }, + send: proofSend, + }; + const receiptMessage = { + id: 'receipt-orphan', + embeds: [new EmbedBuilder().setFooter({ text: 'Pending admin review • Action ID: action-1' }).toJSON()], + thread: proofThread, + }; + const adminMessage = { + id: 'review-orphan', + embeds: [new EmbedBuilder().setFooter({ text: 'Action ID: action-1' }).toJSON()], + }; + const receiptRecent = new Collection(); + receiptRecent.set(receiptMessage.id, receiptMessage as never); + const adminRecent = new Collection(); + adminRecent.set(adminMessage.id, adminMessage as never); + const interaction = scoreInteraction(receiptSend, adminSend, { + receiptRecent, + adminRecent, + }); + + await handleScoreModal(interaction as never); + + expect(receiptSend).not.toHaveBeenCalled(); + expect(adminSend).not.toHaveBeenCalled(); + expect(createProofThread).not.toHaveBeenCalled(); + expect(proofSend).not.toHaveBeenCalled(); + expect(updatePendingActionMessages).toHaveBeenCalledWith(db, 'action-1', { + adminReviewMessageId: 'review-orphan', + publicReceiptMessageId: 'receipt-orphan', + }); + }); + + it('does not expose Enter stats to an operator when the existing action belongs to another host', async () => { + vi.mocked(createMatchResultActionWithReport).mockResolvedValue({ + code: 'existing', created: false, actionId: 'action-1', pendingActionId: 'action-1', + reportId: 'report-1', matchId: 'match-1', hostDiscordId: 'original-host', status: 'pending', revision: 0, + }); + vi.mocked(getActiveMatchResultPendingAction).mockResolvedValue({ + id: 'action-1', + matchId: 'match-1', + requestedByDiscordId: 'original-host', + status: 'pending', + payloadJson: { winnerOrgId: 'org-home', score: '2-1', parsed: { winnerGames: 2, loserGames: 1, gamesPlayed: 3, expectedScreenshots: 3 } }, + adminReviewMessageId: null, + publicReceiptMessageId: null, + }); + const receiptSend = vi.fn().mockResolvedValue({ id: 'receipt-recovered' }); + const adminSend = vi.fn().mockResolvedValue({ id: 'review-recovered' }); + vi.mocked(createProofThread).mockResolvedValue({ + id: 'thread-recovered', + url: 'https://discord.example/thread-recovered', + send: vi.fn().mockResolvedValue({ id: 'stats-entry-recovered' }), + } as never); + const interaction = scoreInteraction(receiptSend, adminSend); + + await handleScoreModal(interaction as never); + + expect(interaction.editReply).toHaveBeenCalledWith( + expect.not.stringContaining('Use **Enter stats** below'), + ); + }); + + it('rechecks the match-stats capability before issuing an ephemeral host link', async () => { + vi.mocked(hasCommandAccess).mockReturnValue(true); + vi.mocked(issueMatchReportHostReviewLink).mockResolvedValue({ + reviewUrl: 'https://sal.example/match-reports/report-1/review#token=secret', + expiresAt: '2026-08-19T01:02:03.000Z', + }); + const editReply = vi.fn(); + const interaction = { + customId: 'mr_stats:report-1', + member: { roles: ['role-1'] }, + user: { id: 'host-1' }, + deferReply: vi.fn(), + editReply, + }; + + await handleEnterStatsButton(interaction as never); + + expect(hasCommandAccess).toHaveBeenCalledWith( + interaction.member, + 'enter-match-stats', + ); + expect(issueMatchReportHostReviewLink).toHaveBeenCalledWith( + 'report-1', + 'host-1', + ); + expect(interaction.deferReply).toHaveBeenCalledWith({ ephemeral: true }); + expect(editReply.mock.calls[0][0].components[0].toJSON().components[0]).toMatchObject({ + label: 'Open stats review', + url: 'https://sal.example/match-reports/report-1/review#token=secret', + }); + }); + + it('does not call sal-site when the member lost the required capability', async () => { + vi.mocked(hasCommandAccess).mockReturnValue(false); + const reply = vi.fn(); + + await handleEnterStatsButton({ + customId: 'mr_stats:report-1', + member: { roles: [] }, + user: { id: 'host-1' }, + reply, + } as never); + + expect(issueMatchReportHostReviewLink).not.toHaveBeenCalled(); + expect(reply).toHaveBeenCalledWith({ + content: 'You no longer have permission to enter match stats.', + ephemeral: true, + }); + }); +}); + +function scoreInteraction( + receiptSend: ReturnType, + adminSend: ReturnType, + existing: { + receiptMessage?: unknown; + adminMessage?: unknown; + proofThread?: unknown; + receiptRecent?: Collection; + adminRecent?: Collection; + } = {}, +) { + const messages = ( + storedId: string, + storedMessage: unknown, + recent: Collection = new Collection(), + ) => ({ + fetch: vi.fn(async (value: unknown) => { + if (typeof value === 'string') { + if (value === storedId && storedMessage) return storedMessage; + throw new Error('Unknown Message'); + } + return recent; + }), + }); + return { + customId: 'rr_score:match-1:org-home', + fields: { getTextInputValue: () => '2-1' }, + user: { id: 'host-1' }, + deferReply: vi.fn(), + editReply: vi.fn(), + client: { + channels: { + fetch: vi.fn(async (id: string) => { + if (id === 'proof-thread-1') return existing.proofThread; + return id === 'results-channel' + ? { send: receiptSend, messages: messages('receipt-1', existing.receiptMessage, existing.receiptRecent) } + : { send: adminSend, messages: messages('review-1', existing.adminMessage, existing.adminRecent) }; + }), + }, + }, + }; +} diff --git a/apps/bot/src/commands/report-result.ts b/apps/bot/src/commands/report-result.ts index 80f38da..f81dc8e 100644 --- a/apps/bot/src/commands/report-result.ts +++ b/apps/bot/src/commands/report-result.ts @@ -1,6 +1,13 @@ -import type { ChatInputCommandInteraction, StringSelectMenuInteraction, ModalSubmitInteraction, TextChannel } from 'discord.js'; +import type { + ButtonInteraction, + ChatInputCommandInteraction, + ModalSubmitInteraction, + StringSelectMenuInteraction, +} from 'discord.js'; import { ActionRowBuilder, + ButtonBuilder, + ButtonStyle, StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, @@ -8,21 +15,19 @@ import { } from 'discord.js'; import { getEligibleMatchesForOperator, - createPendingAction, - updatePendingActionMessages, + createMatchResultActionWithReport, + getActiveMatchResultPendingAction, } from '@salbot/db'; import { parseScore } from '@salbot/shared'; import type { MatchResultPayload } from '@salbot/shared'; import { db } from '../lib/db'; -import { getAdminReviewChannelId, getResultsChannelId } from '../lib/channels'; -import { - buildMatchResultReceiptEmbed, - buildMatchResultAdminEmbed, - buildApprovalButtons, -} from '../lib/embeds'; -import { createProofThread } from '../lib/proof-thread'; -import { isUniqueViolation } from '../lib/errors'; +import { buildEnterStatsButton } from '../lib/embeds'; import { hasCommandAccess } from '../lib/command-access'; +import { issueMatchReportHostReviewLink } from '../lib/match-report-site'; +import { + ensureMatchResultDiscordArtifacts, + type MatchResultArtifactAction, +} from '../lib/match-result-discord'; export const data = { name: 'report-result', @@ -150,6 +155,7 @@ export async function handleScoreModal(interaction: ModalSubmitInteraction) { .from('matches') .select(` id, week, scheduled_date, scheduled_time, division_id, + proof_thread_id, proof_thread_url, home_org_id, away_org_id, home_org:orgs!home_org_id(id, name, tag), away_org:orgs!away_org_id(id, name, tag), @@ -166,7 +172,6 @@ export async function handleScoreModal(interaction: ModalSubmitInteraction) { const homeOrg = match.home_org as unknown as { id: string; name: string; tag: string }; const awayOrg = match.away_org as unknown as { id: string; name: string; tag: string }; const division = match.division as unknown as { id: string; name: string }; - const winnerOrg = winnerOrgId === homeOrg.id ? homeOrg : awayOrg; const matchInfo = { id: match.id as string, week: match.week as number, @@ -179,59 +184,70 @@ export async function handleScoreModal(interaction: ModalSubmitInteraction) { const payload: MatchResultPayload = { winnerOrgId, score: scoreRaw, parsed }; - let pendingAction; - try { - pendingAction = await createPendingAction(db, { - type: 'match_result', - requestedByDiscordId: interaction.user.id, - matchId, - divisionId: match.division_id as string, - payloadJson: payload as unknown as Record, - }); - } catch (err) { - // Postgres unique_violation (022_pending_result_uniqueness.sql on sal-site) — - // a match_result pending_action already exists for this match in a - // pending/pending_info state. Surface a friendly message instead of a raw error. - if (isUniqueViolation(err)) { - await interaction.editReply( - 'A result for this match is already awaiting review — an admin needs to process the existing submission first.' - ); - return; - } - throw err; - } - - // Public receipt - const resultsChannelId = getResultsChannelId(match.division_id as string); - const resultsChannel = await interaction.client.channels.fetch(resultsChannelId) as TextChannel; - const receiptEmbed = buildMatchResultReceiptEmbed(matchInfo, winnerOrg, scoreRaw, interaction.user.id); - const receiptMsg = await resultsChannel.send({ embeds: [receiptEmbed] }); - - // Proof thread - const matchLabel = `${homeOrg.tag.toLowerCase()}-vs-${awayOrg.tag.toLowerCase()}`; - const proofThread = await createProofThread( - resultsChannel, - receiptMsg, + const matchReport = await createMatchResultActionWithReport( + db, matchId, - matchLabel, - match.week as number, - parsed.expectedScreenshots + interaction.user.id, + payload, ); - - // Admin review card - const adminChannel = await interaction.client.channels.fetch(getAdminReviewChannelId()) as TextChannel; - const adminEmbed = buildMatchResultAdminEmbed(matchInfo, winnerOrg, scoreRaw, interaction.user.id, pendingAction.id); - const reviewMsg = await adminChannel.send({ - embeds: [adminEmbed], - components: [buildApprovalButtons(pendingAction.id)], + const activeAction = await getActiveMatchResultPendingAction(db, matchId); + if (!activeAction + || activeAction.id !== matchReport.actionId + || activeAction.requestedByDiscordId !== matchReport.hostDiscordId) { + throw new Error('Atomic match-result creation returned inconsistent recovery state.'); + } + const pendingAction: MatchResultArtifactAction = activeAction; + const recovered = !matchReport.created; + const winnerOrg = pendingAction.payloadJson.winnerOrgId === homeOrg.id ? homeOrg : awayOrg; + const proofThread = await ensureMatchResultDiscordArtifacts({ + client: interaction.client, + action: pendingAction, + reportId: matchReport.reportId, + match: { + ...matchInfo, + divisionId: match.division_id as string, + proofThreadId: match.proof_thread_id as string | null, + }, + winnerOrg, }); - await updatePendingActionMessages(db, pendingAction.id, { - adminReviewMessageId: reviewMsg.id, - publicReceiptMessageId: receiptMsg.id, - }); + if (recovered) { + const content = 'A result for this match is already awaiting review — its receipt, proof thread, and admin card were recovered.'; + if (pendingAction.requestedByDiscordId === interaction.user.id) { + await interaction.editReply({ + content: `${content} Use **Enter stats** below to continue.`, + components: [buildEnterStatsButton(matchReport.reportId)], + }); + } else { + await interaction.editReply(content); + } + } else { + await interaction.editReply( + `✅ Result submitted and waiting for host stats.\n📊 Use **Enter stats** in the proof thread to upload screenshots and review the extraction: ${proofThread.url}`, + ); + } +} + +export async function handleEnterStatsButton(interaction: ButtonInteraction) { + if (!hasCommandAccess(interaction.member, 'enter-match-stats')) { + await interaction.reply({ + content: 'You no longer have permission to enter match stats.', + ephemeral: true, + }); + return; + } - await interaction.editReply( - `✅ Result submitted for admin review.\n📸 Upload your proof screenshots here: ${proofThread.url}` + const [, reportId] = interaction.customId.split(':'); + await interaction.deferReply({ ephemeral: true }); + const link = await issueMatchReportHostReviewLink(reportId, interaction.user.id); + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel('Open stats review') + .setStyle(ButtonStyle.Link) + .setURL(link.reviewUrl), ); + await interaction.editReply({ + content: 'This private link is bound to your Discord account and expires after use.', + components: [row], + }); } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 8606f11..c10ab4c 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -190,6 +190,10 @@ client.on('interactionCreate', async (interaction) => { // Buttons if (interaction.isButton()) { + if (interaction.customId.startsWith('mr_stats:')) { + await reportResult.handleEnterStatsButton(interaction); + return; + } if (interaction.customId.startsWith('sc_up:')) { await logScouter.handleUploadButton(interaction); return; diff --git a/apps/bot/src/lib/command-access.test.ts b/apps/bot/src/lib/command-access.test.ts index 0bc57a0..8ad485c 100644 --- a/apps/bot/src/lib/command-access.test.ts +++ b/apps/bot/src/lib/command-access.test.ts @@ -8,6 +8,7 @@ const OPERATOR_ROLES = [ '444444444444444444', ]; const ADMIN_ROLE = '555555555555555555'; +const MATCH_STATS_ROLE = '666666666666666666'; function member(...roleIds: string[]) { return { roles: roleIds }; @@ -34,6 +35,26 @@ describe('Discord role-backed operational command access', () => { expect(hasCommandAccess(member(ADMIN_ROLE), 'report-result')).toBe(true); expect(hasCommandAccess(member(ADMIN_ROLE), 'log-scouter')).toBe(true); + expect(hasCommandAccess(member(ADMIN_ROLE), 'enter-match-stats')).toBe(true); + }); + + it('uses the match-stats capability override without widening report-result access', () => { + process.env.SAL_OPERATOR_ROLE_IDS = OPERATOR_ROLES.join(','); + process.env.SAL_ADMIN_ROLE_IDS = ADMIN_ROLE; + process.env.SAL_MATCH_STATS_ROLE_IDS = MATCH_STATS_ROLE; + + expect(hasCommandAccess(member(MATCH_STATS_ROLE), 'enter-match-stats')).toBe(true); + expect(hasCommandAccess(member(MATCH_STATS_ROLE), 'report-result')).toBe(false); + expect(hasCommandAccess(member(OPERATOR_ROLES[0]), 'enter-match-stats')).toBe(false); + expect(hasCommandAccess(member(ADMIN_ROLE), 'enter-match-stats')).toBe(true); + }); + + it('falls back to the operator allowlist when no match-stats override is configured', () => { + process.env.SAL_OPERATOR_ROLE_IDS = OPERATOR_ROLES.join(','); + process.env.SAL_ADMIN_ROLE_IDS = ADMIN_ROLE; + delete process.env.SAL_MATCH_STATS_ROLE_IDS; + + expect(hasCommandAccess(member(OPERATOR_ROLES[0]), 'enter-match-stats')).toBe(true); }); it('supports cached GuildMember roles from normal gateway interactions', () => { @@ -61,5 +82,10 @@ describe('Discord role-backed operational command access', () => { process.env.SAL_OPERATOR_ROLE_IDS = 'not-a-role'; expect(hasCommandAccess(member(ADMIN_ROLE), 'log-scouter')).toBe(false); expect(() => validateCommandAccessEnv()).toThrow(/SAL_OPERATOR_ROLE_IDS/); + + process.env.SAL_OPERATOR_ROLE_IDS = OPERATOR_ROLES.join(','); + process.env.SAL_MATCH_STATS_ROLE_IDS = 'not-a-role'; + expect(hasCommandAccess(member(ADMIN_ROLE), 'enter-match-stats')).toBe(false); + expect(() => validateCommandAccessEnv()).toThrow(/SAL_MATCH_STATS_ROLE_IDS/); }); }); diff --git a/apps/bot/src/lib/command-access.ts b/apps/bot/src/lib/command-access.ts index 68189ad..38c4c7f 100644 --- a/apps/bot/src/lib/command-access.ts +++ b/apps/bot/src/lib/command-access.ts @@ -1,16 +1,21 @@ import type { APIInteractionGuildMember, GuildMember } from 'discord.js'; -export type OperationalCapability = 'report-result' | 'log-scouter'; +export type OperationalCapability = + | 'report-result' + | 'log-scouter' + | 'enter-match-stats'; export type CommandAccessMember = | Pick | Pick | null; -const ROLE_ENV_NAMES = ['SAL_OPERATOR_ROLE_IDS', 'SAL_ADMIN_ROLE_IDS'] as const; +const REQUIRED_ROLE_ENV_NAMES = ['SAL_OPERATOR_ROLE_IDS', 'SAL_ADMIN_ROLE_IDS'] as const; +type RequiredRoleEnvName = (typeof REQUIRED_ROLE_ENV_NAMES)[number]; +type RoleEnvName = RequiredRoleEnvName | 'SAL_MATCH_STATS_ROLE_IDS'; const DISCORD_ROLE_ID = /^\d{17,20}$/; function configuredRoleIds( - name: (typeof ROLE_ENV_NAMES)[number], + name: RoleEnvName, env: NodeJS.ProcessEnv, ): string[] { const roleIds = (env[name] ?? '') @@ -24,19 +29,30 @@ function configuredRoleIds( } export function validateCommandAccessEnv(env: NodeJS.ProcessEnv = process.env): void { - for (const name of ROLE_ENV_NAMES) configuredRoleIds(name, env); + for (const name of REQUIRED_ROLE_ENV_NAMES) configuredRoleIds(name, env); + if (env.SAL_MATCH_STATS_ROLE_IDS?.trim()) { + configuredRoleIds('SAL_MATCH_STATS_ROLE_IDS', env); + } } export function hasCommandAccess( member: CommandAccessMember, - _capability: OperationalCapability, + capability: OperationalCapability, env: NodeJS.ProcessEnv = process.env, ): boolean { if (!member) return false; let authorizedRoleIds: string[]; try { - authorizedRoleIds = ROLE_ENV_NAMES.flatMap((name) => configuredRoleIds(name, env)); + validateCommandAccessEnv(env); + const operatorRoleEnv = capability === 'enter-match-stats' + && env.SAL_MATCH_STATS_ROLE_IDS?.trim() + ? 'SAL_MATCH_STATS_ROLE_IDS' + : 'SAL_OPERATOR_ROLE_IDS'; + authorizedRoleIds = [ + ...configuredRoleIds(operatorRoleEnv, env), + ...configuredRoleIds('SAL_ADMIN_ROLE_IDS', env), + ]; } catch { return false; } diff --git a/apps/bot/src/lib/embeds.ts b/apps/bot/src/lib/embeds.ts index 637f14a..8d46111 100644 --- a/apps/bot/src/lib/embeds.ts +++ b/apps/bot/src/lib/embeds.ts @@ -40,7 +40,8 @@ export function buildMatchResultReceiptEmbed( match: MatchInfo, winnerOrg: OrgInfo, score: string, - captainDiscordId: string + captainDiscordId: string, + pendingActionId: string, ) { return new EmbedBuilder() .setColor(COLOR.pending) @@ -53,7 +54,7 @@ export function buildMatchResultReceiptEmbed( { name: 'Score', value: score, inline: true }, { name: 'Submitted By', value: `<@${captainDiscordId}>`, inline: true }, ) - .setFooter({ text: 'Pending admin review' }) + .setFooter({ text: `Pending admin review • Action ID: ${pendingActionId}` }) .setTimestamp(); } @@ -66,7 +67,10 @@ export function buildMatchResultAdminEmbed( ) { return new EmbedBuilder() .setColor(COLOR.pending) - .setTitle(`${STATUS_EMOJI.pending} Match Result Pending Review`) + .setTitle(`${STATUS_EMOJI.pending} Match Result — Waiting for Host Stats`) + .setDescription( + 'The reported score is not ready for final approval. The host must submit match stats before the unified admin review.', + ) .addFields( { name: 'Match', value: `Week ${match.week} — ${match.home_org.tag} vs ${match.away_org.tag}`, inline: true }, { name: 'Division', value: match.division.name, inline: true }, @@ -79,6 +83,28 @@ export function buildMatchResultAdminEmbed( .setTimestamp(); } +export function buildMatchResultWaitingButtons(pendingActionId: string) { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`deny:${pendingActionId}`) + .setLabel('Deny') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(`needs_info:${pendingActionId}`) + .setLabel('⚠️ Needs Info') + .setStyle(ButtonStyle.Secondary), + ); +} + +export function buildEnterStatsButton(reportId: string) { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`mr_stats:${reportId}`) + .setLabel('Enter stats') + .setStyle(ButtonStyle.Primary), + ); +} + export function buildRescheduleReceiptEmbed( match: MatchInfo, newDate: string, diff --git a/apps/bot/src/lib/match-report-site.test.ts b/apps/bot/src/lib/match-report-site.test.ts new file mode 100644 index 0000000..b14a8e0 --- /dev/null +++ b/apps/bot/src/lib/match-report-site.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + issueMatchReportHostReviewLink, + MatchReportSiteError, + type MatchReportSiteDependencies, +} from './match-report-site'; + +function dependencies(fetchImpl: ReturnType): MatchReportSiteDependencies { + return { + siteUrl: 'https://sal.example', + token: 'internal-token', + fetchImpl: fetchImpl as MatchReportSiteDependencies['fetchImpl'], + }; +} + +describe('match report host-link transport', () => { + it('issues a host-bound review link through the internal sal-site boundary', async () => { + const fetchImpl = vi.fn().mockResolvedValue(ok({ + review_url: 'https://sal.example/match-reports/report-1/review#token=secret', + expires_at: '2026-08-19T01:02:03.000Z', + })); + + await expect( + issueMatchReportHostReviewLink('report/unsafe', 'discord-host', dependencies(fetchImpl)), + ).resolves.toEqual({ + reviewUrl: 'https://sal.example/match-reports/report-1/review#token=secret', + expiresAt: '2026-08-19T01:02:03.000Z', + }); + + const [url, request] = fetchImpl.mock.calls[0]; + expect(String(url)).toBe( + 'https://sal.example/api/internal/match-reports/report%2Funsafe/host-token', + ); + expect(request).toMatchObject({ + method: 'POST', + headers: { + Authorization: 'Bearer internal-token', + 'Content-Type': 'application/json', + }, + }); + expect(JSON.parse(String(request.body))).toEqual({ + host_discord_id: 'discord-host', + }); + }); + + it('rejects malformed responses instead of sending an unusable link', async () => { + const fetchImpl = vi.fn().mockResolvedValue(ok({ review_url: '/relative' })); + + await expect( + issueMatchReportHostReviewLink('report-1', 'discord-host', dependencies(fetchImpl)), + ).rejects.toBeInstanceOf(MatchReportSiteError); + }); + + it('rejects a review link that escapes the configured sal-site origin', async () => { + const fetchImpl = vi.fn().mockResolvedValue(ok({ + review_url: 'https://phishing.example/match-reports/report-1/review#token=secret', + expires_at: '2026-08-19T01:02:03.000Z', + })); + + await expect( + issueMatchReportHostReviewLink('report-1', 'discord-host', dependencies(fetchImpl)), + ).rejects.toMatchObject({ + name: 'MatchReportSiteError', + status: 502, + }); + }); + + it('rejects an HTTP downgrade when sal-site is configured with HTTPS', async () => { + const fetchImpl = vi.fn().mockResolvedValue(ok({ + review_url: 'http://sal.example/match-reports/report-1/review#token=secret', + expires_at: '2026-08-19T01:02:03.000Z', + })); + + await expect( + issueMatchReportHostReviewLink('report-1', 'discord-host', dependencies(fetchImpl)), + ).rejects.toBeInstanceOf(MatchReportSiteError); + }); +}); + +function ok(body: unknown) { + return { + ok: true, + status: 200, + json: async () => body, + }; +} diff --git a/apps/bot/src/lib/match-report-site.ts b/apps/bot/src/lib/match-report-site.ts new file mode 100644 index 0000000..bf06c7d --- /dev/null +++ b/apps/bot/src/lib/match-report-site.ts @@ -0,0 +1,65 @@ +import { + siteRequest, + type SiteRequestDependencies, +} from './site-request'; + +export type MatchReportSiteDependencies = SiteRequestDependencies; + +export class MatchReportSiteError extends Error { + constructor( + message: string, + readonly status: number, + readonly rawResponse?: string, + ) { + super(message); + this.name = 'MatchReportSiteError'; + } +} + +export async function issueMatchReportHostReviewLink( + reportId: string, + hostDiscordId: string, + dependencies: MatchReportSiteDependencies = {}, +): Promise<{ reviewUrl: string; expiresAt: string }> { + const configuredSiteUrl = dependencies.siteUrl ?? process.env.SAL_SITE_URL; + const body = await siteRequest( + `/api/internal/match-reports/${encodeURIComponent(reportId)}/host-token`, + 'POST', + { host_discord_id: hostDiscordId }, + dependencies, + { + unconfigured: 'Match report review links are not configured. Ask an admin to set SAL_SITE_URL and SAL_SITE_INTERNAL_TOKEN.', + fallback: (status) => `Match report link request failed with HTTP ${status}.`, + create: (message, status, rawResponse) => + new MatchReportSiteError(message, status, rawResponse), + }, + ); + + if (!isRecord(body) + || !isSafeReviewUrl(body.review_url, configuredSiteUrl) + || typeof body.expires_at !== 'string' + || !Number.isFinite(Date.parse(body.expires_at))) { + throw new MatchReportSiteError( + 'sal-site returned an invalid match report review link.', + 502, + ); + } + return { reviewUrl: body.review_url, expiresAt: body.expires_at }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isSafeReviewUrl(value: unknown, configuredSiteUrl: string | undefined): value is string { + if (typeof value !== 'string' || !configuredSiteUrl) return false; + try { + const reviewUrl = new URL(value); + const siteUrl = new URL(configuredSiteUrl); + return (reviewUrl.protocol === 'https:' || reviewUrl.protocol === 'http:') + && reviewUrl.origin === siteUrl.origin + && (siteUrl.protocol !== 'https:' || reviewUrl.protocol === 'https:'); + } catch { + return false; + } +} diff --git a/apps/bot/src/lib/match-result-discord.ts b/apps/bot/src/lib/match-result-discord.ts new file mode 100644 index 0000000..9333b25 --- /dev/null +++ b/apps/bot/src/lib/match-result-discord.ts @@ -0,0 +1,161 @@ +import type { Client, Message, TextChannel, ThreadChannel } from 'discord.js'; +import { setProofThread, updatePendingActionMessages } from '@salbot/db'; +import type { MatchResultPayload } from '@salbot/shared'; +import { getAdminReviewChannelId, getResultsChannelId } from './channels'; +import { db } from './db'; +import { + buildEnterStatsButton, + buildMatchResultAdminEmbed, + buildMatchResultReceiptEmbed, + buildMatchResultWaitingButtons, +} from './embeds'; +import { createProofThread } from './proof-thread'; + +export type MatchResultArtifactAction = { + id: string; + requestedByDiscordId: string; + payloadJson: MatchResultPayload; + adminReviewMessageId: string | null; + publicReceiptMessageId: string | null; +}; + +type MatchResultArtifactMatch = { + id: string; + week: number; + scheduled_date: string; + scheduled_time: string; + divisionId: string; + division: { id: string; name: string }; + home_org: { id: string; name: string; tag: string }; + away_org: { id: string; name: string; tag: string }; + proofThreadId: string | null; +}; + +export async function ensureMatchResultDiscordArtifacts(params: { + client: Client; + action: MatchResultArtifactAction; + reportId: string; + match: MatchResultArtifactMatch; + winnerOrg: { id: string; name: string; tag: string }; +}): Promise { + const { client, action, reportId, match, winnerOrg } = params; + const actionMarker = `Action ID: ${action.id}`; + const resultsChannel = await client.channels.fetch( + getResultsChannelId(match.divisionId), + ) as TextChannel; + let receiptMessage = await findChannelMessage( + resultsChannel, + action.publicReceiptMessageId, + actionMarker, + ); + if (!receiptMessage) { + receiptMessage = await resultsChannel.send({ + embeds: [buildMatchResultReceiptEmbed( + match, + winnerOrg, + action.payloadJson.score, + action.requestedByDiscordId, + action.id, + )], + }); + } + + let proofThread = await findProofThread(client, match.proofThreadId); + let createdProofThread = false; + if (!proofThread && receiptMessage.thread?.isThread()) { + proofThread = receiptMessage.thread; + await setProofThread( + db, + match.id, + proofThread.id, + proofThread.url, + action.payloadJson.parsed.gamesPlayed, + ); + } + if (!proofThread) { + const matchLabel = `${match.home_org.tag.toLowerCase()}-vs-${match.away_org.tag.toLowerCase()}`; + proofThread = await createProofThread( + resultsChannel, + receiptMessage, + match.id, + matchLabel, + match.week, + action.payloadJson.parsed.gamesPlayed, + ); + createdProofThread = true; + } + if (proofThread.archived) await proofThread.setArchived(false); + if (createdProofThread || !(await threadHasEnterStatsButton(proofThread, reportId))) { + await proofThread.send({ + content: 'Use **Enter stats** to upload screenshots and correct the OCR results on the review page.', + components: [buildEnterStatsButton(reportId)], + }); + } + + const adminChannel = await client.channels.fetch(getAdminReviewChannelId()) as TextChannel; + let adminMessage = await findChannelMessage( + adminChannel, + action.adminReviewMessageId, + actionMarker, + ); + if (!adminMessage) { + adminMessage = await adminChannel.send({ + embeds: [buildMatchResultAdminEmbed( + match, + winnerOrg, + action.payloadJson.score, + action.requestedByDiscordId, + action.id, + )], + components: [buildMatchResultWaitingButtons(action.id)], + }); + } + + await updatePendingActionMessages(db, action.id, { + adminReviewMessageId: adminMessage.id, + publicReceiptMessageId: receiptMessage.id, + }); + return proofThread; +} + +async function findChannelMessage( + channel: TextChannel, + storedMessageId: string | null, + marker: string, +): Promise { + if (storedMessageId) { + try { + const stored = await channel.messages.fetch(storedMessageId); + if (stored) return stored; + } catch { + // Fall through to the stable marker scan before creating a replacement. + } + } + const recent = await channel.messages.fetch({ limit: 100 }); + return recent.find((message) => + message.embeds.some((embed) => embed.footer?.text?.includes(marker))) ?? null; +} + +async function findProofThread( + client: Client, + proofThreadId: string | null, +): Promise { + if (!proofThreadId) return null; + try { + const channel = await client.channels.fetch(proofThreadId); + return channel?.isThread() ? channel : null; + } catch { + return null; + } +} + +async function threadHasEnterStatsButton( + thread: ThreadChannel, + reportId: string, +): Promise { + const recent = await thread.messages.fetch({ limit: 100 }); + const customId = `mr_stats:${reportId}`; + return recent.some((message) => message.components.some((row) => + 'components' in row && row.components.some((component) => + 'customId' in component && component.customId === customId))); +} diff --git a/apps/bot/src/lib/outbox-projections.test.ts b/apps/bot/src/lib/outbox-projections.test.ts index 664c45e..b78a647 100644 --- a/apps/bot/src/lib/outbox-projections.test.ts +++ b/apps/bot/src/lib/outbox-projections.test.ts @@ -1,15 +1,21 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { EmbedBuilder } from 'discord.js'; +import { Collection, EmbedBuilder } from 'discord.js'; vi.mock('./proof-thread', () => ({ removeActiveProofThread: vi.fn() })); import { createOutboxProjector, projectionMarker } from './outbox-projections'; const originalAdminChannel = process.env.CHANNEL_ADMIN_REVIEW; +const originalSiteUrl = process.env.SAL_SITE_URL; +const originalTerraResults = process.env.CHANNEL_RESULTS_TERRA; afterEach(() => { if (originalAdminChannel === undefined) delete process.env.CHANNEL_ADMIN_REVIEW; else process.env.CHANNEL_ADMIN_REVIEW = originalAdminChannel; + if (originalSiteUrl === undefined) delete process.env.SAL_SITE_URL; + else process.env.SAL_SITE_URL = originalSiteUrl; + if (originalTerraResults === undefined) delete process.env.CHANNEL_RESULTS_TERRA; + else process.env.CHANNEL_RESULTS_TERRA = originalTerraResults; }); function outboxRow() { @@ -97,7 +103,416 @@ describe('outbox Discord projections', () => { expect(edit.mock.calls[0][0]).toMatchObject({ components: [] }); }); + it('projects the authoritative reviewed winner and score onto the admin card', async () => { + process.env.CHANNEL_ADMIN_REVIEW = 'admin-channel'; + const edit = vi.fn().mockResolvedValue(undefined); + const message = { + id: 'message-1', + content: '', + embeds: [new EmbedBuilder() + .setTitle('Waiting for Host Stats') + .addFields( + { name: 'Reported Winner', value: 'Preliminary Home' }, + { name: 'Score', value: '2-1' }, + ) + .toJSON()], + edit, + }; + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ + isTextBased: () => true, + messages: { fetch: vi.fn().mockResolvedValue(message) }, + }), + }, + }; + const pendingAction = { + ...action('approved'), + type: 'match_result', + match_id: 'match-1', + payload_json: { winnerOrgId: 'away-org', score: '2-0' }, + }; + const reviewedMatch = { + id: 'match-1', + division_id: 'terra', + home_org: { id: 'home-org', name: 'Home Org', tag: 'HOME' }, + away_org: { id: 'away-org', name: 'Away Org', tag: 'AWAY' }, + }; + const db = { + from: vi.fn((table: string) => { + const builder = { + select: () => builder, + eq: () => builder, + single: () => Promise.resolve({ + data: table === 'pending_actions' ? pendingAction : reviewedMatch, + error: null, + }), + }; + return builder; + }), + }; + const project = createOutboxProjector(client as never, db as never); + + await project({ + ...outboxRow(), + event_type: 'pending_action_approved', + payload: { actionId: 'action-1', finalStatus: 'approved' }, + }); + + const fields = edit.mock.calls[0][0].embeds[0].toJSON().fields; + expect(fields).toContainEqual(expect.objectContaining({ + name: 'Reported Winner', + value: 'Away Org', + })); + expect(fields).toContainEqual(expect.objectContaining({ + name: 'Score', + value: '2-0', + })); + }); + + it('projects the authoritative reviewed winner and score onto the public receipt', async () => { + process.env.CHANNEL_RESULTS_TERRA = 'results-channel'; + const edit = vi.fn().mockResolvedValue(undefined); + const message = { + id: 'receipt-1', + content: '', + embeds: [new EmbedBuilder() + .setTitle('Match Result — Under Review') + .addFields( + { name: 'Reported Winner', value: 'Preliminary Home' }, + { name: 'Score', value: '2-1' }, + ) + .toJSON()], + edit, + }; + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ + isTextBased: () => true, + messages: { fetch: vi.fn().mockResolvedValue(message) }, + }), + }, + }; + const pendingAction = { + ...action('approved'), + type: 'match_result', + match_id: 'match-1', + public_receipt_message_id: 'receipt-1', + payload_json: { winnerOrgId: 'away-org', score: '2-0' }, + }; + const reviewedMatch = { + id: 'match-1', + division_id: 'terra', + home_org: { id: 'home-org', name: 'Home Org', tag: 'HOME' }, + away_org: { id: 'away-org', name: 'Away Org', tag: 'AWAY' }, + }; + const db = { + from: vi.fn((table: string) => { + const builder = { + select: () => builder, + eq: () => builder, + single: () => Promise.resolve({ + data: table === 'pending_actions' ? pendingAction : reviewedMatch, + error: null, + }), + }; + return builder; + }), + }; + const project = createOutboxProjector(client as never, db as never); + + await project({ + ...outboxRow(), + topic: 'discord_receipt_projection', + event_type: 'pending_action_approved', + payload: { actionId: 'action-1', finalStatus: 'approved' }, + }); + + const fields = edit.mock.calls[0][0].embeds[0].toJSON().fields; + expect(fields).toContainEqual(expect.objectContaining({ + name: 'Reported Winner', + value: 'Away Org', + })); + expect(fields).toContainEqual(expect.objectContaining({ + name: 'Score', + value: '2-0', + })); + }); + it('uses a stable marker for create-once projections', () => { expect(projectionMarker('abc')).toBe('sal-outbox:abc'); }); + + it('mirrors durable host screenshots and posts the admin stats review card', async () => { + process.env.CHANNEL_ADMIN_REVIEW = 'admin-channel'; + process.env.SAL_SITE_URL = 'https://sal.example'; + const proofSend = vi.fn() + .mockResolvedValueOnce({ id: 'proof-image-1' }) + .mockResolvedValueOnce({ id: 'proof-image-2' }); + const adminSend = vi.fn().mockResolvedValue({ id: 'stats-review-1' }); + const emptyMessages = { fetch: vi.fn().mockResolvedValue(new Collection()) }; + const setArchived = vi.fn().mockResolvedValue(undefined); + const proofThread = { + isThread: () => true, + archived: true, + setArchived, + messages: emptyMessages, + send: proofSend, + }; + const adminChannel = { + isTextBased: () => true, + messages: emptyMessages, + send: adminSend, + }; + const client = { + channels: { + fetch: vi.fn(async (id: string) => + id === 'proof-thread-1' ? proofThread : adminChannel), + }, + }; + const project = createOutboxProjector(client as never, {} as never); + const row = { + ...outboxRow(), + aggregate_type: 'match_report', + aggregate_id: 'report-1', + event_type: 'match_report_host_submitted', + payload: { + reportId: 'report-1', + pendingActionId: 'action-1', + matchId: 'match-1', + hostDiscordId: 'host-1', + screenshotUrls: [ + 'https://storage.example/game-1-scoreboard.png', + 'https://storage.example/game-1-details.png', + ], + proofThreadId: 'proof-thread-1', + }, + }; + + await expect(project(row)).resolves.toContain('stats-review-1'); + + expect(proofSend).toHaveBeenCalledTimes(2); + expect(setArchived.mock.calls).toEqual([[false], [true]]); + expect(proofSend.mock.calls[0][0]).toMatchObject({ + files: [{ + attachment: 'https://storage.example/game-1-scoreboard.png', + }], + }); + expect(adminSend).toHaveBeenCalledTimes(1); + const reviewCard = adminSend.mock.calls[0][0]; + expect(reviewCard.embeds[0].toJSON()).toMatchObject({ + title: 'Match stats ready for admin review', + }); + expect(reviewCard.components[0].toJSON().components[0]).toMatchObject({ + label: 'Review match stats', + url: 'https://sal.example/admin/tickets?ticket=match_report%3Areport-1', + }); + }); + + it('projects a manual-entry submission with no screenshots', async () => { + process.env.CHANNEL_ADMIN_REVIEW = 'admin-channel'; + process.env.SAL_SITE_URL = 'https://sal.example'; + const proofSend = vi.fn(); + const adminSend = vi.fn().mockResolvedValue({ id: 'stats-review-1' }); + const emptyMessages = { fetch: vi.fn().mockResolvedValue(new Collection()) }; + const client = { + channels: { + fetch: vi.fn(async (id: string) => id === 'proof-thread-1' + ? { isThread: () => true, archived: false, messages: emptyMessages, send: proofSend } + : { isTextBased: () => true, messages: emptyMessages, send: adminSend }), + }, + }; + const project = createOutboxProjector(client as never, {} as never); + + await expect(project({ + ...outboxRow(), + aggregate_type: 'match_report', + aggregate_id: 'report-1', + event_type: 'match_report_host_submitted', + payload: { + reportId: 'report-1', + pendingActionId: 'action-1', + matchId: 'match-1', + hostDiscordId: 'host-1', + screenshotUrls: [], + proofThreadId: 'proof-thread-1', + }, + })).resolves.toContain('stats-review-1'); + + expect(proofSend).not.toHaveBeenCalled(); + expect(adminSend.mock.calls[0][0].embeds[0].toJSON().fields).toContainEqual({ + name: 'Screenshots', + value: '0', + inline: true, + }); + }); + + it('still notifies admins when a crash prevented proof-thread creation', async () => { + process.env.CHANNEL_ADMIN_REVIEW = 'admin-channel'; + process.env.SAL_SITE_URL = 'https://sal.example'; + const adminSend = vi.fn().mockResolvedValue({ id: 'stats-review-1' }); + const emptyMessages = { fetch: vi.fn().mockResolvedValue(new Collection()) }; + const client = { + channels: { + fetch: vi.fn().mockResolvedValue({ + isTextBased: () => true, + messages: emptyMessages, + send: adminSend, + }), + }, + }; + const project = createOutboxProjector(client as never, {} as never); + + await expect(project({ + ...outboxRow(), + aggregate_type: 'match_report', + aggregate_id: 'report-1', + event_type: 'match_report_host_submitted', + payload: { + reportId: 'report-1', + pendingActionId: 'action-1', + matchId: 'match-1', + hostDiscordId: 'host-1', + screenshotUrls: ['https://storage.example/game-1-scoreboard.png'], + }, + })).resolves.toContain('stats-review-1'); + + expect(client.channels.fetch).toHaveBeenCalledTimes(1); + expect(client.channels.fetch).toHaveBeenCalledWith('admin-channel'); + expect(adminSend.mock.calls[0][0].embeds[0].toJSON().fields).toContainEqual({ + name: 'Proof thread', + value: 'Unavailable — screenshots remain in durable storage.', + }); + }); + + it('still notifies admins when the persisted proof thread was deleted', async () => { + process.env.CHANNEL_ADMIN_REVIEW = 'admin-channel'; + process.env.SAL_SITE_URL = 'https://sal.example'; + const adminSend = vi.fn().mockResolvedValue({ id: 'stats-review-1' }); + const emptyMessages = { fetch: vi.fn().mockResolvedValue(new Collection()) }; + const client = { + channels: { + fetch: vi.fn(async (id: string) => { + if (id === 'proof-thread-1') throw new Error('Unknown Channel'); + return { + isTextBased: () => true, + messages: emptyMessages, + send: adminSend, + }; + }), + }, + }; + const project = createOutboxProjector(client as never, {} as never); + + await expect(project({ + ...outboxRow(), + aggregate_type: 'match_report', + aggregate_id: 'report-1', + event_type: 'match_report_host_submitted', + payload: { + reportId: 'report-1', + pendingActionId: 'action-1', + matchId: 'match-1', + hostDiscordId: 'host-1', + screenshotUrls: ['https://storage.example/game-1-scoreboard.png'], + proofThreadId: 'proof-thread-1', + }, + })).resolves.toContain('stats-review-1'); + + expect(adminSend.mock.calls[0][0].embeds[0].toJSON().fields).toContainEqual({ + name: 'Proof thread', + value: 'Unavailable — screenshots remain in durable storage.', + }); + }); + + it('skips mirroring when the persisted proof channel is not a thread', async () => { + process.env.CHANNEL_ADMIN_REVIEW = 'admin-channel'; + process.env.SAL_SITE_URL = 'https://sal.example'; + const proofSend = vi.fn(); + const adminSend = vi.fn().mockResolvedValue({ id: 'stats-review-1' }); + const emptyMessages = { fetch: vi.fn().mockResolvedValue(new Collection()) }; + const client = { + channels: { + fetch: vi.fn(async (id: string) => id === 'proof-thread-1' + ? { isThread: () => false, send: proofSend } + : { isTextBased: () => true, messages: emptyMessages, send: adminSend }), + }, + }; + const project = createOutboxProjector(client as never, {} as never); + + await expect(project({ + ...outboxRow(), + aggregate_type: 'match_report', + aggregate_id: 'report-1', + event_type: 'match_report_host_submitted', + payload: { + reportId: 'report-1', + pendingActionId: 'action-1', + matchId: 'match-1', + hostDiscordId: 'host-1', + screenshotUrls: ['https://storage.example/game-1-scoreboard.png'], + proofThreadId: 'proof-thread-1', + }, + })).resolves.toContain('stats-review-1'); + + expect(proofSend).not.toHaveBeenCalled(); + expect(adminSend.mock.calls[0][0].embeds[0].toJSON().fields).toContainEqual({ + name: 'Proof thread', + value: 'Unavailable — screenshots remain in durable storage.', + }); + }); + + it('does not duplicate already projected screenshots or review cards on retry', async () => { + process.env.CHANNEL_ADMIN_REVIEW = 'admin-channel'; + const proofSend = vi.fn(); + const adminSend = vi.fn(); + const proofMessages = new Collection(); + proofMessages.set('proof-image-1', { + content: '', + embeds: [new EmbedBuilder() + .setFooter({ text: 'sal-outbox:outbox-1:screenshot:0' }) + .toJSON()], + } as never); + const adminMessages = new Collection(); + adminMessages.set('stats-review-1', { + content: '', + embeds: [new EmbedBuilder() + .setFooter({ text: 'sal-outbox:outbox-1:admin-review' }) + .toJSON()], + } as never); + const client = { + channels: { + fetch: vi.fn(async (id: string) => id === 'proof-thread-1' + ? { + isThread: () => true, + messages: { fetch: vi.fn().mockResolvedValue(proofMessages) }, + send: proofSend, + } + : { + isTextBased: () => true, + messages: { fetch: vi.fn().mockResolvedValue(adminMessages) }, + send: adminSend, + }), + }, + }; + const project = createOutboxProjector(client as never, {} as never); + + await project({ + ...outboxRow(), + aggregate_type: 'match_report', + aggregate_id: 'report-1', + event_type: 'match_report_host_submitted', + payload: { + reportId: 'report-1', + pendingActionId: 'action-1', + matchId: 'match-1', + hostDiscordId: 'host-1', + screenshotUrls: ['https://storage.example/game-1-scoreboard.png'], + proofThreadId: 'proof-thread-1', + }, + }); + + expect(proofSend).not.toHaveBeenCalled(); + expect(adminSend).not.toHaveBeenCalled(); + }); }); diff --git a/apps/bot/src/lib/outbox-projections.ts b/apps/bot/src/lib/outbox-projections.ts index 651ad18..556f1be 100644 --- a/apps/bot/src/lib/outbox-projections.ts +++ b/apps/bot/src/lib/outbox-projections.ts @@ -1,10 +1,18 @@ -import { EmbedBuilder, type Client, type Message } from 'discord.js'; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + type Client, + type Message, +} from 'discord.js'; import { getMatchById, getPendingAction, type OperationOutboxRow, type SupabaseClient, } from '@salbot/db'; +import type { MatchResultPayload } from '@salbot/shared'; import { getAdminReviewChannelId, getResultsChannelId, getReschedulesChannelId } from './channels'; import { applyApprovedStatus, @@ -22,7 +30,10 @@ export function projectionMarker(outboxId: string): string { } function messageHasProjection(message: Message, outboxId: string): boolean { - const marker = projectionMarker(outboxId); + return messageHasMarker(message, projectionMarker(outboxId)); +} + +function messageHasMarker(message: Message, marker: string): boolean { return message.content.includes(marker) || message.embeds.some((embed) => embed.footer?.text?.includes(marker)); } @@ -55,6 +66,9 @@ async function projectReview( db: SupabaseClient, row: OperationOutboxRow, ): Promise { + if (row.aggregate_type === 'match_report') { + return projectSubmittedMatchReport(client, row); + } if (row.aggregate_type === 'pending_stat_record') { // Legacy stat review records have no Discord message reference. The // decision RPC is still authoritative; there is no safe projection target @@ -79,6 +93,12 @@ async function projectReview( } const message = await channel.messages.fetch(action.admin_review_message_id); const embed = existingEmbed(message); + if (status === 'approved' && action.type === 'match_result') { + if (!action.match_id) throw new Error(`Match-result action ${action.id} has no match`); + const match = await getMatchById(db, action.match_id); + if (!match) throw new Error(`Match ${action.match_id} not found`); + applyAuthoritativeMatchResult(embed, action.payload_json as MatchResultPayload, match); + } applyDecisionStatus(embed, status, actorDiscordId, action.admin_note); await message.edit({ embeds: [embed], @@ -87,6 +107,115 @@ async function projectReview( return message.id; } +async function projectSubmittedMatchReport( + client: Client, + row: OperationOutboxRow, +): Promise { + if (row.event_type !== 'match_report_host_submitted') { + throw new Error(`Unsupported match report event: ${row.event_type}`); + } + const reportId = requiredString(row.payload.reportId, 'reportId'); + const pendingActionId = requiredString(row.payload.pendingActionId, 'pendingActionId'); + const matchId = requiredString(row.payload.matchId, 'matchId'); + const hostDiscordId = requiredString(row.payload.hostDiscordId, 'hostDiscordId'); + const proofThreadId = optionalString(row.payload.proofThreadId); + const screenshotUrls = requiredUrlArray(row.payload.screenshotUrls, 'screenshotUrls'); + + const proofMessageIds: string[] = []; + let proofThreadUnavailable = !proofThreadId; + if (proofThreadId) { + let proofThread = null; + try { + const channel = await client.channels.fetch(proofThreadId); + if (channel?.isThread()) proofThread = channel; + } catch { + // A deleted or otherwise stale Discord channel reference must not block + // the durable admin-review projection. + } + if (!proofThread) { + proofThreadUnavailable = true; + } else { + const wasArchived = proofThread.archived; + if (wasArchived) await proofThread.setArchived(false); + try { + // Official reports contain at most five screenshots, so the latest 100 + // messages comfortably retain every stable projection marker on retry. + const existingProofMessages = await proofThread.messages.fetch({ limit: 100 }); + for (const [index, screenshotUrl] of screenshotUrls.entries()) { + const marker = `${projectionMarker(row.id)}:screenshot:${index}`; + const existing = existingProofMessages.find((message) => messageHasMarker(message, marker)); + if (existing) { + proofMessageIds.push(existing.id); + continue; + } + const sent = await proofThread.send({ + embeds: [ + new EmbedBuilder() + .setTitle(`Match stats screenshot ${index + 1} of ${screenshotUrls.length}`) + .setDescription('Uploaded through the host correction flow and mirrored from durable storage.') + .setFooter({ text: marker }) + .setTimestamp(), + ], + files: [{ + attachment: screenshotUrl, + name: screenshotFilename(reportId, screenshotUrl, index), + }], + }); + proofMessageIds.push(sent.id); + } + } finally { + if (wasArchived) await proofThread.setArchived(true); + } + } + } + + const adminChannel = await client.channels.fetch(getAdminReviewChannelId()); + if (!adminChannel?.isTextBased() || !('messages' in adminChannel)) { + throw new Error('Admin review channel is not text based'); + } + const adminMarker = `${projectionMarker(row.id)}:admin-review`; + const existingAdminMessages = await adminChannel.messages.fetch({ limit: 100 }); + let adminMessage = existingAdminMessages.find((message) => + messageHasMarker(message, adminMarker)); + if (!adminMessage) { + if (!('send' in adminChannel)) { + throw new Error('Admin review channel cannot send messages'); + } + const reviewUrl = adminMatchReportUrl(reportId); + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle('Match stats ready for admin review') + .setDescription('The host submitted corrected OCR results. Final publication remains admin-only.') + .addFields( + { name: 'Match report', value: reportId }, + { name: 'Pending action', value: pendingActionId }, + { name: 'Match', value: matchId, inline: true }, + { name: 'Submitted by', value: `<@${hostDiscordId}>`, inline: true }, + { name: 'Screenshots', value: String(screenshotUrls.length), inline: true }, + ) + .setFooter({ text: adminMarker }) + .setTimestamp(); + if (proofThreadUnavailable) { + embed.addFields({ + name: 'Proof thread', + value: 'Unavailable — screenshots remain in durable storage.', + }); + } + adminMessage = await adminChannel.send({ + embeds: [embed], + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel('Review match stats') + .setStyle(ButtonStyle.Link) + .setURL(reviewUrl), + ), + ], + }); + } + return [...proofMessageIds, adminMessage.id].join(','); +} + async function projectReceipt( client: Client, db: SupabaseClient, @@ -111,6 +240,9 @@ async function projectReceipt( } const message = await channel.messages.fetch(action.public_receipt_message_id); const embed = existingEmbed(message); + if (status === 'approved' && action.type === 'match_result') { + applyAuthoritativeMatchResult(embed, action.payload_json as MatchResultPayload, match); + } applyDecisionStatus(embed, status, actorDiscordId, action.admin_note); await message.edit({ embeds: [embed] }); return message.id; @@ -227,3 +359,58 @@ function requiredString(value: unknown, label: string): string { function optionalString(value: unknown): string | null { return typeof value === 'string' && value.length > 0 ? value : null; } + +function applyAuthoritativeMatchResult( + embed: EmbedBuilder, + payload: MatchResultPayload, + match: Awaited>, +): void { + const homeOrg = match?.home_org as unknown as { id: string; name: string } | null; + const awayOrg = match?.away_org as unknown as { id: string; name: string } | null; + const winnerOrg = payload.winnerOrgId === homeOrg?.id + ? homeOrg + : payload.winnerOrgId === awayOrg?.id + ? awayOrg + : null; + if (!winnerOrg) { + throw new Error(`Reviewed winner ${payload.winnerOrgId} is not part of match ${match?.id ?? 'unknown'}`); + } + const fields = embed.toJSON().fields ?? []; + embed.setFields(fields.map((field) => { + if (field.name === 'Reported Winner') return { ...field, value: winnerOrg.name }; + if (field.name === 'Score') return { ...field, value: payload.score }; + return field; + })); +} + +function requiredUrlArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || value.some((item) => !isAbsoluteHttpUrl(item))) { + throw new Error(`Outbox payload ${label} must be an array of HTTP URLs`); + } + return value; +} + +function isAbsoluteHttpUrl(value: unknown): value is string { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return url.protocol === 'https:' || url.protocol === 'http:'; + } catch { + return false; + } +} + +function adminMatchReportUrl(reportId: string): string { + const siteUrl = process.env.SAL_SITE_URL; + if (!siteUrl) throw new Error('SAL_SITE_URL is required for match report review projections'); + const url = new URL('/admin/tickets', siteUrl); + url.searchParams.set('ticket', `match_report:${reportId}`); + return url.toString(); +} + +function screenshotFilename(reportId: string, screenshotUrl: string, index: number): string { + const sourceName = (new URL(screenshotUrl).pathname.split('/').pop() ?? '') + .replace(/[^a-zA-Z0-9._-]/g, '-') + .slice(-80); + return `match-report-${reportId}-${index + 1}-${sourceName || 'screenshot.png'}`; +} diff --git a/apps/bot/src/lib/proof-thread.test.ts b/apps/bot/src/lib/proof-thread.test.ts new file mode 100644 index 0000000..d71b563 --- /dev/null +++ b/apps/bot/src/lib/proof-thread.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./db', () => ({ db: {} })); +vi.mock('@salbot/db', () => ({ + setProofThread: vi.fn(), + incrementScreenshotCount: vi.fn(), +})); + +import { setProofThread } from '@salbot/db'; +import { activeProofThreads, createProofThread } from './proof-thread'; + +describe('match-result proof thread', () => { + beforeEach(() => { + vi.clearAllMocks(); + activeProofThreads.clear(); + }); + + it('directs the verified host to the upload-once web flow', async () => { + const trackingSend = vi.fn().mockResolvedValue({ id: 'tracking-1' }); + const thread = { + id: 'thread-1', + url: 'https://discord.example/thread-1', + send: trackingSend, + }; + const receiptMessage = { + startThread: vi.fn().mockResolvedValue(thread), + }; + + await createProofThread( + {} as never, + receiptMessage as never, + 'match-1', + 'home-vs-away', + 2, + 3, + ); + + const instruction = String(trackingSend.mock.calls[0][0]); + expect(instruction).toContain('Enter stats'); + expect(instruction).toContain('durable storage'); + expect(instruction).toContain('mirrored here'); + expect(instruction).not.toContain('Upload your scoreboard screenshots here'); + expect(instruction).not.toMatch(/0\s*\/\s*3/); + expect(setProofThread).toHaveBeenCalledWith( + {}, + 'match-1', + 'thread-1', + 'https://discord.example/thread-1', + 3, + ); + }); +}); diff --git a/apps/bot/src/lib/proof-thread.ts b/apps/bot/src/lib/proof-thread.ts index bbffba4..5dafca3 100644 --- a/apps/bot/src/lib/proof-thread.ts +++ b/apps/bot/src/lib/proof-thread.ts @@ -20,10 +20,10 @@ export async function createProofThread( }); const trackingMsg = await thread.send( - `📸 **Proof upload thread** — Week ${week} ${matchLabel}\n\n` + - `Upload your scoreboard screenshots here.\n` + - `Progress: **0 / ${expectedScreenshots}** screenshots\n\n` + - `_Both captains may upload. This thread closes when the result is approved or denied._` + `📊 **Match stats review thread** — Week ${week} ${matchLabel}\n\n` + + `The verified host should use **Enter stats** below and upload each game screenshot once on the SAL review page.\n` + + `Screenshots are kept in durable storage and mirrored here after host submission.\n\n` + + `_Do not upload duplicate screenshots directly to Discord. This thread closes after the final admin decision._` ); await setProofThread(db, matchId, thread.id, thread.url, expectedScreenshots); diff --git a/apps/bot/src/lib/scouter-ingest.ts b/apps/bot/src/lib/scouter-ingest.ts index 7159b57..74ff8ed 100644 --- a/apps/bot/src/lib/scouter-ingest.ts +++ b/apps/bot/src/lib/scouter-ingest.ts @@ -1,3 +1,5 @@ +import { siteRequest as requestSite, type SiteRequestDependencies } from './site-request'; + export type ScouterParticipant = { side: "order" | "chaos"; rawIgn: string; @@ -93,27 +95,7 @@ export type ScouterExtractInput = { expectedSmiteMatchId?: string; }; -type FetchResponse = { - ok: boolean; - status: number; - json: () => Promise; -}; - -type FetchImplementation = ( - input: URL, - init: { - method: "GET" | "POST" | "PATCH"; - headers: Record; - body?: string; - signal: AbortSignal; - }, -) => Promise; - -export type ScouterSiteDependencies = { - siteUrl?: string; - token?: string; - fetchImpl?: FetchImplementation; -}; +export type ScouterSiteDependencies = SiteRequestDependencies; export class ScouterIngestError extends Error { constructor( @@ -126,7 +108,19 @@ export class ScouterIngestError extends Error { } } -const REQUEST_TIMEOUT_MS = 90_000; +function siteRequest( + path: string, + method: 'GET' | 'POST' | 'PATCH', + body: Record | undefined, + dependencies: ScouterSiteDependencies, +): Promise { + return requestSite(path, method, body, dependencies, { + unconfigured: 'Scouter ingestion is not configured. Ask an admin to set SAL_SITE_URL and SAL_SITE_INTERNAL_TOKEN.', + fallback: (status) => `Scouter request failed with HTTP ${status}.`, + create: (message, status, rawResponse) => + new ScouterIngestError(message, status, rawResponse), + }); +} export async function extractScouterDraft( input: ScouterExtractInput, @@ -275,54 +269,6 @@ export async function confirmScouterDraft( }; } -async function siteRequest( - path: string, - method: "GET" | "POST" | "PATCH", - body: Record | undefined, - dependencies: ScouterSiteDependencies, -): Promise { - const siteUrl = dependencies.siteUrl ?? process.env.SAL_SITE_URL; - const token = dependencies.token ?? process.env.SAL_SITE_INTERNAL_TOKEN; - const fetchImpl = dependencies.fetchImpl ?? fetch; - if (!siteUrl || !token) { - throw new ScouterIngestError( - "Scouter ingestion is not configured. Ask an admin to set SAL_SITE_URL and SAL_SITE_INTERNAL_TOKEN.", - 503, - ); - } - - let response: FetchResponse; - try { - response = await fetchImpl(new URL(path, siteUrl), { - method, - headers: { - Authorization: `Bearer ${token}`, - ...(body ? { "Content-Type": "application/json" } : {}), - }, - ...(body ? { body: JSON.stringify(body) } : {}), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }); - } catch (error) { - throw new ScouterIngestError( - `Could not reach sal-site: ${error instanceof Error ? error.message : String(error)}`, - 503, - ); - } - - const responseBody = await response.json().catch(() => null); - if (!response.ok) { - const message = - isRecord(responseBody) && typeof responseBody.error === "string" - ? responseBody.error - : `Scouter request failed with HTTP ${response.status}.`; - const rawResponse = - isRecord(responseBody) && typeof responseBody.raw_response === "string" - ? responseBody.raw_response - : undefined; - throw new ScouterIngestError(message, response.status, rawResponse); - } - return responseBody; -} function parseDraft(body: unknown): ScouterDraft { if ( diff --git a/apps/bot/src/lib/site-request.ts b/apps/bot/src/lib/site-request.ts new file mode 100644 index 0000000..6c0bb78 --- /dev/null +++ b/apps/bot/src/lib/site-request.ts @@ -0,0 +1,83 @@ +export type SiteFetchResponse = { + ok: boolean; + status: number; + json: () => Promise; +}; + +export type SiteFetchImplementation = ( + input: URL, + init: { + method: 'GET' | 'POST' | 'PATCH'; + headers: Record; + body?: string; + signal: AbortSignal; + }, +) => Promise; + +export type SiteRequestDependencies = { + siteUrl?: string; + token?: string; + fetchImpl?: SiteFetchImplementation; +}; + +export type SiteRequestErrorFactory = ( + message: string, + status: number, + rawResponse?: string, +) => Error; + +const REQUEST_TIMEOUT_MS = 90_000; + +export async function siteRequest( + path: string, + method: 'GET' | 'POST' | 'PATCH', + body: Record | undefined, + dependencies: SiteRequestDependencies, + errors: { + unconfigured: string; + fallback: (status: number) => string; + create: SiteRequestErrorFactory; + }, +): Promise { + const siteUrl = dependencies.siteUrl ?? process.env.SAL_SITE_URL; + const token = dependencies.token ?? process.env.SAL_SITE_INTERNAL_TOKEN; + const fetchImpl = dependencies.fetchImpl ?? fetch; + if (!siteUrl || !token) { + throw errors.create(errors.unconfigured, 503); + } + + let response: SiteFetchResponse; + try { + response = await fetchImpl(new URL(path, siteUrl), { + method, + headers: { + Authorization: `Bearer ${token}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error) { + throw errors.create( + `Could not reach sal-site: ${error instanceof Error ? error.message : String(error)}`, + 503, + ); + } + + const responseBody = await response.json().catch(() => null); + if (!response.ok) { + const message = isRecord(responseBody) && typeof responseBody.error === 'string' + ? responseBody.error + : errors.fallback(response.status); + const rawResponse = isRecord(responseBody) + && typeof responseBody.raw_response === 'string' + ? responseBody.raw_response + : undefined; + throw errors.create(message, response.status, rawResponse); + } + return responseBody; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/docs/commands.md b/docs/commands.md index 1cd2721..ad7d279 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -15,7 +15,7 @@ handlers, database contracts, permissions, and deployment configuration ship. | Command | Who | What it does | | ----------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| `/report-result` | SAL Operators / Admins | Report a completed match's score. Posts a public receipt, opens a proof-upload thread, and sends the result to admin review. | +| `/report-result` | SAL Operators / Admins | Report a completed match's score, then open the host web flow to upload and correct official stats before admin review. | | `/reschedule` | Captains | Request a new date/time for an upcoming match. Posts a public receipt and sends the request to admin review. | | `/request-admin-review` | Everyone | Escalate an issue (score dispute, scheduling, eligibility, other) directly to admins. No public receipt. | | `/rules` | Everyone | Ask a question about the league ruleset. Answered by an AI assistant restricted to the official rules text. | @@ -148,10 +148,17 @@ state. **On submit:** - Creates a `pending_actions` row (`type: 'match_result'`). +- Creates or retrieves the one canonical `match_reports` row linked to that pending action. - Posts a public "Under Review" receipt embed to that division's results channel (`CHANNEL_RESULTS_SOLAR` / `_LUNAR` / `_TERRA`). -- Opens a proof-upload thread under the receipt, named `proof-week-{week}-{home-tag}-vs-{away-tag}`. Screenshot counts are tracked **in memory only** — they reset if the bot restarts before the result is approved. +- Opens a proof thread under the receipt, named `proof-week-{week}-{home-tag}-vs-{away-tag}`, with an **Enter stats** button. - Posts an admin review card with **Approve / Deny / ⚠️ Needs Info** buttons to `#admin-review` (`CHANNEL_ADMIN_REVIEW`). +**Enter stats:** rechecks the member's current Discord-role capability, then +issues a private host-bound sal-site review link. The host uploads screenshots +once on the site. After host submission, durable screenshots are mirrored into +the proof thread and an idempotent admin stats-review card links to the +canonical admin page. SALBot never writes official stats directly. + **On admin Approve:** the match is marked `completed` with the winner/score, the proof thread is closed and archived, and both embeds are updated in place (not deleted). ### `/reschedule` diff --git a/docs/workflows/discord-workflows.md b/docs/workflows/discord-workflows.md index b070451..f894cc6 100644 --- a/docs/workflows/discord-workflows.md +++ b/docs/workflows/discord-workflows.md @@ -49,6 +49,11 @@ malformed role configuration fails closed. `/report-result` then presents the scheduled, non-archived matches in the current season; downstream pending action and admin review safeguards are unchanged. +The `enter-match-stats` capability uses `SAL_MATCH_STATS_ROLE_IDS` when that +optional allowlist is configured and otherwise falls back to +`SAL_OPERATOR_ROLE_IDS`. `SAL_ADMIN_ROLE_IDS` always remains an override. The +button rechecks current member roles before sal-site mints a host-bound link. + `/reschedule` still uses its existing linked-captain/org match filter. It is not part of the initial ADR-009 role-authorization migration. @@ -112,8 +117,12 @@ The bot does not delete old cards — they're updated in place to preserve histo When a captain reports a result, the bot opens a thread under the public receipt: - Named `proof-week-{week}-{home-tag}-vs-{away-tag}`. -- Tracks a running screenshot count against an expected count derived from the score (e.g. a `2-1` result expects 3 games × 2 screenshots = 6). -- Any message with an image attachment posted in the thread increments the count and edits the thread's tracking message. +- Contains a durable **Enter stats** button for the host correction workflow. +- The host uploads screenshots once on sal-site. A durable outbox projection + mirrors those stored screenshots into the thread after host submission and + posts the admin stats-review card exactly once. +- Direct image attachments continue to increment the legacy proof counter, but + they are evidence only and do not enter the official stat workflow. - **Screenshot counts are tracked in memory only** (`activeProofThreads` in `apps/bot/src/lib/proof-thread.ts`) — a bot restart before the result is approved loses in-flight progress tracking, though the uploaded images themselves remain in the thread. Full persistence is a future phase. - On a terminal admin decision (**Approve**, **Deny**, or stale cancellation), the durable outbox worker posts one marked closing message and archives the diff --git a/package.json b/package.json index ff69d6b..f0989d3 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "pnpm": { "overrides": { "brace-expansion@<5.0.9": "5.0.9", - "js-yaml@>=4.0.0 <4.3.0": "4.3.0", + "js-yaml@>=4.0.0 <4.3.1": "4.3.1", + "nanoid@<3.3.18": "3.3.18", "postcss@<8.5.23": "8.5.23", "undici@<6.28.0": "6.28.0", "ws@>=8.0.0 <8.21.0": "8.21.1" diff --git a/packages/db/src/queries/index.ts b/packages/db/src/queries/index.ts index a68aded..83fa6e5 100644 --- a/packages/db/src/queries/index.ts +++ b/packages/db/src/queries/index.ts @@ -9,3 +9,4 @@ export * from './divisions'; export * from './division-role-mappings'; export * from './scouters'; export * from './operation-outbox'; +export * from './match-reports'; diff --git a/packages/db/src/queries/match-reports.test.ts b/packages/db/src/queries/match-reports.test.ts new file mode 100644 index 0000000..38313f1 --- /dev/null +++ b/packages/db/src/queries/match-reports.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createMatchResultActionWithReport, + ensureMatchReportForPendingAction, +} from './match-reports'; + +describe('match report workflow queries', () => { + it('atomically creates the match-result action and canonical report', async () => { + const rpc = vi.fn().mockResolvedValue({ + data: { + code: 'created', + created: true, + actionId: 'action-1', + pendingActionId: 'action-1', + reportId: 'report-1', + matchId: 'match-1', + hostDiscordId: 'host-1', + status: 'pending', + revision: 0, + }, + error: null, + }); + const payload = { + winnerOrgId: 'org-home', + score: '2-1', + parsed: { + winnerGames: 2, + loserGames: 1, + gamesPlayed: 3, + expectedScreenshots: 3, + }, + }; + + await expect( + createMatchResultActionWithReport( + { rpc } as never, + 'match-1', + 'host-1', + payload, + ), + ).resolves.toMatchObject({ + code: 'created', + actionId: 'action-1', + reportId: 'report-1', + }); + + expect(rpc).toHaveBeenCalledWith('create_match_result_action_with_report', { + p_match_id: 'match-1', + p_host_discord_id: 'host-1', + p_payload: payload, + }); + }); + + it('creates or retrieves the canonical report linked to a pending action', async () => { + const rpc = vi.fn().mockResolvedValue({ + data: { + code: 'created', + created: true, + reportId: 'report-1', + pendingActionId: 'action-1', + matchId: 'match-1', + hostDiscordId: 'host-1', + status: 'pending', + revision: 0, + }, + error: null, + }); + + await expect( + ensureMatchReportForPendingAction({ rpc } as never, 'action-1', 'host-1'), + ).resolves.toEqual({ + code: 'created', + created: true, + reportId: 'report-1', + pendingActionId: 'action-1', + matchId: 'match-1', + hostDiscordId: 'host-1', + status: 'pending', + revision: 0, + }); + + expect(rpc).toHaveBeenCalledWith('ensure_match_report_for_pending_action', { + p_pending_action_id: 'action-1', + p_host_discord_id: 'host-1', + }); + }); + + it('rejects malformed results instead of inventing a report identity', async () => { + const rpc = vi.fn().mockResolvedValue({ data: { code: 'created' }, error: null }); + + await expect( + ensureMatchReportForPendingAction({ rpc } as never, 'action-1', 'host-1'), + ).rejects.toThrow('invalid match report result'); + }); + + it('rejects an inconsistent atomic action/report identity', async () => { + const rpc = vi.fn().mockResolvedValue({ + data: { + code: 'existing', + created: false, + actionId: 'action-1', + pendingActionId: 'different-action', + reportId: 'report-1', + matchId: 'match-1', + hostDiscordId: 'host-1', + status: 'pending', + revision: 0, + }, + error: null, + }); + + await expect(createMatchResultActionWithReport( + { rpc } as never, + 'match-1', + 'host-1', + { winnerOrgId: 'org-home', score: '2-1', parsed: { winnerGames: 2, loserGames: 1, gamesPlayed: 3, expectedScreenshots: 3 } }, + )).rejects.toThrow('invalid atomic match-result action'); + }); +}); diff --git a/packages/db/src/queries/match-reports.ts b/packages/db/src/queries/match-reports.ts new file mode 100644 index 0000000..5b8b471 --- /dev/null +++ b/packages/db/src/queries/match-reports.ts @@ -0,0 +1,111 @@ +import type { SupabaseClient } from '../client'; +import { parseMatchResultPayload, type MatchResultPayload } from '@salbot/shared'; +import { toDatabaseJson } from '../json'; + +export type CreatedMatchResultAction = { + code: 'created' | 'existing'; + created: boolean; + actionId: string; + pendingActionId: string; + reportId: string; + matchId: string; + hostDiscordId: string; + status: string; + revision: number; +}; + +export async function createMatchResultActionWithReport( + db: SupabaseClient, + matchId: string, + hostDiscordId: string, + payload: MatchResultPayload, +): Promise { + const canonicalPayload = parseMatchResultPayload(payload); + // Keep the compatibility cast isolated until the protected sal-database + // release is pinned and generated types are refreshed in this consumer. + const rpc = db.rpc as unknown as ( + name: 'create_match_result_action_with_report', + args: { p_match_id: string; p_host_discord_id: string; p_payload: unknown }, + ) => Promise<{ data: unknown; error: unknown }>; + const { data, error } = await rpc('create_match_result_action_with_report', { + p_match_id: matchId, + p_host_discord_id: hostDiscordId, + p_payload: toDatabaseJson(canonicalPayload), + }); + + if (error) throw error; + if (!isCreatedMatchResultAction(data) || data.matchId !== matchId) { + throw new Error('Database returned an invalid atomic match-result action.'); + } + return data; +} + +export type EnsuredMatchReport = { + code: string; + created: boolean; + reportId: string; + pendingActionId: string; + matchId: string; + hostDiscordId: string; + status: string; + revision: number; +}; + +export async function ensureMatchReportForPendingAction( + db: SupabaseClient, + pendingActionId: string, + hostDiscordId: string, +): Promise { + // Keep the compatibility cast isolated until the coordinated sal-database + // release is pinned and generated types are refreshed in this consumer. + const rpc = db.rpc as unknown as ( + name: 'ensure_match_report_for_pending_action', + args: { p_pending_action_id: string; p_host_discord_id: string }, + ) => Promise<{ data: unknown; error: unknown }>; + const { data, error } = await rpc('ensure_match_report_for_pending_action', { + p_pending_action_id: pendingActionId, + p_host_discord_id: hostDiscordId, + }); + + if (error) throw error; + if (!isEnsuredMatchReport(data)) { + throw new Error('Database returned an invalid match report result.'); + } + return data; +} + +function isEnsuredMatchReport(value: unknown): value is EnsuredMatchReport { + return isRecord(value) + && nonEmptyString(value.code) + && typeof value.created === 'boolean' + && nonEmptyString(value.reportId) + && nonEmptyString(value.pendingActionId) + && nonEmptyString(value.matchId) + && nonEmptyString(value.hostDiscordId) + && nonEmptyString(value.status) + && Number.isInteger(value.revision) + && Number(value.revision) >= 0; +} + +function isCreatedMatchResultAction(value: unknown): value is CreatedMatchResultAction { + return isRecord(value) + && (value.code === 'created' || value.code === 'existing') + && typeof value.created === 'boolean' + && value.created === (value.code === 'created') + && nonEmptyString(value.actionId) + && value.pendingActionId === value.actionId + && nonEmptyString(value.reportId) + && nonEmptyString(value.matchId) + && nonEmptyString(value.hostDiscordId) + && nonEmptyString(value.status) + && Number.isInteger(value.revision) + && Number(value.revision) >= 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function nonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} diff --git a/packages/db/src/queries/pending-actions.test.ts b/packages/db/src/queries/pending-actions.test.ts index b53c134..54464a7 100644 --- a/packages/db/src/queries/pending-actions.test.ts +++ b/packages/db/src/queries/pending-actions.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from "vitest"; -import { claimPendingActionForApproval, denyPendingAction, needsInfoPendingAction } from "./pending-actions"; +import { describe, expect, it, vi } from "vitest"; +import { + claimPendingActionForApproval, + denyPendingAction, + getActiveMatchResultPendingAction, + needsInfoPendingAction, +} from "./pending-actions"; // Regression test for a bug where these functions checked `count` from // Supabase without requesting it (`{ count: 'exact' }`), so `count` was @@ -67,3 +72,51 @@ describe("pending action status guard (claim/deny/needs-info)", () => { expect(rows.get("pa-3")?.status).toBe("pending_info"); }); }); + +describe('active match-result recovery', () => { + it('loads the existing actionable submission and its original host', async () => { + const maybeSingle = vi.fn().mockResolvedValue({ + data: { + id: 'action-1', + match_id: 'match-1', + requested_by_discord_id: 'original-host', + status: 'pending_info', + payload_json: { winnerOrgId: 'org-home', score: '2-1' }, + admin_review_message_id: 'review-1', + public_receipt_message_id: 'receipt-1', + }, + error: null, + }); + const inFilter = vi.fn(() => ({ maybeSingle })); + const eqType = vi.fn(() => ({ in: inFilter })); + const eqMatch = vi.fn(() => ({ eq: eqType })); + const select = vi.fn(() => ({ eq: eqMatch })); + const from = vi.fn(() => ({ select })); + + await expect( + getActiveMatchResultPendingAction({ from } as never, 'match-1'), + ).resolves.toEqual({ + id: 'action-1', + matchId: 'match-1', + requestedByDiscordId: 'original-host', + status: 'pending_info', + payloadJson: { + winnerOrgId: 'org-home', + score: '2-1', + parsed: { + winnerGames: 2, + loserGames: 1, + gamesPlayed: 3, + expectedScreenshots: 3, + }, + }, + adminReviewMessageId: 'review-1', + publicReceiptMessageId: 'receipt-1', + }); + + expect(from).toHaveBeenCalledWith('pending_actions'); + expect(eqMatch).toHaveBeenCalledWith('match_id', 'match-1'); + expect(eqType).toHaveBeenCalledWith('type', 'match_result'); + expect(inFilter).toHaveBeenCalledWith('status', ['pending', 'pending_info']); + }); +}); diff --git a/packages/db/src/queries/pending-actions.ts b/packages/db/src/queries/pending-actions.ts index 000d4f6..264ee18 100644 --- a/packages/db/src/queries/pending-actions.ts +++ b/packages/db/src/queries/pending-actions.ts @@ -1,6 +1,8 @@ import type { SupabaseClient } from '../client'; import { + parseMatchResultPayload, parsePendingActionPayload, + type MatchResultPayload, type PendingActionPayload, type PendingActionType, } from '@salbot/shared'; @@ -86,6 +88,45 @@ export async function getPendingAction(db: SupabaseClient, id: string) { }; } +export async function getActiveMatchResultPendingAction( + db: SupabaseClient, + matchId: string, +): Promise<{ + id: string; + matchId: string; + requestedByDiscordId: string; + status: 'pending' | 'pending_info'; + payloadJson: MatchResultPayload; + adminReviewMessageId: string | null; + publicReceiptMessageId: string | null; +} | null> { + const { data, error } = await db + .from('pending_actions') + .select(` + id, match_id, requested_by_discord_id, status, payload_json, + admin_review_message_id, public_receipt_message_id + `) + .eq('match_id', matchId) + .eq('type', 'match_result') + .in('status', ['pending', 'pending_info']) + .maybeSingle(); + + if (error) throw error; + if (!data) return null; + if (data.status !== 'pending' && data.status !== 'pending_info') { + throw new Error(`Active pending action ${data.id} has an invalid status.`); + } + return { + id: data.id, + matchId: data.match_id as string, + requestedByDiscordId: data.requested_by_discord_id, + status: data.status, + payloadJson: parseMatchResultPayload(data.payload_json), + adminReviewMessageId: data.admin_review_message_id, + publicReceiptMessageId: data.public_receipt_message_id, + }; +} + function isPendingActionType(value: string): value is PendingActionType { return ['match_result', 'reschedule', 'admin_review', 'alias_change'].includes(value); } diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index b6140b3..ed81a23 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -7,7 +7,7 @@ export const STATUS_EMOJI = { revised: '🔁', } as const; -export const SCREENSHOTS_PER_GAME = 2; +export const SCREENSHOTS_PER_GAME = 1; export const CONFIDENCE_THRESHOLDS = { standard: 0.85, diff --git a/packages/shared/src/payloads.test.ts b/packages/shared/src/payloads.test.ts index b2c1e52..cd31e54 100644 --- a/packages/shared/src/payloads.test.ts +++ b/packages/shared/src/payloads.test.ts @@ -25,7 +25,7 @@ describe("pending action payload validation", () => { winnerGames: 2, loserGames: 1, gamesPlayed: 3, - expectedScreenshots: 6, + expectedScreenshots: 3, }, }); }); diff --git a/packages/shared/src/score.test.ts b/packages/shared/src/score.test.ts index b2a25f7..4eb0c17 100644 --- a/packages/shared/src/score.test.ts +++ b/packages/shared/src/score.test.ts @@ -7,7 +7,7 @@ describe("parseScore", () => { winnerGames: 2, loserGames: 1, gamesPlayed: 3, - expectedScreenshots: 6, + expectedScreenshots: 3, }); }); @@ -16,7 +16,7 @@ describe("parseScore", () => { winnerGames: 2, loserGames: 0, gamesPlayed: 2, - expectedScreenshots: 4, + expectedScreenshots: 2, }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc96d62..47492e1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,7 +6,8 @@ settings: overrides: brace-expansion@<5.0.9: 5.0.9 - js-yaml@>=4.0.0 <4.3.0: 4.3.0 + js-yaml@>=4.0.0 <4.3.1: 4.3.1 + nanoid@<3.3.18: 3.3.18 postcss@<8.5.23: 8.5.23 undici@<6.28.0: 6.28.0 ws@>=8.0.0 <8.21.0: 8.21.1 @@ -868,8 +869,8 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true json-buffer@3.0.1: @@ -991,8 +992,8 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -1462,7 +1463,7 @@ snapshots: globals: 13.24.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -1933,7 +1934,7 @@ snapshots: imurmurhash: 0.1.4 is-glob: 4.0.3 is-path-inside: 3.0.3 - js-yaml: 4.3.0 + js-yaml: 4.3.1 json-stable-stringify-without-jsonify: 1.0.1 levn: 0.4.1 lodash.merge: 4.6.2 @@ -2056,7 +2057,7 @@ snapshots: isexe@2.0.0: {} - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 @@ -2150,7 +2151,7 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.16: {} + nanoid@3.3.18: {} natural-compare@1.4.0: {} @@ -2195,7 +2196,7 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.16 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1