mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(pairing): solo sealed answers that unlock on pairing
Unpaired users could only reach a waiting-room dead end and could not answer questions until a partner joined. Responses.couple_id is now nullable so a solo user answers immediately and banks "sealed answers" (couple_id IS NULL). Pairing claims both members' sealed answers and computes matches for every question both have now answered. Invite copy and the unpaired push nudge escalate with the sealed answer count. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f22d4a3649
commit
09255da7db
26 files changed
+510
-75
No files matched your search
@@ -0,0 +1,25 @@
|
||||
-- Unpaired users previously hit a waiting room with no way to answer questions,
|
||||
-- because responses.couple_id was NOT NULL. This migration relaxes that column so
|
||||
-- a solo user can bank "sealed" answers before they have a couple. The foreign key
|
||||
-- and ON DELETE CASCADE are unchanged for non-null values: once a couple is deleted,
|
||||
-- the responses that belonged to it are still removed with it. Sealed answers keep
|
||||
-- couple_id NULL until claimed by the application at pairing time, so couple deletion
|
||||
-- has no effect on them; they are not tied to any couple's lifecycle until claimed.
|
||||
ALTER TABLE "responses" ALTER COLUMN "couple_id" DROP NOT NULL;
|
||||
--> statement-breakpoint
|
||||
-- A sealed answer (couple_id IS NULL) has no couple to digest a match notification
|
||||
-- for yet; the partner digest only makes sense once claim-on-pair assigns the
|
||||
-- response to a couple, at which point the claim path computes matches directly.
|
||||
CREATE OR REPLACE FUNCTION queue_response_digest() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF current_setting('sauci.suppress_operations',true)='on' THEN RETURN NEW; END IF;
|
||||
IF NEW.couple_id IS NULL THEN RETURN NEW; END IF;
|
||||
INSERT INTO pending_match_notifications(couple_id, active_user_id, notify_at)
|
||||
VALUES(NEW.couple_id, NEW.user_id, now() + interval '5 minutes')
|
||||
ON CONFLICT(couple_id) DO UPDATE SET
|
||||
active_user_id=excluded.active_user_id,
|
||||
response_count=pending_match_notifications.response_count+1,
|
||||
notify_at=excluded.notify_at,
|
||||
updated_at=now();
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
@@ -148,6 +148,13 @@
|
||||
"when": 1788168060000,
|
||||
"tag": "0020_quiz_question_pool",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 21,
|
||||
"version": "7",
|
||||
"when": 1788168120000,
|
||||
"tag": "0021_solo_sealed_answers",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -42,7 +42,7 @@ export interface AnswersRepository {
|
||||
pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string): Promise<Array<{ id: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } }; partnerAnsweredAt: string }>>;
|
||||
answerGap(userId: string): Promise<{ unanswered_by_partner: number; threshold: number; is_blocked: boolean }>;
|
||||
dailyLimit(userId: string): Promise<{ responses_today: number; limit_value: number; remaining: number; reset_at: string | null; is_blocked: boolean }>;
|
||||
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
|
||||
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null; sealed_count?: number }>;
|
||||
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
|
||||
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
|
||||
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number | null; hasMore: boolean }>;
|
||||
@@ -58,10 +58,17 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
||||
private zones: Promise<Set<string>> | null = null;
|
||||
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||
|
||||
/**
|
||||
* couple_id is nullable here: an unpaired user still has a profile and can answer
|
||||
* questions solo, banking them as sealed answers (couple_id IS NULL on responses)
|
||||
* until a partner joins. Callers that need a couple for their own operation (e.g.
|
||||
* matches, streaks) get an empty/no-op result naturally because every downstream
|
||||
* query is scoped by couple_id, which stays null for a solo caller.
|
||||
*/
|
||||
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
|
||||
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
|
||||
const row = result.rows[0];
|
||||
if (!row?.couple_id) throw new AnswersError('no_couple', 409, 'Join a couple before answering questions');
|
||||
if (!row) throw new AnswersError('no_couple', 409, 'Complete signup before answering questions');
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -147,15 +154,25 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
||||
await this.assertEligible(client,userId,ctx,q);
|
||||
const existing = await client.query('select id from responses where user_id=$1 and question_id=$2 for update', [userId,input.questionId]);
|
||||
if (!existing.rowCount) {
|
||||
const catchingUp = await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[ctx.couple_id,input.questionId,userId]);
|
||||
// A solo answerer has no partner, so the catch-up exemption never applies:
|
||||
// every sealed answer counts against the daily limit like a fresh one.
|
||||
const catchingUp = ctx.couple_id
|
||||
? await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[ctx.couple_id,input.questionId,userId])
|
||||
: { rowCount: 0 };
|
||||
if (!catchingUp.rowCount) {
|
||||
const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, responses_today: limit.responses_today, remaining: 0, reset_at: limit.reset_at });
|
||||
}
|
||||
}
|
||||
const response = await client.query<ResponseRow>(`insert into responses(id,user_id,question_id,couple_id,answer,response_data) values(gen_random_uuid(),$1,$2,$3,$4,$5)
|
||||
on conflict(user_id,question_id) do update set answer=excluded.answer,response_data=excluded.response_data returning *`,[userId,input.questionId,ctx.couple_id,input.answer,input.responseData ?? null]);
|
||||
const match = await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,input.responseData ?? null,true);
|
||||
await this.touchStreak(client,userId,ctx.couple_id!);
|
||||
if (!ctx.couple_id) {
|
||||
// No couple yet: bank the answer as sealed. It is claimed and reconciled
|
||||
// into matches once a partner joins; see couples repository join().
|
||||
const sealed = await client.query<{ count: number }>('select count(*)::int count from responses where user_id=$1 and couple_id is null',[userId]);
|
||||
return { response: this.dates(response.rows[0]), match: null, sealed_count: sealed.rows[0]?.count ?? 0 };
|
||||
}
|
||||
const match = await this.reconcileMatch(client,userId,ctx.couple_id,q,input.answer,input.responseData ?? null,true);
|
||||
await this.touchStreak(client,userId,ctx.couple_id);
|
||||
return { response: this.dates(response.rows[0]), match: match ? { ...this.dates(match), question: this.dates(q) } : null };
|
||||
});
|
||||
}
|
||||
@@ -172,9 +189,9 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
||||
return { success:false,requires_confirmation:true,match_id:existing.rows[0].id,message_count:count.rows[0]?.count ?? 0 };
|
||||
}
|
||||
const normalized=q.question_type!=='swipe' && input.answer!=='no' ? (typeof input.responseData==='undefined' ? current.rows[0].response_data : input.responseData) : null;
|
||||
const before=existing.rows[0]; const match=await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,normalized,false);
|
||||
const before=existing.rows[0]; const match=ctx.couple_id?await this.reconcileMatch(client,userId,ctx.couple_id,q,input.answer,normalized,false):null;
|
||||
await client.query('update responses set answer=$3,response_data=$4 where user_id=$1 and question_id=$2',[userId,input.questionId,input.answer,normalized]);
|
||||
await this.touchStreak(client,userId,ctx.couple_id!);
|
||||
if (ctx.couple_id) await this.touchStreak(client,userId,ctx.couple_id);
|
||||
return { success:true, ...(before&&!match?{match_deleted:true}:{ }), ...(!before&&match?{new_match:this.dates(match)}:{}), ...(before&&match?{match_type_updated:true}:{}) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Couple, Profile } from '@sauci/shared';
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import { calculateMatchType, type Answer } from '../answers/types.js';
|
||||
import { CoupleError, type CoupleStateResponse } from './types.js';
|
||||
|
||||
interface ProfileRecord extends QueryResultRow {
|
||||
@@ -77,6 +78,60 @@ async function profileForUpdate(client: PoolClient, userId: string): Promise<{ c
|
||||
return profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* A user answering solo banks each answer with couple_id NULL ("sealed"). Once
|
||||
* pairing reaches two members, every sealed answer belonging to either member is
|
||||
* claimed into the couple, and any question both members now have an answer for
|
||||
* gets its match computed and upserted, reusing the same match-type rules the
|
||||
* live answer flow uses. This only runs from join(), the one place membership can
|
||||
* go from one to two, so it only ever fires once per couple.
|
||||
*/
|
||||
async function claimSealedAnswers(client: PoolClient, coupleId: string): Promise<void> {
|
||||
await client.query(
|
||||
`update responses set couple_id = $1
|
||||
where couple_id is null and user_id in (select id from profiles where couple_id = $1)`,
|
||||
[coupleId],
|
||||
);
|
||||
|
||||
const pairs = await client.query<{
|
||||
question_id: string;
|
||||
question_type: string;
|
||||
a_user: string;
|
||||
a_answer: Answer;
|
||||
a_data: Record<string, unknown> | null;
|
||||
b_user: string;
|
||||
b_answer: Answer;
|
||||
b_data: Record<string, unknown> | null;
|
||||
}>(
|
||||
`select r1.question_id, q.question_type,
|
||||
r1.user_id a_user, r1.answer a_answer, r1.response_data a_data,
|
||||
r2.user_id b_user, r2.answer b_answer, r2.response_data b_data
|
||||
from responses r1
|
||||
join responses r2 on r2.question_id = r1.question_id and r2.couple_id = r1.couple_id and r2.user_id > r1.user_id
|
||||
join questions q on q.id = r1.question_id
|
||||
where r1.couple_id = $1`,
|
||||
[coupleId],
|
||||
);
|
||||
|
||||
for (const pair of pairs.rows) {
|
||||
const matchType = calculateMatchType({ id: pair.question_id, question_type: pair.question_type as never }, pair.a_answer, pair.b_answer);
|
||||
if (!matchType) {
|
||||
await client.query('delete from matches where couple_id = $1 and question_id = $2', [coupleId, pair.question_id]);
|
||||
continue;
|
||||
}
|
||||
const responseSummary = matchType === 'both_answered'
|
||||
? { [pair.a_user]: pair.a_data, [pair.b_user]: pair.b_data }
|
||||
: null;
|
||||
await client.query(
|
||||
`insert into matches(couple_id, question_id, match_type, is_new, response_summary)
|
||||
values($1, $2, $3, true, $4)
|
||||
on conflict(couple_id, question_id) do update set
|
||||
match_type = excluded.match_type, response_summary = excluded.response_summary, is_new = true`,
|
||||
[coupleId, pair.question_id, matchType, responseSummary],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -111,7 +166,8 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
||||
if (!profile) {
|
||||
throw new CoupleError('profile_not_found', 'Profile not found. Please complete signup first.', 404);
|
||||
}
|
||||
if (!profile.couple_id) return { couple: null, partner: null };
|
||||
const sealedCount = await this.sealedCount(userId);
|
||||
if (!profile.couple_id) return { couple: null, partner: null, sealed_count: sealedCount };
|
||||
|
||||
const [coupleResult, partnerResult] = await Promise.all([
|
||||
this.pool.query<CoupleRecord>(
|
||||
@@ -129,13 +185,22 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
||||
),
|
||||
]);
|
||||
const couple = coupleResult.rows[0];
|
||||
if (!couple) return { couple: null, partner: null };
|
||||
if (!couple) return { couple: null, partner: null, sealed_count: sealedCount };
|
||||
return {
|
||||
couple: toCouple(couple),
|
||||
partner: partnerResult.rows[0] ? toProfile(partnerResult.rows[0]) : null,
|
||||
sealed_count: sealedCount,
|
||||
};
|
||||
}
|
||||
|
||||
private async sealedCount(userId: string): Promise<number> {
|
||||
const result = await this.pool.query<{ count: number }>(
|
||||
'select count(*)::int count from responses where user_id = $1 and couple_id is null',
|
||||
[userId],
|
||||
);
|
||||
return result.rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
async create(userId: string, coupleId: string, inviteCode: string): Promise<Couple> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
const profile = await profileForUpdate(client, userId);
|
||||
@@ -183,6 +248,7 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
||||
}
|
||||
|
||||
await client.query('update profiles set couple_id = $1, updated_at = now() where id = $2', [couple.id, userId]);
|
||||
await claimSealedAnswers(client, couple.id);
|
||||
return toCouple(couple);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Couple, Profile } from '@sauci/shared';
|
||||
export interface CoupleStateResponse {
|
||||
couple: Couple | null;
|
||||
partner: Profile | null;
|
||||
/** Count of this user's banked answers with no couple yet (couple_id IS NULL). */
|
||||
sealed_count: number;
|
||||
}
|
||||
|
||||
export interface CoupleMutationResponse {
|
||||
|
||||
@@ -132,9 +132,14 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
||||
and exists(select 1 from responses r where r.couple_id=p.couple_id and r.created_at>$1::timestamptz-interval '7 days')
|
||||
group by p.id on conflict(dedupe_key) do nothing`, [now]);
|
||||
|
||||
// Sealed answers are the earned-value hook for the nudge: a solo answerer who
|
||||
// has already banked answers gets told what pairing unlocks for them, instead
|
||||
// of the generic invite copy that applies before they have answered anything.
|
||||
const unpaired=await client.query(
|
||||
`with eligible as (
|
||||
select p.id,p.couple_id is not null has_invite from profiles p left join notification_preferences np on np.user_id=p.id
|
||||
select p.id,p.couple_id is not null has_invite,
|
||||
(select count(*)::int from responses r where r.user_id=p.id and r.couple_id is null) sealed_count
|
||||
from profiles p left join notification_preferences np on np.user_id=p.id
|
||||
where extract(hour from $1::timestamptz)>=18 and p.push_token is not null
|
||||
and (p.couple_id is null or (select count(*) from profiles member where member.couple_id=p.couple_id)<2)
|
||||
and coalesce(np.unpaired_reminders_enabled,true)
|
||||
@@ -143,10 +148,11 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
||||
), queued as (
|
||||
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||
select 'expo','unpaired:'||to_char($1::timestamptz,'YYYY-MM-DD')||':'||e.id,e.id,
|
||||
jsonb_build_object('title','Sauci','body',case when e.has_invite
|
||||
then 'Your invite code is waiting! Share it with your partner to start discovering what you have in common.'
|
||||
jsonb_build_object('title','Sauci','body',case
|
||||
when e.sealed_count>0 then 'You have '||e.sealed_count||' sealed answer'||(case when e.sealed_count=1 then '' else 's' end)||' waiting. Invite your partner to unlock what you said about them.'
|
||||
when e.has_invite then 'Your invite code is waiting! Share it with your partner to start discovering what you have in common.'
|
||||
else 'Ready to get started? Create an invite code and share it with your partner!' end,
|
||||
'data',jsonb_build_object('type','unpaired_reminder'))
|
||||
'data',jsonb_build_object('type','unpaired_reminder','sealed_count',e.sealed_count))
|
||||
from eligible e on conflict(dedupe_key) do nothing returning recipient_id
|
||||
) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]);
|
||||
|
||||
|
||||
@@ -61,7 +61,9 @@ export const responses = pgTable('responses', {
|
||||
id: uuid('id').primaryKey(),
|
||||
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||
questionId: uuid('question_id').notNull().references(() => questions.id, { onDelete: 'cascade' }),
|
||||
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
|
||||
// Nullable: an unpaired user's answer is banked as a sealed answer with no couple
|
||||
// yet. It is claimed into the couple at pairing time; see the answers repository.
|
||||
coupleId: uuid('couple_id').references(() => couples.id, { onDelete: 'cascade' }),
|
||||
answer: text('answer').notNull(),
|
||||
responseData: jsonb('response_data'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -5,8 +5,8 @@ import { afterAll,beforeAll,describe,expect,it } from 'vitest';
|
||||
import { PostgresAnswersRepository } from '../src/domains/answers/repository.js';
|
||||
const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost');
|
||||
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
|
||||
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
||||
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc',solo='dddddddd-dddd-4ddd-8ddd-dddddddddddd',soloQ1='eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',soloQ2='ffffffff-ffff-4fff-8fff-ffffffffffff';
|
||||
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql','0021_solo_sealed_answers.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into profiles(id,gender,max_intensity) values($1,null,5)",[solo]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null),($8,$2,'Solo one',2,null),($9,$2,'Solo two',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ,soloQ1,soloQ2]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
||||
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
|
||||
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
|
||||
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
|
||||
@@ -109,4 +109,25 @@ it('buckets the window on the user local day and falls back to UTC',async()=>{aw
|
||||
const utcBoundary=await pool.query<{expected:string}>("select ((date_trunc('day',now() at time zone 'UTC')+interval '1 day') at time zone 'UTC') expected");
|
||||
for(const zone of [null,'Not/AZone']){await pool.query('update profiles set timezone=$2 where id=$1',[u1,zone]);expect((await repo.dailyLimit(u1)).reset_at).toBe(new Date(utcBoundary.rows[0].expected).toISOString());}});
|
||||
it('blocks pack browsing once the limit is reached',async()=>{await resetLimit(1);expect((await repo.recommended(u1,pack)).length).toBeGreaterThan(0);await repo.submit(u1,{questionId:limitQ1,answer:'yes'});expect(await repo.recommended(u1,pack)).toEqual([]);expect(await repo.recommended(u1)).toEqual([]);await resetLimit(0);});
|
||||
|
||||
describe('solo answering before a couple exists',()=>{
|
||||
it('recommends questions to an unpaired user instead of a no_couple error',async()=>{
|
||||
const questions=await repo.recommended(solo);
|
||||
expect(questions.map(x=>x.id)).toContain(soloQ1);
|
||||
});
|
||||
it('banks a solo answer with no couple, skips match reconciliation, and reports the sealed count',async()=>{
|
||||
const first=await repo.submit(solo,{questionId:soloQ1,answer:'yes'});
|
||||
expect(first.match).toBeNull();
|
||||
expect(first.sealed_count).toBe(1);
|
||||
const stored=await pool.query('select couple_id from responses where user_id=$1 and question_id=$2',[solo,soloQ1]);
|
||||
expect(stored.rows[0].couple_id).toBeNull();
|
||||
|
||||
const second=await repo.submit(solo,{questionId:soloQ2,answer:'maybe'});
|
||||
expect(second.match).toBeNull();
|
||||
expect(second.sealed_count).toBe(2);
|
||||
});
|
||||
it('lets a solo answerer edit a sealed answer without a couple to reconcile against',async()=>{
|
||||
await expect(repo.update(solo,{questionId:soloQ1,answer:'no',responseData:undefined,confirmDeleteMatch:false})).resolves.toMatchObject({success:true});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,13 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
|
||||
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
||||
repository = new PostgresCoupleRepository(isolatedUrl.toString());
|
||||
|
||||
for (const migrationName of ['0000_identity_and_feature_interests.sql', '0001_couples.sql']) {
|
||||
for (const migrationName of [
|
||||
'0000_identity_and_feature_interests.sql',
|
||||
'0001_couples.sql',
|
||||
'0002_packs_catalog_progress.sql',
|
||||
'0003_answers_matches.sql',
|
||||
'0021_solo_sealed_answers.sql',
|
||||
]) {
|
||||
const migration = await readFile(new URL(`../drizzle/${migrationName}`, import.meta.url), 'utf8');
|
||||
for (const statement of migration.split('--> statement-breakpoint')) {
|
||||
if (statement.trim()) await pool.query(statement);
|
||||
@@ -57,8 +63,71 @@ describe.skipIf(!databaseUrl || !localDatabase)('PostgresCoupleRepository', () =
|
||||
await expect(repository.join(charlie, 'ABCD2345')).rejects.toMatchObject({ code: 'couple_full' });
|
||||
|
||||
await repository.cancel(alice);
|
||||
await expect(repository.getState(alice)).resolves.toEqual({ couple: null, partner: null });
|
||||
await expect(repository.getState(bob)).resolves.toEqual({ couple: null, partner: null });
|
||||
await expect(repository.getState(alice)).resolves.toEqual({ couple: null, partner: null, sealed_count: 0 });
|
||||
await expect(repository.getState(bob)).resolves.toEqual({ couple: null, partner: null, sealed_count: 0 });
|
||||
expect((await pool.query('select count(*)::int as count from couples')).rows[0].count).toBe(0);
|
||||
});
|
||||
|
||||
it('reports a solo answerer\'s sealed count before any couple exists', async () => {
|
||||
const pack = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||
const solo = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
|
||||
const question = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
|
||||
await pool.query(`insert into profiles(id, name) values ($1, 'Solo')`, [solo]);
|
||||
await pool.query(`insert into question_packs(id, name) values ($1, 'Pack')`, [pack]);
|
||||
await pool.query(`insert into questions(id, pack_id, text) values ($1, $2, 'Question')`, [question, pack]);
|
||||
await pool.query(
|
||||
`insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, 'yes')`,
|
||||
[randomUUID(), solo, question],
|
||||
);
|
||||
|
||||
await expect(repository.getState(solo)).resolves.toEqual({ couple: null, partner: null, sealed_count: 1 });
|
||||
});
|
||||
|
||||
it('claims both members\' sealed answers and computes matches when the second member joins', async () => {
|
||||
const coupleId = 'ffffffff-ffff-4fff-8fff-ffffffffffff';
|
||||
const dana = '44444444-4444-4444-8444-444444444444';
|
||||
const erin = '55555555-5555-4555-8555-555555555555';
|
||||
const pack = '66666666-6666-4666-8666-666666666666';
|
||||
const yesYesQuestion = '77777777-7777-4777-8777-777777777777';
|
||||
const yesNoQuestion = '88888888-8888-4888-8888-888888888888';
|
||||
const soloOnlyQuestion = '99999999-9999-4999-8999-999999999999';
|
||||
|
||||
await pool.query(`insert into profiles(id, name) values ($1, 'Dana'), ($2, 'Erin')`, [dana, erin]);
|
||||
await pool.query(`insert into question_packs(id, name) values ($1, 'Claim pack')`, [pack]);
|
||||
await pool.query(
|
||||
`insert into questions(id, pack_id, text) values ($1, $2, 'Yes yes'), ($3, $2, 'Yes no'), ($4, $2, 'Solo only')`,
|
||||
[yesYesQuestion, pack, yesNoQuestion, soloOnlyQuestion],
|
||||
);
|
||||
|
||||
// Both answer the same question before pairing (sealed, couple_id null). Dana
|
||||
// and Erin agree on one question (yes/yes), disagree on another (yes/no), and
|
||||
// Dana alone answers a third, which has no match to compute.
|
||||
const insertResponse = (userId: string, questionId: string, answer: 'yes' | 'no' | 'maybe') =>
|
||||
pool.query(
|
||||
`insert into responses(id, user_id, question_id, couple_id, answer) values ($1, $2, $3, null, $4)`,
|
||||
[randomUUID(), userId, questionId, answer],
|
||||
);
|
||||
await insertResponse(dana, yesYesQuestion, 'yes');
|
||||
await insertResponse(erin, yesYesQuestion, 'yes');
|
||||
await insertResponse(dana, yesNoQuestion, 'yes');
|
||||
await insertResponse(erin, yesNoQuestion, 'no');
|
||||
await insertResponse(dana, soloOnlyQuestion, 'maybe');
|
||||
|
||||
await repository.create(dana, coupleId, 'CLAIM123');
|
||||
await repository.join(erin, 'CLAIM123');
|
||||
|
||||
const claimed = await pool.query<{ couple_id: string | null; count: number }>(
|
||||
'select couple_id, count(*)::int count from responses where user_id in ($1,$2) group by couple_id',
|
||||
[dana, erin],
|
||||
);
|
||||
expect(claimed.rows).toEqual([{ couple_id: coupleId, count: 5 }]);
|
||||
|
||||
// Yes/yes produces a match; yes/no produces none; the solo-only answer has no
|
||||
// partner response yet, so only one match is computed from the claim.
|
||||
const matches = await pool.query<{ question_id: string; match_type: string }>(
|
||||
'select question_id, match_type from matches where couple_id=$1 order by question_id',
|
||||
[coupleId],
|
||||
);
|
||||
expect(matches.rows).toEqual([{ question_id: yesYesQuestion, match_type: 'yes_yes' }]);
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ const identity: AuthIdentity = {
|
||||
|
||||
function setup() {
|
||||
const repository: CoupleRepository = {
|
||||
getState: vi.fn(async () => ({ couple: null, partner: null })),
|
||||
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
|
||||
create: vi.fn(async (_userId, coupleId, inviteCode) => ({
|
||||
id: coupleId,
|
||||
invite_code: inviteCode,
|
||||
@@ -43,7 +43,7 @@ describe('couple routes', () => {
|
||||
const response = await app.request('/v1/couple');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ couple: null, partner: null });
|
||||
expect(await response.json()).toEqual({ couple: null, partner: null, sealed_count: 0 });
|
||||
expect(repository.getState).toHaveBeenCalledWith(identity.id);
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const couple: Couple = {
|
||||
|
||||
function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository {
|
||||
return {
|
||||
getState: vi.fn(async () => ({ couple: null, partner: null })),
|
||||
getState: vi.fn(async () => ({ couple: null, partner: null, sealed_count: 0 })),
|
||||
create: vi.fn(async () => couple),
|
||||
join: vi.fn(async () => couple),
|
||||
cancel: vi.fn(async () => undefined),
|
||||
|
||||
@@ -12,7 +12,7 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
|
||||
const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository;
|
||||
beforeAll(async()=>{
|
||||
await admin.query(`create schema "${schema}"`);const url=new URL(databaseUrl!);url.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:url.toString()});repo=new PostgresOperationsRepository(url.toString());repo2=new PostgresOperationsRepository(url.toString());
|
||||
for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0014_daily_limit_local_reset.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql']){
|
||||
for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0009_operations_workers.sql','0010_admin.sql','0014_daily_limit_local_reset.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql','0021_solo_sealed_answers.sql']){
|
||||
const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const statement of sql.split('--> statement-breakpoint'))if(statement.trim())await pool.query(statement);
|
||||
}
|
||||
await pool.query('insert into couples(id,invite_code) values($1,$2)',[couple,'ABCD2345']);
|
||||
@@ -59,6 +59,22 @@ describe.skipIf(!databaseUrl||!local)('PostgresOperationsRepository',()=>{
|
||||
expect((await pool.query('select count(*)::int count from operations_outbox where dedupe_key=$1',[`discord:couple_paired:${inviteCouple}`])).rows[0].count).toBe(1);
|
||||
});
|
||||
|
||||
it('escalates the unpaired nudge copy with the sealed answer count',async()=>{
|
||||
const solo=randomUUID(),secondQuestion=randomUUID();
|
||||
await pool.query(`insert into profiles(id,name,email,push_token,onboarding_completed) values($1,'Solo','solo@test','token-solo',false)`,[solo]);
|
||||
await pool.query('insert into questions(id,pack_id,text) values($1,$2,$3)',[secondQuestion,pack,'Second question']);
|
||||
await pool.query(`insert into responses(id,user_id,question_id,couple_id,answer) values($1,$2,$3,null,'yes'),($4,$2,$5,null,'maybe')`,
|
||||
[randomUUID(),solo,question,randomUUID(),secondQuestion]);
|
||||
|
||||
await repo.produce(new Date('2026-08-28T18:00:00.000Z'),100);
|
||||
const body=(await pool.query<{body:string;data:{sealed_count:number}}>(
|
||||
`select payload->>'body' body,payload->'data' data from operations_outbox where dedupe_key=$1`,
|
||||
[`unpaired:2026-08-28:${solo}`],
|
||||
)).rows[0];
|
||||
expect(body?.body).toContain('2 sealed answers waiting');
|
||||
expect(body?.data).toMatchObject({sealed_count:2});
|
||||
});
|
||||
|
||||
it('atomically releases packs, celebrates milestones, and produces a single digest',async()=>{
|
||||
await pool.query(`insert into couple_streaks(couple_id,current_streak,longest_streak,streak_celebrated_at) values($1,7,7,0) on conflict(couple_id) do update set current_streak=7,streak_celebrated_at=0`,[couple]);
|
||||
const result=await repo.produce(new Date(Date.now()+10*60*1000),100);
|
||||
|
||||
@@ -16,9 +16,10 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useAuthStore } from "../../src/store";
|
||||
import { useAuthStore, useMatchStore } from "../../src/store";
|
||||
import { coupleApi } from "../../src/lib/coupleApi";
|
||||
import { getPairingError } from "../../src/lib/errors";
|
||||
import { buildInviteShareMessage } from "../../src/lib/inviteShareCopy";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
import { router, useLocalSearchParams } from "expo-router";
|
||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
||||
@@ -28,7 +29,7 @@ import { getPendingInviteCode, clearPendingInviteCode } from "../../src/lib/pend
|
||||
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
|
||||
|
||||
export default function PairingScreen() {
|
||||
const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
|
||||
const { fetchCouple, fetchUser, couple, partner, sealedCount, isLoading: isAuthLoading } = useAuthStore();
|
||||
const params = useLocalSearchParams<{ code?: string }>();
|
||||
const [inviteCode, setInviteCode] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -36,9 +37,13 @@ export default function PairingScreen() {
|
||||
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
|
||||
const [prefillAttempted, setPrefillAttempted] = useState(false);
|
||||
|
||||
// Redirect if already paired
|
||||
// Redirect if already paired. Pairing claims both members' sealed answers and
|
||||
// computes matches server-side, so the matches store must refetch here too:
|
||||
// whichever partner lands on this redirect first would otherwise show stale,
|
||||
// pre-pairing matches until some unrelated screen happened to refresh it.
|
||||
useEffect(() => {
|
||||
if (couple && partner) {
|
||||
void useMatchStore.getState().fetchMatches(true);
|
||||
router.replace("/(app)");
|
||||
}
|
||||
}, [couple, partner]);
|
||||
@@ -145,6 +150,9 @@ export default function PairingScreen() {
|
||||
|
||||
await fetchUser(); // Refresh user to get couple_id
|
||||
await fetchCouple(); // Fetch couple data
|
||||
// Joining claims both members' sealed answers and computes matches
|
||||
// server-side, so the joiner needs a fresh matches list too.
|
||||
void useMatchStore.getState().fetchMatches(true);
|
||||
Events.coupleJoined();
|
||||
|
||||
Alert.alert("Success", "You are now paired!", [
|
||||
@@ -171,7 +179,7 @@ export default function PairingScreen() {
|
||||
};
|
||||
|
||||
const shareMessage = couple?.invite_code
|
||||
? `Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/${couple.invite_code} (or enter code ${couple.invite_code} in the app)`
|
||||
? buildInviteShareMessage(couple.invite_code, sealedCount)
|
||||
: "";
|
||||
|
||||
const shareCode = async () => {
|
||||
@@ -279,6 +287,21 @@ export default function PairingScreen() {
|
||||
</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* Earned value: sealed answers already banked while unpaired */}
|
||||
{sealedCount > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(250).duration(500)}
|
||||
style={styles.section}
|
||||
>
|
||||
<GlassCard variant="elevated">
|
||||
<Text style={styles.sealedCountText} testID="pairing-sealed-count">
|
||||
You have already answered {sealedCount} question{sealedCount === 1 ? "" : "s"} about us.
|
||||
They will unlock the moment your partner joins.
|
||||
</Text>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Code Card */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(300).duration(500)}
|
||||
@@ -645,6 +668,11 @@ const styles = StyleSheet.create({
|
||||
textAlign: "center",
|
||||
maxWidth: 280,
|
||||
},
|
||||
sealedCountText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react-native";
|
||||
|
||||
import { useSwipeScreen } from "@/features/swipe/hooks/useSwipeScreen";
|
||||
import { ApiError, apiClient } from "@/lib/apiClient";
|
||||
import { useAuthStore } from "@/store";
|
||||
import * as swipeService from "@/features/swipe/services/swipeService";
|
||||
import { getSkippedQuestionIds } from "@/lib/skippedQuestions";
|
||||
|
||||
@@ -15,12 +16,13 @@ jest.mock("expo-router", () => ({
|
||||
// Stable identities: the hook's effects key off these, so fresh objects per render
|
||||
// would retrigger the fetch effect forever and the screen would never settle.
|
||||
jest.mock("@/store", () => {
|
||||
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
|
||||
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" }, sealedCount: 0 };
|
||||
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
|
||||
const packState = { ...packs, invalidatePacks: jest.fn() };
|
||||
const responses = { invalidateResponses: jest.fn() };
|
||||
const useAuthStore = () => auth;
|
||||
useAuthStore.getState = () => auth.user;
|
||||
useAuthStore.setState = jest.fn();
|
||||
const usePacksStore = () => packs;
|
||||
usePacksStore.getState = () => packState;
|
||||
const useResponsesStore = () => responses;
|
||||
@@ -146,6 +148,19 @@ describe("useSwipeScreen daily limit", () => {
|
||||
expect(result.current.dailyLimitInfo).toMatchObject({ responses_today: 5, remaining: 5 });
|
||||
});
|
||||
|
||||
it("updates the sealed answer count optimistically when the server reports one", async () => {
|
||||
// A solo answer has no partner to match against, so sealed_count is the
|
||||
// only feedback the server gives back; the banner reads it from auth state.
|
||||
const { result } = await renderReady();
|
||||
|
||||
jest.spyOn(apiClient, "post").mockResolvedValueOnce({ match: null, sealed_count: 3 });
|
||||
await act(async () => {
|
||||
await result.current.handleAnswer("q1", "yes");
|
||||
});
|
||||
|
||||
expect(useAuthStore.setState).toHaveBeenCalledWith({ sealedCount: 3 });
|
||||
});
|
||||
|
||||
it("refreshes the shared streak once the answer lands", async () => {
|
||||
const { result } = await renderReady();
|
||||
mockFetchStreak.mockClear();
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { buildInviteShareMessage } from "../../lib/inviteShareCopy";
|
||||
|
||||
describe("buildInviteShareMessage", () => {
|
||||
it("leads with a plain invite when there are no sealed answers", () => {
|
||||
const message = buildInviteShareMessage("ABCD1234", 0);
|
||||
expect(message).toBe(
|
||||
"Join me on Sauci! Tap this link to pair up instantly: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
|
||||
);
|
||||
});
|
||||
|
||||
it("leads with earned value and singular wording for exactly one sealed answer", () => {
|
||||
const message = buildInviteShareMessage("ABCD1234", 1);
|
||||
expect(message).toBe(
|
||||
"Join me on Sauci! I have already answered 1 question about us. Tap this link to unlock them: https://sauci.app/join/ABCD1234 (or enter code ABCD1234 in the app)"
|
||||
);
|
||||
});
|
||||
|
||||
it("pluralises for more than one sealed answer", () => {
|
||||
const message = buildInviteShareMessage("ABCD1234", 5);
|
||||
expect(message).toContain("I have already answered 5 questions about us.");
|
||||
});
|
||||
});
|
||||
@@ -30,10 +30,15 @@ const profile = {
|
||||
describe("authStore", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
// fetchUser always fetches couple state now, since it also carries the
|
||||
// sealed answer count for a user who has no couple yet. Individual tests
|
||||
// override this when they care about the couple/partner shape.
|
||||
jest.spyOn(coupleApi, "getState").mockResolvedValue({ couple: null, partner: null, sealed_count: 0 });
|
||||
useAuthStore.setState({
|
||||
user: null,
|
||||
couple: null,
|
||||
partner: null,
|
||||
sealedCount: 0,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
@@ -89,12 +94,12 @@ describe("authStore", () => {
|
||||
max_intensity: 3,
|
||||
};
|
||||
useAuthStore.setState({ user: pairedProfile } as any);
|
||||
const stateSpy = jest.spyOn(coupleApi, "getState").mockResolvedValueOnce({ couple, partner });
|
||||
const stateSpy = jest.spyOn(coupleApi, "getState").mockResolvedValueOnce({ couple, partner, sealed_count: 3 });
|
||||
|
||||
await useAuthStore.getState().fetchCouple();
|
||||
|
||||
expect(stateSpy).toHaveBeenCalledWith();
|
||||
expect(useAuthStore.getState()).toMatchObject({ couple, partner });
|
||||
expect(useAuthStore.getState()).toMatchObject({ couple, partner, sealedCount: 3 });
|
||||
});
|
||||
|
||||
it("updates activity through the standalone API", async () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { SwipeCardStack } from "./components/SwipeCardStack";
|
||||
import { SwipeHeader } from "./components/SwipeHeader";
|
||||
import { SwipeUploadOverlay } from "./components/SwipeUploadOverlay";
|
||||
import { SwipeLoadingState } from "./components/SwipeLoadingState";
|
||||
import { SwipeNoPartnerState } from "./components/SwipeNoPartnerState";
|
||||
import { SwipeSealedAnswersBanner } from "./components/SwipeSealedAnswersBanner";
|
||||
import { SwipeDailyLimitState } from "./components/SwipeDailyLimitState";
|
||||
import { SwipeBlockedState } from "./components/SwipeBlockedState";
|
||||
import { SwipeNoPacksState } from "./components/SwipeNoPacksState";
|
||||
@@ -36,6 +36,7 @@ const SwipeScreen = () => {
|
||||
user,
|
||||
partner,
|
||||
couple,
|
||||
sealedCount,
|
||||
enabledPackIds,
|
||||
effectiveTotal,
|
||||
fetchQuestions,
|
||||
@@ -51,15 +52,6 @@ const SwipeScreen = () => {
|
||||
return <SwipeLoadingState />;
|
||||
}
|
||||
|
||||
if (!partner) {
|
||||
return (
|
||||
<SwipeNoPartnerState
|
||||
hasCouple={!!couple}
|
||||
onPairPress={() => router.push("/pairing")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (dailyLimitInfo?.is_blocked) {
|
||||
return (
|
||||
<SwipeDailyLimitState
|
||||
@@ -85,7 +77,10 @@ const SwipeScreen = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!packId && enabledPackIds.length === 0) {
|
||||
// Pack selection is stored per couple, so it only exists once paired. A solo
|
||||
// user cannot have configured packs yet; recommended() already falls back to
|
||||
// every public pack for them, so this gate would otherwise be a dead end.
|
||||
if (couple && !packId && enabledPackIds.length === 0) {
|
||||
return (
|
||||
<SwipeNoPacksState onBrowsePacks={() => router.push("/")} />
|
||||
);
|
||||
@@ -118,6 +113,13 @@ const SwipeScreen = () => {
|
||||
dailyLimitInfo={dailyLimitInfo}
|
||||
/>
|
||||
|
||||
{!partner && (
|
||||
<SwipeSealedAnswersBanner
|
||||
sealedCount={sealedCount}
|
||||
onInvitePress={() => router.push("/pairing")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SwipeCardStack
|
||||
questions={filteredQuestions}
|
||||
currentIndex={currentIndex}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Share } from "react-native";
|
||||
import { colors } from "../../../theme";
|
||||
import { useAuthStore } from "../../../store";
|
||||
import { Events } from "../../../lib/analytics";
|
||||
import { buildInviteShareMessage } from "../../../lib/inviteShareCopy";
|
||||
import { SwipeInfoStateLayout } from "./SwipeInfoStateLayout";
|
||||
|
||||
interface SwipeNoPartnerStateProps {
|
||||
@@ -11,13 +12,13 @@ interface SwipeNoPartnerStateProps {
|
||||
|
||||
export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerStateProps) => {
|
||||
const accent = colors.premium.rose;
|
||||
const { couple } = useAuthStore();
|
||||
const { couple, sealedCount } = useAuthStore();
|
||||
|
||||
const handleShareCode = async () => {
|
||||
if (!couple?.invite_code) return;
|
||||
try {
|
||||
await Share.share({
|
||||
message: `Join me on Sauci! Download the app at https://sauci.app and use my invite code to pair up: ${couple.invite_code}`,
|
||||
message: buildInviteShareMessage(couple.invite_code, sealedCount),
|
||||
});
|
||||
Events.codeShared();
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { colors, radius, spacing, typography } from "../../../theme";
|
||||
|
||||
interface SwipeSealedAnswersBannerProps {
|
||||
sealedCount: number;
|
||||
onInvitePress: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An unpaired user answers straight into the swipe flow now instead of hitting a
|
||||
* dead-end waiting room. This banner keeps the invite ask visible without blocking
|
||||
* play, and grows more concrete as the sealed count climbs.
|
||||
*/
|
||||
export const SwipeSealedAnswersBanner = ({ sealedCount, onInvitePress }: SwipeSealedAnswersBannerProps) => {
|
||||
if (sealedCount <= 0) return null;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.container}
|
||||
onPress={onInvitePress}
|
||||
activeOpacity={0.8}
|
||||
testID="swipe-sealed-answers-banner"
|
||||
>
|
||||
<Ionicons name="lock-closed" size={16} color={colors.premium.rose} />
|
||||
<Text style={styles.text}>
|
||||
{sealedCount} answer{sealedCount === 1 ? "" : "s"} sealed. Your partner unlocks {sealedCount === 1 ? "it" : "them"} when they join.
|
||||
</Text>
|
||||
<Text style={styles.cta}>Invite</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: spacing.sm,
|
||||
marginHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: colors.backgroundLight,
|
||||
},
|
||||
text: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
flex: 1,
|
||||
},
|
||||
cta: {
|
||||
...typography.caption1,
|
||||
color: colors.premium.rose,
|
||||
fontWeight: "700",
|
||||
},
|
||||
});
|
||||
@@ -48,7 +48,7 @@ export const useSwipeScreen = () => {
|
||||
const [countdown, setCountdown] = useState<string>("");
|
||||
|
||||
const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore();
|
||||
const { user, partner, couple } = useAuthStore();
|
||||
const { user, partner, couple, sealedCount } = useAuthStore();
|
||||
|
||||
const filteredQuestions = filterSupportedQuestions(questions);
|
||||
|
||||
@@ -128,20 +128,15 @@ export const useSwipeScreen = () => {
|
||||
filtered = data;
|
||||
}
|
||||
|
||||
if (partner) {
|
||||
// The daily limit applies to pack browsing too, so it is read in both
|
||||
// modes; the answer gap is deliberately only enforced outside packs.
|
||||
if (packId) {
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
await checkDailyLimit();
|
||||
} else {
|
||||
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
||||
}
|
||||
} else {
|
||||
// The daily limit applies solo too, since a sealed answer still counts
|
||||
// against it; the answer gap only makes sense once there is a partner
|
||||
// to be ahead of, and checkAnswerGap already no-ops without one.
|
||||
if (packId) {
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
setDailyLimitInfo(null);
|
||||
await checkDailyLimit();
|
||||
} else {
|
||||
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
||||
}
|
||||
|
||||
if (currentRequestId === fetchRequestId.current) {
|
||||
@@ -156,7 +151,7 @@ export const useSwipeScreen = () => {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [packId, partner, checkAnswerGap, checkDailyLimit]);
|
||||
}, [packId, checkAnswerGap, checkDailyLimit]);
|
||||
|
||||
const fetchPending = useCallback(async () => {
|
||||
const currentRequestId = ++fetchRequestId.current;
|
||||
@@ -315,7 +310,7 @@ export const useSwipeScreen = () => {
|
||||
uploadResponseMedia,
|
||||
});
|
||||
|
||||
const data = await apiClient.post<{ match: unknown | null }>("/v1/responses", {
|
||||
const data = await apiClient.post<{ match: unknown | null; sealed_count?: number }>("/v1/responses", {
|
||||
question_id: questionId,
|
||||
answer,
|
||||
response_data: finalResponseData,
|
||||
@@ -323,6 +318,13 @@ export const useSwipeScreen = () => {
|
||||
|
||||
Events.questionAnswered(answer, filteredQuestions[currentIndex]?.pack_id);
|
||||
|
||||
// A solo answer has no partner to match against; the sealed count is
|
||||
// this answer's only visible feedback, so the banner updates instantly
|
||||
// instead of waiting for the couple state to refresh next time round.
|
||||
if (typeof data?.sealed_count === 'number') {
|
||||
useAuthStore.setState({ sealedCount: data.sealed_count });
|
||||
}
|
||||
|
||||
const hasMatch = data?.match != null;
|
||||
|
||||
if (hasMatch) {
|
||||
@@ -423,6 +425,7 @@ export const useSwipeScreen = () => {
|
||||
user,
|
||||
partner,
|
||||
couple,
|
||||
sealedCount,
|
||||
enabledPackIds,
|
||||
effectiveTotal,
|
||||
fetchQuestions,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { apiRequest } from "./apiClient";
|
||||
export interface CoupleStateResponse {
|
||||
couple: Couple | null;
|
||||
partner: Profile | null;
|
||||
/** Count of this user's banked answers with no couple yet (couple_id IS NULL). */
|
||||
sealed_count: number;
|
||||
}
|
||||
|
||||
export interface CoupleMutationResponse {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Builds the message shared when inviting a partner to pair. Once the inviter has
|
||||
* sealed answers banked (answered questions solo, before a partner joined), the
|
||||
* copy leads with that earned value instead of a plain invite, because it gives
|
||||
* the recipient a concrete reason to join right away.
|
||||
*/
|
||||
export function buildInviteShareMessage(inviteCode: string, sealedCount: number): string {
|
||||
const link = `https://sauci.app/join/${inviteCode} (or enter code ${inviteCode} in the app)`;
|
||||
if (sealedCount > 0) {
|
||||
const noun = sealedCount === 1 ? "question" : "questions";
|
||||
return `Join me on Sauci! I have already answered ${sealedCount} ${noun} about us. Tap this link to unlock them: ${link}`;
|
||||
}
|
||||
return `Join me on Sauci! Tap this link to pair up instantly: ${link}`;
|
||||
}
|
||||
@@ -12,6 +12,8 @@ interface AuthState {
|
||||
user: Profile | null;
|
||||
couple: Couple | null;
|
||||
partner: Profile | null;
|
||||
/** This user's banked (sealed) answers with no couple yet. Zero once claimed at pairing. */
|
||||
sealedCount: number;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
isAnonymous: boolean;
|
||||
@@ -44,6 +46,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
couple: null,
|
||||
partner: null,
|
||||
sealedCount: 0,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
@@ -77,12 +80,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
set({ isAuthenticated: true, isAnonymous });
|
||||
}
|
||||
|
||||
// If user has a couple, fetch couple data; otherwise clear couple/partner
|
||||
if (profile?.couple_id) {
|
||||
await get().fetchCouple();
|
||||
} else {
|
||||
set({ couple: null, partner: null });
|
||||
}
|
||||
// Always fetch couple state: it reports the sealed answer count for a
|
||||
// solo user even before any couple exists, not just once paired.
|
||||
await get().fetchCouple();
|
||||
|
||||
set({ isLoading: false });
|
||||
} catch (error) {
|
||||
@@ -107,14 +107,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
fetchCouple: async () => {
|
||||
const user = get().user;
|
||||
if (!user?.couple_id) {
|
||||
set({ couple: null, partner: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const { couple, partner } = await coupleApi.getState();
|
||||
set({ couple, partner });
|
||||
// Fetches unconditionally: getState() reports sealed_count for a solo user
|
||||
// who has no couple yet too, not only once paired.
|
||||
const { couple, partner, sealed_count } = await coupleApi.getState();
|
||||
set({ couple, partner, sealedCount: sealed_count });
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,54 @@ A user can be in one of three states:
|
||||
| **Waiting** | Set | `null` | Created couple, waiting for partner |
|
||||
| **Paired** | Set | Exists | Both partners joined |
|
||||
|
||||
Unpaired and Waiting are not dead ends: both states can answer questions
|
||||
immediately through the standalone API (`POST /v1/responses`). See
|
||||
[Solo answering and sealed answers](#solo-answering-and-sealed-answers) below.
|
||||
|
||||
## Solo answering and sealed answers
|
||||
|
||||
An Unpaired or Waiting user answers straight into the swipe flow instead of
|
||||
being blocked until a partner joins. `responses.couple_id` is nullable
|
||||
(`apps/api/drizzle/0021_solo_sealed_answers.sql`): a response submitted with no
|
||||
couple is inserted with `couple_id IS NULL` and is called a **sealed answer**.
|
||||
It is banked, not lost, and counts toward the daily response limit like any
|
||||
other answer, but no match reconciliation runs for it (there is no partner
|
||||
response to compare against).
|
||||
|
||||
`GET /v1/couple` reports `sealed_count`, the caller's count of responses with
|
||||
`couple_id IS NULL`, alongside `couple` and `partner`, so the client can show
|
||||
"N sealed answers" before and while waiting to pair. `POST /v1/responses` also
|
||||
returns `sealed_count` in its response body for a solo submission (its `match`
|
||||
is always `null`).
|
||||
|
||||
### Claim on pair
|
||||
|
||||
`PostgresCoupleRepository.join()` (`apps/api/src/domains/couples/repository.ts`)
|
||||
is the only place membership grows from one member to two. In the same
|
||||
transaction that adds the second member, it:
|
||||
|
||||
1. Reassigns every sealed answer (`couple_id IS NULL`) belonging to either
|
||||
member to the new couple.
|
||||
2. For every question where both members now have an answer, computes the
|
||||
match type with the same rule `calculateMatchType` uses for live answers
|
||||
and upserts into `matches` (respecting the `(couple_id, question_id)`
|
||||
uniqueness), so a couple's history of solo answers surfaces as matches the
|
||||
moment they pair, not only for answers given after pairing.
|
||||
|
||||
A user who re-pairs after their previous couple was deleted only has
|
||||
`couple_id IS NULL` answers left to claim (the old couple's responses were
|
||||
cascade-deleted with it), which this handles the same way as first-time
|
||||
pairing.
|
||||
|
||||
### Push nudge escalation
|
||||
|
||||
The unpaired push reminder (`apps/api/src/domains/operations/repository.ts`,
|
||||
`produce()`) reads each eligible recipient's sealed count and leads with it
|
||||
once it is greater than zero: "You have N sealed answers waiting. Invite your
|
||||
partner to unlock what you said about them." Recipients with no sealed
|
||||
answers yet see the original invite-code copy. The existing 3-day cadence and
|
||||
18:00-local gate are unchanged.
|
||||
|
||||
## Edge Functions
|
||||
|
||||
### `manage-couple` (POST)
|
||||
|
||||
@@ -139,11 +139,16 @@ const sorted = filtered.sort((a, b) => {
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| No packs enabled | Shows "No packs enabled" prompt |
|
||||
| No packs enabled | Shows "No packs enabled" prompt (paired couples only; a couple with no configured packs has not chosen yet) |
|
||||
| All questions answered | Shows "All caught up!" with refresh button |
|
||||
| No partner paired | Shows pairing prompt |
|
||||
| No partner paired | Answers straight into the swipe flow instead of a blocking prompt. Every answer is sealed (`couple_id IS NULL`, no match). A persistent banner reports the sealed count with an invite CTA. See [Solo answering and sealed answers](couple-pairing.md#solo-answering-and-sealed-answers). |
|
||||
| Pack disabled mid-session | Client filters out questions from disabled packs |
|
||||
|
||||
An unpaired user has no `couple_packs` row to configure (it is keyed to
|
||||
`couple_id`), so `recommended()`'s "unconfigured couple" fallback (every
|
||||
public pack) is what they always see; the pack-selection screen only applies
|
||||
once paired.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Value | Location |
|
||||
|
||||
+8
-1
@@ -68,7 +68,7 @@ erDiagram
|
||||
uuid id PK
|
||||
uuid user_id FK
|
||||
uuid question_id FK
|
||||
uuid couple_id FK
|
||||
uuid couple_id FK "nullable: null is a sealed solo answer, claimed at pairing"
|
||||
answer_type answer "yes/no/maybe"
|
||||
timestamptz created_at
|
||||
}
|
||||
@@ -196,10 +196,17 @@ allowed.
|
||||
5. Match triggers push notification to both partners
|
||||
6. Match unlocks chat thread in `messages` table
|
||||
|
||||
Without a couple yet, step 3 has no partner response to check: the response is
|
||||
saved with `couple_id IS NULL` (a sealed answer) and no match is created. See
|
||||
[Solo answering and sealed answers](couple-pairing.md#solo-answering-and-sealed-answers).
|
||||
|
||||
### Couple Pairing Flow
|
||||
1. First user creates couple, gets `invite_code`
|
||||
2. Second user joins via `manage-couple` edge function with invite code
|
||||
3. Both profiles' `couple_id` set to same couple
|
||||
4. Every sealed answer (`couple_id IS NULL`) belonging to either member is
|
||||
claimed into the couple, and matches are computed for every question both
|
||||
members have now answered
|
||||
|
||||
### Pack Selection Flow
|
||||
1. Couples can enable/disable packs via `couple_packs` junction table
|
||||
|
||||
Reference in new issue
Block a user