Skip to content

refactor(actions): parse server-action input with zod - #217

Merged
guarzo merged 2 commits into
mainfrom
worktree-zod-server-actions
Aug 10, 2026
Merged

refactor(actions): parse server-action input with zod#217
guarzo merged 2 commits into
mainfrom
worktree-zod-server-actions

Conversation

@guarzo

@guarzo guarzo commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Brings all five src/app/**/actions.ts modules in line with the zod convention .coderabbit.yaml already states (lines 94 and 115). Every one of them returned 0 for grep -c zod before this.

Convention conformance, not behaviour change: every existing error code is preserved, no code was merged or split, and no zod message reaches a user.

What changed per file

File Shape
account/actions.ts characterIdSchema + parseCharacterId; throws (unreachable input)
admin/access-lists/actions.ts idSchema (z.preprocess + refine) + parseId; throws
admin/accounts/actions.ts eight schemas + assertValid; throws
admin/sync/actions.ts jobTypeSchema = z.custom<JobType>(isJobType); throws
payouts/actions.ts field schemas + buildCreateOperationSchema, returning typed codes as state

parseYmd and battleReportUrlProblem moved to a new src/app/payouts/validation.ts so the schemas and their tests can import them — a "use server" file may only export async functions, so nothing testable can live beside the actions.

createOperationAction

Folded into one schema parsed before appraiseLoot, with the date parse and the future-date check as separate schema steps. date_invalid and date_future stay separately reachable — a single collapsing refinement would have been a regression. URL validation still precedes the appraisal call, so a bad scheme never triggers a network call that gets thrown away. todayUtc is threaded in via buildCreateOperationSchema(todayUtc) rather than read inside the schema, keeping the schema a pure function of its inputs.

Error-code preservation

Codes are read back off the rejected issue (issues[0].message, which is what each schema's error: option puts there) rather than restated at the call site, so each code has exactly one spelling. That makes the error: options load-bearing, which is why they are spelled on every rejecting path: z.number().int().positive({ error }) attaches the code to positive alone, so a non-integer would otherwise throw zod's own "Invalid input: expected int, received number".

Per AGENTS.md item 22, both newly load-bearing options were demonstrated with the fix surgically removed and the assertions untouched:

× setMainAction rejects a non-integer characterId with invalid_character_id
× designateHolderAction rejects a non-numeric characterId with invalid_id
AssertionError: expected [Function] to throw error including 'invalid_character_id' but got 'Invalid input: expected int, received…'
AssertionError: expected [Function] to throw error including 'invalid_id' but got 'Invalid input: expected number, recei…'
Test Files  2 failed (2) | Tests  2 failed | 4 passed (6)

The payouts coverage tests derive each code from a real parse rather than a hand-copied list, so a typo'd error: fails them instead of passing a stale map.

Ordering

Every site parses after its auth guard, not before, so an unauthenticated caller gets the guard's own redirect rather than a validation error telling them whether their argument had the right shape.

Notes for the reviewer

  • Pre-existing, untouched: Number("0x10")16, so a hex spelling of an id is accepted. This is byte-identical to the chain that was there before; changing it would be a behaviour change this PR is not.
  • Invented names. admin/accounts' bound arguments had no prior validation and therefore no prior error string, so invalid_account_id / invalid_tier / invalid_status / invalid_list_search / invalid_identity / invalid_character_id are new. They are thrown, never rendered.
  • Declined: pinning the 14 emit sites with satisfies OperationErrorCode. The invariant it would enforce is already covered by tests that derive each code from a real parse, and adding a type layer across 14 sites is the adjacent cleanup this task ruled out.
  • Out of scope and untouched: the date field on /payouts/[id] (pre-existing gap, deliberately left alone in design sweep 2: work the ranked backlog #214).

Gates

Gate Result
npm run typecheck clean
npm run lint clean
npm run format:check All matched files use Prettier code style!
npm test 99 files / 1579 tests passed (file count matches baseline)
npm run build Compiled successfully
e2e/{payouts,account,admin,access-lists}.spec.ts 290 passed (5.5m), matching baseline

guarzo added 2 commits August 10, 2026 16:52
.coderabbit.yaml states the rule twice — validate input with zod before
touching a service, and parse rather than cast — and no server action
followed it. All five action modules now do.

Behaviour does not move. Every parse failure maps back to the code the
page already renders (`readValidationCode` throws if a schema emits one
the destination's error map lacks), and the two error channels stay as
they were: payouts returns typed codes as state, the other four throw,
matching the posture that page-unreachable input earns no notice copy.

`parseYmd` and `battleReportUrlProblem` move to a new payouts/validation
module because a "use server" file may only export async functions. The
schemas keep every ordering the `if` chains they replace had —
declaration order inside `z.object`, refine order inside a chain — and
`date_invalid` / `date_future` stay separately reachable rather than
collapsing into one refinement. URL validation still precedes the
appraisal call, so a bad scheme costs no network round trip.

Bound arguments are caller-controlled over the wire, so they are parsed
too — after the auth guard, never before, so a caller who fails the
guard learns nothing about the shape of what they sent.
…ed it

Unifies parseId/parseCharacterId onto the convention assertValid already
uses: throw the code taken off the rejected issue rather than restating it
at the call site, so each code has one spelling. Spells the code on every
step a schema can reject through, since `.positive({ error })` attaches it
to `positive` alone and a non-integer would otherwise surface zod's own
wording. Comment corrections throughout.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 27 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 280b886e-8b4a-4d30-b9da-05613f37cfe4

📥 Commits

Reviewing files that changed from the base of the PR and between 099f59e and af1521b.

📒 Files selected for processing (13)
  • src/app/account/actions.ts
  • src/app/admin/access-lists/actions.ts
  • src/app/admin/accounts/actions.ts
  • src/app/admin/sync/actions.ts
  • src/app/payouts/actions.ts
  • src/app/payouts/validation.ts
  • tests/account-actions-validation.test.ts
  • tests/actions-guard-before-validation.test.ts
  • tests/admin-access-lists-actions-validation.test.ts
  • tests/admin-accounts-actions-validation.test.ts
  • tests/admin-accounts-save-note-validation.test.ts
  • tests/admin-sync-actions-validation.test.ts
  • tests/payouts-validation.test.ts

Comment @coderabbitai help to get the list of available commands.

@guarzo
guarzo merged commit ec45211 into main Aug 10, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant