Files
bibdle/src/lib/server/db/schema.ts
T

124 lines
5.3 KiB
TypeScript

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;