mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(dares): ship the dares loop to mobile
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>
This commit is contained in:
1 parent
a7470310ae
commit
ce0b188776
34 files changed
+2673
-242
No files matched your search
@@ -0,0 +1,223 @@
|
||||
-- Dares loop: catalogue review parity, durable dare instances, and outbox notifications.
|
||||
--
|
||||
-- Three concerns land together because the send path depends on all of them:
|
||||
-- 1. `content_status` parity with the legacy catalogue so unreviewed dares stay hidden.
|
||||
-- 2. Text snapshots on `sent_dares` so catalogue edits never rewrite a couple's history.
|
||||
-- 3. Outbox rows for every status transition, which is the whole retention argument.
|
||||
|
||||
-- `to_regtype` resolves through the active search_path, so this creates the type in
|
||||
-- the current schema rather than skipping because an unrelated schema already has it.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regtype('content_review_status') IS NULL THEN
|
||||
CREATE TYPE content_review_status AS ENUM ('unreviewed', 'allowed', 'archived');
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE dare_packs
|
||||
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
|
||||
ADD COLUMN IF NOT EXISTS content_review_reason text,
|
||||
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE dares
|
||||
ADD COLUMN IF NOT EXISTS content_status content_review_status NOT NULL DEFAULT 'unreviewed',
|
||||
ADD COLUMN IF NOT EXISTS content_review_reason text,
|
||||
ADD COLUMN IF NOT EXISTS content_reviewed_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS content_reviewed_by uuid REFERENCES profiles(id) ON DELETE SET NULL;
|
||||
--> statement-breakpoint
|
||||
COMMENT ON COLUMN dare_packs.content_status IS
|
||||
'Store-safety review state. Only `allowed` packs are exposed to the mobile catalogue.';
|
||||
--> statement-breakpoint
|
||||
COMMENT ON COLUMN dares.content_status IS
|
||||
'Store-safety review state. Only `allowed` dares are sendable.';
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS dare_packs_content_status_idx
|
||||
ON dare_packs (content_status, is_public, sort_order);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS dares_content_status_idx
|
||||
ON dares (pack_id, content_status, intensity);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- `sent_dares.dare_id` is ON DELETE SET NULL, but `sent_dares_dare_or_custom_check`
|
||||
-- requires one of the two source columns to survive. Deleting a dare that had ever
|
||||
-- been sent therefore aborted with a check violation. Snapshotting the text at send
|
||||
-- time removes the dependency entirely and keeps history stable across content edits.
|
||||
ALTER TABLE sent_dares
|
||||
ADD COLUMN IF NOT EXISTS dare_text_snapshot text,
|
||||
ADD COLUMN IF NOT EXISTS dare_intensity_snapshot integer,
|
||||
ADD COLUMN IF NOT EXISTS submitted_at timestamptz,
|
||||
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
|
||||
--> statement-breakpoint
|
||||
UPDATE sent_dares sd
|
||||
SET dare_text_snapshot = COALESCE(sd.custom_dare_text, d.text),
|
||||
dare_intensity_snapshot = COALESCE(sd.custom_dare_intensity, d.intensity)
|
||||
FROM dares d
|
||||
WHERE d.id = sd.dare_id AND sd.dare_text_snapshot IS NULL;
|
||||
--> statement-breakpoint
|
||||
UPDATE sent_dares
|
||||
SET dare_text_snapshot = custom_dare_text,
|
||||
dare_intensity_snapshot = COALESCE(custom_dare_intensity, 1)
|
||||
WHERE dare_text_snapshot IS NULL AND custom_dare_text IS NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE sent_dares
|
||||
ALTER COLUMN dare_text_snapshot SET NOT NULL,
|
||||
ALTER COLUMN dare_intensity_snapshot SET NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_dare_or_custom_check;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_intensity_snapshot_check;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE sent_dares
|
||||
ADD CONSTRAINT sent_dares_intensity_snapshot_check
|
||||
CHECK (dare_intensity_snapshot BETWEEN 1 AND 5);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_participants_distinct_check;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE sent_dares
|
||||
ADD CONSTRAINT sent_dares_participants_distinct_check
|
||||
CHECK (sender_id <> recipient_id);
|
||||
--> statement-breakpoint
|
||||
|
||||
-- `submitted` closes the loop the original design left open: the recipient performs
|
||||
-- the dare, so the recipient reports it done and the sender confirms.
|
||||
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_status_check;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE sent_dares
|
||||
ADD CONSTRAINT sent_dares_status_check CHECK (
|
||||
status IN ('pending', 'active', 'submitted', 'completed', 'expired', 'declined', 'cancelled')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS sent_dares_recipient_status_idx ON sent_dares(recipient_id, status);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS sent_dares_couple_status_idx ON sent_dares(couple_id, status, sent_at DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS sent_dares_sender_sent_at_idx ON sent_dares(sender_id, sent_at DESC);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS sent_dares_expiry_idx
|
||||
ON sent_dares(expires_at) WHERE status IN ('pending', 'active', 'submitted');
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE notification_preferences
|
||||
ADD COLUMN IF NOT EXISTS dares_enabled boolean NOT NULL DEFAULT true;
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE OR REPLACE FUNCTION touch_sent_dare() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS sent_dares_touch ON sent_dares;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER sent_dares_touch BEFORE UPDATE ON sent_dares
|
||||
FOR EACH ROW EXECUTE FUNCTION touch_sent_dare();
|
||||
--> statement-breakpoint
|
||||
|
||||
-- Push copy is deliberately generic. Store compliance forbids dare, question,
|
||||
-- response, or message text in a notification preview.
|
||||
CREATE OR REPLACE FUNCTION queue_dare_operations() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
v_recipients uuid[];
|
||||
v_recipient uuid;
|
||||
v_title text;
|
||||
v_body text;
|
||||
v_event text;
|
||||
BEGIN
|
||||
IF current_setting('sauci.suppress_operations', true) = 'on' THEN RETURN NEW; END IF;
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
v_event := 'sent';
|
||||
v_recipients := ARRAY[NEW.recipient_id];
|
||||
v_title := 'New dare';
|
||||
v_body := 'Your partner sent you a dare';
|
||||
ELSIF NEW.status IS DISTINCT FROM OLD.status THEN
|
||||
v_event := NEW.status;
|
||||
CASE NEW.status
|
||||
WHEN 'active' THEN
|
||||
v_recipients := ARRAY[NEW.sender_id];
|
||||
v_title := 'Dare accepted';
|
||||
v_body := 'Your partner accepted your dare';
|
||||
WHEN 'declined' THEN
|
||||
v_recipients := ARRAY[NEW.sender_id];
|
||||
v_title := 'Dare declined';
|
||||
v_body := 'Your partner passed on your dare';
|
||||
WHEN 'submitted' THEN
|
||||
v_recipients := ARRAY[NEW.sender_id];
|
||||
v_title := 'Dare done';
|
||||
v_body := 'Your partner says they completed your dare';
|
||||
WHEN 'completed' THEN
|
||||
v_recipients := ARRAY[NEW.recipient_id];
|
||||
v_title := 'Dare complete';
|
||||
v_body := 'Your partner confirmed your dare';
|
||||
WHEN 'cancelled' THEN
|
||||
v_recipients := ARRAY[NEW.recipient_id];
|
||||
v_title := 'Dare cancelled';
|
||||
v_body := 'Your partner cancelled the dare';
|
||||
WHEN 'expired' THEN
|
||||
-- Both sides lose the dare, so both sides hear about it.
|
||||
v_recipients := ARRAY[NEW.sender_id, NEW.recipient_id];
|
||||
v_title := 'Dare expired';
|
||||
v_body := 'A dare ran out of time';
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END CASE;
|
||||
ELSE
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
FOREACH v_recipient IN ARRAY v_recipients LOOP
|
||||
CONTINUE WHEN NOT EXISTS (
|
||||
SELECT 1 FROM profiles p
|
||||
LEFT JOIN notification_preferences np ON np.user_id = p.id
|
||||
WHERE p.id = v_recipient AND COALESCE(np.dares_enabled, true) = true
|
||||
);
|
||||
|
||||
INSERT INTO operations_outbox(kind, dedupe_key, recipient_id, payload)
|
||||
VALUES (
|
||||
'expo',
|
||||
'dare:' || NEW.id || ':' || v_event || ':' || v_recipient,
|
||||
v_recipient,
|
||||
jsonb_build_object(
|
||||
'title', v_title,
|
||||
'body', v_body,
|
||||
'data', jsonb_build_object('type', 'dare', 'event', v_event, 'sent_dare_id', NEW.id)
|
||||
)
|
||||
)
|
||||
ON CONFLICT (dedupe_key) DO NOTHING;
|
||||
END LOOP;
|
||||
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DROP TRIGGER IF EXISTS operations_dare_changed ON sent_dares;
|
||||
--> statement-breakpoint
|
||||
CREATE TRIGGER operations_dare_changed AFTER INSERT OR UPDATE ON sent_dares
|
||||
FOR EACH ROW EXECUTE FUNCTION queue_dare_operations();
|
||||
|
||||
--> statement-breakpoint
|
||||
-- Mirror the review decision already enforced on the legacy catalogue
|
||||
-- (20260828114256_enforce_allowed_catalogue): "Romantic Gestures" passed store-safety
|
||||
-- review, the other seeded packs did not. Only rows still `unreviewed` are touched, so
|
||||
-- this never overrides a later decision made in the admin portal.
|
||||
UPDATE dare_packs
|
||||
SET content_status = 'allowed',
|
||||
content_review_reason = 'Reviewed as non-sexual romantic gestures'
|
||||
WHERE id = 'a1b2c3d4-1111-1111-1111-111111111111'::uuid AND content_status = 'unreviewed';
|
||||
--> statement-breakpoint
|
||||
UPDATE dare_packs
|
||||
SET content_status = 'archived',
|
||||
content_review_reason = 'Pack contains suggestive, sexual, dominance, or fantasy dares'
|
||||
WHERE id IN (
|
||||
'a1b2c3d4-2222-2222-2222-222222222222'::uuid,
|
||||
'a1b2c3d4-3333-3333-3333-333333333333'::uuid,
|
||||
'a1b2c3d4-4444-4444-4444-444444444444'::uuid
|
||||
) AND content_status = 'unreviewed';
|
||||
--> statement-breakpoint
|
||||
UPDATE dares d
|
||||
SET content_status = dp.content_status,
|
||||
content_review_reason = CASE
|
||||
WHEN dp.content_status = 'allowed' THEN 'Reviewed with Romantic Gestures pack'
|
||||
ELSE 'Archived with non-store-safe dare pack'
|
||||
END
|
||||
FROM dare_packs dp
|
||||
WHERE d.pack_id = dp.id AND d.content_status = 'unreviewed' AND dp.content_status <> 'unreviewed';
|
||||
@@ -92,6 +92,13 @@
|
||||
"when": 1787908500000,
|
||||
"tag": "0012_catalogue_field_parity",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1787909400000,
|
||||
"tag": "0013_dares_loop",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import type { AnswersRepository } from './domains/answers/repository.js';
|
||||
import { registerAnswerRoutes } from './domains/answers/routes.js';
|
||||
import type { ChatRepository } from './domains/chat/repository.js';
|
||||
import { registerChatRoutes } from './domains/chat/routes.js';
|
||||
import type { DaresRepository } from './domains/dares/repository.js';
|
||||
import { registerDareRoutes } from './domains/dares/routes.js';
|
||||
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
||||
import type { CoupleService } from './domains/couples/service.js';
|
||||
import type { PacksRepository } from './domains/packs/repository.js';
|
||||
@@ -35,6 +37,7 @@ export interface AppDependencies {
|
||||
packsRepository?: PacksRepository;
|
||||
answersRepository?: AnswersRepository;
|
||||
chatRepository?: ChatRepository;
|
||||
daresRepository?: DaresRepository;
|
||||
profileSettingsRepository?: ProfileSettingsRepository;
|
||||
accountOperationsService?: AccountOperationsService;
|
||||
mediaRepository?: MediaRepository;
|
||||
@@ -109,6 +112,7 @@ export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }>
|
||||
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
|
||||
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
||||
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
||||
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
|
||||
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
||||
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
||||
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
|
||||
export type DareStatus =
|
||||
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
|
||||
|
||||
/** Free senders get a rolling weekly allowance; receiving and responding are never gated. */
|
||||
export const FREE_WEEKLY_SEND_LIMIT = 3;
|
||||
|
||||
/** Durations offered in the send sheet. `null` means no deadline. */
|
||||
export const DURATION_PRESET_HOURS = [1, 6, 12, 24, 72, 168] as const;
|
||||
|
||||
export interface DarePack {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
icon: string | null;
|
||||
is_premium: boolean;
|
||||
is_explicit: boolean;
|
||||
sort_order: number;
|
||||
category_id: string | null;
|
||||
min_intensity: number | null;
|
||||
max_intensity: number | null;
|
||||
avg_intensity: number | null;
|
||||
dare_count: number;
|
||||
}
|
||||
|
||||
export interface DareItem {
|
||||
id: string;
|
||||
pack_id: string;
|
||||
text: string;
|
||||
intensity: number;
|
||||
suggested_duration_hours: number | null;
|
||||
}
|
||||
|
||||
export interface SentDare {
|
||||
id: string;
|
||||
couple_id: string;
|
||||
dare_id: string | null;
|
||||
text: string;
|
||||
intensity: number;
|
||||
is_custom: boolean;
|
||||
sender_id: string;
|
||||
recipient_id: string;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
status: DareStatus;
|
||||
sender_notes: string | null;
|
||||
sent_at: string;
|
||||
accepted_at: string | null;
|
||||
submitted_at: string | null;
|
||||
completed_at: string | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface DareEntitlement {
|
||||
is_premium: boolean;
|
||||
can_send_custom: boolean;
|
||||
weekly_send_limit: number | null;
|
||||
sends_remaining: number | null;
|
||||
}
|
||||
|
||||
export interface DareCatalog {
|
||||
entitlement: DareEntitlement;
|
||||
packs: DarePack[];
|
||||
}
|
||||
|
||||
export interface DareStats {
|
||||
sent: number;
|
||||
received: number;
|
||||
completed_together: number;
|
||||
active: number;
|
||||
completed_by_me: number;
|
||||
completed_by_partner: number;
|
||||
}
|
||||
|
||||
export interface SendDareInput {
|
||||
dare_id?: string | null;
|
||||
custom_dare_text?: string | null;
|
||||
custom_dare_intensity?: number | null;
|
||||
duration_hours?: number | null;
|
||||
sender_notes?: string | null;
|
||||
}
|
||||
|
||||
export class DaresError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly status: 400 | 402 | 403 | 404 | 409,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
interface ContextRow extends QueryResultRow {
|
||||
couple_id: string | null;
|
||||
is_premium: boolean;
|
||||
hide_nsfw: boolean;
|
||||
max_intensity: number;
|
||||
partner_id: string | null;
|
||||
partner_premium: boolean;
|
||||
}
|
||||
|
||||
interface SentDareRecord extends QueryResultRow {
|
||||
id: string;
|
||||
couple_id: string;
|
||||
dare_id: string | null;
|
||||
dare_text_snapshot: string;
|
||||
dare_intensity_snapshot: number;
|
||||
custom_dare_text: string | null;
|
||||
sender_id: string;
|
||||
recipient_id: string;
|
||||
status: DareStatus;
|
||||
sender_notes: string | null;
|
||||
sent_at: Date;
|
||||
accepted_at: Date | null;
|
||||
submitted_at: Date | null;
|
||||
completed_at: Date | null;
|
||||
expires_at: Date | null;
|
||||
}
|
||||
|
||||
export interface DaresRepository {
|
||||
getCatalog(userId: string): Promise<DareCatalog>;
|
||||
listPackDares(userId: string, packId: string): Promise<DareItem[]>;
|
||||
listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>;
|
||||
send(userId: string, input: SendDareInput): Promise<SentDare>;
|
||||
respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>;
|
||||
submit(userId: string, dareId: string): Promise<SentDare>;
|
||||
complete(userId: string, dareId: string): Promise<SentDare>;
|
||||
cancel(userId: string, dareId: string): Promise<SentDare>;
|
||||
stats(userId: string): Promise<DareStats>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot,
|
||||
custom_dare_text, sender_id, recipient_id, status, sender_notes,
|
||||
sent_at, accepted_at, submitted_at, completed_at, expires_at`;
|
||||
|
||||
const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', ');
|
||||
|
||||
/** Statuses still awaiting somebody's action; everything else is history. */
|
||||
const OPEN_STATUSES: readonly DareStatus[] = ['pending', 'active', 'submitted'];
|
||||
|
||||
function iso(value: Date | null): string | null {
|
||||
return value?.toISOString() ?? null;
|
||||
}
|
||||
|
||||
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('begin');
|
||||
const result = await operation(client);
|
||||
await client.query('commit');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('rollback');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
|
||||
return {
|
||||
id: row.id,
|
||||
couple_id: row.couple_id,
|
||||
dare_id: row.dare_id,
|
||||
text: row.dare_text_snapshot,
|
||||
intensity: row.dare_intensity_snapshot,
|
||||
is_custom: row.custom_dare_text !== null,
|
||||
sender_id: row.sender_id,
|
||||
recipient_id: row.recipient_id,
|
||||
direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing',
|
||||
status: row.status,
|
||||
sender_notes: row.sender_notes,
|
||||
sent_at: row.sent_at.toISOString(),
|
||||
accepted_at: iso(row.accepted_at),
|
||||
submitted_at: iso(row.submitted_at),
|
||||
completed_at: iso(row.completed_at),
|
||||
expires_at: iso(row.expires_at),
|
||||
};
|
||||
}
|
||||
|
||||
export class PostgresDaresRepository implements DaresRepository {
|
||||
private readonly pool: Pool;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
}
|
||||
|
||||
/**
|
||||
* Premium is couple-shared, matching the answers domain: either partner paying
|
||||
* unlocks the catalogue for both.
|
||||
*/
|
||||
private async context(userId: string): Promise<ContextRow> {
|
||||
const result = await this.pool.query<ContextRow>(
|
||||
`select p.couple_id, p.is_premium, p.hide_nsfw, p.max_intensity,
|
||||
partner.id as partner_id,
|
||||
coalesce(partner.is_premium, false) as partner_premium
|
||||
from profiles p
|
||||
left join lateral (
|
||||
select id, is_premium from profiles other
|
||||
where other.couple_id = p.couple_id and other.id <> p.id
|
||||
order by other.id limit 1
|
||||
) partner on true
|
||||
where p.id = $1`,
|
||||
[userId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new DaresError('profile_not_found', 'Profile not found', 404);
|
||||
return row;
|
||||
}
|
||||
|
||||
private static isPremium(context: ContextRow): boolean {
|
||||
return context.is_premium || context.partner_premium;
|
||||
}
|
||||
|
||||
private async sendsThisWeek(userId: string, client: Pool | PoolClient = this.pool): Promise<number> {
|
||||
const result = await client.query<{ count: string }>(
|
||||
`select count(*) as count from sent_dares
|
||||
where sender_id = $1 and sent_at > now() - interval '7 days'`,
|
||||
[userId],
|
||||
);
|
||||
return Number(result.rows[0]?.count ?? 0);
|
||||
}
|
||||
|
||||
private async entitlement(context: ContextRow, userId: string): Promise<DareEntitlement> {
|
||||
const premium = PostgresDaresRepository.isPremium(context);
|
||||
if (premium) {
|
||||
return { is_premium: true, can_send_custom: true, weekly_send_limit: null, sends_remaining: null };
|
||||
}
|
||||
const used = await this.sendsThisWeek(userId);
|
||||
return {
|
||||
is_premium: false,
|
||||
can_send_custom: false,
|
||||
weekly_send_limit: FREE_WEEKLY_SEND_LIMIT,
|
||||
sends_remaining: Math.max(0, FREE_WEEKLY_SEND_LIMIT - used),
|
||||
};
|
||||
}
|
||||
|
||||
async getCatalog(userId: string): Promise<DareCatalog> {
|
||||
const context = await this.context(userId);
|
||||
const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity;
|
||||
const [entitlement, packs] = await Promise.all([
|
||||
this.entitlement(context, userId),
|
||||
this.pool.query<DarePack & QueryResultRow>(
|
||||
`select dp.id, dp.name, dp.description, dp.icon, dp.is_premium, dp.is_explicit,
|
||||
dp.sort_order, dp.category_id, dp.min_intensity, dp.max_intensity,
|
||||
dp.avg_intensity::double precision as avg_intensity,
|
||||
count(d.id) filter (where d.content_status = 'allowed')::integer as dare_count
|
||||
from dare_packs dp
|
||||
left join categories c on c.id = dp.category_id
|
||||
left join dares d on d.pack_id = dp.id
|
||||
where dp.is_public = true
|
||||
and dp.content_status = 'allowed'
|
||||
and (dp.category_id is null or c.is_public = true)
|
||||
and ($1::boolean = false or dp.is_explicit = false)
|
||||
and (dp.min_intensity is null or dp.min_intensity <= $2::integer)
|
||||
group by dp.id
|
||||
having count(d.id) filter (where d.content_status = 'allowed') > 0
|
||||
order by dp.sort_order, dp.name, dp.id`,
|
||||
[context.hide_nsfw, maxIntensity],
|
||||
),
|
||||
]);
|
||||
return { entitlement, packs: packs.rows };
|
||||
}
|
||||
|
||||
async listPackDares(userId: string, packId: string): Promise<DareItem[]> {
|
||||
const context = await this.context(userId);
|
||||
const pack = await this.pool.query<{ is_premium: boolean; is_explicit: boolean }>(
|
||||
`select dp.is_premium, dp.is_explicit
|
||||
from dare_packs dp
|
||||
left join categories c on c.id = dp.category_id
|
||||
where dp.id = $1 and dp.is_public = true and dp.content_status = 'allowed'
|
||||
and (dp.category_id is null or c.is_public = true)`,
|
||||
[packId],
|
||||
);
|
||||
const row = pack.rows[0];
|
||||
if (!row) throw new DaresError('pack_not_found', 'Dare pack not found', 404);
|
||||
if (row.is_explicit && context.hide_nsfw) {
|
||||
throw new DaresError('pack_not_found', 'Dare pack not found', 404);
|
||||
}
|
||||
if (row.is_premium && !PostgresDaresRepository.isPremium(context)) {
|
||||
throw new DaresError('premium_required', 'This dare pack requires premium', 402);
|
||||
}
|
||||
const maxIntensity = context.hide_nsfw ? 2 : context.max_intensity;
|
||||
const result = await this.pool.query<DareItem & QueryResultRow>(
|
||||
`select d.id, d.pack_id, d.text, d.intensity, d.suggested_duration_hours
|
||||
from dares d
|
||||
where d.pack_id = $1 and d.content_status = 'allowed' and d.intensity <= $2::integer
|
||||
order by d.intensity, d.created_at, d.id`,
|
||||
[packId, maxIntensity],
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]> {
|
||||
const context = await this.context(userId);
|
||||
if (!context.couple_id) return [];
|
||||
const result = await this.pool.query<SentDareRecord>(
|
||||
`select ${SENT_COLUMNS}
|
||||
from sent_dares sd
|
||||
where sd.couple_id = $1
|
||||
and (sd.status = any($2::text[])) = $3::boolean
|
||||
order by sd.sent_at desc, sd.id desc
|
||||
limit 200`,
|
||||
[context.couple_id, OPEN_STATUSES, filter === 'active'],
|
||||
);
|
||||
return result.rows.map((row) => toSentDare(row, userId));
|
||||
}
|
||||
|
||||
async send(userId: string, input: SendDareInput): Promise<SentDare> {
|
||||
const context = await this.context(userId);
|
||||
if (!context.couple_id || !context.partner_id) {
|
||||
throw new DaresError('no_couple', 'Pair with your partner before sending dares', 409);
|
||||
}
|
||||
const premium = PostgresDaresRepository.isPremium(context);
|
||||
|
||||
const duration = input.duration_hours ?? null;
|
||||
if (duration !== null && !DURATION_PRESET_HOURS.includes(duration as never)) {
|
||||
throw new DaresError('invalid_duration', 'Duration must be one of the offered presets', 400);
|
||||
}
|
||||
|
||||
let text: string;
|
||||
let intensity: number;
|
||||
let dareId: string | null = null;
|
||||
let customText: string | null = null;
|
||||
let customIntensity: number | null = null;
|
||||
|
||||
if (input.dare_id) {
|
||||
// Re-check entitlement against the pack so a stale client cannot send a locked dare.
|
||||
const dare = await this.pool.query<{ text: string; intensity: number; is_premium: boolean; is_explicit: boolean }>(
|
||||
`select d.text, d.intensity, dp.is_premium, dp.is_explicit
|
||||
from dares d
|
||||
join dare_packs dp on dp.id = d.pack_id
|
||||
left join categories c on c.id = dp.category_id
|
||||
where d.id = $1 and d.content_status = 'allowed' and dp.content_status = 'allowed'
|
||||
and dp.is_public = true and (dp.category_id is null or c.is_public = true)`,
|
||||
[input.dare_id],
|
||||
);
|
||||
const row = dare.rows[0];
|
||||
if (!row) throw new DaresError('dare_not_found', 'Dare not found', 404);
|
||||
if (row.is_explicit && context.hide_nsfw) {
|
||||
throw new DaresError('dare_not_found', 'Dare not found', 404);
|
||||
}
|
||||
if (row.is_premium && !premium) {
|
||||
throw new DaresError('premium_required', 'This dare requires premium', 402);
|
||||
}
|
||||
dareId = input.dare_id;
|
||||
text = row.text;
|
||||
intensity = row.intensity;
|
||||
} else {
|
||||
if (!premium) throw new DaresError('premium_required', 'Custom dares require premium', 402);
|
||||
const trimmed = input.custom_dare_text?.trim();
|
||||
if (!trimmed) throw new DaresError('invalid_dare', 'Custom dare text is required', 400);
|
||||
customText = trimmed;
|
||||
customIntensity = input.custom_dare_intensity ?? 1;
|
||||
text = trimmed;
|
||||
intensity = customIntensity;
|
||||
}
|
||||
|
||||
// The quota is a paywall boundary, so the count and the insert have to be atomic:
|
||||
// locking the sender's profile row serializes concurrent sends by the same user.
|
||||
return transaction(this.pool, async (client) => {
|
||||
if (!premium) {
|
||||
await client.query('select 1 from profiles where id = $1 for update', [userId]);
|
||||
const used = await this.sendsThisWeek(userId, client);
|
||||
if (used >= FREE_WEEKLY_SEND_LIMIT) {
|
||||
throw new DaresError('send_limit_reached', 'Weekly dare limit reached', 402);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await client.query<SentDareRecord>(
|
||||
`insert into sent_dares (
|
||||
couple_id, dare_id, custom_dare_text, custom_dare_intensity,
|
||||
dare_text_snapshot, dare_intensity_snapshot,
|
||||
sender_id, recipient_id, sender_notes, expires_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
case when $10::integer is null then null else now() + ($10::integer * interval '1 hour') end
|
||||
)
|
||||
returning ${BARE_COLUMNS}`,
|
||||
[
|
||||
context.couple_id, dareId, customText, customIntensity, text, intensity,
|
||||
userId, context.partner_id, input.sender_notes?.trim() || null, duration,
|
||||
],
|
||||
);
|
||||
return toSentDare(result.rows[0]!, userId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a status change, enforcing both the actor's role and the legal
|
||||
* transitions out of the current status. Notifications are emitted by the
|
||||
* `operations_dare_changed` trigger, so no enqueue happens here.
|
||||
*/
|
||||
private async transition(
|
||||
userId: string,
|
||||
dareId: string,
|
||||
actor: 'sender' | 'recipient',
|
||||
from: readonly DareStatus[],
|
||||
to: DareStatus,
|
||||
timestampColumn: 'accepted_at' | 'submitted_at' | 'completed_at' | null,
|
||||
): Promise<SentDare> {
|
||||
const actorColumn = actor === 'sender' ? 'sender_id' : 'recipient_id';
|
||||
const setTimestamp = timestampColumn ? `, ${timestampColumn} = now()` : '';
|
||||
const result = await this.pool.query<SentDareRecord>(
|
||||
`update sent_dares sd
|
||||
set status = $3::text${setTimestamp}
|
||||
where sd.id = $1 and sd.${actorColumn} = $2 and sd.status = any($4::text[])
|
||||
returning ${SENT_COLUMNS}`,
|
||||
[dareId, userId, to, from],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
if (row) return toSentDare(row, userId);
|
||||
|
||||
// Distinguish "not yours / gone" from "wrong state" so the client can react.
|
||||
const existing = await this.pool.query<{ sender_id: string; recipient_id: string; status: DareStatus }>(
|
||||
'select sender_id, recipient_id, status from sent_dares where id = $1',
|
||||
[dareId],
|
||||
);
|
||||
const found = existing.rows[0];
|
||||
if (!found || (found.sender_id !== userId && found.recipient_id !== userId)) {
|
||||
throw new DaresError('dare_not_found', 'Dare not found', 404);
|
||||
}
|
||||
const actorId = actor === 'sender' ? found.sender_id : found.recipient_id;
|
||||
if (actorId !== userId) {
|
||||
throw new DaresError('not_permitted', `Only the ${actor} can do that`, 403);
|
||||
}
|
||||
throw new DaresError('invalid_transition', `A ${found.status} dare cannot change to ${to}`, 409);
|
||||
}
|
||||
|
||||
async respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare> {
|
||||
return action === 'accept'
|
||||
? this.transition(userId, dareId, 'recipient', ['pending'], 'active', 'accepted_at')
|
||||
: this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null);
|
||||
}
|
||||
|
||||
async submit(userId: string, dareId: string): Promise<SentDare> {
|
||||
return this.transition(userId, dareId, 'recipient', ['active'], 'submitted', 'submitted_at');
|
||||
}
|
||||
|
||||
async complete(userId: string, dareId: string): Promise<SentDare> {
|
||||
return this.transition(userId, dareId, 'sender', ['active', 'submitted'], 'completed', 'completed_at');
|
||||
}
|
||||
|
||||
async cancel(userId: string, dareId: string): Promise<SentDare> {
|
||||
return this.transition(userId, dareId, 'sender', ['pending', 'active', 'submitted'], 'cancelled', null);
|
||||
}
|
||||
|
||||
async stats(userId: string): Promise<DareStats> {
|
||||
const context = await this.context(userId);
|
||||
if (!context.couple_id) {
|
||||
return { sent: 0, received: 0, completed_together: 0, active: 0, completed_by_me: 0, completed_by_partner: 0 };
|
||||
}
|
||||
const result = await this.pool.query<Record<string, string>>(
|
||||
`select
|
||||
count(*) filter (where sender_id = $2)::text as sent,
|
||||
count(*) filter (where recipient_id = $2)::text as received,
|
||||
count(*) filter (where status = 'completed')::text as completed_together,
|
||||
count(*) filter (where status = any($3::text[]))::text as active,
|
||||
count(*) filter (where status = 'completed' and recipient_id = $2)::text as completed_by_me,
|
||||
count(*) filter (where status = 'completed' and sender_id = $2)::text as completed_by_partner
|
||||
from sent_dares where couple_id = $1`,
|
||||
[context.couple_id, userId, OPEN_STATUSES],
|
||||
);
|
||||
const row = result.rows[0] ?? {};
|
||||
const read = (key: string): number => Number(row[key] ?? 0);
|
||||
return {
|
||||
sent: read('sent'),
|
||||
received: read('received'),
|
||||
completed_together: read('completed_together'),
|
||||
active: read('active'),
|
||||
completed_by_me: read('completed_by_me'),
|
||||
completed_by_partner: read('completed_by_partner'),
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { ApiErrorResponse } from '@sauci/shared';
|
||||
import type { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import type { AuthIdentity } from '../../auth.js';
|
||||
import { DURATION_PRESET_HOURS, DaresError, type DaresRepository } from './repository.js';
|
||||
|
||||
type DaresApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
|
||||
const id = z.string().uuid();
|
||||
|
||||
const sendBody = z
|
||||
.object({
|
||||
dare_id: id.nullish(),
|
||||
custom_dare_text: z.string().trim().min(1).max(500).nullish(),
|
||||
custom_dare_intensity: z.number().int().min(1).max(5).nullish(),
|
||||
duration_hours: z.union([z.literal(1), z.literal(6), z.literal(12), z.literal(24), z.literal(72), z.literal(168)]).nullish(),
|
||||
sender_notes: z.string().trim().max(500).nullish(),
|
||||
})
|
||||
.strict()
|
||||
.refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), {
|
||||
message: 'Provide either dare_id or custom_dare_text',
|
||||
});
|
||||
|
||||
const respondBody = z.object({ action: z.enum(['accept', 'decline']) }).strict();
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
}
|
||||
|
||||
async function result<T>(operation: () => Promise<T>) {
|
||||
try {
|
||||
return { ok: true as const, value: await operation() };
|
||||
} catch (cause) {
|
||||
if (cause instanceof DaresError) return { ok: false as const, cause };
|
||||
throw cause;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerDareRoutes(app: DaresApp, repository: DaresRepository): void {
|
||||
app.get('/v1/dares/durations', (c) => c.json({ presets: DURATION_PRESET_HOURS }));
|
||||
|
||||
app.get('/v1/dares/packs', async (c) => {
|
||||
const response = await result(() => repository.getCatalog(c.get('identity').id));
|
||||
return response.ok
|
||||
? c.json(response.value)
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/dares/packs/:packId/dares', async (c) => {
|
||||
const packId = id.safeParse(c.req.param('packId'));
|
||||
if (!packId.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
|
||||
const response = await result(() => repository.listPackDares(c.get('identity').id, packId.data));
|
||||
return response.ok
|
||||
? c.json({ dares: response.value })
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/dares', async (c) => {
|
||||
const filter = c.req.query('filter') ?? 'active';
|
||||
if (filter !== 'active' && filter !== 'history') {
|
||||
return c.json(error('invalid_filter', 'Filter must be active or history'), 400);
|
||||
}
|
||||
const response = await result(() => repository.listDares(c.get('identity').id, filter));
|
||||
return response.ok
|
||||
? c.json({ dares: response.value })
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.get('/v1/dares/stats', async (c) => {
|
||||
const response = await result(() => repository.stats(c.get('identity').id));
|
||||
return response.ok
|
||||
? c.json(response.value)
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/dares', async (c) => {
|
||||
const body = sendBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!body.success) return c.json(error('invalid_dare', 'Provide either a dare_id or custom dare text'), 400);
|
||||
const response = await result(() => repository.send(c.get('identity').id, body.data));
|
||||
return response.ok
|
||||
? c.json({ dare: response.value }, 201)
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/dares/:dareId/respond', async (c) => {
|
||||
const dareId = id.safeParse(c.req.param('dareId'));
|
||||
const body = respondBody.safeParse(await c.req.json().catch(() => ({})));
|
||||
if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400);
|
||||
if (!body.success) return c.json(error('invalid_action', 'Action must be accept or decline'), 400);
|
||||
const response = await result(() => repository.respond(c.get('identity').id, dareId.data, body.data.action));
|
||||
return response.ok
|
||||
? c.json({ dare: response.value })
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
for (const [path, method] of [
|
||||
['submit', 'submit'],
|
||||
['complete', 'complete'],
|
||||
['cancel', 'cancel'],
|
||||
] as const) {
|
||||
app.post(`/v1/dares/:dareId/${path}`, async (c) => {
|
||||
const dareId = id.safeParse(c.req.param('dareId'));
|
||||
if (!dareId.success) return c.json(error('invalid_dare_id', 'A valid dare ID is required'), 400);
|
||||
const response = await result(() => repository[method](c.get('identity').id, dareId.data));
|
||||
return response.ok
|
||||
? c.json({ dare: response.value })
|
||||
: c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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)]);
|
||||
@@ -36,7 +36,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
||||
async produce(now: Date, limit=100): Promise<ProducerSummary> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked");
|
||||
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
|
||||
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,daresExpired:0};
|
||||
if (!lock.rows[0]?.locked) return empty;
|
||||
|
||||
const released=await client.query<{id:string;name:string}>(
|
||||
@@ -138,9 +138,19 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
||||
from eligible e on conflict(dedupe_key) do nothing returning recipient_id
|
||||
) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]);
|
||||
|
||||
// Expiring pending dares too: the original design only swept `active`, so an
|
||||
// invitation the recipient never opened stayed open forever.
|
||||
const expiredDares=await client.query(
|
||||
`with due as (
|
||||
select id from sent_dares
|
||||
where expires_at is not null and expires_at<=$1
|
||||
and status in ('pending','active','submitted')
|
||||
order by expires_at for update skip locked limit $2
|
||||
) update sent_dares sd set status='expired' from due d where sd.id=d.id returning sd.id`, [now,limit]);
|
||||
|
||||
const catchup=await this.produceCatchups(client,now,limit);
|
||||
return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0,
|
||||
packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,catchupReminders:catchup};
|
||||
packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,catchupReminders:catchup,daresExpired:expiredDares.rowCount??0};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -48,4 +48,5 @@ export interface ProducerSummary {
|
||||
weeklySummaries: number;
|
||||
unpairedReminders: number;
|
||||
catchupReminders: number;
|
||||
daresExpired: number;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ interface PreferencesRow extends QueryResultRow {
|
||||
weekly_summary_enabled: boolean;
|
||||
unpaired_reminders_enabled: boolean;
|
||||
catchup_reminders_enabled: boolean;
|
||||
dares_enabled: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
@@ -64,6 +65,7 @@ function toPreferences(row: PreferencesRow): NotificationPreferences {
|
||||
weekly_summary_enabled: row.weekly_summary_enabled,
|
||||
unpaired_reminders_enabled: row.unpaired_reminders_enabled,
|
||||
catchup_reminders_enabled: row.catchup_reminders_enabled,
|
||||
dares_enabled: row.dares_enabled,
|
||||
created_at: row.created_at.toISOString(),
|
||||
updated_at: row.updated_at.toISOString(),
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ export const notificationPreferenceKeys = [
|
||||
'weekly_summary_enabled',
|
||||
'unpaired_reminders_enabled',
|
||||
'catchup_reminders_enabled',
|
||||
'dares_enabled',
|
||||
] as const;
|
||||
|
||||
export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number];
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from './domains/account-operations/index.js';
|
||||
import { PostgresAnswersRepository } from './domains/answers/repository.js';
|
||||
import { PostgresChatRepository } from './domains/chat/repository.js';
|
||||
import { PostgresDaresRepository } from './domains/dares/repository.js';
|
||||
import { PostgresCoupleRepository } from './domains/couples/repository.js';
|
||||
import { CoupleService } from './domains/couples/service.js';
|
||||
import { PostgresPacksRepository } from './domains/packs/repository.js';
|
||||
@@ -30,6 +31,7 @@ const coupleRepository = new PostgresCoupleRepository(config.databaseUrl);
|
||||
const packsRepository = new PostgresPacksRepository(config.databaseUrl);
|
||||
const answersRepository = new PostgresAnswersRepository(config.databaseUrl);
|
||||
const chatRepository = new PostgresChatRepository(config.databaseUrl);
|
||||
const daresRepository = new PostgresDaresRepository(config.databaseUrl);
|
||||
const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl);
|
||||
const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl);
|
||||
const accountOperationsService = new AccountOperationsService(
|
||||
@@ -59,6 +61,7 @@ const app = createApp({
|
||||
packsRepository,
|
||||
answersRepository,
|
||||
chatRepository,
|
||||
daresRepository,
|
||||
profileSettingsRepository,
|
||||
accountOperationsService,
|
||||
mediaRepository,
|
||||
@@ -97,6 +100,7 @@ async function shutdown(signal: string): Promise<void> {
|
||||
packsRepository.close(),
|
||||
answersRepository.close(),
|
||||
chatRepository.close(),
|
||||
daresRepository.close(),
|
||||
profileSettingsRepository.close(),
|
||||
accountOperationsRepository.close(),
|
||||
mediaRepository.close(),
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { Pool } from 'pg';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { FREE_WEEKLY_SEND_LIMIT, PostgresDaresRepository } from '../src/domains/dares/repository.js';
|
||||
|
||||
const url = process.env.DATABASE_URL;
|
||||
const local = url ? ['127.0.0.1', 'localhost', '::1'].includes(new URL(url).hostname) : false;
|
||||
if (url && !local) throw new Error('Dare integration tests only permit localhost');
|
||||
|
||||
describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
|
||||
const admin = new Pool({ connectionString: url });
|
||||
const schema = `dares_${randomUUID().replaceAll('-', '')}`;
|
||||
let pool: Pool;
|
||||
let repo: PostgresDaresRepository;
|
||||
|
||||
const sender = '11111111-1111-4111-8111-111111111111';
|
||||
const recipient = '22222222-2222-4222-8222-222222222222';
|
||||
const outsider = '33333333-3333-4333-8333-333333333333';
|
||||
const couple = '44444444-4444-4444-8444-444444444444';
|
||||
const otherCouple = '55555555-5555-4555-8555-555555555555';
|
||||
const freePack = '66666666-6666-4666-8666-666666666666';
|
||||
const premiumPack = '77777777-7777-4777-8777-777777777777';
|
||||
const unreviewedPack = '88888888-8888-4888-8888-888888888888';
|
||||
const freeDare = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
const premiumDare = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||||
const unreviewedDare = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||
|
||||
beforeAll(async () => {
|
||||
await admin.query(`create schema "${schema}"`);
|
||||
const isolated = new URL(url!);
|
||||
isolated.searchParams.set('options', `-c search_path=${schema}`);
|
||||
pool = new Pool({ connectionString: isolated.toString() });
|
||||
repo = new PostgresDaresRepository(isolated.toString());
|
||||
|
||||
const dir = new URL('../drizzle/', import.meta.url);
|
||||
const files = (await readdir(dir)).filter((name) => name.endsWith('.sql')).sort();
|
||||
for (const name of files) {
|
||||
const sql = await readFile(new URL(name, dir), 'utf8');
|
||||
for (const statement of sql.split('--> statement-breakpoint')) {
|
||||
if (statement.trim()) await pool.query(statement);
|
||||
}
|
||||
}
|
||||
|
||||
await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)', [couple, 'DARESAA1', otherCouple, 'DARESBB2']);
|
||||
await pool.query(
|
||||
'insert into profiles(id,couple_id,is_premium,hide_nsfw,max_intensity) values($1,$4,false,false,5),($2,$4,false,false,5),($3,$5,false,false,5)',
|
||||
[sender, recipient, outsider, couple, otherCouple],
|
||||
);
|
||||
await pool.query(
|
||||
`insert into dare_packs(id,name,is_premium,content_status) values
|
||||
($1,'Free Pack',false,'allowed'),($2,'Premium Pack',true,'allowed'),($3,'Unreviewed Pack',false,'unreviewed')`,
|
||||
[freePack, premiumPack, unreviewedPack],
|
||||
);
|
||||
await pool.query(
|
||||
`insert into dares(id,pack_id,text,intensity,content_status) values
|
||||
($1,$4,'Free dare text',2,'allowed'),($2,$5,'Premium dare text',3,'allowed'),($3,$6,'Unreviewed dare text',1,'unreviewed')`,
|
||||
[freeDare, premiumDare, unreviewedDare, freePack, premiumPack, unreviewedPack],
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await pool.query('delete from sent_dares');
|
||||
await pool.query('delete from operations_outbox');
|
||||
await pool.query('update profiles set is_premium=false');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await repo.close();
|
||||
await pool.end();
|
||||
await admin.query(`drop schema "${schema}" cascade`);
|
||||
await admin.end();
|
||||
});
|
||||
|
||||
it('hides unreviewed packs and dares from the catalogue', async () => {
|
||||
const catalog = await repo.getCatalog(sender);
|
||||
const ids = catalog.packs.map((pack) => pack.id);
|
||||
expect(ids).toContain(freePack);
|
||||
expect(ids).toContain(premiumPack);
|
||||
expect(ids).not.toContain(unreviewedPack);
|
||||
await expect(repo.listPackDares(sender, unreviewedPack)).rejects.toMatchObject({ code: 'pack_not_found' });
|
||||
await expect(repo.send(sender, { dare_id: unreviewedDare })).rejects.toMatchObject({ code: 'dare_not_found' });
|
||||
});
|
||||
|
||||
it('gates premium packs and custom dares, and unlocks when either partner pays', async () => {
|
||||
await expect(repo.listPackDares(sender, premiumPack)).rejects.toMatchObject({ code: 'premium_required' });
|
||||
await expect(repo.send(sender, { dare_id: premiumDare })).rejects.toMatchObject({ code: 'premium_required' });
|
||||
await expect(repo.send(sender, { custom_dare_text: 'My own dare' })).rejects.toMatchObject({ code: 'premium_required' });
|
||||
|
||||
// Premium is couple-shared: the partner paying unlocks the sender.
|
||||
await pool.query('update profiles set is_premium=true where id=$1', [recipient]);
|
||||
expect((await repo.getCatalog(sender)).entitlement).toMatchObject({ is_premium: true, sends_remaining: null });
|
||||
await expect(repo.listPackDares(sender, premiumPack)).resolves.toHaveLength(1);
|
||||
const custom = await repo.send(sender, { custom_dare_text: ' My own dare ', custom_dare_intensity: 4 });
|
||||
expect(custom).toMatchObject({ is_custom: true, text: 'My own dare', intensity: 4 });
|
||||
});
|
||||
|
||||
it('enforces the free weekly send allowance without blocking the response side', async () => {
|
||||
for (let index = 0; index < FREE_WEEKLY_SEND_LIMIT; index += 1) {
|
||||
await repo.send(sender, { dare_id: freeDare });
|
||||
}
|
||||
expect((await repo.getCatalog(sender)).entitlement.sends_remaining).toBe(0);
|
||||
await expect(repo.send(sender, { dare_id: freeDare })).rejects.toMatchObject({ code: 'send_limit_reached' });
|
||||
|
||||
// The quota is per-sender, so the recipient can still answer back.
|
||||
const reply = await repo.send(recipient, { dare_id: freeDare });
|
||||
expect(reply.direction).toBe('outgoing');
|
||||
});
|
||||
|
||||
it('serializes concurrent sends so the free allowance cannot be raced', async () => {
|
||||
const attempts = await Promise.allSettled(
|
||||
Array.from({ length: FREE_WEEKLY_SEND_LIMIT + 3 }, () => repo.send(sender, { dare_id: freeDare })),
|
||||
);
|
||||
expect(attempts.filter((attempt) => attempt.status === 'fulfilled')).toHaveLength(FREE_WEEKLY_SEND_LIMIT);
|
||||
const stored = await pool.query<{ count: string }>('select count(*) as count from sent_dares where sender_id=$1', [sender]);
|
||||
expect(stored.rows[0]!.count).toBe(String(FREE_WEEKLY_SEND_LIMIT));
|
||||
});
|
||||
|
||||
it('walks the full loop and records each transition exactly once', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare, duration_hours: 24, sender_notes: 'have fun' });
|
||||
expect(dare).toMatchObject({ status: 'pending', direction: 'outgoing', text: 'Free dare text' });
|
||||
expect(dare.expires_at).not.toBeNull();
|
||||
|
||||
expect((await repo.respond(recipient, dare.id, 'accept')).status).toBe('active');
|
||||
expect((await repo.submit(recipient, dare.id)).status).toBe('submitted');
|
||||
expect((await repo.complete(sender, dare.id)).status).toBe('completed');
|
||||
|
||||
const events = await pool.query<{ dedupe_key: string; recipient_id: string }>(
|
||||
"select dedupe_key, recipient_id from operations_outbox where kind='expo' order by created_at",
|
||||
);
|
||||
expect(events.rows.map((row) => row.dedupe_key.split(':')[2])).toEqual(['sent', 'active', 'submitted', 'completed']);
|
||||
expect(events.rows.map((row) => row.recipient_id)).toEqual([recipient, sender, sender, recipient]);
|
||||
});
|
||||
|
||||
it('never puts dare text in a notification payload', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||
await repo.respond(recipient, dare.id, 'accept');
|
||||
const leaked = await pool.query<{ count: string }>(
|
||||
"select count(*) as count from operations_outbox where payload::text ilike '%Free dare text%'",
|
||||
);
|
||||
expect(leaked.rows[0]!.count).toBe('0');
|
||||
});
|
||||
|
||||
it('respects the dares notification preference', async () => {
|
||||
await pool.query('insert into notification_preferences(user_id,dares_enabled) values($1,false)', [recipient]);
|
||||
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||
const events = await pool.query('select 1 from operations_outbox where recipient_id=$1', [recipient]);
|
||||
expect(events.rowCount).toBe(0);
|
||||
// The sender has not opted out, so their side still fires.
|
||||
await repo.respond(recipient, dare.id, 'decline');
|
||||
const senderEvents = await pool.query('select 1 from operations_outbox where recipient_id=$1', [sender]);
|
||||
expect(senderEvents.rowCount).toBe(1);
|
||||
await pool.query('delete from notification_preferences where user_id=$1', [recipient]);
|
||||
});
|
||||
|
||||
it('enforces actor and state on every transition', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||
await expect(repo.respond(sender, dare.id, 'accept')).rejects.toMatchObject({ code: 'not_permitted' });
|
||||
await expect(repo.submit(recipient, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
|
||||
await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
|
||||
await expect(repo.respond(outsider, dare.id, 'accept')).rejects.toMatchObject({ code: 'dare_not_found' });
|
||||
|
||||
await repo.respond(recipient, dare.id, 'accept');
|
||||
await expect(repo.cancel(recipient, dare.id)).rejects.toMatchObject({ code: 'not_permitted' });
|
||||
expect((await repo.cancel(sender, dare.id)).status).toBe('cancelled');
|
||||
await expect(repo.complete(sender, dare.id)).rejects.toMatchObject({ code: 'invalid_transition' });
|
||||
});
|
||||
|
||||
it('keeps history intact when the source dare is deleted', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||
await pool.query('delete from dares where id=$1', [freeDare]);
|
||||
const history = await repo.listDares(sender, 'active');
|
||||
expect(history.find((row) => row.id === dare.id)).toMatchObject({ dare_id: null, text: 'Free dare text', intensity: 2 });
|
||||
await pool.query(
|
||||
"insert into dares(id,pack_id,text,intensity,content_status) values($1,$2,'Free dare text',2,'allowed')",
|
||||
[freeDare, freePack],
|
||||
);
|
||||
});
|
||||
|
||||
it('scopes listing and stats to the couple', async () => {
|
||||
const dare = await repo.send(sender, { dare_id: freeDare });
|
||||
await repo.respond(recipient, dare.id, 'accept');
|
||||
await repo.submit(recipient, dare.id);
|
||||
await repo.complete(sender, dare.id);
|
||||
|
||||
expect(await repo.listDares(outsider, 'active')).toEqual([]);
|
||||
expect(await repo.stats(outsider)).toMatchObject({ sent: 0, completed_together: 0 });
|
||||
expect(await repo.stats(sender)).toMatchObject({ sent: 1, received: 0, completed_together: 1, completed_by_partner: 1 });
|
||||
expect(await repo.stats(recipient)).toMatchObject({ sent: 0, received: 1, completed_together: 1, completed_by_me: 1 });
|
||||
|
||||
const active = await repo.listDares(sender, 'active');
|
||||
const history = await repo.listDares(sender, 'history');
|
||||
expect(active).toEqual([]);
|
||||
expect(history).toHaveLength(1);
|
||||
expect(history[0]!.direction).toBe('outgoing');
|
||||
expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming');
|
||||
});
|
||||
|
||||
it('rejects durations outside the offered presets', async () => {
|
||||
await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' });
|
||||
});
|
||||
|
||||
it('refuses to send without a partner', async () => {
|
||||
await expect(repo.send(outsider, { dare_id: freeDare })).rejects.toMatchObject({ code: 'no_couple' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Hono } from 'hono';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthIdentity } from '../src/auth.js';
|
||||
import { DaresError, type DaresRepository, type SentDare } from '../src/domains/dares/repository.js';
|
||||
import { registerDareRoutes } from '../src/domains/dares/routes.js';
|
||||
|
||||
const identity: AuthIdentity = { id: '11111111-1111-4111-8111-111111111111', email: null, name: null, avatarUrl: null };
|
||||
const partner = '22222222-2222-4222-8222-222222222222';
|
||||
const dareId = '33333333-3333-4333-8333-333333333333';
|
||||
const packId = '44444444-4444-4444-8444-444444444444';
|
||||
const catalogueDareId = '55555555-5555-4555-8555-555555555555';
|
||||
|
||||
const dare: SentDare = {
|
||||
id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId,
|
||||
text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner,
|
||||
direction: 'outgoing', status: 'pending', sender_notes: null,
|
||||
sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
|
||||
};
|
||||
|
||||
function setup(overrides: Partial<DaresRepository> = {}) {
|
||||
const repository: DaresRepository = {
|
||||
getCatalog: vi.fn(async () => ({
|
||||
entitlement: { is_premium: false, can_send_custom: false, weekly_send_limit: 3, sends_remaining: 3 },
|
||||
packs: [],
|
||||
})),
|
||||
listPackDares: vi.fn(async () => []),
|
||||
listDares: vi.fn(async () => [dare]),
|
||||
send: vi.fn(async () => dare),
|
||||
respond: vi.fn(async () => ({ ...dare, status: 'active' as const })),
|
||||
submit: vi.fn(async () => ({ ...dare, status: 'submitted' as const })),
|
||||
complete: vi.fn(async () => ({ ...dare, status: 'completed' as const })),
|
||||
cancel: vi.fn(async () => ({ ...dare, status: 'cancelled' as const })),
|
||||
stats: vi.fn(async () => ({ sent: 1, received: 0, completed_together: 0, active: 1, completed_by_me: 0, completed_by_partner: 0 })),
|
||||
close: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
};
|
||||
const app = new Hono<{ Variables: { identity: AuthIdentity } }>();
|
||||
app.use('/v1/*', async (c, next) => { c.set('identity', identity); await next(); });
|
||||
registerDareRoutes(app, repository);
|
||||
return { app, repository };
|
||||
}
|
||||
|
||||
function post(app: Hono<{ Variables: { identity: AuthIdentity } }>, path: string, body?: unknown) {
|
||||
return app.request(path, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe('dare routes', () => {
|
||||
it('derives the actor from the bearer identity, never the body', async () => {
|
||||
const { app, repository } = setup();
|
||||
const response = await post(app, '/v1/dares', {
|
||||
dare_id: catalogueDareId,
|
||||
sender_notes: ' be brave ',
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(repository.send).toHaveBeenCalledWith(identity.id, expect.objectContaining({ dare_id: catalogueDareId }));
|
||||
});
|
||||
|
||||
it('rejects client-supplied ownership fields', async () => {
|
||||
const { app } = setup();
|
||||
const response = await post(app, '/v1/dares', { dare_id: catalogueDareId, sender_id: partner });
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('requires exactly one of dare_id or custom text', async () => {
|
||||
const { app } = setup();
|
||||
expect((await post(app, '/v1/dares', {})).status).toBe(400);
|
||||
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, custom_dare_text: 'both' })).status).toBe(400);
|
||||
expect((await post(app, '/v1/dares', { custom_dare_text: 'just text' })).status).toBe(201);
|
||||
});
|
||||
|
||||
it('accepts only preset durations', async () => {
|
||||
const { app } = setup();
|
||||
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 24 })).status).toBe(201);
|
||||
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, duration_hours: 5 })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('validates identifiers and filters', async () => {
|
||||
const { app } = setup();
|
||||
expect((await app.request('/v1/dares?filter=nonsense')).status).toBe(400);
|
||||
expect((await app.request('/v1/dares/packs/not-a-uuid/dares')).status).toBe(400);
|
||||
expect((await post(app, '/v1/dares/not-a-uuid/submit')).status).toBe(400);
|
||||
expect((await post(app, `/v1/dares/${dareId}/respond`, { action: 'maybe' })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('routes each transition to its repository method', async () => {
|
||||
const { app, repository } = setup();
|
||||
await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' });
|
||||
expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline');
|
||||
await post(app, `/v1/dares/${dareId}/submit`);
|
||||
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId);
|
||||
await post(app, `/v1/dares/${dareId}/complete`);
|
||||
expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId);
|
||||
await post(app, `/v1/dares/${dareId}/cancel`);
|
||||
expect(repository.cancel).toHaveBeenCalledWith(identity.id, dareId);
|
||||
});
|
||||
|
||||
it('surfaces domain errors with their status codes', async () => {
|
||||
const { app } = setup({
|
||||
send: vi.fn(async () => { throw new DaresError('premium_required', 'Custom dares require premium', 402); }),
|
||||
submit: vi.fn(async () => { throw new DaresError('invalid_transition', 'A pending dare cannot change to submitted', 409); }),
|
||||
});
|
||||
const paywalled = await post(app, '/v1/dares', { custom_dare_text: 'mine' });
|
||||
expect(paywalled.status).toBe(402);
|
||||
expect(await paywalled.json()).toMatchObject({ error: { code: 'premium_required' } });
|
||||
expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409);
|
||||
});
|
||||
|
||||
it('exposes the duration presets the send sheet offers', async () => {
|
||||
const { app } = setup();
|
||||
const response = await app.request('/v1/dares/durations');
|
||||
expect(await response.json()).toEqual({ presets: [1, 6, 12, 24, 72, 168] });
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { OperationsRunner } from '../src/domains/operations/runner.js';
|
||||
import type { OperationItem } from '../src/domains/operations/types.js';
|
||||
import { loadWorkerConfig } from '../src/workers/config.js';
|
||||
|
||||
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
|
||||
const summary={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0,daresExpired:0};
|
||||
function repository(items:OperationItem[]):OperationsRepository {
|
||||
return {produce:vi.fn(async()=>summary),claim:vi.fn(async()=>items),complete:vi.fn(async()=>undefined),fail:vi.fn(async()=>undefined),
|
||||
message:vi.fn(async()=>null),classifierConfig:vi.fn(async()=>null),classify:vi.fn(async()=>undefined),close:vi.fn(async()=>undefined)};
|
||||
|
||||
@@ -23,6 +23,7 @@ const preferences = {
|
||||
weekly_summary_enabled: true,
|
||||
unpaired_reminders_enabled: true,
|
||||
catchup_reminders_enabled: true,
|
||||
dares_enabled: true,
|
||||
created_at: '2026-08-27T10:00:00.000Z',
|
||||
updated_at: '2026-08-27T10:00:00.000Z',
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user