- Add reminderLeadMinutes: number | null to CalendarOccurrence interface (D-06) - Import classifyValarms in expand.ts; derive series-level value once per VEVENT - Add reminderLeadMinutes to both non-recurring and recurring occurrence construction - Add reminderLeadMinutes to GET /api/events select for edit-mode pre-population
643 lines
28 KiB
TypeScript
643 lines
28 KiB
TypeScript
/**
|
|
* Events router — windowed reads + write-back API surface.
|
|
*
|
|
* Architecture invariant (T-03-02, broker-boundary):
|
|
* This route reads ONLY from the MariaDB cache for GET /. It NEVER calls Fastmail directly.
|
|
* All Fastmail I/O is owned exclusively by the broker module (src/broker/).
|
|
* No tsdav import here; no createFastmailClient import here.
|
|
* Write endpoints (POST /create, PATCH /:uid/edit, DELETE /:uid) ENQUEUE outbox rows only —
|
|
* they do not build VEVENTs and do not call Fastmail; the outbox worker (Plan 04) does both.
|
|
*
|
|
* Security (threat model T-02b-01 / T-02b-02, T-03-06..T-03-11):
|
|
* - start/end query params validated with zod ISO-date regex before any SQL.
|
|
* - Window hard-capped at 90 days (DoS guard).
|
|
* - Write payloads validated with zod (title 255, location/description 2000 — T-03-08).
|
|
* - All write endpoints assert calendar ownership (userId = currentUser.id OR isShared) — T-03-06.
|
|
* - sync-status scoped strictly to currentUser.id — T-03-07.
|
|
* - writable-calendars query restricts to userId = currentUser.id OR isShared=1 — T-03-11.
|
|
* - Drizzle parameterized queries prevent SQL injection — T-03-09.
|
|
* - etag read server-side from calendarEvents; client never supplies it — T-03-10.
|
|
*
|
|
* 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 { signalOutboxDrain } from '../lib/outboxTrigger.js';
|
|
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
|
|
import '../auth/devBypass.js';
|
|
|
|
export const eventsRouter = new Hono();
|
|
|
|
/** Shared-family calendar rose color (D-06). */
|
|
const SHARED_FAMILY_COLOR = '#F25C7A';
|
|
|
|
/** Maximum allowed date-window span to prevent DoS (T-02b-02). */
|
|
const MAX_WINDOW_DAYS = 90;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Auth helper — shared by all write endpoints
|
|
//
|
|
// Resolution order (D-10, CR-06):
|
|
// 1. Dev-bypass path: c.get('user') is set by devAuthBypass() middleware when
|
|
// DEV_AUTH_BYPASS=true. Return its .id directly — no OIDC round-trip.
|
|
// 2. OIDC path: call getAuth(c). If null → unauthenticated, return null.
|
|
// Otherwise extract iss/sub/email and call upsertUser — which writes the
|
|
// user row on first login and returns the existing row on subsequent calls.
|
|
// Identity is keyed on oidc_iss + oidc_sub (D-10), never email.
|
|
// 3. Callers emit 401 when resolveUserId returns null.
|
|
//
|
|
// IN-04: typed as Hono's Context instead of `any`. c.get('user') resolves through the
|
|
// 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 auth = await getAuth(c);
|
|
if (!auth) return null;
|
|
|
|
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 user = await upsertUser(iss, sub, displayName);
|
|
return user?.id ?? null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Zod schemas
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Windowed GET query params. */
|
|
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).
|
|
*
|
|
* Field names match the PWA CreateEventPayload (apps/pwa/src/api/client.ts:119-128)
|
|
* exactly — title/start/end — so no rename map is needed end-to-end (CR-01).
|
|
* The stored payload JSON uses these same names; the outbox worker (plan 03-10)
|
|
* reads title/start/end when building the VEVENT.
|
|
*/
|
|
const eventFieldsSchema = z.object({
|
|
title: z.string().min(1).max(255),
|
|
allDay: z.boolean(),
|
|
start: z.string().min(1).max(64), // ISO string or DATE (YYYY-MM-DD for allDay)
|
|
end: z.string().min(1).max(64),
|
|
location: z.string().max(2000).optional(),
|
|
description: z.string().max(2000).optional(),
|
|
recurrence: z.enum(['none', 'daily', 'weekly', 'monthly', 'yearly']).optional(),
|
|
calendarUrl: z.string().url().max(1024).optional(),
|
|
// D-06: recurrence bounding (RRULE UNTIL / COUNT)
|
|
// CR-01: validate the exact 'YYYY-MM-DD' shape (reusing eventsQuerySchema's regex) so a
|
|
// ≤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
|
|
recurrenceCount: z.number().int().min(1).optional(), // integer ≥ 1 → RRULE COUNT
|
|
// Phase 11: per-event reminder lead in minutes (CAL-13/CAL-14, D-08).
|
|
// absent — field not present; outbox worker preserves existing VALARM (no-change, D-08)
|
|
// null — explicit "None" → clear the VALARM on write-back
|
|
// 0 — same-day all-day reminder (9 AM on event date); timed 0 = None (D-06)
|
|
// positive int — N minutes before event start (timed) or N/1440 days before (all-day)
|
|
reminderLeadMinutes: z.number().int().min(0).nullable().optional(),
|
|
});
|
|
|
|
/** 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
|
|
//
|
|
// Returns a flat array of concrete occurrences windowed to [start, end).
|
|
// Joins calendarEvents → calendars → users to resolve color and ownership.
|
|
// Expands recurring masters (hasRrule=true) via expandOccurrences() so RRULE,
|
|
// EXDATE, and DST are all handled server-side (D-09).
|
|
//
|
|
// Response shape: { occurrences: CalendarOccurrence[] }
|
|
// ---------------------------------------------------------------------------
|
|
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 { 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);
|
|
if (spanDays > MAX_WINDOW_DAYS || spanDays <= 0) {
|
|
return c.json({ error: 'Date window must be between 1 and 90 days' }, 400);
|
|
}
|
|
|
|
try {
|
|
// --- SQL pre-filter strategy (RESEARCH.md §Open Questions 3 / Pitfall 5) ---
|
|
//
|
|
// The WHERE clause must include THREE categories of events:
|
|
// 1. Non-recurring timed events with dtstartUtc in [windowStart, windowEnd)
|
|
// 2. Non-recurring all-day events with dtstartDate in [start, end) (DATE comparison)
|
|
// 3. Recurring masters (hasRrule=true) with dtstartUtc < windowEnd
|
|
// (a weekly meeting created 3 years ago can still have occurrences in the window)
|
|
//
|
|
// expandOccurrences() does the precise window-boundary check for all rows returned.
|
|
//
|
|
// Using raw SQL for the complex WHERE to keep the query readable and unambiguous.
|
|
// Drizzle's `sql` tag parameterizes all values — no string interpolation.
|
|
const rows = await db
|
|
.select({
|
|
rawVevent: calendarEvents.rawVevent,
|
|
calendarId: calendars.id,
|
|
calendarName: calendars.displayName,
|
|
isShared: calendars.isShared,
|
|
userId: users.id,
|
|
userColor: users.color,
|
|
ownerName: users.displayName,
|
|
// Phase 11 Plan 03: surface reminderLeadMinutes for edit-mode pre-population (D-06/D-10)
|
|
reminderLeadMinutes: calendarEvents.reminderLeadMinutes,
|
|
})
|
|
.from(calendarEvents)
|
|
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
|
.innerJoin(users, eq(calendars.userId, users.id))
|
|
.where(
|
|
and(
|
|
// Ownership predicate (BUG 3 fix): restrict to calendars owned by the current user
|
|
// OR shared-family calendars (isShared=true). Mirrors the /writable-calendars idiom
|
|
// (~line 509) so both endpoints agree on the authoritative writable set (D-03).
|
|
or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
|
|
// Date-window pre-filter (RESEARCH.md §Open Questions 3 / Pitfall 5):
|
|
or(
|
|
// Recurring masters: may have occurrences inside the window even if dtstartUtc is old.
|
|
// Two sub-cases:
|
|
// (a) Timed recurring masters: dtstartUtc < windowEnd
|
|
// (b) All-day recurring masters: dtstartUtc is NULL (DATE-only), use dtstartDate < end
|
|
// A NULL dtstartUtc causes the timed comparison to be NULL/false, so (b) carries it.
|
|
and(
|
|
sql`${calendarEvents.hasRrule} = 1`,
|
|
or(
|
|
sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`,
|
|
sql`${calendarEvents.dtstartDate} < ${end}`,
|
|
),
|
|
),
|
|
// Non-recurring timed events: dtstartUtc falls in [windowStart, windowEnd)
|
|
and(
|
|
sql`${calendarEvents.hasRrule} = 0`,
|
|
sql`${calendarEvents.dtstartUtc} IS NOT NULL`,
|
|
sql`${calendarEvents.dtstartUtc} >= ${windowStartDate}`,
|
|
sql`${calendarEvents.dtstartUtc} < ${windowEndDate}`,
|
|
),
|
|
// Non-recurring all-day events: dtstartDate falls in [start, end) — DATE comparison.
|
|
// WR-01: gate on hasRrule = 0 so a recurring all-day master whose dtstartDate
|
|
// lands in the window is NOT also matched here (it is already carried by the
|
|
// recurring branch above). Without this gate the master is expanded twice and
|
|
// every occurrence is duplicated on the wire.
|
|
and(
|
|
sql`${calendarEvents.hasRrule} = 0`,
|
|
sql`${calendarEvents.dtstartDate} IS NOT NULL`,
|
|
sql`${calendarEvents.dtstartDate} >= ${start}`,
|
|
sql`${calendarEvents.dtstartDate} < ${end}`,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
// --- Expand each row into concrete occurrences ---
|
|
const allOccurrences = rows.flatMap((row) => {
|
|
const color = row.isShared ? SHARED_FAMILY_COLOR : row.userColor;
|
|
return expandOccurrences(
|
|
row.rawVevent,
|
|
windowStartDate,
|
|
windowEndDate,
|
|
row.calendarId,
|
|
row.calendarName ?? '',
|
|
row.userId,
|
|
row.ownerName ?? null,
|
|
color,
|
|
row.isShared,
|
|
);
|
|
});
|
|
|
|
return c.json({ occurrences: allOccurrences });
|
|
} catch (err) {
|
|
console.error('[events] DB query or expansion failed:', err);
|
|
return c.json({ error: 'Service unavailable' }, 503);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// POST /api/events/create
|
|
//
|
|
// Validates input, resolves the target calendar (D-03 ownership check), and
|
|
// enqueues a `pending` outbox row. Returns 202 immediately (D-05, optimistic-accept).
|
|
// 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 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;
|
|
|
|
if (payload.calendarUrl) {
|
|
// Look up the calendar — it must be owned by the current user or be shared.
|
|
const [calRow] = await db
|
|
.select({ url: calendars.url, userId: calendars.userId, isShared: calendars.isShared })
|
|
.from(calendars)
|
|
.where(
|
|
and(
|
|
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);
|
|
}
|
|
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
|
|
// multiple personal calendars gets an arbitrary, query-to-query-unstable "first"
|
|
// row. Ordering by id (insertion order) gives a stable default; limit(1) avoids
|
|
// fetching the whole set just to take [0].
|
|
const [calRow] = await db
|
|
.select({ url: calendars.url })
|
|
.from(calendars)
|
|
.where(eq(calendars.userId, currentUserId))
|
|
.orderBy(calendars.id)
|
|
.limit(1);
|
|
|
|
if (!calRow) {
|
|
return c.json({ error: 'No writable calendar found for user' }, 422);
|
|
}
|
|
targetCalendarUrl = calRow.url;
|
|
}
|
|
|
|
// Generate a UID for the new event (Node.js 22 built-in)
|
|
const uid = `${randomUUID()}@familysync`;
|
|
|
|
// Enqueue the outbox row (pending) — the worker builds the VEVENT and calls Fastmail.
|
|
await db.insert(calendarOutbox).values({
|
|
userId: currentUserId,
|
|
operation: 'create',
|
|
status: 'pending',
|
|
uid,
|
|
calendarUrl: targetCalendarUrl,
|
|
payload: JSON.stringify(payload),
|
|
});
|
|
|
|
// must stay AFTER the enqueue commit — worker selects committed pending rows only
|
|
signalOutboxDrain();
|
|
return c.json({ uid }, 202);
|
|
} catch (err) {
|
|
console.error('[events/create] DB operation failed:', err);
|
|
return c.json({ error: 'Service unavailable' }, 503);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// PATCH /api/events/:uid/edit
|
|
//
|
|
// Looks up the cached event by uid, asserts ownership (D-03), and enqueues an
|
|
// outbox row. If the target calendarUrl differs from the current calendar (calendar
|
|
// move, D-04), TWO rows are inserted in a single transaction (delete+create pair).
|
|
// 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 uid = c.req.param('uid');
|
|
const payload = c.req.valid('json');
|
|
|
|
try {
|
|
// --- Look up the event and verify ownership ---
|
|
// Join calendarEvents → calendars so we can read calendars.url and calendars.userId
|
|
// in the same query. Without the join, referencing calendars.* produces invalid SQL
|
|
// (Drizzle throws at toSQL() time) → 503. Mirrors the GET / join idiom at line 153.
|
|
//
|
|
// CR-01: calendar_events is keyed (calendarId, uid), NOT uid alone (schema.ts:121).
|
|
// With a shared Fastmail account (D-16) the SAME uid is cached once per member's
|
|
// calendar, so a uid-only lookup returns 2+ rows and an arbitrary [0] (typically the
|
|
// OTHER member's row). Scope the lookup to the acting member's writable set
|
|
// (own calendars OR shared) so the etag/objectUrl/ownership we act on belong to the
|
|
// right calendar. Order so the current user's OWN row ranks before a shared/other row
|
|
// — when both a personal and a shared copy of the uid exist, the acting member's copy
|
|
// is authoritative for the write target. limit(1) makes the pick deterministic.
|
|
const [eventRow] = await db
|
|
.select({
|
|
uid: calendarEvents.uid,
|
|
etag: calendarEvents.etag,
|
|
objectUrl: calendarEvents.objectUrl,
|
|
calendarId: calendarEvents.calendarId,
|
|
calendarUrl: calendars.url,
|
|
userId: calendars.userId,
|
|
rawVevent: calendarEvents.rawVevent,
|
|
})
|
|
.from(calendarEvents)
|
|
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
|
.where(
|
|
and(
|
|
eq(calendarEvents.uid, uid),
|
|
or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
|
|
),
|
|
)
|
|
.orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
|
|
.limit(1);
|
|
|
|
if (!eventRow) {
|
|
return c.json({ error: 'Event not found' }, 404);
|
|
}
|
|
|
|
// Ownership check: must be the calendar owner or shared (T-03-06).
|
|
// The WHERE above already restricts to the writable set, so any returned row is
|
|
// either the user's own calendar or a shared one — re-verify defensively.
|
|
if (eventRow.userId !== currentUserId) {
|
|
// Check if the calendar is shared (shared calendars are writable by all household members)
|
|
const [calRow] = await db
|
|
.select({ isShared: calendars.isShared })
|
|
.from(calendars)
|
|
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)));
|
|
|
|
if (!calRow) {
|
|
return c.json({ error: 'Access denied' }, 403);
|
|
}
|
|
}
|
|
|
|
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();
|
|
|
|
// 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
|
|
// create lands under a brand-new uid that the worker can never look up the
|
|
// original RRULE from. Unlike the same-calendar `update` branch — which reads
|
|
// rawVevent and re-applies the stored RRULE — the create branch has no source
|
|
// for it. Extract the RRULE from the source event here and stash it on the
|
|
// create payload so the worker re-applies it, preventing a recurring series
|
|
// from silently collapsing into a single occurrence on a calendar move.
|
|
// 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;
|
|
const createPayload =
|
|
preservedRrule !== undefined ? { ...payload, _preservedRrule: preservedRrule } : payload;
|
|
|
|
await db.transaction(async (tx) => {
|
|
// Delete from old calendar
|
|
await tx.insert(calendarOutbox).values({
|
|
userId: currentUserId,
|
|
operation: 'delete',
|
|
status: 'pending',
|
|
uid,
|
|
calendarUrl: eventRow.calendarUrl ?? '',
|
|
calendarObjectUrl: eventRow.objectUrl ?? undefined,
|
|
etag: eventRow.etag ?? undefined,
|
|
groupId,
|
|
});
|
|
// Create on new calendar
|
|
await tx.insert(calendarOutbox).values({
|
|
userId: currentUserId,
|
|
operation: 'create',
|
|
status: 'pending',
|
|
uid: newUid,
|
|
calendarUrl: newCalendarUrl,
|
|
payload: JSON.stringify(createPayload),
|
|
groupId,
|
|
});
|
|
});
|
|
|
|
// must stay AFTER the enqueue commit — worker selects committed pending rows only
|
|
signalOutboxDrain();
|
|
return c.json({ uid: newUid }, 202);
|
|
}
|
|
|
|
// Same calendar — simple update row
|
|
await db.insert(calendarOutbox).values({
|
|
userId: currentUserId,
|
|
operation: 'update',
|
|
status: 'pending',
|
|
uid,
|
|
calendarUrl: eventRow.calendarUrl ?? '',
|
|
calendarObjectUrl: eventRow.objectUrl ?? undefined,
|
|
etag: eventRow.etag ?? undefined,
|
|
payload: JSON.stringify(payload),
|
|
});
|
|
|
|
// must stay AFTER the enqueue commit — worker selects committed pending rows only
|
|
signalOutboxDrain();
|
|
return c.json({ uid }, 202);
|
|
} catch (err) {
|
|
console.error('[events/edit] DB operation failed:', err);
|
|
return c.json({ error: 'Service unavailable' }, 503);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DELETE /api/events/:uid
|
|
//
|
|
// Asserts ownership, enqueues a delete outbox row with the cached etag (D-08 / T-03-10).
|
|
// 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 uid = c.req.param('uid');
|
|
|
|
try {
|
|
// Look up the event — join calendars so calendars.url / calendars.userId are accessible.
|
|
// Same innerJoin idiom as the GET / handler (line 153). Without this join, Drizzle
|
|
// throws at toSQL() time → 503.
|
|
//
|
|
// CR-01: scope to the acting member's writable set and pick deterministically — a
|
|
// shared Fastmail account (D-16) caches the same uid once per member's calendar, so a
|
|
// uid-only lookup would otherwise act on an arbitrary member's etag/objectUrl.
|
|
const [eventRow] = await db
|
|
.select({
|
|
uid: calendarEvents.uid,
|
|
etag: calendarEvents.etag,
|
|
objectUrl: calendarEvents.objectUrl,
|
|
calendarId: calendarEvents.calendarId,
|
|
calendarUrl: calendars.url,
|
|
userId: calendars.userId,
|
|
})
|
|
.from(calendarEvents)
|
|
.innerJoin(calendars, eq(calendarEvents.calendarId, calendars.id))
|
|
.where(
|
|
and(
|
|
eq(calendarEvents.uid, uid),
|
|
or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)),
|
|
),
|
|
)
|
|
.orderBy(sql`(${calendars.userId} = ${currentUserId}) desc`)
|
|
.limit(1);
|
|
|
|
if (!eventRow) {
|
|
return c.json({ error: 'Event not found' }, 404);
|
|
}
|
|
|
|
// Ownership check (T-03-06)
|
|
if (eventRow.userId !== currentUserId) {
|
|
const [calRow] = await db
|
|
.select({ isShared: calendars.isShared })
|
|
.from(calendars)
|
|
.where(and(eq(calendars.id, eventRow.calendarId), eq(calendars.isShared, true)));
|
|
|
|
if (!calRow) {
|
|
return c.json({ error: 'Access denied' }, 403);
|
|
}
|
|
}
|
|
|
|
// Enqueue delete — etag from calendarEvents (T-03-10, never from client)
|
|
await db.insert(calendarOutbox).values({
|
|
userId: currentUserId,
|
|
operation: 'delete',
|
|
status: 'pending',
|
|
uid,
|
|
calendarUrl: eventRow.calendarUrl ?? '',
|
|
calendarObjectUrl: eventRow.objectUrl ?? undefined,
|
|
etag: eventRow.etag ?? undefined,
|
|
});
|
|
|
|
// must stay AFTER the enqueue commit — worker selects committed pending rows only
|
|
signalOutboxDrain();
|
|
return c.json({ uid }, 202);
|
|
} catch (err) {
|
|
console.error('[events/delete] DB operation failed:', err);
|
|
return c.json({ error: 'Service unavailable' }, 503);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/events/sync-status?uid=<uid>
|
|
//
|
|
// Returns the outbox status for the given UID, scoped to the current member (T-03-07).
|
|
// Polled by TanStack Query on the client to power the optimistic-accept toast (D-09).
|
|
// 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 { uid } = c.req.valid('query');
|
|
|
|
try {
|
|
// Scope strictly to current member's rows (T-03-07 — never leak another member's outbox).
|
|
//
|
|
// WR-04: rapid successive same-uid edits enqueue multiple outbox rows. A plain
|
|
// "newest row" pick (ORDER BY createdAt DESC LIMIT 1) reports only the latest row's
|
|
// status — so if the newest succeeds but an older row dead-lettered, the user sees
|
|
// "Saved" while a queued write silently failed. Rank an unsettled/failed row ABOVE a
|
|
// done row: a row in pending/failed/dead for the uid outranks a done row, and only
|
|
// among same-priority rows do we fall back to newest-first. This surfaces a failure
|
|
// for ANY row of the uid instead of masking it behind a later success.
|
|
const rows = await db
|
|
.select({
|
|
uid: calendarOutbox.uid,
|
|
status: calendarOutbox.status,
|
|
lastError: calendarOutbox.lastError,
|
|
})
|
|
.from(calendarOutbox)
|
|
.where(and(eq(calendarOutbox.userId, currentUserId), eq(calendarOutbox.uid, uid)))
|
|
// status priority: failed/dead first, then pending, then done.
|
|
.orderBy(
|
|
sql`case ${calendarOutbox.status} when 'failed' then 0 when 'dead' then 0 when 'pending' then 1 else 2 end`,
|
|
desc(calendarOutbox.createdAt),
|
|
)
|
|
.limit(1);
|
|
|
|
if (!rows.length) {
|
|
// No outbox row → nothing pending = settled as done
|
|
return c.json({ uid, status: 'done' });
|
|
}
|
|
|
|
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);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GET /api/events/writable-calendars
|
|
//
|
|
// Returns the D-03 writable set for the current member:
|
|
// - The member's own personal calendar(s) (userId = currentUser.id)
|
|
// - The shared Family calendar (isShared = true)
|
|
//
|
|
// The other member's personal calendar (different userId, isShared=false) MUST NOT
|
|
// appear — it is a read-only overlay only (D-03). This endpoint is AUTHORITATIVE:
|
|
// the client (Plan 05) consumes it verbatim and never derives the writable set itself (T-03-11).
|
|
//
|
|
// 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);
|
|
|
|
try {
|
|
// D-03 writable set: own personal calendars + shared Family calendar.
|
|
// Another member's personal (userId ≠ currentUserId AND isShared=false) is excluded (T-03-11).
|
|
const rows = await db
|
|
.select({
|
|
url: calendars.url,
|
|
displayName: calendars.displayName,
|
|
color: calendars.color,
|
|
isShared: calendars.isShared,
|
|
})
|
|
.from(calendars)
|
|
.where(or(eq(calendars.userId, currentUserId), eq(calendars.isShared, true)));
|
|
|
|
return c.json({
|
|
calendars: rows.map((row) => ({
|
|
url: row.url,
|
|
displayName: row.displayName ?? '',
|
|
color: row.color ?? '',
|
|
isShared: row.isShared,
|
|
})),
|
|
});
|
|
} catch (err) {
|
|
console.error('[events/writable-calendars] DB query failed:', err);
|
|
return c.json({ error: 'Service unavailable' }, 503);
|
|
}
|
|
});
|