mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
1 parent
01bb8225ae
commit
006761d3b0
294 files changed
+16259
-6138
No files matched your search
@@ -0,0 +1,211 @@
|
||||
import { Pool, type PoolClient } from 'pg';
|
||||
import { AnswersError, calculateMatchType, type Answer, type MatchType, type QuestionType, type ResponseData } from './types.js';
|
||||
|
||||
interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean }
|
||||
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 }
|
||||
|
||||
export interface SubmitInput { questionId: string; answer: Answer; responseData?: ResponseData }
|
||||
export interface UpdateInput extends SubmitInput { confirmDeleteMatch: boolean }
|
||||
|
||||
export interface AnswersRepository {
|
||||
recommended(userId: string, packId?: string): Promise<QuestionRow[]>;
|
||||
pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string): Promise<Array<{ id: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } }; partnerAnsweredAt: string }>>;
|
||||
answerGap(userId: string): Promise<{ unanswered_by_partner: number; threshold: number; is_blocked: boolean }>;
|
||||
dailyLimit(userId: string): Promise<{ responses_today: number; limit_value: number; remaining: number; reset_at: string | null; is_blocked: boolean }>;
|
||||
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
|
||||
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
|
||||
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
|
||||
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
|
||||
markSeen(userId: string, ids: string[]): Promise<void>;
|
||||
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
|
||||
streak(userId: string): Promise<Record<string, unknown> | null>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export class PostgresAnswersRepository implements AnswersRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
|
||||
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
|
||||
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
|
||||
const row = result.rows[0];
|
||||
if (!row?.couple_id) throw new AnswersError('no_couple', 409, 'Join a couple before answering questions');
|
||||
return row;
|
||||
}
|
||||
|
||||
async recommended(userId: string, packId?: string): Promise<QuestionRow[]> {
|
||||
const ctx = await this.context(this.pool, userId);
|
||||
const partner = await this.pool.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];
|
||||
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 [];
|
||||
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>(`
|
||||
with configured as (select exists(select 1 from couple_packs where couple_id=$2) value),
|
||||
active as (
|
||||
select $3::uuid pack_id where $3::uuid is not null
|
||||
union all select cp.pack_id from couple_packs cp, configured c where $3::uuid is null and cp.couple_id=$2 and cp.enabled and c.value
|
||||
union all select qp.id from question_packs qp, configured c where $3::uuid is null and qp.is_public and not c.value
|
||||
)
|
||||
select q.id,q.pack_id,
|
||||
case when q.partner_text is not null and pr.id is not null then q.partner_text else q.text end text,
|
||||
q.partner_text,q.intensity,q.allowed_couple_genders,q.target_user_genders,q.question_type,q.config,q.created_at
|
||||
from questions q join active a on a.pack_id=q.pack_id join question_packs qp on qp.id=q.pack_id
|
||||
left join responses pr on pr.question_id=q.id and pr.couple_id=$2 and pr.user_id<>$1
|
||||
where q.deleted_at is null and not exists(select 1 from responses r where r.user_id=$1 and r.question_id=q.id)
|
||||
and (not qp.is_premium or $4)
|
||||
and (q.allowed_couple_genders is null or $5::text is null or $5=any(q.allowed_couple_genders))
|
||||
and (q.target_user_genders is null or $6=any(q.target_user_genders) or pr.id is not null)
|
||||
and q.intensity <= $7
|
||||
order by (pr.id is not null) desc,q.intensity,q.id`,
|
||||
[userId, ctx.couple_id, packId ?? null, ctx.is_premium || !!p?.is_premium, genders, ctx.gender, maxIntensity]);
|
||||
return result.rows.map(this.dates);
|
||||
}
|
||||
|
||||
async pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string) {
|
||||
const ctx = await this.context(this.pool, userId);
|
||||
const result = await this.pool.query<{ id: string; created_at: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } } }>(`
|
||||
select r.id,r.created_at,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,
|
||||
'intensity',q.intensity,'allowed_couple_genders',q.allowed_couple_genders,'target_user_genders',q.target_user_genders,
|
||||
'question_type',q.question_type,'config',q.config,'created_at',q.created_at,
|
||||
'pack',json_build_object('id',qp.id,'name',qp.name,'icon',qp.icon)) question
|
||||
from responses r join questions q on q.id=r.question_id join question_packs qp on qp.id=q.pack_id
|
||||
where r.couple_id=$2 and r.user_id ${direction === 'partner' ? '<>' : '='} $1 and q.deleted_at is null
|
||||
and not exists(select 1 from responses other where other.question_id=r.question_id and other.user_id ${direction === 'partner' ? '=' : '<>'} $1 and other.couple_id=$2)
|
||||
order by case when r.question_id=$3 then 0 else 1 end,r.created_at ${direction === 'partner' ? 'asc' : 'desc'}`,
|
||||
[userId, ctx.couple_id, startQuestionId ?? null]);
|
||||
return result.rows.map(row => ({ id: row.id, question: row.question, partnerAnsweredAt: new Date(row.created_at).toISOString() }));
|
||||
}
|
||||
|
||||
private async config(client: Pool | PoolClient = this.pool) {
|
||||
const result = await client.query<{ answer_gap_threshold: number; daily_response_limit: number; couple_intensity_gate_enabled: boolean }>('select answer_gap_threshold,daily_response_limit,couple_intensity_gate_enabled from app_config limit 1');
|
||||
return result.rows[0] ?? { answer_gap_threshold: 10, daily_response_limit: 0, couple_intensity_gate_enabled: false };
|
||||
}
|
||||
|
||||
async answerGap(userId: string) {
|
||||
let ctx: ContextRow; try { ctx = await this.context(this.pool, userId); } catch { return { unanswered_by_partner: 0, threshold: 0, is_blocked: false }; }
|
||||
const cfg = await this.config();
|
||||
const partner = await this.pool.query<{ id: string; max_intensity: number }>('select id,max_intensity from profiles where couple_id=$1 and id<>$2 order by id limit 1', [ctx.couple_id, userId]);
|
||||
if (!partner.rows[0] || cfg.answer_gap_threshold === 0) return { unanswered_by_partner: 0, threshold: cfg.answer_gap_threshold, is_blocked: false };
|
||||
const gate = cfg.couple_intensity_gate_enabled ? Math.min(ctx.max_intensity, partner.rows[0].max_intensity) : 5;
|
||||
const counts = await this.pool.query<{ mine: number; theirs: number }>(`
|
||||
with enabled as(select pack_id from couple_packs where couple_id=$2 and enabled), eligible as(
|
||||
select r.* from responses r join questions q on q.id=r.question_id
|
||||
where r.couple_id=$2 and exists(select 1 from enabled) and q.pack_id in(select pack_id from enabled) and q.intensity <= $4)
|
||||
select count(*) filter(where e.user_id=$1 and not exists(select 1 from eligible x where x.question_id=e.question_id and x.user_id=$3))::int mine,
|
||||
count(*) filter(where e.user_id=$3 and not exists(select 1 from eligible x where x.question_id=e.question_id and x.user_id=$1))::int theirs from eligible e`,
|
||||
[userId, ctx.couple_id, partner.rows[0].id, gate]);
|
||||
const net = Math.max(0, (counts.rows[0]?.mine ?? 0) - (counts.rows[0]?.theirs ?? 0));
|
||||
return { unanswered_by_partner: net, threshold: cfg.answer_gap_threshold, is_blocked: net >= cfg.answer_gap_threshold };
|
||||
}
|
||||
|
||||
async dailyLimit(userId: string) {
|
||||
return this.dailyLimitWith(this.pool,userId);
|
||||
}
|
||||
|
||||
async submit(userId: string, input: SubmitInput) {
|
||||
return this.tx(async client => {
|
||||
// Profile lock serializes a user's daily-limit count across different questions.
|
||||
const ctx = await this.lockedContext(client, userId);
|
||||
// Question lock serializes both partners so the second transaction observes
|
||||
// the first committed response and creates exactly one match.
|
||||
const q = await this.question(client, input.questionId, true);
|
||||
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 response = await client.query<ResponseRow>(`insert into responses(id,user_id,question_id,couple_id,answer,response_data) values(gen_random_uuid(),$1,$2,$3,$4,$5)
|
||||
on conflict(user_id,question_id) do update set answer=excluded.answer,response_data=excluded.response_data returning *`,[userId,input.questionId,ctx.couple_id,input.answer,input.responseData ?? null]);
|
||||
const match = await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,input.responseData ?? null,true);
|
||||
await this.touchStreak(client,userId,ctx.couple_id!);
|
||||
return { response: this.dates(response.rows[0]), match: match ? { ...this.dates(match), question: this.dates(q) } : null };
|
||||
});
|
||||
}
|
||||
|
||||
async update(userId: string, input: UpdateInput) {
|
||||
return this.tx(async client => {
|
||||
const ctx = await this.lockedContext(client,userId); const q=await this.question(client,input.questionId,true);
|
||||
const current=await client.query<ResponseRow>('select * from responses where user_id=$1 and question_id=$2 for update',[userId,input.questionId]);
|
||||
if (!current.rows[0]) throw new AnswersError('response_not_found',404,'No existing response found for this question');
|
||||
if (current.rows[0].answer===input.answer && typeof input.responseData==='undefined') return { success:true,message:'Answer unchanged' };
|
||||
const existing=await client.query<MatchRow>('select * from matches where couple_id=$1 and question_id=$2 for update',[ctx.couple_id,input.questionId]);
|
||||
if (input.answer==='no' && existing.rows[0] && !input.confirmDeleteMatch) {
|
||||
const count=await client.query<{ count:number }>('select count(*)::int count from messages where match_id=$1',[existing.rows[0].id]);
|
||||
return { success:false,requires_confirmation:true,match_id:existing.rows[0].id,message_count:count.rows[0]?.count ?? 0 };
|
||||
}
|
||||
const normalized=q.question_type!=='swipe' && input.answer!=='no' ? (typeof input.responseData==='undefined' ? current.rows[0].response_data : input.responseData) : null;
|
||||
const before=existing.rows[0]; const match=await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,normalized,false);
|
||||
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!);
|
||||
return { success:true, ...(before&&!match?{match_deleted:true}:{ }), ...(!before&&match?{new_match:this.dates(match)}:{}), ...(before&&match?{match_type_updated:true}:{}) };
|
||||
});
|
||||
}
|
||||
|
||||
private async reconcileMatch(client:PoolClient,userId:string,coupleId:string,q:QuestionRow,answer:Answer,data:ResponseData,isNew:boolean) {
|
||||
const partner=await client.query<ResponseRow>('select * from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[coupleId,q.id,userId]);
|
||||
if(!partner.rows[0]) return null;
|
||||
const type=calculateMatchType(q,answer,partner.rows[0].answer);
|
||||
if(!type){ await client.query('delete from matches where couple_id=$1 and question_id=$2',[coupleId,q.id]); return null; }
|
||||
const summary=type==='both_answered'?{[userId]:data,[partner.rows[0].user_id]:partner.rows[0].response_data}:null;
|
||||
const result=await client.query<MatchRow>(`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=case when $5 then true else matches.is_new end returning *`,[coupleId,q.id,type,summary,isNew]);
|
||||
return result.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 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];
|
||||
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;
|
||||
if(first&&second&&completed!==today){current=completed===yesterday?current+1:1;completed=today;}
|
||||
await client.query(`update couple_streaks set current_streak=$2,longest_streak=greatest(longest_streak,$2),last_active_date=$3,last_completed_date=$4,user1_answered_today=$5,user2_answered_today=$6,updated_at=now() where couple_id=$1`,[coupleId,current,today,completed,first,second]);
|
||||
}
|
||||
|
||||
async responses(userId:string,page:number,limit:number){
|
||||
const ctx=await this.context(this.pool,userId); const offset=page*limit;
|
||||
const [rows,count]=await Promise.all([
|
||||
this.pool.query(`select r.id,r.question_id,r.answer,r.response_data,r.created_at,
|
||||
json_build_object('id',q.id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'pack_id',q.pack_id,'question_type',q.question_type,'config',q.config,'created_at',q.created_at,'pack',json_build_object('id',qp.id,'name',qp.name,'icon',qp.icon)) question,
|
||||
m.id is not null has_match,m.id match_id,pr.id is not null partner_answered
|
||||
from responses r join questions q on q.id=r.question_id join question_packs qp on qp.id=q.pack_id left join matches m on m.couple_id=r.couple_id and m.question_id=r.question_id left join responses pr on pr.couple_id=r.couple_id and pr.question_id=r.question_id and pr.user_id<>$1 where r.user_id=$1 and r.couple_id=$2 order by r.created_at desc limit $3 offset $4`,[userId,ctx.couple_id,limit,offset]),
|
||||
this.pool.query<{count:number}>('select count(*)::int count from responses where user_id=$1 and couple_id=$2',[userId,ctx.couple_id])]);
|
||||
return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
||||
}
|
||||
|
||||
async matches(userId:string,page:number,limit:number,archived:boolean){
|
||||
const ctx=await this.context(this.pool,userId); const offset=page*limit;
|
||||
const params=[userId,ctx.couple_id,archived,limit,offset];
|
||||
const base=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1 where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
|
||||
const [rows,count]=await Promise.all([this.pool.query(`select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,(select count(*)::int from messages msg where msg.match_id=m.id and msg.user_id<>$1 and msg.read_at is null) "unreadCount" ${base} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),this.pool.query<{count:number}>(`select count(*)::int count ${base}`,params.slice(0,3))]);
|
||||
return {matches:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
||||
}
|
||||
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;}
|
||||
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 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};
|
||||
}
|
||||
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];
|
||||
const cfg=await this.config(client);const max=cfg.couple_intensity_gate_enabled&&p?Math.min(ctx.max_intensity,p.max_intensity):ctx.max_intensity;const genders=ctx.gender&&p?.gender?[ctx.gender,p.gender].sort().join('+'):null;
|
||||
const allowed=!q.allowed_couple_genders||!genders||q.allowed_couple_genders.includes(genders);const targeted=!q.target_user_genders||!!ctx.gender&&q.target_user_genders.includes(ctx.gender)||!!(await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3',[ctx.couple_id,q.id,userId])).rowCount;
|
||||
const pack=await client.query<{is_public:boolean;is_premium:boolean;enabled:boolean|null;configured:boolean}>(`select qp.is_public,qp.is_premium,cp.enabled,exists(select 1 from couple_packs where couple_id=$2) configured from question_packs qp left join couple_packs cp on cp.pack_id=qp.id and cp.couple_id=$2 where qp.id=$1`,[q.pack_id,ctx.couple_id]);const pr=pack.rows[0];const active=!!pr&&(pr.configured?pr.enabled===true:pr.is_public);const premium=!pr?.is_premium||ctx.is_premium||!!p?.is_premium;
|
||||
if(!active||!premium||q.intensity>max||!allowed||!targeted)throw new AnswersError('question_not_eligible',404,'Question was not found');
|
||||
}
|
||||
private async tx<T>(fn:(client:PoolClient)=>Promise<T>):Promise<T>{const c=await this.pool.connect();try{await c.query('begin');const value=await fn(c);await c.query('commit');return value;}catch(e){await c.query('rollback');throw e;}finally{c.release();}}
|
||||
private dates<T extends Record<string,any>>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;}
|
||||
async close(){await this.pool.end();}
|
||||
}
|
||||
Reference in new issue
Block a user