docs(260607-l6l): pre-dispatch plan for write-path correctness bugs
This commit is contained in:
+172
@@ -0,0 +1,172 @@
|
||||
---
|
||||
phase: quick-260607-l6l
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- apps/api/src/routes/events.ts
|
||||
- apps/api/src/routes/me.ts
|
||||
- apps/api/tests/routes/events.test.ts
|
||||
autonomous: true
|
||||
requirements: []
|
||||
must_haves:
|
||||
truths:
|
||||
- "DELETE /api/events/:uid returns 202 (not 503) for an owned event — delete dialog closes."
|
||||
- "PATCH /api/events/:uid/edit returns 202 (not 503) for an owned event."
|
||||
- "A regression test exercises the REAL Drizzle query builder for the edit + delete lookups and fails when the calendars join is absent."
|
||||
- "GET /api/me derives a non-blank displayName from OIDC name/preferred_username/email claims, falling back sensibly."
|
||||
- "GET /api/events returns only events whose calendar is owned by the current user OR is shared."
|
||||
artifacts:
|
||||
- path: "apps/api/src/routes/events.ts"
|
||||
provides: "Joined edit/delete lookups + user/shared-scoped GET filter"
|
||||
- path: "apps/api/src/routes/me.ts"
|
||||
provides: "Robust displayName claim derivation"
|
||||
- path: "apps/api/tests/routes/events.test.ts"
|
||||
provides: "Real-query-builder regression test for the missing-join class of bug"
|
||||
key_links:
|
||||
- from: "events.ts PATCH /:uid/edit + DELETE /:uid lookups"
|
||||
to: "calendars table"
|
||||
via: "innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))"
|
||||
pattern: "innerJoin\\(calendars"
|
||||
- from: "events.ts GET /"
|
||||
to: "current user ownership"
|
||||
via: "WHERE calendars.userId = currentUserId OR calendars.isShared"
|
||||
pattern: "calendars\\.(userId|isShared)"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Fix three confirmed Phase 03 write-path correctness bugs in `apps/api`, each as an atomic commit:
|
||||
|
||||
1. **BLOCKING** — `PATCH /:uid/edit` and `DELETE /:uid` in `events.ts` select `calendars.url` / `calendars.userId` from `.from(calendarEvents)` with no join → invalid SQL → 503. Add the missing `innerJoin(calendars, ...)` to BOTH lookups and add a regression test that runs the real query builder (the existing tests mock `db.select()` and cannot catch this).
|
||||
2. `me.ts` passes the (often-missing) `email` claim as `displayName` and ignores `name` / `preferred_username` → blank legend name. Derive `displayName` robustly from claims.
|
||||
3. `GET /api/events` returns ALL users' events (no ownership predicate). Filter to `calendars.userId = currentUserId OR calendars.isShared = true`.
|
||||
|
||||
Purpose: Unblock event delete/edit (Gate 2), fix the blank calendar-legend name, and make `GET /api/events` correct for the second household member.
|
||||
Output: Corrected `events.ts`, `me.ts`, and a real-query-builder regression test in `events.test.ts`.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@apps/api/src/routes/events.ts
|
||||
@apps/api/src/routes/me.ts
|
||||
@apps/api/src/db/schema.ts
|
||||
@apps/api/src/auth/user.ts
|
||||
@apps/api/tests/routes/events.test.ts
|
||||
@.planning/phases/03-event-write-back-pwa-install/.continue-here.md
|
||||
|
||||
# Reference idioms already in events.ts:
|
||||
# - Working join: GET / at ~line 142-183 uses
|
||||
# .from(calendarEvents).innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
||||
# - Working ownership predicate: /writable-calendars at ~line 501-509 uses
|
||||
# .where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)))
|
||||
</context>
|
||||
|
||||
<constraints>
|
||||
- DO NOT modify `.env` or Authelia config — those are operator actions and `.env` is permission-locked. BUG 2 is a code-only fix (read the right claims robustly); the operator handles any Authelia claim-emission config separately.
|
||||
- DO NOT use playwright-cli (broken in this WSL2 env). Running-app verification is a manual operator browser re-test, noted as a follow-up — not a plan task.
|
||||
- Do not touch ROADMAP.md (quick task).
|
||||
- Keep each task an atomic, self-contained commit.
|
||||
- Match existing idioms in `events.ts` (the working GET join and the writable-calendars ownership predicate) rather than inventing new query shapes.
|
||||
</constraints>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Add missing calendars join to edit + delete lookups, with a real-query-builder regression test</name>
|
||||
<files>apps/api/src/routes/events.ts, apps/api/tests/routes/events.test.ts</files>
|
||||
<behavior>
|
||||
- Regression test (RED before fix, GREEN after): build the EXACT lookup query used by
|
||||
PATCH /:uid/edit and DELETE /:uid against the real Drizzle `db` instance and call
|
||||
`.toSQL()` (no DB connection needed — toSQL does not execute). Assert the generated
|
||||
`sql` string contains an inner join referencing `calendar_events` → `calendars`
|
||||
(e.g. matches /inner join .*calendars/i). The current un-joined query selects
|
||||
`calendars.url`/`calendars.userId` with no join, so its SQL omits the join clause and
|
||||
the assertion FAILS; after the fix it PASSES.
|
||||
- Existing events.test.ts cases (POST/PATCH/DELETE 202 paths) must still pass — they mock
|
||||
`db.select()` and use a `.from().where()` chain. Adding `.innerJoin(...)` before `.where()`
|
||||
means the edit/delete mock chain must now route through innerJoin → where. Update the
|
||||
PATCH and DELETE `beforeEach` blocks so the mocked select chain exposes
|
||||
`.innerJoin(...).where(...)` (mirror the GET-suite chain at the top of the file:
|
||||
mockInnerJoin1Fn / mockWhereFn), returning the seeded mockDbRows. Verify the secondary
|
||||
isShared ownership-fallback `.from(calendars).where(...)` chain still resolves.
|
||||
</behavior>
|
||||
<action>
|
||||
In `apps/api/src/routes/events.ts`, add `.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))` between `.from(calendarEvents)` and `.where(eq(calendarEvents.uid, uid))` in BOTH the `PATCH /:uid/edit` lookup (~line 295) and the `DELETE /:uid` lookup (~line 392). Mirror the working `GET /` join idiom at ~line 153 exactly. Do not change the selected columns or the ownership-check logic that follows.
|
||||
|
||||
In `apps/api/tests/routes/events.test.ts`, add a new `describe` block (e.g. "regression: edit/delete lookups join calendars") that imports the real query builder from `../../src/db/client.js` (NOT the mocked one — use `vi.importActual` or place this test where the db mock does not apply, or construct the query via `drizzle-orm` directly against the real schema with a throwaway driver). Build the edit/delete lookup select with the same columns/from/innerJoin/where as the handler, call `.toSQL()`, and assert `result.sql` matches `/inner join[\s\S]*calendars/i`. The test must FAIL on the un-joined query and PASS after the join is added.
|
||||
|
||||
Then update the PATCH `/:uid/edit` and DELETE `/:uid` describe-block `beforeEach` mocks so the mocked select chain routes `.from(calendarEvents).innerJoin(...).where(...)` to the seeded `mockDbRows` (reuse the GET-suite `mockInnerJoin1Fn` → `mockWhereFn` wiring already defined at the top of the file), and keep the secondary `.from(calendars).where(...)` isShared-fallback chain working.
|
||||
|
||||
Prefer the lightest approach that catches the class of bug: a `.toSQL()` string assertion on the real query builder. Do NOT introduce a new full DB test harness (no SQLite container, no live MariaDB) — investigate the existing mock structure first and reuse it.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd apps/api && npm test -- routes/events.test.ts</automated>
|
||||
</verify>
|
||||
<done>
|
||||
PATCH and DELETE lookups both include `.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))`. The new regression test asserts the generated SQL contains the calendars inner join and passes; it would fail without the join. All pre-existing events.test.ts cases pass. `cd apps/api && npm run typecheck` passes.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Derive a non-blank displayName from OIDC claims in me.ts</name>
|
||||
<files>apps/api/src/routes/me.ts</files>
|
||||
<action>
|
||||
In `apps/api/src/routes/me.ts`, the OIDC path (~lines 51-56) currently reads only `iss`, `sub`, and `email`, then calls `upsertUser(iss, sub, email)` — passing `email` into the parameter that `upsertUser` stores as `display_name` (see `apps/api/src/auth/user.ts` signature `upsertUser(oidcIss, oidcSub, displayName?)`). When the `email` claim is absent the stored display_name is blank.
|
||||
|
||||
Derive a display name robustly from the OIDC claims object returned by `getAuth(c)`, preferring in order: `name`, then `preferred_username`, then `email`, then a sensible fallback derived from `sub` (e.g. `'Member ' + sub` or the local-part if email exists). Each candidate must be read defensively (`typeof claim === 'string' && claim.trim() !== ''`) since claims may be missing or empty. Pass the resolved display name as the third argument to `upsertUser(iss, sub, displayName)`.
|
||||
|
||||
Keep `iss`/`sub` extraction unchanged (identity stays keyed on iss+sub per D-10). Do not change the dev-bypass branch. Do not alter `upsertUser`'s signature or `user.ts`. Add a brief comment noting the claim-preference order and that Authelia-side claim emission is an operator concern (out of scope here).
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd apps/api && npm run typecheck && npm test -- routes/me.test.ts</automated>
|
||||
</verify>
|
||||
<done>
|
||||
me.ts computes displayName via name → preferred_username → email → fallback, never passing an empty string when any claim is present, and passes it to upsertUser. Typecheck passes and the existing me.test.ts cases still pass (dev-bypass + OIDC 401 paths unaffected).
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Scope GET /api/events to the current user's + shared calendars</name>
|
||||
<files>apps/api/src/routes/events.ts</files>
|
||||
<action>
|
||||
In `apps/api/src/routes/events.ts`, the `GET /` handler (~line 118) currently returns ALL users' events with no ownership predicate. Resolve the current user id at the top of the handler using the existing `resolveUserId(c)` helper (already defined ~line 59 and used by the write endpoints); return `c.json({ error: 'Unauthorized' }, 401)` if it is null — match the write-endpoint pattern exactly.
|
||||
|
||||
Add an ownership predicate to the existing `.where(...)` so only events on calendars owned by the current user OR shared calendars are returned. Combine the new ownership filter with the existing date-window `or(...)` block using `and(...)`, i.e. effectively `and(<existing window or-block>, or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)))`. Mirror the authoritative ownership idiom already used by `/writable-calendars` (~line 509). Do not change the window-span guard, the join chain, or the expansion logic.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd apps/api && npm run typecheck && npm test -- routes/events.test.ts</automated>
|
||||
</verify>
|
||||
<done>
|
||||
GET /api/events resolves the current user, returns 401 when unauthenticated, and its WHERE clause restricts results to `calendars.userId = currentUserId OR calendars.isShared = true` combined (AND) with the existing date-window predicate. Typecheck passes; existing GET tests pass (update the GET-suite mocks if the added resolveUserId call or predicate changes the chain — keep them green).
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- `cd apps/api && npm run typecheck` passes.
|
||||
- `cd apps/api && npm test` passes (full suite), including the new join regression test.
|
||||
- The regression test in events.test.ts fails if the calendars innerJoin is removed from the edit/delete lookups (verify by temporarily removing one join during development, then restoring).
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- BUG 1: Both edit and delete lookups innerJoin calendars; a real-query-builder regression test guards against the missing-join regression.
|
||||
- BUG 2: me.ts derives a non-blank displayName from OIDC claims (name → preferred_username → email → fallback).
|
||||
- BUG 3: GET /api/events filters to current-user-owned OR shared calendars.
|
||||
- Full test suite + typecheck green.
|
||||
- Each fix landed as a separate atomic commit.
|
||||
</success_criteria>
|
||||
|
||||
<manual_follow_up>
|
||||
NOT a plan task — operator action after merge:
|
||||
- Re-test in a real browser through the tunnel: delete an event (dialog should close, event disappears), edit an event, and confirm the calendar legend shows the member's name (BUG 2 may additionally require Authelia to emit the `name`/`email` claim — operator's call; the code now reads whatever claims are present).
|
||||
- playwright-cli is intentionally NOT used (broken in this WSL2 env).
|
||||
</manual_follow_up>
|
||||
|
||||
<output>
|
||||
Create `.planning/quick/260607-l6l-fix-phase-03-write-path-correctness-bugs/260607-l6l-SUMMARY.md` when done.
|
||||
</output>
|
||||
Reference in New Issue
Block a user