Files
Reactive-Resume/plans/003-api-quick-fixes.md
T

10 KiB
Raw Blame History

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.ts If 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:

  1. Silent-success mutations. setLocked, setPassword, and removePassword in packages/api/src/features/resume/service.ts run an UPDATE ... WHERE id = ? AND userId = ? RETURNING, and when no row matches they do if (!resume) return; — returning HTTP 200 success. Every other mutation in this file (update, delete) throws ORPCError("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 (the WHERE includes userId), so this is not an auth bypass — it's a misleading false-success that hides not-found/not-owned.

  2. Unbounded bulk inputs. bulkUpdate and bulkDelete DTOs in packages/api/src/dto/application.ts accept ids: 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 large ids array is unbounded memory/DB work. A .max() cap is a one-line defensive fix.

Current state

Part A — silent-success (resume/service.ts)

setLocked (~663679), setPassword (~681699), removePassword (~726742) 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:187 setLockedprotectedProcedure, does not currently declare a NOT_FOUND error in .errors({...}).
  • sharing.ts:29 setPassword, sharing.ts:80 removePassword — 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 ~340381, bulkDelete ~383396; 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.ts if 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.ts routers — no change needed (see Current state).
  • The applications service body — a .max() on input is sufficient; do not also rewrite the in-memory bulkDelete scan 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 resume and fix(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.ts exists): update its setLocked / setPassword / removePassword "not-found path" cases to now assert the method rejects with NOT_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.ts covering just these three methods' not-found → NOT_FOUND behavior and success path, using packages/api/src/features/applications/service.test.ts as 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_FOUND on no-match (updated or new cases in resume/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.ts returns no matches
  • grep -c "throw new ORPCError(\"NOT_FOUND\")" packages/api/src/features/resume/service.ts is ≥ 5 (update, delete, + the 3 new)
  • grep -n ".max(" packages/api/src/dto/application.ts shows the cap on both bulk inputs
  • pnpm --filter @reactive-resume/api typecheck exits 0
  • pnpm --filter @reactive-resume/api test exits 0
  • git status --porcelain lists only in-scope files + plans/README.md
  • plans/README.md status 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_FOUND breaks 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 for bulkDelete'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.