10 KiB
Plan 003: Fix silent-success mutations and bound bulk-operation inputs
Executor instructions: Follow step by step. Run every verification command and confirm the expected result before moving on. Honor "STOP conditions". When done, update the status row in
plans/README.md.Drift check (run first):
git diff --stat 73daf22b2..HEAD -- packages/api/src/features/resume/service.ts packages/api/src/dto/application.ts packages/api/src/features/applications/service.tsIf any changed since this plan was written, compare "Current state" excerpts against live code; on mismatch, STOP.
Status
- Priority: P1
- Effort: S
- Risk: LOW
- Depends on: 001 (its Step 3 tests characterize the current silent-return behavior this plan changes — update them here). Not a hard blocker, but if 001 is DONE you must update its assertions in the same PR.
- Category: bug
- Planned at: commit
73daf22b2, 2026-07-08
Why this matters
Two small, independent correctness/robustness fixes on the API layer:
-
Silent-success mutations.
setLocked,setPassword, andremovePasswordinpackages/api/src/features/resume/service.tsrun anUPDATE ... WHERE id = ? AND userId = ? RETURNING, and when no row matches they doif (!resume) return;— returning HTTP 200 success. Every other mutation in this file (update,delete) throwsORPCError("NOT_FOUND")in the same situation. The inconsistency means a client calling these on a resume that doesn't exist (or isn't theirs) is told it succeeded, so the UI updates its state as if the lock/password change took effect. Ownership is still enforced (theWHEREincludesuserId), so this is not an auth bypass — it's a misleading false-success that hides not-found/not-owned. -
Unbounded bulk inputs.
bulkUpdateandbulkDeleteDTOs inpackages/api/src/dto/application.tsacceptids: z.array(z.string()).min(1)with no.max().bulkDelete's service loads all target rows into memory before deleting. A single API call with a very largeidsarray is unbounded memory/DB work. A.max()cap is a one-line defensive fix.
Current state
Part A — silent-success (resume/service.ts)
setLocked (~663–679), setPassword (~681–699), removePassword (~726–742)
each look like this (setLocked shown; the other two differ only in the .set
and the mutation label):
setLocked: async (input: { id: string; userId: string; isLocked: boolean }) => {
const [resume] = await db
.update(schema.resume)
.set({ isLocked: input.isLocked })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
.returning({ id: schema.resume.id, updatedAt: schema.resume.updatedAt });
if (!resume) return; // <-- silent no-op; should throw NOT_FOUND
await notifyResumeUpdated({ /* ... mutation: "lock" ... */ });
},
The reference behavior to match is update (~603) and delete (~751):
if (!resume) throw new ORPCError("NOT_FOUND");. ORPCError is already imported
at the top of service.ts (used throughout).
The routers that call these (for context; do not change them):
crud.ts:187setLocked—protectedProcedure, does not currently declare aNOT_FOUNDerror in.errors({...}).sharing.ts:29setPassword,sharing.ts:80removePassword— likewise.
oRPC will surface a thrown ORPCError("NOT_FOUND") as a 404 regardless of
whether it's declared in .errors(), matching how update/delete already
behave (they throw the same without special router declarations). So no router
change is required.
Part B — unbounded bulk inputs (dto/application.ts)
packages/api/src/dto/application.ts:163-176:
bulkUpdate: {
input: z.object({
ids: z.array(z.string()).min(1),
status: applicationStatusSchema.optional(),
archived: z.boolean().optional(),
addTags: z.array(z.string()).optional(),
}),
output: z.object({ updated: z.number() }),
},
bulkDelete: {
input: z.object({ ids: z.array(z.string()).min(1) }),
output: z.object({ deleted: z.number() }),
},
The consuming service is packages/api/src/features/applications/service.ts
(bulkUpdate ~340–381, bulkDelete ~383–396; bulkDelete fetches all target
rows into memory before deleting).
Commands you will need
| Purpose | Command | Expected |
|---|---|---|
| Typecheck | pnpm --filter @reactive-resume/api typecheck |
exit 0 |
| Test A | pnpm --filter @reactive-resume/api test -- resume/service.test.ts |
all pass |
| Test B | pnpm --filter @reactive-resume/api test -- applications |
all pass |
| All api | pnpm --filter @reactive-resume/api test |
all pass |
(Do NOT run pnpm check.)
Scope
In scope:
packages/api/src/features/resume/service.ts(Part A)packages/api/src/features/resume/service.test.ts(update if 001 is DONE; else the tests may not exist yet — see Step 3)packages/api/src/dto/application.ts(Part B)packages/api/src/dto/application.test.tsif one exists, else add a focused test near the applications service tests (Step 4)plans/README.md(status row)
Out of scope:
crud.ts/sharing.tsrouters — no change needed (see Current state).- The applications service body — a
.max()on input is sufficient; do not also rewrite the in-memorybulkDeletescan in this plan (that's a separate perf concern tracked elsewhere). - Any other service method.
Git workflow
- Branch:
advisor/003-api-quick-fixes - Commit style: conventional commits, e.g.
fix(api): throw NOT_FOUND on lock/password mutations for missing resumeandfix(api): cap bulk application operation id arrays. - Do NOT push or open a PR unless instructed.
Steps
Step 1: Throw NOT_FOUND in the three mutations
In resume/service.ts, change if (!resume) return; to
if (!resume) throw new ORPCError("NOT_FOUND"); in all three of setLocked,
setPassword, removePassword. Match the exact form used by update/delete
in the same file.
Verify: pnpm --filter @reactive-resume/api typecheck → exit 0.
Step 2: Cap the bulk id arrays
In dto/application.ts, add .max(200, "Too many items in a single bulk operation") to the ids array in both bulkUpdate and bulkDelete
inputs (i.e. z.array(z.string()).min(1).max(200)). 200 comfortably exceeds
the UI's page size (~25) while bounding abuse; if a comment nearby documents a
different intended cap, use that number and note it.
Verify: pnpm --filter @reactive-resume/api typecheck → exit 0.
Step 3: Update / add tests for Part A
- If Plan 001 is DONE (
resume/service.test.tsexists): update itssetLocked/setPassword/removePassword"not-found path" cases to now assert the method rejects withNOT_FOUND(previously they asserted a silent resolve). This test diff is the intended proof the behavior changed. - If Plan 001 is NOT done (no test file): add a minimal
resume/service.test.tscovering just these three methods' not-found →NOT_FOUNDbehavior and success path, usingpackages/api/src/features/applications/service.test.tsas the mock pattern.
Verify: pnpm --filter @reactive-resume/api test -- resume/service.test.ts
→ all pass; the three not-found cases assert NOT_FOUND.
Step 4: Test for Part B
Add a small test that bulkUpdate/bulkDelete input schemas reject an ids
array longer than the cap and accept one at the cap. If
packages/api/src/dto/application.test.ts exists, add there; otherwise create
it. Example shape:
import { describe, expect, it } from "vitest";
import { applicationDto } from "./application"; // confirm the real export name
it("rejects oversized bulk id arrays", () => {
const ids = Array.from({ length: 201 }, (_, i) => String(i));
expect(applicationDto.bulkDelete.input.safeParse({ ids }).success).toBe(false);
});
Confirm the actual export name/shape by reading the top and bottom of
dto/application.ts before writing the import.
Verify: pnpm --filter @reactive-resume/api test -- applications and
pnpm --filter @reactive-resume/api test → all pass.
Test plan
- Part A: three methods now reject with
NOT_FOUNDon no-match (updated or new cases inresume/service.test.ts). - Part B: bulk input schemas reject arrays over the cap, accept at the cap.
- Verification:
pnpm --filter @reactive-resume/api test→ all pass.
Done criteria
Machine-checkable. ALL must hold:
grep -n "if (!resume) return;" packages/api/src/features/resume/service.tsreturns no matchesgrep -c "throw new ORPCError(\"NOT_FOUND\")" packages/api/src/features/resume/service.tsis ≥ 5 (update, delete, + the 3 new)grep -n ".max(" packages/api/src/dto/application.tsshows the cap on both bulk inputspnpm --filter @reactive-resume/api typecheckexits 0pnpm --filter @reactive-resume/api testexits 0git status --porcelainlists only in-scope files +plans/README.mdplans/README.mdstatus row for 003 updated
STOP conditions
Stop and report if:
- The three methods no longer contain
if (!resume) return;(already fixed by someone else, or drifted) — reconcile against live code before editing. - Throwing
NOT_FOUNDbreaks an e2e or unit test that depended on the silent success (a client that fires these against unknown ids and expects 200) — report the caller; do not revert the fix without confirming intent. - The applications DTO export name differs from
applicationDto— read the file and use the real name; do not guess.
Maintenance notes
- The
.max(200)cap is an input guard, not a fix forbulkDelete's O(n) in-memory scan — that remains a known, separate perf item. If bulk operations ever need to exceed the cap, revisit both the cap and the scan together. - If the routers later add explicit
.errors({ NOT_FOUND: ... })declarations for these procedures (for nicer OpenAPI docs), that's compatible with this change but not required by it.