From 6988b92f2d877e33ba49a1aeca9c9aeb3b1f0ecb Mon Sep 17 00:00:00 2001 From: Dylan Vidal Date: Sat, 5 Sep 2026 22:55:35 -0400 Subject: [PATCH 01/11] added discord communications for judging rooms Co-authored-by: Codex --- .forge/features/judging-discord-comms/spec.md | 157 + .forge/features/judging-discord-comms/srd.md | 165 + .../features/judging-discord-comms/status.md | 69 + .../judging-discord-comms/test-cases.md | 134 + .../judging/judging-control-panel.tsx | 287 +- .../projects/judge-project-workspace.tsx | 8 +- docs/DATABASE-USAGE.md | 6 + packages/api/src/judging-access.server.ts | 83 +- packages/api/src/routers/judging.ts | 298 +- .../src/tests/judging/discord-comms.test.ts | 91 + .../api/src/utils/judging/discord-comms.ts | 394 + packages/api/src/utils/judging/principal.ts | 2 + packages/db/drizzle/0048_little_tarot.sql | 2 + packages/db/drizzle/meta/0048_snapshot.json | 12730 ++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schemas/knight-hacks.ts | 2 + .../db/src/tests/migration-lineage.test.ts | 6 +- packages/validators/src/audit.ts | 5 + packages/validators/src/judging.ts | 6 + packages/validators/src/tests/judging.test.ts | 22 + 20 files changed, 14423 insertions(+), 51 deletions(-) create mode 100644 .forge/features/judging-discord-comms/spec.md create mode 100644 .forge/features/judging-discord-comms/srd.md create mode 100644 .forge/features/judging-discord-comms/status.md create mode 100644 .forge/features/judging-discord-comms/test-cases.md create mode 100644 packages/api/src/tests/judging/discord-comms.test.ts create mode 100644 packages/api/src/utils/judging/discord-comms.ts create mode 100644 packages/db/drizzle/0048_little_tarot.sql create mode 100644 packages/db/drizzle/meta/0048_snapshot.json diff --git a/.forge/features/judging-discord-comms/spec.md b/.forge/features/judging-discord-comms/spec.md new file mode 100644 index 000000000..99a238c53 --- /dev/null +++ b/.forge/features/judging-discord-comms/spec.md @@ -0,0 +1,157 @@ +# Judging Discord comms spec + +Status: Approved from the 2026-09-05 implementation request + +> This file owns the user and product behavior. Technical design belongs in `srd.md`. + +## User-facing purpose + +Judging rooms need a quiet way to reach organizers while pitches are underway. +Walkie-talkies interrupt presenters, and authenticated organizers already have +Discord on their phones. Officers should be able to connect the judging room +workspace to one Discord channel, then use a room-specific thread for updates +and guest QR delivery. + +## Users and actors + +- **Officer:** chooses or removes the hackathon's judging communications + channel and manages room QR access. +- **Authenticated judge:** a Blade user with judge or officer access who may + select a room. Discord mentions let this judge find that room's thread. +- **Guest judge:** enters through the existing room QR flow. Completing the + name dialog announces the guest to that room's authenticated judges. The + guest does not need Discord and is never mentioned as a Discord user. + +## User-visible interface + +### Communications channel + +The Rooms tab in Command Center has a `Judging communications` panel. An +officer can search the active environment's Knight Hacks guild text channels, +select one, save it, or disconnect Discord communications. + +The setting is optional. Rooms, QR generation, room assignment, and revocation +continue to work when no channel is selected or Discord is unavailable. + +Saving a channel creates a thread named after each active room. A room created +later receives its thread automatically. Changing the channel creates new room +threads under the new channel. Existing Discord history stays in the old +channel. + +The panel shows the selected channel, connection state, and any rooms whose +thread could not be provisioned. Officers can retry provisioning without +changing the channel. + +### Room thread access and arrivals + +When an authenticated judge newly selects or switches into a room, Blade posts +a short notice in that room's thread and mentions that judge. The mention makes +the thread visible in Discord. Reopening the page and presence heartbeats do +not send another notice. + +Authenticated judges who enter a room by scanning its QR receive the same +mention. + +When a guest completes the required name dialog, Blade posts a guest-arrival +notice with the entered name and mentions the authenticated judges currently +assigned to the room. QR activation alone stays quiet because the guest has not +identified themselves yet. Guest heartbeats and page reloads do not post. + +Each connected room includes an `Open thread` link in Command Center. + +### QR delivery + +Creating a room's first active QR posts the QR image and activation link in the +room thread. It mentions the authenticated judges currently assigned to the +room. + +An active room has a separate `Send QR` action. It posts the current QR again +without rotating or revoking it. Opening the QR preview does not post to +Discord. + +Rotating a QR revokes the old access, creates the replacement, and posts the +new QR image and link. The notice mentions assigned authenticated judges. + +### Revocation notices + +Revoking one guest session posts the guest's display name and the officer who +revoked access. Revoking a whole room QR identifies the affected guest judges, +when any are named, and explains that the room QR is no longer valid. Both +notices mention authenticated judges currently assigned to the room. + +Discord delivery never controls the security result. A join, guest sign-in, +revocation, or rotation still succeeds if the message cannot be sent. Blade +reports the Discord failure where an initiating user is present. The live room +roster remains the source of truth for guest presence. + +## Scope + +### In scope + +- Optional communications channel per hackathon judging configuration. +- Searchable guild text-channel selection through the existing combobox pattern. +- One durable Discord thread reference per active judging room. +- Thread provisioning when the channel is saved and when rooms are created. +- Authenticated judge room-entry mentions. +- Named guest arrival notices with authenticated room-judge mentions. +- QR image and link delivery on first generation, explicit resend, and rotation. +- Guest-session and room-link revocation notices. +- Links from Command Center to room threads. +- Safe partial-failure feedback and retry. + +### Out of scope + +- Discord accounts or mentions for guest judges. +- General judging chat outside room threads. +- Mirroring messages between Blade and Discord. +- Reading Discord thread messages inside Blade. +- WebSockets or a new Discord bot command. +- Deleting old threads when an officer changes or removes the channel. +- Scheduling presentations or notifying hackers. + +## Vocabulary + +- **Root communications channel:** the optional Discord text channel that owns + all judging room threads for one hackathon. +- **Room thread:** the Discord thread tied to one durable judging room. +- **Assigned authenticated judge:** a member judge with current presence in + the room. Guest sessions do not count as Discord recipients. +- **Guest arrival:** the one-time notice sent after a guest successfully saves + their display name. +- **Send QR:** repost the current active room QR without changing its access + credential. + +## Acceptance criteria + +- An officer can search and select a text channel from the configured + development or production guild. +- Saving is allowed with no channel selected, and all non-Discord judging + behavior remains available. +- Saving a channel creates or reuses one thread for every active room. +- A room created after channel setup receives a thread. +- Changing the channel creates replacement room threads without deleting old + Discord history. +- A newly assigned authenticated judge is mentioned once in the room thread. +- Authenticated QR entry sends the same room-entry mention. +- A guest's completed name step posts one arrival notice and mentions current + authenticated room judges. +- Guest QR activation, page reloads, and heartbeat polling send no message. +- First QR generation posts its image and link in the room thread. +- Viewing an existing QR does not post it again. +- `Send QR` posts the current QR without changing its URL or link ID. +- QR rotation posts only the replacement QR and leaves the security change in + effect if Discord delivery fails. +- Guest revocation names the revoked guest and actor in the room thread. +- Room-link revocation names affected guests when available. +- QR, guest-arrival, and revocation messages mention only current + authenticated room judges. +- Discord messages cannot produce `@everyone`, `@here`, role, or arbitrary + user mentions from room names or guest names. +- Command Center shows the configured channel and provides room-thread links. +- A Discord outage never rolls back a room join, guest sign-in, QR creation, + rotation, or revocation. + +## Open questions + +None blocking. Message wording may tighten during visual and live Discord +review without changing this contract. diff --git a/.forge/features/judging-discord-comms/srd.md b/.forge/features/judging-discord-comms/srd.md new file mode 100644 index 000000000..fb091726a --- /dev/null +++ b/.forge/features/judging-discord-comms/srd.md @@ -0,0 +1,165 @@ +# Judging Discord comms SRD + +Status: Approved from the 2026-09-05 implementation request + +## Technical purpose + +Connect judging rooms and QR access to optional, hackathon-scoped Discord +communications. Forge stores the selected root channel and each room's current +thread. Discord writes happen after judging state commits and return a separate +delivery result. + +## Relevant principles + +- Keep Blade pages server-first and business logic in `@forge/api`. +- Use officer-managed data instead of hard-coded yearly Discord IDs. +- Resolve the guild through the existing environment-aware Discord config. +- Keep Discord calls outside database transactions. +- Follow `apps/blade/DESIGN_SYSTEM.md` for the Command Center UI. + +## Access policy + +- Guests and unauthenticated users cannot list channels, configure Discord, or + send QR messages. +- Guest QR activation stays quiet. Successful name completion may announce the + guest to the room thread. +- Authenticated judges may trigger a self-mention only when newly assigned to a + configured room. +- Existing officer project-management permission gates protect every + configuration, provisioning, resend, rotation, and revocation procedure. + +## Architecture and data flow + +- `@forge/db` owns additive nullable channel and thread columns. +- `@forge/validators` owns the nullable channel input. +- `@forge/api` owns channel validation, thread lifecycle, payload safety, + recipient resolution, QR attachments, and delivery status. +- Blade owns the responsive channel combobox, thread links, actions, and toasts. +- `@forge/utils/discord` remains the REST client. + +### Stored state + +`HackathonJudgingConfiguration` gains nullable +`judgingCommsChannelId varchar(20)`. Null disables Discord communications. +`JudgingRoom` gains nullable `discordThreadId varchar(20)`. + +Changing or clearing the channel nulls active-room thread IDs. Saving a new +channel provisions replacement threads but does not delete old Discord +history. Archived rooms do not receive replacement threads. + +### Thread provisioning + +Saving a channel validates that it belongs to the resolved guild and supports +text messages and threads. The channel commits first. The API then creates one +starter message and public thread per active room, saving each returned thread +ID. New rooms provision after their database insert. Room renames sync the +current thread name after commit. + +Provisioning is retryable and keeps successful room IDs when another room +fails. A missing or unusable stored thread is replaced. Thread names are +single-line, mention-neutralized, and limited to Discord's 100-character +maximum. + +### Room arrivals + +Member joins and authenticated QR activation distinguish existing same-room +presence from a new entry. Existing presence only refreshes `lastSeenAt`. +New presence commits, then sends a self-mention using the authenticated user's +stored Discord snowflake. + +Guest QR activation sends nothing. `completeGuestJudge` commits the identity +and presence, then sends one arrival notice with the guest's inert display +name. It mentions authenticated judges with current presence in the room. +Guest reloads and heartbeats send nothing. + +### QR and revocation delivery + +First QR generation posts the new PNG and signed link. Reading an existing QR +does not post. `sendRoomQr` regenerates the PNG for the active link without +changing its ID or sessions. It resolves and mentions current authenticated +room judges at send time. + +Rotation commits revocation and replacement before posting the replacement QR. +Guest revocation notices identify the guest and actor. Room-link revocation +notices identify affected named guests when available. Both resolve current +authenticated room judges after the security change. + +Payloads disable parsed mentions and allowlist only validated member Discord +IDs. Room, actor, and guest names cannot create mentions. Guest identities are +never treated as Discord users. + +Delivery status is `not_configured`, `delivered`, or `failed`. A Discord +failure never rolls back room entry, guest completion, QR creation, rotation, +or revocation. + +## tRPC and API behavior + +Extend `judgingRouter` with: + +- `listDiscordChannels` for officer-only channel discovery. +- `setCommsChannel` to save a nullable channel and provision active rooms. +- `provisionRoomThreads` to retry missing threads. +- `sendRoomQr` to repost the current active QR. + +`listAdmin` returns the channel ID, resolved guild ID, room thread IDs, and +missing-thread state. Current join, guest completion, QR, and revocation +procedures return delivery status beside their domain result. + +Use `BAD_REQUEST` for an unsupported channel and `NOT_FOUND` for a missing +room or active QR. Do not return raw Discord errors. + +## Validation + +- Optional channel IDs are 17 through 20 digit snowflakes. +- Existing room and hackathon IDs remain UUIDs. +- Live validation checks guild and channel type. +- Message and filename builders enforce Discord limits and neutralize hostile + display names. + +## Data, migration, and compatibility + +- Generate an additive migration for the nullable columns. +- Existing hackathons remain disconnected. No migration backfill calls Discord. +- Existing QR URLs, guest cookies, scoring, imports, and presence stay + compatible. +- Rollback removes the columns but cannot delete Discord threads already + created. + +## Discord integration + +Add a focused judging gateway under `packages/api/src/utils/judging` with +testable payload builders. Use existing Discord REST v10 dependencies and +`getKnightHacksGuildId()`. Do not add a bot command, role, environment +variable, dependency, or hard-coded guild/channel ID. + +## Configurability review + +No developer change is needed next year. Officers choose the channel for each +hackathon and the existing Discord config chooses the environment's guild. + +## React and frontend constraints + +- Add one top-level communications panel inside the Rooms tab. +- Use `ResponsiveComboBox` with channel name and ID search plus an explicit + disconnected action. +- Show disconnected, connected, loading, and partial-failure states with text. +- Add `Open thread` and `Send QR` beside current room actions. +- Keep 44px targets, mobile wrapping, skeleton parity, and no horizontal + document overflow. +- Warn about Discord delivery failure without claiming the judging action + failed. +- Put screenshots only in PR discussion, never in the repository. + +## Testing and verification strategy + +Cover validators, payload safety, thread reuse and recovery, current-recipient +selection, member and guest arrivals, QR lifecycle rules, revocation failure +isolation, permissions, Blade states, migrations, desktop, mobile, and live +development-guild delivery. + +Run targeted tests, `pnpm format`, `pnpm lint`, `pnpm typecheck`, +`pnpm analyze:react:changed`, migration checks, and the Blade build. + +## Open questions + +None. diff --git a/.forge/features/judging-discord-comms/status.md b/.forge/features/judging-discord-comms/status.md new file mode 100644 index 000000000..bd4e8bda1 --- /dev/null +++ b/.forge/features/judging-discord-comms/status.md @@ -0,0 +1,69 @@ +# Judging Discord comms status + +Current phase: Review and delivery + +## Decision log + +- 2026-09-05: The human approved a separate PR for optional, per-hackathon + judging Discord communications. +- 2026-09-05: Forge uses the existing environment-aware Knight Hacks guild. + No guild or channel ID is hard-coded. +- 2026-09-05: Active rooms receive one current thread. Channel changes create + replacements and keep old Discord history. +- 2026-09-05: Newly assigned authenticated judges receive a self-mention. + Heartbeats and same-room activity remain quiet. +- 2026-09-05: Guest name completion posts one arrival notice and mentions the + authenticated judges currently assigned to the room. +- 2026-09-05: First QR generation, explicit resend, and rotation post the QR. + Every explicit resend mentions the authenticated judges assigned at that + moment. Viewing an existing QR does not post. +- 2026-09-05: Database security and presence actions commit independently of + Discord delivery. +- 2026-09-05: Screenshots belong in PR discussion only. + +## Open questions + +None blocking. + +## Task list + +- [x] Create a worktree and branch from merged `origin/main`. +- [x] Review repository, database, design-system, Discord, and judging guidance. +- [x] Complete the product spec, SRD, and test plan. +- [x] Add schema, migration, validators, and Discord comms gateway. +- [x] Add API configuration, thread, arrival, QR, and revocation behavior. +- [x] Add Command Center channel and room-thread controls. +- [x] Add automated tests and run required checks. +- [x] Run desktop and mobile visual QA against KH VIII data. +- [ ] Test delivery in the development guild bot-testing channel. +- [x] Create and assign the GitHub issue. +- [ ] Commit, push, open the PR, and attach screenshots outside the repo. +- [ ] Address and resolve CodeRabbit review threads until approved. + +## Validation and commands + +- `git fetch origin main`: passed. Worktree starts at `ec5e26ec`. +- `pnpm forge:feature judging-discord-comms "Judging Discord Comms"`: passed + after linking the existing dependency directory. +- `pnpm db:generate`: passed; created additive migration `0048_little_tarot`. +- `pnpm db:migrate`: passed against the pulled local database. +- `pnpm format`: passed. +- `pnpm lint`: passed after resolving two feature errors; repository warning + baseline remains. +- `pnpm typecheck`: passed, 33 tasks. +- `pnpm --filter=@forge/validators test -- judging audit`: passed, 16 tests. +- `pnpm --filter=@forge/api test -- judging`: passed, 13 tests. +- `pnpm --filter=@forge/db test -- migration`: passed, 65 tests. +- `pnpm analyze:react:changed`: passed, two components and zero failures. +- `pnpm --filter=@forge/blade build`: passed; 58 static pages generated. +- Desktop Command Center, QR dialog, authenticated room assignment, live + roster, and 390px guest-dialog visual checks: passed. +- Live Discord delivery: blocked locally because the configured Kairox token + is not a member of the development guild. Human browser access to + `#bot-testing` is confirmed; no guild membership or token was changed. + +## Links + +- PRs: pending +- Issues: https://github.com/KnightHacks/forge/issues/534 +- Discord/thread context: pending live development-guild check diff --git a/.forge/features/judging-discord-comms/test-cases.md b/.forge/features/judging-discord-comms/test-cases.md new file mode 100644 index 000000000..9a2852108 --- /dev/null +++ b/.forge/features/judging-discord-comms/test-cases.md @@ -0,0 +1,134 @@ +# Judging Discord comms test cases + +Status: Approved from the 2026-09-05 implementation request + +## Scope and placement + +These cases cover channel configuration, room threads, arrival notices, QR +delivery, revocation, permissions, message safety, failure isolation, and the +Command Center UI. Validators, API utilities and router tests, migration tests, +Blade tests, and manual desktop/mobile checks own the proof. + +## Test cases + +### TC-001: connect judging communications + +Saving a valid guild text channel persists it and provisions one named thread +for every active room. Command Center shows the connected channel and links. + +### TC-002: keep Discord optional + +With no channel, room assignment and every QR action still work. Delivery +returns `not_configured` and the UI never claims a message was sent. + +### TC-003: change or clear the channel + +Changing channels creates replacement active-room threads and leaves old +history intact. Clearing the channel clears current thread references without +disabling judging. + +### TC-004: provision new rooms + +Creating a room after channel setup commits the room and creates its sanitized +thread. + +### TC-005: retry partial provisioning + +If one room fails, successful thread IDs stay saved, failed rooms are named, +and retry provisions only missing threads. + +### TC-006: mention a member on new entry + +Selecting a room or entering through an authenticated QR commits presence and +sends one notice that allowlists and mentions that member. + +### TC-007: keep repeated member activity quiet + +Same-room selection, reloads, and heartbeats refresh presence without another +Discord post. + +### TC-008: announce a named guest + +Successful guest name completion commits identity and presence, posts the inert +guest name once, and mentions authenticated judges currently in the room. + +### TC-009: keep other guest activity quiet + +Guest QR activation, reloads, and heartbeats do not post. + +### TC-010: post only a newly generated QR + +First generation posts the PNG and link with current member mentions. Viewing +an existing QR sends nothing. + +### TC-011: resend the current QR + +`Send QR` posts the unchanged active link and a fresh PNG. It mentions every +authenticated judge assigned at send time and does not change guest sessions. + +### TC-012: rotate and deliver the replacement + +Rotation revokes old access, commits a new link, and posts only the replacement +QR with current member mentions. + +### TC-013: announce guest revocation + +Individual revocation commits, identifies the guest and actor in the thread, +and mentions current member judges without treating the guest as a Discord user. + +### TC-014: announce room-link revocation + +Room-link revocation commits, identifies affected named guests within Discord +limits, and mentions current member judges. + +### TC-015: render thread links safely + +An active room thread links to +`https://discord.com/channels/{resolvedGuildId}/{threadId}` in a new tab. + +### TC-016: support desktop and mobile channel search + +Channel name and ID search works with long and numerous channels at desktop and +390px widths. Controls retain 44px targets and no document overflow appears. + +## Negative and regression cases + +### TC-NEG-001: reject a foreign or unsupported channel + +The API returns `BAD_REQUEST`, preserves prior configuration, and creates no +threads. + +### TC-NEG-002: enforce officer access + +Guests and non-officer judges cannot discover channels, save configuration, +retry provisioning, or send a QR. + +### TC-NEG-003: isolate Discord failures + +Room entry, guest completion, QR creation, rotation, and revocation remain +committed when Discord fails. Delivery reports `failed`. + +### TC-NEG-004: contain hostile names + +Markdown, `@everyone`, `@here`, and mention syntax in names stay inert. +Allowed mentions contain only validated current member IDs. + +### TC-NEG-005: skip invalid Discord IDs + +Malformed member Discord IDs are omitted without affecting other recipients. + +### TC-NEG-006: recover a missing room thread + +A deleted or unusable stored thread is replaced and saved before delivery. + +### TC-NEG-007: require an active QR for resend + +`sendRoomQr` returns `NOT_FOUND` and sends nothing when no active link exists. + +### TC-NEG-008: preserve archived-room history + +Archived rooms receive no replacement thread and show no active send controls. + +## Open questions + +None. diff --git a/apps/blade/src/app/_components/judging/judging-control-panel.tsx b/apps/blade/src/app/_components/judging/judging-control-panel.tsx index ff9b7ec2a..c853f107b 100644 --- a/apps/blade/src/app/_components/judging/judging-control-panel.tsx +++ b/apps/blade/src/app/_components/judging/judging-control-panel.tsx @@ -10,12 +10,16 @@ import { ArrowUp, Copy, DoorOpen, + ExternalLink, + Hash, KeyRound, + MessageCircle, Pencil, Plus, Printer, QrCode, RefreshCw, + Send, ShieldAlert, UserRoundX, UsersRound, @@ -35,6 +39,7 @@ import { } from "@forge/ui/dialog"; import { Input } from "@forge/ui/input"; import { Label } from "@forge/ui/label"; +import { ResponsiveComboBox } from "@forge/ui/responsive-combo-box"; import { toast } from "@forge/ui/toast"; import { @@ -47,7 +52,10 @@ import { api } from "~/trpc/react"; type ControlData = RouterOutputs["judging"]["listAdmin"]; type Hackathons = RouterOutputs["projects"]["listAdminHackathons"]; type Room = ControlData["rooms"][number]; -type QrResult = RouterOutputs["judging"]["generateRoomLink"]; +type QrResult = Pick< + RouterOutputs["judging"]["generateRoomLink"], + "id" | "qrCodeUrl" | "url" +>; const ACTIVE_PRESENCE_WINDOW_MS = 2 * 60 * 1000; @@ -329,11 +337,19 @@ export function JudgingControlPanel({ null, ); const [archiving, setArchiving] = useState(null); + const [commsDraft, setCommsDraft] = useState({ + channelId: initialData.configuration.judgingCommsChannelId, + hackathonId: initialData.hackathon.id, + }); const query = api.judging.listAdmin.useQuery( { hackathonId: initialData.hackathon.id }, { initialData, refetchInterval: 10_000 }, ); const generate = api.judging.generateRoomLink.useMutation(); + const channels = api.judging.listDiscordChannels.useQuery(); + const saveComms = api.judging.setCommsChannel.useMutation(); + const retryThreads = api.judging.provisionRoomThreads.useMutation(); + const sendQr = api.judging.sendRoomQr.useMutation(); const revoke = api.judging.revokeRoomLink.useMutation(); const rotate = api.judging.rotateRoomLink.useMutation(); const move = api.judging.moveRoom.useMutation(); @@ -341,6 +357,14 @@ export function JudgingControlPanel({ const revokeGuest = api.judging.revokeGuest.useMutation(); const removeJudge = api.judging.removeJudgeFromRoom.useMutation(); const data = query.data; + const commsChannelId = + commsDraft.hackathonId === data.hackathon.id + ? commsDraft.channelId + : data.configuration.judgingCommsChannelId; + + function setCommsChannelId(channelId: string | null) { + setCommsDraft({ channelId, hackathonId: data.hackathon.id }); + } function refresh() { void query.refetch(); @@ -360,12 +384,47 @@ export function JudgingControlPanel({ ? await rotate.mutateAsync({ roomId: room.id }) : await generate.mutateAsync({ roomId: room.id }); setQr({ roomName: room.name, value }); + if (value.discordDelivery === "failed") { + toast.error( + mode === "rotate" + ? "QR rotated, but the Discord message failed." + : "QR generated, but the Discord message failed.", + ); + } refresh(); } catch (error) { toast.error(error instanceof Error ? error.message : "QR update failed."); } } + async function updateCommsChannel(channelId: string | null) { + try { + const result = await saveComms.mutateAsync({ + channelId, + hackathonId: data.hackathon.id, + }); + setCommsChannelId(channelId); + if (result.failedRooms.length) { + toast.error( + `Channel saved, but ${result.failedRooms.length} room thread${result.failedRooms.length === 1 ? "" : "s"} need a retry.`, + ); + } else { + toast.success( + channelId + ? "Judging communications connected." + : "Judging communications disconnected.", + ); + } + refresh(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Discord communications could not be saved.", + ); + } + } + async function moveRoom(roomId: string, direction: "up" | "down") { try { await move.mutateAsync({ direction, roomId }); @@ -380,6 +439,12 @@ export function JudgingControlPanel({ const activeRoomIds = data.rooms .filter((room) => room.archivedAt === null) .map((room) => room.id); + const missingThreadCount = data.rooms.filter( + (room) => + room.archivedAt === null && + data.configuration.judgingCommsChannelId && + !room.discordThreadId, + ).length; const Root = embedded ? "div" : "main"; @@ -466,6 +531,129 @@ export function JudgingControlPanel({ +
+
+
+ + +
+

