Compare commits

Author SHA1 Message Date
jjbrunton f38b6f14fa chore(release): promote invite funnel join links to main
Promote invite funnel join links to main
2026-08-30 18:16:36 +01:00
jjbrunton 2cbe9e9c55 chore(release): promote photo picker fix
Promote the verified Android photo-picker policy fix from staging to main after target-branch and pull-request checks passed.
2026-08-30 17:31:02 +01:00
26 changed files with 35 additions and 799 deletions

No files matched your search

@@ -1,53 +0,0 @@
-- The seeded quiz pool of 12 questions was small enough that repeat sessions for
-- the same couple felt almost identical. Widen the pool so the never-used-first
-- selection strategy in the repository has enough fresh material to draw from.
INSERT INTO "quiz_questions" ("prompt_self", "prompt_guess", "options", "sort_order") VALUES
('What is your ideal pet?', 'What is your partner''s ideal pet?',
ARRAY['A dog', 'A cat', 'Something low-maintenance', 'No pets, thanks'], 130),
('What is your go-to coffee or tea order?', 'What is your partner''s go-to coffee or tea order?',
ARRAY['Strong and simple', 'Something sweet and milky', 'Herbal or decaf', 'I skip it entirely'], 140),
('What feature would your dream house have?', 'What feature would your partner''s dream house have?',
ARRAY['A big kitchen', 'A garden or outdoor space', 'A cosy reading nook', 'A home cinema or games room'], 150),
('What is your favourite season?', 'What is your partner''s favourite season?',
ARRAY['Spring', 'Summer', 'Autumn', 'Winter'], 160),
('On a road trip, what role do you take?', 'On a road trip, what role does your partner take?',
ARRAY['Driver', 'Navigator', 'Playlist curator', 'Snack manager'], 170),
('When a cooking attempt goes wrong, what do you do?', 'When a cooking attempt goes wrong, what does your partner do?',
ARRAY['Laugh it off and order in', 'Try to fix it anyway', 'Start again from scratch', 'Blame the recipe'], 180),
('What is your worst phone habit?', 'What is your partner''s worst phone habit?',
ARRAY['Scrolling too long', 'Never replying quickly', 'Too many notifications on', 'Checking it during conversations'], 190),
('What makes your ideal birthday?', 'What makes your partner''s ideal birthday?',
ARRAY['A big party with friends', 'A quiet day together', 'A surprise outing', 'Just a nice meal and cake'], 200),
('How do you behave during a board game?', 'How does your partner behave during a board game?',
ARRAY['Fiercely competitive', 'Relaxed, just for fun', 'Overly strategic', 'Happy to lose as long as it is fun'], 210),
('What is your packing style before a trip?', 'What is your partner''s packing style before a trip?',
ARRAY['Packed days in advance', 'Careful list-maker', 'Last-minute scramble', 'Overpacks just in case'], 220),
('How well do you handle scary movies?', 'How well does your partner handle scary movies?',
ARRAY['Loves them, no problem', 'Watches through fingers', 'Needs the lights on after', 'Avoids them completely'], 230),
('What is your dance floor style?', 'What is your partner''s dance floor style?',
ARRAY['First one out there', 'Needs a drink first', 'Sways from the sidelines', 'Stays firmly seated'], 240),
('What would you do with a free hour to yourself?', 'What would your partner do with a free hour to themselves?',
ARRAY['Nap or rest', 'Read or watch something', 'Go for a walk or exercise', 'Tidy up or get things done'], 250),
('Which type of celebrity would you want as a dinner guest?', 'Which type of celebrity would your partner want as a dinner guest?',
ARRAY['A comedian', 'A musician', 'An athlete', 'An author or thinker'], 260),
('What childhood dream job says the most about you?', 'What childhood dream job says the most about your partner?',
ARRAY['Astronaut or explorer', 'Performer or artist', 'Something helping others', 'Something running the show'], 270),
('How would you describe your tidiness style?', 'How would you describe your partner''s tidiness style?',
ARRAY['Everything has its place', 'Tidy in bursts', 'Organised chaos', 'Relaxed about mess'], 280),
('Are you more spontaneous or a planner?', 'Is your partner more spontaneous or a planner?',
ARRAY['Fully spontaneous', 'Mostly spontaneous', 'Mostly a planner', 'Fully a planner'], 290),
('What genre do you rewatch for comfort?', 'What genre does your partner rewatch for comfort?',
ARRAY['Sitcoms', 'Feel-good films', 'Fantasy or sci-fi', 'Reality shows'], 300),
('How would you describe your social battery?', 'How would you describe your partner''s social battery?',
ARRAY['Energised by people', 'Fine in small doses', 'Drained quickly', 'Depends entirely on the day'], 310),
('What is your gift-giving style?', 'What is your partner''s gift-giving style?',
ARRAY['Thoughtful and personal', 'Practical and useful', 'Big and generous', 'Last-minute but well-meant'], 320),
('What kind of breakfast personality do you have?', 'What kind of breakfast personality does your partner have?',
ARRAY['Big cooked breakfast', 'Quick and light', 'Coffee only', 'Skips it most days'], 330),
('What weather do you prefer?', 'What weather does your partner prefer?',
ARRAY['Warm and sunny', 'Cool and crisp', 'Rainy and cosy', 'Snowy'], 340),
('If you won the lottery, what would you do first?', 'If your partner won the lottery, what would they do first?',
ARRAY['Pay off everything', 'Book a big trip', 'Treat the people close to them', 'Save it and think it over'], 350),
('When lost in a new city, what do you do?', 'When lost in a new city, what does your partner do?',
ARRAY['Ask a local for help', 'Follow a map or app closely', 'Wander until something looks familiar', 'Stay calm and retrace your steps'], 360)
ON CONFLICT ("prompt_self") DO NOTHING;
-16
View File
@@ -1,16 +0,0 @@
-- Optional proof-of-completion for dares. The sender picks a requirement
-- ('none', 'photo', 'audio') at send time for both catalogue and custom dares;
-- the recipient attaches a media object when submitting.
ALTER TABLE sent_dares ADD COLUMN IF NOT EXISTS proof_type text NOT NULL DEFAULT 'none';
--> statement-breakpoint
ALTER TABLE sent_dares DROP CONSTRAINT IF EXISTS sent_dares_proof_type_check;
ALTER TABLE sent_dares ADD CONSTRAINT sent_dares_proof_type_check
CHECK (proof_type IN ('none', 'photo', 'audio'));
--> statement-breakpoint
ALTER TABLE sent_dares ADD COLUMN IF NOT EXISTS proof_media_id uuid REFERENCES media_objects(id) ON DELETE SET NULL;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS sent_dares_proof_media_idx ON sent_dares(proof_media_id) WHERE proof_media_id IS NOT NULL;
--> statement-breakpoint
ALTER TABLE media_objects DROP CONSTRAINT IF EXISTS media_objects_kind_check;
ALTER TABLE media_objects ADD CONSTRAINT media_objects_kind_check
CHECK (kind IN ('avatar', 'response', 'chat', 'feedback', 'dare_proof'));
-7
View File
@@ -141,13 +141,6 @@
"when": 1788168000000,
"tag": "0019_quiz",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1788168060000,
"tag": "0020_quiz_question_pool",
"breakpoints": true
}
]
}
+8 -76
View File
@@ -4,11 +4,6 @@ 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;
@@ -50,8 +45,6 @@ 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;
@@ -86,7 +79,6 @@ 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 {
@@ -119,8 +111,6 @@ 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;
@@ -134,7 +124,7 @@ export interface DaresRepository {
listDares(userId: string, filter: 'active' | 'history'): Promise<SentDare[]>;
send(userId: string, input: SendDareInput): Promise<SentDare>;
respond(userId: string, dareId: string, action: 'accept' | 'decline'): Promise<SentDare>;
submit(userId: string, dareId: string, proofMediaId?: string | null): Promise<SentDare>;
submit(userId: string, dareId: string): Promise<SentDare>;
complete(userId: string, dareId: string): Promise<SentDare>;
cancel(userId: string, dareId: string): Promise<SentDare>;
stats(userId: string): Promise<DareStats>;
@@ -142,7 +132,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, proof_type, proof_media_id,
custom_dare_text, sender_id, recipient_id, status, sender_notes,
sent_at, accepted_at, submitted_at, completed_at, expires_at`;
const SENT_COLUMNS = BARE_COLUMNS.split(',').map((column) => `sd.${column.trim()}`).join(', ');
@@ -182,8 +172,6 @@ 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),
@@ -335,11 +323,6 @@ 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;
@@ -393,15 +376,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, proof_type, expires_at
sender_id, recipient_id, sender_notes, expires_at
) values (
$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
$1, $2, $3, $4, $5, $6, $7, $8, $9,
case when $10::integer is null then null else now() + ($10::integer * interval '1 hour') end
)
returning ${BARE_COLUMNS}`,
[
context.couple_id, dareId, customText, customIntensity, text, intensity,
userId, context.partner_id, input.sender_notes?.trim() || null, proofType, duration,
userId, context.partner_id, input.sender_notes?.trim() || null, duration,
],
);
return toSentDare(result.rows[0]!, userId);
@@ -455,59 +438,8 @@ export class PostgresDaresRepository implements DaresRepository {
: this.transition(userId, dareId, 'recipient', ['pending'], 'declined', null);
}
/**
* 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 submit(userId: string, dareId: string): Promise<SentDare> {
return this.transition(userId, dareId, 'recipient', ['active'], 'submitted', 'submitted_at');
}
async complete(userId: string, dareId: string): Promise<SentDare> {
+1 -16
View File
@@ -15,7 +15,6 @@ 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), {
@@ -24,8 +23,6 @@ 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 } };
}
@@ -96,20 +93,8 @@ 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) {
+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]);
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' || kind === 'dare_proof') && !coupleId) throw new MediaError('couple_required', 'Join a couple before sharing media', 409);
if ((kind === 'response' || kind === 'chat') && !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','dare_proof') 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') 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);
+1 -2
View File
@@ -8,9 +8,8 @@ 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']),
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<MediaKind, number> = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*1024*1024, dare_proof: 25*1024*1024 };
const limits: Record<MediaKind, number> = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*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' };
export class FilesystemMediaStorage {
+1 -1
View File
@@ -1,4 +1,4 @@
export const mediaKinds = ['avatar', 'response', 'chat', 'feedback', 'dare_proof'] as const;
export const mediaKinds = ['avatar', 'response', 'chat', 'feedback'] as const;
export type MediaKind = typeof mediaKinds[number];
export interface MediaObject {
+2 -15
View File
@@ -167,22 +167,9 @@ export class PostgresQuizRepository implements QuizRepository {
return { session: await this.buildPayload(client, existing.rows[0], userId), created: false };
}
// Prefer questions this couple has never had in any prior session (completed or
// active), then the least-recently-used ones, with a random tiebreak within each
// group so repeat quizzes stay varied instead of always reusing the same pool.
const questions = await client.query<{ id: string }>(
`select q.id
from quiz_questions q
left join (
select question_id, max(created_at) as last_used
from quiz_sessions, unnest(question_ids) as question_id
where couple_id = $1
group by question_id
) usage on usage.question_id = q.id
where q.is_active = true
order by (usage.last_used is not null), usage.last_used asc, random()
limit $2`,
[coupleId, QUESTIONS_PER_SESSION],
'select id from quiz_questions where is_active = true order by sort_order, id limit $1',
[QUESTIONS_PER_SESSION],
);
const questionIds = questions.rows.map((row) => row.id);
-52
View File
@@ -196,58 +196,6 @@ 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' });
});
+2 -20
View File
@@ -13,12 +13,10 @@ 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, proof_type: 'none', proof_media_id: null,
direction: 'outgoing', status: 'pending', sender_notes: 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> = {}) {
const repository: DaresRepository = {
getCatalog: vi.fn(async () => ({
@@ -93,7 +91,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, null);
expect(repository.submit).toHaveBeenCalledWith(identity.id, dareId);
await post(app, `/v1/dares/${dareId}/complete`);
expect(repository.complete).toHaveBeenCalledWith(identity.id, dareId);
await post(app, `/v1/dares/${dareId}/cancel`);
@@ -111,22 +109,6 @@ 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');
+2 -43
View File
@@ -21,7 +21,6 @@ describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
const couple = '55555555-5555-4555-8555-555555555555';
const soloCouple = '66666666-6666-4666-8666-666666666666';
const otherCouple = '77777777-7777-4777-8777-777777777777';
const userC = '88888888-8888-4888-8888-888888888888';
beforeAll(async () => {
await admin.query(`create schema "${schema}"`);
@@ -45,8 +44,8 @@ describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
);
await pool.query(
`insert into profiles(id,couple_id) values
($1,$5),($2,$5),($3,$6),($4,$7),($8,$7)`,
[userA, userB, soloUser, outsider, couple, soloCouple, otherCouple, userC],
($1,$5),($2,$5),($3,$6),($4,$7)`,
[userA, userB, soloUser, outsider, couple, soloCouple, otherCouple],
);
});
@@ -207,44 +206,4 @@ describe.skipIf(!url || !local)('quiz + PostgreSQL', () => {
const current = await repo.currentSession(userB);
expect(current?.id).toBe(session.id);
});
it('draws the next session from never-used questions once a session completes', async () => {
const { session: first } = await repo.startSession(userA);
const firstQuestionIds = first.questions.map((question) => question.id);
expect(firstQuestionIds).toHaveLength(10);
const answers = firstQuestionIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
await repo.submitAnswers(userA, first.id, answers);
const completed = await repo.submitAnswers(userB, first.id, answers);
expect(completed.status).toBe('completed');
const { session: second, created } = await repo.startSession(userA);
expect(created).toBe(true);
const secondQuestionIds = second.questions.map((question) => question.id);
expect(secondQuestionIds).toHaveLength(10);
// The seeded pool has 36 active questions, so the never-used-first preference
// guarantees the second session's questions are wholly disjoint from the first.
const overlap = secondQuestionIds.filter((id) => firstQuestionIds.includes(id));
expect(overlap).toEqual([]);
});
it('scopes selection to each couple independently', async () => {
const { session: coupleOneFirst } = await repo.startSession(userA);
const coupleOneFirstIds = coupleOneFirst.questions.map((question) => question.id);
const answers = coupleOneFirstIds.map((id) => ({ question_id: id, self_index: 0, guess_index: 0 }));
await repo.submitAnswers(userA, coupleOneFirst.id, answers);
await repo.submitAnswers(userB, coupleOneFirst.id, answers);
// A different couple with no prior sessions should still be able to draw a
// fresh set of 10 questions regardless of what the first couple has used,
// since the never-used-for-them preference is scoped per couple.
const { session: otherSession, created } = await repo.startSession(outsider);
expect(created).toBe(true);
expect(otherSession.questions).toHaveLength(10);
const partnerView = await repo.startSession(userC);
expect(partnerView.created).toBe(false);
expect(partnerView.session.id).toBe(otherSession.id);
});
});
@@ -7,7 +7,7 @@ import type { SentDare } from "@/features/dares/types";
const incoming: SentDare = {
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, proof_type: "none", proof_media_id: null, sent_at: "2026-08-28T10:00:00.000Z",
status: "pending", sender_notes: null, sent_at: "2026-08-28T10:00:00.000Z",
accepted_at: null, submitted_at: null, completed_at: null, expires_at: null,
};
@@ -55,23 +55,6 @@ 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));
+2 -17
View File
@@ -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, DareProofSheet, SendDareSheet } from "./components";
import { DareCard, DarePackGrid, SendDareSheet } from "./components";
import { useDares } from "./hooks/useDares";
import type { DarePack, SentDare } from "./types";
import type { DarePack } from "./types";
const ACCENT = featureColors.dares.accent;
@@ -27,7 +27,6 @@ export function DaresScreen() {
const [sheetPack, setSheetPack] = useState<DarePack | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
const [paywallOpen, setPaywallOpen] = useState(false);
const [proofDare, setProofDare] = useState<SentDare | null>(null);
const openPaywall = useCallback(() => {
setSheetOpen(false);
@@ -69,14 +68,6 @@ 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],
@@ -182,12 +173,6 @@ export function DaresScreen() {
onRequestPremium={openPaywall}
/>
<DareProofSheet
dare={proofDare}
onClose={() => setProofDare(null)}
onSubmit={(dareId, proofMediaId) => dares.submit(dareId, proofMediaId)}
/>
<Paywall
visible={paywallOpen}
onClose={closePaywall}
@@ -2,7 +2,6 @@ import { memo } from "react";
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import { DareProofView } from "./DareProofView";
import type { SentDare } from "../types";
const ACCENT = featureColors.dares.accent;
@@ -93,23 +92,6 @@ export const DareCard = memo(function DareCard({ dare, busy, onAction }: DareCar
{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 ? (
<View style={styles.deadlineRow}>
<Ionicons name="time-outline" size={13} color={colors.textTertiary} />
@@ -175,13 +157,6 @@ 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",
@@ -1,243 +0,0 @@
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" },
});
@@ -1,112 +0,0 @@
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 { daresApi } from "../../../lib/daresApi";
import { colors, featureColors, radius, spacing, typography } from "../../../theme";
import { DURATION_OPTIONS, PROOF_OPTIONS, type DareItem, type DarePack, type DareProofType, type SendDarePayload } from "../types";
import { DURATION_OPTIONS, type DareItem, type DarePack, type SendDarePayload } from "../types";
const ACCENT = featureColors.dares.accent;
@@ -26,7 +26,6 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o
const [selectedId, setSelectedId] = useState<string | null>(null);
const [customText, setCustomText] = useState("");
const [durationHours, setDurationHours] = useState<number | null>(24);
const [proofType, setProofType] = useState<DareProofType>("none");
const [notes, setNotes] = useState("");
const [sending, setSending] = useState(false);
@@ -36,7 +35,6 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o
setSelectedId(null);
setCustomText("");
setNotes("");
setProofType("none");
daresApi
.packDares(pack.id)
.then((result) => setDares(result.dares))
@@ -54,11 +52,10 @@ 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, proofType, notes, onSend, onClose]);
}, [canSubmit, sending, isCustom, customText, selectedId, durationHours, notes, onSend, onClose]);
return (
<Modal visible={visible} animationType="slide" transparent onRequestClose={onClose}>
@@ -138,29 +135,6 @@ export function SendDareSheet({ visible, pack, canSendCustom, onClose, onSend, o
})}
</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>
<TextInput
style={styles.notesInput}
@@ -255,7 +229,6 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md, paddingVertical: spacing.sm - 2,
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)" },
chipText: { ...typography.footnote, color: colors.textSecondary },
chipTextSelected: { color: ACCENT, fontWeight: "600" },
@@ -4,7 +4,3 @@ export { DarePackGrid } from "./DarePackGrid";
export type { DarePackGridProps } from "./DarePackGrid";
export { SendDareSheet } 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>;
send: (payload: SendDarePayload) => Promise<boolean>;
respond: (dareId: string, action: "accept" | "decline") => Promise<void>;
submit: (dareId: string, proofMediaId?: string) => Promise<void>;
submit: (dareId: string) => Promise<void>;
complete: (dareId: string) => Promise<void>;
cancel: (dareId: string) => Promise<void>;
}
@@ -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, proofMediaId) => act(dareId, () => daresApi.submit(dareId, proofMediaId)), [act]),
submit: useCallback((dareId) => act(dareId, () => daresApi.submit(dareId)), [act]),
complete: useCallback((dareId) => act(dareId, () => daresApi.complete(dareId)), [act]),
cancel: useCallback((dareId) => act(dareId, () => daresApi.cancel(dareId)), [act]),
};
-12
View File
@@ -1,9 +1,6 @@
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;
@@ -39,8 +36,6 @@ 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;
@@ -75,15 +70,8 @@ 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 },
+1 -5
View File
@@ -13,11 +13,7 @@ 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, proofMediaId?: string) =>
apiClient.post<{ dare: SentDare }>(
`${dare(dareId)}/submit`,
proofMediaId ? { proof_media_id: proofMediaId } : undefined,
),
submit: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/submit`),
complete: (dareId: string) => apiClient.post<{ dare: SentDare }>(`${dare(dareId)}/complete`),
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 { ApiError, apiClient, authenticatedFetch } from './apiClient';
export type MediaKind = 'avatar' | 'response' | 'chat' | 'feedback' | 'dare_proof';
export type MediaKind = 'avatar' | 'response' | 'chat' | 'feedback';
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> }
+1 -1
View File
@@ -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 | ✅ | Optional photo/audio proof, chosen by the sender per dare |
| Proof/Verification | ❌ | Not in v1 (could add photo proof later) |
| 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) |
+3 -14
View File
@@ -109,26 +109,15 @@ 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; optional `proof_type` (`none`/`photo`/`audio`) |
| POST | `/v1/dares` | `dare_id` **or** `custom_dare_text`, never both |
| POST | `/v1/dares/:dareId/respond` | `{ action: "accept" \| "decline" }` |
| POST | `/v1/dares/:dareId/submit` | Recipient reports done; `{ proof_media_id }` required when the dare has a proof requirement |
| POST | `/v1/dares/:dareId/submit` | Recipient reports done |
| 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
@@ -153,4 +142,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. Random-dare and streaks remain unbuilt.
note. Photo proof, random-dare, and streaks remain unbuilt.
+3 -13
View File
@@ -9,7 +9,7 @@ once both have finished.
| Concern | Location |
|---|---|
| Schema and seed content | `apps/api/drizzle/0019_quiz.sql`, `apps/api/drizzle/0020_quiz_question_pool.sql` |
| Schema and seed content | `apps/api/drizzle/0019_quiz.sql` |
| HTTP contract | `apps/api/src/domains/quiz/routes.ts` |
| Session and scoring rules | `apps/api/src/domains/quiz/repository.ts` |
| Pure scoring function | `apps/api/src/domains/quiz/service.ts` (`computeQuizScore`) |
@@ -30,15 +30,6 @@ quiz while one is already active returns that same session (`200`) instead of
creating a second one; two simultaneous starts race safely because the
partial unique index, not application locking, is the source of truth.
When a new session is created, the 10 questions are chosen from the active
pool with a couple-scoped preference order: questions this couple has never
had in any prior session (completed or active) come first, then the
least-recently-used ones, with a random tiebreak within each group. This
keeps repeat quizzes varied instead of always drawing the same
`sort_order`-first questions. The seeded pool (`0019_quiz.sql` plus
`0020_quiz_question_pool.sql`) has 36 active questions, so two consecutive
sessions for the same couple are guaranteed not to overlap.
Answers are submitted as one complete batch per call (`quiz_answers`, upsert
on `(session_id, user_id, question_id)`). A submission must cover every
question in the session exactly once, with every index inside that
@@ -82,9 +73,8 @@ completed session.
- `apps/api/test/quiz.routes.test.ts`: HTTP contract and error mapping
- `apps/api/test/quiz.integration.test.ts`: real PostgreSQL, covering the full
happy path with a hand-computed score, idempotent session start, couple
isolation, incomplete answer set rejection, result blocked before
completion, no question overlap between a completed session and the next
one for the same couple, and that selection is scoped per couple
isolation, incomplete answer set rejection, and result blocked before
completion
## Mobile client