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

23 KiB
Raw Blame History

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):

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):

allDay: boolean('all_day').default(false).notNull(),

varchar with length + notNull + default — copy from memberCredentials.fastmailEmail (line 64):

fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(),

int nullable — copy from calendarEvents.dtstartUtc (line 125) but use int:

dtstartUtc: timestamp('dtstart_utc'),   // nullable = no .notNull()

New app_config table — follow pushSubscriptions single-table pattern (lines 236257):

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):

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:

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:

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):

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):

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):

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):

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):

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):

import type { MiddlewareHandler } from 'hono';

MiddlewareHandler signature (devBypass.ts lines 5876):

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)):

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):

import '../auth/devBypass.js';

apps/api/src/index.ts — mount adminRouter

Analog: self — extend in place.

Existing route mounting pattern (lines 6772):

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/*):

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):

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:

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:

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:

// 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:

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):

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):

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):

<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:

import { AdminPage } from './routes/AdminPage.js';
// ...
<Route
  path="/admin"
  element={
    meQuery.data?.user.isAdmin
      ? <AdminPage />
      : <Navigate to="/calendar" replace />
  }
/>

meQuery consumption pattern (lines 6368):

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):

export interface MeUser {
  id: number;
  displayName: string | null;
  color: string;
}

Add fields:

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):

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):

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:

const membersQuery = useQuery({
  queryKey: ['admin', 'members'],
  queryFn: fetchAdminMembers,
  retry: false,
});

Section label style — per UI-SPEC, copy the pattern from SettingsSheet.tsx section headers:

// 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):

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):

// 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:

<div role="dialog" aria-modal="true" aria-label="Rotate Credential">

Password input pattern (UI-SPEC — never pre-filled, type="password", autocomplete="new-password"):

<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):

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):

{
  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:

<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)

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

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

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)

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

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

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

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