Judging communications

+

+ Give every room a quiet Discord thread for judge arrivals, guest + check-ins, QR delivery, and access changes. +

+
+
+ + {data.configuration.judgingCommsChannelId + ? missingThreadCount + ? `${missingThreadCount} thread${missingThreadCount === 1 ? "" : "s"} pending` + : "Connected" + : "Optional · disconnected"} + +
+ +
+
+ + `#${channel.name}`} + getItemSearchValue={(channel) => `${channel.name} ${channel.id}`} + getItemValue={(channel) => channel.id} + inputPlaceholder="Search channels" + isDisabled={saveComms.isPending} + isLoading={channels.isLoading} + items={channels.data ?? []} + onValueChange={setCommsChannelId} + renderItem={(channel) => ( + + + )} + triggerClassName="h-11 bg-background/70" + triggerId="judging-comms-channel" + value={commsChannelId} + /> +

+ Blade uses the configured{" "} + {data.discordGuildId ? "Knight Hacks server" : "Discord server"}{" "} + for this environment. +

+ {channels.isError ? ( +

+ Discord channels could not be loaded. Check the bot's access to + this server, then retry. +

+ ) : null} +
+
+ {data.configuration.judgingCommsChannelId ? ( + + ) : null} + {missingThreadCount ? ( + + ) : null} + +
+
+
+ {!data.challenges.length ? ( @@ -557,16 +745,76 @@ export function JudgingControlPanel({ {room.activeLinkId ? "View QR" : "Generate QR"} + {room.discordThreadId && data.discordGuildId ? ( + + ) : null} {room.activeLinkId ? ( <> + + + + ))} + + + ) : null; + + return ( + <> + {mounted && bannerLayer + ? createPortal(bannerLayer, document.body) + : bannerLayer} + + { + if (!open && urgent) dismiss(urgent.id); + }} + open={!!urgent} + > + event.preventDefault()} + onInteractOutside={(event) => event.preventDefault()} + portalled={mounted} + showCloseButton={false} + > + {urgent ? ( + <> +
+
+ +
+
+
+ Urgent judging announcement + {scopeLabel(urgent)} +
+ + {urgent.message} + +
+ + + +
+ + ) : null} + +
+ + ); +} diff --git a/apps/blade/src/app/_components/judging/judging-control-panel.tsx b/apps/blade/src/app/_components/judging/judging-control-panel.tsx index c853f107b..9bf9c5ce9 100644 --- a/apps/blade/src/app/_components/judging/judging-control-panel.tsx +++ b/apps/blade/src/app/_components/judging/judging-control-panel.tsx @@ -13,6 +13,7 @@ import { ExternalLink, Hash, KeyRound, + Megaphone, MessageCircle, Pencil, Plus, @@ -40,6 +41,8 @@ import { import { Input } from "@forge/ui/input"; import { Label } from "@forge/ui/label"; import { ResponsiveComboBox } from "@forge/ui/responsive-combo-box"; +import { Switch } from "@forge/ui/switch"; +import { Textarea } from "@forge/ui/textarea"; import { toast } from "@forge/ui/toast"; import { @@ -52,6 +55,7 @@ import { api } from "~/trpc/react"; type ControlData = RouterOutputs["judging"]["listAdmin"]; type Hackathons = RouterOutputs["projects"]["listAdminHackathons"]; type Room = ControlData["rooms"][number]; +type Announcement = NonNullable; type QrResult = Pick< RouterOutputs["judging"]["generateRoomLink"], "id" | "qrCodeUrl" | "url" @@ -319,6 +323,186 @@ function RoomQrDialog({ ); } +function AnnouncementDialog({ + current, + data, + onClose, + onSaved, + room, +}: { + current: Announcement | null; + data: ControlData; + onClose: () => void; + onSaved: () => void; + room: Room | null; +}) { + const publish = api.judging.publishAnnouncement.useMutation(); + const clear = api.judging.clearAnnouncement.useMutation(); + const [includeGuests, setIncludeGuests] = useState( + current?.includeGuests ?? false, + ); + const [isUrgent, setIsUrgent] = useState(current?.isUrgent ?? false); + const scope = room?.name ?? "All judging rooms"; + + async function submit(event: FormEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + try { + const result = await publish.mutateAsync({ + hackathonId: data.hackathon.id, + includeGuests, + isUrgent, + message: formString(form.get("message")), + roomId: room?.id ?? null, + }); + if (result.discordDelivery === "failed") { + toast.error( + "Announcement published in Blade, but Discord delivery failed.", + ); + } else if (result.discordDelivery === "not_configured") { + toast.success( + "Announcement published in Blade. Discord is not connected.", + ); + } else if (result.discordDelivery === "superseded") { + toast.success( + "Announcement published in Blade, then replaced before Discord delivery.", + ); + } else { + toast.success("Announcement published in Blade and Discord."); + } + onClose(); + onSaved(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Announcement could not be published.", + ); + } + } + + async function clearCurrent() { + if (!current) return; + try { + await clear.mutateAsync({ announcementId: current.id }); + toast.success("Announcement cleared."); + onClose(); + onSaved(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Announcement could not be cleared.", + ); + } + } + + return ( + !open && onClose()}> + +
+ +
+
+ + {current ? "Replace announcement" : "Publish announcement"} + + + This goes to {scope} in Blade and Discord. Blade publication + succeeds even if Discord is unavailable. + +
+ +
+ +