Merge dare proof of completion into staging

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Fable 5 committed 2026-08-30 19:37:08 +01:00
commit 986d0b21cb
21 files changed
+668 -28

No files matched your search

+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'));
+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 {
+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');
@@ -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.