Files
familysync/.planning/milestones/v1.1-phases/10-admin-role-settings/10-PATTERNS.md
T
2026-06-18 22:21:38 -04:00

711 lines
23 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.
# Phase 10: Admin Role & Settings - Pattern Map
**Mapped:** 2026-06-13
**Files analyzed:** 14 new/modified files
**Analogs found:** 13 / 14
---
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `apps/api/src/db/schema.ts` | model | CRUD | self (existing schema.ts) | exact — extend in place |
| `apps/api/src/db/migrations/0001_v1_1_foundation.sql` | config | batch | `0000_baseline.sql` | exact |
| `apps/api/src/routes/admin.ts` (NEW) | controller | request-response | `apps/api/src/routes/push.ts` | role-match |
| `apps/api/src/lib/requireAdmin.ts` (NEW) | middleware | request-response | `apps/api/src/auth/devBypass.ts` | role-match |
| `apps/api/src/index.ts` | config | request-response | self (existing index.ts) | exact — extend in place |
| `apps/api/src/routes/me.ts` | controller | request-response | self (existing me.ts) | exact — extend in place |
| `apps/api/src/auth/user.ts` | service | CRUD | self (existing user.ts) | exact — extend in place |
| `apps/api/src/broker/crypto.ts` | utility | transform | — | reuse only, no changes |
| `apps/api/src/broker/client.ts` | utility | request-response | — | reuse only, no changes |
| `apps/api/src/broker/outboxWorker.ts` | service | event-driven | — | reuse `loadClientForUser` / `triggerTargetedResync` (promote to export) |
| `apps/pwa/src/App.tsx` | component | request-response | self (existing App.tsx) | exact — extend in place |
| `apps/pwa/src/api/client.ts` | utility | request-response | self (existing client.ts) | exact — extend in place |
| `apps/pwa/src/routes/AdminPage.tsx` (NEW) | component | request-response | `apps/pwa/src/routes/ListsIndex.tsx` | role-match |
| `apps/pwa/src/components/CredentialSheet.tsx` (NEW) | component | request-response | `apps/pwa/src/components/SettingsSheet.tsx` | exact |
| `apps/pwa/src/components/SetupBanner.tsx` (NEW) | component | event-driven | `apps/pwa/src/components/PermissionDeniedBanner.tsx` | role-match |
---
## Pattern Assignments
### `apps/api/src/db/schema.ts` — add columns + new table
**Analog:** self — extend in place.
**Existing import pattern** (lines 114):
```typescript
import {
mysqlTable,
mysqlEnum,
varchar,
text,
int,
date,
timestamp,
boolean,
index,
unique,
customType,
} from 'drizzle-orm/mysql-core';
```
**Existing column patterns to copy for new columns:**
`boolean` with NOT NULL DEFAULT false — copy from `calendarEvents.allDay` (line 127):
```typescript
allDay: boolean('all_day').default(false).notNull(),
```
`varchar` with length + notNull + default — copy from `memberCredentials.fastmailEmail` (line 64):
```typescript
fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(),
```
`int` nullable — copy from `calendarEvents.dtstartUtc` (line 125) but use `int`:
```typescript
dtstartUtc: timestamp('dtstart_utc'), // nullable = no .notNull()
```
**New `app_config` table — follow `pushSubscriptions` single-table pattern** (lines 236257):
```typescript
export const pushSubscriptions = mysqlTable(
'push_subscriptions',
{
id: int().primaryKey().autoincrement(),
userId: int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
endpoint: varchar('endpoint', { length: 2048 }).notNull(),
...
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
},
(t) => [
unique('uniq_push_endpoint').on(t.endpoint),
index('idx_push_subscriptions_user_id').on(t.userId),
],
);
```
**`memberCredentials` `unique` constraint pattern** — copy from `calendars` (lines 91100):
```typescript
unique('uniq_calendar_user_url').on(t.userId, t.url),
```
Apply as `unique('uniq_member_credential_user').on(t.userId)` to enforce one-credential-per-member and enable `onDuplicateKeyUpdate`.
**Changes to make:**
1. `users` table: add `isAdmin: boolean('is_admin').default(false).notNull()`
2. `memberCredentials` table: add `providerType: varchar('provider_type', { length: 64 }).notNull().default('caldav')` + add `unique('uniq_member_credential_user').on(t.userId)` to the index array
3. `calendarEvents` table: add `reminderLeadMinutes: int('reminder_lead_minutes')` (nullable — no `.notNull()`)
4. New `appConfig` table: `key VARCHAR PK, value TEXT, updatedAt timestamp`
---
### `apps/api/src/db/migrations/0001_v1_1_foundation.sql` (NEW, generated)
**Analog:** `apps/api/src/db/migrations/0000_baseline.sql` lines 116.
**Migration file format** — each DDL statement separated by `--> statement-breakpoint`:
```sql
ALTER TABLE `users` ADD COLUMN `is_admin` boolean NOT NULL DEFAULT false;
--> statement-breakpoint
ALTER TABLE `member_credentials` ADD COLUMN `provider_type` varchar(64) NOT NULL DEFAULT 'caldav';
--> statement-breakpoint
ALTER TABLE `member_credentials` ADD UNIQUE `uniq_member_credential_user`(`user_id`);
--> statement-breakpoint
ALTER TABLE `calendar_events` ADD COLUMN `reminder_lead_minutes` int;
--> statement-breakpoint
CREATE TABLE `app_config` ( ... );
```
**Do not hand-write.** Run `pnpm --filter @familysync/api db:generate` after editing schema.ts; the file is generated automatically. Commit the output.
---
### `apps/api/src/routes/admin.ts` (NEW) — admin sub-router
**Analog:** `apps/api/src/routes/push.ts` (closest: Hono sub-router + zValidator + resolveUserId pattern)
**Imports pattern** — copy from `push.ts` lines 1526, substitute admin-specific imports:
```typescript
import { Hono } from 'hono';
import type { Context, MiddlewareHandler } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { eq, and } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users, memberCredentials, calendars } from '../db/schema.js';
import { encryptPassword } from '../broker/crypto.js';
import { createFastmailClient } from '../broker/client.js';
import { requireAdmin } from '../lib/requireAdmin.js';
// Side-effect import for ContextVariableMap augmentation
import '../auth/devBypass.js';
```
**Router + guard pattern** (Pitfall 9 — guard FIRST inside the sub-router):
```typescript
export const adminRouter = new Hono();
adminRouter.use('*', requireAdmin); // ← MUST be first; guards every sub-route
```
**zValidator with no-echo hook** (Pitfall 7) — adapt from `push.ts` lines 6067 (subscribeSchema):
```typescript
const credentialSchema = z.object({
userId: z.number().int().positive(),
providerType: z.literal('caldav'),
fastmailEmail: z.string().email().max(256),
appPassword: z.string().min(1).max(500),
});
// Hook MUST never echo Zod issues (which contain .received = the password value)
const noEchoHook = (result: { success: boolean }, c: Context) => {
if (!result.success) return c.json({ error: 'Invalid request' }, 400);
};
adminRouter.post(
'/credentials',
zValidator('json', credentialSchema, noEchoHook),
async (c) => {
const { userId, fastmailEmail, appPassword } = c.req.valid('json');
// NEVER log appPassword or c.req.valid('json')
// validate → encrypt → upsert → trigger-sync
},
);
```
**Drizzle SELECT pattern** — copy from `events.ts` lines 165177 (join + where):
```typescript
const rows = await db
.select({ ... })
.from(users)
.leftJoin(memberCredentials, eq(memberCredentials.userId, users.id))
.where(/* ... */);
```
**Drizzle upsert pattern** — copy from `events.ts` `onDuplicateKeyUpdate` usage (found in outboxWorker):
```typescript
await db.insert(memberCredentials)
.values({ userId, encryptedPassword: encrypted, fastmailEmail, providerType: 'caldav' })
.onDuplicateKeyUpdate({ set: { encryptedPassword: encrypted, fastmailEmail, providerType: 'caldav' } });
// Requires UNIQUE(user_id) added by v1.1 migration
```
**Exclusive is_shared update** — two sequential Drizzle UPDATEs (RESEARCH.md Pattern 7):
```typescript
await db.update(calendars).set({ isShared: false }).where(eq(calendars.isShared, true));
await db.update(calendars).set({ isShared: true }).where(eq(calendars.id, targetId));
```
---
### `apps/api/src/lib/requireAdmin.ts` (NEW) — role middleware
**Analog:** `apps/api/src/auth/devBypass.ts` (MiddlewareHandler pattern)
**Import + type pattern** (devBypass.ts lines 2728):
```typescript
import type { MiddlewareHandler } from 'hono';
```
**MiddlewareHandler signature** (devBypass.ts lines 5876):
```typescript
export function devAuthBypass(): MiddlewareHandler {
return async (c, next) => {
c.set('user', DEV_USER);
await next();
};
}
```
**requireAdmin must be an inline `MiddlewareHandler`**, not a factory function (applied as `.use('*', requireAdmin)`):
```typescript
import type { MiddlewareHandler } from 'hono';
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users } from '../db/schema.js';
export const requireAdmin: MiddlewareHandler = async (c, next) => {
const devUser = c.get('user') as { id: number } | undefined;
const userId = devUser?.id;
if (!userId) return c.json({ error: 'Forbidden' }, 403);
// Always look up is_admin from DB — bypass only skips OIDC, not the DB check
const [row] = await db
.select({ isAdmin: users.isAdmin })
.from(users)
.where(eq(users.id, userId))
.limit(1);
if (!row?.isAdmin) return c.json({ error: 'Forbidden' }, 403);
await next();
};
```
**ContextVariableMap augmentation** — include side-effect import from devBypass.ts (line 39):
```typescript
import '../auth/devBypass.js';
```
---
### `apps/api/src/index.ts` — mount adminRouter
**Analog:** self — extend in place.
**Existing route mounting pattern** (lines 6772):
```typescript
app.route('/api/me', meRouter);
app.route('/api/events', eventsRouter);
app.route('/api/lists', listsRouter);
app.route('/api/list-items', listItemsRouter);
app.route('/api/push', pushRouter);
```
**Add after the existing route block** (same style, behind the existing devAuthBypass → oidcAuthMiddleware band already covering `/api/*`):
```typescript
import { adminRouter } from './routes/admin.js';
// ...
app.route('/api/admin', adminRouter);
```
No additional middleware needed at the `app` level — `requireAdmin` is applied inside `adminRouter` itself (Pitfall 9).
---
### `apps/api/src/routes/me.ts` — add isAdmin + needsProviderSetup
**Analog:** self — extend in place.
**Current response shape** (lines 3043 dev-bypass path, lines 6673 OIDC path):
```typescript
return c.json({
user: {
id: devUser.id,
displayName: devUser.displayName,
color: devUser.color,
},
});
```
**Pattern:** Both paths (dev-bypass + OIDC) must add `isAdmin` and `needsProviderSetup`. The dev-bypass path currently short-circuits WITHOUT a DB lookup — for `isAdmin` it MUST query the DB for user id 1 (same as requireAdmin). `needsProviderSetup` requires a COUNT/EXISTS on `memberCredentials` for the current user id.
**DB import additions needed:**
```typescript
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users, memberCredentials } from '../db/schema.js';
```
**needsProviderSetup lookup pattern** — copy Drizzle `.select().from().where().limit(1)` pattern from user.ts lines 7982:
```typescript
const [cred] = await db
.select({ id: memberCredentials.id })
.from(memberCredentials)
.where(eq(memberCredentials.userId, userId))
.limit(1);
const needsProviderSetup = !cred;
```
---
### `apps/api/src/auth/user.ts` — first-login-wins is_admin bootstrap
**Analog:** self — extend in place.
**Insert block** (lines 112122) — add `isAdmin` to the `.values({...})` call:
```typescript
// Before INSERT: check if zero admins exist (first-login-wins, D-01)
const [{ count }] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(users)
.where(eq(users.isAdmin, true));
const shouldBeAdmin = Number(count) === 0;
const [inserted] = await db
.insert(users)
.values({
oidcIss,
oidcSub,
displayName: displayName ?? null,
color,
isAdmin: shouldBeAdmin, // ← new
})
.$returningId();
```
**Import additions needed:**
```typescript
import { sql } from 'drizzle-orm';
```
---
### `apps/api/src/broker/outboxWorker.ts` — promote triggerTargetedResync
**Analog:** self — promote private function to export.
**Current private function signature** (lines 302348):
```typescript
async function triggerTargetedResync(
calendarUrl: string,
userId: number,
clientCache?: Map<number, FastmailClient>,
): Promise<void>
```
**Change:** add `export` keyword. Admin routes (and member self-service) will import and call it after credential upsert.
**Also export `loadClientForUser`** (lines 271288) — needed for the initial full per-member sync (no known `calendarUrl` after first credential save):
```typescript
export async function loadClientForUser(userId: number): Promise<FastmailClient>
```
For the post-credential-save full sync (no specific `calendarUrl`), the admin route calls `loadClientForUser`, then `client.fetchCalendars()`, iterates each `davCal`, and calls `syncCalendar` for each — mirroring what the poller does per member.
---
### `apps/pwa/src/App.tsx` — add /admin route
**Analog:** self — extend in place.
**Existing Routes block** (lines 121127):
```typescript
<Routes>
<Route path="/" element={<Navigate to="/calendar" replace />} />
<Route path="/calendar" element={<CalendarShell />} />
<Route path="/lists" element={<ListsIndex />} />
<Route path="/lists/:listId" element={<ListDetail />} />
</Routes>
```
**Add `/admin` route** with inline redirect guard:
```typescript
import { AdminPage } from './routes/AdminPage.js';
// ...
<Route
path="/admin"
element={
meQuery.data?.user.isAdmin
? <AdminPage />
: <Navigate to="/calendar" replace />
}
/>
```
**meQuery consumption pattern** (lines 6368):
```typescript
const meQuery = useQuery({
queryKey: ['me'],
queryFn: fetchMe,
retry: false,
staleTime: 5 * 60 * 1000,
});
```
The `isAdmin` guard on the route uses `meQuery.data?.user.isAdmin` — while `meQuery` is loading, `isAdmin` is `undefined` (falsy), so the route redirects. Add a loading gate if flash-of-redirect is a concern (planner's call).
---
### `apps/pwa/src/api/client.ts` — add isAdmin + needsProviderSetup to MeUser
**Analog:** self — extend in place.
**Current MeUser interface** (lines 6266):
```typescript
export interface MeUser {
id: number;
displayName: string | null;
color: string;
}
```
**Add fields:**
```typescript
export interface MeUser {
id: number;
displayName: string | null;
color: string;
isAdmin: boolean; // from users.is_admin
needsProviderSetup: boolean; // true when no member_credentials row exists
}
```
**API fetch functions pattern for admin routes** — copy from `createEvent` (lines 221233):
```typescript
export async function createEvent(payload: CreateEventPayload): Promise<CreateEventResponse> {
const res = await fetch('/api/events/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
redirect: 'manual',
body: JSON.stringify(payload),
});
handleAuthResponse(res, 'POST /api/events/create');
return res.json() as Promise<CreateEventResponse>;
}
```
Apply same pattern for `fetchAdminMembers`, `saveCredential`, `fetchAdminCalendars`, `setSharedCalendar`, `saveMyCredential`.
---
### `apps/pwa/src/routes/AdminPage.tsx` (NEW) — /admin page shell
**Analog:** `apps/pwa/src/routes/ListsIndex.tsx` (page-level component with TanStack Query + sections)
**Page structure pattern** — copy AppNav/content layout from `App.tsx` content area style (lines 99102):
```typescript
const contentStyle: React.CSSProperties = {
flex: 1,
minWidth: 0,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
position: 'relative',
};
```
**TanStack Query fetch pattern** — copy from App.tsx meQuery (lines 6368); admin page will add its own queries for members and calendars:
```typescript
const membersQuery = useQuery({
queryKey: ['admin', 'members'],
queryFn: fetchAdminMembers,
retry: false,
});
```
**Section label style** — per UI-SPEC, copy the pattern from `SettingsSheet.tsx` section headers:
```typescript
// 13px / weight 600 / var(--color-text-muted) / uppercase / letterSpacing 0.06em
{
fontSize: 'var(--text-label-size)',
fontWeight: 600,
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.06em',
marginBottom: 'var(--space-2)',
}
```
---
### `apps/pwa/src/components/CredentialSheet.tsx` (NEW) — credential bottom sheet
**Analog:** `apps/pwa/src/components/SettingsSheet.tsx` (closest exact match: bottom sheet pattern, role="dialog", Escape key, focus management)
**Bottom sheet structural pattern** (SettingsSheet.tsx lines 5276):
```typescript
export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!isOpen) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [isOpen, onClose]);
useEffect(() => {
if (isOpen && closeButtonRef.current) {
closeButtonRef.current.focus();
}
}, [isOpen]);
// ...
}
```
**Sheet container style** (apply zIndex 301, backdrop 300, borderRadius 12px 12px 0 0 — matching SettingsSheet):
```typescript
// Backdrop
{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 300 }
// Sheet
{ position: 'fixed', bottom: 0, left: 0, right: 0, background: 'var(--color-surface)',
borderRadius: '12px 12px 0 0', padding: 'var(--space-6)', zIndex: 301 }
```
**ARIA pattern:**
```tsx
<div role="dialog" aria-modal="true" aria-label="Rotate Credential">
```
**Password input pattern** (UI-SPEC — never pre-filled, `type="password"`, `autocomplete="new-password"`):
```tsx
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{ /* ... full-width, border, borderRadius, fontSize */ }}
/>
```
**TanStack Query mutation pattern** — copy from PWA list mutation (useMutation with onSuccess invalidation):
```typescript
const credentialMutation = useMutation({
mutationFn: saveCredential,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'members'] });
queryClient.invalidateQueries({ queryKey: ['me'] }); // needsProviderSetup refresh
onClose();
},
});
```
---
### `apps/pwa/src/components/SetupBanner.tsx` (NEW) — needsProviderSetup banner
**Analog:** `apps/pwa/src/components/PermissionDeniedBanner.tsx` (conditional banner rendered from App.tsx level)
**Pattern:** renders only when `meQuery.data?.user.needsProviderSetup === true`. No dismiss button per UI-SPEC — disappears when `needsProviderSetup` becomes false after save.
**Banner style** (UI-SPEC Surface 4):
```typescript
{
background: 'var(--color-surface-dim)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--space-2)',
padding: 'var(--space-4)',
margin: 'var(--space-4)',
}
```
**`role="status"` for live announcement:**
```tsx
<div role="status" aria-live="polite">
{/* KeyRound icon + heading + body + CTA */}
</div>
```
---
## Shared Patterns
### resolveUserId — auth helper per router
**Source:** `apps/api/src/routes/push.ts` lines 3749 (canonical copy in use across push, events, lists routers)
**Apply to:** `apps/api/src/routes/admin.ts` (member self-service endpoint on `/api/me/credential` added to meRouter)
```typescript
async function resolveUserId(c: Context): Promise<number | null> {
const devUser = c.get('user') as { id: number } | undefined;
if (devUser) return devUser.id;
const auth = await getAuth(c);
if (!auth) return null;
const iss = (auth.iss as string | undefined) ?? '';
const sub = auth.sub ?? '';
const displayName = deriveDisplayName(auth);
const user = await upsertUser(iss, sub, displayName);
return user?.id ?? null;
}
```
### Hono sub-router mounting
**Source:** `apps/api/src/index.ts` lines 6772
```typescript
app.route('/api/admin', adminRouter);
```
**Apply to:** index.ts — adminRouter added to the existing route block (after the auth guards already cover `/api/*`).
### Zod + zValidator (no hook = safe for non-credential fields)
**Source:** `apps/api/src/routes/events.ts` lines 2728 + 137
```typescript
import { zValidator } from '@hono/zod-validator';
// Usage:
eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => { ... });
```
**Apply to:** non-credential admin routes (GET /members, GET /calendars, PUT /calendars/:id/shared).
**For credential routes only — add the no-echo hook** (RESEARCH.md Pattern 2). Never return `result.error` directly for any route that accepts `appPassword`.
### ContextVariableMap side-effect import
**Source:** every route file (push.ts line 25, events.ts line 39, me.ts line 26)
```typescript
import '../auth/devBypass.js';
```
**Apply to:** `apps/api/src/routes/admin.ts` and `apps/api/src/lib/requireAdmin.ts`.
### NavLink + Lucide icon (nav entry)
**Source:** `apps/pwa/src/components/AppNav.tsx` lines 1415 + `BottomTabBar.tsx` lines 7698
```typescript
import { NavLink } from 'react-router';
import { CalendarDays, List } from 'lucide-react';
// NavLink usage:
<NavLink to="/calendar" aria-label="Calendar" style={({ isActive }) => ({
...tabBase, ...(isActive ? tabActiveOverride : {}),
})}>
<CalendarDays size={22} aria-hidden="true" />
<span>Calendar</span>
</NavLink>
```
**Apply to:** `AppNav.tsx` (DesktopNav section) and `BottomTabBar.tsx` — add Admin entry with `ShieldCheck` icon (size 18/22), conditional on `isAdmin === true`.
### CSS token inline style pattern
**Source:** `apps/pwa/src/components/BottomTabBar.tsx` lines 2745
```typescript
const tabBase: React.CSSProperties = {
fontSize: 'var(--text-label-size, 13px)',
color: 'var(--color-text-muted)',
fontFamily: 'var(--font-family-base)',
minHeight: '44px',
};
```
**Apply to:** all new PWA components (AdminPage, CredentialSheet, SetupBanner). No hard-coded px except the 44px touch-target minimum. All color/typography/spacing references through `var(--token)`.
### `handleAuthResponse` + `redirect: 'manual'` in fetch
**Source:** `apps/pwa/src/api/client.ts` lines 5158 + 8083
```typescript
function handleAuthResponse(res: Response, label: string): void {
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
if (!res.ok) throw new Error(`${label} failed: ${res.status}`);
}
// Usage:
const res = await fetch('/api/admin/members', { credentials: 'include', redirect: 'manual' });
handleAuthResponse(res, 'GET /api/admin/members');
```
**Apply to:** all new `client.ts` fetch functions for admin and me/credential endpoints.
---
## No Analog Found
| File | Role | Data Flow | Reason |
|---|---|---|---|
| (none) | — | — | All files have close analogs in the existing codebase |
---
## Analog Search Scope
- `apps/api/src/routes/` — all route files
- `apps/api/src/auth/` — devBypass.ts, user.ts, middleware.ts
- `apps/api/src/broker/` — crypto.ts, client.ts, outboxWorker.ts
- `apps/api/src/db/` — schema.ts, migrations/
- `apps/pwa/src/` — App.tsx, api/client.ts, components/, routes/
**Files scanned:** 15 source files read directly.
**Pattern extraction date:** 2026-06-13