style(13-03): apply Prettier formatting across repo

Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
+116 -117
View File
@@ -21,29 +21,29 @@
* Mounted under /api/* in index.ts — behind oidcAuthMiddleware.
*/
import { randomUUID } from 'node:crypto'
import { Hono } from 'hono'
import type { Context } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { and, or, eq, desc } from 'drizzle-orm'
import { sql } from 'drizzle-orm'
import { db } from '../db/client.js'
import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js'
import { expandOccurrences } from '../broker/expand.js'
import { extractRruleString } from '../broker/vevent.js'
import { getAuth } from '../auth/middleware.js'
import { upsertUser, deriveDisplayName } from '../auth/user.js'
import { randomUUID } from 'node:crypto';
import { Hono } from 'hono';
import type { Context } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
import { and, or, eq, desc } from 'drizzle-orm';
import { sql } from 'drizzle-orm';
import { db } from '../db/client.js';
import { calendarEvents, calendars, users, calendarOutbox } from '../db/schema.js';
import { expandOccurrences } from '../broker/expand.js';
import { extractRruleString } from '../broker/vevent.js';
import { getAuth } from '../auth/middleware.js';
import { upsertUser, deriveDisplayName } from '../auth/user.js';
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'
import '../auth/devBypass.js';
export const eventsRouter = new Hono()
export const eventsRouter = new Hono();
/** Shared-family calendar rose color (D-06). */
const SHARED_FAMILY_COLOR = '#F25C7A'
const SHARED_FAMILY_COLOR = '#F25C7A';
/** Maximum allowed date-window span to prevent DoS (T-02b-02). */
const MAX_WINDOW_DAYS = 90
const MAX_WINDOW_DAYS = 90;
// ---------------------------------------------------------------------------
// Auth helper — shared by all write endpoints
@@ -61,22 +61,22 @@ const MAX_WINDOW_DAYS = 90
// ContextVariableMap augmentation in auth/devBypass.ts (typed as the DEV_USER shape),
// and getAuth(c) accepts a Context — so no `any` / eslint-disable is needed here.
async function resolveUserId(c: Context): Promise<number | null> {
const devUser = c.get('user') as { id: number } | undefined
if (devUser) return devUser.id
const devUser = c.get('user') as { id: number } | undefined;
if (devUser) return devUser.id;
const auth = await getAuth(c)
if (!auth) return null
const auth = await getAuth(c);
if (!auth) return null;
const iss = (auth.iss as string | undefined) ?? ''
const sub = auth.sub ?? ''
const iss = (auth.iss as string | undefined) ?? '';
const sub = auth.sub ?? '';
// Derive displayName via the shared helper (name → preferred_username → email
// → sub fallback) so the write-path upsert agrees with me.ts and never
// overwrites a correctly-derived name with a worse one.
const displayName = deriveDisplayName(auth)
const displayName = deriveDisplayName(auth);
const user = await upsertUser(iss, sub, displayName)
return user?.id ?? null
const user = await upsertUser(iss, sub, displayName);
return user?.id ?? null;
}
// ---------------------------------------------------------------------------
@@ -87,7 +87,7 @@ async function resolveUserId(c: Context): Promise<number | null> {
const eventsQuerySchema = z.object({
start: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
end: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
})
});
/**
* Shared event field validation (V5 — bounded lengths, T-03-08).
@@ -111,14 +111,17 @@ const eventFieldsSchema = z.object({
// ≤10-char non-date string cannot survive .replace(/-/g,'') and inject extra ';'-delimited
// RRULE parts when spliced into the UNTIL template (outboxWorker.assembleRruleString).
// int().min(1) prevents zero/negative counts.
recurrenceUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), // 'YYYY-MM-DD' → RRULE UNTIL
recurrenceUntil: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(), // 'YYYY-MM-DD' → RRULE UNTIL
recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT
})
});
/** sync-status query params. */
const syncStatusQuerySchema = z.object({
uid: z.string().min(1).max(512),
})
});
// ---------------------------------------------------------------------------
// GET /api/events?start=YYYY-MM-DD&end=YYYY-MM-DD
@@ -132,17 +135,17 @@ const syncStatusQuerySchema = z.object({
// ---------------------------------------------------------------------------
eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
// Resolve the current user first — only return events for owned + shared calendars (T-03-06).
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const { start, end } = c.req.valid('query')
const { start, end } = c.req.valid('query');
// --- Window span guard (T-02b-02) ---
const windowStartDate = new Date(start + 'T00:00:00Z')
const windowEndDate = new Date(end + 'T00:00:00Z')
const spanDays = (windowEndDate.getTime() - windowStartDate.getTime()) / (1000 * 60 * 60 * 24)
const windowStartDate = new Date(start + 'T00:00:00Z');
const windowEndDate = new Date(end + 'T00:00:00Z');
const spanDays = (windowEndDate.getTime() - windowStartDate.getTime()) / (1000 * 60 * 60 * 24);
if (spanDays > MAX_WINDOW_DAYS || spanDays <= 0) {
return c.json({ error: 'Date window must be between 1 and 90 days' }, 400)
return c.json({ error: 'Date window must be between 1 and 90 days' }, 400);
}
try {
@@ -211,11 +214,11 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
),
),
),
)
);
// --- Expand each row into concrete occurrences ---
const allOccurrences = rows.flatMap((row) => {
const color = row.isShared ? SHARED_FAMILY_COLOR : row.userColor
const color = row.isShared ? SHARED_FAMILY_COLOR : row.userColor;
return expandOccurrences(
row.rawVevent,
windowStartDate,
@@ -226,15 +229,15 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
row.ownerName ?? null,
color,
row.isShared,
)
})
);
});
return c.json({ occurrences: allOccurrences })
return c.json({ occurrences: allOccurrences });
} catch (err) {
console.error('[events] DB query or expansion failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[events] DB query or expansion failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// POST /api/events/create
@@ -244,16 +247,16 @@ eventsRouter.get('/', zValidator('query', eventsQuerySchema), async (c) => {
// Does NOT build a VEVENT and does NOT call Fastmail — that is the worker's job (D-12).
// ---------------------------------------------------------------------------
eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) => {
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const payload = c.req.valid('json')
const payload = c.req.valid('json');
try {
// --- Resolve target calendar (D-03 / T-03-06) ---
// If calendarUrl given: assert the calendar is owned by the current user OR is shared.
// If not given: use the first personal calendar (D-01 last-used is a frontend concern).
let targetCalendarUrl: string
let targetCalendarUrl: string;
if (payload.calendarUrl) {
// Look up the calendar — it must be owned by the current user or be shared.
@@ -265,12 +268,12 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
eq(calendars.url, payload.calendarUrl),
or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
),
)
);
if (!calRow) {
return c.json({ error: 'Calendar not found or access denied' }, 403)
return c.json({ error: 'Calendar not found or access denied' }, 403);
}
targetCalendarUrl = calRow.url
targetCalendarUrl = calRow.url;
} else {
// Default to the member's first personal calendar (D-01).
// WR-02: add a deterministic ORDER BY + LIMIT. Without them, a member with
@@ -282,16 +285,16 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
.from(calendars)
.where(eq(calendars.userId, currentUserId))
.orderBy(calendars.id)
.limit(1)
.limit(1);
if (!calRow) {
return c.json({ error: 'No writable calendar found for user' }, 422)
return c.json({ error: 'No writable calendar found for user' }, 422);
}
targetCalendarUrl = calRow.url
targetCalendarUrl = calRow.url;
}
// Generate a UID for the new event (Node.js 22 built-in)
const uid = `${randomUUID()}@familysync`
const uid = `${randomUUID()}@familysync`;
// Enqueue the outbox row (pending) — the worker builds the VEVENT and calls Fastmail.
await db.insert(calendarOutbox).values({
@@ -301,14 +304,14 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
uid,
calendarUrl: targetCalendarUrl,
payload: JSON.stringify(payload),
})
});
return c.json({ uid }, 202)
return c.json({ uid }, 202);
} catch (err) {
console.error('[events/create] DB operation failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[events/create] DB operation failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// PATCH /api/events/:uid/edit
@@ -319,11 +322,11 @@ eventsRouter.post('/create', zValidator('json', eventFieldsSchema), async (c) =>
// Returns 202 immediately (D-05). Does NOT call Fastmail (D-12).
// ---------------------------------------------------------------------------
eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c) => {
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const uid = c.req.param('uid')
const payload = c.req.valid('json')
const uid = c.req.param('uid');
const payload = c.req.valid('json');
try {
// --- Look up the event and verify ownership ---
@@ -358,10 +361,10 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
),
)
.orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
.limit(1)
.limit(1);
if (!eventRow) {
return c.json({ error: 'Event not found' }, 404)
return c.json({ error: 'Event not found' }, 404);
}
// Ownership check: must be the calendar owner or shared (T-03-06).
@@ -372,20 +375,20 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
const [calRow] = await db
.select({ isShared: calendars.isShared })
.from(calendars)
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)))
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)));
if (!calRow) {
return c.json({ error: 'Access denied' }, 403)
return c.json({ error: 'Access denied' }, 403);
}
}
const newCalendarUrl = payload.calendarUrl ?? eventRow.calendarUrl
const isCalendarMove = newCalendarUrl !== eventRow.calendarUrl
const newCalendarUrl = payload.calendarUrl ?? eventRow.calendarUrl;
const isCalendarMove = newCalendarUrl !== eventRow.calendarUrl;
if (isCalendarMove) {
// D-04: edit-as-move — insert delete+create pair in one transaction (D-04 / Pitfall 5)
const newUid = `${randomUUID()}@familysync`
const groupId = randomUUID()
const newUid = `${randomUUID()}@familysync`;
const groupId = randomUUID();
// CR-01: carry the existing RRULE through the move. The edit payload omits
// `recurrence` (the occurrence contract does not expose it, D-03), and the
@@ -398,13 +401,9 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
// Only stash when the edit did NOT carry an explicit recurrence: an explicit
// value (including 'none') is a deliberate user change and must win.
const preservedRrule =
payload.recurrence === undefined
? extractRruleString(eventRow.rawVevent ?? '')
: undefined
payload.recurrence === undefined ? extractRruleString(eventRow.rawVevent ?? '') : undefined;
const createPayload =
preservedRrule !== undefined
? { ...payload, _preservedRrule: preservedRrule }
: payload
preservedRrule !== undefined ? { ...payload, _preservedRrule: preservedRrule } : payload;
await db.transaction(async (tx) => {
// Delete from old calendar
@@ -417,7 +416,7 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
calendarObjectUrl: eventRow.objectUrl ?? undefined,
etag: eventRow.etag ?? undefined,
groupId,
})
});
// Create on new calendar
await tx.insert(calendarOutbox).values({
userId: currentUserId,
@@ -427,10 +426,10 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
calendarUrl: newCalendarUrl,
payload: JSON.stringify(createPayload),
groupId,
})
})
});
});
return c.json({ uid: newUid }, 202)
return c.json({ uid: newUid }, 202);
}
// Same calendar — simple update row
@@ -443,14 +442,14 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
calendarObjectUrl: eventRow.objectUrl ?? undefined,
etag: eventRow.etag ?? undefined,
payload: JSON.stringify(payload),
})
});
return c.json({ uid }, 202)
return c.json({ uid }, 202);
} catch (err) {
console.error('[events/edit] DB operation failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[events/edit] DB operation failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// DELETE /api/events/:uid
@@ -459,10 +458,10 @@ eventsRouter.patch('/:uid/edit', zValidator('json', eventFieldsSchema), async (c
// Returns 202 immediately (D-05). Does NOT call Fastmail (D-12).
// ---------------------------------------------------------------------------
eventsRouter.delete('/:uid', async (c) => {
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const uid = c.req.param('uid')
const uid = c.req.param('uid');
try {
// Look up the event — join calendars so calendars.url / calendars.userId are accessible.
@@ -490,10 +489,10 @@ eventsRouter.delete('/:uid', async (c) => {
),
)
.orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
.limit(1)
.limit(1);
if (!eventRow) {
return c.json({ error: 'Event not found' }, 404)
return c.json({ error: 'Event not found' }, 404);
}
// Ownership check (T-03-06)
@@ -501,10 +500,10 @@ eventsRouter.delete('/:uid', async (c) => {
const [calRow] = await db
.select({ isShared: calendars.isShared })
.from(calendars)
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)))
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)));
if (!calRow) {
return c.json({ error: 'Access denied' }, 403)
return c.json({ error: 'Access denied' }, 403);
}
}
@@ -517,14 +516,14 @@ eventsRouter.delete('/:uid', async (c) => {
calendarUrl: eventRow.calendarUrl ?? '',
calendarObjectUrl: eventRow.objectUrl ?? undefined,
etag: eventRow.etag ?? undefined,
})
});
return c.json({ uid }, 202)
return c.json({ uid }, 202);
} catch (err) {
console.error('[events/delete] DB operation failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[events/delete] DB operation failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// GET /api/events/sync-status?uid=<uid>
@@ -534,10 +533,10 @@ eventsRouter.delete('/:uid', async (c) => {
// Returns { uid, status: 'done' } when no outbox row exists (nothing pending = settled).
// ---------------------------------------------------------------------------
eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), async (c) => {
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
const { uid } = c.req.valid('query')
const { uid } = c.req.valid('query');
try {
// Scope strictly to current member's rows (T-03-07 — never leak another member's outbox).
@@ -562,24 +561,24 @@ eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), asy
sql`case ${calendarOutbox.status} when 'failed' then 0 when 'dead' then 0 when 'pending' then 1 else 2 end`,
desc(calendarOutbox.createdAt),
)
.limit(1)
.limit(1);
if (!rows.length) {
// No outbox row → nothing pending = settled as done
return c.json({ uid, status: 'done' })
return c.json({ uid, status: 'done' });
}
const row = rows[0]
const row = rows[0];
return c.json({
uid: row.uid,
status: row.status,
...(row.lastError != null ? { error: row.lastError } : {}),
})
});
} catch (err) {
console.error('[events/sync-status] DB query failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[events/sync-status] DB query failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});
// ---------------------------------------------------------------------------
// GET /api/events/writable-calendars
@@ -595,8 +594,8 @@ eventsRouter.get('/sync-status', zValidator('query', syncStatusQuerySchema), asy
// Response: { calendars: [{ url, displayName, color, isShared }] }
// ---------------------------------------------------------------------------
eventsRouter.get('/writable-calendars', async (c) => {
const currentUserId = await resolveUserId(c)
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401)
const currentUserId = await resolveUserId(c);
if (currentUserId === null) return c.json({ error: 'Unauthorized' }, 401);
try {
// D-03 writable set: own personal calendars + shared Family calendar.
@@ -609,7 +608,7 @@ eventsRouter.get('/writable-calendars', async (c) => {
isShared: calendars.isShared,
})
.from(calendars)
.where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)))
.where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)));
return c.json({
calendars: rows.map((row) => ({
@@ -618,9 +617,9 @@ eventsRouter.get('/writable-calendars', async (c) => {
color: row.color ?? '',
isShared: row.isShared,
})),
})
});
} catch (err) {
console.error('[events/writable-calendars] DB query failed:', err)
return c.json({ error: 'Service unavailable' }, 503)
console.error('[events/writable-calendars] DB query failed:', err);
return c.json({ error: 'Service unavailable' }, 503);
}
})
});