diff --git a/.forge/features/forms-delivery-feedback/investigation.md b/.forge/features/forms-delivery-feedback/investigation.md new file mode 100644 index 000000000..5b3d3f29f --- /dev/null +++ b/.forge/features/forms-delivery-feedback/investigation.md @@ -0,0 +1,115 @@ +# Forms Delivery and Action Feedback Investigation + +Inspected 2026-09-05 at local Forge revision `1c1457e0`. +Scope: code/history inspection, user evidence, offline checks, and planning. +No production or database access was used in this investigation. + +## Confirmed delivery defect + +Screenshot 04 shows three `recruiting.notify` failures with one attempt and: + +> Invalid Form Body nonce[NONCE_TYPE_TOO_LONG]: Must be 25 or fewer characters long. + +It also shows one cancelled execution with zero attempts. This supersedes the +earlier observation of no executions: configuration/enqueue now exist for +these samples, and a dispatcher reached Discord. The screenshot cannot tell +whether Cron or manual Retry performed those attempts. Cancellation is +consistent with the reported deletion; it is not evidence of Discord failure. + +`packages/api/src/utils/forms/callback-policy.ts:17` returns `executionId` +unchanged. Executions use UUIDs. `database-callbacks.ts:214` posts that value as +`nonce` with `enforce_nonce: true`. A synthetic invocation of the real helper +returned length **36**, exceeding Discord's **25** character limit. + +The [Discord Create Message contract](https://docs.discord.com/developers/resources/message#create-message) +confirms the length limit and says nonce deduplication covers the past few +minutes. The defect is in request construction, independent of the editor's +chosen note. Coolify access is unnecessary to diagnose this error. It does not +prove all deployment settings are correct or that no additional error will +appear after the nonce is fixed. + +The existing `packages/api/src/tests/forms/callbacks.test.ts:278` explicitly +asserts that the full UUID is returned. It tests identity/stability but omits +the provider length contract. This explains why the suite passes with the bug. + +## Submission confirmation + +`generic-form-response-form.tsx:634` ignores the create response result and +uses `window.location.reload()` when no `onSubmitted` callback is passed. The +public form route passes none. The API already returns `formResponseId`. + +`database-responses.ts:210` only selects a saved response for a multiple-response +form when `requestedResponseId` is provided. Reloading the bare form URL thus +renders an open blank form for `multiple_locked`. The existing +`?responseId=...` route can instead render an ownership-checked receipt. + +The submitted-state panel exists in `generic-form-respondent.tsx:233`, so it +would be inaccurate to say there is no confirmation implementation anywhere. +The confirmed gap is the successful create transition, particularly for +multiple-response forms. Chris reports the symptom; screenshot 03 is a blank +form state, not independent proof of the preceding mutation or live mode. +Editable responses also reload without a distinct update-success announcement. + +## Callback dialog overflow and clarity + +Screenshots 01 and 02 show the same callback dialog at opposite horizontal +scroll positions. The primary action's text is outside the initial view. +Screenshot 05 shows the native dropdown and raw `recruiting.notify` label. + +`form-callbacks-dialog.tsx:58` places a nested grid inside a width-limited dialog +with vertical auto overflow; its selects and grid children have no explicit +minimum-width reset. Native selects can size to the longest option, even when +that option is not selected. Shared buttons default to `whitespace-nowrap`. +These are plausible contributors; exact CSS causality needs a real browser +with long labels and viewport measurements. No browser reproduction was run. + +Further source-confirmed usability gaps: + +- `admin-form-builder.tsx:101` always initializes `discord.assign-role`, even + when unavailable to the editor. +- The dialog shows active configurations by raw slug instead of catalog label. +- The note source select uses only an accessible name, and fixed input relies + on placeholder copy. The callback description is not rendered. +- `addCallback` sets a message on the underlying builder, without closing the + dialog or refreshing configurations. The modal obscures that message. +- Disable refreshes, but has no rejection handler in its promise chain. +- The current mapped contract is only `memberId` plus `note`. Selecting + "What is your name?" maps that answer to the note; it does not construct a + full application summary or select a team director. + +## Response deletion feedback + +`form-responses-dashboard.tsx:642` already has an explicit pre-delete warning +and Delete permanently action. However, the mutation at line 902 only refreshes +on success and exposes neither success toast nor failure feedback. The detail +selection stores a response object, so refreshing list props alone does not +explicitly close that selected detail. Plan deletion as one complete interaction +with pending, success, and failure states rather than adding another warning. + +## Blast radius and previous diagnosis correction + +- The invalid nonce affects every `recruiting.notify` execution using this + helper, across all forms. It does not establish that role assignment or all + other Discord features fail; they do not use this message path. +- Callback dialog/feedback changes apply to the shared generic form editor. +- Receipt defects particularly affect multiple-response forms; verify all modes. +- Deletion feedback affects the shared generic response dashboard. +- All five Club team slugs share these generic surfaces: Sponsorship, Workshop, + Design, Outreach, and Dev. Only Outreach's current failure is pictured; other + forms' live configurations and execution counts remain unknown. +- Earlier migration/history inspection found no automatic legacy-connection + backfill and no historical director-routing parity. Those are separate gaps. + Missing configuration was a plausible explanation for the earlier empty + Delivery view, not a confirmed production root cause. The new configured + failures have a directly evidenced code cause: the invalid nonce. + +## Remaining checks + +1. Reproduce long-label overflow locally with synthetic data in a browser. +2. Exercise actual submit/delete interactions and receipts in all response modes. +3. Test the real outbound handler against a mock enforcing Discord limits. +4. Confirm desired scope of historical director-routing parity before adding + payload fields or configuration migration. +5. After the code repair is deployed, an authorized maintainer verifies matching + Blade/Cron revisions and performs one agreed delivery check. Only investigate + environment or cron if fresh evidence indicates those failures. diff --git a/.forge/features/forms-delivery-feedback/spec.md b/.forge/features/forms-delivery-feedback/spec.md new file mode 100644 index 000000000..31085512f --- /dev/null +++ b/.forge/features/forms-delivery-feedback/spec.md @@ -0,0 +1,82 @@ +# Forms Delivery and Action Feedback Spec + +Status: Callback parity refinement approved; implementation and validation in progress + +## Purpose and users + +Members need an unmistakable receipt after submitting a form. Form editors +need understandable notification settings, reliable Discord delivery, and clear +confirmation when configuring a callback or deleting a response. + +This proposal addresses Chris's five screenshots and submission/deletion report +from 2026-09-05. See [investigation.md](./investigation.md) for evidence. The +before/after screenshots are hosted in the pull request rather than committed +to the repository. + +## Proposed PR scope + +- Fix recruiting notification delivery rejected by Discord's nonce limit. +- Keep the callback dialog and its actions within the viewport, including long + question labels, mobile widths, and browser zoom. +- Restore the generic tRPC callback mapper. Each explicitly registered + procedure exposes named inputs, and an administrator maps each input to one + form question, one respondent value, or a fixed value. +- Restore the structured recruiting announcement with applicant fields, team + color, and the configured director mention. +- Make Discord role assignment use the same mapper, with its role ID entered as + a fixed value. +- Show a persistent submission receipt, including when multiple responses are + allowed. Starting another response must be an explicit choice. +- Confirm successful response deletion and show failures without losing context. +- Show callback configure/disable/retry results in the surface where initiated. + +## Acceptance criteria + +1. A valid configured recruiting callback can reach Succeeded with a + provider-compliant request. Submission success is independent of delivery. +2. Neither the document nor callback dialog requires horizontal scrolling at + 320px, 375px, 768px, and desktop widths. Long labels do not push actions away. + Vertical scrolling remains available for short viewports. +3. Existing configurations display a readable procedure name, enabled state, + and an input-by-input summary. Procedure metadata may give each input a + label, description, fixed-value placeholder, and allowed source kinds. +4. Each required procedure input is mapped exactly once. A form question may + supply at most one input in a configuration. Available respondent values are + Member ID, respondent name, respondent email, auth user ID, and Discord user + ID. The UI labels each identity precisely. +5. The recruiting action accepts name, email, major, graduation term, + graduation year, and team. It sends the legacy-style structured Discord + announcement, colors it from the configured team role, and mentions the + configured director role. Team may be a fixed value per form. +6. The Discord role action accepts a Discord role ID as a fixed value and acts + on the respondent's Discord user ID. Server-side role policy remains in + force. +7. A successful submission shows "Response submitted" and a way to review the + saved response. Refresh retains the receipt. A form accepting multiple + responses offers an explicit "Submit another response" action. +8. Failed submissions retain answers and visibly explain the failure. Pending + requests prevent repeated clicks. Editable responses acknowledge updates. +9. Deletion retains the existing destructive warning. Only confirmed successful + deletion closes the detail view, updates the list/count, and announces + "Response deleted". Failure keeps the response available with an error. +10. Saved callback changes become visible immediately. Errors appear inside the + open dialog; retry reports the returned delivery outcome accurately. + +## Boundaries and open decisions + +- Chris approved implementing the proposed order, including mobile, on 2026-09-05. + No production changes, replay, messages, commit, push, issue, or PR creation. +- Missing instruction video is out of scope. +- No schema, dependency, permission, or deployment change is currently needed + for the core fixes. +- Callback procedures are discovered only when their tRPC metadata explicitly + registers them. The configuration surface remains admin-only, and each + procedure retains its own authorization and input validation. +- Existing durable execution snapshots, leases, retries, and Delivery tab stay + in scope. No historical response is replayed automatically. +- Existing one-note recruiting configurations cannot be translated into the + new structured contract without knowing the intended question mappings. + They must fail visibly as stale configuration until an administrator remaps + them. +- A rich Discord role picker and automatic legacy configuration migration are + outside this refinement. diff --git a/.forge/features/forms-delivery-feedback/srd.md b/.forge/features/forms-delivery-feedback/srd.md new file mode 100644 index 000000000..b0d27a083 --- /dev/null +++ b/.forge/features/forms-delivery-feedback/srd.md @@ -0,0 +1,143 @@ +# Forms Delivery and Action Feedback SRD + +Status: Callback parity refinement approved on 2026-09-05 + +## Ownership and constraints + +Follow [engineering principles](../../../docs/agentic-development/forge-engineering-principles.md), +[repository conventions](../../../docs/REPO-CONVENTIONS.md), and +[Blade design system](../../../apps/blade/DESIGN_SYSTEM.md). + +Blade owns respondent receipts, callback configuration, and action feedback. +`@forge/api` owns execution dispatch and provider payload validation. Cron is an +affected consumer of the API helper; it should not need a new workflow. +Preserve current form/section permissions, callback permissions, locked modes, +response ownership checks, and server-only external effects. + +## Proposed implementation sequence + +### 1. Repair the Discord request contract + +- Replace the identity implementation in + `packages/api/src/utils/forms/callback-policy.ts:formCallbackDeliveryNonce` + with a deterministic encoding no longer than 25 characters. +- Preferred candidate: encode the full 16 UUID bytes as unpadded base64url + (22 characters), with explicit canonical UUID validation. This preserves all + identity bits; do not truncate a UUID or generate a fresh nonce per retry. +- Keep database execution UUIDs and `enforce_nonce: true` unchanged. The + dispatcher must derive the nonce from the same execution on every attempt. +- Test the actual recruiting request through a mocked Discord boundary, not + only a separately modeled dispatcher. Use synthetic member/config data. +- Preserve succeeded/cancelled guards and fenced lease completion. Discord + documents nonce deduplication only for the past few minutes, so do not claim + unconditional exactly-once delivery across an arbitrarily delayed crash. +- No table migration is required for this proposal. Failed executions contain + input snapshots and derive their nonce at dispatch time. + +### 2. Discover registered tRPC callback procedures + +- Add typed tRPC metadata for a form callback's label, description, required + permission, and per-input presentation hints. +- Build the catalog by scanning the root router for procedures carrying that + metadata. Do not maintain a second hard-coded callback registry. +- Keep the queue's stored procedure path and validated input snapshot. At + dispatch, resolve that registered path and invoke it through the server + router. A missing or changed procedure fails the execution visibly. +- Only admin users with the form editing capability and the procedure's + declared permission may save, disable, or retry a mapping. + +### 3. Restore the generic input mapper + +Likely files: `apps/blade/src/app/_components/admin/forms/` +`form-callbacks-dialog.tsx`, `admin-form-builder.tsx`, +`form-callback-mappings.ts`, and adjacent tests. + +- Reproduce the overflow with a long unselected question option and the + disabled permission label; measure actual scrollWidth/clientWidth before + choosing CSS. Suspected causes are native select intrinsic width and nested + grid minimum sizing, compounded by non-wrapping actions. +- Use existing Blade Select/combobox conventions, explicit constrained widths, + shrinkable grid children, wrapping summaries/actions, and viewport gutters. + Do not hide overflow as a substitute for making controls usable. +- Select the first permitted procedure (or an empty explanatory state), rather + than hardcoding unavailable role assignment. Keep server enforcement intact. +- Render one mapping row per declared input. Each row selects a form question, + a respondent value, or fixed input. Used questions are unavailable in other + rows, matching the legacy editor and preventing ambiguous fan-out. +- Respondent values are Member ID, full name, email, auth user ID, and Discord + user ID. Resolve them once while enqueueing and persist only the resulting + input snapshot. +- Use procedure metadata for labels, descriptions, placeholders, allowed source + kinds, and fixed-value input type. Saved settings seed the edit flow. +- Keep save failures in the dialog. On success, close and announce the result, + refresh server-read props, and preserve unrelated unsaved builder edits. + Add explicit error handling for disable. + +### 4. Restore concrete callback behavior + +- `recruiting.notify` accepts `name`, `email`, `major`, `gradTerm`, `gradYear`, + and `team`. Resolve the director role through the database-backed Discord + configuration, use its live role color for the embed, mention only that role, + and retain the deterministic execution nonce. +- `discord.assign-role` accepts respondent Discord user ID and a fixed Discord + role ID. Validate that role against the existing safe assignment policy before + saving and again before delivery. +- Procedure input schemas validate configuration-time representative values and + the mapped snapshot at enqueue and dispatch boundaries. + +### 5. Retain the response receipt + +Likely files: `generic-form-response-form.tsx`, `generic-form-respondent.tsx`, +and `apps/blade/src/app/form/[slug]/page.tsx`. + +- Use the returned `formResponseId` to navigate to the existing + `/form/?responseId=` ownership-checked receipt path instead of + unconditionally reloading the same URL. Keep pages server components. +- Display and focus a clear submitted state; preserve the receipt on refresh. + Use a transient immediate success state if navigation is delayed. +- For `multiple_locked`, provide an explicit action back to the bare form URL + to start another response. Preserve single-locked and editable behavior. +- Audit all `GenericFormResponseForm` consumers and its optional `onSubmitted` + callback before changing the component contract. Announce update success. +- Failed mutations preserve answers and show an accessible error. Do not infer + that a callback failure means the response failed to save. + +### 6. Confirm deletion and callback outcomes + +Likely file: `form-responses-dashboard.tsx` and its interaction tests. + +- Keep the existing pre-delete confirmation. Await deletion before closing the + selected detail, refreshing the list/counts, and showing a success toast. +- Keep the detail open on failure and expose the error. Reset selection only + for the response actually deleted; keep search and active tab. +- Retry returns a result that may itself be `failed` despite mutation success. + Do not show a delivered toast merely because the HTTP mutation resolved. +- Display friendly callback labels and status explanations, retaining provider + detail for diagnosis. Cancelled/deleted executions must not offer retry. + +## Compatibility and rollout + +- A new configuration affects future responses only; the PR must not silently + enqueue historical submissions or revive legacy synchronous connections. +- An authorized maintainer deploys API-consuming Blade and Cron images and + checks the revision of both, since manual retry and scheduled delivery can + otherwise use different code. +- After deployment, an authorized operator may retry one retained execution + that failed specifically with `NONCE_TYPE_TOO_LONG`, then verify its result. + This is a separate side-effecting action, not part of this investigation. +- Cancelled executions whose responses were deleted remain cancelled. Failed + executions are not automatically selected by `dispatchPendingFormCallbacks`. +- If a fresh execution remains pending, investigate cron scheduling. If it + reports a different provider error, investigate that error separately. +- Reverting the nonce repair restores the known rejection; pause further + operational retries if rollback is needed. + +## Confirmed scope details + +- Recruiting fields are name, email, major, graduation term, graduation year, + and team. The repeated “major” in the discussion is treated as email, matching + the legacy job contract. +- Team is normally a fixed value on a team-specific application form. The job + owns team-to-director routing and embed presentation. +- Auth user ID and Discord user ID are distinct sources and must not be + mislabeled. The Discord role action consumes the Discord user ID. diff --git a/.forge/features/forms-delivery-feedback/status.md b/.forge/features/forms-delivery-feedback/status.md new file mode 100644 index 000000000..0dd2c072f --- /dev/null +++ b/.forge/features/forms-delivery-feedback/status.md @@ -0,0 +1,133 @@ +# Forms Delivery and Action Feedback Status + +Current phase: Callback parity refinement verified and published for review + +## Decisions + +- 2026-09-05: Chris requested investigation and durable planning for Discord + recruiting failure, callback dialog overflow/clarity, submission receipts, + and response deletion feedback. Preserve the supplied screenshots for the PR. +- New screenshot evidence confirms Discord rejects the nonce; it replaces + missing configuration as the explanation for these attempted executions. +- Proposed core PR owns Blade forms UX and the shared API nonce repair. Cron + is a verification/deployment consumer. +- Work branch: `codex/forms-delivery-feedback`, rebased on 2026-09-05 onto + current `origin/main` at `ec5e26ec` before publication. +- Chris approved starting the proposed implementation order with mobile support. +- Core nonce repair, callback editor, submission receipt, deletion feedback and + retry outcome feedback are implemented. Automated tests used only synthetic + local/disposable PostgreSQL data and cleaned up their fixtures. No production + access, real callback invocation, or Discord send occurred. +- Dylan confirmed the legacy generic mapper is the intended model: registered + tRPC procedures expose inputs, and admins map each input from one question, + one respondent field, or a fixed value. Question sources cannot be reused. +- Dylan confirmed structured recruiting parity and Discord role assignment are + part of this PR. Recruiting uses the six legacy fields, configured director + role, and team color. Discord role assignment uses a fixed Discord role ID. +- Auth user ID and Discord user ID are separate sources. The latter is the + Discord snowflake used for Discord actions. + +## Ordered work + +- [x] Inspect current code and user evidence; correct earlier hypotheses. +- [x] Preserve screenshots and create investigation/spec/SRD/test-case bundle. +- [x] Measure the real helper's nonce length and run focused existing tests. +- [x] Approve core PR and callback parity refinement. +- [x] Reproduce provider-contract failure and overflow/interaction bugs with + tests that exercise the actual implementation boundary. +- [x] Repair nonce, callback editor, receipt transition, and deletion feedback. +- [x] Verify isolated negative cases, unavailable actions, all receipt modes, + retry outcomes, and mobile/desktop component layout. +- [x] Run root checks and changed React analysis; preserve exact blockers below. +- [x] Restore local dependencies and PostgreSQL; pass the full automated gate + and targeted authenticated forms E2E. +- [x] Complete final diff and responsive browser review. +- [x] Prepare and publish PR text with before/after evidence and a deployment + checklist after Dylan authorized the update. +- [x] Replace hard-coded callbacks with metadata-discovered tRPC procedures. +- [x] Restore the per-input mapping UI and structured recruiting/role actions. +- [x] Add regression coverage, rerun all gates, and capture new screenshots. +- [x] Push the rebased branch and update the pull request with the behavior + summary and attached screenshots. +- [ ] Authorized maintainer validates one delivery after deploying matching + Blade/Cron revisions. Do not replay historical responses automatically. + +## Investigation baseline captured on 2026-09-05 + +- Real `formCallbackDeliveryNonce` called with a synthetic UUID via `tsx`: + `{ nonceLength: 36, discordMaxLength: 25, violatesDiscordLimit: true }`. +- `pnpm --filter @forge/api test -- src/tests/forms/callbacks.test.ts src/tests/forms/responses.test.ts`: + **27 tests passed**, 2 files. +- `pnpm --filter @forge/blade test -- src/tests/forms/generic-form-response-form.test.tsx src/tests/forms/generic-form-respondent.test.tsx src/tests/forms/form-responses-dashboard.test.tsx src/tests/forms/admin-form-builder-dialogs.test.tsx src/tests/forms/form-callback-mappings.test.ts`: + **29 tests passed**, 5 files. +- These passing baseline tests do not establish correct provider payload or + browser layout. No new regression test or end-to-end run occurred this phase. +- Documentation formatting passed; all local Markdown links resolve and all + five preserved screenshots were verified byte-for-byte against attachments. + +## Implementation verification + +- Nonce regression failed at 36 > 25 before the fix. The full API forms suite + now passes 73 tests in 11 files, including actual enqueue/dispatcher tests + using mocked database and Discord boundaries. +- Blade forms suite: 132 tests passed in 20 files after rebasing onto current + `origin/main`. Covers pending submit, + failure retention, each response mode, callback save/edit/permission states, + deletion confirmation/failure, and accurate retry outcomes. +- Cron suite: 29 tests passed in 6 files. No cron process was started. +- Headed Chrome with real components and synthetic mocked data reproduced the + old dialog at clientWidth 373 / scrollWidth 1412 on a 375px viewport. + Updated dialog clientWidth equals scrollWidth at 320, 375, 768, and 1440px. + Synthetic receipt navigation/refresh and mobile deletion also passed. +- Edge cases: 320x480 short viewport, 240-character unbroken question label at + 320px, and 720x450 reflow passed. The unbroken-label regression was reproduced + first (scrollWidth 3245), then fixed and rechecked (scrollWidth 271). +- Mobile submission/deletion failures retained context. Rechecked synthetic + answer text after receipt navigation and refresh. Inspect the preserved + after screenshots in the pull request's Screenshots section. +- The synthetic browser checks isolate components rather than a deployed system. + The authenticated local Next route is covered separately below. No real + Discord behavior was tested. +- Restored dependencies from the unchanged lockfile and refreshed stale + generated validator/UI declarations. No dependency declarations or lockfile + were changed. +- React analyzer: 12 tracked changed TSX files, 8 components, zero failures. +- `pnpm format`: passed, 24 tasks. `git diff --check`: passed. +- Repository lint without stale ESLint caches: 31 tasks passed with warnings and + zero errors. The normal cached run had replayed unresolved-type errors created + before dependencies were restored. +- `pnpm typecheck`: 33 tasks passed. The Blade production build and its 13 + dependencies passed. A repository-wide build still reproduces unrelated + `_global-error` prerender failures in the archived 2023 and 2024 apps. +- `pnpm test`: 29 tasks passed on the second full run. The first run completed + all 136 database assertions but timed out dropping one disposable database; + that file passed 10/10 in isolation before the successful full rerun. +- Extended `forms-platform.spec.ts` to assert receipt URL/refresh and deletion + toast/count. The targeted authenticated journey passed 1/1 in 26.5 seconds + against localhost PostgreSQL. Earlier timeouts came from Playwright reusing a + stale unresponsive Node 24 server on port 3100; a fresh Node 25 server passed. +- After rebasing, the API forms suite passed 73 tests in 11 files, the Blade + forms suite passed 132 tests in 20 files, the Discord configuration suite + passed 10 tests, and root typecheck passed all 33 tasks. +- The authenticated admin mapping page was captured in headed Chrome at desktop + and mobile widths with zero console errors and zero horizontal overflow. Its + synthetic database fixture was removed after capture. +- The feature-specific automated gate is green. Maintainer review and deployed + delivery verification remain before merge. + +## Next owner actions + +1. Review the final PR diff and attached desktop/mobile callback screenshots. +2. After approved deployment, an authorized maintainer verifies matching + Blade/Cron revisions and retries one retained NONCE_TYPE_TOO_LONG failure, + without replaying all old responses. + +## Links + +- [Investigation and source evidence](./investigation.md) +- [Product scope](./spec.md) +- [Technical plan](./srd.md) +- [Acceptance tests](./test-cases.md) +- Existing baseline: [Forms and Event Feedback](../forms-and-event-feedback/spec.md) +- Tracking issue: [ChrisH0125/forge#1](https://github.com/ChrisH0125/forge/issues/1) +- Pull request: [KnightHacks/forge#533](https://github.com/KnightHacks/forge/pull/533) diff --git a/.forge/features/forms-delivery-feedback/test-cases.md b/.forge/features/forms-delivery-feedback/test-cases.md new file mode 100644 index 000000000..b2ff6c33e --- /dev/null +++ b/.forge/features/forms-delivery-feedback/test-cases.md @@ -0,0 +1,88 @@ +# Forms Delivery and Action Feedback Test Cases + +Status: Callback parity regression tests implemented and passing + +## API and provider boundary + +- **TC-001:** Given a canonical execution UUID, derive a stable string nonce + of at most 25 characters. Distinct UUIDs (including a difference in the last + byte) remain distinct. Invalid identities fail before an outbound request. +- **TC-002:** Enqueue a synthetic configured recruiting submission in a + disposable local test environment; dispatch through the actual database + dispatcher with mocked Discord. Assert the request meets the nonce limit, + preserves `enforce_nonce` and mention restrictions, and records Succeeded. +- **TC-003:** Mock a provider rejection. Response remains saved, delivery is + Failed with bounded error and attempts. Retry uses the same nonce; completed + and cancelled executions cannot send again. Existing lease tests stay green. +- **TC-004:** No active configuration produces no execution. A valid active + configuration creates work for new submissions only. Configuration changes + do not rewrite existing execution inputs or enqueue old responses. +- **TC-005:** Distinguish automatic dispatch of due pending/expired running + work from explicitly retried failed work. Exercise deleted-response guards. + +## Callback editor and responsive browser checks + +- **TC-006:** At 320px, 375px, 768px, and desktop widths plus 200% browser zoom, + open the real callback dialog with a long question prompt, long permission + label, and active configuration. The dialog and document have no horizontal + overflow. Save, Disable, and Close remain reachable. Verify real bounding + boxes and screenshots; jsdom cannot prove layout. +- **TC-007:** Role assignment unavailable, recruiting available: no unavailable + action is selected for configuration. If none are available, saving is + disabled with an explanation. Keyboard selection remains usable. +- **TC-008:** Configure every recruiting input independently from compatible + questions, respondent fields, or fixed values. Team can be fixed. Saved + labels and source summaries match persisted mappings on reopen. +- **TC-009:** Successful configure/disable updates the visible state and + announces completion. Failed operations display errors where the user is + working. Callback saves must not erase unsaved question edits. +- **TC-015:** Catalog discovery includes only tRPC procedures with valid form + callback metadata and returns their declared input labels, descriptions, + source restrictions, and placeholders. Duplicate paths cannot occur. +- **TC-016:** Configuration rejects missing or unknown inputs, incompatible + sources, and reusing one question for two inputs. Fixed-only inputs reject + respondent and question sources on the server as well as in the UI. +- **TC-017:** Enqueue resolves Member ID, respondent name, respondent email, + auth user ID, and Discord user ID from the response owner. Similar IDs remain + distinct and missing nullable profile data fails required inputs visibly. +- **TC-018:** Dispatch resolves the saved registered tRPC path, validates its + input schema again, and invokes it once. Removed metadata, changed schemas, + and unknown paths record Failed without reporting success. +- **TC-019:** Recruiting produces the structured applicant embed, configured + director mention, role-derived team color, restricted allowed mentions, and + stable execution nonce. An unknown team fails without sending. +- **TC-020:** Discord role assignment accepts a fixed Discord role ID, rejects + disallowed roles at save and delivery, and grants the allowed role to the + mapped respondent Discord ID. + +## Member submission and response administration + +- **TC-010:** Submit a `multiple_locked` form through the actual route. A clear + receipt uses the returned response ID, survives refresh, and shows saved + answers. Only "Submit another response" opens a new blank form. +- **TC-011:** Repeat for `single_locked` and `single_editable`. Locked behavior + is preserved; updating an editable response visibly confirms the update. +- **TC-012:** Failed submit/update retains answers and exposes an accessible + error. Pending state prevents repeated clicks. A different user's response + ID cannot be viewed through the receipt URL. +- **TC-013:** Delete from the response detail. The existing warning precedes + deletion. Successful deletion closes detail, updates count/list, preserves + search/tab, and announces success. Rejected deletion retains detail and + reports the error; cancelled confirmation does not mutate anything. +- **TC-014:** Retry failure is not reported as delivery success. Cancelled rows + have no Retry button. Friendly names retain diagnostic procedure identifiers + as secondary detail. Respondents never see callback errors or controls. + +## Verification placement and limits + +- API contract/dispatch tests: `packages/api/src/tests/forms/`, using the real + handler with mocked provider and disposable test data where persistence is + needed. Do not start Cron against a real database to run tests. +- Blade interaction tests: `apps/blade/src/tests/forms/`; browser flows and + screenshots: `apps/blade/src/tests/e2e/forms-platform.spec.ts` or a focused + adjacent spec. Test all generic form modes and both desktop/mobile. +- API shared changes require affected Blade/Cron typechecks and tests. Finish + with required root format/lint/typecheck/test/build checks and changed React + analysis once implementation exists. +- Production smoke testing is an explicitly authorized maintainer action. + Local tests must not send Discord messages or load production data. diff --git a/apps/blade/src/app/_components/admin/forms/admin-form-builder.tsx b/apps/blade/src/app/_components/admin/forms/admin-form-builder.tsx index 521764cf4..04b7f7106 100644 --- a/apps/blade/src/app/_components/admin/forms/admin-form-builder.tsx +++ b/apps/blade/src/app/_components/admin/forms/admin-form-builder.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useReducer, useState } from "react"; +import { useEffect, useReducer, useState, useTransition } from "react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { @@ -15,6 +15,7 @@ import { ArrowLeft, FilePenLine } from "lucide-react"; import type { RouterOutputs } from "@forge/api"; import { Badge } from "@forge/ui/badge"; import { Button } from "@forge/ui/button"; +import { toast } from "@forge/ui/toast"; import { checkUploadMetadata, FORM_BANNER_UPLOAD_POLICY, @@ -26,7 +27,10 @@ import type { BuilderInitial, CallbackCatalogItem, } from "./form-builder-types"; -import type { FormCallbackDraft } from "./form-callback-mappings"; +import type { + ConfiguredFormCallback, + FormCallbackDraft, +} from "./form-callback-mappings"; import type { MediaInstruction } from "./form-definition-draft"; import { AdminPageHeader, @@ -46,7 +50,10 @@ import { } from "./form-builder-formatting"; import { FormBuilderHeaderActions } from "./form-builder-header-actions"; import { FormBuilderQuestionsSection } from "./form-builder-questions-section"; -import { callbackInputMappings } from "./form-callback-mappings"; +import { + callbackInputMappings, + emptyCallbackDraft, +} from "./form-callback-mappings"; import { FormCallbacksDialog } from "./form-callbacks-dialog"; import { buildFormDefinition, @@ -68,7 +75,7 @@ export function AdminFormBuilder({ shareAssets, }: { callbacks: CallbackCatalogItem[]; - configuredCallbacks?: { active: boolean; callbackSlug: string; id: string }[]; + configuredCallbacks?: ConfiguredFormCallback[]; initial?: BuilderInitial; readOnly?: boolean; respondentRoles: { id: string; name: string }[]; @@ -103,11 +110,11 @@ export function AdminFormBuilder({ draftAvailability(initial, sections), ); const [message, setMessage] = useState(null); - const [callbackDraft, setCallbackDraft] = useState({ - questionId: "", - slug: "discord.assign-role", - value: "", - }); + const [callbackError, setCallbackError] = useState(null); + const [callbacksRefreshing, refreshCallbacks] = useTransition(); + const [callbackDraft, setCallbackDraft] = useState(() => + emptyCallbackDraft(callbacks.find((callback) => callback.available)), + ); const [openDialog, setOpenDialog] = useState("none"); const [respondentRoleSearch, setRespondentRoleSearch] = useState(""); const questionSensors = useSensors( @@ -142,6 +149,7 @@ export function AdminFormBuilder({ dialog: Exclude, open: boolean, ) { + if (dialog === "callbacks") setCallbackError(null); setOpenDialog(open ? dialog : "none"); } @@ -242,15 +250,18 @@ export function AdminFormBuilder({ async function addCallback() { if (!initial) return; + setCallbackError(null); try { await configureCallback.mutateAsync({ callbackSlug: callbackDraft.slug, formId: initial.id, mappings: callbackInputMappings(callbackDraft), }); - setMessage("Callback configured for future responses."); + setOpenDialog("none"); + toast.success("Callback saved for future responses."); + refreshCallbacks(() => router.refresh()); } catch (cause) { - setMessage( + setCallbackError( cause instanceof Error ? cause.message : "Callback configuration failed.", @@ -258,14 +269,21 @@ export function AdminFormBuilder({ } } - function disableFormCallback(callbackSlug: string) { + async function disableFormCallback(callbackSlug: string) { if (!initial) return; - void disableCallback - .mutateAsync({ callbackSlug, formId: initial.id }) - .then(() => { - setMessage("Callback disabled for future responses."); - router.refresh(); - }); + setCallbackError(null); + try { + await disableCallback.mutateAsync({ callbackSlug, formId: initial.id }); + setOpenDialog("none"); + toast.success("Callback disabled for future responses."); + refreshCallbacks(() => router.refresh()); + } catch (cause) { + setCallbackError( + cause instanceof Error + ? cause.message + : "Callback could not be disabled.", + ); + } } function deleteFormPermanently() { @@ -455,9 +473,14 @@ export function AdminFormBuilder({ setOpenDialog("none")} onDisableCallback={disableFormCallback} diff --git a/apps/blade/src/app/_components/admin/forms/form-builder-types.ts b/apps/blade/src/app/_components/admin/forms/form-builder-types.ts index 62fe4b8ef..a50d2c79a 100644 --- a/apps/blade/src/app/_components/admin/forms/form-builder-types.ts +++ b/apps/blade/src/app/_components/admin/forms/form-builder-types.ts @@ -21,9 +21,27 @@ export interface BuilderInitial extends FormAvailabilitySource { */ export type BuilderDialog = "actions" | "callbacks" | "none" | "settings"; +export interface CallbackCatalogInput { + allowedSources: readonly ("fixed" | "question" | "respondent")[]; + description?: string; + fixedInputType: "email" | "number" | "text"; + key: string; + label: string; + placeholder?: string; + questionTypes?: readonly FormDefinition["questions"][number]["type"][]; + respondentValues?: readonly ( + | "auth_user_id" + | "discord_user_id" + | "member_id" + | "respondent_email" + | "respondent_name" + )[]; +} + export interface CallbackCatalogItem { available: boolean; description: string; + inputs: CallbackCatalogInput[]; label: string; requiredPermission: string; slug: string; diff --git a/apps/blade/src/app/_components/admin/forms/form-callback-mappings.ts b/apps/blade/src/app/_components/admin/forms/form-callback-mappings.ts index c277c92f4..06d9af77d 100644 --- a/apps/blade/src/app/_components/admin/forms/form-callback-mappings.ts +++ b/apps/blade/src/app/_components/admin/forms/form-callback-mappings.ts @@ -1,48 +1,148 @@ import type { z } from "zod"; -import type { callbackConfigurationSchema } from "@forge/validators"; +import { callbackConfigurationSchema } from "@forge/validators"; -/** - * How the callback dialog's three inputs become the input mappings - * `forms.configureCallback` stores. The procedure accepts `z.unknown()` and - * validates on the server, so nothing on the client was checking this shape — - * the annotated return type here is the only thing that does. - */ +import type { + CallbackCatalogInput, + CallbackCatalogItem, +} from "./form-builder-types"; type CallbackMapping = z.infer< typeof callbackConfigurationSchema >["mappings"][number]; +type RespondentValue = Extract< + CallbackMapping["source"], + { kind: "respondent" } +>["value"]; + +export type FormCallbackDraftSource = + | { kind: "fixed"; value: string } + | { kind: "question"; questionId: string } + | { kind: "respondent"; value: RespondentValue }; -/** The callback dialog's draft: which callback, and where its input comes from. */ export interface FormCallbackDraft { - questionId: string; + invalidSavedMappings?: boolean; + mappings: Record; slug: string; - value: string; -} - -/** - * Every callback is handed the responding member, because a callback that - * cannot identify the member has nothing to act on. The second input is what - * differs: assigning a Discord role needs a role id typed by the author, while - * a recruiting note can be pulled from a text answer instead — and falls back - * to the typed value when no question is chosen. - */ -export function callbackInputMappings({ - questionId, - slug, - value, -}: FormCallbackDraft): CallbackMapping[] { - const member: CallbackMapping = { - inputKey: "memberId", - source: { kind: "system", value: "member_id" }, +} + +export interface ConfiguredFormCallback { + active: boolean; + callbackSlug: string; + id: string; + mappings: unknown; +} + +export const respondentValueLabels = { + auth_user_id: "Auth User ID", + discord_user_id: "Discord User ID", + member_id: "Member ID", + respondent_email: "Respondent email", + respondent_name: "Respondent name", +} as const satisfies Record; + +function defaultSource(input: CallbackCatalogInput): FormCallbackDraftSource { + const respondentValue = input.respondentValues?.[0]; + if ( + respondentValue && + input.allowedSources.includes("respondent") && + input.respondentValues?.length === 1 + ) { + return { kind: "respondent", value: respondentValue }; + } + if (input.allowedSources.includes("question")) { + return { kind: "question", questionId: "" }; + } + if (input.allowedSources.includes("fixed")) { + return { kind: "fixed", value: "" }; + } + const fallback = input.respondentValues?.[0] ?? "member_id"; + return { kind: "respondent", value: fallback }; +} + +function fixedDraftValue(value: unknown) { + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + return ""; +} + +export function emptyCallbackDraft( + callback?: CallbackCatalogItem, +): FormCallbackDraft { + return { + mappings: Object.fromEntries( + (callback?.inputs ?? []).map((input) => [ + input.key, + defaultSource(input), + ]), + ), + slug: callback?.slug ?? "", + }; +} + +export function savedCallbackDraft( + callback: ConfiguredFormCallback, + catalog?: CallbackCatalogItem, +): FormCallbackDraft { + const draft = emptyCallbackDraft(catalog); + const parsed = callbackConfigurationSchema.shape.mappings.safeParse( + callback.mappings, + ); + if (!parsed.success) { + return { + ...draft, + invalidSavedMappings: true, + slug: callback.callbackSlug, + }; + } + return { + mappings: { + ...draft.mappings, + ...Object.fromEntries( + parsed.data.map(({ inputKey, source }) => [ + inputKey, + source.kind === "fixed" + ? { kind: "fixed", value: fixedDraftValue(source.value) } + : source, + ]), + ), + }, + slug: callback.callbackSlug, }; - if (slug === "discord.assign-role") { - return [member, { inputKey: "roleId", source: { kind: "fixed", value } }]; +} + +export function callbackInputMappings( + draft: FormCallbackDraft, +): CallbackMapping[] { + return Object.entries(draft.mappings).map(([inputKey, source]) => ({ + inputKey, + source, + })); +} + +export function isCallbackDraftComplete(draft: FormCallbackDraft) { + if (draft.invalidSavedMappings) return false; + return Object.values(draft.mappings).every((source) => { + if (source.kind === "question") return source.questionId.length > 0; + if (source.kind === "fixed") return source.value.trim().length > 0; + return true; + }); +} + +export function callbackSourceSummary( + source: FormCallbackDraftSource | undefined, + questionPrompt?: string, +) { + if (!source) return "Not configured"; + if (source.kind === "question") { + return source.questionId + ? `Question: ${questionPrompt ?? "Unavailable question"}` + : "Question not selected"; + } + if (source.kind === "respondent") { + return respondentValueLabels[source.value]; } - return [ - member, - questionId - ? { inputKey: "note", source: { kind: "question", questionId } } - : { inputKey: "note", source: { kind: "fixed", value } }, - ]; + return source.value ? `Manual: ${source.value}` : "Manual value not set"; } diff --git a/apps/blade/src/app/_components/admin/forms/form-callbacks-dialog.tsx b/apps/blade/src/app/_components/admin/forms/form-callbacks-dialog.tsx index 017803305..6b5c7ed37 100644 --- a/apps/blade/src/app/_components/admin/forms/form-callbacks-dialog.tsx +++ b/apps/blade/src/app/_components/admin/forms/form-callbacks-dialog.tsx @@ -3,6 +3,7 @@ import type { Dispatch, SetStateAction } from "react"; import type { FormQuestion } from "@forge/validators"; +import { Badge } from "@forge/ui/badge"; import { Button } from "@forge/ui/button"; import { Dialog, @@ -13,10 +14,40 @@ import { DialogTitle, } from "@forge/ui/dialog"; import { Input } from "@forge/ui/input"; +import { Label } from "@forge/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@forge/ui/select"; import type { FormResponseMode } from "./form-availability-draft"; import type { CallbackCatalogItem } from "./form-builder-types"; -import type { FormCallbackDraft } from "./form-callback-mappings"; +import type { + ConfiguredFormCallback, + FormCallbackDraft, + FormCallbackDraftSource, +} from "./form-callback-mappings"; +import { + callbackSourceSummary, + emptyCallbackDraft, + isCallbackDraftComplete, + respondentValueLabels, + savedCallbackDraft, +} from "./form-callback-mappings"; + +const allRespondentValues = Object.keys( + respondentValueLabels, +) as (keyof typeof respondentValueLabels)[]; + +function sourceSelectValue(source: FormCallbackDraftSource | undefined) { + if (!source) return undefined; + if (source.kind === "fixed") return "fixed"; + if (source.kind === "respondent") return `respondent:${source.value}`; + return source.questionId ? `question:${source.questionId}` : undefined; +} export function FormCallbacksDialog({ callbackDraft, @@ -24,6 +55,7 @@ export function FormCallbacksDialog({ configureCallbackPending, configuredCallbacks, disableCallbackPending, + error, onAddCallback, onClose, onDisableCallback, @@ -36,127 +68,355 @@ export function FormCallbacksDialog({ callbackDraft: FormCallbackDraft; callbacks: CallbackCatalogItem[]; configureCallbackPending: boolean; - configuredCallbacks: { active: boolean; callbackSlug: string; id: string }[]; + configuredCallbacks: ConfiguredFormCallback[]; disableCallbackPending: boolean; + error?: string | null; onAddCallback: () => Promise; onClose: () => void; - onDisableCallback: (callbackSlug: string) => void; + onDisableCallback: (callbackSlug: string) => Promise; onOpenChange: (open: boolean) => void; open: boolean; questions: FormQuestion[]; responseMode: FormResponseMode; setCallbackDraft: Dispatch>; }) { - function updateCallbackDraft( - key: Key, - value: FormCallbackDraft[Key], - ) { - setCallbackDraft((current) => ({ ...current, [key]: value })); + const selected = callbacks.find(({ slug }) => slug === callbackDraft.slug); + const pending = configureCallbackPending || disableCallbackPending; + const editable = responseMode !== "single_editable"; + const usedQuestionIds = new Set( + Object.values(callbackDraft.mappings).flatMap((source) => + source.kind === "question" && source.questionId + ? [source.questionId] + : [], + ), + ); + + function selectCallback(slug: string) { + const catalog = callbacks.find((callback) => callback.slug === slug); + const saved = configuredCallbacks.find( + (callback) => callback.callbackSlug === slug, + ); + setCallbackDraft( + saved ? savedCallbackDraft(saved, catalog) : emptyCallbackDraft(catalog), + ); + } + + function updateSource(inputKey: string, value: string) { + setCallbackDraft((current) => { + let source: FormCallbackDraftSource; + if (value === "fixed") { + const existing = current.mappings[inputKey]; + source = { + kind: "fixed", + value: existing?.kind === "fixed" ? existing.value : "", + }; + } else if (value.startsWith("respondent:")) { + source = { + kind: "respondent", + value: value.slice( + "respondent:".length, + ) as keyof typeof respondentValueLabels, + }; + } else { + source = { + kind: "question", + questionId: value.slice("question:".length), + }; + } + return { + ...current, + invalidSavedMappings: false, + mappings: { ...current.mappings, [inputKey]: source }, + }; + }); + } + + function updateFixedValue(inputKey: string, value: string) { + setCallbackDraft((current) => ({ + ...current, + invalidSavedMappings: false, + mappings: { + ...current.mappings, + [inputKey]: { kind: "fixed", value }, + }, + })); } return ( - - - + { + if (!pending) onOpenChange(next); + }} + > + + Callbacks - Configure code-owned actions for future locked responses. + Map each procedure input to one form answer, respondent field, or + manual value. Changes apply to future responses only. -
- {configuredCallbacks - .filter(({ active }) => active) - .map((callback) => ( +
+ {configuredCallbacks.map((callback) => { + const catalog = callbacks.find( + ({ slug }) => slug === callback.callbackSlug, + ); + const saved = savedCallbackDraft(callback, catalog); + return (
- {callback.callbackSlug} - +
+ + {catalog?.label ?? callback.callbackSlug} + + + {callback.active ? "Enabled" : "Disabled"} + +
+ {catalog && saved.invalidSavedMappings && ( +

+ These saved mappings use an older format. Edit and resave + them before this callback can run. +

+ )} + {catalog && !saved.invalidSavedMappings && ( +
+ {catalog.inputs.map((input) => { + const source = saved.mappings[input.key]; + const question = + source?.kind === "question" + ? questions.find(({ id }) => id === source.questionId) + : undefined; + return ( +
+
+ {input.label}: +
+
+ {callbackSourceSummary(source, question?.prompt)} +
+
+ ); + })} +
+ )} +
+ + {callback.active && ( + + )} +
- ))} - - {callbackDraft.slug === "recruiting.notify" && ( - - - {questions - .filter( - (question) => - question.type === "short_text" || - question.type === "paragraph", - ) - .map((question) => ( - + + + + + {callbacks.map((callback) => ( + + {callback.label} + {callback.available + ? "" + : ` (requires ${callback.requiredPermission})`} + ))} - + + +
+ + {selected && ( +
+

+ {selected.description} +

+ {callbackDraft.invalidSavedMappings && ( +

+ Choose a current source for the saved inputs before saving. +

+ )} + {selected.inputs.map((input) => { + const source = callbackDraft.mappings[input.key]; + const compatibleQuestions = questions.filter( + (question) => + !question.retired && + (!input.questionTypes || + input.questionTypes.includes(question.type)), + ); + const respondentValues = + input.respondentValues ?? allRespondentValues; + return ( +
+
+ + {input.description && ( +

+ {input.description} +

+ )} +
+ + {source?.kind === "fixed" && ( +
+ + + updateFixedValue(input.key, event.target.value) + } + /> +
+ )} +
+ ); + })} +
+ )} + + {!editable && ( +

+ Callbacks are available only for forms with locked responses. +

)} - {(!callbackDraft.questionId || - callbackDraft.slug === "discord.assign-role") && ( - - updateCallbackDraft("value", event.target.value) - } - /> + {!callbacks.some(({ available }) => available) && ( +

+ You do not have permission to configure these procedures. +

+ )} + {error && ( +

+ {error} +

)} -

- Respondents never see callback configuration or execution status. +

+ Each question can fill one input. Earlier responses are not resent. + Check the Delivery tab for results.

- diff --git a/apps/blade/src/app/_components/admin/forms/form-responses-dashboard.tsx b/apps/blade/src/app/_components/admin/forms/form-responses-dashboard.tsx index 8c0130776..06eae92ea 100644 --- a/apps/blade/src/app/_components/admin/forms/form-responses-dashboard.tsx +++ b/apps/blade/src/app/_components/admin/forms/form-responses-dashboard.tsx @@ -1,11 +1,6 @@ "use client"; -import { - startTransition, - useMemo, - useState, - useSyncExternalStore, -} from "react"; +import { useMemo, useState, useSyncExternalStore, useTransition } from "react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { @@ -58,6 +53,7 @@ import { TableRow, } from "@forge/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@forge/ui/tabs"; +import { toast } from "@forge/ui/toast"; import { FormResponseValue } from "~/app/_components/forms/form-response-value"; import { @@ -635,21 +631,40 @@ function ResponseDetailDialog({ response, }: { deletePending: boolean; - onDelete: (responseId: string) => void; + onDelete: (responseId: string) => Promise | void; onOpenChange: (open: boolean) => void; response?: IdentifiedFormResponse; }) { const [confirmingDelete, setConfirmingDelete] = useState(false); + const [deleteError, setDeleteError] = useState(null); + + async function confirmDelete() { + if (!response || deletePending) return; + setDeleteError(null); + try { + await onDelete(response.responseId); + setConfirmingDelete(false); + onOpenChange(false); + } catch (cause) { + setDeleteError( + cause instanceof Error + ? cause.message + : "Response could not be deleted.", + ); + } + } return ( { + if (deletePending) return; if (!open) setConfirmingDelete(false); + setDeleteError(null); onOpenChange(open); }} open={response !== undefined} > - + {response?.member.name ?? "Response"} @@ -686,7 +701,15 @@ function ResponseDetailDialog({ })} - + {deleteError && ( +

+ {deleteError} +

+ )} + {confirmingDelete ? ( <>

@@ -694,6 +717,7 @@ function ResponseDetailDialog({

+ )} )} {respondentState.status === "submitted" && ( diff --git a/apps/blade/src/app/_components/forms/generic-form-response-form.tsx b/apps/blade/src/app/_components/forms/generic-form-response-form.tsx index f0b55f750..f87e11ad4 100644 --- a/apps/blade/src/app/_components/forms/generic-form-response-form.tsx +++ b/apps/blade/src/app/_components/forms/generic-form-response-form.tsx @@ -1,7 +1,9 @@ "use client"; -import { useMemo, useState } from "react"; -import { Loader2 } from "lucide-react"; +import { useMemo, useState, useTransition } from "react"; +import Link from "next/link"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { CheckCircle2, Loader2 } from "lucide-react"; import type { RouterInputs } from "@forge/api"; import type { FormDefinition, FormQuestion } from "@forge/validators"; @@ -10,6 +12,7 @@ import { Input } from "@forge/ui/input"; import { Label } from "@forge/ui/label"; import { Skeleton } from "@forge/ui/skeleton"; import { Textarea } from "@forge/ui/textarea"; +import { toast } from "@forge/ui/toast"; import { countNonWhitespaceCharacters, FORM_LINEAR_SCALE_ENDPOINT_MAX, @@ -583,6 +586,11 @@ export function GenericFormResponseForm({ onSubmitted?: () => void; }) { const [answers, setAnswers] = useState(initialAnswers); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [isRefreshing, refresh] = useTransition(); + const [receiptHref, setReceiptHref] = useState(null); const [error, setError] = useState(null); const [pendingUploadQuestionIds, setPendingUploadQuestionIds] = useState( () => new Set(), @@ -595,10 +603,16 @@ export function GenericFormResponseForm({ onError(mutationError) { setError(mutationError.message); }, - onSuccess() { + onSuccess(result) { setError(null); if (onSubmitted) onSubmitted(); - else window.location.reload(); + else { + const query = new URLSearchParams(searchParams.toString()); + query.set("responseId", result.formResponseId); + const href = `${pathname}?${query.toString()}`; + setReceiptHref(href); + refresh(() => router.replace(href)); + } }, }); const update = api.forms.updateResponse.useMutation({ @@ -607,12 +621,15 @@ export function GenericFormResponseForm({ }, onSuccess() { setError(null); + toast.success("Response updated."); if (onSubmitted) onSubmitted(); - else window.location.reload(); + else refresh(() => router.refresh()); }, }); async function handleSubmit() { + if (submit.isPending || update.isPending || isRefreshing || receiptHref) + return; setError(null); const missingQuestion = questions.find((question) => { if (!question.required) return false; @@ -660,6 +677,36 @@ export function GenericFormResponseForm({ } } + if (receiptHref) + return ( +
{ + node?.focus(); + }} + tabIndex={-1} + > +
+
+

+ Your answers have been saved. +

+ +
+ ); + return (
0 || submit.isPending || - update.isPending + update.isPending || + isRefreshing } type="submit" > diff --git a/apps/blade/src/app/admin/forms/[formId]/responses/page.tsx b/apps/blade/src/app/admin/forms/[formId]/responses/page.tsx index cb1f8ca50..26e67bf71 100644 --- a/apps/blade/src/app/admin/forms/[formId]/responses/page.tsx +++ b/apps/blade/src/app/admin/forms/[formId]/responses/page.tsx @@ -26,7 +26,7 @@ export default async function FormResponsesPage({ } const { formId } = await params; - const [responses, callbacks, workspace] = await Promise.all([ + const [responses, callbacks, workspace, callbackCatalog] = await Promise.all([ api.forms .listResponses({ formId }) .then((data) => ({ data, error: null })) @@ -39,12 +39,14 @@ export default async function FormResponsesPage({ })), api.forms.listCallbackExecutions({ formId }).catch(() => null), api.forms.listAdmin().catch(() => null), + api.forms.listCallbacks().catch(() => []), ]); const formName = workspace?.forms.find((form) => form.id === formId)?.name; return ( { }); describe("TC-004: inert keys are structurally distinct from live keys", () => { - it("marks the ten inert keys with a rendered badge rather than the description", () => { + it("marks inert keys with a rendered badge rather than the description", () => { const { container } = renderDiscordSection(); const { cards, tables } = renderings(container); @@ -260,12 +260,23 @@ describe("TC-004: inert keys are structurally distinct from live keys", () => { ); } - expect(DISCORD.INERT_CONFIG_KEYS).toHaveLength(10); + expect(DISCORD.INERT_CONFIG_KEYS).toEqual([ + "officer_role", + "admin_role", + "volunteer_role", + "vip_role", + ]); expect([...DISCORD.LIVE_CONFIG_KEYS]).toEqual([ "guild", "log_channel", "recruiting_channel", "alumni_role", + "outreach_director_role", + "design_director_role", + "development_director_role", + "sponsorship_director_role", + "workshops_director_role", + "projects_mentorship_director_role", ]); }); diff --git a/apps/blade/src/tests/e2e/forms-platform.spec.ts b/apps/blade/src/tests/e2e/forms-platform.spec.ts index d3728be46..62869ca8c 100644 --- a/apps/blade/src/tests/e2e/forms-platform.spec.ts +++ b/apps/blade/src/tests/e2e/forms-platform.spec.ts @@ -294,7 +294,14 @@ test.describe("forms platform cross-surface journey", () => { await answer.fill("More club tools"); await page.getByRole("button", { name: "Submit response" }).click(); await expect( - page.getByRole("heading", { name: "Your submitted response" }), + page.getByRole("heading", { name: "Response submitted" }), + ).toBeVisible(); + await expect(page).toHaveURL( + new RegExp(`/form/${FORM_SLUG}\\?responseId=[0-9a-f-]+$`), + ); + await page.reload(); + await expect( + page.getByRole("heading", { name: "Response submitted" }), ).toBeVisible(); const submittedAnswers = page.getByRole("region", { name: "Submitted answers", @@ -389,5 +396,18 @@ test.describe("forms platform cross-surface journey", () => { await expect( page.getByRole("dialog").getByText("More club tools"), ).toBeVisible(); + await page + .getByRole("button", { name: "Delete response", exact: true }) + .click(); + await page + .getByRole("button", { name: "Delete permanently", exact: true }) + .click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect( + page.getByText("Response deleted.", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("59 identified submissions", { exact: true }), + ).toBeVisible(); }); }); diff --git a/apps/blade/src/tests/forms/admin-form-builder-dialogs.test.tsx b/apps/blade/src/tests/forms/admin-form-builder-dialogs.test.tsx index b433747d2..d00f497ae 100644 --- a/apps/blade/src/tests/forms/admin-form-builder-dialogs.test.tsx +++ b/apps/blade/src/tests/forms/admin-form-builder-dialogs.test.tsx @@ -1,15 +1,25 @@ /** @vitest-environment jsdom */ -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { describe, expect, it, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { FormDefinition } from "@forge/validators"; import { AdminFormBuilder } from "~/app/_components/admin/forms/admin-form-builder"; +const callbackMocks = vi.hoisted(() => ({ + configure: vi.fn(), + disable: vi.fn(), + refresh: vi.fn(), + success: vi.fn(), +})); +vi.mock("@forge/ui/toast", () => ({ + toast: { success: callbackMocks.success }, +})); + vi.mock("next/navigation", () => ({ usePathname: () => "/admin/forms/form-1", - useRouter: () => ({ refresh: vi.fn(), replace: vi.fn() }), + useRouter: () => ({ refresh: callbackMocks.refresh, replace: vi.fn() }), useSearchParams: () => new URLSearchParams(), })); @@ -19,11 +29,21 @@ vi.mock("~/trpc/react", () => ({ api: { forms: { changeState: { useMutation: () => mutation() }, - configureCallback: { useMutation: () => mutation() }, + configureCallback: { + useMutation: () => ({ + isPending: false, + mutateAsync: callbackMocks.configure, + }), + }, createForm: { useMutation: () => mutation() }, createUpload: { useMutation: () => mutation() }, deleteForm: { useMutation: () => mutation() }, - disableCallback: { useMutation: () => mutation() }, + disableCallback: { + useMutation: () => ({ + isPending: false, + mutateAsync: callbackMocks.disable, + }), + }, finalizeUpload: { useMutation: () => mutation() }, updateForm: { useMutation: () => mutation() }, updateSettings: { useMutation: () => mutation() }, @@ -47,18 +67,105 @@ const definition: FormDefinition = { title: "Fixture form", }; -function renderBuilder() { +beforeAll(() => { + HTMLElement.prototype.hasPointerCapture = vi.fn(() => false); + HTMLElement.prototype.releasePointerCapture = vi.fn(); + HTMLElement.prototype.setPointerCapture = vi.fn(); + Element.prototype.scrollIntoView = vi.fn(); +}); + +function renderBuilder( + recruiting = false, + available = true, + invalidMappings = false, +) { return render( { + beforeEach(() => { + vi.clearAllMocks(); + callbackMocks.configure.mockResolvedValue({}); + callbackMocks.disable.mockResolvedValue({}); + }); + + it("[TC-007] prevents configuration when no callback is permitted", async () => { + const user = userEvent.setup(); + renderBuilder(false, false); + await user.click(screen.getByRole("button", { name: /callbacks/i })); + expect( + screen.getByRole("button", { name: "Save for future responses" }), + ).toBeDisabled(); + expect( + screen.getByText( + "You do not have permission to configure these procedures.", + ), + ).toBeInTheDocument(); + expect(callbackMocks.configure).not.toHaveBeenCalled(); + }); + + it("[TC-007, TC-008, TC-009] defaults to an allowed action, edits saved settings and confirms saving", async () => { + const user = userEvent.setup(); + renderBuilder(true); + await user.click(screen.getByRole("button", { name: /callbacks/i })); + expect( + screen.getByRole("combobox", { name: "Procedure" }), + ).toHaveTextContent("Notify recruiting"); + expect(screen.getByText("Manual: Outreach")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Edit mappings" })); + expect(screen.getByRole("textbox", { name: "Team value" })).toHaveValue( + "Outreach", + ); + await user.click( + screen.getByRole("button", { name: "Save for future responses" }), + ); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + expect(callbackMocks.configure).toHaveBeenCalledWith({ + callbackSlug: "recruiting.notify", + formId: "form-1", + mappings: [ + { + inputKey: "name", + source: { kind: "respondent", value: "respondent_name" }, + }, + { + inputKey: "team", + source: { kind: "fixed", value: "Outreach" }, + }, + ], + }); + expect(callbackMocks.success).toHaveBeenCalledWith( + "Callback saved for future responses.", + ); + expect(callbackMocks.refresh).toHaveBeenCalled(); + expect(screen.getByDisplayValue("Your name")).toBeInTheDocument(); + }); + + it("[TC-016] prevents one question from filling two procedure inputs", async () => { + const user = userEvent.setup(); + renderBuilder(true); + await user.click(screen.getByRole("button", { name: /callbacks/i })); + await user.click(screen.getByRole("button", { name: "Edit mappings" })); + + await user.click(screen.getByRole("combobox", { name: "Name source" })); + await user.click( + screen.getByRole("option", { name: "Question: Your name" }), + ); + await user.click(screen.getByRole("combobox", { name: "Team source" })); + + expect( + screen.getByRole("option", { name: "Question: Your name" }), + ).toHaveAttribute("aria-disabled", "true"); + }); + + it("requires legacy callback mappings to be reviewed before saving", async () => { + const user = userEvent.setup(); + renderBuilder(true, true, true); + await user.click(screen.getByRole("button", { name: /callbacks/i })); + + expect( + screen.getByText( + "These saved mappings use an older format. Edit and resave them before this callback can run.", + ), + ).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Edit mappings" })); + expect( + screen.getByText( + "Choose a current source for the saved inputs before saving.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Save for future responses" }), + ).toBeDisabled(); + }); + + it("[TC-009] keeps callback failures in the dialog", async () => { + callbackMocks.configure.mockRejectedValueOnce( + new Error("Configuration rejected"), + ); + callbackMocks.disable.mockRejectedValueOnce(new Error("Disable rejected")); + const user = userEvent.setup(); + renderBuilder(true); + await user.click(screen.getByRole("button", { name: /callbacks/i })); + await user.click(screen.getByRole("button", { name: "Edit mappings" })); + await user.click( + screen.getByRole("button", { name: "Save for future responses" }), + ); + expect( + await within(screen.getByRole("dialog")).findByRole("alert"), + ).toHaveTextContent("Configuration rejected"); + await user.click(screen.getByRole("button", { name: "Disable" })); + expect( + await within(screen.getByRole("dialog")).findByRole("alert"), + ).toHaveTextContent("Disable rejected"); + expect(callbackMocks.refresh).not.toHaveBeenCalled(); + }); it("opens the availability dialog seeded from the saved form", async () => { const user = userEvent.setup(); renderBuilder(); diff --git a/apps/blade/src/tests/forms/form-action-feedback.test.tsx b/apps/blade/src/tests/forms/form-action-feedback.test.tsx new file mode 100644 index 000000000..3be0f01a3 --- /dev/null +++ b/apps/blade/src/tests/forms/form-action-feedback.test.tsx @@ -0,0 +1,241 @@ +/** @vitest-environment jsdom */ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { IdentifiedResponses } from "~/app/_components/admin/forms/form-responses-dashboard"; +import { GenericFormRespondent } from "~/app/_components/forms/generic-form-respondent"; +import { GenericFormResponseForm } from "~/app/_components/forms/generic-form-response-form"; + +const mocks = vi.hoisted(() => ({ + replace: vi.fn(), + refresh: vi.fn(), + save: vi.fn(), + success: vi.fn(), + pending: false, +})); +vi.mock("next/navigation", () => ({ + usePathname: () => "/form/test-form", + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ replace: mocks.replace, refresh: mocks.refresh }), +})); +vi.mock("@forge/ui/toast", () => ({ toast: { success: mocks.success } })); +vi.mock("~/trpc/react", () => { + const useMutation = (options: { + onSuccess: (result: { formResponseId: string }) => void; + }) => ({ + isPending: mocks.pending, + mutateAsync: async (input: unknown) => { + const result = (await mocks.save(input)) as { formResponseId: string }; + options.onSuccess(result); + return result; + }, + }); + return { + api: { + forms: { + createResponse: { useMutation }, + updateResponse: { useMutation }, + }, + }, + }; +}); + +const questionId = "10000000-0000-4000-8000-000000000001"; +const definition = { + title: "Test form", + description: "Synthetic fixture", + instructions: [], + questions: [ + { + id: questionId, + prompt: "Your note", + type: "short_text" as const, + maxLength: 100, + required: true, + retired: false, + }, + ], +}; +const response = { + answers: { [questionId]: "Saved answer" }, + member: { + name: "Test Member", + email: "synthetic@example.invalid", + id: "member-1", + }, + responseId: "response-1", + snapshot: definition, + submittedAt: new Date("2026-08-01T12:00:00Z"), +}; + +describe("form action feedback", () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.pending = false; + mocks.save.mockResolvedValue({ formResponseId: "response-1" }); + }); + + it("[TC-012] prevents another submit while the request is pending", async () => { + mocks.pending = true; + const user = userEvent.setup(); + render( + , + ); + const button = screen.getByRole("button", { name: "Submit response" }); + expect(button).toBeDisabled(); + await user.click(button); + expect(mocks.save).not.toHaveBeenCalled(); + }); + + it("[TC-010] keeps a success receipt while routing to the saved response", async () => { + const user = userEvent.setup(); + render(); + await user.type(screen.getByRole("textbox"), "Keep my answer"); + await user.click(screen.getByRole("button", { name: "Submit response" })); + expect( + await screen.findByRole("heading", { name: "Response submitted" }), + ).toBeInTheDocument(); + expect(mocks.replace).toHaveBeenCalledWith( + "/form/test-form?responseId=response-1", + ); + expect( + screen.getByRole("link", { name: "Review submitted response" }), + ).toHaveAttribute("href", "/form/test-form?responseId=response-1"); + expect( + screen.queryByRole("button", { name: "Submit response" }), + ).not.toBeInTheDocument(); + }); + + it("[TC-012] retains answers and displays a rejected submission", async () => { + mocks.save.mockRejectedValueOnce(new Error("Submission failed")); + const user = userEvent.setup(); + render(); + await user.type(screen.getByRole("textbox"), "Keep my answer"); + await user.click(screen.getByRole("button", { name: "Submit response" })); + expect(await screen.findByRole("alert")).toHaveTextContent( + "Submission failed", + ); + expect(screen.getByRole("textbox")).toHaveValue("Keep my answer"); + expect(mocks.replace).not.toHaveBeenCalled(); + }); + + it("[TC-011] announces an editable response update", async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole("button", { name: "Save response" })); + await waitFor(() => + expect(mocks.success).toHaveBeenCalledWith("Response updated."), + ); + expect(mocks.refresh).toHaveBeenCalled(); + expect(screen.getByRole("textbox")).toHaveValue("Saved answer"); + }); + + it.each(["multiple_locked", "single_locked", "single_editable"] as const)( + "[TC-010, TC-011] receipt offers another response only in multiple mode: %s", + (responseMode) => { + render( + , + ); + expect( + screen.getByRole("heading", { name: "Response submitted" }), + ).toBeInTheDocument(); + const link = screen.queryByRole("link", { + name: "Submit another response", + }); + if (responseMode === "multiple_locked") + expect(link).toHaveAttribute("href", "/form/test-form"); + else expect(link).not.toBeInTheDocument(); + }, + ); + + it("[TC-013] waits for deletion before closing the detail", async () => { + const user = userEvent.setup(); + let finish: () => void = () => { + throw new Error("Deletion not started"); + }; + const onDelete = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + render( + , + ); + const [viewButton] = screen.getAllByRole("button", { + name: "View response", + }); + if (!viewButton) throw new Error("Response action missing"); + await user.click(viewButton); + await user.click(screen.getByRole("button", { name: "Delete response" })); + expect(onDelete).not.toHaveBeenCalled(); + await user.click( + screen.getByRole("button", { name: "Delete permanently" }), + ); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + finish(); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), + ); + expect(onDelete).toHaveBeenCalledWith("response-1"); + }); + + it("[TC-013] retains the detail on deletion failure and supports cancel", async () => { + const user = userEvent.setup(); + const onDelete = vi.fn().mockRejectedValue(new Error("Deletion failed")); + render( + , + ); + const [viewButton] = screen.getAllByRole("button", { + name: "View response", + }); + if (!viewButton) throw new Error("Response action missing"); + await user.click(viewButton); + await user.click(screen.getByRole("button", { name: "Delete response" })); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(onDelete).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Delete response" })); + await user.click( + screen.getByRole("button", { name: "Delete permanently" }), + ); + expect( + await within(screen.getByRole("dialog")).findByRole("alert"), + ).toHaveTextContent("Deletion failed"); + expect(screen.getByText("Saved answer")).toBeInTheDocument(); + }); +}); diff --git a/apps/blade/src/tests/forms/form-callback-delivery.test.tsx b/apps/blade/src/tests/forms/form-callback-delivery.test.tsx new file mode 100644 index 000000000..f9d7d2098 --- /dev/null +++ b/apps/blade/src/tests/forms/form-callback-delivery.test.tsx @@ -0,0 +1,119 @@ +/** @vitest-environment jsdom */ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { FormResponsesDashboard } from "~/app/_components/admin/forms/form-responses-dashboard"; + +const mocks = vi.hoisted(() => ({ + refresh: vi.fn(), + retry: vi.fn(), + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + result: { status: "failed" } as { + status: "failed" | "succeeded" | "superseded"; + }, +})); +vi.mock("next/navigation", () => ({ + usePathname: () => "/admin/forms/test/responses", + useSearchParams: () => new URLSearchParams("view=delivery"), + useRouter: () => ({ refresh: mocks.refresh, replace: vi.fn() }), +})); +vi.mock("@forge/ui/toast", () => ({ + toast: { success: mocks.success, error: mocks.error, info: mocks.info }, +})); +vi.mock("~/trpc/react", () => ({ + api: { + forms: { + exportResponses: { + useQuery: () => ({ isFetching: false, refetch: vi.fn() }), + }, + deleteResponse: { useMutation: () => ({ isPending: false }) }, + retryCallback: { + useMutation: (options: { + onSuccess: (result: typeof mocks.result) => void; + onSettled: () => void; + }) => ({ + isPending: false, + mutate: (input: unknown) => { + mocks.retry(input); + options.onSuccess(mocks.result); + options.onSettled(); + }, + }), + }, + }, + }, +})); + +const execution = { + id: "execution-1", + callbackSlug: "recruiting.notify", + status: "failed" as const, + attempts: 1, + lastError: "Synthetic provider error", + responseId: "response-1", + createdAt: new Date("2026-08-01T12:00:00Z"), + updatedAt: new Date("2026-08-01T12:00:00Z"), +}; + +describe("callback delivery feedback", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it.each(["failed", "succeeded", "superseded"] as const)( + "[TC-014] reports actual retry outcome: %s", + async (status) => { + mocks.result = { status }; + const user = userEvent.setup(); + render( + , + ); + expect(screen.getByText("Notify recruiting")).toBeInTheDocument(); + expect(screen.getByText("recruiting.notify")).toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(mocks.retry).toHaveBeenCalledWith({ executionId: "execution-1" }); + expect(mocks.refresh).toHaveBeenCalled(); + if (status === "succeeded") + expect(mocks.success).toHaveBeenCalledWith("Callback delivered."); + else { + expect(mocks.success).not.toHaveBeenCalled(); + if (status === "failed") expect(mocks.error).toHaveBeenCalled(); + else expect(mocks.info).toHaveBeenCalled(); + } + }, + ); + + it("[TC-014] never offers retry for cancelled or deleted responses", () => { + render( + , + ); + expect( + screen.queryByRole("button", { name: "Retry" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/apps/blade/src/tests/forms/form-callback-mappings.test.ts b/apps/blade/src/tests/forms/form-callback-mappings.test.ts index f0d0ccb73..580351f3e 100644 --- a/apps/blade/src/tests/forms/form-callback-mappings.test.ts +++ b/apps/blade/src/tests/forms/form-callback-mappings.test.ts @@ -2,74 +2,137 @@ import { describe, expect, it } from "vitest"; import { callbackConfigurationSchema } from "@forge/validators"; -import { callbackInputMappings } from "~/app/_components/admin/forms/form-callback-mappings"; +import type { CallbackCatalogItem } from "~/app/_components/admin/forms/form-builder-types"; +import { + callbackInputMappings, + emptyCallbackDraft, + isCallbackDraftComplete, + savedCallbackDraft, +} from "~/app/_components/admin/forms/form-callback-mappings"; + +const recruiting: CallbackCatalogItem = { + available: true, + description: "Notify recruiting", + inputs: [ + { + allowedSources: ["question", "respondent", "fixed"], + fixedInputType: "text", + key: "name", + label: "Name", + respondentValues: ["respondent_name"], + }, + { + allowedSources: ["question", "fixed"], + fixedInputType: "text", + key: "team", + label: "Team", + }, + ], + label: "Notify recruiting", + requiredPermission: "EDIT_FORMS", + slug: "recruiting.notify", +}; describe("form callback mappings", () => { - it("always identifies the responding member", () => { - const [member] = callbackInputMappings({ - questionId: "", + it("defaults semantic respondent fields and leaves question mappings explicit", () => { + expect(emptyCallbackDraft(recruiting)).toEqual({ + mappings: { + name: { kind: "respondent", value: "respondent_name" }, + team: { kind: "question", questionId: "" }, + }, slug: "recruiting.notify", - value: "", - }); - - expect(member).toEqual({ - inputKey: "memberId", - source: { kind: "system", value: "member_id" }, }); }); - it("sends the typed role id when assigning a Discord role", () => { + it("serializes every procedure input without callback-specific branches", () => { const mappings = callbackInputMappings({ - questionId: "0f1c9c4e-1f4a-4d5a-9c1e-2f3a4b5c6d7e", - slug: "discord.assign-role", - value: "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + mappings: { + name: { kind: "respondent", value: "respondent_name" }, + team: { kind: "fixed", value: "Outreach" }, + }, + slug: "recruiting.notify", }); - expect(mappings[1]).toEqual({ - inputKey: "roleId", - source: { kind: "fixed", value: "6ba7b810-9dad-11d1-80b4-00c04fd430c8" }, - }); + expect(mappings).toEqual([ + { + inputKey: "name", + source: { kind: "respondent", value: "respondent_name" }, + }, + { inputKey: "team", source: { kind: "fixed", value: "Outreach" } }, + ]); }); - it("pulls a recruiting note from the chosen question", () => { - const mappings = callbackInputMappings({ - questionId: "0f1c9c4e-1f4a-4d5a-9c1e-2f3a4b5c6d7e", + it("restores saved question, respondent, and manual mappings", () => { + expect( + savedCallbackDraft( + { + active: true, + callbackSlug: "recruiting.notify", + id: "callback-1", + mappings: [ + { + inputKey: "name", + source: { kind: "respondent", value: "respondent_name" }, + }, + { + inputKey: "team", + source: { kind: "fixed", value: "Development" }, + }, + ], + }, + recruiting, + ), + ).toEqual({ + mappings: { + name: { kind: "respondent", value: "respondent_name" }, + team: { kind: "fixed", value: "Development" }, + }, slug: "recruiting.notify", - value: "ignored once a question is chosen", }); + }); - expect(mappings[1]).toEqual({ - inputKey: "note", - source: { - kind: "question", - questionId: "0f1c9c4e-1f4a-4d5a-9c1e-2f3a4b5c6d7e", + it("marks legacy mappings invalid until an admin replaces them", () => { + const draft = savedCallbackDraft( + { + active: true, + callbackSlug: "recruiting.notify", + id: "callback-1", + mappings: [ + { + inputKey: "name", + source: { kind: "note", value: "legacy" }, + }, + ], }, - }); - }); + recruiting, + ); - it("falls back to the typed note when no question is chosen", () => { - const mappings = callbackInputMappings({ - questionId: "", - slug: "recruiting.notify", - value: "Reach out about the internship", - }); + expect(draft.invalidSavedMappings).toBe(true); + expect(isCallbackDraftComplete(draft)).toBe(false); + }); - expect(mappings[1]).toEqual({ - inputKey: "note", - source: { kind: "fixed", value: "Reach out about the internship" }, - }); + it("requires a selected question or non-empty manual value", () => { + expect(isCallbackDraftComplete(emptyCallbackDraft(recruiting))).toBe(false); + expect( + isCallbackDraftComplete({ + mappings: { + name: { kind: "respondent", value: "respondent_name" }, + team: { kind: "fixed", value: "Outreach" }, + }, + slug: "recruiting.notify", + }), + ).toBe(true); }); - // `forms.configureCallback` takes `z.array(z.unknown())` and validates on the - // server, so a client-side shape change would have reached the API before - // anything rejected it. it("produces mappings the stored configuration accepts", () => { const parsed = callbackConfigurationSchema.safeParse({ - callbackSlug: "discord.assign-role", + callbackSlug: "recruiting.notify", mappings: callbackInputMappings({ - questionId: "", - slug: "discord.assign-role", - value: "6ba7b810-9dad-11d1-80b4-00c04fd430c8", + mappings: { + name: { kind: "respondent", value: "respondent_name" }, + team: { kind: "fixed", value: "Outreach" }, + }, + slug: "recruiting.notify", }), responseMode: "single_locked", }); diff --git a/apps/blade/src/tests/forms/generic-form-respondent.test.tsx b/apps/blade/src/tests/forms/generic-form-respondent.test.tsx index 33490746f..3e4b3fed4 100644 --- a/apps/blade/src/tests/forms/generic-form-respondent.test.tsx +++ b/apps/blade/src/tests/forms/generic-form-respondent.test.tsx @@ -202,7 +202,7 @@ describe("GenericFormRespondent", () => { ); expect(html).toContain('aria-live="polite"'); - expect(html).toContain("Your submitted response"); + expect(html).toContain("Response submitted"); expect(html).toContain("This response is locked and cannot be edited"); expect(html).toContain('aria-label="Submitted answers"'); expect(html).toContain("Director/Officer"); diff --git a/apps/blade/src/tests/forms/generic-form-response-form.test.tsx b/apps/blade/src/tests/forms/generic-form-response-form.test.tsx index 45407138b..fd28a0b42 100644 --- a/apps/blade/src/tests/forms/generic-form-response-form.test.tsx +++ b/apps/blade/src/tests/forms/generic-form-response-form.test.tsx @@ -7,6 +7,12 @@ import { linearScaleValues, } from "~/app/_components/forms/generic-form-response-form"; +vi.mock("next/navigation", () => ({ + usePathname: () => "/form/fixture", + useRouter: () => ({ refresh: vi.fn(), replace: vi.fn() }), + useSearchParams: () => new URLSearchParams(), +})); + vi.mock("~/trpc/react", () => ({ api: { forms: { diff --git a/packages/api/src/routers/forms.ts b/packages/api/src/routers/forms.ts index e2ef63c68..c2f949f24 100644 --- a/packages/api/src/routers/forms.ts +++ b/packages/api/src/routers/forms.ts @@ -45,7 +45,10 @@ import { getLegacyFormFileDownloadUrl, isRespondentFormAsset, } from "../utils/forms/attachments"; -import { listFormCallbackCatalog } from "../utils/forms/callbacks"; +import { + getFormCallbackRegistry, + listFormCallbackCatalog, +} from "../utils/forms/callbacks"; import { codeOwnedFormConfigs, formResponseCallbacks, @@ -68,7 +71,7 @@ import { updateResponse, updateResponseInputSchema, } from "../utils/forms/manager"; -import { formCallbackRegistry } from "../utils/forms/registry"; +import { formCallbackProcedures } from "../utils/forms/procedures"; import { provisionFormSection, requirePlatformFormCapability, @@ -94,10 +97,11 @@ function containsExactValue(value: unknown, expected: string): boolean { ); } -function requireCallbackPermission( +async function requireCallbackPermission( permissions: Awaited>["permissions"], callbackSlug: string, ) { + const formCallbackRegistry = await getFormCallbackRegistry(); const definition = formCallbackRegistry.get(callbackSlug); if (!definition) { throw new TRPCError({ @@ -143,6 +147,7 @@ function catalogValue(label: string) { } export const formsRouter = { + ...formCallbackProcedures, createUpload: permProcedure .input( z @@ -521,8 +526,11 @@ export const formsRouter = { }; }), - listCallbacks: permProcedure.query(({ ctx }) => - listFormCallbackCatalog(formCallbackRegistry, ctx.session.permissions), + listCallbacks: permProcedure.query(async ({ ctx }) => + listFormCallbackCatalog( + await getFormCallbackRegistry(), + ctx.session.permissions, + ), ), listRespondentRoles: permProcedure.query(async ({ ctx }) => { @@ -603,7 +611,8 @@ export const formsRouter = { const destinationIds = mappings.flatMap(({ source }) => source.kind === "fixed" && typeof source.value === "string" && - z.string().uuid().safeParse(source.value).success + (z.string().uuid().safeParse(source.value).success || + /^\d{17,20}$/.test(source.value)) ? [source.value] : [], ); @@ -690,7 +699,7 @@ export const formsRouter = { } const actor = await loadPlatformFormActor(ctx.session); await requirePlatformFormCapability(actor, row.formId, "edit_definition"); - requireCallbackPermission(actor.permissions, row.callbackSlug); + await requireCallbackPermission(actor.permissions, row.callbackSlug); const result = await dispatchFormCallbackExecution(input.executionId); if (!result) return result; const execution = await db.query.FormCallbackExecution.findFirst({ @@ -761,7 +770,7 @@ export const formsRouter = { input.formId, "edit_definition", ); - requireCallbackPermission(actor.permissions, input.callbackSlug); + await requireCallbackPermission(actor.permissions, input.callbackSlug); return db.transaction(async (tx) => { const existing = await tx.query.FormCallbackConfiguration.findFirst({ where: and( diff --git a/packages/api/src/tests/config/discord-config.test.ts b/packages/api/src/tests/config/discord-config.test.ts index d6b24619c..a9de3bc04 100644 --- a/packages/api/src/tests/config/discord-config.test.ts +++ b/packages/api/src/tests/config/discord-config.test.ts @@ -167,7 +167,7 @@ describe("discordConfig procedures (TC-002, TC-019)", () => { expect(kinds.indexOf("role")).toBe(4); }); - it("marks exactly the ten inert keys with an empty consumer list", async () => { + it("marks exactly the four inert keys with an empty consumer list", async () => { mocks.permissionRows = [{ permissions: permissionBitstring("IS_OFFICER") }]; const result = await createCaller().list(); @@ -175,13 +175,24 @@ describe("discordConfig procedures (TC-002, TC-019)", () => { .filter((row) => row.readBy.length === 0) .map((row) => row.key); - expect(inert).toHaveLength(10); + expect(inert).toHaveLength(4); expect( result.rows .filter((row) => row.readBy.length > 0) .map((row) => row.key) .sort(), - ).toEqual(["alumni_role", "guild", "log_channel", "recruiting_channel"]); + ).toEqual([ + "alumni_role", + "design_director_role", + "development_director_role", + "guild", + "log_channel", + "outreach_director_role", + "projects_mentorship_director_role", + "recruiting_channel", + "sponsorship_director_role", + "workshops_director_role", + ]); // The description text is identical across every fixture row, so nothing // here could be distinguishing them by prose. expect(new Set(result.rows.map((row) => row.description)).size).toBe(1); diff --git a/packages/api/src/tests/forms/callbacks.test.ts b/packages/api/src/tests/forms/callbacks.test.ts index 17ace2acd..17424f962 100644 --- a/packages/api/src/tests/forms/callbacks.test.ts +++ b/packages/api/src/tests/forms/callbacks.test.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { FORMS } from "@forge/consts"; import type { PermissionMap } from "../../utils/permissions"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; import { assertAllowedFormCallbackDiscordRole, formCallbackDeliveryNonce, @@ -14,6 +15,7 @@ import { assertCallbackMappingsMatchSchema, createFormCallbackDispatcher, createFormCallbackRegistry, + createFormCallbackRegistryFromRouter, defineFormCallback, listFormCallbackCatalog, mapFormCallbackInput, @@ -101,7 +103,7 @@ describe("form callback catalog and mapping", () => { ]); }); - it("[TC-030, TC-NEG-014] maps question IDs, fixed, and system values without ambiguous label matching", () => { + it("[TC-030, TC-NEG-014] maps question IDs, fixed, and respondent values without ambiguous label matching", () => { expect( mapFormCallbackInput( [ @@ -115,17 +117,17 @@ describe("form callback catalog and mapping", () => { }, { inputKey: "memberId", - source: { kind: "system", value: "member_id" }, + source: { kind: "respondent", value: "member_id" }, }, ], { answers: { [QUESTION_ID]: "Workshop application" }, - system: { - event_id: null, + respondent: { + auth_user_id: "40000000-0000-4000-8000-000000000201", + discord_user_id: "123456789012345678", member_id: "20000000-0000-4000-8000-000000000201", - response_id: "30000000-0000-4000-8000-000000000201", - submitted_at: new Date("2026-07-15T18:00:00.000Z"), - user_id: "40000000-0000-4000-8000-000000000201", + respondent_email: "synthetic@example.invalid", + respondent_name: "Test Member", }, }, ), @@ -136,6 +138,28 @@ describe("form callback catalog and mapping", () => { }); }); + it("[TC-017] keeps every respondent identity source distinct", () => { + const respondent = { + auth_user_id: "40000000-0000-4000-8000-000000000201", + discord_user_id: "123456789012345678", + member_id: "20000000-0000-4000-8000-000000000201", + respondent_email: "synthetic@example.invalid", + respondent_name: "Test Member", + }; + expect( + mapFormCallbackInput( + Object.keys(respondent).map((value) => ({ + inputKey: value, + source: { + kind: "respondent" as const, + value: value as keyof typeof respondent, + }, + })), + { answers: {}, respondent }, + ), + ).toEqual(respondent); + }); + it("[TC-034] requires callback metadata permission and always enforces the safe role allowlist", () => { expect(() => assertCallbackConfigurationAllowed(assignRole, { @@ -177,15 +201,84 @@ describe("form callback catalog and mapping", () => { ); }); + it("[TC-015] discovers only metadata-registered tRPC mutations and exposes input copy", () => { + const inputSchema = z.object({ message: z.string() }); + const router = createTRPCRouter({ + ignored: publicProcedure.query(() => "ignored"), + notify: publicProcedure + .meta({ + formCallback: { + description: "Send a test notification.", + inputSchema, + inputs: { + message: { + description: "Text included in the notification.", + label: "Message", + placeholder: "Hello", + }, + }, + label: "Notify", + requiredPermission: "EDIT_FORMS", + slug: "test.notify", + }, + }) + .input(inputSchema) + .mutation(({ input }) => input.message), + }); + + const registry = createFormCallbackRegistryFromRouter(router); + expect([...registry.keys()]).toEqual(["test.notify"]); + expect( + listFormCallbackCatalog(registry, permissionMap("EDIT_FORMS"))[0], + ).toMatchObject({ + available: true, + inputs: [ + { + description: "Text included in the notification.", + key: "message", + label: "Message", + placeholder: "Hello", + }, + ], + }); + }); + + it("[TC-016] rejects using one question for two procedure inputs", () => { + const twoFields = defineFormCallback({ + description: "Two text fields", + inputSchema: z.object({ first: z.string(), second: z.string() }), + label: "Two fields", + requiredPermission: "EDIT_FORMS", + slug: "test.two-fields", + }); + + expect(() => + assertCallbackMappingsMatchSchema({ + definition: twoFields, + formDefinition: callbackFormDefinition, + mappings: [ + { + inputKey: "first", + source: { kind: "question", questionId: QUESTION_ID }, + }, + { + inputKey: "second", + source: { kind: "question", questionId: QUESTION_ID }, + }, + ], + }), + ).toThrow(/each form question may supply only one/i); + }); + it("[TC-030] validates callback keys, required mappings, sources, and field types at configuration time", () => { expect(() => assertCallbackMappingsMatchSchema({ - callbackSchema: notifyRecruiting.inputSchema, + definition: notifyRecruiting, formDefinition: callbackFormDefinition, mappings: [ { inputKey: "memberId", - source: { kind: "system", value: "member_id" }, + source: { kind: "respondent", value: "member_id" }, }, { inputKey: "note", @@ -197,12 +290,12 @@ describe("form callback catalog and mapping", () => { expect(() => assertCallbackMappingsMatchSchema({ - callbackSchema: notifyRecruiting.inputSchema, + definition: notifyRecruiting, formDefinition: callbackFormDefinition, mappings: [ { inputKey: "memberId", - source: { kind: "system", value: "member_id" }, + source: { kind: "respondent", value: "member_id" }, }, ], }), @@ -210,7 +303,7 @@ describe("form callback catalog and mapping", () => { expect(() => assertCallbackMappingsMatchSchema({ - callbackSchema: notifyRecruiting.inputSchema, + definition: notifyRecruiting, formDefinition: callbackFormDefinition, mappings: [ { @@ -223,12 +316,12 @@ describe("form callback catalog and mapping", () => { expect(() => assertCallbackMappingsMatchSchema({ - callbackSchema: notifyRecruiting.inputSchema, + definition: notifyRecruiting, formDefinition: callbackFormDefinition, mappings: [ { inputKey: "memberId", - source: { kind: "system", value: "event_id" }, + source: { kind: "respondent", value: "discord_user_id" }, }, { inputKey: "note", @@ -236,7 +329,7 @@ describe("form callback catalog and mapping", () => { }, ], }), - ).toThrow(/do not provide an event ID/i); + ).toThrow(/incompatible.*memberId/i); }); }); @@ -277,12 +370,25 @@ describe("durable callback execution", () => { it("[TC-NEG-008] reuses the execution identity as the provider nonce", () => { const executionId = "30000000-0000-4000-8000-000000000201"; - expect(formCallbackDeliveryNonce(executionId)).toBe(executionId); + const nonce = formCallbackDeliveryNonce(executionId); + expect(nonce.length).toBeLessThanOrEqual(25); + expect(Buffer.from(nonce, "base64url").toString("hex")).toBe( + executionId.replaceAll("-", ""), + ); + expect(nonce).not.toBe( + formCallbackDeliveryNonce("30000000-0000-4000-8000-000000000202"), + ); expect(formCallbackDeliveryNonce(executionId)).toBe( formCallbackDeliveryNonce(executionId), ); }); + it("[TC-001] rejects malformed execution identities", () => { + for (const id of ["", "not-a-uuid", "30000000000040008000000000000201"]) { + expect(() => formCallbackDeliveryNonce(id)).toThrow(); + } + }); + it("[TC-032, TC-033] records independent successes and failures and permits retry", async () => { const attempts = new Map(); interface TestExecution { diff --git a/packages/api/src/tests/forms/database-callbacks.test.ts b/packages/api/src/tests/forms/database-callbacks.test.ts new file mode 100644 index 000000000..c263cde85 --- /dev/null +++ b/packages/api/src/tests/forms/database-callbacks.test.ts @@ -0,0 +1,326 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { db } from "@forge/db/client"; + +import { + dispatchFormCallbackExecution, + enqueueConfiguredFormCallbacks, +} from "../../utils/forms/database-callbacks"; +import { formCallbackRouter } from "../../utils/forms/procedures"; + +const mocks = vi.hoisted(() => ({ + update: vi.fn(), + select: vi.fn(), + insert: vi.fn(), + member: vi.fn(), + role: vi.fn(), + post: vi.fn< + ( + route: string, + request: { + body: { + allowed_mentions: { parse: string[]; roles: string[] }; + content: string; + embeds: unknown[]; + nonce: string; + enforce_nonce: boolean; + }; + }, + ) => Promise + >(), + channel: vi.fn(), +})); + +vi.mock("@forge/db/client", () => ({ + db: { + update: mocks.update, + select: mocks.select, + insert: mocks.insert, + query: { + Member: { findFirst: mocks.member }, + Roles: { findFirst: mocks.role }, + }, + }, +})); +vi.mock("@forge/utils/discord", () => ({ api: { post: mocks.post } })); +vi.mock("@forge/utils/discord-config", () => ({ + getDiscordConfigId: mocks.channel, +})); +vi.mock("../../utils/roles/discord-gateway", () => ({ + liveRoleDiscordGateway: {}, +})); + +const executionId = "30000000-0000-4000-8000-000000000201"; +const execution = { + id: executionId, + callbackSlug: "recruiting.notify", + input: { + email: "synthetic@example.invalid", + gradTerm: "Spring", + gradYear: 2028, + major: "Computer Science", + name: "Test Member", + team: "Outreach", + }, +}; + +function mockUpdate(rows: unknown[]) { + const set = vi.fn(() => ({ + where: vi.fn(() => ({ returning: vi.fn().mockResolvedValue(rows) })), + })); + mocks.update.mockReturnValueOnce({ set }); + return set; +} + +describe("recruiting Discord delivery boundary", () => { + beforeEach(() => { + vi.resetAllMocks(); + mocks.member.mockResolvedValue({ + id: "20000000-0000-4000-8000-000000000201", + firstName: "Test", + lastName: "Member", + email: "synthetic@example.invalid", + }); + mocks.role.mockResolvedValue({ teamHexcodeColor: "#88fea1" }); + mocks.channel.mockImplementation((key: string) => + Promise.resolve( + key === "recruiting_channel" + ? "100000000000000001" + : "100000000000000002", + ), + ); + }); + + it("[TC-015] rejects direct callers without an internal execution context", async () => { + const caller = formCallbackRouter.createCaller({ + headers: new Headers(), + session: null, + source: "test", + }); + + await expect( + caller.notifyRecruiting(execution.input), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + expect(mocks.post).not.toHaveBeenCalled(); + }); + + it("[TC-002, TC-004] enqueues mapped input for a new response before dispatching", async () => { + mocks.select.mockReturnValueOnce({ + from: () => ({ + where: () => + Promise.resolve([ + { + id: "configuration-1", + callbackSlug: "recruiting.notify", + mappings: [ + { + inputKey: "name", + source: { kind: "respondent", value: "respondent_name" }, + }, + { + inputKey: "email", + source: { + kind: "respondent", + value: "respondent_email", + }, + }, + { + inputKey: "major", + source: { + kind: "question", + questionId: "10000000-0000-4000-8000-000000000201", + }, + }, + { + inputKey: "gradTerm", + source: { kind: "fixed", value: "Spring" }, + }, + { + inputKey: "gradYear", + source: { kind: "fixed", value: "2028" }, + }, + { + inputKey: "team", + source: { kind: "fixed", value: "Outreach" }, + }, + ], + }, + ]), + }), + }); + mocks.select.mockReturnValueOnce({ + from: () => ({ + innerJoin: () => ({ + where: () => + Promise.resolve([ + { + authUserId: "40000000-0000-4000-8000-000000000201", + discordUserId: "123456789012345678", + email: "synthetic@example.invalid", + firstName: "Test", + lastName: "Member", + memberId: "20000000-0000-4000-8000-000000000201", + }, + ]), + }), + }), + }); + const values = vi.fn((value: Record) => ({ + returning: () => Promise.resolve([{ ...execution, ...value }]), + })); + mocks.insert.mockReturnValue({ values }); + const rows = await enqueueConfiguredFormCallbacks({ + database: db, + formId: "form-1", + responseId: "response-1", + userId: "user-1", + submittedAt: new Date("2026-08-01T12:00:00Z"), + answers: { + "10000000-0000-4000-8000-000000000201": "Computer Science", + }, + }); + expect(values).toHaveBeenCalledWith({ + callbackSlug: "recruiting.notify", + configurationId: "configuration-1", + input: execution.input, + lastError: null, + responseId: "response-1", + status: "pending", + }); + expect(mocks.post).not.toHaveBeenCalled(); + mockUpdate(rows); + mockUpdate([{ id: executionId }]); + await expect(dispatchFormCallbackExecution(executionId)).resolves.toEqual({ + status: "succeeded", + }); + expect(mocks.post).toHaveBeenCalledTimes(1); + }); + + it("[TC-004] does not enqueue or send without active configuration", async () => { + mocks.select.mockReturnValue({ + from: () => ({ where: () => Promise.resolve([]) }), + }); + await expect( + enqueueConfiguredFormCallbacks({ + database: db, + formId: "form-1", + responseId: "response-1", + userId: "user-1", + submittedAt: new Date("2026-08-01T12:00:00Z"), + answers: {}, + }), + ).resolves.toEqual([]); + expect(mocks.insert).not.toHaveBeenCalled(); + expect(mocks.post).not.toHaveBeenCalled(); + }); + + it("[TC-002] sends a provider-compliant nonce through the actual dispatcher", async () => { + mockUpdate([execution]); + const complete = mockUpdate([{ id: executionId }]); + mocks.post.mockImplementation( + (_route: string, request: { body: { nonce: string } }) => { + if (request.body.nonce.length > 25) + return Promise.reject(new Error("NONCE_TYPE_TOO_LONG")); + return Promise.resolve({}); + }, + ); + + await expect(dispatchFormCallbackExecution(executionId)).resolves.toEqual({ + status: "succeeded", + }); + expect(mocks.channel).toHaveBeenCalledWith("recruiting_channel"); + expect(mocks.channel).toHaveBeenCalledWith("outreach_director_role"); + const call = mocks.post.mock.calls[0]; + if (!call) throw new Error("Expected one provider request"); + const [route, request] = call; + expect(route).toBe("/channels/100000000000000001/messages"); + expect(request.body.allowed_mentions).toEqual({ + parse: [], + roles: ["100000000000000002"], + }); + expect(request.body.enforce_nonce).toBe(true); + expect(request.body.nonce).toMatch(/^[A-Za-z0-9_-]{22}$/); + expect(request.body.content).toContain("New Applicant for Outreach"); + expect(request.body.embeds).toHaveLength(1); + const embed = request.body.embeds[0] as { + color: number; + description: string; + fields: { inline: boolean; name: string; value: string }[]; + footer: { text: string }; + title: string; + }; + expect(embed).toMatchObject({ + color: 0x88fea1, + description: + "A new applicant is interested in joining the **Outreach** team.\n\nPlease see details below:", + fields: [ + { inline: true, name: "Name", value: "Test Member" }, + { + inline: true, + name: "Email", + value: "synthetic@example.invalid", + }, + { inline: true, name: "Major", value: "Computer Science" }, + { inline: true, name: "Grad Term", value: "Spring" }, + { inline: true, name: "Grad Year", value: "2028" }, + { inline: true, name: "Team", value: "Outreach" }, + ], + title: "Test Member's Application", + }); + expect(embed.footer.text).toMatch(/^Submitted at: /); + expect(complete).toHaveBeenCalledWith( + expect.objectContaining({ status: "succeeded", leaseToken: null }), + ); + }); + + it("[TC-003] records provider failure and reuses the nonce on retry", async () => { + mockUpdate([execution]); + const failed = mockUpdate([{ id: executionId }]); + mockUpdate([execution]); + mockUpdate([{ id: executionId }]); + mocks.post + .mockRejectedValueOnce(new Error("Provider unavailable")) + .mockResolvedValueOnce({}); + await expect(dispatchFormCallbackExecution(executionId)).resolves.toEqual({ + status: "failed", + error: "Provider unavailable", + }); + expect(failed).toHaveBeenCalledWith( + expect.objectContaining({ + status: "failed", + lastError: "Provider unavailable", + }), + ); + await expect(dispatchFormCallbackExecution(executionId)).resolves.toEqual({ + status: "succeeded", + }); + expect(mocks.post.mock.calls[0]?.[1].body.nonce).toBe( + mocks.post.mock.calls[1]?.[1].body.nonce, + ); + }); + + it("[TC-003] does not send when the database refuses the claim", async () => { + mockUpdate([]); + await expect( + dispatchFormCallbackExecution(executionId), + ).resolves.toBeNull(); + expect(mocks.post).not.toHaveBeenCalled(); + }); + + it("[TC-001] rejects malformed execution identity before sending", async () => { + mockUpdate([{ ...execution, id: "invalid" }]); + mockUpdate([{ id: "invalid" }]); + await expect( + dispatchFormCallbackExecution("invalid"), + ).resolves.toMatchObject({ status: "failed" }); + expect(mocks.post).not.toHaveBeenCalled(); + }); + + it("[TC-003] does not report success after a lost completion lease", async () => { + mockUpdate([execution]); + mockUpdate([]); + await expect(dispatchFormCallbackExecution(executionId)).resolves.toEqual({ + status: "superseded", + }); + }); +}); diff --git a/packages/api/src/tests/root/__snapshots__/api-surface.test.ts.snap b/packages/api/src/tests/root/__snapshots__/api-surface.test.ts.snap index e1a8ec1fa..733272668 100644 --- a/packages/api/src/tests/root/__snapshots__/api-surface.test.ts.snap +++ b/packages/api/src/tests/root/__snapshots__/api-surface.test.ts.snap @@ -101,6 +101,7 @@ exports[`client-facing API surface > exposes exactly the documented procedure pa "event.updateEvent", "event.updateFeedbackTemplate", "event.updateTag", + "forms.assignRespondentDiscordRole", "forms.changeState", "forms.configureCallback", "forms.createForm", @@ -124,6 +125,7 @@ exports[`client-facing API surface > exposes exactly the documented procedure pa "forms.listRespondentRoles", "forms.listResponses", "forms.memberHistory", + "forms.notifyRecruiting", "forms.retryCallback", "forms.searchCatalog", "forms.sectionProvisioning", diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index 5812d21fa..52ce7e7ea 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -4,13 +4,25 @@ import { ZodError } from "zod"; import type { Session } from "@forge/auth/server"; +import type { ForgeTRPCMeta } from "./utils/forms/callbacks"; import { resolveJudgeAccess } from "./utils/judging/principal"; import { loadPermissionsForUser } from "./utils/permissions-db"; +export interface FormCallbackContext { + executionId: string; +} + +export interface TRPCContext { + formCallback?: FormCallbackContext | null; + headers: Headers; + session: Session | null; + source: string; +} + export const createTRPCContext = (opts: { headers: Headers; session?: Session | null; -}) => { +}): TRPCContext => { const source = opts.headers.get("x-trpc-source") ?? "unknown"; return { @@ -20,21 +32,33 @@ export const createTRPCContext = (opts: { }; }; -const t = initTRPC.context().create({ - transformer: superjson, - errorFormatter: ({ shape, error }) => ({ - ...shape, - data: { - ...shape.data, - zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, - }, - }), -}); +const t = initTRPC + .context() + .meta() + .create({ + transformer: superjson, + errorFormatter: ({ shape, error }) => ({ + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }), + }); export const createCallerFactory = t.createCallerFactory; export const createTRPCRouter = t.router; export const publicProcedure = t.procedure; +export const formCallbackProcedure = t.procedure.use(({ ctx, next }) => { + if (!ctx.formCallback) { + throw new TRPCError({ code: "UNAUTHORIZED" }); + } + + return next({ ctx: { ...ctx, formCallback: ctx.formCallback } }); +}); + export const protectedProcedure = t.procedure.use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: "UNAUTHORIZED" }); diff --git a/packages/api/src/utils/forms/callback-policy.ts b/packages/api/src/utils/forms/callback-policy.ts index 5e456c5f4..144005232 100644 --- a/packages/api/src/utils/forms/callback-policy.ts +++ b/packages/api/src/utils/forms/callback-policy.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + import { FORMS } from "@forge/consts"; export const RETRYABLE_FORM_CALLBACK_STATUSES = ["failed", "pending"] as const; @@ -15,7 +17,9 @@ export function assertAllowedFormCallbackDiscordRole(discordRoleId: string) { } export function formCallbackDeliveryNonce(executionId: string) { - return executionId; + const uuid = z.string().uuid().parse(executionId); + // Preserve all 128 identity bits within Discord's 25-character nonce limit. + return Buffer.from(uuid.replaceAll("-", ""), "hex").toString("base64url"); } export function isFormCallbackExecutionClaimable( diff --git a/packages/api/src/utils/forms/callbacks.ts b/packages/api/src/utils/forms/callbacks.ts index 38c9989e7..034d330c7 100644 --- a/packages/api/src/utils/forms/callbacks.ts +++ b/packages/api/src/utils/forms/callbacks.ts @@ -1,3 +1,4 @@ +import type { AnyProcedure, AnyRouter } from "@trpc/server"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -10,18 +11,55 @@ import { formDefinitionSchema } from "@forge/validators"; import type { PermissionMap } from "../permissions"; -export interface FormCallbackDefinition { +export const callbackRespondentValues = [ + "member_id", + "respondent_name", + "respondent_email", + "auth_user_id", + "discord_user_id", +] as const; + +export type CallbackRespondentValue = (typeof callbackRespondentValues)[number]; +export type CallbackSourceKind = "fixed" | "question" | "respondent"; + +export interface FormCallbackInputMetadata { + allowedSources?: readonly CallbackSourceKind[]; + description?: string; + fixedInputType?: "email" | "number" | "text"; + label?: string; + placeholder?: string; + questionTypes?: readonly FormQuestion["type"][]; + respondentValues?: readonly CallbackRespondentValue[]; +} + +export interface FormCallbackRegistration< + TSchema extends z.ZodType = z.ZodType, +> { description: string; inputSchema: TSchema; + inputs?: Readonly>; label: string; requiredPermission: PERMISSIONS.PermissionKey; slug: string; } +export interface ForgeTRPCMeta { + formCallback?: FormCallbackRegistration; +} + +export interface FormCallbackDefinition< + TSchema extends z.ZodType = z.ZodType, +> extends FormCallbackRegistration { + procedurePath: string; +} + export function defineFormCallback( - definition: FormCallbackDefinition, -) { - return definition; + definition: FormCallbackRegistration & { procedurePath?: string }, +): FormCallbackDefinition { + return { + ...definition, + procedurePath: definition.procedurePath ?? definition.slug, + }; } export type FormCallbackRegistry = ReadonlyMap; @@ -34,47 +72,127 @@ export function createFormCallbackRegistry( if (registry.has(definition.slug)) { throw new Error(`Duplicate form callback metadata: ${definition.slug}`); } + if (!(definition.inputSchema instanceof z.ZodObject)) { + throw new Error( + `Form callback ${definition.slug} must accept an object.`, + ); + } + const shape = definition.inputSchema.shape as Record; + for (const key of Object.keys(definition.inputs ?? {})) { + if (!(key in shape)) { + throw new Error( + `Form callback ${definition.slug} describes unknown input ${key}.`, + ); + } + } registry.set(definition.slug, definition); } return registry; } +export function createFormCallbackRegistryFromRouter( + router: AnyRouter, +): FormCallbackRegistry { + const procedures = router._def.procedures as Record; + const definitions = Object.entries(procedures).flatMap( + ([procedurePath, procedure]) => { + const registration = (procedure._def.meta as ForgeTRPCMeta | undefined) + ?.formCallback; + if (!registration) return []; + if (procedure._def.type !== "mutation") { + throw new Error( + `Form callback ${registration.slug} must be a mutation procedure.`, + ); + } + return [{ ...registration, procedurePath }]; + }, + ); + return createFormCallbackRegistry(definitions); +} + +export async function getFormCallbackRegistry(): Promise { + const { formCallbackRouter } = await import("./procedures"); + return createFormCallbackRegistryFromRouter(formCallbackRouter); +} + +function defaultInputLabel(inputKey: string) { + return inputKey + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replaceAll("_", " ") + .replace(/^./, (first) => first.toUpperCase()); +} + export function listFormCallbackCatalog( registry: FormCallbackRegistry, permissions: PermissionMap, ) { - return [...registry.values()].map((definition) => ({ - available: - permissions.IS_OFFICER === true - ? true - : permissions[definition.requiredPermission] === true, - description: definition.description, - label: definition.label, - requiredPermission: definition.requiredPermission, - slug: definition.slug, - })); + return [...registry.values()].map((definition) => { + const shape = (definition.inputSchema as z.ZodObject).shape as Record< + string, + z.ZodType + >; + return { + available: + permissions.IS_OFFICER === true + ? true + : permissions[definition.requiredPermission] === true, + description: definition.description, + inputs: Object.keys(shape).map((key) => ({ + allowedSources: definition.inputs?.[key]?.allowedSources ?? [ + "question", + "respondent", + "fixed", + ], + description: definition.inputs?.[key]?.description, + fixedInputType: definition.inputs?.[key]?.fixedInputType ?? "text", + key, + label: definition.inputs?.[key]?.label ?? defaultInputLabel(key), + placeholder: definition.inputs?.[key]?.placeholder, + questionTypes: definition.inputs?.[key]?.questionTypes, + respondentValues: definition.inputs?.[key]?.respondentValues, + })), + label: definition.label, + requiredPermission: definition.requiredPermission, + slug: definition.slug, + }; + }); } -type CallbackSystemValue = - | "event_id" - | "member_id" - | "response_id" - | "submitted_at" - | "user_id"; - export interface CallbackMapping { inputKey: string; source: | { kind: "fixed"; value: unknown } | { kind: "question"; questionId: string } - | { kind: "system"; value: CallbackSystemValue }; + | { kind: "respondent"; value: CallbackRespondentValue }; +} + +function callbackQuestionValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(callbackQuestionValue); + if (typeof value !== "object" || value === null) return value; + if ( + "kind" in value && + value.kind === "option" && + "label" in value && + typeof value.label === "string" + ) { + return value.label; + } + if ( + "kind" in value && + value.kind === "other" && + "text" in value && + typeof value.text === "string" + ) { + return value.text; + } + return value; } export function mapFormCallbackInput( mappings: readonly CallbackMapping[], source: { answers: Record; - system: Record; + respondent: Record; }, ) { const result: Record = {}; @@ -94,9 +212,11 @@ export function mapFormCallbackInput( message: `Missing callback question: ${mapping.source.questionId}`, }); } - result[mapping.inputKey] = source.answers[mapping.source.questionId]; + result[mapping.inputKey] = callbackQuestionValue( + source.answers[mapping.source.questionId], + ); } else { - result[mapping.inputKey] = source.system[mapping.source.value]; + result[mapping.inputKey] = source.respondent[mapping.source.value]; } } return result; @@ -106,15 +226,18 @@ function representativeQuestionValue(question: FormQuestion): unknown { switch (question.type) { case "short_text": case "paragraph": + return "Example"; case "email": + return "member@example.com"; case "phone": + return "+14075550123"; case "link": - return "00000000-0000-4000-8000-000000000000"; + return "https://example.com"; case "multiple_choice": case "dropdown": - return { kind: "option", label: "Example", value: "example" }; + return "Example"; case "checkboxes": - return [{ kind: "option", label: "Example", value: "example" }]; + return ["Example"]; case "file": return { attachmentId: "00000000-0000-4000-8000-000000000000", @@ -132,20 +255,26 @@ function representativeQuestionValue(question: FormQuestion): unknown { } } -function representativeSystemValue( - value: "event_id" | "member_id" | "response_id" | "submitted_at" | "user_id", -) { - return value === "submitted_at" - ? "2026-01-01T00:00:00.000Z" - : "00000000-0000-4000-8000-000000000000"; +function representativeRespondentValue(value: CallbackRespondentValue) { + switch (value) { + case "member_id": + case "auth_user_id": + return "00000000-0000-4000-8000-000000000000"; + case "discord_user_id": + return "123456789012345678"; + case "respondent_email": + return "member@example.com"; + case "respondent_name": + return "Example Member"; + } } export function assertCallbackMappingsMatchSchema(input: { - callbackSchema: z.ZodType; + definition: FormCallbackDefinition; formDefinition: unknown; mappings: z.infer["mappings"]; }) { - if (!(input.callbackSchema instanceof z.ZodObject)) { + if (!(input.definition.inputSchema instanceof z.ZodObject)) { throw new TRPCError({ code: "BAD_REQUEST", message: "Callback inputs must use an object schema.", @@ -155,17 +284,18 @@ export function assertCallbackMappingsMatchSchema(input: { const questions = new Map( formDefinition.questions.map((question) => [question.id, question]), ); - const shape: Record = input.callbackSchema.shape; - const seen = new Set(); + const shape: Record = input.definition.inputSchema.shape; + const seenInputs = new Set(); + const seenQuestions = new Set(); for (const mapping of input.mappings) { - if (seen.has(mapping.inputKey)) { + if (seenInputs.has(mapping.inputKey)) { throw new TRPCError({ code: "BAD_REQUEST", message: `Duplicate callback input mapping: ${mapping.inputKey}`, }); } - seen.add(mapping.inputKey); + seenInputs.add(mapping.inputKey); const target = shape[mapping.inputKey]; if (!target) { throw new TRPCError({ @@ -174,18 +304,41 @@ export function assertCallbackMappingsMatchSchema(input: { }); } + const metadata = input.definition.inputs?.[mapping.inputKey]; + const allowedSources = metadata?.allowedSources ?? [ + "question", + "respondent", + "fixed", + ]; + if (!allowedSources.includes(mapping.source.kind)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${defaultInputLabel(mapping.inputKey)} does not allow ${mapping.source.kind} values.`, + }); + } + let representative: unknown; if (mapping.source.kind === "fixed") { representative = mapping.source.value; - } else if (mapping.source.kind === "system") { - if (mapping.source.value === "event_id") { + } else if (mapping.source.kind === "respondent") { + if ( + metadata?.respondentValues && + !metadata.respondentValues.includes(mapping.source.value) + ) { throw new TRPCError({ code: "BAD_REQUEST", - message: "General forms do not provide an event ID callback value.", + message: `${defaultInputLabel(mapping.inputKey)} does not allow that respondent value.`, }); } - representative = representativeSystemValue(mapping.source.value); + representative = representativeRespondentValue(mapping.source.value); } else { + if (seenQuestions.has(mapping.source.questionId)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Each form question may supply only one callback input.", + }); + } + seenQuestions.add(mapping.source.questionId); const question = questions.get(mapping.source.questionId); if (!question || question.retired) { throw new TRPCError({ @@ -193,6 +346,15 @@ export function assertCallbackMappingsMatchSchema(input: { message: `Callback question is missing or retired: ${mapping.source.questionId}`, }); } + if ( + metadata?.questionTypes && + !metadata.questionTypes.includes(question.type) + ) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `${defaultInputLabel(mapping.inputKey)} does not allow ${question.type} questions.`, + }); + } representative = representativeQuestionValue(question); } @@ -206,7 +368,8 @@ export function assertCallbackMappingsMatchSchema(input: { const missing = Object.entries(shape) .filter( - ([key, schema]) => !seen.has(key) && !schema.safeParse(undefined).success, + ([key, schema]) => + !seenInputs.has(key) && !schema.safeParse(undefined).success, ) .map(([key]) => key); if (missing.length > 0) { diff --git a/packages/api/src/utils/forms/database-callbacks.ts b/packages/api/src/utils/forms/database-callbacks.ts index 56ebe6e3d..469c88866 100644 --- a/packages/api/src/utils/forms/database-callbacks.ts +++ b/packages/api/src/utils/forms/database-callbacks.ts @@ -1,44 +1,37 @@ import { randomUUID } from "node:crypto"; -import { TRPCError } from "@trpc/server"; -import { Routes } from "discord-api-types/v10"; +import { callTRPCProcedure, TRPCError } from "@trpc/server"; import { z } from "zod"; import { and, eq, inArray, lte, or, sql } from "@forge/db"; import { db } from "@forge/db/client"; -import { Permissions, Roles, User } from "@forge/db/schemas/auth"; +import { User } from "@forge/db/schemas/auth"; import { FormCallbackConfiguration, FormCallbackExecution, Member, } from "@forge/db/schemas/knight-hacks"; -import * as discord from "@forge/utils/discord"; -import { getDiscordConfigId } from "@forge/utils/discord-config"; import { callbackConfigurationSchema } from "@forge/validators"; import type { WriteDb } from "../db"; import type { PermissionMap } from "../permissions"; -import { liveRoleDiscordGateway } from "../roles/discord-gateway"; import { assertAllowedFormCallbackDiscordRole, - formCallbackDeliveryNonce, RETRYABLE_FORM_CALLBACK_STATUSES, } from "./callback-policy"; import { assertCallbackMappingsMatchSchema, + getFormCallbackRegistry, mapFormCallbackInput, } from "./callbacks"; -import { formCallbackRegistry } from "./registry"; const LEASE_MS = 5 * 60 * 1000; -async function requireAllowedAssignableRole(database: WriteDb, roleId: string) { - const parsedRoleId = z.string().uuid().parse(roleId); - const role = await database.query.Roles.findFirst({ - where: eq(Roles.id, parsedRoleId), - }); - if (!role) throw new Error("The selected assignable role no longer exists."); - assertAllowedFormCallbackDiscordRole(role.discordRoleId); - return role; +function requireAllowedAssignableRole(roleId: string) { + const parsedRoleId = z + .string() + .regex(/^\d{17,20}$/) + .parse(roleId); + assertAllowedFormCallbackDiscordRole(parsedRoleId); } export async function saveFormCallbackConfiguration(input: { @@ -56,7 +49,7 @@ export async function saveFormCallbackConfiguration(input: { mappings: input.mappings, responseMode: input.responseMode, }); - const definition = formCallbackRegistry.get(parsed.callbackSlug); + const definition = (await getFormCallbackRegistry()).get(parsed.callbackSlug); if (!definition) throw new Error("Callback is not registered."); if ( @@ -70,7 +63,7 @@ export async function saveFormCallbackConfiguration(input: { } assertCallbackMappingsMatchSchema({ - callbackSchema: definition.inputSchema, + definition, formDefinition: input.formDefinition, mappings: parsed.mappings, }); @@ -85,7 +78,7 @@ export async function saveFormCallbackConfiguration(input: { ); if (fixedRoleIds.length > 0) { for (const roleId of new Set(fixedRoleIds)) { - await requireAllowedAssignableRole(database, roleId); + requireAllowedAssignableRole(roleId); } } } @@ -127,11 +120,21 @@ export async function enqueueConfiguredFormCallbacks(input: { ); if (configurations.length === 0) return []; - const member = await input.database.query.Member.findFirst({ - columns: { id: true }, - where: eq(Member.userId, input.userId), - }); + const respondent = await input.database + .select({ + authUserId: User.id, + discordUserId: User.discordUserId, + email: Member.email, + firstName: Member.firstName, + lastName: Member.lastName, + memberId: Member.id, + }) + .from(Member) + .innerJoin(User, eq(Member.userId, User.id)) + .where(eq(Member.userId, input.userId)) + .then((rows) => rows[0]); const executions: (typeof FormCallbackExecution.$inferSelect)[] = []; + const registry = await getFormCallbackRegistry(); for (const configuration of configurations) { let mappedInput: Record = {}; @@ -143,25 +146,24 @@ export async function enqueueConfiguredFormCallbacks(input: { ); mappedInput = mapFormCallbackInput(parsed, { answers: input.answers, - system: { - event_id: null, - member_id: member?.id ?? null, - response_id: input.responseId, - submitted_at: input.submittedAt.toISOString(), - user_id: input.userId, + respondent: { + auth_user_id: respondent?.authUserId ?? null, + discord_user_id: respondent?.discordUserId ?? null, + member_id: respondent?.memberId ?? null, + respondent_email: respondent?.email ?? null, + respondent_name: respondent + ? `${respondent.firstName} ${respondent.lastName}`.trim() + : null, }, }); - const definition = formCallbackRegistry.get(configuration.callbackSlug); + const definition = registry.get(configuration.callbackSlug); if (!definition) throw new Error("Callback is no longer registered."); mappedInput = definition.inputSchema.parse(mappedInput) as Record< string, unknown >; if (configuration.callbackSlug === "discord.assign-role") { - await requireAllowedAssignableRole( - input.database, - z.string().uuid().parse(mappedInput.roleId), - ); + requireAllowedAssignableRole(z.string().parse(mappedInput.roleId)); } } catch (cause) { status = "failed"; @@ -187,52 +189,6 @@ export async function enqueueConfiguredFormCallbacks(input: { return executions; } -async function runAssignRole(input: { memberId: string; roleId: string }) { - const [member, role] = await Promise.all([ - db - .select({ discordUserId: User.discordUserId, userId: Member.userId }) - .from(Member) - .innerJoin(User, eq(Member.userId, User.id)) - .where(eq(Member.id, input.memberId)) - .then((rows) => rows[0]), - requireAllowedAssignableRole(db, input.roleId), - ]); - if (!member) throw new Error("Callback member was not found."); - const gateway = liveRoleDiscordGateway; - await gateway.grantRole(member.discordUserId, role.discordRoleId); - try { - await db - .insert(Permissions) - .values({ roleId: role.id, userId: member.userId }) - .onConflictDoNothing(); - } catch (cause) { - await gateway.revokeRole(member.discordUserId, role.discordRoleId); - throw cause; - } -} - -async function runRecruitingNotification( - input: { - memberId: string; - note: string; - }, - executionId: string, -) { - const member = await db.query.Member.findFirst({ - where: eq(Member.id, input.memberId), - }); - if (!member) throw new Error("Callback member was not found."); - const recruitingChannelId = await getDiscordConfigId("recruiting_channel"); - await discord.api.post(Routes.channelMessages(recruitingChannelId), { - body: { - content: `**Form recruiting notification**\n${member.firstName} ${member.lastName} (${member.email})\n${input.note}`, - allowed_mentions: { parse: [] }, - enforce_nonce: true, - nonce: formCallbackDeliveryNonce(executionId), - }, - }); -} - export async function dispatchFormCallbackExecution(executionId: string) { const leaseToken = randomUUID(); const now = new Date(); @@ -265,24 +221,28 @@ export async function dispatchFormCallbackExecution(executionId: string) { if (!execution) return null; try { - const definition = formCallbackRegistry.get(execution.callbackSlug); + const definition = (await getFormCallbackRegistry()).get( + execution.callbackSlug, + ); if (!definition) { throw new Error(`No callback handler for ${execution.callbackSlug}.`); } const parsedInput = definition.inputSchema.parse(execution.input); - if (execution.callbackSlug === "discord.assign-role") { - await runAssignRole(parsedInput as { memberId: string; roleId: string }); - } else if (execution.callbackSlug === "recruiting.notify") { - await runRecruitingNotification( - parsedInput as { - memberId: string; - note: string; - }, - execution.id, - ); - } else { - throw new Error(`No callback handler for ${execution.callbackSlug}.`); - } + const { formCallbackRouter } = await import("./procedures"); + await callTRPCProcedure({ + batchIndex: 0, + ctx: { + formCallback: { executionId: execution.id }, + headers: new Headers(), + session: null, + source: "form-callback", + }, + getRawInput: () => Promise.resolve(parsedInput), + path: definition.procedurePath, + router: formCallbackRouter, + signal: undefined, + type: "mutation", + }); const [completed] = await db .update(FormCallbackExecution) .set({ diff --git a/packages/api/src/utils/forms/procedures.ts b/packages/api/src/utils/forms/procedures.ts new file mode 100644 index 000000000..7e6492417 --- /dev/null +++ b/packages/api/src/utils/forms/procedures.ts @@ -0,0 +1,222 @@ +import type { TRPCRouterRecord } from "@trpc/server"; +import { Routes } from "discord-api-types/v10"; +import { z } from "zod"; + +import { eq } from "@forge/db"; +import { db } from "@forge/db/client"; +import { Roles } from "@forge/db/schemas/auth"; +import * as discord from "@forge/utils/discord"; +import { getDiscordConfigId } from "@forge/utils/discord-config"; + +import type { FormCallbackRegistration } from "./callbacks"; +import { createTRPCRouter, formCallbackProcedure } from "../../trpc"; +import { liveRoleDiscordGateway } from "../roles/discord-gateway"; +import { + assertAllowedFormCallbackDiscordRole, + formCallbackDeliveryNonce, +} from "./callback-policy"; + +const discordSnowflake = z.string().regex(/^\d{17,20}$/, "Enter a Discord ID."); + +const assignDiscordRoleInput = z.object({ + discordUserId: discordSnowflake, + roleId: discordSnowflake, +}); + +const assignDiscordRoleRegistration = { + description: + "Assign an approved Discord role to the member who submitted the form.", + inputSchema: assignDiscordRoleInput, + inputs: { + discordUserId: { + allowedSources: ["respondent"], + description: + "The Discord account linked to the submitted Member profile.", + label: "Discord User ID", + respondentValues: ["discord_user_id"], + }, + roleId: { + allowedSources: ["fixed"], + description: "The Discord role every respondent should receive.", + label: "Discord Role ID", + placeholder: "123456789012345678", + }, + }, + label: "Assign Discord role", + requiredPermission: "ASSIGN_ROLES", + slug: "discord.assign-role", +} as const satisfies FormCallbackRegistration; + +const recruitingInput = z.object({ + name: z.string().trim().min(1).max(120), + email: z.string().email().max(320), + major: z.string().trim().min(1).max(255), + gradTerm: z.string().trim().min(1).max(40), + gradYear: z.coerce.number().int().min(2000).max(2200), + team: z.string().trim().min(1).max(100), +}); + +const recruitingRegistration = { + description: + "Post a structured applicant summary and notify the selected team's director.", + inputSchema: recruitingInput, + inputs: { + name: { + description: "Applicant name shown in the announcement.", + label: "Name", + questionTypes: ["short_text"], + respondentValues: ["respondent_name"], + }, + email: { + description: "Applicant email shown in the announcement.", + fixedInputType: "email", + label: "Email", + questionTypes: ["email", "short_text"], + respondentValues: ["respondent_email"], + }, + major: { + allowedSources: ["question", "fixed"], + description: "Applicant's academic major.", + label: "Major", + questionTypes: ["dropdown", "multiple_choice", "short_text"], + }, + gradTerm: { + allowedSources: ["question", "fixed"], + description: "Applicant's graduation term, such as Spring or Fall.", + label: "Graduation term", + questionTypes: ["dropdown", "multiple_choice", "short_text"], + }, + gradYear: { + allowedSources: ["question", "fixed"], + description: "Applicant's four-digit graduation year.", + fixedInputType: "number", + label: "Graduation year", + placeholder: "2028", + questionTypes: ["dropdown", "multiple_choice", "number", "short_text"], + }, + team: { + allowedSources: ["question", "fixed"], + description: "Team used to choose the director mention and embed color.", + label: "Team", + placeholder: "Outreach", + questionTypes: ["dropdown", "multiple_choice", "short_text"], + }, + }, + label: "Notify recruiting", + requiredPermission: "EDIT_FORMS", + slug: "recruiting.notify", +} as const satisfies FormCallbackRegistration; + +const recruitingTeams = { + design: { + label: "Design", + }, + development: { + label: "Development", + }, + outreach: { + label: "Outreach", + }, + projectsmentorship: { + label: "Projects/Mentorship", + }, + sponsorship: { + label: "Sponsorship", + }, + workshops: { + label: "Workshops", + }, +} as const; + +function recruitingTeam(value: string) { + const key = value.toLowerCase().replace(/[^a-z0-9]+/g, ""); + const teams: Readonly> = recruitingTeams; + const team = teams[key]; + if (!team) throw new Error(`Recruiting team is not configured: ${value}`); + return { key: key as keyof typeof recruitingTeams, ...team }; +} + +function recruitingDirectorRoleId(team: keyof typeof recruitingTeams) { + switch (team) { + case "outreach": + return getDiscordConfigId("outreach_director_role"); + case "design": + return getDiscordConfigId("design_director_role"); + case "development": + return getDiscordConfigId("development_director_role"); + case "sponsorship": + return getDiscordConfigId("sponsorship_director_role"); + case "workshops": + return getDiscordConfigId("workshops_director_role"); + case "projectsmentorship": + return getDiscordConfigId("projects_mentorship_director_role"); + } +} + +async function recruitingTeamColor(discordRoleId: string) { + const role = await db.query.Roles.findFirst({ + columns: { teamHexcodeColor: true }, + where: eq(Roles.discordRoleId, discordRoleId), + }); + const color = role?.teamHexcodeColor; + if (!color || !/^#[0-9a-f]{6}$/i.test(color)) { + throw new Error("The recruiting director role has no valid team color."); + } + return Number.parseInt(color.slice(1), 16); +} + +export const formCallbackProcedures = { + assignRespondentDiscordRole: formCallbackProcedure + .meta({ formCallback: assignDiscordRoleRegistration }) + .input(assignDiscordRoleInput) + .mutation(async ({ input }) => { + assertAllowedFormCallbackDiscordRole(input.roleId); + await liveRoleDiscordGateway.grantRole(input.discordUserId, input.roleId); + }), + + notifyRecruiting: formCallbackProcedure + .meta({ formCallback: recruitingRegistration }) + .input(recruitingInput) + .mutation(async ({ ctx, input }) => { + const team = recruitingTeam(input.team); + const [channelId, directorRoleId] = await Promise.all([ + getDiscordConfigId("recruiting_channel"), + recruitingDirectorRoleId(team.key), + ]); + const color = await recruitingTeamColor(directorRoleId); + const submittedAt = new Date(); + await discord.api.post(Routes.channelMessages(channelId), { + body: { + allowed_mentions: { parse: [], roles: [directorRoleId] }, + content: `<@&${directorRoleId}> **New Applicant for ${team.label}!**`, + embeds: [ + { + color, + description: `A new applicant is interested in joining the **${team.label}** team.\n\nPlease see details below:`, + fields: [ + { inline: true, name: "Name", value: input.name }, + { inline: true, name: "Email", value: input.email }, + { inline: true, name: "Major", value: input.major }, + { inline: true, name: "Grad Term", value: input.gradTerm }, + { + inline: true, + name: "Grad Year", + value: String(input.gradYear), + }, + { inline: true, name: "Team", value: team.label }, + ], + footer: { + text: `Submitted at: ${submittedAt.toLocaleString()}`, + }, + timestamp: submittedAt.toISOString(), + title: `${input.name}'s Application`, + }, + ], + enforce_nonce: true, + nonce: formCallbackDeliveryNonce(ctx.formCallback.executionId), + }, + }); + }), +} satisfies TRPCRouterRecord; + +export const formCallbackRouter = createTRPCRouter(formCallbackProcedures); diff --git a/packages/api/src/utils/forms/registry.ts b/packages/api/src/utils/forms/registry.ts deleted file mode 100644 index 0b2629819..000000000 --- a/packages/api/src/utils/forms/registry.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { z } from "zod"; - -import { createFormCallbackRegistry, defineFormCallback } from "./callbacks"; - -export const formCallbackRegistry = createFormCallbackRegistry([ - defineFormCallback({ - description: "Assign one code-approved Discord role after submission.", - inputSchema: z.object({ - memberId: z.string().uuid(), - roleId: z.string().uuid(), - }), - label: "Assign Discord role", - requiredPermission: "ASSIGN_ROLES", - slug: "discord.assign-role", - }), - defineFormCallback({ - description: "Send a typed response summary to the recruiting workflow.", - inputSchema: z.object({ - memberId: z.string().uuid(), - note: z.string().trim().min(1).max(1_500), - }), - label: "Notify recruiting", - requiredPermission: "EDIT_FORMS", - slug: "recruiting.notify", - }), -]); diff --git a/packages/consts/src/discord.ts b/packages/consts/src/discord.ts index 616af27e1..b9c25b65a 100644 --- a/packages/consts/src/discord.ts +++ b/packages/consts/src/discord.ts @@ -78,12 +78,20 @@ export const CONFIG_KEY_CONSUMERS = { admin_role: [], volunteer_role: [], vip_role: [], - outreach_director_role: [], - design_director_role: [], - development_director_role: [], - sponsorship_director_role: [], - workshops_director_role: [], - projects_mentorship_director_role: [], + outreach_director_role: ["Recruiting notifications posted by form callbacks"], + design_director_role: ["Recruiting notifications posted by form callbacks"], + development_director_role: [ + "Recruiting notifications posted by form callbacks", + ], + sponsorship_director_role: [ + "Recruiting notifications posted by form callbacks", + ], + workshops_director_role: [ + "Recruiting notifications posted by form callbacks", + ], + projects_mentorship_director_role: [ + "Recruiting notifications posted by form callbacks", + ], } as const satisfies Record; /** Keys some code resolves. Ordered by {@link CONFIG_KEYS}, not alphabetically. */ diff --git a/packages/validators/src/forms-platform.ts b/packages/validators/src/forms-platform.ts index e7df492d4..d83889c86 100644 --- a/packages/validators/src/forms-platform.ts +++ b/packages/validators/src/forms-platform.ts @@ -412,13 +412,13 @@ const callbackSourceSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("fixed"), value: z.unknown() }), z.object({ kind: z.literal("question"), questionId: z.string().uuid() }), z.object({ - kind: z.literal("system"), + kind: z.literal("respondent"), value: z.enum([ - "user_id", + "auth_user_id", + "discord_user_id", "member_id", - "response_id", - "submitted_at", - "event_id", + "respondent_email", + "respondent_name", ]), }), ]); diff --git a/packages/validators/src/tests/forms-platform.test.ts b/packages/validators/src/tests/forms-platform.test.ts index c4dd576c3..8418662dc 100644 --- a/packages/validators/src/tests/forms-platform.test.ts +++ b/packages/validators/src/tests/forms-platform.test.ts @@ -375,7 +375,7 @@ describe("forms platform state, availability, callbacks, and uploads", () => { }, { inputKey: "memberId", - source: { kind: "system", value: "member_id" }, + source: { kind: "respondent", value: "member_id" }, }, ], responseMode: "single_locked",