mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Completes the half-built dares feature: send a dare, respond, report done, confirm, and get a push notification on every transition. Backend (apps/api): - New `dares` domain with couple-shared premium entitlement, a free weekly send allowance, and server-enforced actor/state rules on every transition. - `submitted` status so the recipient reports completion and the sender confirms, rather than the sender guessing. - Notifications ride the existing operations outbox via a trigger on `sent_dares`. Push copy is generic; store compliance forbids dare text in a preview and a test asserts no payload leaks it. - Worker expiry sweep now covers `pending` as well as `active`/`submitted`; an invitation nobody opened previously stayed open forever. Fixes two latent defects: - Deleting any dare that had been sent aborted with a check violation, because `dare_id` is ON DELETE SET NULL while `sent_dares_dare_or_custom_check` required it. Dare text is now snapshotted at send time, which also keeps a couple's history stable across catalogue edits. - The free send allowance was raceable: concurrent requests all read the same count and every one succeeded. The count and insert are now serialized on the sender's profile row. Also ports `content_status` review metadata to the API catalogue, which had no store-safety gating at all, and mirrors the review decision already enforced on the legacy catalogue. Mobile: dares screen (active/send/history), send sheet with duration presets, home tile badging incoming dares, paywall on both proactive and 402 paths. Verified: lint, typecheck, 379 unit tests, build, 71 integration tests against local PostgreSQL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
80 lines
4.7 KiB
TypeScript
80 lines
4.7 KiB
TypeScript
import { boolean, check, index, integer, numeric, pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
|
import { sql } from 'drizzle-orm';
|
|
import { profiles } from '../../db/schema.js';
|
|
|
|
// `couples`, `categories`, and the review-status enum are owned by other verticals.
|
|
// Their FKs stay in SQL so this fragment does not import across domain boundaries.
|
|
export const darePacks = pgTable('dare_packs', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
name: text('name').notNull(),
|
|
description: text('description'),
|
|
icon: text('icon'),
|
|
isPremium: boolean('is_premium').notNull().default(false),
|
|
isPublic: boolean('is_public').notNull().default(true),
|
|
isExplicit: boolean('is_explicit').notNull().default(false),
|
|
sortOrder: integer('sort_order').notNull().default(0),
|
|
categoryId: uuid('category_id'),
|
|
minIntensity: integer('min_intensity'),
|
|
maxIntensity: integer('max_intensity'),
|
|
avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2 }),
|
|
contentStatus: text('content_status').notNull().default('unreviewed'),
|
|
contentReviewReason: text('content_review_reason'),
|
|
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
|
|
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
}, (table) => [index('dare_packs_content_status_idx').on(table.contentStatus, table.isPublic, table.sortOrder)]);
|
|
|
|
export const dares = pgTable('dares', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
packId: uuid('pack_id').notNull().references(() => darePacks.id, { onDelete: 'cascade' }),
|
|
text: text('text').notNull(),
|
|
intensity: integer('intensity').notNull().default(1),
|
|
suggestedDurationHours: integer('suggested_duration_hours'),
|
|
contentStatus: text('content_status').notNull().default('unreviewed'),
|
|
contentReviewReason: text('content_review_reason'),
|
|
contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }),
|
|
contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
}, (table) => [
|
|
index('dares_pack_idx').on(table.packId),
|
|
index('dares_content_status_idx').on(table.packId, table.contentStatus, table.intensity),
|
|
]);
|
|
|
|
// `dare_text_snapshot` deliberately duplicates the catalogue text: a couple's
|
|
// history must survive the dare being edited, archived, or deleted.
|
|
export const sentDares = pgTable('sent_dares', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
coupleId: uuid('couple_id').notNull(),
|
|
dareId: uuid('dare_id').references(() => dares.id, { onDelete: 'set null' }),
|
|
customDareText: text('custom_dare_text'),
|
|
customDareIntensity: integer('custom_dare_intensity'),
|
|
dareTextSnapshot: text('dare_text_snapshot').notNull(),
|
|
dareIntensitySnapshot: integer('dare_intensity_snapshot').notNull(),
|
|
senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
|
recipientId: uuid('recipient_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
|
status: text('status').notNull().default('pending'),
|
|
senderNotes: text('sender_notes'),
|
|
sentAt: timestamp('sent_at', { withTimezone: true }).notNull().defaultNow(),
|
|
acceptedAt: timestamp('accepted_at', { withTimezone: true }),
|
|
submittedAt: timestamp('submitted_at', { withTimezone: true }),
|
|
completedAt: timestamp('completed_at', { withTimezone: true }),
|
|
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
|
}, (table) => [
|
|
index('sent_dares_couple_idx').on(table.coupleId),
|
|
index('sent_dares_recipient_status_idx').on(table.recipientId, table.status),
|
|
index('sent_dares_sender_sent_at_idx').on(table.senderId, table.sentAt),
|
|
check('sent_dares_participants_distinct_check', sql`${table.senderId} <> ${table.recipientId}`),
|
|
check('sent_dares_status_check', sql`${table.status} in ('pending','active','submitted','completed','expired','declined','cancelled')`),
|
|
]);
|
|
|
|
export const dareMessages = pgTable('dare_messages', {
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
sentDareId: uuid('sent_dare_id').notNull().references(() => sentDares.id, { onDelete: 'cascade' }),
|
|
senderId: uuid('sender_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
|
content: text('content').notNull(),
|
|
readAt: timestamp('read_at', { withTimezone: true }),
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
}, (table) => [index('dare_messages_sent_dare_created_idx').on(table.sentDareId, table.createdAt)]);
|