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

239 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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):
```ts
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` `setLocked``protectedProcedure`, 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`:
```ts
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:
```ts
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.