mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-21 14:01:42 +10:00
239 lines
10 KiB
Markdown
239 lines
10 KiB
Markdown
# 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` (~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):
|
||
|
||
```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` ~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.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.
|