From d0a4cb4e35c6d0c39203ff79b89a8308ae9b5961 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Sat, 13 Jun 2026 14:20:10 -0400 Subject: [PATCH] feat(10-01): add v1.1 schema bundle (is_admin, provider_type, reminder_lead_minutes, app_config) - users.isAdmin: boolean NOT NULL DEFAULT false (first-login-wins admin flag, D-01) - memberCredentials.providerType: varchar(64) NOT NULL DEFAULT 'caldav' (generic provider discriminator, D-04) - memberCredentials: UNIQUE(user_id) constraint for one-credential-per-member + upsert support (D-05) - calendarEvents.reminderLeadMinutes: int nullable (created now, consumed by Phase 11) - appConfig table: key VARCHAR PK, value TEXT, updated_at (setup_complete consumed by Phase 12) --- apps/api/src/db/schema.ts | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 5d8f625..dd97b76 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -30,6 +30,7 @@ const varcharBin = (name: string) => /** * 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). + * isAdmin: first-login-wins bootstrap (D-01); gated by app_config.setup_complete in Phase 12. */ export const users = mysqlTable( 'users', @@ -40,6 +41,8 @@ export const users = mysqlTable( displayName: varchar('display_name', { length: 256 }), color: varchar('color', { length: 7 }).notNull(), // hex e.g. '#4A90D9' createdAt: timestamp('created_at').defaultNow().notNull(), + // v1.1 (Phase 10): admin role flag — first-login-wins; Phase 12 tightens bootstrap + isAdmin: boolean('is_admin').default(false).notNull(), }, (t) => [ // Composite unique key — identity is iss+sub, never email (D-10) @@ -48,9 +51,11 @@ export const users = mysqlTable( ); /** - * Encrypted Fastmail app-password credentials per member (D-04). + * Encrypted provider credentials per member (D-04 generic provider shape, Fastmail/CalDAV first). * Keyed by user_id (oidc_sub → user row). Backend-only, never exposed to frontend. * Stored as JSON: { iv, authTag, ciphertext } (AES-256-GCM). + * providerType: generic discriminator (D-04); 'caldav' is the only implemented provider. + * UNIQUE(user_id) enforces one-credential-per-member (D-05) and enables onDuplicateKeyUpdate upsert. */ export const memberCredentials = mysqlTable( 'member_credentials', @@ -64,8 +69,14 @@ export const memberCredentials = mysqlTable( fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(), createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), + // v1.1 (Phase 10, D-04): generic provider discriminator; 'caldav' default for existing rows + providerType: varchar('provider_type', { length: 64 }).notNull().default('caldav'), }, - (t) => [index('idx_member_credentials_user_id').on(t.userId)], + (t) => [ + index('idx_member_credentials_user_id').on(t.userId), + // v1.1 (Phase 10, D-05): one credential per member; enables Drizzle onDuplicateKeyUpdate upsert + unique('uniq_member_credential_user').on(t.userId), + ], ); /** @@ -129,6 +140,8 @@ export const calendarEvents = mysqlTable( // Events with hasRrule=true have dtstartUtc potentially years before any window, // so the windowed query must include them regardless of dtstartUtc range (see RESEARCH.md §Pitfall 5). hasRrule: boolean('has_rrule').default(false).notNull(), + // v1.1 (Phase 10): reminder lead time in minutes; nullable — consumed by Phase 11 + reminderLeadMinutes: int('reminder_lead_minutes'), updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), }, (t) => [ @@ -256,6 +269,22 @@ export const pushSubscriptions = mysqlTable( ], ); +/** + * Global app configuration — simple key/value store. + * + * v1.1 (Phase 10): ships the table; consumed by downstream phases: + * - setup_complete (boolean string 'true'/'false'): Phase 12 setup wizard writes this key + * after first-time setup; Phase 10's first-login-wins admin bootstrap reads it in Phase 12. + * Key: 'setup_complete', Value: 'true' | 'false' | null (not yet set → treated as false). + * + * Do NOT add setup_complete gating logic here — Phase 12 owns that. + */ +export const appConfig = mysqlTable('app_config', { + key: varchar('key', { length: 128 }).primaryKey(), + value: text('value'), // nullable + updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), +}); + /** * Items within a list. *