Compare commits

Author SHA1 Message Date
jjbruntonandClaude Fable 5 504eddaf68 feat(dares): optional photo/audio proof of completion
The sender can require photo or audio proof when sending any dare,
catalogue or custom. The recipient captures and uploads the proof as a
new couple-scoped 'dare_proof' media kind and attaches it on submit;
the server enforces ownership, kind, couple, and MIME-type match.
The proof renders inline on the dare card for the sender to review
before confirming completion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 19:36:11 +01:00
jjbruntonandClaude Fable 5 f22d4a3649 Merge quiz question variety into staging
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:22:10 +01:00
jjbruntonandClaude Fable 5 45d24911fb feat(quiz): variety in quiz question selection
Expand the seeded pool from 12 to 36 questions (migration 0020) and
select 10 per session preferring questions the couple has never seen,
then least recently used, with random tiebreaks, so repeat quizzes stop
serving the same set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:21:55 +01:00
26 changed files with 799 additions and 35 deletions

No files matched your search

@@ -0,0 +1,53 @@
-- The seeded quiz pool of 12 questions was small enough that repeat sessions for
-- the same couple felt almost identical. Widen the pool so the never-used-first
-- selection strategy in the repository has enough fresh material to draw from.
INSERT INTO "quiz_questions" ("prompt_self", "prompt_guess", "options", "sort_order") VALUES
('What is your ideal pet?', 'What is your partner''s ideal pet?',
ARRAY['A dog', 'A cat', 'Something low-maintenance', 'No pets, thanks'], 130),
('What is your go-to coffee or tea order?', 'What is your partner''s go-to coffee or tea order?',
ARRAY['Strong and simple', 'Something sweet and milky', 'Herbal or decaf', 'I skip it entirely'], 140),
('What feature would your dream house have?', 'What feature would your partner''s dream house have?',
ARRAY['A big kitchen', 'A garden or outdoor space', 'A cosy reading nook', 'A home cinema or games room'], 150),
('What is your favourite season?', 'What is your partner''s favourite season?',
ARRAY['Spring', 'Summer', 'Autumn', 'Winter'], 160),
('On a road trip, what role do you take?', 'On a road trip, what role does your partner take?',
ARRAY['Driver', 'Navigator', 'Playlist curator', 'Snack manager'], 170),
('When a cooking attempt goes wrong, what do you do?', 'When a cooking attempt goes wrong, what does your partner do?',
ARRAY['Laugh it off and order in', 'Try to fix it anyway', 'Start again from scratch', 'Blame the recipe'], 180),
('What is your worst phone habit?', 'What is your partner''s worst phone habit?',
ARRAY['Scrolling too long', 'Never replying quickly', 'Too many notifications on', 'Checking it during conversations'], 190),
('What makes your ideal birthday?', 'What makes your partner''s ideal birthday?',
ARRAY['A big party with friends', 'A quiet day together', 'A surprise outing', 'Just a nice meal and cake'], 200),
('How do you behave during a board game?', 'How does your partner behave during a board game?',
ARRAY['Fiercely competitive', 'Relaxed, just for fun', 'Overly strategic', 'Happy to lose as long as it is fun'], 210),
('What is your packing style before a trip?', 'What is your partner''s packing style before a trip?',
ARRAY['Packed days in advance', 'Careful list-maker', 'Last-minute scramble', 'Overpacks just in case'], 220),
('How well do you handle scary movies?', 'How well does your partner handle scary movies?',
ARRAY['Loves them, no problem', 'Watches through fingers', 'Needs the lights on after', 'Avoids them completely'], 230),
('What is your dance floor style?', 'What is your partner''s dance floor style?',
ARRAY['First one out there', 'Needs a drink first', 'Sways from the sidelines', 'Stays firmly seated'], 240),
('What would you do with a free hour to yourself?', 'What would your partner do with a free hour to themselves?',
ARRAY['Nap or rest', 'Read or watch something', 'Go for a walk or exercise', 'Tidy up or get things done'], 250),
('Which type of celebrity would you want as a dinner guest?', 'Which type of celebrity would your partner want as a dinner guest?',
ARRAY['A comedian', 'A musician', 'An athlete', 'An author or thinker'], 260),
('What childhood dream job says the most about you?', 'What childhood dream job says the most about your partner?',
ARRAY['Astronaut or explorer', 'Performer or artist', 'Something helping others', 'Something running the show'], 270),
('How would you describe your tidiness style?', 'How would you describe your partner''s tidiness style?',
ARRAY['Everything has its place', 'Tidy in bursts', 'Organised chaos', 'Relaxed about mess'], 280),
('Are you more spontaneous or a planner?', 'Is your partner more spontaneous or a planner?',
ARRAY['Fully spontaneous', 'Mostly spontaneous', 'Mostly a planner', 'Fully a planner'], 290),
('What genre do you rewatch for comfort?', 'What genre does your partner rewatch for comfort?',
ARRAY['Sitcoms', 'Feel-good films', 'Fantasy or sci-fi', 'Reality shows'], 300),
('How would you describe your social battery?', 'How would you describe your partner''s social battery?',
ARRAY['Energised by people', 'Fine in small doses', 'Drained quickly', 'Depends entirely on the day'], 310),
('What is your gift-giving style?', 'What is your partner''s gift-giving style?',
ARRAY['Thoughtful and personal', 'Practical and useful', 'Big and generous', 'Last-minute but well-meant'], 320),
('What kind of breakfast personality do you have?', 'What kind of breakfast personality does your partner have?',
ARRAY['Big cooked breakfast', 'Quick and light', 'Coffee only', 'Skips it most days'], 330),
('What weather do you prefer?', 'What weather does your partner prefer?',
ARRAY['Warm and sunny', 'Cool and crisp', 'Rainy and cosy', 'Snowy'], 340),
('If you won the lottery, what would you do first?', 'If your partner won the lottery, what would they do first?',
ARRAY['Pay off everything', 'Book a big trip', 'Treat the people close to them', 'Save it and think it over'], 350),
('When lost in a new city, what do you do?', 'When lost in a new city, what does your partner do?',
ARRAY['Ask a local for help', 'Follow a map or app closely', 'Wander until something looks familiar', 'Stay calm and retrace your steps'], 360)
ON CONFLICT ("prompt_self") DO NOTHING;
+16
View File
@@ -0,0 +1,16 @@
-- Optional proof-of-completion for dares. The sender picks a requirement
-- ('none', 'photo', 'audio') at send time for both catalogue and custom dares;
-- the recipient attaches a media object when submitting.
ALTER TABLE sent_dares ADD COLUMN IF NOT EXISTS proof_type text NOT NULL DEFAULT 'none';
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_proof_type_check;
ALTER TABLE sent_dares ADD CONSTRAINT sent_dares_proof_type_check
CHECK (proof_type IN ('none', 'photo', 'audio'));
--> statement-breakpoint
ALTER TABLE sent_dares ADD COLUMN IF NOT EXISTS proof_media_id uuid REFERENCES media_objects(id) ON DELETE SET NULL;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_proof_media_idx ON sent_dares(proof_media_id) WHERE proof_media_id IS NOT NULL;
--> statement-breakpoint
ALTER TABLE media_objects DROP CONSTRAINT IF EXISTS media_objects_kind_check;
ALTER TABLE media_objects ADD CONSTRAINT media_objects_kind_check
CHECK (kind IN ('avatar', 'response', 'chat', 'feedback', 'dare_proof'));
+7
View File
@@ -141,6 +141,13 @@
"when": 1788168000000, "when": 1788168000000,
"tag": "0019_quiz", "tag": "0019_quiz",
"breakpoints": true "breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1788168060000,
"tag": "0020_quiz_question_pool",
"breakpoints": true
} }
] ]
} }
+76 -8
View File
@@ -4,6 +4,11 @@ import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../d
export type DareStatus = export type DareStatus =
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled'; | 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
/** Sender-chosen proof of completion; 'none' means a plain "I did it" suffices. */
export type DareProofType = 'none' | 'photo' | 'audio';
export const DARE_PROOF_TYPES: readonly DareProofType[] = ['none', 'photo', 'audio'];
/** Free senders get a rolling weekly allowance; receiving and responding are never gated. */ /** Free senders get a rolling weekly allowance; receiving and responding are never gated. */
export const FREE_WEEKLY_SEND_LIMIT = 3; export const FREE_WEEKLY_SEND_LIMIT = 3;
@@ -45,6 +50,8 @@ export interface SentDare {
direction: 'incoming' | 'outgoing'; direction: 'incoming' | 'outgoing';
status: DareStatus; status: DareStatus;
sender_notes: string | null; sender_notes: string | null;
proof_type: DareProofType;
proof_media_id: string | null;
sent_at: string; sent_at: string;
accepted_at: string | null; accepted_at: string | null;
submitted_at: string | null; submitted_at: string | null;
@@ -79,6 +86,7 @@ export interface SendDareInput {
custom_dare_intensity?: number | null; custom_dare_intensity?: number | null;
duration_hours?: number | null; duration_hours?: number | null;
sender_notes?: string | null; sender_notes?: string | null;
proof_type?: DareProofType | null;
} }
export class DaresError extends Error { export class DaresError extends Error {
@@ -111,6 +119,8 @@ interface SentDareRecord extends QueryResultRow {
recipient_id: string; recipient_id: string;
status: DareStatus; status: DareStatus;
sender_notes: string | null; sender_notes: string | null;
proof_type: DareProofType;
proof_media_id: string | null;
sent_at: Date; sent_at: Date;
accepted_at: Date | null; accepted_at: Date | null;
submitted_at: Date | null; submitted_at: Date | null;
@@ -124,7 +134,7 @@ export interface DaresRepository {
listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>; listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>;
send(userId: string, input: SendDareInput): Promise<SentDare>; send(userId: string, input: SendDareInput): Promise<SentDare>;
respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>; respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>;
submit(userId: string, dareId: string): Promise<SentDare>; submit(userId: string, dareId: string, proofMediaId?: string | null): Promise<SentDare>;
complete(userId: string, dareId: string): Promise<SentDare>; complete(userId: string, dareId: string): Promise<SentDare>;
cancel(userId: string, dareId: string): Promise<SentDare>; cancel(userId: string, dareId: string): Promise<SentDare>;
stats(userId: string): Promise<DareStats>; stats(userId: string): Promise<DareStats>;
@@ -132,7 +142,7 @@ export interface DaresRepository {
} }
const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot, const BARE_COLUMNS = `id, couple_id, dare_id, dare_text_snapshot, dare_intensity_snapshot,
custom_dare_text, sender_id, recipient_id, status, sender_notes, custom_dare_text, sender_id, recipient_id, status, sender_notes, proof_type, proof_media_id,
sent_at, accepted_at, submitted_at, completed_at, expires_at`; sent_at, accepted_at, submitted_at, completed_at, expires_at`;
const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', '); const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', ');
@@ -172,6 +182,8 @@ function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing', direction: row.recipient_id === viewerId ? 'incoming' : 'outgoing',
status: row.status, status: row.status,
sender_notes: row.sender_notes, sender_notes: row.sender_notes,
proof_type: row.proof_type,
proof_media_id: row.proof_media_id,
sent_at: row.sent_at.toISOString(), sent_at: row.sent_at.toISOString(),
accepted_at: iso(row.accepted_at), accepted_at: iso(row.accepted_at),
submitted_at: iso(row.submitted_at), submitted_at: iso(row.submitted_at),
@@ -323,6 +335,11 @@ export class PostgresDaresRepository implements DaresRepository {
throw new DaresError('invalid_duration', 'Duration must be one of the offered presets', 400); throw new DaresError('invalid_duration', 'Duration must be one of the offered presets', 400);
} }
const proofType = input.proof_type ?? 'none';
if (!DARE_PROOF_TYPES.includes(proofType)) {
throw new DaresError('invalid_proof_type', 'Proof must be none, photo, or audio', 400);
}
let text: string; let text: string;
let intensity: number; let intensity: number;
let dareId: string | null = null; let dareId: string | null = null;
@@ -376,15 +393,15 @@ export class PostgresDaresRepository implements DaresRepository {
`insert into sent_dares ( `insert into sent_dares (
couple_id, dare_id, custom_dare_text, custom_dare_intensity, couple_id, dare_id, custom_dare_text, custom_dare_intensity,
dare_text_snapshot, dare_intensity_snapshot, dare_text_snapshot, dare_intensity_snapshot,
sender_id, recipient_id, sender_notes, expires_at sender_id, recipient_id, sender_notes, proof_type, expires_at
) values ( ) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
case when $10::integer is null then null else now() + ($10::integer * interval '1 hour') end case when $11::integer is null then null else now() + ($11::integer * interval '1 hour') end
) )
returning ${BARE_COLUMNS}`, returning ${BARE_COLUMNS}`,
[ [
context.couple_id, dareId, customText, customIntensity, text, intensity, context.couple_id, dareId, customText, customIntensity, text, intensity,
userId, context.partner_id, input.sender_notes?.trim() || null, duration, userId, context.partner_id, input.sender_notes?.trim() || null, proofType, duration,
], ],
); );
return toSentDare(result.rows[0]!, userId); return toSentDare(result.rows[0]!, userId);
@@ -438,8 +455,59 @@ export class PostgresDaresRepository implements DaresRepository {
: this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null); : 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'); * Submission enforces the sender's proof requirement: a proof-required dare
* cannot be marked done without media of the matching type. Proof is also
* accepted voluntarily on 'none' dares. Media ownership, kind, and couple are
* re-checked here so a submit cannot reference someone else's upload.
*/
async submit(userId: string, dareId: string, proofMediaId?: string | null): Promise<SentDare> {
return transaction(this.pool, async (client) => {
const existing = await client.query<{
sender_id: string; recipient_id: string; status: DareStatus;
proof_type: DareProofType; couple_id: string;
}>(
'select sender_id, recipient_id, status, proof_type, couple_id from sent_dares where id = $1 for update',
[dareId],
);
const dare = existing.rows[0];
if (!dare || (dare.sender_id !== userId && dare.recipient_id !== userId)) {
throw new DaresError('dare_not_found', 'Dare not found', 404);
}
if (dare.recipient_id !== userId) {
throw new DaresError('not_permitted', 'Only the recipient can do that', 403);
}
if (dare.status !== 'active') {
throw new DaresError('invalid_transition', `A ${dare.status} dare cannot change to submitted`, 409);
}
if (dare.proof_type !== 'none' && !proofMediaId) {
throw new DaresError('proof_required', `This dare requires ${dare.proof_type} proof`, 400);
}
if (proofMediaId) {
const media = await client.query<{ kind: string; mime_type: string; owner_id: string; couple_id: string | null }>(
'select kind, mime_type, owner_id, couple_id from media_objects where id = $1 and deleted_at is null',
[proofMediaId],
);
const proof = media.rows[0];
if (!proof || proof.owner_id !== userId || proof.kind !== 'dare_proof' || proof.couple_id !== dare.couple_id) {
throw new DaresError('proof_not_found', 'Proof media not found', 404);
}
const category = dare.proof_type === 'none'
? (proof.mime_type.startsWith('image/') || proof.mime_type.startsWith('audio/'))
: proof.mime_type.startsWith(dare.proof_type === 'photo' ? 'image/' : 'audio/');
if (!category) {
throw new DaresError('proof_type_mismatch', `This dare requires ${dare.proof_type} proof`, 400);
}
}
const result = await client.query<SentDareRecord>(
`update sent_dares
set status = 'submitted', submitted_at = now(), proof_media_id = $2::uuid
where id = $1
returning ${BARE_COLUMNS}`,
[dareId, proofMediaId ?? null],
);
return toSentDare(result.rows[0]!, userId);
});
} }
async complete(userId: string, dareId: string): Promise<SentDare> { async complete(userId: string, dareId: string): Promise<SentDare> {
+16 -1
View File
@@ -15,6 +15,7 @@ const sendBody = z
custom_dare_intensity: z.number().int().min(1).max(5).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(), 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(), sender_notes: z.string().trim().max(500).nullish(),
proof_type: z.enum(['none', 'photo', 'audio']).nullish(),
}) })
.strict() .strict()
.refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), { .refine((value) => Boolean(value.dare_id) !== Boolean(value.custom_dare_text), {
@@ -23,6 +24,8 @@ const sendBody = z
const respondBody = z.object({ action: z.enum(['accept', 'decline']) }).strict(); const respondBody = z.object({ action: z.enum(['accept', 'decline']) }).strict();
const submitBody = z.object({ proof_media_id: id.nullish() }).strict();
function error(code: string, message: string): ApiErrorResponse { function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } }; return { error: { code, message } };
} }
@@ -93,8 +96,20 @@ export function registerDareRoutes(app: DaresApp, repository: DaresRepository):
: c.json(error(response.cause.code, response.cause.message), response.cause.status); : c.json(error(response.cause.code, response.cause.message), response.cause.status);
}); });
app.post('/v1/dares/:dareId/submit', 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);
// Body is optional: dares without a proof requirement submit with no payload.
const body = submitBody.safeParse(await c.req.json().catch(() => ({})));
if (!body.success) return c.json(error('invalid_proof', 'proof_media_id must be a valid media ID'), 400);
const response = await result(() =>
repository.submit(c.get('identity').id, dareId.data, body.data.proof_media_id ?? null));
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 [ for (const [path, method] of [
['submit', 'submit'],
['complete', 'complete'], ['complete', 'complete'],
['cancel', 'cancel'], ['cancel', 'cancel'],
] as const) { ] as const) {
+2 -2
View File
@@ -39,7 +39,7 @@ export class PostgresMediaRepository implements MediaRepository {
const profile = await client.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1 for update', [userId]); const profile = await client.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1 for update', [userId]);
if (!profile.rows[0]) throw new MediaError('profile_not_found', 'Profile not found', 404); if (!profile.rows[0]) throw new MediaError('profile_not_found', 'Profile not found', 404);
const coupleId = profile.rows[0].couple_id; const coupleId = profile.rows[0].couple_id;
if ((kind === 'response' || kind === 'chat') && !coupleId) throw new MediaError('couple_required', 'Join a couple before sharing media', 409); if ((kind === 'response' || kind === 'chat' || kind === 'dare_proof') && !coupleId) throw new MediaError('couple_required', 'Join a couple before sharing media', 409);
if (kind === 'response') { if (kind === 'response') {
if (!context.questionId) throw new MediaError('question_required', 'A question ID is required', 400); if (!context.questionId) throw new MediaError('question_required', 'A question ID is required', 400);
const question = await client.query('select 1 from questions where id=$1 and deleted_at is null', [context.questionId]); const question = await client.query('select 1 from questions where id=$1 and deleted_at is null', [context.questionId]);
@@ -84,7 +84,7 @@ export class PostgresMediaRepository implements MediaRepository {
from media_objects mo join profiles viewer on viewer.id=$1 from media_objects mo join profiles viewer on viewer.id=$1
where mo.id=$2 and mo.deleted_at is null and (mo.expires_at is null or mo.expires_at>now()) where mo.id=$2 and mo.deleted_at is null and (mo.expires_at is null or mo.expires_at>now())
and not exists(select 1 from messages m where m.media_path='media:'||mo.id::text and (m.media_expired or m.media_expires_at<=now())) and not exists(select 1 from messages m where m.media_path='media:'||mo.id::text and (m.media_expired or m.media_expires_at<=now()))
and (mo.owner_id=$1 or (mo.kind in ('avatar','response','chat') and mo.couple_id is not null and mo.couple_id=viewer.couple_id))`, and (mo.owner_id=$1 or (mo.kind in ('avatar','response','chat','dare_proof') and mo.couple_id is not null and mo.couple_id=viewer.couple_id))`,
[userId, mediaId], [userId, mediaId],
); );
if (!result.rows[0]) throw new MediaError('media_not_found', 'Media not found', 404); if (!result.rows[0]) throw new MediaError('media_not_found', 'Media not found', 404);
+2 -1
View File
@@ -8,8 +8,9 @@ const allowed: Record<MediaKind, ReadonlySet<string>> = {
response: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/x-caf']), response: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/x-caf']),
chat: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/quicktime', 'video/webm']), chat: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/quicktime', 'video/webm']),
feedback: new Set(['image/jpeg', 'image/png', 'image/webp']), feedback: new Set(['image/jpeg', 'image/png', 'image/webp']),
dare_proof: new Set(['image/jpeg', 'image/png', 'image/webp', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/x-caf']),
}; };
const limits: Record<MediaKind, number> = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*1024*1024 }; const limits: Record<MediaKind, number> = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*1024*1024, dare_proof: 25*1024*1024 };
const extensions: Record<string, string> = { 'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','video/mp4':'.mp4','video/quicktime':'.mov','video/webm':'.webm','audio/mp4':'.m4a','audio/m4a':'.m4a','audio/x-m4a':'.m4a','audio/mpeg':'.mp3','audio/wav':'.wav','audio/webm':'.webm','audio/aac':'.aac','audio/x-caf':'.caf' }; const extensions: Record<string, string> = { 'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','video/mp4':'.mp4','video/quicktime':'.mov','video/webm':'.webm','audio/mp4':'.m4a','audio/m4a':'.m4a','audio/x-m4a':'.m4a','audio/mpeg':'.mp3','audio/wav':'.wav','audio/webm':'.webm','audio/aac':'.aac','audio/x-caf':'.caf' };
export class FilesystemMediaStorage { export class FilesystemMediaStorage {
+1 -1
View File
@@ -1,4 +1,4 @@
export const mediaKinds = ['avatar', 'response', 'chat', 'feedback'] as const; export const mediaKinds = ['avatar', 'response', 'chat', 'feedback', 'dare_proof'] as const;
export type MediaKind = typeof mediaKinds[number]; export type MediaKind = typeof mediaKinds[number];
export interface MediaObject { export interface MediaObject {
+15 -2
View File
@@ -167,9 +167,22 @@ export class PostgresQuizRepository implements QuizRepository {
return { session: await this.buildPayload(client, existing.rows[0], userId), created: false }; return { session: await this.buildPayload(client, existing.rows[0], userId), created: false };
} }
// Prefer questions this couple has never had in any prior session (completed or
// active), then the least-recently-used ones, with a random tiebreak within each
// group so repeat quizzes stay varied instead of always reusing the same pool.
const questions = await client.query<{ id: string }>( const questions = await client.query<{ id: string }>(
'select id from quiz_questions where is_active = true order by sort_order, id limit $1', `select q.id
[QUESTIONS_PER_SESSION], from quiz_questions q
left join (
select question_id, max(created_at) as last_used
from quiz_sessions, unnest(question_ids) as question_id
where couple_id = $1
group by question_id
) usage on usage.question_id = q.id
where q.is_active = true
order by (usage.last_used is not null), usage.last_used asc, random()
limit $2`,
[coupleId, QUESTIONS_PER_SESSION],
); );
const questionIds = questions.rows.map((row) => row.id); const questionIds = questions.rows.map((row) => row.id);
+52
View File
@@ -196,6 +196,58 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming'); expect((await repo.listDares(recipient, 'history'))[0]!.direction).toBe('incoming');
}); });
async function insertProof(owner: string, coupleId: string | null, mime: string, kind = 'dare_proof') {
const id = randomUUID();
await pool.query(
'insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size) values($1,$2,$3,$4,$5,$6,100)',
[id, owner, coupleId, kind, `${kind}/test/${id}.bin`, mime],
);
return id;
}
it('stores the proof requirement and defaults to none', async () => {
const plain = await repo.send(sender, { dare_id: freeDare });
expect(plain).toMatchObject({ proof_type: 'none', proof_media_id: null });
const proofed = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
expect(proofed.proof_type).toBe('photo');
await expect(repo.send(sender, { dare_id: freeDare, proof_type: 'video' as never }))
.rejects.toMatchObject({ code: 'invalid_proof_type' });
});
it('requires matching proof media before a proofed dare can be submitted', async () => {
const dare = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
await repo.respond(recipient, dare.id, 'accept');
await expect(repo.submit(recipient, dare.id)).rejects.toMatchObject({ code: 'proof_required' });
const audio = await insertProof(recipient, couple, 'audio/mp4');
await expect(repo.submit(recipient, dare.id, audio)).rejects.toMatchObject({ code: 'proof_type_mismatch' });
const photo = await insertProof(recipient, couple, 'image/jpeg');
const submitted = await repo.submit(recipient, dare.id, photo);
expect(submitted).toMatchObject({ status: 'submitted', proof_media_id: photo });
expect((await repo.complete(sender, dare.id)).status).toBe('completed');
});
it('refuses proof media the recipient does not own or that is not dare proof', async () => {
const dare = await repo.send(sender, { dare_id: freeDare, proof_type: 'photo' });
await repo.respond(recipient, dare.id, 'accept');
const foreign = await insertProof(outsider, otherCouple, 'image/jpeg');
await expect(repo.submit(recipient, dare.id, foreign)).rejects.toMatchObject({ code: 'proof_not_found' });
const wrongKind = await insertProof(recipient, couple, 'image/jpeg', 'chat');
await expect(repo.submit(recipient, dare.id, wrongKind)).rejects.toMatchObject({ code: 'proof_not_found' });
const senderOwned = await insertProof(sender, couple, 'image/jpeg');
await expect(repo.submit(recipient, dare.id, senderOwned)).rejects.toMatchObject({ code: 'proof_not_found' });
});
it('accepts voluntary proof on dares that did not require any', async () => {
const dare = await repo.send(sender, { dare_id: freeDare });
await repo.respond(recipient, dare.id, 'accept');
const photo = await insertProof(recipient, couple, 'image/jpeg');
const submitted = await repo.submit(recipient, dare.id, photo);
expect(submitted).toMatchObject({ status: 'submitted', proof_type: 'none', proof_media_id: photo });
});
it('rejects durations outside the offered presets', async () => { it('rejects durations outside the offered presets', async () => {
await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' }); await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' });
}); });
+20 -2
View File
@@ -13,10 +13,12 @@ const catalogueDareId = '55555555-5555-4555-8555-555555555555';
const dare: SentDare = { const dare: SentDare = {
id: dareId, couple_id: '66666666-6666-4666-8666-666666666666', dare_id: catalogueDareId, 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, text: 'A dare', intensity: 2, is_custom: false, sender_id: identity.id, recipient_id: partner,
direction: 'outgoing', status: 'pending', sender_notes: null, direction: 'outgoing', status: 'pending', sender_notes: null, proof_type: 'none', proof_media_id: null,
sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null, sent_at: new Date().toISOString(), accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
}; };
const proofMediaId = '77777777-7777-4777-8777-777777777777';
function setup(overrides: Partial<DaresRepository> = {}) { function setup(overrides: Partial<DaresRepository> = {}) {
const repository: DaresRepository = { const repository: DaresRepository = {
getCatalog: vi.fn(async () => ({ getCatalog: vi.fn(async () => ({
@@ -91,7 +93,7 @@ describe('dare routes', () => {
await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' }); await post(app, `/v1/dares/${dareId}/respond`, { action: 'decline' });
expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline'); expect(repository.respond).toHaveBeenCalledWith(identity.id, dareId, 'decline');
await post(app, `/v1/dares/${dareId}/submit`); await post(app, `/v1/dares/${dareId}/submit`);
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId); expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId, null);
await post(app, `/v1/dares/${dareId}/complete`); await post(app, `/v1/dares/${dareId}/complete`);
expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId); expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId);
await post(app, `/v1/dares/${dareId}/cancel`); await post(app, `/v1/dares/${dareId}/cancel`);
@@ -109,6 +111,22 @@ describe('dare routes', () => {
expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409); expect((await post(app, `/v1/dares/${dareId}/submit`)).status).toBe(409);
}); });
it('accepts a proof requirement on send and rejects unknown ones', async () => {
const { app, repository } = setup();
const response = await post(app, '/v1/dares', { dare_id: catalogueDareId, proof_type: 'photo' });
expect(response.status).toBe(201);
expect(repository.send).toHaveBeenCalledWith(identity.id, expect.objectContaining({ proof_type: 'photo' }));
expect((await post(app, '/v1/dares', { dare_id: catalogueDareId, proof_type: 'video' })).status).toBe(400);
});
it('passes proof media through on submit and validates its shape', async () => {
const { app, repository } = setup();
await post(app, `/v1/dares/${dareId}/submit`, { proof_media_id: proofMediaId });
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId, proofMediaId);
expect((await post(app, `/v1/dares/${dareId}/submit`, { proof_media_id: 'not-a-uuid' })).status).toBe(400);
expect((await post(app, `/v1/dares/${dareId}/submit`, { extra_field: true })).status).toBe(400);
});
it('exposes the duration presets the send sheet offers', async () => { it('exposes the duration presets the send sheet offers', async () => {
const { app } = setup(); const { app } = setup();
const response = await app.request('/v1/dares/durations'); const response = await app.request('/v1/dares/durations');
+43 -2
View File
@@ -21,6 +21,7 @@ describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
const couple = '55555555-5555-4555-8555-555555555555'; const couple = '55555555-5555-4555-8555-555555555555';
const soloCouple = '66666666-6666-4666-8666-666666666666'; const soloCouple = '66666666-6666-4666-8666-666666666666';
const otherCouple = '77777777-7777-4777-8777-777777777777'; const otherCouple = '77777777-7777-4777-8777-777777777777';
const userC = '88888888-8888-4888-8888-888888888888';
beforeAll(async () => { beforeAll(async () => {
await admin.query(`create schema "${schema}"`); await admin.query(`create schema "${schema}"`);
@@ -44,8 +45,8 @@ describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
); );
await pool.query( await pool.query(
`insert into profiles(id,couple_id) values `insert into profiles(id,couple_id) values
($1,$5),($2,$5),($3,$6),($4,$7)`, ($1,$5),($2,$5),($3,$6),($4,$7),($8,$7)`,
[userA, userB, soloUser, outsider, couple, soloCouple, otherCouple], [userA, userB, soloUser, outsider, couple, soloCouple, otherCouple, userC],
); );
}); });
@@ -206,4 +207,44 @@ describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
const current = await repo.currentSession(userB); const current = await repo.currentSession(userB);
expect(current?.id).toBe(session.id); expect(current?.id).toBe(session.id);
}); });
it('draws the next session from never-used questions once a session completes', async () => {
const { session: first } = await repo.startSession(userA);
const firstQuestionIds = first.questions.map((question) => question.id);
expect(firstQuestionIds).toHaveLength(10);
const answers = firstQuestionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
await repo.submitAnswers(userA, first.id, answers);
const completed = await repo.submitAnswers(userB, first.id, answers);
expect(completed.status).toBe('completed');
const { session: second, created } = await repo.startSession(userA);
expect(created).toBe(true);
const secondQuestionIds = second.questions.map((question) => question.id);
expect(secondQuestionIds).toHaveLength(10);
// The seeded pool has 36 active questions, so the never-used-first preference
// guarantees the second session's questions are wholly disjoint from the first.
const overlap = secondQuestionIds.filter((id) => firstQuestionIds.includes(id));
expect(overlap).toEqual([]);
});
it('scopes selection to each couple independently', async () => {
const { session: coupleOneFirst } = await repo.startSession(userA);
const coupleOneFirstIds = coupleOneFirst.questions.map((question) => question.id);
const answers = coupleOneFirstIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
await repo.submitAnswers(userA, coupleOneFirst.id, answers);
await repo.submitAnswers(userB, coupleOneFirst.id, answers);
// A different couple with no prior sessions should still be able to draw a
// fresh set of 10 questions regardless of what the first couple has used,
// since the never-used-for-them preference is scoped per couple.
const { session: otherSession, created } = await repo.startSession(outsider);
expect(created).toBe(true);
expect(otherSession.questions).toHaveLength(10);
const partnerView = await repo.startSession(userC);
expect(partnerView.created).toBe(false);
expect(partnerView.session.id).toBe(otherSession.id);
});
}); });
@@ -7,7 +7,7 @@ import type { SentDare } from "@/features/dares/types";
const incoming: SentDare = { const incoming: SentDare = {
id: "dare-1", couple_id: "couple-1", dare_id: "catalogue-1", text: "A dare", intensity: 2, id: "dare-1", couple_id: "couple-1", dare_id: "catalogue-1", text: "A dare", intensity: 2,
is_custom: false, sender_id: "partner", recipient_id: "me", direction: "incoming", is_custom: false, sender_id: "partner", recipient_id: "me", direction: "incoming",
status: "pending", sender_notes: null, sent_at: "2026-08-28T10:00:00.000Z", status: "pending", sender_notes: null, proof_type: "none", proof_media_id: null, sent_at: "2026-08-28T10:00:00.000Z",
accepted_at: null, submitted_at: null, completed_at: null, expires_at: null, accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
}; };
@@ -55,6 +55,23 @@ describe("useDares", () => {
expect(postSpy).toHaveBeenCalledWith("/v1/dares/dare-1/respond", { action: "accept" }); expect(postSpy).toHaveBeenCalledWith("/v1/dares/dare-1/respond", { action: "accept" });
}); });
it("attaches proof media to a submit when one is supplied", async () => {
mockLoad();
const postSpy = jest.spyOn(apiClient, "post").mockResolvedValue({ dare: { ...incoming, status: "submitted" } } as never);
const { result } = renderHook(() => useDares());
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.submit("dare-1", "media-1");
});
expect(postSpy).toHaveBeenCalledWith("/v1/dares/dare-1/submit", { proof_media_id: "media-1" });
await act(async () => {
await result.current.submit("dare-1");
});
expect(postSpy).toHaveBeenLastCalledWith("/v1/dares/dare-1/submit", undefined);
});
it("routes a premium refusal to the paywall rather than the error banner", async () => { it("routes a premium refusal to the paywall rather than the error banner", async () => {
mockLoad(); mockLoad();
jest.spyOn(apiClient, "post").mockRejectedValue(new ApiError("Custom dares require premium", 402)); jest.spyOn(apiClient, "post").mockRejectedValue(new ApiError("Custom dares require premium", 402));
+17 -2
View File
@@ -6,9 +6,9 @@ import Animated, { FadeIn } from "react-native-reanimated";
import { GradientBackground } from "../../components/ui"; import { GradientBackground } from "../../components/ui";
import { Paywall } from "../../components/paywall"; import { Paywall } from "../../components/paywall";
import { colors, featureColors, radius, spacing, typography } from "../../theme"; import { colors, featureColors, radius, spacing, typography } from "../../theme";
import { DareCard, DarePackGrid, SendDareSheet } from "./components"; import { DareCard, DarePackGrid, DareProofSheet, SendDareSheet } from "./components";
import { useDares } from "./hooks/useDares"; import { useDares } from "./hooks/useDares";
import type { DarePack } from "./types"; import type { DarePack, SentDare } from "./types";
const ACCENT = featureColors.dares.accent; const ACCENT = featureColors.dares.accent;
@@ -27,6 +27,7 @@ export function DaresScreen() {
const [sheetPack, setSheetPack] = useState<DarePack | null>(null); const [sheetPack, setSheetPack] = useState<DarePack | null>(null);
const [sheetOpen, setSheetOpen] = useState(false); const [sheetOpen, setSheetOpen] = useState(false);
const [paywallOpen, setPaywallOpen] = useState(false); const [paywallOpen, setPaywallOpen] = useState(false);
const [proofDare, setProofDare] = useState<SentDare | null>(null);
const openPaywall = useCallback(() => { const openPaywall = useCallback(() => {
setSheetOpen(false); setSheetOpen(false);
@@ -68,6 +69,14 @@ export function DaresScreen() {
void dares.respond(dareId, action); void dares.respond(dareId, action);
return; return;
} }
if (action === "submit") {
// Proof-required dares detour through the capture sheet before submitting.
const dare = dares.active.find((candidate) => candidate.id === dareId);
if (dare && dare.proof_type !== "none") {
setProofDare(dare);
return;
}
}
void dares[action](dareId); void dares[action](dareId);
}, },
[dares], [dares],
@@ -173,6 +182,12 @@ export function DaresScreen() {
onRequestPremium={openPaywall} onRequestPremium={openPaywall}
/> />
<DareProofSheet
dare={proofDare}
onClose={() => setProofDare(null)}
onSubmit={(dareId, proofMediaId) => dares.submit(dareId, proofMediaId)}
/>
<Paywall <Paywall
visible={paywallOpen} visible={paywallOpen}
onClose={closePaywall} onClose={closePaywall}
@@ -2,6 +2,7 @@ import { memo } from "react";
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native"; import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { colors, featureColors, radius, spacing, typography } from "../../../theme"; import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import { DareProofView } from "./DareProofView";
import type { SentDare } from "../types"; import type { SentDare } from "../types";
const ACCENT = featureColors.dares.accent; const ACCENT = featureColors.dares.accent;
@@ -92,6 +93,23 @@ export const DareCard = memo(function DareCard({ dare, busy, onAction }: DareCar
{dare.sender_notes ? <Text style={styles.notes}>“{dare.sender_notes}”</Text> : null} {dare.sender_notes ? <Text style={styles.notes}>“{dare.sender_notes}”</Text> : null}
{dare.proof_type !== "none" ? (
<View style={styles.proofRow}>
<Ionicons
name={dare.proof_type === "photo" ? "camera-outline" : "mic-outline"}
size={13}
color={ACCENT}
/>
<Text style={styles.proofLabel}>
{dare.proof_type === "photo" ? "Photo proof required" : "Audio proof required"}
</Text>
</View>
) : null}
{dare.proof_media_id && dare.proof_type !== "none" ? (
<DareProofView proofMediaId={dare.proof_media_id} proofType={dare.proof_type} />
) : null}
{deadline ? ( {deadline ? (
<View style={styles.deadlineRow}> <View style={styles.deadlineRow}>
<Ionicons name="time-outline" size={13} color={colors.textTertiary} /> <Ionicons name="time-outline" size={13} color={colors.textTertiary} />
@@ -157,6 +175,13 @@ const styles = StyleSheet.create({
fontStyle: "italic", fontStyle: "italic",
marginTop: spacing.sm, marginTop: spacing.sm,
}, },
proofRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
marginTop: spacing.sm,
},
proofLabel: { ...typography.caption1, color: ACCENT, fontWeight: "600" },
deadlineRow: { deadlineRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -0,0 +1,243 @@
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator, Image, Modal, Platform, Pressable, StyleSheet, Text, View,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import * as ImagePicker from "expo-image-picker";
import { useAudioRecorder } from "../../../hooks/useAudioRecorder";
import { uploadMedia } from "../../../lib/mediaApi";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import type { SentDare } from "../types";
const ACCENT = featureColors.dares.accent;
const MAX_AUDIO_SECONDS = 60;
export interface DareProofSheetProps {
/** The active incoming dare being submitted; null closes the sheet. */
dare: SentDare | null;
onClose: () => void;
/** Uploads have already happened; the id is ready to attach to the submit call. */
onSubmit: (dareId: string, proofMediaId: string) => Promise<void> | void;
}
function imageMime(uri: string): string {
const ext = uri.split(".").pop()?.toLowerCase() ?? "jpg";
if (ext === "png") return "image/png";
if (ext === "webp") return "image/webp";
return "image/jpeg";
}
/** expo-av records .m4a on both platforms; web lands on webm. */
function audioMime(uri: string): string {
const ext = uri.split(".").pop()?.toLowerCase() ?? "m4a";
if (ext === "webm") return "audio/webm";
if (ext === "wav") return "audio/wav";
if (ext === "mp3") return "audio/mpeg";
if (ext === "caf") return "audio/x-caf";
return "audio/mp4";
}
export function DareProofSheet({ dare, onClose, onSubmit }: DareProofSheetProps) {
const [photoUri, setPhotoUri] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const recorder = useAudioRecorder({ maxDurationSeconds: MAX_AUDIO_SECONDS });
const visible = dare !== null;
const mode = dare?.proof_type ?? "none";
useEffect(() => {
if (!visible) return;
setPhotoUri(null);
setError(null);
setSending(false);
recorder.resetRecording();
// Reset only when a new dare opens the sheet, not on recorder identity churn.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visible, dare?.id]);
const pickPhoto = useCallback(async (fromCamera: boolean) => {
setError(null);
if (fromCamera) {
const permission = await ImagePicker.requestCameraPermissionsAsync();
if (!permission.granted) {
setError("Camera access is needed to take a proof photo.");
return;
}
}
const result = fromCamera
? await ImagePicker.launchCameraAsync({ allowsEditing: false, quality: 0.7 })
: await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: false,
quality: 0.7,
});
if (!result.canceled) setPhotoUri(result.assets[0].uri);
}, []);
const proofUri = mode === "photo" ? photoUri : recorder.recordingUri;
const canSend = proofUri !== null && !sending && recorder.state !== "recording";
const handleSend = useCallback(async () => {
if (!dare || !proofUri) return;
setSending(true);
setError(null);
try {
const mimeType = mode === "photo" ? imageMime(proofUri) : audioMime(proofUri);
const upload = await uploadMedia(proofUri, { kind: "dare_proof", mimeType });
await onSubmit(dare.id, upload.media.id);
onClose();
} catch {
setError("Could not send your proof. Check your connection and try again.");
} finally {
setSending(false);
}
}, [dare, proofUri, mode, onSubmit, onClose]);
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
<View style={styles.backdrop}>
<View style={styles.sheet}>
<View style={styles.handle} />
<View style={styles.header}>
<Text style={styles.title}>
{mode === "photo" ? "Photo proof" : "Audio proof"}
</Text>
<Pressable onPress={onClose} hitSlop={12} disabled={sending}>
<Ionicons name="close" size={22} color={colors.textSecondary} />
</Pressable>
</View>
<Text style={styles.subtitle}>
{mode === "photo"
? "Your partner asked for a photo to prove this one."
: "Your partner asked for a voice note to prove this one."}
</Text>
{mode === "photo" ? (
<View style={styles.captureArea}>
{photoUri ? (
<Image source={{ uri: photoUri }} style={styles.preview} resizeMode="cover" />
) : (
<View style={styles.placeholder}>
<Ionicons name="camera-outline" size={32} color={colors.textTertiary} />
</View>
)}
<View style={styles.captureButtons}>
{Platform.OS !== "web" ? (
<Pressable style={styles.captureButton} onPress={() => void pickPhoto(true)}>
<Ionicons name="camera" size={16} color={ACCENT} />
<Text style={styles.captureButtonText}>Take photo</Text>
</Pressable>
) : null}
<Pressable style={styles.captureButton} onPress={() => void pickPhoto(false)}>
<Ionicons name="images-outline" size={16} color={ACCENT} />
<Text style={styles.captureButtonText}>
{photoUri ? "Choose another" : "Choose photo"}
</Text>
</Pressable>
</View>
</View>
) : (
<View style={styles.captureArea}>
<Pressable
style={[styles.recordButton, recorder.state === "recording" && styles.recordButtonActive]}
onPress={() => {
if (recorder.state === "recording") void recorder.stopRecording();
else void recorder.startRecording();
}}
disabled={sending}
>
<Ionicons
name={recorder.state === "recording" ? "stop" : "mic"}
size={28}
color={recorder.state === "recording" ? colors.background : ACCENT}
/>
</Pressable>
<Text style={styles.recordStatus}>
{recorder.state === "recording"
? `Recording… ${recorder.durationSeconds}s`
: recorder.recordingUri
? `Recorded ${recorder.durationSeconds}s — tap the mic to redo`
: `Tap to record (up to ${MAX_AUDIO_SECONDS}s)`}
</Text>
</View>
)}
{error ? <Text style={styles.error}>{error}</Text> : null}
<Pressable
onPress={() => void handleSend()}
disabled={!canSend}
style={[styles.sendButton, !canSend && styles.sendButtonDisabled]}
>
{sending ? (
<ActivityIndicator size="small" color={colors.background} />
) : (
<Text style={styles.sendButtonText}>Send proof & finish dare</Text>
)}
</Pressable>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
backdrop: { flex: 1, backgroundColor: "rgba(0,0,0,0.6)", justifyContent: "flex-end" },
sheet: {
backgroundColor: colors.background,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
paddingHorizontal: spacing.lg,
paddingBottom: Platform.OS === "ios" ? spacing.xl : spacing.lg,
},
handle: {
width: 36, height: 4, borderRadius: radius.full,
backgroundColor: colors.border, alignSelf: "center", marginTop: spacing.sm,
},
header: {
flexDirection: "row", alignItems: "center", justifyContent: "space-between",
paddingVertical: spacing.md,
},
title: { ...typography.title3, color: colors.text },
subtitle: { ...typography.subhead, color: colors.textSecondary, marginBottom: spacing.lg },
captureArea: { alignItems: "center", gap: spacing.md, marginBottom: spacing.lg },
preview: {
width: "100%", height: 220,
borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border,
},
placeholder: {
width: "100%", height: 220,
borderRadius: radius.lg, borderWidth: 1, borderColor: colors.border,
backgroundColor: colors.backgroundLight,
alignItems: "center", justifyContent: "center",
},
captureButtons: { flexDirection: "row", gap: spacing.sm },
captureButton: {
flexDirection: "row", alignItems: "center", gap: spacing.xs,
paddingHorizontal: spacing.md, paddingVertical: spacing.sm,
borderRadius: radius.full, borderWidth: 1, borderColor: ACCENT,
},
captureButtonText: { ...typography.subhead, color: ACCENT, fontWeight: "600" },
recordButton: {
width: 72, height: 72, borderRadius: radius.full,
borderWidth: 2, borderColor: ACCENT,
alignItems: "center", justifyContent: "center",
backgroundColor: colors.backgroundLight,
},
recordButtonActive: { backgroundColor: ACCENT },
recordStatus: { ...typography.footnote, color: colors.textSecondary },
error: { ...typography.footnote, color: colors.error, marginBottom: spacing.md },
sendButton: {
backgroundColor: ACCENT,
borderRadius: radius.full,
paddingVertical: spacing.md,
alignItems: "center",
justifyContent: "center",
minHeight: 50,
},
sendButtonDisabled: { opacity: 0.4 },
sendButtonText: { ...typography.headline, color: colors.background, fontWeight: "700" },
});
@@ -0,0 +1,112 @@
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Image, Pressable, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { Audio } from "expo-av";
import { getMediaUrl } from "../../../lib/mediaApi";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import type { DareProofType } from "../types";
const ACCENT = featureColors.dares.accent;
export interface DareProofViewProps {
proofMediaId: string;
proofType: DareProofType;
}
/**
* Renders the attached proof inline on a dare card: the photo itself, or a
* play button for audio. Signed URLs are short-lived, so the URL is resolved
* lazily on first render (photo) or first play (audio).
*/
export function DareProofView({ proofMediaId, proofType }: DareProofViewProps) {
const [url, setUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const [sound, setSound] = useState<Audio.Sound | null>(null);
const [playing, setPlaying] = useState(false);
const [loadingAudio, setLoadingAudio] = useState(false);
useEffect(() => {
if (proofType !== "photo") return;
let cancelled = false;
getMediaUrl(proofMediaId)
.then((result) => { if (!cancelled) setUrl(result.url); })
.catch(() => { if (!cancelled) setFailed(true); });
return () => { cancelled = true; };
}, [proofMediaId, proofType]);
useEffect(() => {
return () => { if (sound) void sound.unloadAsync(); };
}, [sound]);
const togglePlayback = useCallback(async () => {
try {
if (sound) {
const status = await sound.getStatusAsync();
if (status.isLoaded && status.isPlaying) {
await sound.pauseAsync();
setPlaying(false);
} else {
await sound.replayAsync();
setPlaying(true);
}
return;
}
setLoadingAudio(true);
const { url: signedUrl } = await getMediaUrl(proofMediaId);
const created = await Audio.Sound.createAsync(
{ uri: signedUrl },
{ shouldPlay: true },
(status) => {
if (status.isLoaded && status.didJustFinish) setPlaying(false);
},
);
setSound(created.sound);
setPlaying(true);
} catch {
setFailed(true);
} finally {
setLoadingAudio(false);
}
}, [sound, proofMediaId]);
if (failed) {
return <Text style={styles.failed}>Proof could not be loaded.</Text>;
}
if (proofType === "photo") {
return url ? (
<Image source={{ uri: url }} style={styles.photo} resizeMode="cover" />
) : (
<ActivityIndicator size="small" color={ACCENT} style={styles.loader} />
);
}
return (
<Pressable style={styles.audioRow} onPress={() => void togglePlayback()} disabled={loadingAudio}>
{loadingAudio ? (
<ActivityIndicator size="small" color={ACCENT} />
) : (
<Ionicons name={playing ? "pause-circle" : "play-circle"} size={28} color={ACCENT} />
)}
<Text style={styles.audioLabel}>{playing ? "Playing proof…" : "Play audio proof"}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
photo: {
width: "100%", height: 180, marginTop: spacing.sm,
borderRadius: radius.md, borderWidth: 1, borderColor: colors.border,
},
loader: { marginTop: spacing.sm, alignSelf: "flex-start" },
audioRow: {
flexDirection: "row", alignItems: "center", gap: spacing.sm,
marginTop: spacing.sm,
paddingHorizontal: spacing.md, paddingVertical: spacing.sm,
borderRadius: radius.md, borderWidth: 1, borderColor: colors.border,
backgroundColor: colors.background,
alignSelf: "flex-start",
},
audioLabel: { ...typography.subhead, color: colors.text, fontWeight: "600" },
failed: { ...typography.footnote, color: colors.textTertiary, marginTop: spacing.sm },
});
@@ -6,7 +6,7 @@ import {
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { daresApi } from "../../../lib/daresApi"; import { daresApi } from "../../../lib/daresApi";
import { colors, featureColors, radius, spacing, typography } from "../../../theme"; import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import { DURATION_OPTIONS, type DareItem, type DarePack, type SendDarePayload } from "../types"; import { DURATION_OPTIONS, PROOF_OPTIONS, type DareItem, type DarePack, type DareProofType, type SendDarePayload } from "../types";
const ACCENT = featureColors.dares.accent; const ACCENT = featureColors.dares.accent;
@@ -26,6 +26,7 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedId, setSelectedId] = useState<string | null>(null);
const [customText, setCustomText] = useState(""); const [customText, setCustomText] = useState("");
const [durationHours, setDurationHours] = useState<number | null>(24); const [durationHours, setDurationHours] = useState<number | null>(24);
const [proofType, setProofType] = useState<DareProofType>("none");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
@@ -35,6 +36,7 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o
setSelectedId(null); setSelectedId(null);
setCustomText(""); setCustomText("");
setNotes(""); setNotes("");
setProofType("none");
daresApi daresApi
.packDares(pack.id) .packDares(pack.id)
.then((result) => setDares(result.dares)) .then((result) => setDares(result.dares))
@@ -52,10 +54,11 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o
? { custom_dare_text: customText.trim(), custom_dare_intensity: 3, duration_hours: durationHours } ? { custom_dare_text: customText.trim(), custom_dare_intensity: 3, duration_hours: durationHours }
: { dare_id: selectedId!, duration_hours: durationHours }; : { dare_id: selectedId!, duration_hours: durationHours };
if (notes.trim()) payload.sender_notes = notes.trim(); if (notes.trim()) payload.sender_notes = notes.trim();
if (proofType !== "none") payload.proof_type = proofType;
const sent = await onSend(payload); const sent = await onSend(payload);
setSending(false); setSending(false);
if (sent) onClose(); if (sent) onClose();
}, [canSubmit, sending, isCustom, customText, selectedId, durationHours, notes, onSend, onClose]); }, [canSubmit, sending, isCustom, customText, selectedId, durationHours, proofType, notes, onSend, onClose]);
return ( return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}> <Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
@@ -135,6 +138,29 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o
})} })}
</View> </View>
<Text style={styles.sectionLabel}>REQUIRE PROOF</Text>
<View style={styles.durationRow}>
{PROOF_OPTIONS.map((option) => {
const selected = option.value === proofType;
return (
<Pressable
key={option.value}
onPress={() => setProofType(option.value)}
style={[styles.chip, styles.proofChip, selected && styles.chipSelected]}
>
<Ionicons
name={option.icon as keyof typeof Ionicons.glyphMap}
size={14}
color={selected ? ACCENT : colors.textSecondary}
/>
<Text style={[styles.chipText, selected && styles.chipTextSelected]}>
{option.label}
</Text>
</Pressable>
);
})}
</View>
<Text style={styles.sectionLabel}>ADD A NOTE</Text> <Text style={styles.sectionLabel}>ADD A NOTE</Text>
<TextInput <TextInput
style={styles.notesInput} style={styles.notesInput}
@@ -229,6 +255,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md, paddingVertical: spacing.sm - 2, paddingHorizontal: spacing.md, paddingVertical: spacing.sm - 2,
borderRadius: radius.full, borderWidth: 1, borderColor: colors.border, borderRadius: radius.full, borderWidth: 1, borderColor: colors.border,
}, },
proofChip: { flexDirection: "row", alignItems: "center", gap: spacing.xs },
chipSelected: { borderColor: ACCENT, backgroundColor: "rgba(212,175,55,0.12)" }, chipSelected: { borderColor: ACCENT, backgroundColor: "rgba(212,175,55,0.12)" },
chipText: { ...typography.footnote, color: colors.textSecondary }, chipText: { ...typography.footnote, color: colors.textSecondary },
chipTextSelected: { color: ACCENT, fontWeight: "600" }, chipTextSelected: { color: ACCENT, fontWeight: "600" },
@@ -4,3 +4,7 @@ export { DarePackGrid } from "./DarePackGrid";
export type { DarePackGridProps } from "./DarePackGrid"; export type { DarePackGridProps } from "./DarePackGrid";
export { SendDareSheet } from "./SendDareSheet"; export { SendDareSheet } from "./SendDareSheet";
export type { SendDareSheetProps } from "./SendDareSheet"; export type { SendDareSheetProps } from "./SendDareSheet";
export { DareProofSheet } from "./DareProofSheet";
export type { DareProofSheetProps } from "./DareProofSheet";
export { DareProofView } from "./DareProofView";
export type { DareProofViewProps } from "./DareProofView";
@@ -30,7 +30,7 @@ export interface UseDaresReturn {
refresh: () => Promise<void>; refresh: () => Promise<void>;
send: (payload: SendDarePayload) => Promise<boolean>; send: (payload: SendDarePayload) => Promise<boolean>;
respond: (dareId: string, action: "accept" | "decline") => Promise<void>; respond: (dareId: string, action: "accept" | "decline") => Promise<void>;
submit: (dareId: string) => Promise<void>; submit: (dareId: string, proofMediaId?: string) => Promise<void>;
complete: (dareId: string) => Promise<void>; complete: (dareId: string) => Promise<void>;
cancel: (dareId: string) => Promise<void>; cancel: (dareId: string) => Promise<void>;
} }
@@ -167,7 +167,7 @@ export function useDares({ isFocused = true }: UseDaresConfig = {}): UseDaresRet
refresh, refresh,
send, send,
respond: useCallback((dareId, action) => act(dareId, () => daresApi.respond(dareId, action)), [act]), respond: useCallback((dareId, action) => act(dareId, () => daresApi.respond(dareId, action)), [act]),
submit: useCallback((dareId) => act(dareId, () => daresApi.submit(dareId)), [act]), submit: useCallback((dareId, proofMediaId) => act(dareId, () => daresApi.submit(dareId, proofMediaId)), [act]),
complete: useCallback((dareId) => act(dareId, () => daresApi.complete(dareId)), [act]), complete: useCallback((dareId) => act(dareId, () => daresApi.complete(dareId)), [act]),
cancel: useCallback((dareId) => act(dareId, () => daresApi.cancel(dareId)), [act]), cancel: useCallback((dareId) => act(dareId, () => daresApi.cancel(dareId)), [act]),
}; };
+12
View File
@@ -1,6 +1,9 @@
export type DareStatus = export type DareStatus =
| "pending" | "active" | "submitted" | "completed" | "expired" | "declined" | "cancelled"; | "pending" | "active" | "submitted" | "completed" | "expired" | "declined" | "cancelled";
/** Sender-chosen proof of completion; "none" means a plain "I did it" suffices. */
export type DareProofType = "none" | "photo" | "audio";
export interface DarePack { export interface DarePack {
id: string; id: string;
name: string; name: string;
@@ -36,6 +39,8 @@ export interface SentDare {
direction: "incoming" | "outgoing"; direction: "incoming" | "outgoing";
status: DareStatus; status: DareStatus;
sender_notes: string | null; sender_notes: string | null;
proof_type: DareProofType;
proof_media_id: string | null;
sent_at: string; sent_at: string;
accepted_at: string | null; accepted_at: string | null;
submitted_at: string | null; submitted_at: string | null;
@@ -70,8 +75,15 @@ export interface SendDarePayload {
custom_dare_intensity?: number; custom_dare_intensity?: number;
duration_hours?: number | null; duration_hours?: number | null;
sender_notes?: string | null; sender_notes?: string | null;
proof_type?: DareProofType;
} }
export const PROOF_OPTIONS: { label: string; value: DareProofType; icon: string }[] = [
{ label: "No proof", value: "none", icon: "checkmark-circle-outline" },
{ label: "Photo", value: "photo", icon: "camera-outline" },
{ label: "Audio", value: "audio", icon: "mic-outline" },
];
export const DURATION_OPTIONS: { label: string; hours: number | null }[] = [ export const DURATION_OPTIONS: { label: string; hours: number | null }[] = [
{ label: "1 hour", hours: 1 }, { label: "1 hour", hours: 1 },
{ label: "6 hours", hours: 6 }, { label: "6 hours", hours: 6 },
+5 -1
View File
@@ -13,7 +13,11 @@ export const daresApi = {
send: (payload: SendDarePayload) => apiClient.post<{ dare: SentDare }>("/v1/dares", payload), send: (payload: SendDarePayload) => apiClient.post<{ dare: SentDare }>("/v1/dares", payload),
respond: (dareId: string, action: "accept" | "decline") => respond: (dareId: string, action: "accept" | "decline") =>
apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/respond`, { action }), apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/respond`, { action }),
submit: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/submit`), submit: (dareId: string, proofMediaId?: string) =>
apiClient.post<{ dare: SentDare }>(
`${dare(dareId)}/submit`,
proofMediaId ? { proof_media_id: proofMediaId } : undefined,
),
complete: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/complete`), complete: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/complete`),
cancel: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/cancel`), cancel: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/cancel`),
}; };
+1 -1
View File
@@ -3,7 +3,7 @@ import * as FileSystem from 'expo-file-system/legacy';
import { decode } from 'base64-arraybuffer'; import { decode } from 'base64-arraybuffer';
import { ApiError, apiClient, authenticatedFetch } from './apiClient'; import { ApiError, apiClient, authenticatedFetch } from './apiClient';
export type MediaKind = 'avatar' | 'response' | 'chat' | 'feedback'; export type MediaKind = 'avatar' | 'response' | 'chat' | 'feedback' | 'dare_proof';
export interface UploadMediaOptions { kind: MediaKind; mimeType: string; questionId?: string; matchId?: string } export interface UploadMediaOptions { kind: MediaKind; mimeType: string; questionId?: string; matchId?: string }
export interface MediaUploadResponse { reference: string; media: { id: string; mime_type: string; byte_size: number }; message?: Record<string, unknown> } export interface MediaUploadResponse { reference: string; media: { id: string; mime_type: string; byte_size: number }; message?: Record<string, unknown> }
+1 -1
View File
@@ -595,7 +595,7 @@ Features observed in competitor apps (Desire, Kindu, Spicer, Lovewick):
| Decline Option | ✅ | No negative impact | | Decline Option | ✅ | No negative impact |
| Dare History | ✅ | Via `sent_dares` table | | Dare History | ✅ | Via `sent_dares` table |
| Chat per Dare | ✅ | `dare_messages` table | | Chat per Dare | ✅ | `dare_messages` table |
| Proof/Verification | ❌ | Not in v1 (could add photo proof later) | | Proof/Verification | ✅ | Optional photo/audio proof, chosen by the sender per dare |
| Daily/Random Dares | ❌ | Not in v1 (could add "random dare" button) | | Daily/Random Dares | ❌ | Not in v1 (could add "random dare" button) |
| Dare Rewards | ❌ | Not in v1 (could unlock content) | | Dare Rewards | ❌ | Not in v1 (could unlock content) |
| Streak Tracking | ❌ | Not in v1 (could add later) | | Streak Tracking | ❌ | Not in v1 (could add later) |
+14 -3
View File
@@ -109,15 +109,26 @@ and validated server-side.
| GET | `/v1/dares?filter=active\|history` | Couple-scoped; `direction` is relative to the caller | | GET | `/v1/dares?filter=active\|history` | Couple-scoped; `direction` is relative to the caller |
| GET | `/v1/dares/stats` | Shared scoreboard | | GET | `/v1/dares/stats` | Shared scoreboard |
| GET | `/v1/dares/durations` | Preset list the send sheet renders | | GET | `/v1/dares/durations` | Preset list the send sheet renders |
| POST | `/v1/dares` | `dare_id` **or** `custom_dare_text`, never both | | POST | `/v1/dares` | `dare_id` **or** `custom_dare_text`, never both; optional `proof_type` (`none`/`photo`/`audio`) |
| POST | `/v1/dares/:dareId/respond` | `{ action: "accept" \| "decline" }` | | POST | `/v1/dares/:dareId/respond` | `{ action: "accept" \| "decline" }` |
| POST | `/v1/dares/:dareId/submit` | Recipient reports done | | POST | `/v1/dares/:dareId/submit` | Recipient reports done; `{ proof_media_id }` required when the dare has a proof requirement |
| POST | `/v1/dares/:dareId/complete` | Sender confirms | | POST | `/v1/dares/:dareId/complete` | Sender confirms |
| POST | `/v1/dares/:dareId/cancel` | Sender withdraws | | POST | `/v1/dares/:dareId/cancel` | Sender withdraws |
The actor always comes from the bearer identity; ownership fields in a request body are The actor always comes from the bearer identity; ownership fields in a request body are
rejected. rejected.
## Proof of completion
The sender may optionally require proof when sending any dare, catalogue or custom:
`sent_dares.proof_type` is `'none'` (default), `'photo'`, or `'audio'`. The recipient
uploads the proof first (`POST /v1/media/dare_proof` — images and audio, 25MB cap,
couple-scoped so the sender can view it) and passes the resulting media id to `submit`.
The server enforces the requirement: a proofed dare cannot be submitted without media of
the matching type, and the media must be a `dare_proof` object owned by the recipient in
the same couple. Voluntary proof on a `'none'` dare is accepted. The proof renders inline
on the dare card (photo, or an audio player) via the standard signed-URL flow.
## Mobile ## Mobile
Reached from the home screen's `DaresTile`, which badges pending incoming dares. The Reached from the home screen's `DaresTile`, which badges pending incoming dares. The
@@ -142,4 +153,4 @@ actually changed, or on an explicit refresh or a mutation. See
Per-dare chat. `dare_messages` exists and is wired into the schema, but a second inbox Per-dare chat. `dare_messages` exists and is wired into the schema, but a second inbox
split from the main match chat was cut in favour of status transitions plus the sender's split from the main match chat was cut in favour of status transitions plus the sender's
note. Photo proof, random-dare, and streaks remain unbuilt. note. Random-dare and streaks remain unbuilt.
+13 -3
View File
@@ -9,7 +9,7 @@ once both have finished.
| Concern | Location | | Concern | Location |
|---|---| |---|---|
| Schema and seed content | `apps/api/drizzle/0019_quiz.sql` | | Schema and seed content | `apps/api/drizzle/0019_quiz.sql`, `apps/api/drizzle/0020_quiz_question_pool.sql` |
| HTTP contract | `apps/api/src/domains/quiz/routes.ts` | | HTTP contract | `apps/api/src/domains/quiz/routes.ts` |
| Session and scoring rules | `apps/api/src/domains/quiz/repository.ts` | | Session and scoring rules | `apps/api/src/domains/quiz/repository.ts` |
| Pure scoring function | `apps/api/src/domains/quiz/service.ts` (`computeQuizScore`) | | Pure scoring function | `apps/api/src/domains/quiz/service.ts` (`computeQuizScore`) |
@@ -30,6 +30,15 @@ quiz while one is already active returns that same session (`200`) instead of
creating a second one; two simultaneous starts race safely because the creating a second one; two simultaneous starts race safely because the
partial unique index, not application locking, is the source of truth. partial unique index, not application locking, is the source of truth.
When a new session is created, the 10 questions are chosen from the active
pool with a couple-scoped preference order: questions this couple has never
had in any prior session (completed or active) come first, then the
least-recently-used ones, with a random tiebreak within each group. This
keeps repeat quizzes varied instead of always drawing the same
`sort_order`-first questions. The seeded pool (`0019_quiz.sql` plus
`0020_quiz_question_pool.sql`) has 36 active questions, so two consecutive
sessions for the same couple are guaranteed not to overlap.
Answers are submitted as one complete batch per call (`quiz_answers`, upsert Answers are submitted as one complete batch per call (`quiz_answers`, upsert
on `(session_id, user_id, question_id)`). A submission must cover every on `(session_id, user_id, question_id)`). A submission must cover every
question in the session exactly once, with every index inside that question in the session exactly once, with every index inside that
@@ -73,8 +82,9 @@ completed session.
- `apps/api/test/quiz.routes.test.ts`: HTTP contract and error mapping - `apps/api/test/quiz.routes.test.ts`: HTTP contract and error mapping
- `apps/api/test/quiz.integration.test.ts`: real PostgreSQL, covering the full - `apps/api/test/quiz.integration.test.ts`: real PostgreSQL, covering the full
happy path with a hand-computed score, idempotent session start, couple happy path with a hand-computed score, idempotent session start, couple
isolation, incomplete answer set rejection, and result blocked before isolation, incomplete answer set rejection, result blocked before
completion completion, no question overlap between a completed session and the next
one for the same couple, and that selection is scoped per couple
## Mobile client ## Mobile client