diff --git a/.forge/features/judging-discord-comms/spec.md b/.forge/features/judging-discord-comms/spec.md new file mode 100644 index 000000000..5348482d2 --- /dev/null +++ b/.forge/features/judging-discord-comms/spec.md @@ -0,0 +1,222 @@ +# 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. Officers also need a fast way to place an operational +notice over the judging workspace without interrupting a pitch. + +## 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 + full-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. + +Every Blade view that identifies an authenticated judge uses the current full +name from that judge's Member profile. This includes room rosters, feedback, +scores, evaluation history, and deliberation. The linked Discord account label +is the fallback only when no Member profile name is available. Guest judges +keep the full name they entered during QR access. + +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. + +### Judging announcements + +The Rooms tab lets an officer publish either a hackathon-wide announcement or +an announcement for one room. Each scope has one current announcement. +Publishing another message in the same scope replaces the current one, and an +officer can clear either scope independently. + +Announcements target authenticated judges by default. An explicit `Include +guest judges` switch also shows the announcement to QR guests. A guest never +sees a room announcement for another room. + +Current announcements appear over the judging workspace. A standard notice is +a persistent banner with an X. It never disappears on a timer. An urgent notice +opens as a blocking dialog that the judge must acknowledge. Both forms remain +available on Projects, Submissions, Deliberation, project details, and while a +judge fills out feedback. Their labels identify whether they apply to all +judging rooms or the selected room. + +Publishing also posts to Discord. A global announcement posts in the root +judging communications channel and mentions every authenticated judge for the +hackathon who has a linked Discord account. A room announcement posts in that +room's thread and mentions the authenticated judges currently assigned there. +Guest judges can see eligible Blade notices but are never Discord recipients. +The announcement remains published in Blade if Discord delivery fails. + +The server renders the current notices on first load. While the judging page +stays open, Blade checks every 30 seconds for replacements and cleared notices, +including while the tab is hidden. Announcements do not depend on Discord or a +webhook. + +## 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. +- Hackathon-wide and room-scoped judging announcements. +- Authenticated-only delivery by default, with optional guest delivery. +- Server-rendered notices followed by 30-second polling. +- Persistent banners, urgent acknowledgement dialogs, and Discord delivery. + +### 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. +- Push notifications and WebSockets for announcements. + +## 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. +- **Judging announcement:** the current operational notice for either the + hackathon or one room. + +## 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. +- The guest identity field is labeled `Full name`. +- Authenticated judge names shown in judging views come from current Member + profiles, with the Discord account label as fallback. +- 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. +- An officer can publish, replace, and clear a global announcement or one room's + announcement. +- Publishing a global announcement posts it in the root communications channel + and mentions authenticated hackathon judges. Publishing a room announcement + posts it in the room thread and mentions assigned authenticated judges. +- Announcements default to authenticated judges. Guests see only announcements + whose officer enabled guest delivery. +- Authenticated judges see the global announcement and the announcement for + their selected room. Guests see the eligible global announcement and the + eligible announcement for their QR room. +- Room announcements never cross room boundaries. +- Current notices render on the first server response and update within 30 + seconds while the judging page remains open. +- Standard notices stay above the judging workspace until the judge dismisses + them with an X. Urgent notices block interaction until acknowledged. No notice + disappears on a timer. +- A Discord failure is reported to the officer but does not undo the Blade + announcement. + +## 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..01fde2630 --- /dev/null +++ b/.forge/features/judging-discord-comms/srd.md @@ -0,0 +1,238 @@ +# 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. Forge also stores current judging announcements and serves +them to Blade independently of Discord delivery. + +## 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. +- The same officer permission protects announcement publication and clearing. +- `judgeProcedure` resolves announcement visibility from the principal. Clients + cannot select another room or opt themselves into guest-hidden notices. + +## 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)`. + +`JudgingAnnouncement` stores a hackathon, optional room, message, urgency, +guest audience flag, publishing officer, and clear metadata. A null room means +the announcement is hackathon-wide. Partial unique indexes permit at most one +uncleared global announcement per hackathon and one uncleared announcement per +room. + +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. + +The selected root channel is the Discord access boundary and must be restricted +to Knight Hacks organizers. Public room threads intentionally inherit that +channel's readers so officers can inspect every room without being assigned. +Mentions are notifications, not an access-control list. Command Center states +this requirement beside the channel selector because Blade cannot make an +otherwise public Discord channel private. + +Provisioning is retryable and keeps successful room IDs when another room +fails. A missing, forbidden, or invalid stored thread is replaced. Rate limits, +server errors, and transport failures preserve the saved thread for retry. +Thread names are single-line, mention-neutralized, and limited to Discord's +100-character maximum. Discord network work runs outside database +transactions; per-room serialization prevents duplicate local provisioning. + +### 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. + +Judge-facing API reads batch-resolve authenticated judge names through the +linked Member record before returning room rosters, score feedback, or +evaluation history. They retain the stored Judge label as the Discord fallback. +Guest labels never pass through Member-name resolution. The guest introduction +field says `Full Name`, but the signed guest session remains the authorization +identity. + +### 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. + +### Announcement reads and lifecycle + +Publishing runs in a transaction. It locks the scope, clears its current +announcement, inserts the replacement, and records both actor and scope in the +audit log. Clearing timestamps the current row instead of deleting it. The +history remains available for incident review. + +Member visibility uses the member judge's current room presence for the chosen +hackathon. Members receive the active global announcement and, when assigned, +their room announcement. Guest visibility derives the hackathon and room from +the signed guest principal and adds `includeGuests = true`. The API never trusts +a client-provided room ID for announcement reads. + +`getContext` includes the initial visible announcements for server rendering. +`listAnnouncements` uses the same resolver for client polling every 30 seconds. +Polling continues while the document is hidden so a clear or urgent replacement +arrives without waiting for focus. + +A normal announcement renders as a persistent banner. The judge can dismiss it +with an X, and Blade remembers that announcement ID in the browser. A newly +published replacement has a new ID and appears again. An urgent announcement +renders as a blocking dialog and requires an acknowledgement for that ID. + +After the transaction commits, global publication posts to the configured root +channel and allowlists linked Discord IDs for all member judges in the +hackathon. Room publication posts to the room thread and allowlists linked IDs +for member judges with current room presence. The mutation returns delivery +status. Discord failure never rolls back the stored Blade announcement. + +## 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. +- `publishAnnouncement` to replace the current global or room announcement. +- `clearAnnouncement` to clear one current announcement. +- `listAnnouncements` for principal-scoped polling. + +`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. +- Announcement messages trim surrounding whitespace, reject control + characters, and allow 1 through 1,000 characters. + +## Data, migration, and compatibility + +- Generate additive migrations for the Discord columns and announcement table. +- 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. +- Put a global announcement action beside the room controls and an announcement + action on every active room. +- Use one composed dialog for global and room publication. Show the scope, + current message, authenticated default, guest switch, urgency switch, replace + action, and clear action. +- Render standard notices above the judge workspace. Render urgent notices in a + blocking dialog above the feedback flow. Stack the global notice before the + room notice and label both scopes. +- Server-render initial notices. Poll with React Query every 30 seconds and keep + background polling enabled. +- Keep 44px targets, mobile wrapping, skeleton parity, and no horizontal + document overflow. +- Warn about Discord delivery failure without claiming the judging action + failed. +- Label the guest identity field `Full Name`. +- Render authenticated judge identities with the current Member full name and + use the stored Discord label only when no Member profile is linked. +- 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..33c9f90b6 --- /dev/null +++ b/.forge/features/judging-discord-comms/status.md @@ -0,0 +1,110 @@ +# 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. +- 2026-09-05: Discord copy uses the linked member profile's full name. The + Discord account label remains a fallback for members without a profile. +- 2026-09-05: The same full-name rule applies to every judging view that names + an authenticated judge. Guest access now labels its identity field `Full +Name`. +- 2026-09-05: The slice now includes one current global announcement and one + current announcement per room. Authenticated judges are the default audience; + officers may include QR guests. +- 2026-09-05: Judging pages server-render visible notices and poll every 30 + seconds, including in hidden tabs. +- 2026-09-05: Every announcement also posts to Discord. Global notices mention + all authenticated hackathon judges in the root channel. Room notices mention + assigned authenticated judges in the room thread. Discord failure does not + roll back Blade publication. +- 2026-09-05: Standard notices require an explicit X dismissal. Urgent notices + block the workspace until the judge acknowledges them. Neither uses a timer. + +## 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. +- [x] Test delivery in the development guild bot-testing channel. +- [x] Create and assign the GitHub issue. +- [x] Commit, push, open the PR, and attach screenshots outside the repo. +- [x] Add announcement persistence, validation, principal-scoped reads, and + audit events. +- [x] Add global and room announcement controls to Command Center. +- [x] Add server-rendered announcement banners and urgent dialogs. +- [x] Extend automated tests and visual evidence for announcements. +- [x] Address and resolve CodeRabbit review threads until approved. + +## Validation and commands + +- `git fetch origin main` and `git rebase origin/main`: passed. The branch is + based on `bd97fccb` after PR #533 merged. +- `pnpm forge:feature judging-discord-comms "Judging Discord Comms"`: passed + after linking the existing dependency directory. +- `pnpm db:generate`: passed; created additive migrations + `0048_little_tarot` and `0049_real_redwing`. +- `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 verify:precommit`: passed, including formatting, lint, type checks, and + repository policy checks across 33 tasks. +- `pnpm --filter=@forge/validators test -- judging audit`: passed, 17 tests. +- `pnpm --filter=@forge/api test -- judging`: passed, 25 tests. +- `pnpm --filter=@forge/api test`: passed, 112 files and 797 tests. +- `pnpm --filter=@forge/db test -- migration`: passed, 65 tests. +- `pnpm --filter=@forge/db test -- judging-schema`: passed, 11 tests. +- `pnpm --filter=@forge/db test`: passed, 148 tests, including development + backup policy classification for announcements. +- `pnpm --filter=@forge/blade test -- authenticated-shell +project-judge-privacy guest-name-gate judging-announcements`: passed, 29 + tests. +- `pnpm analyze:react:changed`: passed, 11 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 through T.K in Dev@KnightHacks `#bot-testing`: passed. + The check covered thread provisioning, member arrival, guest arrival, + current-QR delivery, rotation, member mentions, and guest revocation. +- Live testing caught Discord's 25-character nonce limit. QR and thread + messages now use a 22-character base64url nonce, backed by a regression test. +- Live copy check: passed. Member arrivals and organizer actions show the + linked member profile name, such as `Dylan Vidal`, instead of a Discord + username. +- Announcement delivery review: passed for replacement ordering, current-role + recipients, Discord mention batching, room-thread provisioning races, + complete long-message delivery, server-rendered urgent notices, background + polling, and mobile overflow. + +## Links + +- PRs: https://github.com/KnightHacks/forge/pull/535 +- Issues: https://github.com/KnightHacks/forge/issues/534 +- Discord/thread context: Dev@KnightHacks `#bot-testing`, Sponsor Suite A 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..9d0345d20 --- /dev/null +++ b/.forge/features/judging-discord-comms/test-cases.md @@ -0,0 +1,232 @@ +# 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. + +### TC-017: publish a global announcement + +An officer publishes a message with the default audience. Every authenticated +judge for the hackathon sees an `All judging rooms` notice. QR guests do not. +The root Discord channel receives the message and linked member mentions. + +### TC-018: include guests in a global announcement + +Enabling `Include guest judges` exposes the global Blade notice to authenticated +judges and active QR guests for that hackathon. Guests are not Discord +recipients. + +### TC-019: publish a room announcement + +An authenticated judge assigned to the room sees its notice. A member in +another room and a member without a room do not. A QR guest in the room sees it +only when guest delivery is enabled. The room thread receives the message and +mentions assigned authenticated judges. + +### TC-020: replace and clear an announcement + +Publishing again in the same scope clears the old row and makes only the new +message current. Clearing removes the notice from the next read while retaining +the stored history. + +### TC-021: render notices before hydration + +The first server response includes every announcement visible to the principal. +There is no empty announcement flash while the client hydrates. + +### TC-022: poll every 30 seconds + +An open judging page picks up publications, replacements, and clears within 30 +seconds. Polling continues while the document is hidden. + +### TC-023: dismiss standard notices explicitly + +A standard notice never expires on a timer. Selecting its X hides that exact +announcement in the browser. A replacement in the same scope appears because +it has a new ID. + +### TC-024: acknowledge urgent notices + +An urgent global or room announcement opens above the judging workspace and an +open feedback form. Judging interaction remains blocked until the judge +acknowledges that announcement ID. + +### TC-025: expose current state in Command Center + +The Rooms tab shows the current global notice and each room's current notice, +including guest visibility and urgency. Officers can replace or clear either +one. + +### TC-026: show human names for every judge + +Room rosters, score feedback, and officer evaluation history show an +authenticated judge's current Member full name even when the stored Judge label +contains a Discord username. An authenticated judge without a linked Member +profile keeps the stored Discord label. Guest judges keep the full name entered +through the field labeled `Full Name`. + +## 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. + +### TC-NEG-009: prevent announcement scope spoofing + +Guests and members cannot request announcements for an arbitrary room. The API +derives room visibility from the signed guest session or current member +presence. + +### TC-NEG-010: enforce announcement permissions + +Guests and non-officer judges cannot publish, replace, or clear announcements. + +### TC-NEG-011: reject invalid announcement content + +Blank text, text over 1,000 characters, and control characters fail validation +without replacing the current message. + +### TC-NEG-012: serialize same-scope publication + +Concurrent publications leave exactly one current row in a scope. Global and +room scopes do not clear one another. + +### TC-NEG-013: isolate announcement Discord failure + +Blade publication stays current and the officer sees `failed` when Discord +cannot accept the global or room announcement. + +### TC-NEG-014: preserve threads after transient Discord failures + +A rate limit, server error, or transport failure does not replace the saved +room thread. A confirmed missing, forbidden, invalid, or wrong-parent thread is +eligible for replacement. + +### TC-NEG-015: expose the Discord access boundary + +Command Center tells officers to select an organizer-only root channel and that +thread readers inherit channel access. Mentions remain notification routing, +not authorization. + +## Open questions + +None. diff --git a/apps/blade/src/app/_components/judging/guest-name-gate.tsx b/apps/blade/src/app/_components/judging/guest-name-gate.tsx index 8edb4521b..edfddbf10 100644 --- a/apps/blade/src/app/_components/judging/guest-name-gate.tsx +++ b/apps/blade/src/app/_components/judging/guest-name-gate.tsx @@ -59,14 +59,14 @@ export function GuestNameGate() {
- + setDisplayName(event.target.value)} - placeholder="Your name" + placeholder="Your full name" required value={displayName} /> diff --git a/apps/blade/src/app/_components/judging/judging-announcements.tsx b/apps/blade/src/app/_components/judging/judging-announcements.tsx new file mode 100644 index 000000000..e68f92ebb --- /dev/null +++ b/apps/blade/src/app/_components/judging/judging-announcements.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useMemo, useState, useSyncExternalStore } from "react"; +import { AlertTriangle, BellRing, X } from "lucide-react"; +import { createPortal } from "react-dom"; + +import type { RouterOutputs } from "@forge/api"; +import { Badge } from "@forge/ui/badge"; +import { Button } from "@forge/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@forge/ui/dialog"; + +import { api } from "~/trpc/react"; + +type Announcement = RouterOutputs["judging"]["listAnnouncements"][number]; + +const DISMISSED_KEY = "forge-judging-announcements-dismissed"; +const subscribeToNothing = () => () => undefined; + +function scopeLabel(announcement: Announcement) { + return announcement.roomName ?? "All judging rooms"; +} + +function readDismissed() { + try { + const value = JSON.parse( + sessionStorage.getItem(DISMISSED_KEY) ?? "[]", + ) as unknown; + return Array.isArray(value) + ? new Set(value.filter((id): id is string => typeof id === "string")) + : new Set(); + } catch { + return new Set(); + } +} + +export function JudgingAnnouncements({ + hackathonId, + initialAnnouncements, +}: { + hackathonId?: string; + initialAnnouncements: Announcement[]; +}) { + const [dismissedThisMount, setDismissedThisMount] = useState>( + new Set(), + ); + const mounted = useSyncExternalStore( + subscribeToNothing, + () => true, + () => false, + ); + const query = api.judging.listAnnouncements.useQuery( + { hackathonId }, + { + refetchInterval: 30_000, + refetchIntervalInBackground: true, + }, + ); + const announcements = query.data ?? initialAnnouncements; + const dismissed = useMemo(() => { + if (!mounted) return dismissedThisMount; + return new Set([...readDismissed(), ...dismissedThisMount]); + }, [dismissedThisMount, mounted]); + + function dismiss(id: string) { + const next = new Set([...readDismissed(), ...dismissedThisMount]); + next.add(id); + const ids = [...next].slice(-100); + try { + sessionStorage.setItem(DISMISSED_KEY, JSON.stringify(ids)); + } catch { + // Keep this dismissal in memory when browser storage is unavailable. + } + setDismissedThisMount(new Set(ids)); + } + + const visible = announcements.filter( + (announcement) => !dismissed.has(announcement.id), + ); + const urgent = visible.find((announcement) => announcement.isUrgent); + const banners = visible.filter((announcement) => !announcement.isUrgent); + const bannerLayer = banners.length ? ( +
+
+ {banners.map((announcement) => ( +
+
+
+ + +
+
+

Judging announcement

+ + {scopeLabel(announcement)} + +
+

+ {announcement.message} +

+
+ +
+
+ ))} +
+
+ ) : 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 ff9b7ec2a..e32ec6e47 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,17 @@ import { ArrowUp, Copy, DoorOpen, + ExternalLink, + Hash, KeyRound, + Megaphone, + MessageCircle, Pencil, Plus, Printer, QrCode, RefreshCw, + Send, ShieldAlert, UserRoundX, UsersRound, @@ -35,6 +40,9 @@ 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 { Switch } from "@forge/ui/switch"; +import { Textarea } from "@forge/ui/textarea"; import { toast } from "@forge/ui/toast"; import { @@ -47,7 +55,11 @@ 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 Announcement = NonNullable; +type QrResult = Pick< + RouterOutputs["judging"]["generateRoomLink"], + "id" | "qrCodeUrl" | "url" +>; const ACTIVE_PRESENCE_WINDOW_MS = 2 * 60 * 1000; @@ -311,6 +323,198 @@ function RoomQrDialog({ ); } +interface AnnouncementDialogProps { + current: Announcement | null; + data: ControlData; + onClose: () => void; + onSaved: () => void; + room: Room | null; +} + +export function AnnouncementDialog(props: AnnouncementDialogProps) { + return ( + + ); +} + +function AnnouncementDialogContent({ + current, + data, + onClose, + onSaved, + room, +}: AnnouncementDialogProps) { + 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 [message, setMessage] = useState(current?.message ?? ""); + const scope = room?.name ?? "All judging rooms"; + + async function submit(event: FormEvent) { + event.preventDefault(); + try { + const result = await publish.mutateAsync({ + hackathonId: data.hackathon.id, + includeGuests, + isUrgent, + 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. + +
+ +
+ +