import { integer, sqliteTable, text, index, unique, uniqueIndex } from 'drizzle-orm/sqlite-core'; import { sql } from 'drizzle-orm'; export const user = sqliteTable('user', { id: text('id').primaryKey(), firstName: text('first_name'), lastName: text('last_name'), email: text('email').unique(), passwordHash: text('password_hash'), appleId: text('apple_id').unique(), googleId: text('google_id').unique(), isPrivate: integer('is_private', { mode: 'boolean' }).default(false), // Additive: existing rows stay NULL ("unknown signup date"). New rows get a // timestamp automatically via the client-side default function. createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()) }); export const session = sqliteTable('session', { id: text('id').primaryKey(), userId: text('user_id').notNull().references(() => user.id), expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull() }); export type Session = typeof session.$inferSelect; export type User = typeof user.$inferSelect; export const dailyVerses = sqliteTable('daily_verses', { id: text('id').primaryKey(), date: text('date').unique().notNull(), bookId: text('book_id').notNull(), verseText: text('verse_text').notNull(), reference: text('reference').notNull(), createdAt: integer('created_at', { mode: 'timestamp' }), }); export type DailyVerse = typeof dailyVerses.$inferSelect; export const dailyCompletions = sqliteTable('daily_completions', { id: text('id').primaryKey(), anonymousId: text('anonymous_id').notNull(), date: text('date').notNull(), guessCount: integer('guess_count').notNull(), guesses: text('guesses'), // nullable; only stored for logged-in users completedAt: integer('completed_at', { mode: 'timestamp' }).notNull(), }, (table) => [ index('anonymous_id_date_idx').on(table.anonymousId, table.date), index('date_idx').on(table.date), index('date_guess_idx').on(table.date, table.guessCount), // Ensures schema matches the database migration and prevents duplicate submissions unique('daily_completions_anonymous_id_date_unique').on(table.anonymousId, table.date), ]); export type DailyCompletion = typeof dailyCompletions.$inferSelect; export const verseSubmissions = sqliteTable('verse_submissions', { id: text('id').primaryKey(), // Nullable so ON DELETE SET NULL can clear attribution when a user is deleted; // the scheduled verse still plays out (canonical text lives on daily_verses). userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), scheduledDate: text('scheduled_date').notNull().unique(), selectedBookId: text('selected_book_id').notNull(), selectedChapter: integer('selected_chapter').notNull(), selectedVerse: integer('selected_verse').notNull(), // Raw integer of server UTC milliseconds — drives the 7-day rolling cooldown. submittedAt: integer('submitted_at').notNull(), }, (table) => [ index('verse_submissions_user_id_idx').on(table.userId), ]); export type VerseSubmission = typeof verseSubmissions.$inferSelect; // ── Statistics dashboard (see statistics-plan.md) ──────────────────────────── // One immutable row per "as of" day. Payload/config/playerState are JSON text. export const statsSnapshot = sqliteTable('stats_snapshot', { // as-of date, YYYY-MM-DD date: text('date').primaryKey(), // build time (server timestamp) generatedAt: integer('generated_at', { mode: 'timestamp' }).notNull(), // scheduled | lazy | reconstructed buildKind: text('build_kind').notNull(), // stable hash of the settings used at build time configVersion: text('config_version').notNull(), // all aggregate series / lab data payload: text('payload').notNull(), // compact per-player state for render-time segmentation (JSON) playerState: text('player_state'), // copy of the resolved settings used at build time (JSON) config: text('config').notNull() }); export type StatsSnapshot = typeof statsSnapshot.$inferSelect; // Generic, extensible event log. Unlocks instrumentation B/D/E/F now; C later. export const analyticsEvents = sqliteTable('analytics_events', { id: text('id').primaryKey(), name: text('name').notNull(), anonymousId: text('anonymous_id'), // Nullable so ON DELETE SET NULL keeps historical events when a user is deleted. userId: text('user_id').references(() => user.id, { onDelete: 'set null' }), date: text('date'), createdAt: integer('created_at', { mode: 'timestamp' }).notNull(), metadata: text('metadata') }, (table) => [ index('analytics_events_name_date_idx').on(table.name, table.date), index('analytics_events_anonymous_id_date_idx').on(table.anonymousId, table.date), // Dedupe verse_served per player per day (reloads), without collapsing // legitimate repeat events like multiple shares in a day. uniqueIndex('analytics_events_serve_unique') .on(table.name, table.anonymousId, table.date) .where(sql`${table.name} = 'verse_served'`) ]); export type AnalyticsEvent = typeof analyticsEvents.$inferSelect; // Simple key/value store for dashboard thresholds (env vars provide defaults). export const dashboardSettings = sqliteTable('dashboard_settings', { key: text('key').primaryKey(), value: text('value').notNull(), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull() }); export type DashboardSetting = typeof dashboardSettings.$inferSelect;