mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Completes the half-built dares feature: send a dare, respond, report done, confirm, and get a push notification on every transition. Backend (apps/api): - New `dares` domain with couple-shared premium entitlement, a free weekly send allowance, and server-enforced actor/state rules on every transition. - `submitted` status so the recipient reports completion and the sender confirms, rather than the sender guessing. - Notifications ride the existing operations outbox via a trigger on `sent_dares`. Push copy is generic; store compliance forbids dare text in a preview and a test asserts no payload leaks it. - Worker expiry sweep now covers `pending` as well as `active`/`submitted`; an invitation nobody opened previously stayed open forever. Fixes two latent defects: - Deleting any dare that had been sent aborted with a check violation, because `dare_id` is ON DELETE SET NULL while `sent_dares_dare_or_custom_check` required it. Dare text is now snapshotted at send time, which also keeps a couple's history stable across catalogue edits. - The free send allowance was raceable: concurrent requests all read the same count and every one succeeded. The count and insert are now serialized on the sender's profile row. Also ports `content_status` review metadata to the API catalogue, which had no store-safety gating at all, and mirrors the review decision already enforced on the legacy catalogue. Mobile: dares screen (active/send/history), send sheet with duration presets, home tile badging incoming dares, paywall on both proactive and 402 paths. Verified: lint, typecheck, 379 unit tests, build, 71 integration tests against local PostgreSQL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
111 lines
4.7 KiB
TypeScript
111 lines
4.7 KiB
TypeScript
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);
|
|
});
|
|
}
|
|
}
|