mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Merge solo sealed answers that unlock on pairing into staging
This commit is contained in:
commit
7d392e756d
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,
|
"when": 1788168060000,
|
||||||
"tag": "0020_quiz_question_pool",
|
"tag": "0020_quiz_question_pool",
|
||||||
"breakpoints": true
|
"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 }>>;
|
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 }>;
|
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 }>;
|
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>>;
|
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
|
||||||
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
|
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 }>;
|
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;
|
private zones: Promise<Set<string>> | null = null;
|
||||||
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
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> {
|
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 result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
|
||||||
const row = result.rows[0];
|
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;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,15 +154,25 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
|||||||
await this.assertEligible(client,userId,ctx,q);
|
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]);
|
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) {
|
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) {
|
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 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)
|
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]);
|
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);
|
if (!ctx.couple_id) {
|
||||||
await this.touchStreak(client,userId,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 };
|
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 };
|
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 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 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}:{}) };
|
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 type { Couple, Profile } from '@sauci/shared';
|
||||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||||
|
import { calculateMatchType, type Answer } from '../answers/types.js';
|
||||||
import { CoupleError, type CoupleStateResponse } from './types.js';
|
import { CoupleError, type CoupleStateResponse } from './types.js';
|
||||||
|
|
||||||
interface ProfileRecord extends QueryResultRow {
|
interface ProfileRecord extends QueryResultRow {
|
||||||
@@ -77,6 +78,60 @@ async function profileForUpdate(client: PoolClient, userId: string): Promise<{ c
|
|||||||
return profile;
|
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> {
|
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -111,7 +166,8 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
|||||||
if (!profile) {
|
if (!profile) {
|
||||||
throw new CoupleError('profile_not_found', 'Profile not found. Please complete signup first.', 404);
|
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([
|
const [coupleResult, partnerResult] = await Promise.all([
|
||||||
this.pool.query<CoupleRecord>(
|
this.pool.query<CoupleRecord>(
|
||||||
@@ -129,13 +185,22 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
const couple = coupleResult.rows[0];
|
const couple = coupleResult.rows[0];
|
||||||
if (!couple) return { couple: null, partner: null };
|
if (!couple) return { couple: null, partner: null, sealed_count: sealedCount };
|
||||||
return {
|
return {
|
||||||
couple: toCouple(couple),
|
couple: toCouple(couple),
|
||||||
partner: partnerResult.rows[0] ? toProfile(partnerResult.rows[0]) : null,
|
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> {
|
async create(userId: string, coupleId: string, inviteCode: string): Promise<Couple> {
|
||||||
return transaction(this.pool, async (client) => {
|
return transaction(this.pool, async (client) => {
|
||||||
const profile = await profileForUpdate(client, userId);
|
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 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);
|
return toCouple(couple);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { Couple, Profile } from '@sauci/shared';
|
|||||||
export interface CoupleStateResponse {
|
export interface CoupleStateResponse {
|
||||||
couple: Couple | null;
|
couple: Couple | null;
|
||||||
partner: Profile | 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 {
|
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')
|
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]);
|
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(
|
const unpaired=await client.query(
|
||||||
`with eligible as (
|
`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
|
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 (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)
|
and coalesce(np.unpaired_reminders_enabled,true)
|
||||||
@@ -143,10 +148,11 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
|||||||
), queued as (
|
), queued as (
|
||||||
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
|
||||||
select 'expo','unpaired:'||to_char($1::timestamptz,'YYYY-MM-DD')||':'||e.id,e.id,
|
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
|
jsonb_build_object('title','Sauci','body',case
|
||||||
then 'Your invite code is waiting! Share it with your partner to start discovering what you have in common.'
|
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,
|
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
|
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]);
|
) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]);
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,9 @@ export const responses = pgTable('responses', {
|
|||||||
id: uuid('id').primaryKey(),
|
id: uuid('id').primaryKey(),
|
||||||
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
|
||||||
questionId: uuid('question_id').notNull().references(() => questions.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(),
|
answer: text('answer').notNull(),
|
||||||
responseData: jsonb('response_data'),
|
responseData: jsonb('response_data'),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
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';
|
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');
|
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;
|
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';
|
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']){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();});
|
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('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('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);});
|
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");
|
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());}});
|
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);});
|
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() });
|
pool = new Pool({ connectionString: isolatedUrl.toString() });
|
||||||
repository = new PostgresCoupleRepository(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');
|
const migration = await readFile(new URL(`../drizzle/${migrationName}`, import.meta.url), 'utf8');
|
||||||
for (const statement of migration.split('--> statement-breakpoint')) {
|
for (const statement of migration.split('--> statement-breakpoint')) {
|
||||||
if (statement.trim()) await pool.query(statement);
|
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 expect(repository.join(charlie, 'ABCD2345')).rejects.toMatchObject({ code: 'couple_full' });
|
||||||
|
|
||||||
await repository.cancel(alice);
|
await repository.cancel(alice);
|
||||||
await expect(repository.getState(alice)).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 });
|
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);
|
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() {
|
function setup() {
|
||||||
const repository: CoupleRepository = {
|
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) => ({
|
create: vi.fn(async (_userId, coupleId, inviteCode) => ({
|
||||||
id: coupleId,
|
id: coupleId,
|
||||||
invite_code: inviteCode,
|
invite_code: inviteCode,
|
||||||
@@ -43,7 +43,7 @@ describe('couple routes', () => {
|
|||||||
const response = await app.request('/v1/couple');
|
const response = await app.request('/v1/couple');
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
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);
|
expect(repository.getState).toHaveBeenCalledWith(identity.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const couple: Couple = {
|
|||||||
|
|
||||||
function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository {
|
function repository(overrides: Partial<CoupleRepository> = {}): CoupleRepository {
|
||||||
return {
|
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),
|
create: vi.fn(async () => couple),
|
||||||
join: vi.fn(async () => couple),
|
join: vi.fn(async () => couple),
|
||||||
cancel: vi.fn(async () => undefined),
|
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;
|
const pack='44444444-4444-4444-8444-444444444444',question='55555555-5555-4555-8555-555555555555';let pool:Pool;let repo:PostgresOperationsRepository;let repo2:PostgresOperationsRepository;
|
||||||
beforeAll(async()=>{
|
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());
|
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);
|
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']);
|
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);
|
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()=>{
|
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]);
|
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);
|
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 { LinearGradient } from "expo-linear-gradient";
|
||||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import { useAuthStore } from "../../src/store";
|
import { useAuthStore, useMatchStore } from "../../src/store";
|
||||||
import { coupleApi } from "../../src/lib/coupleApi";
|
import { coupleApi } from "../../src/lib/coupleApi";
|
||||||
import { getPairingError } from "../../src/lib/errors";
|
import { getPairingError } from "../../src/lib/errors";
|
||||||
|
import { buildInviteShareMessage } from "../../src/lib/inviteShareCopy";
|
||||||
import { Events } from "../../src/lib/analytics";
|
import { Events } from "../../src/lib/analytics";
|
||||||
import { router, useLocalSearchParams } from "expo-router";
|
import { router, useLocalSearchParams } from "expo-router";
|
||||||
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
|
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";
|
import { checkClipboardForInviteCode } from "../../src/lib/clipboardInviteOffer";
|
||||||
|
|
||||||
export default function PairingScreen() {
|
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 params = useLocalSearchParams<{ code?: string }>();
|
||||||
const [inviteCode, setInviteCode] = useState("");
|
const [inviteCode, setInviteCode] = useState("");
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
@@ -36,9 +37,13 @@ export default function PairingScreen() {
|
|||||||
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
|
const [clipboardOfferCode, setClipboardOfferCode] = useState<string | null>(null);
|
||||||
const [prefillAttempted, setPrefillAttempted] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (couple && partner) {
|
if (couple && partner) {
|
||||||
|
void useMatchStore.getState().fetchMatches(true);
|
||||||
router.replace("/(app)");
|
router.replace("/(app)");
|
||||||
}
|
}
|
||||||
}, [couple, partner]);
|
}, [couple, partner]);
|
||||||
@@ -145,6 +150,9 @@ export default function PairingScreen() {
|
|||||||
|
|
||||||
await fetchUser(); // Refresh user to get couple_id
|
await fetchUser(); // Refresh user to get couple_id
|
||||||
await fetchCouple(); // Fetch couple data
|
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();
|
Events.coupleJoined();
|
||||||
|
|
||||||
Alert.alert("Success", "You are now paired!", [
|
Alert.alert("Success", "You are now paired!", [
|
||||||
@@ -171,7 +179,7 @@ export default function PairingScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const shareMessage = couple?.invite_code
|
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 () => {
|
const shareCode = async () => {
|
||||||
@@ -279,6 +287,21 @@ export default function PairingScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</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 */}
|
{/* Code Card */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(300).duration(500)}
|
entering={FadeInDown.delay(300).duration(500)}
|
||||||
@@ -645,6 +668,11 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
maxWidth: 280,
|
maxWidth: 280,
|
||||||
},
|
},
|
||||||
|
sealedCountText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
section: {
|
section: {
|
||||||
marginBottom: spacing.md,
|
marginBottom: spacing.md,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from "@testing-library/react-native";
|
|||||||
|
|
||||||
import { useSwipeScreen } from "@/features/swipe/hooks/useSwipeScreen";
|
import { useSwipeScreen } from "@/features/swipe/hooks/useSwipeScreen";
|
||||||
import { ApiError, apiClient } from "@/lib/apiClient";
|
import { ApiError, apiClient } from "@/lib/apiClient";
|
||||||
|
import { useAuthStore } from "@/store";
|
||||||
import * as swipeService from "@/features/swipe/services/swipeService";
|
import * as swipeService from "@/features/swipe/services/swipeService";
|
||||||
import { getSkippedQuestionIds } from "@/lib/skippedQuestions";
|
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
|
// 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.
|
// would retrigger the fetch effect forever and the screen would never settle.
|
||||||
jest.mock("@/store", () => {
|
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 packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
|
||||||
const packState = { ...packs, invalidatePacks: jest.fn() };
|
const packState = { ...packs, invalidatePacks: jest.fn() };
|
||||||
const responses = { invalidateResponses: jest.fn() };
|
const responses = { invalidateResponses: jest.fn() };
|
||||||
const useAuthStore = () => auth;
|
const useAuthStore = () => auth;
|
||||||
useAuthStore.getState = () => auth.user;
|
useAuthStore.getState = () => auth.user;
|
||||||
|
useAuthStore.setState = jest.fn();
|
||||||
const usePacksStore = () => packs;
|
const usePacksStore = () => packs;
|
||||||
usePacksStore.getState = () => packState;
|
usePacksStore.getState = () => packState;
|
||||||
const useResponsesStore = () => responses;
|
const useResponsesStore = () => responses;
|
||||||
@@ -146,6 +148,19 @@ describe("useSwipeScreen daily limit", () => {
|
|||||||
expect(result.current.dailyLimitInfo).toMatchObject({ responses_today: 5, remaining: 5 });
|
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 () => {
|
it("refreshes the shared streak once the answer lands", async () => {
|
||||||
const { result } = await renderReady();
|
const { result } = await renderReady();
|
||||||
mockFetchStreak.mockClear();
|
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", () => {
|
describe("authStore", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
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({
|
useAuthStore.setState({
|
||||||
user: null,
|
user: null,
|
||||||
couple: null,
|
couple: null,
|
||||||
partner: null,
|
partner: null,
|
||||||
|
sealedCount: 0,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isAnonymous: false,
|
isAnonymous: false,
|
||||||
@@ -89,12 +94,12 @@ describe("authStore", () => {
|
|||||||
max_intensity: 3,
|
max_intensity: 3,
|
||||||
};
|
};
|
||||||
useAuthStore.setState({ user: pairedProfile } as any);
|
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();
|
await useAuthStore.getState().fetchCouple();
|
||||||
|
|
||||||
expect(stateSpy).toHaveBeenCalledWith();
|
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 () => {
|
it("updates activity through the standalone API", async () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { SwipeCardStack } from "./components/SwipeCardStack";
|
|||||||
import { SwipeHeader } from "./components/SwipeHeader";
|
import { SwipeHeader } from "./components/SwipeHeader";
|
||||||
import { SwipeUploadOverlay } from "./components/SwipeUploadOverlay";
|
import { SwipeUploadOverlay } from "./components/SwipeUploadOverlay";
|
||||||
import { SwipeLoadingState } from "./components/SwipeLoadingState";
|
import { SwipeLoadingState } from "./components/SwipeLoadingState";
|
||||||
import { SwipeNoPartnerState } from "./components/SwipeNoPartnerState";
|
import { SwipeSealedAnswersBanner } from "./components/SwipeSealedAnswersBanner";
|
||||||
import { SwipeDailyLimitState } from "./components/SwipeDailyLimitState";
|
import { SwipeDailyLimitState } from "./components/SwipeDailyLimitState";
|
||||||
import { SwipeBlockedState } from "./components/SwipeBlockedState";
|
import { SwipeBlockedState } from "./components/SwipeBlockedState";
|
||||||
import { SwipeNoPacksState } from "./components/SwipeNoPacksState";
|
import { SwipeNoPacksState } from "./components/SwipeNoPacksState";
|
||||||
@@ -38,6 +38,7 @@ const SwipeScreen = () => {
|
|||||||
user,
|
user,
|
||||||
partner,
|
partner,
|
||||||
couple,
|
couple,
|
||||||
|
sealedCount,
|
||||||
enabledPackIds,
|
enabledPackIds,
|
||||||
effectiveTotal,
|
effectiveTotal,
|
||||||
fetchQuestions,
|
fetchQuestions,
|
||||||
@@ -55,15 +56,6 @@ const SwipeScreen = () => {
|
|||||||
return <SwipeLoadingState />;
|
return <SwipeLoadingState />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!partner) {
|
|
||||||
return (
|
|
||||||
<SwipeNoPartnerState
|
|
||||||
hasCouple={!!couple}
|
|
||||||
onPairPress={() => router.push("/pairing")}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dailyLimitInfo?.is_blocked) {
|
if (dailyLimitInfo?.is_blocked) {
|
||||||
return (
|
return (
|
||||||
<SwipeDailyLimitState
|
<SwipeDailyLimitState
|
||||||
@@ -89,7 +81,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 (
|
return (
|
||||||
<SwipeNoPacksState onBrowsePacks={() => router.push("/")} />
|
<SwipeNoPacksState onBrowsePacks={() => router.push("/")} />
|
||||||
);
|
);
|
||||||
@@ -122,6 +117,13 @@ const SwipeScreen = () => {
|
|||||||
dailyLimitInfo={dailyLimitInfo}
|
dailyLimitInfo={dailyLimitInfo}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{!partner && (
|
||||||
|
<SwipeSealedAnswersBanner
|
||||||
|
sealedCount={sealedCount}
|
||||||
|
onInvitePress={() => router.push("/pairing")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<SwipeCardStack
|
<SwipeCardStack
|
||||||
questions={filteredQuestions}
|
questions={filteredQuestions}
|
||||||
currentIndex={currentIndex}
|
currentIndex={currentIndex}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Share } from "react-native";
|
|||||||
import { colors } from "../../../theme";
|
import { colors } from "../../../theme";
|
||||||
import { useAuthStore } from "../../../store";
|
import { useAuthStore } from "../../../store";
|
||||||
import { Events } from "../../../lib/analytics";
|
import { Events } from "../../../lib/analytics";
|
||||||
|
import { buildInviteShareMessage } from "../../../lib/inviteShareCopy";
|
||||||
import { SwipeInfoStateLayout } from "./SwipeInfoStateLayout";
|
import { SwipeInfoStateLayout } from "./SwipeInfoStateLayout";
|
||||||
|
|
||||||
interface SwipeNoPartnerStateProps {
|
interface SwipeNoPartnerStateProps {
|
||||||
@@ -11,13 +12,13 @@ interface SwipeNoPartnerStateProps {
|
|||||||
|
|
||||||
export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerStateProps) => {
|
export const SwipeNoPartnerState = ({ hasCouple, onPairPress }: SwipeNoPartnerStateProps) => {
|
||||||
const accent = colors.premium.rose;
|
const accent = colors.premium.rose;
|
||||||
const { couple } = useAuthStore();
|
const { couple, sealedCount } = useAuthStore();
|
||||||
|
|
||||||
const handleShareCode = async () => {
|
const handleShareCode = async () => {
|
||||||
if (!couple?.invite_code) return;
|
if (!couple?.invite_code) return;
|
||||||
try {
|
try {
|
||||||
await Share.share({
|
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();
|
Events.codeShared();
|
||||||
} catch (error) {
|
} 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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -51,7 +51,7 @@ export const useSwipeScreen = () => {
|
|||||||
const hasEvaluatedAvatarPrompt = useRef(false);
|
const hasEvaluatedAvatarPrompt = useRef(false);
|
||||||
|
|
||||||
const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore();
|
const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore();
|
||||||
const { user, partner, couple } = useAuthStore();
|
const { user, partner, couple, sealedCount } = useAuthStore();
|
||||||
|
|
||||||
const filteredQuestions = filterSupportedQuestions(questions);
|
const filteredQuestions = filterSupportedQuestions(questions);
|
||||||
|
|
||||||
@@ -131,20 +131,15 @@ export const useSwipeScreen = () => {
|
|||||||
filtered = data;
|
filtered = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (partner) {
|
// The daily limit applies solo too, since a sealed answer still counts
|
||||||
// The daily limit applies to pack browsing too, so it is read in both
|
// against it; the answer gap only makes sense once there is a partner
|
||||||
// modes; the answer gap is deliberately only enforced outside packs.
|
// to be ahead of, and checkAnswerGap already no-ops without one.
|
||||||
if (packId) {
|
if (packId) {
|
||||||
setIsBlocked(false);
|
|
||||||
setGapInfo(null);
|
|
||||||
await checkDailyLimit();
|
|
||||||
} else {
|
|
||||||
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setIsBlocked(false);
|
setIsBlocked(false);
|
||||||
setGapInfo(null);
|
setGapInfo(null);
|
||||||
setDailyLimitInfo(null);
|
await checkDailyLimit();
|
||||||
|
} else {
|
||||||
|
await Promise.all([checkAnswerGap(), checkDailyLimit()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentRequestId === fetchRequestId.current) {
|
if (currentRequestId === fetchRequestId.current) {
|
||||||
@@ -159,7 +154,7 @@ export const useSwipeScreen = () => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [packId, partner, checkAnswerGap, checkDailyLimit]);
|
}, [packId, checkAnswerGap, checkDailyLimit]);
|
||||||
|
|
||||||
const fetchPending = useCallback(async () => {
|
const fetchPending = useCallback(async () => {
|
||||||
const currentRequestId = ++fetchRequestId.current;
|
const currentRequestId = ++fetchRequestId.current;
|
||||||
@@ -318,7 +313,7 @@ export const useSwipeScreen = () => {
|
|||||||
uploadResponseMedia,
|
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,
|
question_id: questionId,
|
||||||
answer,
|
answer,
|
||||||
response_data: finalResponseData,
|
response_data: finalResponseData,
|
||||||
@@ -326,6 +321,13 @@ export const useSwipeScreen = () => {
|
|||||||
|
|
||||||
Events.questionAnswered(answer, filteredQuestions[currentIndex]?.pack_id);
|
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;
|
const hasMatch = data?.match != null;
|
||||||
|
|
||||||
if (hasMatch) {
|
if (hasMatch) {
|
||||||
@@ -458,6 +460,7 @@ export const useSwipeScreen = () => {
|
|||||||
user,
|
user,
|
||||||
partner,
|
partner,
|
||||||
couple,
|
couple,
|
||||||
|
sealedCount,
|
||||||
enabledPackIds,
|
enabledPackIds,
|
||||||
effectiveTotal,
|
effectiveTotal,
|
||||||
fetchQuestions,
|
fetchQuestions,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { apiRequest } from "./apiClient";
|
|||||||
export interface CoupleStateResponse {
|
export interface CoupleStateResponse {
|
||||||
couple: Couple | null;
|
couple: Couple | null;
|
||||||
partner: Profile | 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 {
|
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;
|
user: Profile | null;
|
||||||
couple: Couple | null;
|
couple: Couple | null;
|
||||||
partner: Profile | null;
|
partner: Profile | null;
|
||||||
|
/** This user's banked (sealed) answers with no couple yet. Zero once claimed at pairing. */
|
||||||
|
sealedCount: number;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
isAnonymous: boolean;
|
isAnonymous: boolean;
|
||||||
@@ -44,6 +46,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
user: null,
|
user: null,
|
||||||
couple: null,
|
couple: null,
|
||||||
partner: null,
|
partner: null,
|
||||||
|
sealedCount: 0,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isAnonymous: false,
|
isAnonymous: false,
|
||||||
@@ -77,12 +80,9 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
set({ isAuthenticated: true, isAnonymous });
|
set({ isAuthenticated: true, isAnonymous });
|
||||||
}
|
}
|
||||||
|
|
||||||
// If user has a couple, fetch couple data; otherwise clear couple/partner
|
// Always fetch couple state: it reports the sealed answer count for a
|
||||||
if (profile?.couple_id) {
|
// solo user even before any couple exists, not just once paired.
|
||||||
await get().fetchCouple();
|
await get().fetchCouple();
|
||||||
} else {
|
|
||||||
set({ couple: null, partner: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ isLoading: false });
|
set({ isLoading: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -107,14 +107,10 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
fetchCouple: async () => {
|
fetchCouple: async () => {
|
||||||
const user = get().user;
|
// Fetches unconditionally: getState() reports sealed_count for a solo user
|
||||||
if (!user?.couple_id) {
|
// who has no couple yet too, not only once paired.
|
||||||
set({ couple: null, partner: null });
|
const { couple, partner, sealed_count } = await coupleApi.getState();
|
||||||
return;
|
set({ couple, partner, sealedCount: sealed_count });
|
||||||
}
|
|
||||||
|
|
||||||
const { couple, partner } = await coupleApi.getState();
|
|
||||||
set({ couple, partner });
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -16,6 +16,54 @@ A user can be in one of three states:
|
|||||||
| **Waiting** | Set | `null` | Created couple, waiting for partner |
|
| **Waiting** | Set | `null` | Created couple, waiting for partner |
|
||||||
| **Paired** | Set | Exists | Both partners joined |
|
| **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
|
## Edge Functions
|
||||||
|
|
||||||
### `manage-couple` (POST)
|
### `manage-couple` (POST)
|
||||||
|
|||||||
@@ -139,11 +139,16 @@ const sorted = filtered.sort((a, b) => {
|
|||||||
|
|
||||||
| Scenario | Behavior |
|
| 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 |
|
| 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 |
|
| 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
|
## Configuration
|
||||||
|
|
||||||
| Setting | Value | Location |
|
| Setting | Value | Location |
|
||||||
|
|||||||
+8
-1
@@ -68,7 +68,7 @@ erDiagram
|
|||||||
uuid id PK
|
uuid id PK
|
||||||
uuid user_id FK
|
uuid user_id FK
|
||||||
uuid question_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"
|
answer_type answer "yes/no/maybe"
|
||||||
timestamptz created_at
|
timestamptz created_at
|
||||||
}
|
}
|
||||||
@@ -196,10 +196,17 @@ allowed.
|
|||||||
5. Match triggers push notification to both partners
|
5. Match triggers push notification to both partners
|
||||||
6. Match unlocks chat thread in `messages` table
|
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
|
### Couple Pairing Flow
|
||||||
1. First user creates couple, gets `invite_code`
|
1. First user creates couple, gets `invite_code`
|
||||||
2. Second user joins via `manage-couple` edge function with invite code
|
2. Second user joins via `manage-couple` edge function with invite code
|
||||||
3. Both profiles' `couple_id` set to same couple
|
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
|
### Pack Selection Flow
|
||||||
1. Couples can enable/disable packs via `couple_packs` junction table
|
1. Couples can enable/disable packs via `couple_packs` junction table
|
||||||
|
|||||||
Reference in new issue
Block a user