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:
jjbruntonandClaude Opus 5 committed 2026-08-28 20:03:11 +01:00
1 parent a7470310ae
commit ce0b188776
34 files changed
+2673 -242

No files matched your search

+223
View File
@@ -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';
+7
View File
@@ -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
}
]
}
+4
View File
@@ -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);
+480
View File
@@ -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();
}
}
+110
View File
@@ -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);
});
}
}
+79
View File
@@ -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)]);
+12 -2
View File
@@ -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};
});
}
+1
View File
@@ -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];
+4
View File
@@ -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(),
+206
View File
@@ -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' });
});
});
+117
View File
@@ -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] });
});
});
+1 -1
View File
@@ -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',
};