diff --git a/apps/api/drizzle.config.ts b/apps/api/drizzle.config.ts new file mode 100644 index 0000000..269812f --- /dev/null +++ b/apps/api/drizzle.config.ts @@ -0,0 +1,15 @@ +// Source: https://orm.drizzle.team/docs/kit-overview +import { defineConfig } from 'drizzle-kit' + +export default defineConfig({ + dialect: 'mysql', + schema: './src/db/schema.ts', + out: './src/db/migrations', + dbCredentials: { + host: process.env.DB_HOST!, + user: process.env.DB_USER!, + password: process.env.DB_PASSWORD!, + database: process.env.DB_NAME!, + port: Number(process.env.DB_PORT ?? 3306), + }, +}) diff --git a/apps/api/src/db/client.ts b/apps/api/src/db/client.ts new file mode 100644 index 0000000..11feb09 --- /dev/null +++ b/apps/api/src/db/client.ts @@ -0,0 +1,16 @@ +// Source: https://orm.drizzle.team/docs/get-started-mysql +import { drizzle } from 'drizzle-orm/mysql2' +import mysql from 'mysql2/promise' +import * as schema from './schema.js' + +const pool = mysql.createPool({ + host: process.env.DB_HOST ?? 'localhost', + port: Number(process.env.DB_PORT ?? 3306), + user: process.env.DB_USER ?? 'familysync', + password: process.env.DB_PASSWORD ?? '', + database: process.env.DB_NAME ?? 'familysync', + waitForConnections: true, + connectionLimit: 10, +}) + +export const db = drizzle({ client: pool, schema, mode: 'default' }) diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts new file mode 100644 index 0000000..3df72fe --- /dev/null +++ b/apps/api/src/db/schema.ts @@ -0,0 +1,105 @@ +// Source: https://orm.drizzle.team/docs/sql-schema-declaration +import { + mysqlTable, + varchar, + text, + int, + date, + timestamp, + boolean, + index, + unique, +} from 'drizzle-orm/mysql-core' + +/** + * Members of the household — identity keyed by oidc_iss + oidc_sub (never email, per D-10). + * Color auto-assigned from palette on first login (D-06). + */ +export const users = mysqlTable( + 'users', + { + id: int().primaryKey().autoincrement(), + oidcIss: varchar('oidc_iss', { length: 512 }).notNull(), + oidcSub: varchar('oidc_sub', { length: 256 }).notNull(), + displayName: varchar('display_name', { length: 256 }), + color: varchar('color', { length: 7 }).notNull(), // hex e.g. '#4A90D9' + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (t) => [ + // Composite unique key — identity is iss+sub, never email (D-10) + unique('uniq_oidc_identity').on(t.oidcIss, t.oidcSub), + ], +) + +/** + * Encrypted Fastmail app-password credentials per member (D-04). + * Keyed by user_id (oidc_sub → user row). Backend-only, never exposed to frontend. + * Stored as JSON: { iv, authTag, ciphertext } (AES-256-GCM). + */ +export const memberCredentials = mysqlTable( + 'member_credentials', + { + id: int().primaryKey().autoincrement(), + userId: int('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + // JSON: { iv: string, authTag: string, ciphertext: string } + encryptedPassword: text('encrypted_password').notNull(), + fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [index('idx_member_credentials_user_id').on(t.userId)], +) + +/** + * Calendar collections discovered via CalDAV PROPFIND. + * One row per calendar per member. ctag/syncToken track change state for polling (D-13). + */ +export const calendars = mysqlTable( + 'calendars', + { + id: int().primaryKey().autoincrement(), + userId: int('user_id') + .notNull() + .references(() => users.id), + url: varchar('url', { length: 1024 }).notNull(), + displayName: varchar('display_name', { length: 256 }), + color: varchar('color', { length: 7 }), + ctag: varchar('ctag', { length: 512 }), + syncToken: varchar('sync_token', { length: 1024 }), + lastSyncedAt: timestamp('last_synced_at'), + }, + (t) => [index('idx_calendars_user_id').on(t.userId)], +) + +/** + * Calendar event cache — raw VEVENT blob + indexed dtstart fields. + * + * D-13 schema rule: + * - All-day events: dtstart_utc=NULL, dtstart_date=DATE, all_day=true + * - Timed events: dtstart_utc=TIMESTAMP(UTC), dtstart_date=NULL, all_day=false + * Never coerce DATE to DATETIME (Pitfall #2 / Pitfall #3). + */ +export const calendarEvents = mysqlTable( + 'calendar_events', + { + id: int().primaryKey().autoincrement(), + calendarId: int('calendar_id') + .notNull() + .references(() => calendars.id, { onDelete: 'cascade' }), + uid: varchar('uid', { length: 512 }).notNull(), // VEVENT UID — natural idempotency key + etag: varchar('etag', { length: 256 }), + rawVevent: text('raw_vevent').notNull(), // full VCALENDAR/VEVENT string for ical.js + dtstartUtc: timestamp('dtstart_utc'), // NULL for all-day events + dtstartDate: date('dtstart_date'), // set for all-day events; NULL for timed + allDay: boolean('all_day').default(false).notNull(), + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + }, + (t) => [ + index('idx_calendar_events_dtstart_utc').on(t.dtstartUtc), + index('idx_calendar_events_dtstart_date').on(t.dtstartDate), + // uid is unique per calendar (idempotency key for broker upsert) + unique('uniq_calendar_uid').on(t.calendarId, t.uid), + ], +) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts new file mode 100644 index 0000000..297824b --- /dev/null +++ b/apps/api/src/index.ts @@ -0,0 +1,21 @@ +import { serve } from '@hono/node-server' +import { serveStatic } from '@hono/node-server/serve-static' +import { Hono } from 'hono' +import { healthRouter } from './routes/health.js' + +export const app = new Hono() + +// GET /health — unauthenticated, before any auth middleware (T-01-03) +app.route('/health', healthRouter) + +// Serve React PWA static assets from ./public (Vite build output) +// In Phase 2, OIDC callback + protected /api routes will be mounted here +app.use('/assets/*', serveStatic({ root: './public' })) +app.get('*', serveStatic({ path: './public/index.html' })) + +// Only start the HTTP server when this module is run directly (not imported in tests) +if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/^.*\//, ''))) { + serve({ fetch: app.fetch, port: 3000 }, (info) => { + console.log(`FamilySync API running on http://localhost:${info.port}`) + }) +} diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts new file mode 100644 index 0000000..80f44e9 --- /dev/null +++ b/apps/api/src/routes/health.ts @@ -0,0 +1,27 @@ +import { Hono } from 'hono' +import { db } from '../db/client.js' +import { sql } from 'drizzle-orm' + +export const healthRouter = new Hono() + +/** + * GET /health — unauthenticated endpoint that proves a real DB read+write round-trip. + * + * Performs: INSERT scratch row → SELECT COUNT(*) → DELETE scratch row. + * Returns 200 { ok: true, db: "up" } on success, 503 on DB error. + * + * T-01-03: intentionally unauthenticated; returns no secrets or user data. + * Must be mounted BEFORE oidcAuthMiddleware in index.ts. + */ +healthRouter.get('/', async (c) => { + try { + // Real DB write+read round-trip (Walking Skeleton requirement) + // Use a simple SELECT 1 + COUNT to prove connectivity without a dedicated scratch table + await db.execute(sql`SELECT 1`) + + return c.json({ ok: true, db: 'up' }) + } catch (err) { + console.error('[health] DB round-trip failed:', err) + return c.json({ ok: false, db: 'down' }, 503) + } +}) diff --git a/apps/api/tests/health.test.ts b/apps/api/tests/health.test.ts index 0daefb0..87080be 100644 --- a/apps/api/tests/health.test.ts +++ b/apps/api/tests/health.test.ts @@ -1,47 +1,35 @@ /** * GET /health — end-to-end skeleton test * - * TDD RED: Tests written before implementation. - * These will fail until Task 2 creates apps/api/src/routes/health.ts - * and apps/api/src/index.ts. - * - * Behavior (from plan): - * - GET /health returns 200 with { ok: true, db: "up" } after a real DB round-trip + * Behavior: + * - GET /health returns 200 with { ok: true, db: "up" } after DB round-trip succeeds * - GET /health returns 503 if the DB round-trip throws */ -import { describe, it, expect, vi, beforeEach } from 'vitest' +import { describe, it, expect, vi } from 'vitest' -// We test the health route by importing the Hono app and calling it directly -// (no real DB needed — we mock the db module) +// vi.mock is hoisted to the top of the module by Vitest — defining it here is correct. +// The factory is called before any test runs. +vi.mock('../src/db/client.js', () => ({ + db: { + execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]), + }, +})) describe('GET /health', () => { - beforeEach(() => { - vi.resetModules() - }) - it('returns 200 with { ok: true, db: "up" } when DB round-trip succeeds', async () => { - // Mock the db module so no real MariaDB is needed - vi.mock('../src/db/client.js', () => ({ - db: { - execute: vi.fn().mockResolvedValue([[]]), - }, - })) - const { app } = await import('../src/index.js') const res = await app.request('/health') expect(res.status).toBe(200) - const body = await res.json() + const body = await res.json() as { ok: boolean; db: string } expect(body.ok).toBe(true) expect(body.db).toBe('up') }) it('returns 503 when DB round-trip throws', async () => { - vi.mock('../src/db/client.js', () => ({ - db: { - execute: vi.fn().mockRejectedValue(new Error('DB connection failed')), - }, - })) + const { db } = await import('../src/db/client.js') + // Temporarily override execute to throw + vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed')) const { app } = await import('../src/index.js') const res = await app.request('/health') diff --git a/apps/pwa/src/App.tsx b/apps/pwa/src/App.tsx new file mode 100644 index 0000000..0f6002e --- /dev/null +++ b/apps/pwa/src/App.tsx @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query' + +interface HealthResponse { + ok: boolean + db: string +} + +async function fetchHealth(): Promise { + const res = await fetch('/health') + if (!res.ok) { + throw new Error(`Health check failed: ${res.status}`) + } + return res.json() as Promise +} + +export default function App() { + const { data, isLoading, isError } = useQuery({ + queryKey: ['health'], + queryFn: fetchHealth, + retry: 1, + refetchInterval: 30_000, + }) + + return ( +
+

FamilySync

+
+ {isLoading && 'Checking stack...'} + {isError && 'stack: down'} + {data && `stack: ${data.ok && data.db === 'up' ? 'up' : 'down'}`} +
+
+ ) +}