mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Replaces the notify-me quiz stub with a full loop: quiz sessions per couple, answer-and-guess flow, dual-direction scoring, and a score-only share card (no question or answer content) captured via ViewShot with Sauci branding. API: migration 0019 (quiz_questions, quiz_sessions, quiz_answers, 12 seeded questions), new quiz domain with session start / current / answers / result routes, couple isolation, route + integration + scoring unit tests. Mobile: src/features/quiz feature (intro, per-question self+guess flow, waiting-for-partner nudge, results with breakdown), quizStore with sign-out clearing, QuizShareModal following SharePreviewModal, discovery QuizTile, analytics events, and tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
52 lines
2.6 KiB
TypeScript
52 lines
2.6 KiB
TypeScript
import { boolean, check, index, integer, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
|
import { sql } from 'drizzle-orm';
|
|
import { couples } from '../couples/schema.js';
|
|
|
|
/**
|
|
* Couples quiz catalogue. `promptSelf` and `promptGuess` are two phrasings of the
|
|
* same question so a session can ask each partner about themselves and about the
|
|
* other without a second row.
|
|
*/
|
|
export const quizQuestions = pgTable('quiz_questions', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
promptSelf: text('prompt_self').notNull().unique(),
|
|
promptGuess: text('prompt_guess').notNull(),
|
|
options: text('options').array().notNull(),
|
|
sortOrder: integer('sort_order').notNull().default(0),
|
|
isActive: boolean('is_active').notNull().default(true),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
}, (table) => [
|
|
check('quiz_questions_options_length_check', sql`array_length(${table.options}, 1) between 2 and 4`),
|
|
]);
|
|
|
|
export const quizSessions = pgTable('quiz_sessions', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
|
|
status: text('status').notNull().default('active'),
|
|
questionIds: uuid('question_ids').array().notNull(),
|
|
scorePercent: integer('score_percent'),
|
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
}, (table) => [
|
|
index('quiz_sessions_couple_id_created_at_idx').on(table.coupleId, table.createdAt),
|
|
uniqueIndex('quiz_sessions_one_active_per_couple_idx').on(table.coupleId).where(sql`${table.status} = 'active'`),
|
|
check('quiz_sessions_status_check', sql`${table.status} in ('active','completed')`),
|
|
]);
|
|
|
|
export const quizAnswers = pgTable('quiz_answers', {
|
|
sessionId: uuid('session_id').notNull().references(() => quizSessions.id, { onDelete: 'cascade' }),
|
|
userId: uuid('user_id').notNull(),
|
|
questionId: uuid('question_id').notNull(),
|
|
selfIndex: integer('self_index').notNull(),
|
|
guessIndex: integer('guess_index').notNull(),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
}, (table) => [
|
|
primaryKey({ columns: [table.sessionId, table.userId, table.questionId] }),
|
|
check('quiz_answers_self_index_check', sql`${table.selfIndex} >= 0`),
|
|
check('quiz_answers_guess_index_check', sql`${table.guessIndex} >= 0`),
|
|
]);
|
|
|
|
export type QuizQuestionRow = typeof quizQuestions.$inferSelect;
|
|
export type QuizSessionRow = typeof quizSessions.$inferSelect;
|
|
export type QuizAnswerRow = typeof quizAnswers.$inferSelect;
|