From 504eddaf68dc70e0cc8a39109d3a8714dc20f078 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 30 Aug 2026 19:36:11 +0100 Subject: [PATCH] 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 --- apps/api/drizzle/0021_dare_proof.sql | 16 ++ apps/api/src/domains/dares/repository.ts | 84 +++++- apps/api/src/domains/dares/routes.ts | 17 +- apps/api/src/domains/media/repository.ts | 4 +- apps/api/src/domains/media/storage.ts | 3 +- apps/api/src/domains/media/types.ts | 2 +- apps/api/test/dares.integration.test.ts | 52 ++++ apps/api/test/dares.routes.test.ts | 22 +- .../src/__tests__/hooks/useDares.test.ts | 19 +- .../mobile/src/features/dares/DaresScreen.tsx | 19 +- .../features/dares/components/DareCard.tsx | 25 ++ .../dares/components/DareProofSheet.tsx | 243 ++++++++++++++++++ .../dares/components/DareProofView.tsx | 112 ++++++++ .../dares/components/SendDareSheet.tsx | 31 ++- .../src/features/dares/components/index.ts | 4 + .../src/features/dares/hooks/useDares.ts | 4 +- apps/mobile/src/features/dares/types.ts | 12 + apps/mobile/src/lib/daresApi.ts | 6 +- apps/mobile/src/lib/mediaApi.ts | 2 +- docs/dares-feature.md | 2 +- docs/dares-loop.md | 17 +- 21 files changed, 668 insertions(+), 28 deletions(-) create mode 100644 apps/api/drizzle/0021_dare_proof.sql create mode 100644 apps/mobile/src/features/dares/components/DareProofSheet.tsx create mode 100644 apps/mobile/src/features/dares/components/DareProofView.tsx diff --git a/apps/api/drizzle/0021_dare_proof.sql b/apps/api/drizzle/0021_dare_proof.sql new file mode 100644 index 0000000..ae84c3a --- /dev/null +++ b/apps/api/drizzle/0021_dare_proof.sql @@ -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')); diff --git a/apps/api/src/domains/dares/repository.ts b/apps/api/src/domains/dares/repository.ts index 5902a2d..547eb75 100644 --- a/apps/api/src/domains/dares/repository.ts +++ b/apps/api/src/domains/dares/repository.ts @@ -4,6 +4,11 @@ import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../d export type DareStatus = | '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. */ export const FREE_WEEKLY_SEND_LIMIT = 3; @@ -45,6 +50,8 @@ export interface SentDare { direction: 'incoming' | 'outgoing'; status: DareStatus; sender_notes: string | null; + proof_type: DareProofType; + proof_media_id: string | null; sent_at: string; accepted_at: string | null; submitted_at: string | null; @@ -79,6 +86,7 @@ export interface SendDareInput { custom_dare_intensity?: number | null; duration_hours?: number | null; sender_notes?: string | null; + proof_type?: DareProofType | null; } export class DaresError extends Error { @@ -111,6 +119,8 @@ interface SentDareRecord extends QueryResultRow { recipient_id: string; status: DareStatus; sender_notes: string | null; + proof_type: DareProofType; + proof_media_id: string | null; sent_at: Date; accepted_at: Date | null; submitted_at: Date | null; @@ -124,7 +134,7 @@ export interface DaresRepository { listDares(userId: string, filter: 'active' | 'history'): Promise; send(userId: string, input: SendDareInput): Promise; respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise; - submit(userId: string, dareId: string): Promise; + submit(userId: string, dareId: string, proofMediaId?: string | null): Promise; complete(userId: string, dareId: string): Promise; cancel(userId: string, dareId: string): Promise; stats(userId: string): Promise; @@ -132,7 +142,7 @@ export interface DaresRepository { } 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`; 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', status: row.status, sender_notes: row.sender_notes, + proof_type: row.proof_type, + proof_media_id: row.proof_media_id, sent_at: row.sent_at.toISOString(), accepted_at: iso(row.accepted_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); } + 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 intensity: number; let dareId: string | null = null; @@ -376,15 +393,15 @@ export class PostgresDaresRepository implements DaresRepository { `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 + sender_id, recipient_id, sender_notes, proof_type, 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 + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + case when $11::integer is null then null else now() + ($11::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, + userId, context.partner_id, input.sender_notes?.trim() || null, proofType, duration, ], ); return toSentDare(result.rows[0]!, userId); @@ -438,8 +455,59 @@ export class PostgresDaresRepository implements DaresRepository { : this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null); } - async submit(userId: string, dareId: string): Promise { - 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 { + 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( + `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 { diff --git a/apps/api/src/domains/dares/routes.ts b/apps/api/src/domains/dares/routes.ts index 970b83d..575dadc 100644 --- a/apps/api/src/domains/dares/routes.ts +++ b/apps/api/src/domains/dares/routes.ts @@ -15,6 +15,7 @@ const sendBody = z 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(), + proof_type: z.enum(['none', 'photo', 'audio']).nullish(), }) .strict() .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 submitBody = z.object({ proof_media_id: id.nullish() }).strict(); + function error(code: string, message: string): ApiErrorResponse { 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); }); + 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 [ - ['submit', 'submit'], ['complete', 'complete'], ['cancel', 'cancel'], ] as const) { diff --git a/apps/api/src/domains/media/repository.ts b/apps/api/src/domains/media/repository.ts index 52af5cc..696c712 100644 --- a/apps/api/src/domains/media/repository.ts +++ b/apps/api/src/domains/media/repository.ts @@ -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]); if (!profile.rows[0]) throw new MediaError('profile_not_found', 'Profile not found', 404); 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 (!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]); @@ -84,7 +84,7 @@ export class PostgresMediaRepository implements MediaRepository { 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()) 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], ); if (!result.rows[0]) throw new MediaError('media_not_found', 'Media not found', 404); diff --git a/apps/api/src/domains/media/storage.ts b/apps/api/src/domains/media/storage.ts index fdd744c..97f5bc5 100644 --- a/apps/api/src/domains/media/storage.ts +++ b/apps/api/src/domains/media/storage.ts @@ -8,8 +8,9 @@ const allowed: Record> = { 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']), 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 = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*1024*1024 }; +const limits: Record = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*1024*1024, dare_proof: 25*1024*1024 }; const extensions: Record = { '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 { diff --git a/apps/api/src/domains/media/types.ts b/apps/api/src/domains/media/types.ts index d6e5b5f..f71cf2a 100644 --- a/apps/api/src/domains/media/types.ts +++ b/apps/api/src/domains/media/types.ts @@ -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 interface MediaObject { diff --git a/apps/api/test/dares.integration.test.ts b/apps/api/test/dares.integration.test.ts index 01656a1..9086c10 100644 --- a/apps/api/test/dares.integration.test.ts +++ b/apps/api/test/dares.integration.test.ts @@ -196,6 +196,58 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => { 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 () => { await expect(repo.send(sender, { dare_id: freeDare, duration_hours: 5 })).rejects.toMatchObject({ code: 'invalid_duration' }); }); diff --git a/apps/api/test/dares.routes.test.ts b/apps/api/test/dares.routes.test.ts index cc2cb34..294c0ac 100644 --- a/apps/api/test/dares.routes.test.ts +++ b/apps/api/test/dares.routes.test.ts @@ -13,10 +13,12 @@ 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, + 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, }; +const proofMediaId = '77777777-7777-4777-8777-777777777777'; + function setup(overrides: Partial = {}) { const repository: DaresRepository = { getCatalog: vi.fn(async () => ({ @@ -91,7 +93,7 @@ describe('dare routes', () => { 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); + expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId, null); await post(app, `/v1/dares/${dareId}/complete`); expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId); 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); }); + 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 () => { const { app } = setup(); const response = await app.request('/v1/dares/durations'); diff --git a/apps/mobile/src/__tests__/hooks/useDares.test.ts b/apps/mobile/src/__tests__/hooks/useDares.test.ts index a5db683..9c91dd4 100644 --- a/apps/mobile/src/__tests__/hooks/useDares.test.ts +++ b/apps/mobile/src/__tests__/hooks/useDares.test.ts @@ -7,7 +7,7 @@ import type { SentDare } from "@/features/dares/types"; const incoming: SentDare = { 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", - 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, }; @@ -55,6 +55,23 @@ describe("useDares", () => { 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 () => { mockLoad(); jest.spyOn(apiClient, "post").mockRejectedValue(new ApiError("Custom dares require premium", 402)); diff --git a/apps/mobile/src/features/dares/DaresScreen.tsx b/apps/mobile/src/features/dares/DaresScreen.tsx index 1273ff8..6a604be 100644 --- a/apps/mobile/src/features/dares/DaresScreen.tsx +++ b/apps/mobile/src/features/dares/DaresScreen.tsx @@ -6,9 +6,9 @@ import Animated, { FadeIn } from "react-native-reanimated"; import { GradientBackground } from "../../components/ui"; import { Paywall } from "../../components/paywall"; 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 type { DarePack } from "./types"; +import type { DarePack, SentDare } from "./types"; const ACCENT = featureColors.dares.accent; @@ -27,6 +27,7 @@ export function DaresScreen() { const [sheetPack, setSheetPack] = useState(null); const [sheetOpen, setSheetOpen] = useState(false); const [paywallOpen, setPaywallOpen] = useState(false); + const [proofDare, setProofDare] = useState(null); const openPaywall = useCallback(() => { setSheetOpen(false); @@ -68,6 +69,14 @@ export function DaresScreen() { void dares.respond(dareId, action); 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); }, [dares], @@ -173,6 +182,12 @@ export function DaresScreen() { onRequestPremium={openPaywall} /> + setProofDare(null)} + onSubmit={(dareId, proofMediaId) => dares.submit(dareId, proofMediaId)} + /> + “{dare.sender_notes}” : null} + {dare.proof_type !== "none" ? ( + + + + {dare.proof_type === "photo" ? "Photo proof required" : "Audio proof required"} + + + ) : null} + + {dare.proof_media_id && dare.proof_type !== "none" ? ( + + ) : null} + {deadline ? ( @@ -157,6 +175,13 @@ const styles = StyleSheet.create({ fontStyle: "italic", marginTop: spacing.sm, }, + proofRow: { + flexDirection: "row", + alignItems: "center", + gap: spacing.xs, + marginTop: spacing.sm, + }, + proofLabel: { ...typography.caption1, color: ACCENT, fontWeight: "600" }, deadlineRow: { flexDirection: "row", alignItems: "center", diff --git a/apps/mobile/src/features/dares/components/DareProofSheet.tsx b/apps/mobile/src/features/dares/components/DareProofSheet.tsx new file mode 100644 index 0000000..88dc6e5 --- /dev/null +++ b/apps/mobile/src/features/dares/components/DareProofSheet.tsx @@ -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; +} + +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(null); + const [sending, setSending] = useState(false); + const [error, setError] = useState(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 ( + + + + + + + {mode === "photo" ? "Photo proof" : "Audio proof"} + + + + + + + + {mode === "photo" + ? "Your partner asked for a photo to prove this one." + : "Your partner asked for a voice note to prove this one."} + + + {mode === "photo" ? ( + + {photoUri ? ( + + ) : ( + + + + )} + + {Platform.OS !== "web" ? ( + void pickPhoto(true)}> + + Take photo + + ) : null} + void pickPhoto(false)}> + + + {photoUri ? "Choose another" : "Choose photo"} + + + + + ) : ( + + { + if (recorder.state === "recording") void recorder.stopRecording(); + else void recorder.startRecording(); + }} + disabled={sending} + > + + + + {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)`} + + + )} + + {error ? {error} : null} + + void handleSend()} + disabled={!canSend} + style={[styles.sendButton, !canSend && styles.sendButtonDisabled]} + > + {sending ? ( + + ) : ( + Send proof & finish dare + )} + + + + + ); +} + +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" }, +}); diff --git a/apps/mobile/src/features/dares/components/DareProofView.tsx b/apps/mobile/src/features/dares/components/DareProofView.tsx new file mode 100644 index 0000000..173fad4 --- /dev/null +++ b/apps/mobile/src/features/dares/components/DareProofView.tsx @@ -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(null); + const [failed, setFailed] = useState(false); + const [sound, setSound] = useState(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 Proof could not be loaded.; + } + + if (proofType === "photo") { + return url ? ( + + ) : ( + + ); + } + + return ( + void togglePlayback()} disabled={loadingAudio}> + {loadingAudio ? ( + + ) : ( + + )} + {playing ? "Playing proof…" : "Play audio proof"} + + ); +} + +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 }, +}); diff --git a/apps/mobile/src/features/dares/components/SendDareSheet.tsx b/apps/mobile/src/features/dares/components/SendDareSheet.tsx index afcb49a..3283adb 100644 --- a/apps/mobile/src/features/dares/components/SendDareSheet.tsx +++ b/apps/mobile/src/features/dares/components/SendDareSheet.tsx @@ -6,7 +6,7 @@ import { import { Ionicons } from "@expo/vector-icons"; import { daresApi } from "../../../lib/daresApi"; 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; @@ -26,6 +26,7 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o const [selectedId, setSelectedId] = useState(null); const [customText, setCustomText] = useState(""); const [durationHours, setDurationHours] = useState(24); + const [proofType, setProofType] = useState("none"); const [notes, setNotes] = useState(""); const [sending, setSending] = useState(false); @@ -35,6 +36,7 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o setSelectedId(null); setCustomText(""); setNotes(""); + setProofType("none"); daresApi .packDares(pack.id) .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 } : { dare_id: selectedId!, duration_hours: durationHours }; if (notes.trim()) payload.sender_notes = notes.trim(); + if (proofType !== "none") payload.proof_type = proofType; const sent = await onSend(payload); setSending(false); if (sent) onClose(); - }, [canSubmit, sending, isCustom, customText, selectedId, durationHours, notes, onSend, onClose]); + }, [canSubmit, sending, isCustom, customText, selectedId, durationHours, proofType, notes, onSend, onClose]); return ( @@ -135,6 +138,29 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o })} + REQUIRE PROOF + + {PROOF_OPTIONS.map((option) => { + const selected = option.value === proofType; + return ( + setProofType(option.value)} + style={[styles.chip, styles.proofChip, selected && styles.chipSelected]} + > + + + {option.label} + + + ); + })} + + ADD A NOTE Promise; send: (payload: SendDarePayload) => Promise; respond: (dareId: string, action: "accept" | "decline") => Promise; - submit: (dareId: string) => Promise; + submit: (dareId: string, proofMediaId?: string) => Promise; complete: (dareId: string) => Promise; cancel: (dareId: string) => Promise; } @@ -167,7 +167,7 @@ export function useDares({ isFocused = true }: UseDaresConfig = {}): UseDaresRet refresh, send, 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]), cancel: useCallback((dareId) => act(dareId, () => daresApi.cancel(dareId)), [act]), }; diff --git a/apps/mobile/src/features/dares/types.ts b/apps/mobile/src/features/dares/types.ts index 06632a9..8b3a248 100644 --- a/apps/mobile/src/features/dares/types.ts +++ b/apps/mobile/src/features/dares/types.ts @@ -1,6 +1,9 @@ export type DareStatus = | "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 { id: string; name: string; @@ -36,6 +39,8 @@ export interface SentDare { direction: "incoming" | "outgoing"; status: DareStatus; sender_notes: string | null; + proof_type: DareProofType; + proof_media_id: string | null; sent_at: string; accepted_at: string | null; submitted_at: string | null; @@ -70,8 +75,15 @@ export interface SendDarePayload { custom_dare_intensity?: number; duration_hours?: number | 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 }[] = [ { label: "1 hour", hours: 1 }, { label: "6 hours", hours: 6 }, diff --git a/apps/mobile/src/lib/daresApi.ts b/apps/mobile/src/lib/daresApi.ts index 654d212..8f09566 100644 --- a/apps/mobile/src/lib/daresApi.ts +++ b/apps/mobile/src/lib/daresApi.ts @@ -13,7 +13,11 @@ export const daresApi = { send: (payload: SendDarePayload) => apiClient.post<{ dare: SentDare }>("/v1/dares", payload), respond: (dareId: string, action: "accept" | "decline") => 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`), cancel: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/cancel`), }; diff --git a/apps/mobile/src/lib/mediaApi.ts b/apps/mobile/src/lib/mediaApi.ts index 0af68dc..9c45cb3 100644 --- a/apps/mobile/src/lib/mediaApi.ts +++ b/apps/mobile/src/lib/mediaApi.ts @@ -3,7 +3,7 @@ import * as FileSystem from 'expo-file-system/legacy'; import { decode } from 'base64-arraybuffer'; 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 MediaUploadResponse { reference: string; media: { id: string; mime_type: string; byte_size: number }; message?: Record } diff --git a/docs/dares-feature.md b/docs/dares-feature.md index 4453d3f..7fb50f7 100644 --- a/docs/dares-feature.md +++ b/docs/dares-feature.md @@ -595,7 +595,7 @@ Features observed in competitor apps (Desire, Kindu, Spicer, Lovewick): | Decline Option | ✅ | No negative impact | | Dare History | ✅ | Via `sent_dares` 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) | | Dare Rewards | ❌ | Not in v1 (could unlock content) | | Streak Tracking | ❌ | Not in v1 (could add later) | diff --git a/docs/dares-loop.md b/docs/dares-loop.md index e9f04ae..9324594 100644 --- a/docs/dares-loop.md +++ b/docs/dares-loop.md @@ -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/stats` | Shared scoreboard | | 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/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/cancel` | Sender withdraws | The actor always comes from the bearer identity; ownership fields in a request body are 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 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 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.