mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(engagement): localize limits and streaks
Use reported user timezones for daily response limits, expose couple streak state and reminders, and improve swipe and live-drawing synchronization with focused tests and documentation.
This commit is contained in:
1 parent
61ec3692b7
commit
b60c788362
42 files changed
+1308
-217
No files matched your search
@@ -5,6 +5,33 @@ interface ContextRow { couple_id: string | null; gender: string | null; max_inte
|
||||
interface QuestionRow { id: string; pack_id: string; text: string; partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; target_user_genders: string[] | null; question_type: QuestionType; config: Record<string, unknown>; created_at: string }
|
||||
interface ResponseRow { id: string; user_id: string; question_id: string; couple_id: string; answer: Answer; response_data: ResponseData; created_at: string }
|
||||
interface MatchRow { id: string; couple_id: string; question_id: string; match_type: MatchType; is_new: boolean; response_summary: Record<string, unknown> | null; created_at: string }
|
||||
interface StreakRow { couple_id: string; current_streak: number; longest_streak: number; last_active_date: string | null; last_completed_date: string | null; user1_answered_today: boolean; user2_answered_today: boolean; streak_celebrated_at: number; created_at: Date; updated_at: Date }
|
||||
|
||||
/**
|
||||
* A streak resolved from one partner's point of view. The stored row is positional
|
||||
* (user1/user2 by member id order) because the writer has to be deterministic, but a
|
||||
* client cannot act on "user2 has not answered" without re-deriving that ordering, so
|
||||
* the API answers in terms of you and your partner instead.
|
||||
*/
|
||||
export interface CoupleStreakView {
|
||||
couple_id: string;
|
||||
current_streak: number;
|
||||
longest_streak: number;
|
||||
last_active_date: string | null;
|
||||
last_completed_date: string | null;
|
||||
you_answered_today: boolean;
|
||||
partner_answered_today: boolean;
|
||||
partner_name: string | null;
|
||||
timezone: string;
|
||||
streak_celebrated_at: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// A response to a question the partner answered first is catching up, not new exploration.
|
||||
// Those neither consume nor are blocked by the daily limit, so the reciprocity loop the
|
||||
// answer-gap mechanic pushes can never be refused by the meter.
|
||||
const catchUpExemption = "select 1 from responses pr where pr.question_id=r.question_id and pr.couple_id=r.couple_id and pr.user_id<>r.user_id and pr.created_at<r.created_at";
|
||||
|
||||
export interface SubmitInput { questionId: string; answer: Answer; responseData?: ResponseData }
|
||||
export interface UpdateInput extends SubmitInput { confirmDeleteMatch: boolean }
|
||||
@@ -20,12 +47,13 @@ export interface AnswersRepository {
|
||||
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
|
||||
markSeen(userId: string, ids: string[]): Promise<void>;
|
||||
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
|
||||
streak(userId: string): Promise<Record<string, unknown> | null>;
|
||||
streak(userId: string): Promise<CoupleStreakView | null>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class PostgresAnswersRepository implements AnswersRepository {
|
||||
private readonly pool: Pool;
|
||||
private zones: Promise<Set<string>> | null = null;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
|
||||
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
|
||||
@@ -41,7 +69,7 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
||||
const p = partner.rows[0];
|
||||
const config = await this.config();
|
||||
if (!packId && p && (await this.answerGap(userId)).is_blocked) return [];
|
||||
if (!packId && !ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return [];
|
||||
if (!ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return [];
|
||||
const genders = ctx.gender && p?.gender ? [ctx.gender, p.gender].sort().join('+') : null;
|
||||
const maxIntensity = config.couple_intensity_gate_enabled && p ? Math.min(ctx.max_intensity, p.max_intensity) : ctx.max_intensity;
|
||||
const result = await this.pool.query<QuestionRow>(`
|
||||
@@ -117,7 +145,10 @@ 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 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, reset_at: limit.reset_at });
|
||||
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]);
|
||||
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]);
|
||||
@@ -157,12 +188,30 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* The streak day is the couple's shared local day, not the server's. Both partners have
|
||||
* to land inside the same calendar date for the day to count, so the zone belongs to the
|
||||
* couple rather than to whoever happens to be answering: the first reported zone in member
|
||||
* id order, which is the same ordering that decides user1/user2, falling back to UTC when
|
||||
* neither partner has reported one. Without this a couple in a western zone loses the
|
||||
* streak at UTC midnight while it is still the same evening for them.
|
||||
*/
|
||||
private async coupleZone(client:Pool|PoolClient,reported:(string|null)[]):Promise<string>{
|
||||
const zone=reported.find(value=>value);
|
||||
return zone&&(await this.timezoneNames(client)).has(zone)?zone:'UTC';
|
||||
}
|
||||
|
||||
private async streakDay(client:Pool|PoolClient,zone:string){
|
||||
const r=await client.query<{today:string;yesterday:string}>("select (now() at time zone $1::text)::date::text today,((now() at time zone $1::text)::date-1)::text yesterday",[zone]);
|
||||
return r.rows[0];
|
||||
}
|
||||
|
||||
private async touchStreak(client:PoolClient,userId:string,coupleId:string){
|
||||
const users=await client.query<{id:string}>('select id from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return;
|
||||
const users=await client.query<{id:string;timezone:string|null}>('select id,timezone from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return;
|
||||
const user1=users.rows[0].id===userId;
|
||||
await client.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[coupleId]);
|
||||
const locked=await client.query<{current_streak:number;longest_streak:number;last_active_date:string|null;last_completed_date:string|null;user1_answered_today:boolean;user2_answered_today:boolean}>(`select current_streak,longest_streak,last_active_date::text,last_completed_date::text,user1_answered_today,user2_answered_today from couple_streaks where couple_id=$1 for update`,[coupleId]);
|
||||
const row=locked.rows[0]; const todayResult=await client.query<{today:string;yesterday:string}>('select current_date::text today,(current_date-1)::text yesterday'); const {today,yesterday}=todayResult.rows[0];
|
||||
const row=locked.rows[0]; const {today,yesterday}=await this.streakDay(client,await this.coupleZone(client,users.rows.map(member=>member.timezone)));
|
||||
let first=row.last_active_date===today?row.user1_answered_today:false; let second=row.last_active_date===today?row.user2_answered_today:false;
|
||||
if(user1)first=true;else second=true;
|
||||
let current=row.last_completed_date && row.last_completed_date!==yesterday && row.last_completed_date!==today?0:row.current_streak; let completed=row.last_completed_date;
|
||||
@@ -190,13 +239,61 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
||||
}
|
||||
async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);}
|
||||
async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);}
|
||||
async streak(userId:string){const ctx=await this.context(this.pool,userId);const r=await this.pool.query('select * from couple_streaks where couple_id=$1',[ctx.couple_id]);return r.rows[0]?this.dates(r.rows[0]):null;}
|
||||
async streak(userId:string):Promise<CoupleStreakView|null>{
|
||||
const ctx=await this.context(this.pool,userId);
|
||||
const r=await this.pool.query<StreakRow>(`select couple_id,current_streak,longest_streak,last_active_date::text last_active_date,last_completed_date::text last_completed_date,
|
||||
user1_answered_today,user2_answered_today,streak_celebrated_at,created_at,updated_at from couple_streaks where couple_id=$1`,[ctx.couple_id]);
|
||||
const row=r.rows[0]; if(!row)return null;
|
||||
const members=await this.pool.query<{id:string;name:string|null;timezone:string|null}>('select id,name,timezone from profiles where couple_id=$1 order by id',[ctx.couple_id]);
|
||||
if(members.rows.length<2)return null;
|
||||
const zone=await this.coupleZone(this.pool,members.rows.map(member=>member.timezone));
|
||||
const {today,yesterday}=await this.streakDay(this.pool,zone);
|
||||
// The row is only rewritten when somebody answers, so a lapsed streak keeps its stored
|
||||
// number until the next response arrives. Reading applies the same day rules the writer
|
||||
// does rather than showing a count that the next answer will silently reset to 1.
|
||||
const active=row.last_active_date===today;
|
||||
const first=active&&row.user1_answered_today; const second=active&&row.user2_answered_today;
|
||||
const alive=row.last_completed_date===today||row.last_completed_date===yesterday;
|
||||
const isUser1=members.rows[0].id===userId; const partner=members.rows[isUser1?1:0];
|
||||
return {
|
||||
couple_id:row.couple_id,
|
||||
current_streak:alive?row.current_streak:0,
|
||||
longest_streak:row.longest_streak,
|
||||
last_active_date:row.last_active_date,
|
||||
last_completed_date:row.last_completed_date,
|
||||
you_answered_today:isUser1?first:second,
|
||||
partner_answered_today:isUser1?second:first,
|
||||
partner_name:partner?.name??null,
|
||||
timezone:zone,
|
||||
streak_celebrated_at:row.streak_celebrated_at,
|
||||
created_at:row.created_at.toISOString(),
|
||||
updated_at:row.updated_at.toISOString(),
|
||||
};
|
||||
}
|
||||
private async question(client:PoolClient,id:string,lock=false){const r=await client.query<QuestionRow>(`select * from questions where id=$1 and deleted_at is null${lock?' for update':''}`,[id]);if(!r.rows[0])throw new AnswersError('question_not_found',404,'Question was not found');return r.rows[0];}
|
||||
private timezoneNames(client:Pool|PoolClient){
|
||||
// pg_timezone_names is a ~600-row function scan costing ~12ms. The list is static for the
|
||||
// life of the process, so load it once instead of on every daily-limit check.
|
||||
this.zones??=client.query<{name:string}>('select name from pg_timezone_names')
|
||||
.then(r=>new Set(r.rows.map(row=>row.name)))
|
||||
.catch(cause=>{this.zones=null;throw cause;});
|
||||
return this.zones;
|
||||
}
|
||||
private async lockedContext(client:PoolClient,userId:string){await client.query('select id from profiles where id=$1 for update',[userId]);return this.context(client,userId);}
|
||||
private async dailyLimitWith(client:Pool|PoolClient,userId:string){
|
||||
const cfg=await this.config(client);if(cfg.daily_response_limit===0)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
|
||||
const premium=await client.query<{premium:boolean}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium',[userId]);if(premium.rows[0]?.premium)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
|
||||
const r=await client.query<{count:number;reset_at:string}>("select count(*)::int count,((date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')+interval '1 day') reset_at from responses where user_id=$1 and created_at>=(date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')",[userId]);const count=r.rows[0]?.count??0;return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:new Date(r.rows[0].reset_at).toISOString(),is_blocked:count>=cfg.daily_response_limit};
|
||||
const cfg=await this.config(client);const unlimited={responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
|
||||
if(cfg.daily_response_limit===0)return unlimited;
|
||||
// Premium is shared across a couple: either partner's subscription lifts the cap for both.
|
||||
const profile=await client.query<{premium:boolean;zone:string|null}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium,p.timezone zone from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium,p.timezone',[userId]);
|
||||
if(profile.rows[0]?.premium)return unlimited;
|
||||
// The window is the user's local calendar day; an unreported or unrecognised zone falls
|
||||
// back to UTC. Validating in JS keeps an unknown zone from erroring the query without
|
||||
// paying for a pg_timezone_names scan inside submit()'s locked transaction.
|
||||
const reported=profile.rows[0]?.zone;
|
||||
const zone=reported&&(await this.timezoneNames(client)).has(reported)?reported:'UTC';
|
||||
const r=await client.query<{count:number;reset_at:string}>(`select count(*)::int count,((date_trunc('day',now() at time zone $2::text)+interval '1 day') at time zone $2::text) reset_at from responses r where r.user_id=$1 and r.created_at>=(date_trunc('day',now() at time zone $2::text) at time zone $2::text) and not exists(${catchUpExemption})`,[userId,zone]);
|
||||
const count=r.rows[0]?.count??0;
|
||||
return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:r.rows[0]?new Date(r.rows[0].reset_at).toISOString():null,is_blocked:count>=cfg.daily_response_limit};
|
||||
}
|
||||
private async assertEligible(client:PoolClient,userId:string,ctx:ContextRow,q:QuestionRow){
|
||||
const partner=await client.query<ContextRow&{id:string}>('select id,couple_id,gender,max_intensity,is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1',[ctx.couple_id,userId]);const p=partner.rows[0];
|
||||
|
||||
Reference in new issue
Block a user