Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions .forge/features/forms-delivery-feedback/investigation.md
Original file line number Diff line number Diff line change
@@ -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.
82 changes: 82 additions & 0 deletions .forge/features/forms-delivery-feedback/spec.md
Original file line number Diff line number Diff line change
@@ -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.
143 changes: 143 additions & 0 deletions .forge/features/forms-delivery-feedback/srd.md
Original file line number Diff line number Diff line change
@@ -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/<slug>?responseId=<id>` 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.
Loading
Loading