mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
perf(app): reduce redundant data fetching
Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware. Add focused regression coverage, client freshness documentation, and repository agent role configuration.
This commit is contained in:
1 parent
9957fa43f2
commit
c7ee8a4851
81 files changed
+3488
-432
No files matched your search
@@ -0,0 +1,11 @@
|
||||
name = "architecture_reviewer"
|
||||
description = "Read-only reviewer for cross-cutting or hard-to-reverse Sauci architecture decisions."
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Evaluate the delegated decision against current system boundaries, data flow,
|
||||
migration path, operability, reversibility, and verification. Prefer the smallest
|
||||
coherent change. Return explicit trade-offs, risks, and a recommendation with
|
||||
file-backed evidence. Do not edit files or review unrelated local refactors.
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
name = "database_reviewer"
|
||||
description = "Read-only reviewer for Sauci migrations, RLS, authorization, and query boundaries."
|
||||
model = "gpt-5.6-terra"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Read the applicable AGENTS.md, database migration skill, and migration safety
|
||||
documentation. Review only the delegated diff for migration tracking,
|
||||
idempotency, authorization and tenant isolation, portability, rollback impact,
|
||||
types, and integration coverage. Never mutate a database or edit files. Return
|
||||
prioritized line-specific findings with concrete evidence and counterevidence.
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
name = "implementation"
|
||||
description = "Default executor for a bounded Sauci product or harness change after scope and acceptance criteria are known."
|
||||
model = "gpt-5.6-terra"
|
||||
developer_instructions = """
|
||||
Read the root and nearest scoped AGENTS.md files plus the applicable repository
|
||||
skill. Implement only the delegated objective and preserve unrelated work. Update
|
||||
maintained contract documentation and add focused tests when the contract changes.
|
||||
Run the required focused checks and return changed files, exact results,
|
||||
assumptions, and blockers. Stop for missing authority or a materially ambiguous
|
||||
product choice. Do not expand scope or perform external, destructive, production,
|
||||
release, or provider writes unless the task explicitly authorizes that action.
|
||||
"""
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "repo_scout"
|
||||
description = "Read-only Sauci codebase scout for locating ownership, callers, contracts, tests, and existing patterns."
|
||||
model = "gpt-5.6-luna"
|
||||
model_reasoning_effort = "low"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Read the root and nearest scoped AGENTS.md files. Map only the requested surface.
|
||||
Return evidence-backed file paths, symbols, current behavior, dependencies, tests,
|
||||
and uncertainties. Do not edit files, make design decisions, or propose broad
|
||||
refactors. Do not claim runtime behavior you did not observe.
|
||||
"""
|
||||
@@ -0,0 +1,12 @@
|
||||
name = "security_reviewer"
|
||||
description = "Read-only reviewer for auth, RLS, cryptography, secrets, payments, privileged MCP, and destructive operations."
|
||||
model = "gpt-5.6-sol"
|
||||
model_reasoning_effort = "high"
|
||||
sandbox_mode = "read-only"
|
||||
developer_instructions = """
|
||||
Perform a scoped threat-boundary review of the delegated change. Trace callers
|
||||
through authentication and authorization to the data sink. Prioritize exploitable
|
||||
correctness and security risks, include counterevidence, and cite exact files and
|
||||
lines. Do not edit files or expand into a repository-wide audit unless explicitly
|
||||
requested.
|
||||
"""
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "test_writer"
|
||||
description = "Focused Sauci regression and contract-test author for already-defined behavior."
|
||||
model = "gpt-5.6-terra"
|
||||
model_reasoning_effort = "medium"
|
||||
developer_instructions = """
|
||||
Read the applicable AGENTS.md and testing documentation. Test the intended
|
||||
observable contract at the lowest useful layer while preserving real auth,
|
||||
authorization, tenant, and couple boundaries. Do not weaken assertions to
|
||||
accommodate a failure. Run the focused test and return files changed, exact
|
||||
commands, results, and any product defect exposed by the test.
|
||||
"""
|
||||
@@ -0,0 +1,11 @@
|
||||
name = "verifier"
|
||||
description = "Independent Sauci behavior verifier that drives completed work and returns evidence, not fixes."
|
||||
model = "gpt-5.6-terra"
|
||||
model_reasoning_effort = "medium"
|
||||
developer_instructions = """
|
||||
Read the applicable AGENTS.md and .codex/skills/verify/SKILL.md. Independently
|
||||
exercise the delegated acceptance criteria against the running non-production
|
||||
product. You may write generated proof only under evidence/. Do not edit source,
|
||||
tests, configuration, or acceptance criteria. Return expected behavior, observed
|
||||
behavior, evidence paths, environment limitations, and a works or broken verdict.
|
||||
"""
|
||||
@@ -0,0 +1,5 @@
|
||||
[agents]
|
||||
enabled = true
|
||||
max_concurrent_threads_per_session = 3
|
||||
default_subagent_model = "gpt-5.6-terra"
|
||||
default_subagent_reasoning_effort = "medium"
|
||||
@@ -17,7 +17,10 @@ MCP service in one npm/Turborepo workspace.
|
||||
5. Read `apps/mobile/DESIGN.md` before mobile UI work.
|
||||
6. Keep shared package exports type-only and route them through `src/index.ts`.
|
||||
7. Do not commit secrets, generated evidence, local logs, or runtime state.
|
||||
8. Scope work to the task. Use high-effort reviewers only for the risk triggers in
|
||||
8. Scope work to the task. Route bounded implementation through the project
|
||||
`implementation` agent when delegation is useful; the primary agent retains
|
||||
acceptance, review, and final verification. Do not delegate trivial edits.
|
||||
9. Use high-effort reviewers only for the risk triggers in
|
||||
`docs/agents/routing.md`.
|
||||
|
||||
## Repository map
|
||||
|
||||
@@ -3,6 +3,14 @@ DATABASE_URL=postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci
|
||||
SUPABASE_AUTH_URL=https://your-project.supabase.co
|
||||
SUPABASE_AUTH_AUDIENCE=authenticated
|
||||
|
||||
# Optional connection-pool bounds. The API process opens one pool and shares it
|
||||
# across every repository, so these are the process-wide limits, not per-domain
|
||||
# ones. Raising DATABASE_POOL_MAX raises the server connections this instance can
|
||||
# hold; keep the total across instances inside the database's own limit.
|
||||
# DATABASE_POOL_MAX=10
|
||||
# DATABASE_POOL_IDLE_TIMEOUT_MS=30000
|
||||
# DATABASE_POOL_CONNECTION_TIMEOUT_MS=10000
|
||||
|
||||
# Server-only hosted Auth Admin credential. Used for account deletion and for
|
||||
# enriching admin user lists; it is never exposed to the browser or used as a
|
||||
# Supabase database client.
|
||||
|
||||
@@ -7,6 +7,9 @@ const baseSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
|
||||
DATABASE_URL: z.string().url(),
|
||||
DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(200).default(10),
|
||||
DATABASE_POOL_IDLE_TIMEOUT_MS: z.coerce.number().int().min(0).max(3_600_000).default(30_000),
|
||||
DATABASE_POOL_CONNECTION_TIMEOUT_MS: z.coerce.number().int().min(0).max(60_000).default(10_000),
|
||||
SUPABASE_AUTH_URL: z.string().url().optional(),
|
||||
SUPABASE_AUTH_ISSUER: z.string().url().optional(),
|
||||
SUPABASE_AUTH_JWKS_URL: z.string().url().optional(),
|
||||
@@ -28,6 +31,7 @@ export interface AppConfig {
|
||||
nodeEnv: 'development' | 'test' | 'production';
|
||||
port: number;
|
||||
databaseUrl: string;
|
||||
databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number };
|
||||
authIssuer: string;
|
||||
authJwksUrl?: string;
|
||||
authAudience: string;
|
||||
@@ -81,6 +85,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
nodeEnv: parsed.NODE_ENV,
|
||||
port: parsed.PORT,
|
||||
databaseUrl: parsed.DATABASE_URL,
|
||||
databasePool: {
|
||||
max: parsed.DATABASE_POOL_MAX,
|
||||
idleTimeoutMillis: parsed.DATABASE_POOL_IDLE_TIMEOUT_MS,
|
||||
connectionTimeoutMillis: parsed.DATABASE_POOL_CONNECTION_TIMEOUT_MS,
|
||||
},
|
||||
authIssuer,
|
||||
authJwksUrl,
|
||||
authAudience: parsed.SUPABASE_AUTH_AUDIENCE,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Pool } from 'pg';
|
||||
|
||||
/**
|
||||
* A repository is constructed either from a connection string, in which case it
|
||||
* owns the pool it creates and must end it, or from a pool the caller owns. The
|
||||
* API process takes the second path so every domain shares one bounded pool
|
||||
* instead of opening a default-sized pool per repository; tests keep taking the
|
||||
* first so each suite stays independently closable.
|
||||
*/
|
||||
export type DatabaseConnection = string | Pool;
|
||||
|
||||
export interface DatabasePoolOptions {
|
||||
/** Server-wide ceiling on concurrent PostgreSQL connections. */
|
||||
max?: number;
|
||||
idleTimeoutMillis?: number;
|
||||
connectionTimeoutMillis?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `pg` defaults to ten connections per pool, which multiplied by one pool per
|
||||
* repository. One shared pool of the same size keeps the same headroom for a
|
||||
* single API process while bounding what a self-hosted PostgreSQL must accept.
|
||||
*/
|
||||
export const DEFAULT_POOL_MAX = 10;
|
||||
|
||||
export function createDatabasePool(databaseUrl: string, options: DatabasePoolOptions = {}): Pool {
|
||||
return new Pool({
|
||||
connectionString: databaseUrl,
|
||||
max: options.max ?? DEFAULT_POOL_MAX,
|
||||
...(options.idleTimeoutMillis === undefined ? {} : { idleTimeoutMillis: options.idleTimeoutMillis }),
|
||||
...(options.connectionTimeoutMillis === undefined ? {} : { connectionTimeoutMillis: options.connectionTimeoutMillis }),
|
||||
});
|
||||
}
|
||||
|
||||
export interface ResolvedPool {
|
||||
pool: Pool;
|
||||
/** True when this repository created the pool and is responsible for ending it. */
|
||||
owned: boolean;
|
||||
}
|
||||
|
||||
export function resolvePool(connection: DatabaseConnection): ResolvedPool {
|
||||
return typeof connection === 'string'
|
||||
? { pool: new Pool({ connectionString: connection }), owned: true }
|
||||
: { pool: connection, owned: false };
|
||||
}
|
||||
|
||||
/** Ends the pool only when this repository created it, so shared pools survive. */
|
||||
export async function closeResolvedPool(pool: Pool, owned: boolean): Promise<void> {
|
||||
if (owned) await pool.end();
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { FeatureInterestResponse, Profile } from '@sauci/shared';
|
||||
import { and, eq, sql } from 'drizzle-orm';
|
||||
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
|
||||
import { Pool } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from './pool.js';
|
||||
import type { AuthIdentity } from '../auth.js';
|
||||
import { featureInterests, profiles, type ProfileRow } from './schema.js';
|
||||
|
||||
@@ -44,10 +45,13 @@ function toProfile(row: ProfileRow): MobileCompatibleProfile {
|
||||
|
||||
export class PostgresRepository implements ApiRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
private readonly db: NodePgDatabase;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
this.db = drizzle(this.pool);
|
||||
}
|
||||
|
||||
@@ -55,7 +59,21 @@ export class PostgresRepository implements ApiRepository {
|
||||
await this.db.select({ id: profiles.id }).from(profiles).limit(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent for the common case. `GET /v1/me` runs on every app start and on
|
||||
* every session refresh, and the previous unconditional upsert made each of
|
||||
* those a write that advanced `auth_synced_at` and produced dead row versions
|
||||
* for autovacuum. The only fields this sync can actually change are the ones
|
||||
* the conflict clause sets, so a profile that already carries the identity's
|
||||
* email is returned as read and nothing is written. First-use creation and
|
||||
* email enrichment keep their existing behaviour.
|
||||
*/
|
||||
async upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile> {
|
||||
const [existing] = await this.db.select().from(profiles).where(eq(profiles.id, identity.id)).limit(1);
|
||||
if (existing && (identity.email === null || identity.email === undefined || existing.email === identity.email)) {
|
||||
return toProfile(existing);
|
||||
}
|
||||
|
||||
const [row] = await this.db.insert(profiles).values({
|
||||
id: identity.id,
|
||||
name: identity.name,
|
||||
@@ -93,6 +111,6 @@ export class PostgresRepository implements ApiRepository {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import { AccountOperationError } from './types.js';
|
||||
|
||||
interface ProfileOperationRecord extends QueryResultRow {
|
||||
@@ -63,9 +64,12 @@ async function lockCoupleAndPartner(
|
||||
|
||||
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
|
||||
@@ -135,7 +139,7 @@ export class PostgresAccountOperationsRepository implements AccountOperationsRep
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js';
|
||||
@@ -139,9 +140,10 @@ export interface AdminRepository {
|
||||
|
||||
export class PostgresAdminRepository implements AdminRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string, private readonly options: {
|
||||
private readonly ownsPool: boolean;
|
||||
constructor(connection: DatabaseConnection, private readonly options: {
|
||||
adminPrivateKeyJwk?: string; mediaRoot?: string; authDirectory?: AdminAuthDirectory;
|
||||
} = {}) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
} = {}) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||
|
||||
async principal(userId: string): Promise<AdminPrincipal> {
|
||||
const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>(
|
||||
@@ -356,5 +358,5 @@ export class PostgresAdminRepository implements AdminRepository {
|
||||
await client.query('insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,old_values,new_values) values($1,$2,$3,$4,$5,$6,$7)', [principal.adminId, principal.userId, table, action, recordId || null, redactAudit(oldValues), redactAudit(newValues)]);
|
||||
}
|
||||
|
||||
async close(): Promise<void> { await this.pool.end(); }
|
||||
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type PoolClient } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
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 }
|
||||
@@ -44,7 +45,7 @@ export interface AnswersRepository {
|
||||
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 }>;
|
||||
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number | null; hasMore: boolean }>;
|
||||
markSeen(userId: string, ids: string[]): Promise<void>;
|
||||
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
|
||||
streak(userId: string): Promise<CoupleStreakView | null>;
|
||||
@@ -53,8 +54,9 @@ export interface AnswersRepository {
|
||||
|
||||
export class PostgresAnswersRepository implements AnswersRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
private zones: Promise<Set<string>> | null = null;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||
|
||||
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]);
|
||||
@@ -230,12 +232,23 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
||||
return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reading one row past the page decides `hasMore` exactly, and the total — which
|
||||
* the client only records when it refreshes — is counted on the first page only
|
||||
* instead of on every scroll. Unread is aggregated once per couple rather than as
|
||||
* a correlated count per row: the sort key depends on it, so the old subquery
|
||||
* already ran for every candidate match, not just the twenty that were returned.
|
||||
*/
|
||||
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};
|
||||
const params=[userId,ctx.couple_id,archived,limit+1,offset];
|
||||
const from=`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`;
|
||||
const where=`where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
|
||||
const unread=`unread as (select msg.match_id,count(*)::int c from messages msg join matches um on um.id=msg.match_id and um.couple_id=$2 where msg.user_id<>$1 and msg.read_at is null group by msg.match_id)`;
|
||||
const [rows,count]=await Promise.all([
|
||||
this.pool.query(`with ${unread} 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,coalesce(u.c,0) "unreadCount" ${from} left join unread u on u.match_id=m.id ${where} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),
|
||||
page===0?this.pool.query<{count:number}>(`select count(*)::int count ${from} ${where}`,params.slice(0,3)):null]);
|
||||
return {matches:rows.rows.slice(0,limit).map(this.dates),totalCount:count?count.rows[0]?.count??0:null,hasMore:rows.rows.length>limit};
|
||||
}
|
||||
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]);}
|
||||
@@ -304,5 +317,5 @@ export class PostgresAnswersRepository implements AnswersRepository {
|
||||
}
|
||||
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();}
|
||||
async close(){await closeResolvedPool(this.pool,this.ownsPool);}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
|
||||
export interface PackContext { id: string; name: string; icon: string | null }
|
||||
export interface AppQuestion {
|
||||
@@ -19,6 +20,50 @@ export interface StrokeSegment {
|
||||
}
|
||||
export interface LiveDrawState { strokes: StrokeSegment[]; revision: number; updated_at: string | null; updated_by: string | null }
|
||||
|
||||
/**
|
||||
* A change summary rather than a payload. The mobile app used to re-fetch profile,
|
||||
* couple, matches, both pending directions and enabled packs every five seconds to
|
||||
* notice partner activity; it now compares this summary with the last one it saw and
|
||||
* refreshes only the domains whose marker actually moved. Every field is a marker,
|
||||
* not display data, so the response stays a few hundred bytes whatever the couple's
|
||||
* history looks like.
|
||||
*/
|
||||
export interface SyncSummary {
|
||||
server_time: string;
|
||||
couple_id: string | null;
|
||||
profile_updated_at: string | null;
|
||||
partner_id: string | null;
|
||||
partner_updated_at: string | null;
|
||||
/** Matches visible to this member: archiving one of them moves the count. */
|
||||
match_count: number;
|
||||
new_match_count: number;
|
||||
latest_match_at: string | null;
|
||||
/** Partner answered, this member has not. */
|
||||
pending_yours: number;
|
||||
/** This member answered, partner has not. */
|
||||
pending_theirs: number;
|
||||
unread_total: number;
|
||||
/** Digest of the enabled pack ids, so an equal-and-opposite toggle is still a change. */
|
||||
enabled_packs_fingerprint: string;
|
||||
/**
|
||||
* Digest of each visible match's type and response summary. `match_count` /
|
||||
* `new_match_count` / `latest_match_at` only move when a match is created,
|
||||
* archived or unarchived — not when a partner edits an existing response and
|
||||
* changes its `match_type` or `response_summary` — so this fingerprint is
|
||||
* what actually notices that edit.
|
||||
*/
|
||||
match_state_fingerprint: string;
|
||||
/**
|
||||
* Digest of each visible match's unread count. `unread_total` can stay equal
|
||||
* while unread messages redistribute between matches (one read, another
|
||||
* receives a message), which reorders the unread-first list without moving
|
||||
* the total; this fingerprint is what notices that.
|
||||
*/
|
||||
match_unread_fingerprint: string;
|
||||
/** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */
|
||||
streak_updated_at: string | null;
|
||||
}
|
||||
|
||||
export class AppDataError extends Error {
|
||||
constructor(public readonly code: 'not_found' | 'no_couple' | 'not_recipient' | 'rate_limited' | 'revision_conflict', message: string, public readonly status: 403 | 404 | 409 | 429, public readonly details?: Record<string, unknown>) { super(message); }
|
||||
}
|
||||
@@ -31,6 +76,7 @@ export interface AppDataRepository {
|
||||
markMediaViewed(userId: string, messageId: string, expiresAt: string | null): Promise<{ media_viewed_at: string; media_expires_at: string | null }>;
|
||||
nudgeStatus(userId: string): Promise<{ last_nudge_sent_at: string | null }>;
|
||||
sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>;
|
||||
syncSummary(userId: string): Promise<SyncSummary>;
|
||||
getLiveDraw(userId: string): Promise<LiveDrawState>;
|
||||
putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState>;
|
||||
close(): Promise<void>;
|
||||
@@ -38,11 +84,22 @@ export interface AppDataRepository {
|
||||
|
||||
interface QuestionRow extends QueryResultRow, Omit<AppQuestion, 'created_at'> { created_at: Date }
|
||||
|
||||
interface SyncRow extends QueryResultRow {
|
||||
server_time: Date; couple_id: string | null; profile_updated_at: Date | null;
|
||||
partner_id: string | null; partner_updated_at: Date | null;
|
||||
match_count: number | null; new_match_count: number | null; latest_match_at: Date | null;
|
||||
pending_yours: number | null; pending_theirs: number | null; unread_total: number | null;
|
||||
enabled_packs_fingerprint: string | null;
|
||||
match_state_fingerprint: string | null; match_unread_fingerprint: string | null;
|
||||
streak_updated_at: Date | null;
|
||||
}
|
||||
|
||||
function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; }
|
||||
|
||||
export class PostgresAppDataRepository implements AppDataRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
private readonly ownsPool: boolean;
|
||||
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||
|
||||
private async visiblePack(userId: string, packId: string): Promise<PackContext> {
|
||||
const result = await this.pool.query<PackContext>(`
|
||||
@@ -189,5 +246,75 @@ export class PostgresAppDataRepository implements AppDataRepository {
|
||||
}
|
||||
return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by };
|
||||
}
|
||||
async close(): Promise<void> { await this.pool.end(); }
|
||||
/**
|
||||
* One round trip, all scalar subqueries, nothing that grows with the couple's
|
||||
* history. An unpaired member's couple-scoped markers all collapse to zero or null
|
||||
* because `me.couple_id` is null, so the endpoint stays valid before pairing.
|
||||
*/
|
||||
async syncSummary(userId: string): Promise<SyncSummary> {
|
||||
const result = await this.pool.query<SyncRow>(`
|
||||
with me as (select id, couple_id, updated_at from profiles where id = $1),
|
||||
visible as (
|
||||
select m.id, m.is_new, m.created_at, m.match_type, m.response_summary from matches m, me
|
||||
where m.couple_id = me.couple_id
|
||||
and not exists (select 1 from match_archives a where a.match_id = m.id and a.user_id = me.id)
|
||||
),
|
||||
visible_unread as (
|
||||
select v.id as match_id, count(msg.id)::int as c
|
||||
from visible v
|
||||
left join messages msg on msg.match_id = v.id and msg.user_id <> (select id from me)
|
||||
and msg.read_at is null and msg.deleted_at is null
|
||||
and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = (select id from me))
|
||||
group by v.id
|
||||
)
|
||||
select
|
||||
now() as server_time,
|
||||
me.couple_id,
|
||||
me.updated_at as profile_updated_at,
|
||||
(select p.id from profiles p where p.couple_id = me.couple_id and p.id <> me.id order by p.id limit 1) as partner_id,
|
||||
(select max(p.updated_at) from profiles p where p.couple_id = me.couple_id and p.id <> me.id) as partner_updated_at,
|
||||
(select count(*)::int from visible) as match_count,
|
||||
(select count(*)::int from visible where is_new) as new_match_count,
|
||||
(select max(created_at) from visible) as latest_match_at,
|
||||
(select count(*)::int from responses r join questions q on q.id = r.question_id
|
||||
where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id <> me.id
|
||||
and not exists (select 1 from responses o
|
||||
where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id = me.id)) as pending_yours,
|
||||
(select count(*)::int from responses r join questions q on q.id = r.question_id
|
||||
where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id = me.id
|
||||
and not exists (select 1 from responses o
|
||||
where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id <> me.id)) as pending_theirs,
|
||||
(select count(*)::int from messages msg join matches ma on ma.id = msg.match_id
|
||||
where ma.couple_id = me.couple_id and msg.user_id <> me.id and msg.read_at is null and msg.deleted_at is null
|
||||
and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = me.id)) as unread_total,
|
||||
(select md5(coalesce(string_agg(cp.pack_id::text, ',' order by cp.pack_id), ''))
|
||||
from couple_packs cp where cp.couple_id = me.couple_id and cp.enabled) as enabled_packs_fingerprint,
|
||||
(select md5(coalesce(string_agg(v.id::text || ':' || v.match_type || ':' || coalesce(v.response_summary::text, ''), ',' order by v.id), ''))
|
||||
from visible v) as match_state_fingerprint,
|
||||
(select md5(coalesce(string_agg(vu.match_id::text || ':' || vu.c, ',' order by vu.match_id), ''))
|
||||
from visible_unread vu) as match_unread_fingerprint,
|
||||
(select cs.updated_at from couple_streaks cs where cs.couple_id = me.couple_id) as streak_updated_at
|
||||
from me`, [userId]);
|
||||
const row = result.rows[0];
|
||||
if (!row) throw new AppDataError('not_found', 'Profile not found', 404);
|
||||
return {
|
||||
server_time: row.server_time.toISOString(),
|
||||
couple_id: row.couple_id,
|
||||
profile_updated_at: row.profile_updated_at?.toISOString() ?? null,
|
||||
partner_id: row.partner_id,
|
||||
partner_updated_at: row.partner_updated_at?.toISOString() ?? null,
|
||||
match_count: row.match_count ?? 0,
|
||||
new_match_count: row.new_match_count ?? 0,
|
||||
latest_match_at: row.latest_match_at?.toISOString() ?? null,
|
||||
pending_yours: row.pending_yours ?? 0,
|
||||
pending_theirs: row.pending_theirs ?? 0,
|
||||
unread_total: row.unread_total ?? 0,
|
||||
enabled_packs_fingerprint: row.enabled_packs_fingerprint ?? '',
|
||||
match_state_fingerprint: row.match_state_fingerprint ?? '',
|
||||
match_unread_fingerprint: row.match_unread_fingerprint ?? '',
|
||||
streak_updated_at: row.streak_updated_at?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||
}
|
||||
@@ -44,6 +44,11 @@ export function registerAppDataRoutes(app: App, repository: AppDataRepository):
|
||||
const result = await run(() => repository.markMediaViewed(c.get('identity').id, id.data, body.data.expires_at));
|
||||
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
// The client polls this instead of re-fetching every domain; see SyncSummary.
|
||||
app.get('/v1/me/sync', async c => {
|
||||
const result = await run(() => repository.syncSummary(c.get('identity').id));
|
||||
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
|
||||
});
|
||||
app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id)));
|
||||
app.post('/v1/me/nudge', async c => {
|
||||
const result = await run(() => repository.sendNudge(c.get('identity').id));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js';
|
||||
|
||||
interface ProfileRecord extends QueryResultRow {
|
||||
@@ -43,9 +44,12 @@ async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Pro
|
||||
|
||||
export class PostgresBillingRepository implements BillingRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
async processRevenueCatEvent(
|
||||
@@ -207,6 +211,6 @@ export class PostgresBillingRepository implements BillingRepository {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
|
||||
export type ChatMediaType = 'image' | 'video' | null;
|
||||
export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other';
|
||||
@@ -45,8 +46,37 @@ export class ChatError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reply to a message poll. A request without a cursor is a full snapshot; a
|
||||
* request with one carries only what changed, so a quiet chat costs an empty
|
||||
* array rather than the whole backlog.
|
||||
*/
|
||||
export interface MessagesPage {
|
||||
/** Newest first. Messages created or changed since the cursor, or the snapshot. */
|
||||
messages: ChatMessage[];
|
||||
/** Messages the caller deleted for themselves since the cursor. */
|
||||
removed_ids: string[];
|
||||
/** Cursor to send as `since` on the next poll. */
|
||||
server_time: string;
|
||||
/** True when `messages` is a complete snapshot the client may replace its cache with. */
|
||||
complete: boolean;
|
||||
/** Present only when the caller asked for it, folding the typing poll into this one. */
|
||||
typing?: PartnerTyping;
|
||||
}
|
||||
|
||||
export interface PartnerTyping {
|
||||
typing: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface ListMessagesOptions {
|
||||
/** ISO timestamp from a previous `server_time`. Omit for a full snapshot. */
|
||||
since?: string;
|
||||
includeTyping?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatRepository {
|
||||
listMessages(userId: string, matchId: string): Promise<ChatMessage[]>;
|
||||
listMessages(userId: string, matchId: string, options?: ListMessagesOptions): Promise<MessagesPage>;
|
||||
sendText(userId: string, matchId: string, content: string): Promise<ChatMessage>;
|
||||
unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }>;
|
||||
markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>;
|
||||
@@ -55,13 +85,21 @@ export interface ChatRepository {
|
||||
deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage>;
|
||||
report(userId: string, messageId: string, reason: ReportReason): Promise<void>;
|
||||
setTyping(userId: string, matchId: string, ttlMs: number): Promise<void>;
|
||||
getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }>;
|
||||
getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
// `media_expired` is derived rather than read. Nothing ever writes the stored
|
||||
// column — it is inserted `false` and never updated — so a video that reached
|
||||
// `media_expires_at` still arrived flagged as playable, while the media itself
|
||||
// was already refused (see `accessible` in the media repository, which treats
|
||||
// `media_expired or media_expires_at <= now()` as gone). Deriving it here is the
|
||||
// same rule the media side already applies, and it is what makes the client's
|
||||
// "Video expired" state reachable at all.
|
||||
const columns = `m.id, m.match_id, m.user_id, m.content, m.created_at, m.read_at,
|
||||
m.delivered_at, m.deleted_at, m.media_path, m.media_type, m.media_expires_at,
|
||||
m.media_expired, m.media_viewed_at, m.version, m.encrypted_content,
|
||||
(m.media_expired or (m.media_expires_at is not null and m.media_expires_at <= now())) as media_expired,
|
||||
m.media_viewed_at, m.version, m.encrypted_content,
|
||||
m.encryption_iv, m.keys_metadata, m.moderation_status, m.flag_reason, m.category`;
|
||||
|
||||
function iso(value: Date | null): string | null {
|
||||
@@ -82,9 +120,12 @@ function toMessage(row: MessageRecord): ChatMessage {
|
||||
|
||||
export class PostgresChatRepository implements ChatRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
private async assertMatchAccess(userId: string, matchId: string): Promise<void> {
|
||||
@@ -112,20 +153,68 @@ export class PostgresChatRepository implements ChatRepository {
|
||||
return row;
|
||||
}
|
||||
|
||||
async listMessages(userId: string, matchId: string): Promise<ChatMessage[]> {
|
||||
/**
|
||||
* Without `since` this is the previous behaviour: the newest 500 visible messages.
|
||||
* With `since` it returns only rows whose visible state moved after that instant —
|
||||
* new messages, existing ones whose delivery, read, deletion or media-view
|
||||
* timestamp advanced, and videos whose media expired inside the window — plus the
|
||||
* ids the caller has since deleted for themselves.
|
||||
*
|
||||
* Moderation metadata (`moderation_status`, `flag_reason`, `category`) is not
|
||||
* covered: the classifier and the admin tool rewrite those columns without
|
||||
* advancing any timestamp, so a delta cannot see them move. No client renders
|
||||
* them today, so nothing user-visible goes stale; giving them a cursor would
|
||||
* mean adding a column to `messages`.
|
||||
* Rows are returned whole rather than as patches so the client merges by id and
|
||||
* needs no second shape to interpret.
|
||||
*
|
||||
* The returned cursor is deliberately a couple of seconds behind the server clock:
|
||||
* a transaction that started before this read but committed after it would
|
||||
* otherwise fall into the gap between two polls. The overlap re-delivers a handful
|
||||
* of rows the client already has, which its merge-by-id absorbs.
|
||||
*/
|
||||
async listMessages(userId: string, matchId: string, options: ListMessagesOptions = {}): Promise<MessagesPage> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
const result = await this.pool.query<MessageRecord>(
|
||||
`select ${columns}
|
||||
from messages m
|
||||
where m.match_id = $1
|
||||
and not exists (
|
||||
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2
|
||||
)
|
||||
order by m.created_at desc, m.id desc
|
||||
limit 500`,
|
||||
[matchId, userId],
|
||||
);
|
||||
return result.rows.map(toMessage);
|
||||
const since = options.since ?? null;
|
||||
const visible = `not exists (select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2)`;
|
||||
// Expiry is the one visible change no row write announces: the clock crosses
|
||||
// `media_expires_at` and nothing is updated, so no timestamp moves past the
|
||||
// cursor. Comparing the deadline against the window instead emits the row in
|
||||
// the single poll whose `(since, now()]` contains it, which is exactly when
|
||||
// the derived `media_expired` above flips.
|
||||
const changed = `(m.created_at > $3 or m.read_at > $3 or m.delivered_at > $3
|
||||
or m.deleted_at > $3 or m.media_viewed_at > $3
|
||||
or (m.media_expires_at > $3 and m.media_expires_at <= now()))`;
|
||||
|
||||
const [clock, messages, removed, typing] = await Promise.all([
|
||||
this.pool.query<{ cursor: Date }>(`select now() - interval '2 seconds' as cursor`),
|
||||
this.pool.query<MessageRecord>(
|
||||
`select ${columns}
|
||||
from messages m
|
||||
where m.match_id = $1 and ${visible}${since === null ? '' : ` and ${changed}`}
|
||||
order by m.created_at desc, m.id desc
|
||||
limit 500`,
|
||||
since === null ? [matchId, userId] : [matchId, userId, since],
|
||||
),
|
||||
since === null
|
||||
? null
|
||||
: this.pool.query<{ message_id: string }>(
|
||||
`select d.message_id
|
||||
from message_deletions d
|
||||
join messages m on m.id = d.message_id
|
||||
where m.match_id = $1 and d.user_id = $2 and d.deleted_at > $3`,
|
||||
[matchId, userId, since],
|
||||
),
|
||||
options.includeTyping ? this.partnerTyping(userId, matchId) : null,
|
||||
]);
|
||||
|
||||
return {
|
||||
messages: messages.rows.map(toMessage),
|
||||
removed_ids: removed?.rows.map((row) => row.message_id) ?? [],
|
||||
server_time: clock.rows[0]!.cursor.toISOString(),
|
||||
complete: since === null,
|
||||
...(typing ? { typing } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async sendText(userId: string, matchId: string, content: string): Promise<ChatMessage> {
|
||||
@@ -226,8 +315,8 @@ export class PostgresChatRepository implements ChatRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
/** Access already asserted by the caller, so the message poll can fold this in for free. */
|
||||
private async partnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
|
||||
const result = await this.pool.query<{ expires_at: Date }>(
|
||||
`select expires_at from chat_typing_states
|
||||
where match_id = $1 and user_id <> $2 and expires_at > now()
|
||||
@@ -238,8 +327,13 @@ export class PostgresChatRepository implements ChatRepository {
|
||||
return { typing: Boolean(expiresAt), expires_at: iso(expiresAt) };
|
||||
}
|
||||
|
||||
async getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
return this.partnerTyping(userId, matchId);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
const id = z.string().uuid();
|
||||
const sendBody = z.object({ content: z.string().trim().min(1).max(10_000) }).strict();
|
||||
const reportBody = z.object({ reason: z.enum(['harassment', 'spam', 'inappropriate_content', 'other']) }).strict();
|
||||
const since = z.string().datetime({ offset: true });
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
@@ -25,11 +26,20 @@ async function result<T>(operation: () => Promise<T>) {
|
||||
export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void {
|
||||
app.get('/v1/chat/unread', async (c) => c.json(await repository.unreadCounts(c.get('identity').id)));
|
||||
|
||||
// `since` turns this into a delta poll and `typing=true` folds the typing check
|
||||
// into the same request. Both are optional: a call with neither still answers with
|
||||
// the full snapshot under the `messages` key it always used.
|
||||
app.get('/v1/matches/:matchId/messages', async (c) => {
|
||||
const parsedId = id.safeParse(c.req.param('matchId'));
|
||||
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
|
||||
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data));
|
||||
return response.ok ? c.json({ messages: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
const rawSince = c.req.query('since');
|
||||
const parsedSince = rawSince === undefined ? undefined : since.safeParse(rawSince);
|
||||
if (parsedSince && !parsedSince.success) return c.json(error('invalid_since', 'since must be an ISO-8601 timestamp'), 400);
|
||||
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data, {
|
||||
...(parsedSince?.success ? { since: parsedSince.data } : {}),
|
||||
includeTyping: c.req.query('typing') === 'true',
|
||||
}));
|
||||
return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/matches/:matchId/messages', async (c) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Couple, Profile } from '@sauci/shared';
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import { CoupleError, type CoupleStateResponse } from './types.js';
|
||||
|
||||
interface ProfileRecord extends QueryResultRow {
|
||||
@@ -93,9 +94,12 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
|
||||
|
||||
export class PostgresCoupleRepository implements CoupleRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
async getState(userId: string): Promise<CoupleStateResponse> {
|
||||
@@ -199,6 +203,6 @@ export class PostgresCoupleRepository implements CoupleRepository {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
|
||||
export type DareStatus =
|
||||
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
|
||||
@@ -181,9 +182,12 @@ function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
|
||||
|
||||
export class PostgresDaresRepository implements DaresRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -475,6 +479,6 @@ export class PostgresDaresRepository implements DaresRepository {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import type { MediaKind, MediaObject, MediaUploadContext } from './types.js';
|
||||
|
||||
interface MediaRow extends QueryResultRow {
|
||||
@@ -28,7 +29,8 @@ function object(row: MediaRow): MediaObject {
|
||||
|
||||
export class PostgresMediaRepository implements MediaRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
private readonly ownsPool: boolean;
|
||||
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||
|
||||
async create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext) {
|
||||
const client = await this.pool.connect();
|
||||
@@ -102,5 +104,5 @@ export class PostgresMediaRepository implements MediaRepository {
|
||||
async acknowledgeDeletion(storageKey: string): Promise<void> {
|
||||
await this.pool.query('delete from media_deletion_queue where storage_key=$1',[storageKey]);
|
||||
}
|
||||
async close() { await this.pool.end(); }
|
||||
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import type { ClassifierRuntimeConfig, MessageForClassification, OperationItem, ProducerSummary } from './types.js';
|
||||
|
||||
interface OutboxRow extends QueryResultRow {
|
||||
@@ -31,7 +32,8 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
|
||||
|
||||
export class PostgresOperationsRepository implements OperationsRepository {
|
||||
private readonly pool: Pool;
|
||||
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
|
||||
private readonly ownsPool: boolean;
|
||||
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
|
||||
|
||||
async produce(now: Date, limit=100): Promise<ProducerSummary> {
|
||||
return transaction(this.pool, async (client) => {
|
||||
@@ -312,7 +314,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
|
||||
async classify(id:string,status:'safe'|'flagged',reason:string|null,category:string) {
|
||||
await this.pool.query('update messages set moderation_status=$2,flag_reason=$3,category=$4 where id=$1',[id,status,reason,category]);
|
||||
}
|
||||
async close() { await this.pool.end(); }
|
||||
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
|
||||
}
|
||||
|
||||
function streakCopy(days:number) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Pool } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
|
||||
export interface PackCategory {
|
||||
id: string;
|
||||
@@ -69,9 +70,12 @@ interface ProfilePreferencesRow {
|
||||
|
||||
export class PostgresPacksRepository implements PacksRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
private async profile(userId: string): Promise<ProfilePreferencesRow> {
|
||||
@@ -215,6 +219,6 @@ export class PostgresPacksRepository implements PacksRepository {
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Pool, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
import type {
|
||||
FeedbackSubmission,
|
||||
NotificationPreferenceKey,
|
||||
@@ -80,9 +81,12 @@ function assertChanged(rowCount: number | null): void {
|
||||
|
||||
export class PostgresProfileSettingsRepository implements ProfileSettingsRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
async updateProfile(userId: string, update: ProfileUpdate): Promise<void> {
|
||||
@@ -146,6 +150,6 @@ export class PostgresProfileSettingsRepository implements ProfileSettingsReposit
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
+20
-26
@@ -2,6 +2,7 @@ import { serve } from '@hono/node-server';
|
||||
import { createApp } from './app.js';
|
||||
import { createAuthVerifier } from './auth.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { createDatabasePool } from './db/pool.js';
|
||||
import { PostgresRepository } from './db/repository.js';
|
||||
import { PostgresAppDataRepository } from './domains/app-data/repository.js';
|
||||
import { AdminRequestAuth } from './domains/admin/auth.js';
|
||||
@@ -26,30 +27,34 @@ import { FilesystemMediaStorage, PostgresMediaRepository } from './domains/media
|
||||
|
||||
const config = loadConfig();
|
||||
const authVerifier = createAuthVerifier(config);
|
||||
const repository = new PostgresRepository(config.databaseUrl);
|
||||
const coupleRepository = new PostgresCoupleRepository(config.databaseUrl);
|
||||
const packsRepository = new PostgresPacksRepository(config.databaseUrl);
|
||||
const answersRepository = new PostgresAnswersRepository(config.databaseUrl);
|
||||
const chatRepository = new PostgresChatRepository(config.databaseUrl);
|
||||
const daresRepository = new PostgresDaresRepository(config.databaseUrl);
|
||||
const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl);
|
||||
const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl);
|
||||
// One deliberately bounded pool for the whole process. Repositories used to open
|
||||
// a default-sized pool each, so a single API instance could hold twelve times the
|
||||
// connections a self-hosted PostgreSQL was sized for.
|
||||
const pool = createDatabasePool(config.databaseUrl, config.databasePool);
|
||||
const repository = new PostgresRepository(pool);
|
||||
const coupleRepository = new PostgresCoupleRepository(pool);
|
||||
const packsRepository = new PostgresPacksRepository(pool);
|
||||
const answersRepository = new PostgresAnswersRepository(pool);
|
||||
const chatRepository = new PostgresChatRepository(pool);
|
||||
const daresRepository = new PostgresDaresRepository(pool);
|
||||
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
|
||||
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
|
||||
const accountOperationsService = new AccountOperationsService(
|
||||
accountOperationsRepository,
|
||||
new SupabaseAuthAdminClient(config.authIssuer, config.supabaseAuthServiceRoleKey),
|
||||
new HttpRevenueCatClient(config.revenueCatApiKey, config.revenueCatEntitlementId),
|
||||
new ExpoPartnerNotifier(),
|
||||
);
|
||||
const mediaRepository = new PostgresMediaRepository(config.databaseUrl);
|
||||
const mediaRepository = new PostgresMediaRepository(pool);
|
||||
const mediaStorage = new FilesystemMediaStorage(
|
||||
config.mediaRoot,
|
||||
config.mediaSigningSecret,
|
||||
config.mediaPublicBaseUrl,
|
||||
);
|
||||
const appDataRepository = new PostgresAppDataRepository(config.databaseUrl);
|
||||
const billingRepository = new PostgresBillingRepository(config.databaseUrl);
|
||||
const appDataRepository = new PostgresAppDataRepository(pool);
|
||||
const billingRepository = new PostgresBillingRepository(pool);
|
||||
const billingService = new BillingService(billingRepository, config.revenueCatWebhookSecret);
|
||||
const adminRepository = new PostgresAdminRepository(config.databaseUrl, {
|
||||
const adminRepository = new PostgresAdminRepository(pool, {
|
||||
adminPrivateKeyJwk: config.adminPrivateKeyJwk,
|
||||
mediaRoot: config.mediaRoot,
|
||||
authDirectory: new SupabaseAdminAuthDirectory(config.authIssuer, config.supabaseAuthServiceRoleKey),
|
||||
@@ -94,20 +99,9 @@ async function shutdown(signal: string): Promise<void> {
|
||||
|
||||
server.close(async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
repository.close(),
|
||||
coupleRepository.close(),
|
||||
packsRepository.close(),
|
||||
answersRepository.close(),
|
||||
chatRepository.close(),
|
||||
daresRepository.close(),
|
||||
profileSettingsRepository.close(),
|
||||
accountOperationsRepository.close(),
|
||||
mediaRepository.close(),
|
||||
appDataRepository.close(),
|
||||
billingRepository.close(),
|
||||
adminRepository.close(),
|
||||
]);
|
||||
// Repositories share the process pool and no longer own a connection each,
|
||||
// so ending the pool once is the whole of the database shutdown.
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Failed to close database connections', error);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { PostgresAnswersRepository } from '../src/domains/answers/repository.js'
|
||||
const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost');
|
||||
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
|
||||
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','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']){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();});
|
||||
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
|
||||
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
|
||||
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
|
||||
@@ -18,6 +18,71 @@ it('answers the streak from the caller side rather than by stored position',asyn
|
||||
expect(await repo.streak(u1)).toMatchObject({you_answered_today:true,partner_answered_today:false,partner_name:'Alex',current_streak:0});
|
||||
expect(await repo.streak(u2)).toMatchObject({you_answered_today:false,partner_answered_today:true,partner_name:'Sam',current_streak:0});});
|
||||
|
||||
describe('match pagination',()=>{
|
||||
const paged=Array.from({length:5},(_,index)=>`d0000000-0000-4000-8000-00000000000${index}`);
|
||||
const pagedQuestions=Array.from({length:5},(_,index)=>`f0000000-0000-4000-8000-00000000000${index}`);
|
||||
const otherMatch='e0000000-0000-4000-8000-000000000000';
|
||||
beforeAll(async()=>{
|
||||
await pool.query('delete from match_archives');await pool.query('delete from messages');await pool.query('delete from matches');
|
||||
// One question per match: a couple may only match a given question once.
|
||||
for(const[index,id]of pagedQuestions.entries())await pool.query("insert into questions(id,pack_id,text,intensity) values($1,$2,$3,2)",[id,pack,`Paged ${index}`]);
|
||||
// Distinct creation times so the ordering assertion is about the query, not tie-breaking.
|
||||
for(const[index,id]of paged.entries())await pool.query("insert into matches(id,couple_id,question_id,match_type,created_at) values($1,$2,$3,'yes_yes',now()-($4::text||' minutes')::interval)",[id,couple,pagedQuestions[index],String(index)]);
|
||||
// A match belonging to another couple must never appear, whatever the page.
|
||||
await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[otherMatch,other,q]);
|
||||
// Two unread from the partner on the oldest match; one message of our own, which is never unread.
|
||||
await pool.query("insert into messages(match_id,user_id,content) values($1,$2,'a'),($1,$2,'b'),($1,$3,'mine')",[paged[4],u2,u1]);
|
||||
});
|
||||
|
||||
it('counts once on the first page, never again, and still reports hasMore',async()=>{
|
||||
const first=await repo.matches(u1,0,2,false);
|
||||
expect(first.totalCount).toBe(5);
|
||||
expect(first.hasMore).toBe(true);
|
||||
expect(first.matches).toHaveLength(2);
|
||||
|
||||
// Later pages skip the count entirely: the client already has it and paging
|
||||
// is decided by hasMore, which comes from reading limit + 1 rows.
|
||||
const second=await repo.matches(u1,1,2,false);
|
||||
expect(second.totalCount).toBeNull();
|
||||
expect(second.hasMore).toBe(true);
|
||||
const last=await repo.matches(u1,2,2,false);
|
||||
expect(last.totalCount).toBeNull();
|
||||
expect(last.hasMore).toBe(false);
|
||||
expect(last.matches).toHaveLength(1);
|
||||
|
||||
// An exactly-full final page must not claim another page exists.
|
||||
expect(await repo.matches(u1,0,5,false)).toMatchObject({hasMore:false,totalCount:5});
|
||||
});
|
||||
|
||||
it('keeps unread-first ordering, aggregates unread once, and isolates the other couple',async()=>{
|
||||
const page=await repo.matches(u1,0,5,false);
|
||||
// The unread match sorts ahead of newer ones; the rest stay newest-first.
|
||||
expect(page.matches.map(item=>item.id)).toEqual([paged[4],paged[0],paged[1],paged[2],paged[3]]);
|
||||
expect(page.matches[0]).toMatchObject({unreadCount:2});
|
||||
// Our own message is not unread to us, and everything else is zero.
|
||||
expect(page.matches.slice(1).every(item=>item.unreadCount===0)).toBe(true);
|
||||
// Unread is per viewer: the partner's own two messages are not unread to them,
|
||||
// but ours is, so the same thread reports a different count on each side.
|
||||
expect((await repo.matches(u2,0,5,false)).matches.find(item=>item.id===paged[4])?.unreadCount).toBe(1);
|
||||
// The other couple's match is invisible here, and their member sees only theirs.
|
||||
expect(page.matches.some(item=>item.id===otherMatch)).toBe(false);
|
||||
expect((await repo.matches(outsider,0,5,false)).matches.map(item=>item.id)).toEqual([otherMatch]);
|
||||
});
|
||||
|
||||
it('pages the archived view separately and per user',async()=>{
|
||||
await repo.archive(u1,paged[0],true);
|
||||
const active=await repo.matches(u1,0,5,false);
|
||||
expect(active.matches.some(item=>item.id===paged[0])).toBe(false);
|
||||
expect(active.totalCount).toBe(4);
|
||||
const archived=await repo.matches(u1,0,5,true);
|
||||
expect(archived.matches.map(item=>item.id)).toEqual([paged[0]]);
|
||||
expect(archived).toMatchObject({totalCount:1,hasMore:false});
|
||||
// Archiving is per user, so the partner still sees it in their active list.
|
||||
expect((await repo.matches(u2,0,5,false)).matches.some(item=>item.id===paged[0])).toBe(true);
|
||||
await repo.archive(u1,paged[0],false);
|
||||
});
|
||||
});
|
||||
|
||||
it('counts the day in the couple shared zone and prefers the first reported member zone',async()=>{await pool.query('update profiles set timezone=$2 where id=$1',[u1,'Pacific/Auckland']);await pool.query('update profiles set timezone=$2 where id=$1',[u2,'America/Los_Angeles']);
|
||||
await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);
|
||||
await repo.submit(u1,{questionId:q,answer:'yes'});
|
||||
|
||||
@@ -10,7 +10,7 @@ const questionId='22222222-2222-4222-8222-222222222222';
|
||||
function repo():AnswersRepository{return {
|
||||
recommended:vi.fn(async()=>[]),pending:vi.fn(async()=>[]),answerGap:vi.fn(async()=>({unanswered_by_partner:0,threshold:10,is_blocked:false})),
|
||||
dailyLimit:vi.fn(async()=>({responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false})),
|
||||
submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};}
|
||||
submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0,hasMore:false})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};}
|
||||
function app(r:AnswersRepository){const app=new Hono<{Variables:{identity:AuthIdentity}}>();app.use('/v1/*',async(c,n)=>{c.set('identity',identity);await n();});registerAnswerRoutes(app,r);return app;}
|
||||
|
||||
describe('answer routes',()=>{
|
||||
@@ -18,6 +18,16 @@ describe('answer routes',()=>{
|
||||
it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached',{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}));const blocked=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(blocked.status).toBe(429);
|
||||
// The client renders the limit screen straight from this body, so its shape is a contract.
|
||||
expect(await blocked.json()).toMatchObject({error:{code:'daily_limit'},details:{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}});});
|
||||
it('reports pagination from the repository, including a page with no total',async()=>{const r=repo();const a=app(r);
|
||||
// Later pages skip the count entirely, so `hasMore` — not a client-side
|
||||
// comparison against totalCount — is what decides whether to keep paging.
|
||||
vi.mocked(r.matches).mockResolvedValueOnce({matches:[],totalCount:null,hasMore:true});
|
||||
const later=await a.request('/v1/matches?page=2&limit=20');
|
||||
expect(later.status).toBe(200);
|
||||
expect(await later.json()).toMatchObject({totalCount:null,hasMore:true});
|
||||
expect(r.matches).toHaveBeenCalledWith(identity.id,2,20,false);
|
||||
await a.request('/v1/matches?page=0&limit=20&archived=true');
|
||||
expect(r.matches).toHaveBeenLastCalledWith(identity.id,0,20,true);});
|
||||
it('scopes match mutations to identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/matches/seen',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[questionId]})});expect(r.markSeen).toHaveBeenCalledWith(identity.id,[questionId]);await a.request(`/v1/matches/${questionId}/archive`,{method:'PUT',headers:{'content-type':'application/json'},body:'{"archived":true}'});expect(r.archive).toHaveBeenCalledWith(identity.id,questionId,true);});
|
||||
});
|
||||
|
||||
|
||||
@@ -75,6 +75,31 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration',
|
||||
},
|
||||
});
|
||||
|
||||
// Reading the profile again must not rewrite it. Every authenticated request the
|
||||
// app makes starts here, so an unconditional upsert turned a read into a write
|
||||
// on the busiest path in the API.
|
||||
const stamps = async () => (await pool.query<{ auth_synced_at: Date | null; updated_at: Date }>(
|
||||
'select auth_synced_at, updated_at from profiles where id = $1',
|
||||
['33333333-3333-4333-8333-333333333333'],
|
||||
)).rows[0];
|
||||
const created = await stamps();
|
||||
expect(created.auth_synced_at).not.toBeNull();
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
expect((await app.request('/v1/me', { headers })).status).toBe(200);
|
||||
}
|
||||
const unchanged = await stamps();
|
||||
expect(unchanged.auth_synced_at?.getTime()).toBe(created.auth_synced_at?.getTime());
|
||||
expect(unchanged.updated_at.getTime()).toBe(created.updated_at.getTime());
|
||||
|
||||
// A genuine identity change still writes through, so first use and a changed
|
||||
// email are both recorded rather than skipped.
|
||||
await pool.query('update profiles set email = $2 where id = $1', ['33333333-3333-4333-8333-333333333333', 'stale@sauci.test']);
|
||||
expect((await app.request('/v1/me', { headers })).status).toBe(200);
|
||||
const resynced = await stamps();
|
||||
expect(resynced.auth_synced_at!.getTime()).toBeGreaterThan(created.auth_synced_at!.getTime());
|
||||
expect(await (await app.request('/v1/me', { headers })).json()).toMatchObject({ profile: { email: 'integration@sauci.test' } });
|
||||
|
||||
expect((await app.request('/v1/me/feature-interests/better-chat', { method: 'PUT', headers })).status).toBe(200);
|
||||
expect(await (await app.request('/v1/me/feature-interests/better-chat', { headers })).json())
|
||||
.toEqual({ feature: 'better-chat', interested: true });
|
||||
|
||||
@@ -30,6 +30,99 @@ describe.skipIf(!databaseUrl || !local)('app-data repository + PostgreSQL', () =
|
||||
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'x.jpg','image')",[message,match,alice]);
|
||||
});
|
||||
afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
|
||||
it('summarises change markers per couple without leaking another couple state',async()=>{
|
||||
// This one small read replaced a five-second refresh of profile, couple, matches,
|
||||
// pending questions, packs and unread counts, so its markers have to move for
|
||||
// exactly the changes those refreshes existed to notice.
|
||||
const before=await repo.syncSummary(alice);
|
||||
expect(before).toMatchObject({couple_id:couple,partner_id:bob,match_count:1,new_match_count:1,unread_total:0});
|
||||
expect(before.profile_updated_at).not.toBeNull();
|
||||
expect(Date.parse(before.server_time)).not.toBeNaN();
|
||||
|
||||
// The partner's own message is unread to them and not to us.
|
||||
expect((await repo.syncSummary(bob)).unread_total).toBe(1);
|
||||
|
||||
// A partner profile edit moves the partner marker, which is what tells the
|
||||
// client to re-read the couple rather than everything.
|
||||
await pool.query("update profiles set name='Bobby', updated_at=now() where id=$1",[bob]);
|
||||
const afterPartner=await repo.syncSummary(alice);
|
||||
expect(Date.parse(afterPartner.partner_updated_at!)).toBeGreaterThan(Date.parse(before.partner_updated_at!));
|
||||
expect(afterPartner.profile_updated_at).toBe(before.profile_updated_at);
|
||||
|
||||
// couple_packs carries no timestamp, so equal-and-opposite toggles have to be
|
||||
// caught by a digest rather than a max(updated_at).
|
||||
const emptyDigest=before.enabled_packs_fingerprint;
|
||||
expect(emptyDigest).toEqual(expect.any(String));
|
||||
await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);
|
||||
const enabled=await repo.syncSummary(alice);
|
||||
expect(enabled.enabled_packs_fingerprint).not.toBe(emptyDigest);
|
||||
await pool.query('update couple_packs set enabled=false where couple_id=$1 and pack_id=$2',[couple,pack]);
|
||||
expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(emptyDigest);
|
||||
// Toggling back must land on the same digest, so the client stops refetching.
|
||||
await pool.query('update couple_packs set enabled=true where couple_id=$1 and pack_id=$2',[couple,pack]);
|
||||
expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(enabled.enabled_packs_fingerprint);
|
||||
|
||||
// An outsider's summary is about their own couple and never ours.
|
||||
const other=await repo.syncSummary(outsider);
|
||||
expect(other).toMatchObject({couple_id:otherCouple,partner_id:null,match_count:0,unread_total:0});
|
||||
});
|
||||
|
||||
it('moves the match-state fingerprint on an in-place edit that the count and latest-at markers miss',async()=>{
|
||||
// Editing an existing match's type/response summary (response editing) creates
|
||||
// no new match and touches no created_at, so match_count/new_match_count/
|
||||
// latest_match_at all hold steady; only the state fingerprint must move.
|
||||
const before=await repo.syncSummary(alice);
|
||||
await pool.query("update matches set match_type='yes_maybe', response_summary=$2 where id=$1",[match,JSON.stringify({edited:true})]);
|
||||
const after=await repo.syncSummary(alice);
|
||||
expect(after.match_count).toBe(before.match_count);
|
||||
expect(after.new_match_count).toBe(before.new_match_count);
|
||||
expect(after.latest_match_at).toBe(before.latest_match_at);
|
||||
expect(after.match_state_fingerprint).not.toBe(before.match_state_fingerprint);
|
||||
// Reverting lands back on the same digest, so the client stops refetching.
|
||||
await pool.query("update matches set match_type='yes_yes', response_summary=null where id=$1",[match]);
|
||||
expect((await repo.syncSummary(alice)).match_state_fingerprint).toBe(before.match_state_fingerprint);
|
||||
});
|
||||
|
||||
it('moves the match-unread fingerprint when unread redistributes without changing the total',async()=>{
|
||||
const secondMatch='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||||
const secondQuestion='dddddddd-dddd-4ddd-8ddd-dddddddddddd';
|
||||
const originalUnread='eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
|
||||
const secondUnread='11111111-2222-4333-8444-555555555555';
|
||||
await pool.query("insert into questions(id,pack_id,text) values($1,$2,'Question 2')",[secondQuestion,pack]);
|
||||
await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[secondMatch,couple,secondQuestion]);
|
||||
// Alice's own message ('message', from beforeAll) is never unread to her, so
|
||||
// give the original match a message from her partner to actually redistribute.
|
||||
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y.jpg','image')",[originalUnread,match,bob]);
|
||||
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y2.jpg','image')",[secondUnread,secondMatch,bob]);
|
||||
|
||||
const before=await repo.syncSummary(alice);
|
||||
expect(before.unread_total).toBe(2);
|
||||
|
||||
// Read the original match's unread message while a new one lands in the
|
||||
// second match: unread_total holds steady at 2, but which match owns it moves.
|
||||
await pool.query('update messages set read_at=now() where id=$1',[originalUnread]);
|
||||
const thirdMessage='ffffffff-ffff-4fff-8fff-ffffffffffff';
|
||||
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'z.jpg','image')",[thirdMessage,secondMatch,bob]);
|
||||
|
||||
const after=await repo.syncSummary(alice);
|
||||
expect(after.unread_total).toBe(before.unread_total);
|
||||
expect(after.match_unread_fingerprint).not.toBe(before.match_unread_fingerprint);
|
||||
|
||||
// Clean up so later assertions in this suite see the original single-match state.
|
||||
await pool.query('delete from messages where id=any($1::uuid[])',[[originalUnread,secondUnread,thirdMessage]]);
|
||||
await pool.query('delete from matches where id=$1',[secondMatch]);
|
||||
await pool.query('delete from questions where id=$1',[secondQuestion]);
|
||||
});
|
||||
|
||||
it('moves the streak marker from couple_streaks.updated_at',async()=>{
|
||||
await pool.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[couple]);
|
||||
const before=await repo.syncSummary(alice);
|
||||
expect(before.streak_updated_at).toEqual(expect.any(String));
|
||||
await pool.query('update couple_streaks set current_streak=current_streak+1, updated_at=now() where couple_id=$1',[couple]);
|
||||
const after=await repo.syncSummary(alice);
|
||||
expect(Date.parse(after.streak_updated_at!)).toBeGreaterThan(Date.parse(before.streak_updated_at!));
|
||||
});
|
||||
|
||||
it('returns only public catalog data and couple-owned match context',async()=>{
|
||||
expect((await repo.packQuestions(alice,pack))[0]?.text).toBe('Question');
|
||||
expect((await repo.matchContext(bob,match)).responses).toHaveLength(2);
|
||||
|
||||
@@ -16,6 +16,12 @@ function repository(): AppDataRepository {
|
||||
markMediaViewed: vi.fn(async () => ({ media_viewed_at: 'now', media_expires_at: null })),
|
||||
nudgeStatus: vi.fn(async () => ({ last_nudge_sent_at: null })),
|
||||
sendNudge: vi.fn(async () => ({ success: true as const, notification_sent: false, reason: 'no_push_token', next_nudge_available_at: 'later' })),
|
||||
syncSummary: vi.fn(async () => ({
|
||||
server_time: 'now', couple_id: null, profile_updated_at: null, partner_id: null, partner_updated_at: null,
|
||||
match_count: 0, new_match_count: 0, latest_match_at: null, pending_yours: 0, pending_theirs: 0,
|
||||
unread_total: 0, enabled_packs_fingerprint: '',
|
||||
match_state_fingerprint: '', match_unread_fingerprint: '', streak_updated_at: null,
|
||||
})),
|
||||
getLiveDraw: vi.fn(async () => ({ strokes: [], revision: 0, updated_at: null, updated_by: null })),
|
||||
putLiveDraw: vi.fn(async () => ({ strokes: [], revision: 1, updated_at: 'now', updated_by: identity.id })),
|
||||
close: vi.fn(async () => undefined),
|
||||
|
||||
@@ -48,7 +48,7 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () =>
|
||||
|
||||
it('sends, lists, counts and reads text messages for couple members only', async () => {
|
||||
const sent = await repository.sendText(alice, match, 'Hello');
|
||||
expect((await repository.listMessages(bob, match))[0]?.content).toBe('Hello');
|
||||
expect((await repository.listMessages(bob, match)).messages[0]?.content).toBe('Hello');
|
||||
expect(await repository.unreadCounts(bob)).toEqual({ total: 1, by_match: { [match]: 1 } });
|
||||
expect((await repository.markDelivered(bob, sent.id)).delivered_at).not.toBeNull();
|
||||
expect((await repository.markMatchRead(bob, match)).updated).toBe(1);
|
||||
@@ -58,14 +58,110 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () =>
|
||||
it('enforces per-user and author-only deletions plus reports', async () => {
|
||||
const sent = await repository.sendText(alice, match, 'Moderate me');
|
||||
await repository.deleteForSelf(bob, sent.id);
|
||||
expect((await repository.listMessages(bob, match)).some(item => item.id === sent.id)).toBe(false);
|
||||
expect((await repository.listMessages(alice, match)).some(item => item.id === sent.id)).toBe(true);
|
||||
expect((await repository.listMessages(bob, match)).messages.some(item => item.id === sent.id)).toBe(false);
|
||||
expect((await repository.listMessages(alice, match)).messages.some(item => item.id === sent.id)).toBe(true);
|
||||
await expect(repository.deleteForEveryone(bob, sent.id)).rejects.toMatchObject({ code: 'not_message_author' });
|
||||
expect((await repository.deleteForEveryone(alice, sent.id)).deleted_at).not.toBeNull();
|
||||
await repository.report(bob, sent.id, 'harassment');
|
||||
await expect(repository.report(bob, sent.id, 'spam')).rejects.toBeInstanceOf(ChatError);
|
||||
});
|
||||
|
||||
it('answers a delta with only what changed, and the same rows a full read would give', async () => {
|
||||
const before = await repository.listMessages(bob, match);
|
||||
const cursor = before.server_time;
|
||||
expect(before.complete).toBe(true);
|
||||
|
||||
// Nothing has happened since the cursor, so the delta carries no row the client
|
||||
// has not already seen. The cursor is deliberately held a couple of seconds
|
||||
// behind wall clock so a write racing the read is repeated rather than lost;
|
||||
// that repetition is absorbed by merging on id, never by blanking the thread.
|
||||
const known = new Set(before.messages.map(item => item.id));
|
||||
const quiet = await repository.listMessages(bob, match, { since: cursor });
|
||||
expect(quiet.complete).toBe(false);
|
||||
expect(quiet.messages.every(item => known.has(item.id))).toBe(true);
|
||||
expect(quiet.removed_ids?.every(id => !known.has(id))).toBe(true);
|
||||
|
||||
const fresh = await repository.sendText(alice, match, 'Delta arrival');
|
||||
const delta = await repository.listMessages(bob, match, { since: cursor });
|
||||
expect(delta.messages.map(item => item.id)).toContain(fresh.id);
|
||||
// Media and moderation columns must survive the narrower query.
|
||||
expect(delta.messages.find(item => item.id === fresh.id)).toMatchObject({
|
||||
content: 'Delta arrival', media_path: null, media_type: null, media_expired: false, read_at: null,
|
||||
});
|
||||
|
||||
// A status change with no new row still reaches the sender through the delta.
|
||||
const afterSend = await repository.listMessages(alice, match);
|
||||
await repository.markMatchRead(bob, match);
|
||||
const readDelta = await repository.listMessages(alice, match, { since: afterSend.server_time });
|
||||
expect(readDelta.messages.find(item => item.id === fresh.id)?.read_at).not.toBeNull();
|
||||
|
||||
// So does a deletion, which arrives as a removal rather than a silent gap.
|
||||
const doomed = await repository.sendText(alice, match, 'Delete me');
|
||||
const beforeDelete = await repository.listMessages(bob, match);
|
||||
await repository.deleteForSelf(bob, doomed.id);
|
||||
const removal = await repository.listMessages(bob, match, { since: beforeDelete.server_time });
|
||||
expect(removal.removed_ids).toContain(doomed.id);
|
||||
|
||||
// Ordering is the contract the screen renders against: newest first, both ways.
|
||||
const full = await repository.listMessages(bob, match);
|
||||
const timestamps = full.messages.map(item => Date.parse(item.created_at));
|
||||
expect([...timestamps].sort((a, b) => b - a)).toEqual(timestamps);
|
||||
|
||||
// Access control is unchanged on the delta path.
|
||||
await expect(repository.listMessages(outsider, match, { since: cursor })).rejects.toMatchObject({ code: 'match_not_found' });
|
||||
});
|
||||
|
||||
it('surfaces an expiring video to a chat that is already open, and only in the window it expires', async () => {
|
||||
// Expiry is the one visible change no write announces: the deadline simply
|
||||
// passes. Backdated so neither `created_at` nor `media_viewed_at` can be what
|
||||
// puts the row in a delta — only the deadline crossing may.
|
||||
const video = await repository.sendText(alice, match, 'Sent a video');
|
||||
await pool.query(
|
||||
`update messages
|
||||
set media_path = 'media:expiring', media_type = 'video',
|
||||
moderation_status = 'safe', encrypted_content = 'cipher', encryption_iv = 'iv',
|
||||
created_at = now() - interval '1 hour',
|
||||
media_viewed_at = now() - interval '1 hour',
|
||||
media_expires_at = now() + interval '1 hour'
|
||||
where id = $1`,
|
||||
[video.id],
|
||||
);
|
||||
|
||||
const before = await repository.listMessages(bob, match);
|
||||
expect(before.messages.find(item => item.id === video.id)).toMatchObject({ media_expired: false });
|
||||
|
||||
// A deadline still in the future is not a change, so the row is not re-sent on
|
||||
// every poll until it arrives.
|
||||
const quiet = await repository.listMessages(bob, match, { since: before.server_time });
|
||||
expect(quiet.messages.some(item => item.id === video.id)).toBe(false);
|
||||
|
||||
// The deadline now falls inside this poll's window.
|
||||
await pool.query(`update messages set media_expires_at = now() - interval '1 second' where id = $1`, [video.id]);
|
||||
const delta = await repository.listMessages(bob, match, { since: before.server_time });
|
||||
const expired = delta.messages.find(item => item.id === video.id);
|
||||
// Media, encryption and moderation columns still arrive whole on the delta path.
|
||||
expect(expired).toMatchObject({
|
||||
media_expired: true, media_type: 'video', media_path: 'media:expiring',
|
||||
moderation_status: 'safe', encrypted_content: 'cipher', encryption_iv: 'iv',
|
||||
});
|
||||
expect(expired?.media_expires_at).not.toBeNull();
|
||||
expect(expired?.media_viewed_at).not.toBeNull();
|
||||
|
||||
// A full read agrees, so leaving and re-entering the chat shows the same thing.
|
||||
expect((await repository.listMessages(bob, match)).messages.find(item => item.id === video.id))
|
||||
.toMatchObject({ media_expired: true });
|
||||
});
|
||||
|
||||
it('folds the partner typing check into the message read when asked', async () => {
|
||||
await repository.setTyping(alice, match, 4_000);
|
||||
const withTyping = await repository.listMessages(bob, match, { includeTyping: true });
|
||||
expect(withTyping.typing?.typing).toBe(true);
|
||||
// Own typing is never reflected back, so the sender does not see themselves.
|
||||
expect((await repository.listMessages(alice, match, { includeTyping: true })).typing?.typing).toBe(false);
|
||||
// And it stays absent unless requested, so the default read costs no extra work.
|
||||
expect((await repository.listMessages(bob, match)).typing).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stores typing state with a short expiry and exposes only the partner state', async () => {
|
||||
await repository.setTyping(alice, match, 4_000);
|
||||
expect((await repository.getPartnerTyping(bob, match)).typing).toBe(true);
|
||||
|
||||
@@ -11,7 +11,8 @@ const message = { id: messageId, match_id: matchId, user_id: identity.id, conten
|
||||
|
||||
function setup() {
|
||||
const repository: ChatRepository = {
|
||||
listMessages: vi.fn(async () => [message]), sendText: vi.fn(async () => message),
|
||||
listMessages: vi.fn(async () => ({ messages: [message], removed_ids: [], server_time: new Date().toISOString(), complete: true })),
|
||||
sendText: vi.fn(async () => message),
|
||||
unreadCounts: vi.fn(async () => ({ total: 1, by_match: { [matchId]: 1 } })),
|
||||
markMatchRead: vi.fn(async () => ({ updated: 1, read_at: new Date().toISOString() })),
|
||||
markDelivered: vi.fn(async () => message), deleteForSelf: vi.fn(async () => undefined),
|
||||
@@ -43,6 +44,28 @@ describe('chat routes', () => {
|
||||
expect(repository.sendText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes a delta cursor and folded typing check through to the repository', async () => {
|
||||
const { app, repository } = setup();
|
||||
const since = '2026-08-28T10:00:00.000Z';
|
||||
const delta = await app.request(`/v1/matches/${matchId}/messages?since=${encodeURIComponent(since)}&typing=true`);
|
||||
expect(delta.status).toBe(200);
|
||||
expect(repository.listMessages).toHaveBeenCalledWith(identity.id, matchId, { since, includeTyping: true });
|
||||
|
||||
// Omitting both keeps the full-snapshot contract the client had before.
|
||||
const snapshot = await app.request(`/v1/matches/${matchId}/messages`);
|
||||
expect(snapshot.status).toBe(200);
|
||||
expect(await snapshot.json()).toMatchObject({ messages: [{ id: messageId }] });
|
||||
expect(repository.listMessages).toHaveBeenLastCalledWith(identity.id, matchId, { includeTyping: false });
|
||||
});
|
||||
|
||||
it('refuses a malformed cursor rather than silently reading everything', async () => {
|
||||
const { app, repository } = setup();
|
||||
const response = await app.request(`/v1/matches/${matchId}/messages?since=yesterday`);
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toMatchObject({ error: { code: 'invalid_since' } });
|
||||
expect(repository.listMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supports unread, typing, deletion and reporting actions', async () => {
|
||||
const { app, repository } = setup();
|
||||
expect((await app.request('/v1/chat/unread')).status).toBe(200);
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Pool } from 'pg';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
DEFAULT_POOL_MAX,
|
||||
closeResolvedPool,
|
||||
createDatabasePool,
|
||||
resolvePool,
|
||||
} from '../src/db/pool.js';
|
||||
|
||||
const url = 'postgres://user:pass@127.0.0.1:5432/sauci_test';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('database pool', () => {
|
||||
it('bounds the shared pool and only forwards timeouts that were configured', () => {
|
||||
// A process-wide pool is the whole point of the change: an unbounded default
|
||||
// multiplied by one pool per repository is what the self-hosted database had
|
||||
// to absorb before.
|
||||
const bounded = createDatabasePool(url, { max: 4, idleTimeoutMillis: 1_000, connectionTimeoutMillis: 2_000 });
|
||||
expect(bounded.options.max).toBe(4);
|
||||
expect(bounded.options.idleTimeoutMillis).toBe(1_000);
|
||||
expect(bounded.options.connectionTimeoutMillis).toBe(2_000);
|
||||
|
||||
const defaults = createDatabasePool(url);
|
||||
expect(defaults.options.max).toBe(DEFAULT_POOL_MAX);
|
||||
|
||||
return Promise.all([bounded.end(), defaults.end()]);
|
||||
});
|
||||
|
||||
it('owns a pool it built from a URL and never ends one it was handed', async () => {
|
||||
// Repositories in the API process share one pool, so closing any single
|
||||
// repository must not take the connections out from under the others. Tests
|
||||
// still construct from a URL and must be able to close what they created.
|
||||
const owned = resolvePool(url);
|
||||
expect(owned.owned).toBe(true);
|
||||
const ownedEnd = vi.spyOn(owned.pool, 'end');
|
||||
await closeResolvedPool(owned.pool, owned.owned);
|
||||
expect(ownedEnd).toHaveBeenCalledTimes(1);
|
||||
|
||||
const shared = new Pool({ connectionString: url });
|
||||
const borrowed = resolvePool(shared);
|
||||
expect(borrowed.owned).toBe(false);
|
||||
expect(borrowed.pool).toBe(shared);
|
||||
const sharedEnd = vi.spyOn(shared, 'end');
|
||||
await closeResolvedPool(borrowed.pool, borrowed.owned);
|
||||
expect(sharedEnd).not.toHaveBeenCalled();
|
||||
await shared.end();
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,12 @@ import { Ionicons } from "@expo/vector-icons";
|
||||
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store";
|
||||
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, useStreakStore } from "../../src/store";
|
||||
import { colors, radius, spacing, typography, shadows } from "../../src/theme";
|
||||
import { authClient } from "../../src/lib/authClient";
|
||||
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
||||
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
|
||||
import { useCoupleSync } from "../../src/hooks/useCoupleSync";
|
||||
import { syncBadgeCount } from "../../src/lib/badge";
|
||||
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
@@ -59,9 +60,8 @@ export default function AppLayout() {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
|
||||
const { newMatchesCount, pendingQuestions, fetchPendingQuestions, fetchMatches } = useMatchStore();
|
||||
const { unreadCount, lastMessage, fetchUnreadCount, clearLastMessage } = useMessageStore();
|
||||
const { fetchEnabledPacks } = usePacksStore();
|
||||
const { newMatchesCount, pendingQuestions, matches, fetchPendingQuestions, fetchMatches } = useMatchStore();
|
||||
const { unreadCount, lastMessage, clearLastMessage } = useMessageStore();
|
||||
const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore();
|
||||
const { fetchStreak } = useStreakStore();
|
||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||
@@ -173,12 +173,8 @@ export default function AppLayout() {
|
||||
setIsLocked(false);
|
||||
}, []);
|
||||
|
||||
// Fetch unread message count on mount
|
||||
useEffect(() => {
|
||||
if (user?.id) {
|
||||
fetchUnreadCount();
|
||||
}
|
||||
}, [user?.id, fetchUnreadCount]);
|
||||
// The unread badge arrives with the first sync summary, so it needs no request
|
||||
// of its own here.
|
||||
|
||||
// Fetch streak data when user is in a couple
|
||||
useEffect(() => {
|
||||
@@ -187,12 +183,15 @@ export default function AppLayout() {
|
||||
}
|
||||
}, [user?.couple_id, fetchStreak]);
|
||||
|
||||
// Fetch pending questions (Your Turn) when user is in a couple
|
||||
// Fetch pending questions (Your Turn) and matches when user is in a couple.
|
||||
// Both feed tab badges, so they load once here and are kept current by the
|
||||
// sync summary rather than by re-fetching on a timer.
|
||||
useEffect(() => {
|
||||
if (user?.couple_id) {
|
||||
fetchPendingQuestions();
|
||||
void fetchPendingQuestions();
|
||||
void fetchMatches(true);
|
||||
}
|
||||
}, [user?.couple_id, fetchPendingQuestions]);
|
||||
}, [user?.couple_id, fetchPendingQuestions, fetchMatches]);
|
||||
|
||||
// Sync app icon badge count whenever matches, messages, or pending questions change
|
||||
useEffect(() => {
|
||||
@@ -214,44 +213,31 @@ export default function AppLayout() {
|
||||
}
|
||||
}, [user?.id, user?.onboarding_completed]);
|
||||
|
||||
// Poll the self-hosted API for profile, match, pending-question and pack changes.
|
||||
// This replaces the former Supabase Postgres change subscriptions.
|
||||
// One small change summary drives every domain refresh. It replaces a
|
||||
// five-second refetch of profile, matches, pending questions and packs plus a
|
||||
// three-second unread poll, and only asks a store to reload when the server
|
||||
// reports that its data actually moved.
|
||||
useCoupleSync(user?.id, user?.couple_id);
|
||||
|
||||
// Match analytics are derived from the store rather than from a poll response,
|
||||
// so they fire once per genuinely new match however it arrived.
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
let cancelled = false;
|
||||
matchPollInitializedRef.current = false;
|
||||
knownMatchIdsRef.current = new Set();
|
||||
}, [user?.id, user?.couple_id]);
|
||||
|
||||
const poll = async () => {
|
||||
await Promise.allSettled([
|
||||
useAuthStore.getState().fetchUser(),
|
||||
...(user.couple_id ? [fetchMatches(true), fetchPendingQuestions(), fetchEnabledPacks()] : []),
|
||||
]);
|
||||
if (cancelled || !user.couple_id) return;
|
||||
const refreshed = useMatchStore.getState().matches;
|
||||
if (matchPollInitializedRef.current) {
|
||||
const added = refreshed.filter(item => !knownMatchIdsRef.current.has(item.id));
|
||||
for (const item of added) Events.matchCreated(item.match_type || "unknown");
|
||||
const previousCount = knownMatchIdsRef.current.size;
|
||||
if (previousCount === 0 && added.length > 0) Events.firstMatch();
|
||||
else if (added.length > 0 && refreshed.length % 10 === 0) Events.milestoneMatch(refreshed.length);
|
||||
}
|
||||
knownMatchIdsRef.current = new Set(refreshed.map(item => item.id));
|
||||
matchPollInitializedRef.current = true;
|
||||
};
|
||||
|
||||
void poll();
|
||||
const timer = setInterval(() => void poll(), 5_000);
|
||||
return () => { cancelled = true; clearInterval(timer); };
|
||||
}, [user?.id, user?.couple_id, fetchMatches, fetchPendingQuestions, fetchEnabledPacks]);
|
||||
|
||||
// Poll the self-hosted API for unread chat state. Chat screens perform their
|
||||
// own message polling; this keeps the global badge current without Supabase Realtime.
|
||||
useEffect(() => {
|
||||
if (!user?.couple_id) return;
|
||||
const timer = setInterval(() => void fetchUnreadCount(), 3_000);
|
||||
return () => clearInterval(timer);
|
||||
}, [user?.couple_id, fetchUnreadCount]);
|
||||
const known = knownMatchIdsRef.current;
|
||||
const added = matches.filter(item => !known.has(item.id));
|
||||
if (matchPollInitializedRef.current && added.length > 0) {
|
||||
for (const item of added) Events.matchCreated(item.match_type || "unknown");
|
||||
if (known.size === 0) Events.firstMatch();
|
||||
else if (matches.length % 10 === 0) Events.milestoneMatch(matches.length);
|
||||
}
|
||||
knownMatchIdsRef.current = new Set(matches.map(item => item.id));
|
||||
matchPollInitializedRef.current = true;
|
||||
}, [matches, user?.couple_id]);
|
||||
|
||||
// Animate message toast when lastMessage changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useFocusEffect } from 'expo-router';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { useAuthStore, usePacksStore, useStreakStore, useSubscriptionStore } from '../../src/store';
|
||||
import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store';
|
||||
import { GradientBackground } from '../../src/components/ui';
|
||||
import { Paywall } from '../../src/components/paywall';
|
||||
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery';
|
||||
@@ -68,8 +68,7 @@ function groupPacksByCategory(
|
||||
|
||||
export default function DiscoveryScreen() {
|
||||
const { user, partner, couple } = useAuthStore();
|
||||
const { packs, categories, fetchPacks, getPackProgress } = usePacksStore();
|
||||
const { fetchStreak } = useStreakStore();
|
||||
const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore();
|
||||
const { subscription } = useSubscriptionStore();
|
||||
const { width } = useWindowDimensions();
|
||||
const [showPaywall, setShowPaywall] = useState(false);
|
||||
@@ -79,12 +78,13 @@ export default function DiscoveryScreen() {
|
||||
const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser;
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
|
||||
// Refresh data when screen gains focus
|
||||
// Load the catalog once. Returning to this tab shows what is cached; answering a
|
||||
// question invalidates it, and StreakDisplay owns the streak, so neither needs a
|
||||
// request here.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchPacks();
|
||||
fetchStreak();
|
||||
}, [fetchStreak])
|
||||
void ensurePacksLoaded();
|
||||
}, [ensurePacksLoaded])
|
||||
);
|
||||
|
||||
// Group packs by category
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function MatchesScreen() {
|
||||
fetchMatches,
|
||||
markAllAsSeen,
|
||||
isLoading,
|
||||
isRefreshing,
|
||||
hasMore,
|
||||
isLoadingMore,
|
||||
totalCount,
|
||||
@@ -58,7 +59,9 @@ export default function MatchesScreen() {
|
||||
fetchTheirTurnQuestions,
|
||||
currentView,
|
||||
setCurrentView,
|
||||
ensureViewLoaded,
|
||||
fetchPendingQuestions,
|
||||
fetchArchivedMatches,
|
||||
// Nudge state and methods
|
||||
nudgeCooldownUntil,
|
||||
isNudging,
|
||||
@@ -118,14 +121,12 @@ export default function MatchesScreen() {
|
||||
setShowTutorial(false);
|
||||
};
|
||||
|
||||
// Load whichever view is showing, once. Returning to this tab reuses what is
|
||||
// already cached; the sync poll refreshes it when the server reports a change,
|
||||
// and pull-to-refresh remains the explicit way to force one.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchMatches(true);
|
||||
// Also refresh pending questions when focused
|
||||
fetchPendingQuestions();
|
||||
fetchTheirTurnQuestions();
|
||||
// Check nudge cooldown
|
||||
checkNudgeCooldown();
|
||||
ensureViewLoaded();
|
||||
|
||||
// Cleanup: mark matches as seen when leaving the screen (if on Complete tab)
|
||||
return () => {
|
||||
@@ -134,9 +135,15 @@ export default function MatchesScreen() {
|
||||
state.markAllAsSeen();
|
||||
}
|
||||
};
|
||||
}, [])
|
||||
}, [ensureViewLoaded])
|
||||
);
|
||||
|
||||
// The nudge cooldown only moves when this device sends a nudge, and sendNudge
|
||||
// already records the new cooldown, so one read on mount is enough.
|
||||
useEffect(() => {
|
||||
void checkNudgeCooldown();
|
||||
}, [checkNudgeCooldown]);
|
||||
|
||||
// Format cooldown remaining time
|
||||
const formatCooldownTime = (cooldownUntil: Date): string => {
|
||||
const now = new Date();
|
||||
@@ -633,13 +640,16 @@ export default function MatchesScreen() {
|
||||
refreshing={
|
||||
currentView === 'pending' ? isLoadingPending :
|
||||
currentView === 'their_turn' ? isLoadingTheirTurn :
|
||||
isLoading
|
||||
currentView === 'archived' ? isLoadingArchived :
|
||||
isRefreshing
|
||||
}
|
||||
onRefresh={() => {
|
||||
if (currentView === 'pending') {
|
||||
fetchPendingQuestions();
|
||||
} else if (currentView === 'their_turn') {
|
||||
fetchTheirTurnQuestions();
|
||||
} else if (currentView === 'archived') {
|
||||
fetchArchivedMatches();
|
||||
} else {
|
||||
fetchMatches(true);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
|
||||
type GroupByOption = "pack" | "date" | "answer";
|
||||
|
||||
export default function MyAnswersScreen() {
|
||||
const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
|
||||
const { responses, isLoading, isRefreshing, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, ensureResponsesLoaded, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
|
||||
const { user, partner } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const params = useLocalSearchParams();
|
||||
@@ -110,11 +110,12 @@ export default function MyAnswersScreen() {
|
||||
// Ambient orb breathing animations
|
||||
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
||||
|
||||
// Fetch responses on focus
|
||||
// Load once, then keep showing what is cached. Answering or editing a response
|
||||
// invalidates it, and pull-to-refresh is always available.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchResponses(true);
|
||||
}, [])
|
||||
void ensureResponsesLoaded();
|
||||
}, [ensureResponsesLoaded])
|
||||
);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
@@ -271,7 +272,7 @@ export default function MyAnswersScreen() {
|
||||
scrollEventThrottle={16}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isLoading}
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={() => fetchResponses(true)}
|
||||
tintColor={colors.primary}
|
||||
colors={[colors.primary]}
|
||||
|
||||
@@ -44,7 +44,7 @@ const AnimatedFlatList = Animated.createAnimatedComponent(FlatList<Question>);
|
||||
export default function PackDetailsScreen() {
|
||||
const { id } = useLocalSearchParams();
|
||||
const router = useRouter();
|
||||
const { packs, fetchPacks } = usePacksStore();
|
||||
const { packs, ensurePacksLoaded } = usePacksStore();
|
||||
const { subscription } = useSubscriptionStore();
|
||||
const { user, partner } = useAuthStore();
|
||||
const [questions, setQuestions] = useState<Question[]>([]);
|
||||
@@ -61,9 +61,7 @@ export default function PackDetailsScreen() {
|
||||
const category = categories.find(c => c.id === pack?.category_id);
|
||||
|
||||
useEffect(() => {
|
||||
if (packs.length === 0) {
|
||||
fetchPacks();
|
||||
}
|
||||
void ensurePacksLoaded();
|
||||
fetchPackDetails();
|
||||
}, [id]);
|
||||
|
||||
|
||||
@@ -17,13 +17,19 @@ jest.mock("expo-router", () => ({
|
||||
jest.mock("@/store", () => {
|
||||
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
|
||||
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
|
||||
const packState = { ...packs, invalidatePacks: jest.fn() };
|
||||
const responses = { invalidateResponses: jest.fn() };
|
||||
const useAuthStore = () => auth;
|
||||
useAuthStore.getState = () => auth.user;
|
||||
const usePacksStore = () => packs;
|
||||
usePacksStore.getState = () => packState;
|
||||
const useResponsesStore = () => responses;
|
||||
useResponsesStore.getState = () => responses;
|
||||
// Read lazily: the factory runs before this module's own consts are initialised.
|
||||
const streak = { fetchStreak: () => mockFetchStreak() };
|
||||
const useStreakStore = () => streak;
|
||||
useStreakStore.getState = () => streak;
|
||||
return { useAuthStore, usePacksStore: () => packs, useStreakStore };
|
||||
return { useAuthStore, usePacksStore, useResponsesStore, useStreakStore };
|
||||
});
|
||||
|
||||
jest.mock("@/lib/skippedQuestions", () => ({
|
||||
@@ -45,17 +51,19 @@ const questions = [
|
||||
{ id: "q2", pack_id: "pack-1", question_type: "swipe", text: "Two" },
|
||||
];
|
||||
|
||||
const futureResetAt = "2099-08-29T00:00:00.000Z";
|
||||
|
||||
const openLimit = {
|
||||
responses_today: 4,
|
||||
limit_value: 10,
|
||||
remaining: 6,
|
||||
reset_at: "2026-08-29T00:00:00.000Z",
|
||||
reset_at: futureResetAt,
|
||||
is_blocked: false,
|
||||
};
|
||||
|
||||
const dailyLimit429 = () => new ApiError("Daily response limit reached", 429, {
|
||||
error: { code: "daily_limit", message: "Daily response limit reached" },
|
||||
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: "2026-08-29T00:00:00.000Z" },
|
||||
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: futureResetAt },
|
||||
});
|
||||
|
||||
// clearMocks wipes implementations between tests, so re-arm the whole surface each time.
|
||||
@@ -92,7 +100,7 @@ describe("useSwipeScreen daily limit", () => {
|
||||
responses_today: 10,
|
||||
limit_value: 10,
|
||||
remaining: 0,
|
||||
reset_at: "2026-08-29T00:00:00.000Z",
|
||||
reset_at: futureResetAt,
|
||||
is_blocked: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
import { useCoupleSync, SYNC_INTERVAL_MS } from '@/hooks/useCoupleSync';
|
||||
import { appDataApi, type SyncSummary } from '@/lib/appDataApi';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useMatchStore } from '@/store/matchStore';
|
||||
import { useMessageStore } from '@/store/messageStore';
|
||||
import { usePacksStore } from '@/store/packsStore';
|
||||
import { useStreakStore } from '@/store/streakStore';
|
||||
|
||||
jest.mock('@/lib/appDataApi', () => ({ appDataApi: { syncSummary: jest.fn() } }));
|
||||
|
||||
const mockForeground = { value: true };
|
||||
const mockListeners = new Set<(foreground: boolean) => void>();
|
||||
jest.mock('@/lib/appForeground', () => ({
|
||||
isForeground: () => mockForeground.value,
|
||||
subscribeToForeground: (listener: (foreground: boolean) => void) => {
|
||||
mockListeners.add(listener);
|
||||
return () => { mockListeners.delete(listener); };
|
||||
},
|
||||
}));
|
||||
|
||||
const baseline: SyncSummary = {
|
||||
server_time: '2026-08-27T12:00:00.000Z',
|
||||
couple_id: 'couple',
|
||||
profile_updated_at: '2026-08-01T00:00:00.000Z',
|
||||
partner_id: 'partner',
|
||||
partner_updated_at: '2026-08-01T00:00:00.000Z',
|
||||
match_count: 3,
|
||||
new_match_count: 0,
|
||||
latest_match_at: '2026-08-20T00:00:00.000Z',
|
||||
pending_yours: 1,
|
||||
pending_theirs: 2,
|
||||
unread_total: 0,
|
||||
enabled_packs_fingerprint: 'digest-a',
|
||||
match_state_fingerprint: 'state-a',
|
||||
match_unread_fingerprint: 'unread-a',
|
||||
streak_updated_at: '2026-08-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
|
||||
const tick = async () => {
|
||||
await flush();
|
||||
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS); });
|
||||
await flush();
|
||||
};
|
||||
|
||||
/** Replaces every domain refresh the sync can dispatch with a counter. */
|
||||
function stubRefreshers() {
|
||||
const fetchUser = jest.fn(async () => undefined);
|
||||
const fetchCouple = jest.fn(async () => undefined);
|
||||
const fetchMatches = jest.fn(async () => undefined);
|
||||
const fetchPendingQuestions = jest.fn(async () => undefined);
|
||||
const fetchTheirTurnQuestions = jest.fn(async () => undefined);
|
||||
const fetchEnabledPacks = jest.fn(async () => undefined);
|
||||
const refreshStreak = jest.fn(async () => undefined);
|
||||
useAuthStore.setState({ fetchUser, fetchCouple } as never);
|
||||
useMatchStore.setState({ fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions } as never);
|
||||
usePacksStore.setState({ fetchEnabledPacks } as never);
|
||||
useStreakStore.setState({ refreshStreak } as never);
|
||||
return { fetchUser, fetchCouple, fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions, fetchEnabledPacks, refreshStreak };
|
||||
}
|
||||
|
||||
describe('useCoupleSync', () => {
|
||||
let refreshers: ReturnType<typeof stubRefreshers>;
|
||||
const originals = {
|
||||
auth: { fetchUser: useAuthStore.getState().fetchUser, fetchCouple: useAuthStore.getState().fetchCouple },
|
||||
match: {
|
||||
fetchMatches: useMatchStore.getState().fetchMatches,
|
||||
fetchPendingQuestions: useMatchStore.getState().fetchPendingQuestions,
|
||||
fetchTheirTurnQuestions: useMatchStore.getState().fetchTheirTurnQuestions,
|
||||
},
|
||||
packs: { fetchEnabledPacks: usePacksStore.getState().fetchEnabledPacks },
|
||||
streak: { refreshStreak: useStreakStore.getState().refreshStreak },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
mockForeground.value = true;
|
||||
mockListeners.clear();
|
||||
useMessageStore.setState({ unreadCount: 0 });
|
||||
// Every view starts loaded: this hook must refresh only what is on screen,
|
||||
// and the "not loaded" case is asserted separately below.
|
||||
useMatchStore.setState({ loadedAt: { active: 1, pending: 1, their_turn: 1 } });
|
||||
usePacksStore.setState({ enabledPacksLoadedAt: Date.now() });
|
||||
refreshers = stubRefreshers();
|
||||
});
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
useAuthStore.setState(originals.auth as never);
|
||||
useMatchStore.setState({ ...originals.match, loadedAt: {} } as never);
|
||||
usePacksStore.setState({ ...originals.packs, enabledPacksLoadedAt: null } as never);
|
||||
useStreakStore.setState(originals.streak as never);
|
||||
});
|
||||
|
||||
const answer = (...summaries: SyncSummary[]) => {
|
||||
const mock = appDataApi.syncSummary as jest.Mock;
|
||||
mock.mockReset();
|
||||
for (const summary of summaries) mock.mockResolvedValueOnce(summary);
|
||||
mock.mockResolvedValue(summaries[summaries.length - 1]);
|
||||
};
|
||||
|
||||
it('spends one request per interval and refreshes nothing while the markers hold still', async () => {
|
||||
answer(baseline);
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
|
||||
|
||||
await tick();
|
||||
await tick();
|
||||
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(3);
|
||||
// Three polls, three requests in total: the baseline poll must not trigger
|
||||
// the refresh of five domains that the old five-second sweep performed.
|
||||
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies the unread total from the summary without a second request', async () => {
|
||||
answer(baseline, { ...baseline, unread_total: 4 });
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(4);
|
||||
});
|
||||
|
||||
it('refreshes only the domain whose marker moved', async () => {
|
||||
answer(baseline, { ...baseline, latest_match_at: '2026-08-28T00:00:00.000Z', match_count: 4 });
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
|
||||
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchUser).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes each remaining marker to its own domain', async () => {
|
||||
answer(baseline, {
|
||||
...baseline,
|
||||
pending_yours: 2,
|
||||
pending_theirs: 3,
|
||||
enabled_packs_fingerprint: 'digest-b',
|
||||
partner_updated_at: '2026-08-29T00:00:00.000Z',
|
||||
streak_updated_at: '2026-08-29T00:00:00.000Z',
|
||||
});
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchPendingQuestions).toHaveBeenCalledWith({ silent: true });
|
||||
expect(refreshers.fetchTheirTurnQuestions).toHaveBeenCalledWith({ silent: true });
|
||||
expect(refreshers.fetchEnabledPacks).toHaveBeenCalledTimes(1);
|
||||
expect(refreshers.refreshStreak).toHaveBeenCalledTimes(1);
|
||||
// A partner profile change is a couple-level read; the whole user is only
|
||||
// re-read when the user's own profile or couple membership moved.
|
||||
expect(refreshers.fetchCouple).toHaveBeenCalledTimes(1);
|
||||
expect(refreshers.fetchUser).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes a loaded Matches view when the match-state fingerprint moves in place', async () => {
|
||||
// Counts and latest-created-at do not move for an in-place match_type/
|
||||
// response_summary edit; only the state fingerprint does.
|
||||
answer(baseline, { ...baseline, match_state_fingerprint: 'state-b' });
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
|
||||
});
|
||||
|
||||
it('refreshes a loaded Matches view when unread redistributes without changing the total', async () => {
|
||||
// One match is read while another receives a message: unread_total holds
|
||||
// steady but the per-match fingerprint (and unread-first ordering) moves.
|
||||
answer(baseline, { ...baseline, unread_total: baseline.unread_total, match_unread_fingerprint: 'unread-b' });
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
|
||||
});
|
||||
|
||||
it('does not refresh enabled packs when that domain has never been opened', async () => {
|
||||
usePacksStore.setState({ enabledPacksLoadedAt: null });
|
||||
answer(baseline, { ...baseline, enabled_packs_fingerprint: 'digest-b' });
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resets the baseline when the account identity changes, instead of diffing against the prior account', async () => {
|
||||
answer({ ...baseline, match_count: 3 });
|
||||
const { rerender } = renderHook(
|
||||
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
|
||||
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
|
||||
);
|
||||
await flush();
|
||||
await tick(); // establishes alice's baseline (match_count: 3)
|
||||
|
||||
// Switch accounts: bob's couple has a completely different match_count.
|
||||
// Without a baseline reset this would look like a 12-count jump and
|
||||
// trigger a refresh purely from comparing across accounts.
|
||||
answer({ ...baseline, couple_id: 'couple-b', match_count: 15 });
|
||||
rerender({ userId: 'bob', coupleId: 'couple-b' });
|
||||
await flush();
|
||||
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
|
||||
|
||||
await tick();
|
||||
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-reads the user once when the profile moves, instead of the couple as well', async () => {
|
||||
answer(baseline, {
|
||||
...baseline,
|
||||
profile_updated_at: '2026-08-29T00:00:00.000Z',
|
||||
partner_updated_at: '2026-08-29T00:00:00.000Z',
|
||||
});
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchUser).toHaveBeenCalledTimes(1);
|
||||
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves views the user has never opened unloaded', async () => {
|
||||
useMatchStore.setState({ loadedAt: {} });
|
||||
answer(baseline, {
|
||||
...baseline,
|
||||
match_count: 9,
|
||||
latest_match_at: '2026-08-29T00:00:00.000Z',
|
||||
pending_yours: 7,
|
||||
pending_theirs: 8,
|
||||
});
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
|
||||
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stops entirely for a signed-out user and while the app is backgrounded', async () => {
|
||||
answer(baseline);
|
||||
const { unmount } = renderHook(() => useCoupleSync(undefined, null));
|
||||
await flush();
|
||||
await tick();
|
||||
expect(appDataApi.syncSummary).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
mockForeground.value = false;
|
||||
for (const listener of [...mockListeners]) listener(false);
|
||||
});
|
||||
await tick();
|
||||
await tick();
|
||||
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('discards a summary that lands after the account it belongs to was switched away', async () => {
|
||||
// Alice's poll is still open when Bob signs in. Its answer describes a
|
||||
// couple Bob is not in, so none of it may land: not his badge, not the
|
||||
// baseline his next poll is diffed against, not a refresh decision.
|
||||
let answerAlice: (summary: SyncSummary) => void = () => undefined;
|
||||
const mock = appDataApi.syncSummary as jest.Mock;
|
||||
mock.mockReset();
|
||||
mock.mockReturnValueOnce(new Promise<SyncSummary>(resolve => { answerAlice = resolve; }));
|
||||
|
||||
const { rerender } = renderHook(
|
||||
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
|
||||
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
|
||||
);
|
||||
await flush();
|
||||
expect(mock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const bobBaseline: SyncSummary = { ...baseline, couple_id: 'couple-b', unread_total: 1, match_count: 2 };
|
||||
mock.mockResolvedValue(bobBaseline);
|
||||
rerender({ userId: 'bob', coupleId: 'couple-b' });
|
||||
await flush();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||
|
||||
// Alice's request finally answers, long after she stopped being the user.
|
||||
await act(async () => {
|
||||
answerAlice({ ...baseline, couple_id: 'couple-a', unread_total: 9, match_count: 42 });
|
||||
await Promise.resolve();
|
||||
});
|
||||
await flush();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||
|
||||
// Bob's baseline is still Bob's: a poll identical to his own first one has
|
||||
// nothing to refresh. Had Alice's answer been recorded as the baseline,
|
||||
// this would read as a 40-match jump and refresh the Matches view.
|
||||
await tick();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never runs two summaries at once', async () => {
|
||||
let release: (summary: SyncSummary) => void = () => undefined;
|
||||
(appDataApi.syncSummary as jest.Mock).mockReset();
|
||||
(appDataApi.syncSummary as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
(appDataApi.syncSummary as jest.Mock).mockResolvedValue(baseline);
|
||||
renderHook(() => useCoupleSync('me', 'couple'));
|
||||
await flush();
|
||||
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS * 4); });
|
||||
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { release(baseline); await Promise.resolve(); });
|
||||
await tick();
|
||||
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react-native";
|
||||
import { useDares } from "@/features/dares/hooks/useDares";
|
||||
import { ApiError, apiClient } from "@/lib/apiClient";
|
||||
import type { UseDaresReturn } from "@/features/dares/hooks/useDares";
|
||||
import type { SentDare } from "@/features/dares/types";
|
||||
|
||||
const incoming: SentDare = {
|
||||
@@ -90,3 +91,80 @@ describe("useDares", () => {
|
||||
expect(result.current.error).toBe("offline");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useDares polling cost", () => {
|
||||
/** Counts requests per endpoint and lets the active payload be swapped mid-test. */
|
||||
function mockCounting(active: () => SentDare[]) {
|
||||
const calls = { active: 0, history: 0, catalog: 0, stats: 0 };
|
||||
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
|
||||
if (path.startsWith("/v1/dares?filter=active")) { calls.active += 1; return { dares: active() } as never; }
|
||||
if (path.startsWith("/v1/dares?filter=history")) { calls.history += 1; return { dares: [] } as never; }
|
||||
if (path === "/v1/dares/packs") { calls.catalog += 1; return catalog as never; }
|
||||
if (path === "/v1/dares/stats") { calls.stats += 1; return stats as never; }
|
||||
throw new Error(`unexpected path ${path}`);
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
|
||||
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
|
||||
const tick = async () => {
|
||||
await flush();
|
||||
await act(async () => { jest.advanceTimersByTime(5_000); });
|
||||
await flush();
|
||||
};
|
||||
|
||||
beforeEach(() => { jest.restoreAllMocks(); jest.useFakeTimers(); });
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it("polls the active list alone and re-reads the static endpoints only when it changes", async () => {
|
||||
let current: SentDare[] = [incoming];
|
||||
const calls = mockCounting(() => current);
|
||||
const { result } = renderHook(() => useDares({ isFocused: true }));
|
||||
await flush();
|
||||
expect(result.current.loading).toBe(false);
|
||||
// The first pass is a full load: nothing is cached yet.
|
||||
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
|
||||
|
||||
await tick();
|
||||
await tick();
|
||||
// Two quiet polls cost one request each, not four. The catalogue and the
|
||||
// stats cannot move while the active set is byte-for-byte identical.
|
||||
expect(calls).toEqual({ active: 3, history: 1, catalog: 1, stats: 1 });
|
||||
|
||||
current = [{ ...incoming, status: "active" }];
|
||||
await tick();
|
||||
expect(calls).toEqual({ active: 4, history: 2, catalog: 2, stats: 2 });
|
||||
expect(result.current.active[0]?.status).toBe("active");
|
||||
|
||||
await tick();
|
||||
expect(calls).toEqual({ active: 5, history: 2, catalog: 2, stats: 2 });
|
||||
});
|
||||
|
||||
it("issues nothing at all while the screen is not on top", async () => {
|
||||
const calls = mockCounting(() => [incoming]);
|
||||
const { rerender } = renderHook<UseDaresReturn, { isFocused: boolean }>(
|
||||
({ isFocused }) => useDares({ isFocused }),
|
||||
{ initialProps: { isFocused: false } },
|
||||
);
|
||||
await tick();
|
||||
await tick();
|
||||
expect(calls).toEqual({ active: 0, history: 0, catalog: 0, stats: 0 });
|
||||
|
||||
rerender({ isFocused: true });
|
||||
await flush();
|
||||
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
|
||||
});
|
||||
|
||||
it("never overlaps two polls", async () => {
|
||||
let release: (payload: { dares: SentDare[] }) => void = () => undefined;
|
||||
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
|
||||
if (path.startsWith("/v1/dares?filter=active")) return new Promise(resolve => { release = resolve; }) as never;
|
||||
return { dares: [], ...catalog, ...stats } as never;
|
||||
});
|
||||
renderHook(() => useDares({ isFocused: true }));
|
||||
await flush();
|
||||
await act(async () => { jest.advanceTimersByTime(20_000); });
|
||||
expect((apiClient.get as jest.Mock).mock.calls.filter(([path]) => String(path).includes("filter=active"))).toHaveLength(1);
|
||||
await act(async () => { release({ dares: [] }); await Promise.resolve(); });
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,60 @@ describe('useDrawingSync API polling', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDrawingSync focus gating', () => {
|
||||
const handlers = {
|
||||
onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(), onStrokeEnd: jest.fn(),
|
||||
onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(),
|
||||
};
|
||||
beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); });
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('reads the canvas once on mount and then polls only while the screen is on top', async () => {
|
||||
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
|
||||
const onLoadStrokes = jest.fn();
|
||||
const { rerender } = renderHook<unknown, { isFocused: boolean }>(({ isFocused }) => useDrawingSync({
|
||||
coupleId: 'couple', userId, onLoadStrokes, isFocused, ...handlers,
|
||||
}), { initialProps: { isFocused: false } });
|
||||
|
||||
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
|
||||
// A canvas nobody is looking at costs nothing beyond the state it loaded with.
|
||||
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
|
||||
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ isFocused: true });
|
||||
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
|
||||
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
|
||||
|
||||
rerender({ isFocused: false });
|
||||
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
|
||||
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('never polls over its own write in flight', async () => {
|
||||
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
|
||||
let release: (state: unknown) => void = () => undefined;
|
||||
(appDataApi.putLiveDraw as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
|
||||
const onLoadStrokes = jest.fn();
|
||||
const { result } = renderHook(() => useDrawingSync({
|
||||
coupleId: 'couple', userId, onLoadStrokes, isFocused: true, ...handlers,
|
||||
}));
|
||||
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
|
||||
|
||||
const pending = result.current.persistStrokes([stroke]);
|
||||
await act(async () => { jest.advanceTimersByTime(750 * 4); await Promise.resolve(); });
|
||||
// The write returns the authoritative state; reading over the top of it would
|
||||
// only race a revision we are about to replace.
|
||||
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
release({ strokes: [stroke], revision: 2, updated_at: 'two', updated_by: userId });
|
||||
await pending;
|
||||
});
|
||||
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
|
||||
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDrawingSync stroke deduplication', () => {
|
||||
const at = (id: string, points: number, userId = 'u1') => ({
|
||||
id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })),
|
||||
|
||||
@@ -4,85 +4,183 @@ import { chatApi } from '@/lib/chatApi';
|
||||
|
||||
jest.mock('@/lib/chatApi', () => ({ chatApi: { listMessages: jest.fn(), markRead: jest.fn() } }));
|
||||
|
||||
const message = (id: string, user_id = 'partner') => ({ id, match_id: 'match1', user_id, content: id } as any);
|
||||
const message = (id: string, user_id = 'partner', overrides: Record<string, unknown> = {}) => ({
|
||||
id, match_id: 'match1', user_id, content: id,
|
||||
created_at: '2026-08-27T12:00:00Z', read_at: null, ...overrides,
|
||||
} as any);
|
||||
|
||||
describe('useMessageSubscription API polling', () => {
|
||||
const snapshot = (messages: any[], server_time = 'cursor-1') => ({ messages, removed_ids: [], server_time, complete: true });
|
||||
const delta = (messages: any[], options: { removed?: string[]; server_time?: string; typing?: boolean } = {}) => ({
|
||||
messages,
|
||||
removed_ids: options.removed ?? [],
|
||||
server_time: options.server_time ?? 'cursor-2',
|
||||
complete: false,
|
||||
...(options.typing === undefined ? {} : { typing: { typing: options.typing, expires_at: null } }),
|
||||
});
|
||||
|
||||
const listMessages = chatApi.listMessages as jest.Mock;
|
||||
const markRead = chatApi.markRead as jest.Mock;
|
||||
|
||||
describe('useMessageSubscription incremental polling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [] });
|
||||
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: 'now' });
|
||||
listMessages.mockResolvedValue(snapshot([]));
|
||||
markRead.mockResolvedValue({ updated: 0, read_at: 'now' });
|
||||
});
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('fails closed without both a match and authenticated user', async () => {
|
||||
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
|
||||
const mount = (config: Partial<Parameters<typeof useMessageSubscription>[0]> = {}) =>
|
||||
renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, ...config }));
|
||||
|
||||
it('fails closed without both match and authenticated user', () => {
|
||||
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
|
||||
({ matchId, userId }) => useMessageSubscription({ matchId, userId }),
|
||||
{ initialProps: { matchId: undefined as string | undefined, userId: 'me' as string | undefined } },
|
||||
);
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(chatApi.listMessages).not.toHaveBeenCalled();
|
||||
expect(listMessages).not.toHaveBeenCalled();
|
||||
rerender({ matchId: 'match1', userId: undefined });
|
||||
expect(chatApi.listMessages).not.toHaveBeenCalled();
|
||||
expect(listMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads initial messages without treating them as newly arrived', async () => {
|
||||
it('loads a snapshot first, then requests only changes since the server cursor', async () => {
|
||||
listMessages.mockResolvedValueOnce(snapshot([message('m1')], 'cursor-1'));
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(listMessages).toHaveBeenNthCalledWith(1, 'match1', { typing: true });
|
||||
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
||||
expect(listMessages).toHaveBeenNthCalledWith(2, 'match1', { since: 'cursor-1', typing: true });
|
||||
});
|
||||
|
||||
it('keeps cached messages, and their identity, when a delta carries nothing', async () => {
|
||||
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
|
||||
listMessages.mockResolvedValue(delta([]));
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(result.current.messages).toHaveLength(1));
|
||||
const cached = result.current.messages;
|
||||
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
||||
expect(result.current.messages).toBe(cached);
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('merges delta rows by id, applies self-deletions, and keeps newest first', async () => {
|
||||
listMessages.mockResolvedValueOnce(snapshot([
|
||||
message('m2', 'me', { created_at: '2026-08-27T12:00:02Z' }),
|
||||
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen' }),
|
||||
]));
|
||||
listMessages.mockResolvedValueOnce(delta([
|
||||
message('m3', 'partner', { created_at: '2026-08-27T12:00:03Z', read_at: 'seen' }),
|
||||
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen', deleted_at: 'gone' }),
|
||||
], { removed: ['m2'] }));
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(result.current.messages).toHaveLength(2));
|
||||
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(result.current.messages).toHaveLength(2));
|
||||
expect(result.current.messages.map(m => m.id)).toEqual(['m3', 'm1']);
|
||||
expect(result.current.messages[1]?.deleted_at).toBe('gone');
|
||||
});
|
||||
|
||||
it('marks read only when the response holds an unread partner message', async () => {
|
||||
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner')]));
|
||||
listMessages.mockResolvedValueOnce(delta([message('m2', 'me')]));
|
||||
listMessages.mockResolvedValueOnce(delta([message('m1', 'partner', { read_at: 'seen' })]));
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(markRead).toHaveBeenCalledTimes(1));
|
||||
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(3));
|
||||
expect(markRead).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('issues no request at all while the chat is not focused', async () => {
|
||||
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { focused: boolean }>(
|
||||
({ focused }) => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, isFocused: focused }),
|
||||
{ initialProps: { focused: false } },
|
||||
);
|
||||
await act(async () => { jest.advanceTimersByTime(2_000); });
|
||||
expect(listMessages).not.toHaveBeenCalled();
|
||||
expect(markRead).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ focused: true });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(1));
|
||||
|
||||
rerender({ focused: false });
|
||||
const callsAtBlur = listMessages.mock.calls.length;
|
||||
await act(async () => { jest.advanceTimersByTime(2_000); });
|
||||
expect(listMessages).toHaveBeenCalledTimes(callsAtBlur);
|
||||
});
|
||||
|
||||
it('reports partner arrivals once, and never for the initial load or own messages', async () => {
|
||||
const onNewMessage = jest.fn();
|
||||
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [message('m1')] });
|
||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage }));
|
||||
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
|
||||
listMessages.mockResolvedValueOnce(delta([message('mine', 'me')]));
|
||||
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner')]));
|
||||
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner', { read_at: 'seen' })]));
|
||||
const { result } = mount({ onNewMessage });
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.messages).toEqual([message('m1')]);
|
||||
expect(onNewMessage).not.toHaveBeenCalled();
|
||||
expect(chatApi.markRead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes on the polling interval, marks focused messages read, and reports new partner messages', async () => {
|
||||
const onNewMessage = jest.fn();
|
||||
(chatApi.listMessages as jest.Mock)
|
||||
.mockResolvedValueOnce({ messages: [message('m1')] })
|
||||
.mockResolvedValueOnce({ messages: [message('m2'), message('m1')] });
|
||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => { result.current.isFocusedRef.current = true; });
|
||||
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
|
||||
await waitFor(() => expect(result.current.messages[0]?.id).toBe('m2'));
|
||||
expect(chatApi.markRead).toHaveBeenCalledWith('match1');
|
||||
for (const expected of [2, 3, 4]) {
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(expected));
|
||||
}
|
||||
expect(onNewMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not notify for the current user own newly polled message', async () => {
|
||||
const onNewMessage = jest.fn();
|
||||
(chatApi.listMessages as jest.Mock)
|
||||
.mockResolvedValueOnce({ messages: [] })
|
||||
.mockResolvedValueOnce({ messages: [message('mine', 'me')] });
|
||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
|
||||
expect(onNewMessage).not.toHaveBeenCalled();
|
||||
it('reads partner typing state out of the message response', async () => {
|
||||
listMessages.mockResolvedValueOnce({ ...snapshot([]), typing: { typing: true, expires_at: 'later' } });
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(result.current.partnerTyping).toBe(true));
|
||||
|
||||
listMessages.mockResolvedValueOnce(delta([], { typing: false }));
|
||||
await act(async () => { jest.advanceTimersByTime(500); });
|
||||
await waitFor(() => expect(result.current.partnerTyping).toBe(false));
|
||||
});
|
||||
|
||||
it('surfaces loading completion and preserves a usable empty state after an API error', async () => {
|
||||
(chatApi.listMessages as jest.Mock).mockRejectedValueOnce(new Error('forbidden'));
|
||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
|
||||
listMessages.mockRejectedValueOnce(new Error('forbidden'));
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('prevents overlapping polls and clears the interval on unmount', async () => {
|
||||
let resolveFirst!: (value: { messages: any[] }) => void;
|
||||
(chatApi.listMessages as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
|
||||
const { unmount } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500 }));
|
||||
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
|
||||
act(() => { jest.advanceTimersByTime(1_500); });
|
||||
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
|
||||
await act(async () => resolveFirst({ messages: [] }));
|
||||
it('prevents overlapping polls and stops on unmount', async () => {
|
||||
let resolveFirst!: (value: unknown) => void;
|
||||
listMessages.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
|
||||
const { unmount } = mount();
|
||||
expect(listMessages).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { jest.advanceTimersByTime(1_500); });
|
||||
expect(listMessages).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => { resolveFirst(snapshot([])); });
|
||||
unmount();
|
||||
act(() => { jest.advanceTimersByTime(2_000); });
|
||||
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { jest.advanceTimersByTime(2_000); });
|
||||
expect(listMessages).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restarts immediately, and without a stale cursor, for a different match', async () => {
|
||||
listMessages.mockResolvedValue(snapshot([message('m1')], 'cursor-1'));
|
||||
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string }>(
|
||||
({ matchId }) => useMessageSubscription({ matchId, userId: 'me', pollInterval: 500 }),
|
||||
{ initialProps: { matchId: 'match1' } },
|
||||
);
|
||||
await waitFor(() => expect(result.current.messages).toHaveLength(1));
|
||||
|
||||
rerender({ matchId: 'match2' });
|
||||
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
|
||||
expect(listMessages).toHaveBeenNthCalledWith(2, 'match2', { typing: true });
|
||||
});
|
||||
|
||||
it('supports optimistic external updates', async () => {
|
||||
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
|
||||
const { result } = mount();
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
act(() => result.current.setMessages([message('optimistic', 'me')]));
|
||||
expect(result.current.messages[0]?.id).toBe('optimistic');
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { act, renderHook } from '@testing-library/react-native';
|
||||
import { usePolling } from '@/hooks/usePolling';
|
||||
|
||||
const mockForeground = { value: true };
|
||||
const mockListeners = new Set<(foreground: boolean) => void>();
|
||||
|
||||
jest.mock('@/lib/appForeground', () => ({
|
||||
isForeground: () => mockForeground.value,
|
||||
subscribeToForeground: (listener: (foreground: boolean) => void) => {
|
||||
mockListeners.add(listener);
|
||||
return () => { mockListeners.delete(listener); };
|
||||
},
|
||||
}));
|
||||
|
||||
const setForeground = async (value: boolean) => {
|
||||
await act(async () => {
|
||||
mockForeground.value = value;
|
||||
for (const listener of [...mockListeners]) listener(value);
|
||||
});
|
||||
};
|
||||
// The next run is scheduled once the previous one settles, so let pending
|
||||
// promises land before and after moving the clock.
|
||||
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
|
||||
const tick = async (ms: number) => {
|
||||
await flush();
|
||||
await act(async () => { jest.advanceTimersByTime(ms); });
|
||||
await flush();
|
||||
};
|
||||
|
||||
describe('usePolling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
mockForeground.value = true;
|
||||
mockListeners.clear();
|
||||
});
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('runs once immediately and then on the interval', async () => {
|
||||
const task = jest.fn(async () => undefined);
|
||||
renderHook(() => usePolling(task, { intervalMs: 100 }));
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('skips the leading run when asked to', async () => {
|
||||
const task = jest.fn(async () => undefined);
|
||||
renderHook(() => usePolling(task, { intervalMs: 100, leading: false }));
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('issues nothing while disabled and starts once enabled', async () => {
|
||||
const task = jest.fn(async () => undefined);
|
||||
const { rerender } = renderHook<void, { enabled: boolean }>(({ enabled }) => usePolling(task, { intervalMs: 100, enabled }), {
|
||||
initialProps: { enabled: false },
|
||||
});
|
||||
await tick(1_000);
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
rerender({ enabled: true });
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops while the app is backgrounded and catches up on return', async () => {
|
||||
const task = jest.fn(async () => undefined);
|
||||
renderHook(() => usePolling(task, { intervalMs: 100 }));
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
await setForeground(false);
|
||||
await tick(1_000);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
await setForeground(true);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('never runs two tasks concurrently', async () => {
|
||||
let release!: () => void;
|
||||
const task = jest.fn(() => new Promise<void>(resolve => { release = resolve; }));
|
||||
renderHook(() => usePolling(task, { intervalMs: 100 }));
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
await tick(500);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => { release(); });
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('backs off after consecutive failures and recovers after a success', async () => {
|
||||
const task = jest.fn(async (): Promise<void> => { throw new Error('offline'); });
|
||||
renderHook(() => usePolling(task, { intervalMs: 100, maxIntervalMs: 400 }));
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
|
||||
await tick(399);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
await tick(1);
|
||||
expect(task).toHaveBeenCalledTimes(3);
|
||||
|
||||
task.mockImplementation(async () => undefined);
|
||||
await tick(400);
|
||||
expect(task).toHaveBeenCalledTimes(4);
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('restarts immediately when the reset key changes', async () => {
|
||||
const task = jest.fn(async () => undefined);
|
||||
const { rerender } = renderHook<void, { resetKey: string }>(({ resetKey }) => usePolling(task, { intervalMs: 100, resetKey }), {
|
||||
initialProps: { resetKey: 'a' },
|
||||
});
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
rerender({ resetKey: 'b' });
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
await tick(50);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops and unsubscribes on unmount', async () => {
|
||||
const task = jest.fn(async () => undefined);
|
||||
const { unmount } = renderHook(() => usePolling(task, { intervalMs: 100 }));
|
||||
await tick(100);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
unmount();
|
||||
expect(mockListeners.size).toBe(0);
|
||||
await tick(1_000);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -76,4 +76,22 @@ describe('useTypingIndicator API polling', () => {
|
||||
act(() => { jest.advanceTimersByTime(2_000); });
|
||||
expect(chatApi.getTyping).toHaveBeenCalledTimes(calls);
|
||||
});
|
||||
it('stops polling and mirrors typing state supplied by the message poll', async () => {
|
||||
const { result, rerender } = renderHook<ReturnType<typeof useTypingIndicator>, { externalTyping: boolean | undefined }>(
|
||||
({ externalTyping }) => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', pollInterval: 500, externalTyping }),
|
||||
{ initialProps: { externalTyping: undefined as boolean | undefined } },
|
||||
);
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
const callsBeforeFolding = (chatApi.getTyping as jest.Mock).mock.calls.length;
|
||||
|
||||
rerender({ externalTyping: true });
|
||||
expect(result.current.partnerTyping).toBe(true);
|
||||
await act(async () => { jest.advanceTimersByTime(5_000); });
|
||||
expect(chatApi.getTyping).toHaveBeenCalledTimes(callsBeforeFolding);
|
||||
|
||||
act(() => result.current.clearTypingIndicator());
|
||||
expect(result.current.partnerTyping).toBe(false);
|
||||
rerender({ externalTyping: false });
|
||||
expect(result.current.partnerTyping).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AppState } from 'react-native';
|
||||
import { isForeground, isForegroundState, subscribeToForeground } from '@/lib/appForeground';
|
||||
|
||||
describe('appForeground', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('treats only background and inactive as away', () => {
|
||||
expect(isForegroundState('active')).toBe(true);
|
||||
expect(isForegroundState('background')).toBe(false);
|
||||
expect(isForegroundState('inactive')).toBe(false);
|
||||
// Unknown states — including whatever a test harness leaves behind — must
|
||||
// fail towards running, never towards disabling recurring work forever.
|
||||
expect(isForegroundState(undefined)).toBe(true);
|
||||
expect(isForegroundState('extension')).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the current state and reports transitions until unsubscribed', () => {
|
||||
let emit: (state: string) => void = () => undefined;
|
||||
const remove = jest.fn();
|
||||
jest.spyOn(AppState, 'addEventListener').mockImplementation(((_: string, handler: (state: string) => void) => {
|
||||
emit = handler;
|
||||
return { remove } as never;
|
||||
}) as never);
|
||||
|
||||
expect(isForeground()).toBe(isForegroundState(AppState.currentState));
|
||||
|
||||
const seen: boolean[] = [];
|
||||
const unsubscribe = subscribeToForeground(value => seen.push(value));
|
||||
emit('background');
|
||||
emit('active');
|
||||
expect(seen).toEqual([false, true]);
|
||||
|
||||
unsubscribe();
|
||||
expect(remove).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,18 @@ describe('chatApi', () => {
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/typing');
|
||||
});
|
||||
|
||||
it('encodes the delta cursor and folded typing request into the message query', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ messages: [], removed_ids: [], server_time: 'now', complete: false });
|
||||
|
||||
await chatApi.listMessages('match1', { typing: true });
|
||||
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000+01:00' });
|
||||
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000Z', typing: true });
|
||||
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/matches/match1/messages?typing=true');
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000%2B01%3A00');
|
||||
expect(apiClient.get).toHaveBeenNthCalledWith(3, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000Z&typing=true');
|
||||
});
|
||||
|
||||
it('uses authenticated unread, receipt, deletion and reporting routes', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ total: 0, by_match: {} });
|
||||
(apiClient.post as jest.Mock).mockResolvedValue({});
|
||||
|
||||
@@ -45,9 +45,18 @@ describe('matchStore API behavior', () => {
|
||||
});
|
||||
|
||||
it('guards concurrent loads and exhausted pagination', async () => {
|
||||
useMatchStore.setState({ isLoading: true });
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
useMatchStore.setState({ isLoading: false, hasMore: false });
|
||||
// Overlap protection cannot key off the loading flags any more: a silent
|
||||
// refresh raises none of them and still must not issue a second request.
|
||||
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
|
||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const inFlight = useMatchStore.getState().fetchMatches(true);
|
||||
await useMatchStore.getState().fetchMatches(true, { silent: true });
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||
release({ matches: [], totalCount: 0 });
|
||||
await inFlight;
|
||||
|
||||
(apiClient.get as jest.Mock).mockClear();
|
||||
useMatchStore.setState({ hasMore: false });
|
||||
await useMatchStore.getState().fetchMatches(false);
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -147,7 +156,10 @@ describe('matchStore API behavior', () => {
|
||||
it('toggles archived visibility and triggers an initial fetch only', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 });
|
||||
useMatchStore.getState().toggleShowArchived();
|
||||
await Promise.resolve();
|
||||
// Let the floating fetch settle: its in-flight guard is module-scoped and
|
||||
// is only released in a finally, so a half-run request would leak into
|
||||
// the next test and silently suppress its archived fetch.
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
expect(useMatchStore.getState().showArchived).toBe(true);
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||
useMatchStore.getState().toggleShowArchived();
|
||||
@@ -178,11 +190,19 @@ describe('matchStore API behavior', () => {
|
||||
});
|
||||
|
||||
it('updates current view and delegates an empty view fetch', () => {
|
||||
const real = useMatchStore.getState().fetchArchivedMatches;
|
||||
const fetchArchivedMatches = jest.fn(async () => undefined);
|
||||
useMatchStore.setState({ fetchArchivedMatches });
|
||||
useMatchStore.getState().setCurrentView('archived');
|
||||
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
|
||||
expect(fetchArchivedMatches).toHaveBeenCalled();
|
||||
try {
|
||||
useMatchStore.getState().setCurrentView('archived');
|
||||
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
|
||||
expect(fetchArchivedMatches).toHaveBeenCalled();
|
||||
} finally {
|
||||
// Actions live in the same store as the data, and clearMatches does not
|
||||
// reset them: leaving the stub in place would silence every later test's
|
||||
// archived fetch.
|
||||
useMatchStore.setState({ fetchArchivedMatches: real });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -225,6 +245,222 @@ describe('matchStore API behavior', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('view switching and cache freshness', () => {
|
||||
/** Answers each of the four view endpoints with an empty payload. */
|
||||
const emptyViews = () => (apiClient.get as jest.Mock).mockImplementation(async (path: string) => {
|
||||
if (path.startsWith('/v1/matches?archived=true')) return { matches: [], totalCount: 0 };
|
||||
if (path.startsWith('/v1/matches')) return { matches: [], totalCount: 0, hasMore: false };
|
||||
return { questions: [] };
|
||||
});
|
||||
|
||||
it('switches between loaded views without a single extra request, even when every view is empty', async () => {
|
||||
emptyViews();
|
||||
const views = ['pending', 'their_turn', 'active', 'archived'] as const;
|
||||
|
||||
// First visit to each view loads it once.
|
||||
for (const view of views) {
|
||||
useMatchStore.getState().setCurrentView(view);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
}
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(views.length);
|
||||
expect(useMatchStore.getState()).toMatchObject({
|
||||
matches: [], pendingQuestions: [], theirTurnQuestions: [], archivedMatches: [],
|
||||
});
|
||||
|
||||
// Every subsequent switch is served from cache. An empty view is loaded,
|
||||
// not unloaded: this is the bug where `array.length === 0` meant "never
|
||||
// fetched" and made every visit pay for the same empty answer.
|
||||
(apiClient.get as jest.Mock).mockClear();
|
||||
for (const view of [...views, ...views].reverse()) {
|
||||
useMatchStore.getState().setCurrentView(view);
|
||||
await jest.runOnlyPendingTimersAsync();
|
||||
}
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps cached rows and raises no foreground spinner during a silent refresh', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
|
||||
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const refreshing = useMatchStore.getState().fetchMatches(true, { silent: true });
|
||||
|
||||
// Mid-flight: the user still sees the rows they had, and neither the
|
||||
// full-screen loader nor the pull-to-refresh spinner is showing.
|
||||
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: false });
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
|
||||
|
||||
release({ matches: [match('fresh', false)], totalCount: 1, hasMore: false });
|
||||
await refreshing;
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
|
||||
});
|
||||
|
||||
it('shows the pull-to-refresh spinner for a user-initiated refresh but never the initial loader', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const refreshing = useMatchStore.getState().fetchMatches(true);
|
||||
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: true });
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
|
||||
release({ matches: [], totalCount: 0, hasMore: false });
|
||||
await refreshing;
|
||||
expect(useMatchStore.getState()).toMatchObject({ isRefreshing: false });
|
||||
});
|
||||
|
||||
it('keeps a silent list refresh out of the pending and their-turn loaders', async () => {
|
||||
(apiClient.get as jest.Mock).mockResolvedValue({ questions: [pending('p1')] });
|
||||
await useMatchStore.getState().fetchPendingQuestions();
|
||||
await useMatchStore.getState().fetchTheirTurnQuestions();
|
||||
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
(apiClient.get as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
|
||||
const quiet = Promise.all([
|
||||
useMatchStore.getState().fetchPendingQuestions({ silent: true }),
|
||||
useMatchStore.getState().fetchTheirTurnQuestions({ silent: true }),
|
||||
]);
|
||||
expect(useMatchStore.getState()).toMatchObject({ isLoadingPending: false, isLoadingTheirTurn: false });
|
||||
expect(useMatchStore.getState().pendingQuestions).toHaveLength(1);
|
||||
release({ questions: [] });
|
||||
await quiet;
|
||||
});
|
||||
});
|
||||
|
||||
describe('account isolation (generation tokens)', () => {
|
||||
it('does not let a stale fetchMatches response populate the store after sign-out, and lets the next account start immediately', async () => {
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleFetch = useMatchStore.getState().fetchMatches(true);
|
||||
|
||||
// Simulates sign-out/account switch while the request is still in flight.
|
||||
useMatchStore.getState().clearMatches();
|
||||
release({ matches: [match('stale')], totalCount: 1, hasMore: false });
|
||||
await staleFetch;
|
||||
|
||||
expect(useMatchStore.getState().matches).toEqual([]);
|
||||
|
||||
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('fresh')], totalCount: 1, hasMore: false });
|
||||
await useMatchStore.getState().fetchMatches(true);
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
|
||||
});
|
||||
|
||||
it('does not let a stale seen-marker write over the next account\'s list', async () => {
|
||||
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
|
||||
let release: () => void = () => undefined;
|
||||
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
|
||||
const stale = useMatchStore.getState().markAsSeen('theirs');
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
useMatchStore.setState({ matches: [match('mine')], newMatchesCount: 1 });
|
||||
const untouched = useMatchStore.getState().matches;
|
||||
release();
|
||||
await stale;
|
||||
|
||||
// Not merely "the same ids": the previous account's response must not
|
||||
// rewrite the array at all, because the recount it derives is only true
|
||||
// of the rows that account had loaded.
|
||||
expect(useMatchStore.getState().matches).toBe(untouched);
|
||||
expect(useMatchStore.getState().newMatchesCount).toBe(1);
|
||||
});
|
||||
|
||||
it('does not let a stale mark-all-seen clear the next account\'s new badges', async () => {
|
||||
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
|
||||
let release: () => void = () => undefined;
|
||||
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
|
||||
const stale = useMatchStore.getState().markAllAsSeen();
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
useMatchStore.setState({ matches: [match('mine', true), match('mine2', true)], newMatchesCount: 2 });
|
||||
release();
|
||||
await stale;
|
||||
|
||||
expect(useMatchStore.getState().matches.every(item => item.is_new)).toBe(true);
|
||||
expect(useMatchStore.getState().newMatchesCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not roll a failed archive or unarchive back into the next account', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
let fail: (error: Error) => void = () => undefined;
|
||||
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
|
||||
useMatchStore.setState({ matches: [match('theirs')], archivedMatches: [], archivedMatchIds: new Set<string>(), totalCount: 1 });
|
||||
const staleArchive = useMatchStore.getState().archiveMatch('theirs');
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
useMatchStore.setState({ matches: [match('mine')], totalCount: 1 });
|
||||
fail(new Error('forbidden'));
|
||||
await staleArchive;
|
||||
|
||||
// The revert would otherwise put one couple's match into another's list.
|
||||
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['mine']);
|
||||
expect(useMatchStore.getState().totalCount).toBe(1);
|
||||
|
||||
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
|
||||
useMatchStore.setState({ archivedMatches: [match('theirsArchived')], archivedMatchIds: new Set(['theirsArchived']) });
|
||||
const staleUnarchive = useMatchStore.getState().unarchiveMatch('theirsArchived');
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
useMatchStore.setState({ archivedMatches: [match('mineArchived')], archivedMatchIds: new Set(['mineArchived']), totalCount: 0 });
|
||||
fail(new Error('forbidden'));
|
||||
await staleUnarchive;
|
||||
|
||||
expect(useMatchStore.getState().archivedMatches.map(item => item.id)).toEqual(['mineArchived']);
|
||||
expect(useMatchStore.getState().totalCount).toBe(0);
|
||||
});
|
||||
|
||||
it('does not carry a nudge cooldown across an account switch', async () => {
|
||||
let release: (data: unknown) => void = () => undefined;
|
||||
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleSend = useMatchStore.getState().sendNudge();
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
release({ success: true, notification_sent: true, next_nudge_available_at: '2026-08-28T00:00:00Z' });
|
||||
await expect(staleSend).resolves.toEqual({ success: false, notificationSent: false });
|
||||
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
|
||||
|
||||
// The 429 branch writes a cooldown too, and must be gated the same way.
|
||||
let fail: (error: Error) => void = () => undefined;
|
||||
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
|
||||
const staleLimit = useMatchStore.getState().sendNudge();
|
||||
useMatchStore.getState().clearMatches();
|
||||
fail(new ApiError('rate limited', 429, { cooldown_remaining_seconds: 60 }));
|
||||
await staleLimit;
|
||||
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
|
||||
});
|
||||
|
||||
it('does not let a stale cooldown read clear the next account\'s cooldown', async () => {
|
||||
let release: (profile: unknown) => void = () => undefined;
|
||||
(appDataApi.nudgeStatus as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const stale = useMatchStore.getState().checkNudgeCooldown();
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
const mine = new Date('2026-08-27T18:00:00Z');
|
||||
useMatchStore.setState({ nudgeCooldownUntil: mine });
|
||||
// The previous account not being in cooldown says nothing about this one.
|
||||
release({ last_nudge_sent_at: null });
|
||||
await stale;
|
||||
expect(useMatchStore.getState().nudgeCooldownUntil).toEqual(mine);
|
||||
});
|
||||
|
||||
it('does not let a stale fetchPendingQuestions response populate the store after sign-out, and lets the next account start immediately', async () => {
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleFetch = useMatchStore.getState().fetchPendingQuestions();
|
||||
|
||||
useMatchStore.getState().clearMatches();
|
||||
release({ questions: [pending('stale')] });
|
||||
await staleFetch;
|
||||
|
||||
expect(useMatchStore.getState().pendingQuestions).toEqual([]);
|
||||
|
||||
(apiClient.get as jest.Mock).mockResolvedValueOnce({ questions: [pending('fresh')] });
|
||||
await useMatchStore.getState().fetchPendingQuestions();
|
||||
expect(useMatchStore.getState().pendingQuestions.map(item => item.id)).toEqual(['fresh']);
|
||||
});
|
||||
});
|
||||
|
||||
it('clearMatches resets all user-scoped state', () => {
|
||||
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')], pendingQuestions: [pending('p')], theirTurnQuestions: [pending('t')], currentView: 'archived', showArchived: true, isNudging: true });
|
||||
useMatchStore.getState().clearMatches();
|
||||
|
||||
@@ -47,15 +47,25 @@ describe('messageStore API state', () => {
|
||||
expect(useMessageStore.getState()).toMatchObject({ unreadCount: 1, lastMessage: { id: 'new' } });
|
||||
});
|
||||
|
||||
it('marks partner messages read then reconciles global and per-match counts', async () => {
|
||||
it('marks partner messages read and subtracts the cleared count without re-reading the total', async () => {
|
||||
useMessageStore.setState({ unreadCount: 3 });
|
||||
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 2, read_at: '2026-08-27T00:00:00.000Z' });
|
||||
(chatApi.unread as jest.Mock).mockResolvedValue({ total: 1, by_match: { m2: 1 } });
|
||||
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||
expect(chatApi.markRead).toHaveBeenCalledWith('m1');
|
||||
expect(chatApi.unread).not.toHaveBeenCalled();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(1);
|
||||
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(1);
|
||||
});
|
||||
|
||||
it('spends no request and touches no count when the read API cleared nothing', async () => {
|
||||
useMessageStore.setState({ unreadCount: 3 });
|
||||
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: null });
|
||||
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||
expect(chatApi.unread).not.toHaveBeenCalled();
|
||||
expect(useMessageStore.getState().unreadCount).toBe(3);
|
||||
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
|
||||
});
|
||||
|
||||
it('does not mark read without an authenticated user', async () => {
|
||||
useAuthStore.setState({ user: null } as any);
|
||||
await useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||
@@ -69,6 +79,42 @@ describe('messageStore API state', () => {
|
||||
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
|
||||
});
|
||||
|
||||
describe('account isolation (generation tokens)', () => {
|
||||
it('does not let a stale unread total become the next account\'s badge', async () => {
|
||||
let release: (value: unknown) => void = () => undefined;
|
||||
(chatApi.unread as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleFetch = useMessageStore.getState().fetchUnreadCount();
|
||||
|
||||
// Sign-out or leaving a couple, while the request is still open.
|
||||
useMessageStore.getState().clearMessages();
|
||||
useMessageStore.setState({ unreadCount: 2 });
|
||||
|
||||
release({ total: 9, by_match: { m1: 9 } });
|
||||
await staleFetch;
|
||||
|
||||
expect(useMessageStore.getState().unreadCount).toBe(2);
|
||||
});
|
||||
|
||||
it('does not subtract a stale read receipt from the next account\'s badge', async () => {
|
||||
useMessageStore.setState({ unreadCount: 3 });
|
||||
let release: (value: unknown) => void = () => undefined;
|
||||
(chatApi.markRead as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleRead = useMessageStore.getState().markMatchMessagesAsRead('m1');
|
||||
|
||||
useMessageStore.getState().clearMessages();
|
||||
useMessageStore.setState({ unreadCount: 2 });
|
||||
useMatchStore.setState({ matches: [{ id: 'm1', unreadCount: 2 } as any] });
|
||||
|
||||
release({ updated: 3, read_at: '2026-08-27T00:00:00.000Z' });
|
||||
await staleRead;
|
||||
|
||||
// The badge is wrong whatever the match id; reusing 'm1' also makes the
|
||||
// second half of the damage — the per-match count — visible.
|
||||
expect(useMessageStore.getState().unreadCount).toBe(2);
|
||||
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('clears transient message state', () => {
|
||||
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'last' } as any, activeMatchId: 'm1' });
|
||||
useMessageStore.getState().clearLastMessage();
|
||||
|
||||
@@ -64,4 +64,69 @@ describe('packsStore standalone API integration', () => {
|
||||
expect(getSpy).not.toHaveBeenCalled();
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
||||
});
|
||||
|
||||
describe('account isolation (generation tokens)', () => {
|
||||
it('does not let a stale fetchEnabledPacks response populate the store after clearPacks, and lets the next account start immediately', async () => {
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleFetch = usePacksStore.getState().fetchEnabledPacks();
|
||||
|
||||
// Simulates sign-out/account switch while the request is still in flight.
|
||||
usePacksStore.getState().clearPacks();
|
||||
release({ enabledPackIds: ['stale-pack'] });
|
||||
await staleFetch;
|
||||
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
||||
|
||||
getSpy.mockResolvedValueOnce({ enabledPackIds: ['fresh-pack'] });
|
||||
await usePacksStore.getState().fetchEnabledPacks();
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual(['fresh-pack']);
|
||||
});
|
||||
|
||||
it('does not let a stale fetchPackProgress response populate the store after clearPacks, and lets the next account start immediately', async () => {
|
||||
let release: (page: unknown) => void = () => undefined;
|
||||
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleFetch = usePacksStore.getState().fetchPackProgress();
|
||||
|
||||
usePacksStore.getState().clearPacks();
|
||||
release({ progress: [{ packId: 'stale', totalQuestions: 1, answeredQuestions: 1 }] });
|
||||
await staleFetch;
|
||||
|
||||
expect(usePacksStore.getState().packProgress.size).toBe(0);
|
||||
|
||||
getSpy.mockResolvedValueOnce({ progress: [{ packId: 'fresh', totalQuestions: 2, answeredQuestions: 1 }] });
|
||||
await usePacksStore.getState().fetchPackProgress();
|
||||
expect(usePacksStore.getState().packProgress.get('fresh')).toEqual({ totalQuestions: 2, answeredQuestions: 1 });
|
||||
});
|
||||
|
||||
it('does not let a stale toggle write the previous couple\'s pack selection', async () => {
|
||||
usePacksStore.setState({ enabledPackIds: [] } as any);
|
||||
let release: (value: unknown) => void = () => undefined;
|
||||
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
|
||||
const staleToggle = usePacksStore.getState().togglePack('pack1');
|
||||
|
||||
usePacksStore.getState().clearPacks();
|
||||
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
|
||||
|
||||
release({ enabledPackIds: ['pack1'] });
|
||||
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
|
||||
});
|
||||
|
||||
it('does not roll a failed toggle back into the next couple\'s selection', async () => {
|
||||
usePacksStore.setState({ enabledPackIds: ['pack1'] } as any);
|
||||
let reject: (reason: unknown) => void = () => undefined;
|
||||
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail; }));
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const staleToggle = usePacksStore.getState().togglePack('pack1');
|
||||
|
||||
usePacksStore.getState().clearPacks();
|
||||
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
|
||||
|
||||
reject(new Error('offline'));
|
||||
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
|
||||
// The rollback would have re-added pack1, which this couple never enabled.
|
||||
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -265,6 +265,53 @@ describe('responsesStore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('account isolation (generation tokens)', () => {
|
||||
it('does not let a stale fetchResponses response populate the store after clearResponses, and lets the next account start immediately', async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||
const staleFetch = useResponsesStore.getState().fetchResponses(true);
|
||||
|
||||
// Simulates sign-out/account switch while the request is still in flight.
|
||||
useResponsesStore.getState().clearResponses();
|
||||
resolve({ responses: [response({ id: 'stale' })], totalCount: 1 });
|
||||
await staleFetch;
|
||||
|
||||
expect(useResponsesStore.getState().responses).toEqual([]);
|
||||
|
||||
apiGet.mockResolvedValueOnce({ responses: [response({ id: 'fresh' })], totalCount: 1 });
|
||||
await useResponsesStore.getState().fetchResponses(true);
|
||||
expect(useResponsesStore.getState().responses.map((r) => r.id)).toEqual(['fresh']);
|
||||
});
|
||||
|
||||
it('does not let a stale edit rewrite the next account\'s row or refresh its matches', async () => {
|
||||
// Question ids come from the shared catalogue, so the same id names a real
|
||||
// row in both accounts' lists: a stale edit would land on one of them.
|
||||
const fetchMatches = jest.fn();
|
||||
useMatchStore.setState({ fetchMatches } as any);
|
||||
useResponsesStore.setState({
|
||||
responses: [response({ id: 'alice', answer: 'maybe', has_match: false, match_id: undefined })],
|
||||
});
|
||||
|
||||
let resolve!: (value: unknown) => void;
|
||||
apiPatch.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||
const staleEdit = useResponsesStore.getState().updateResponse('q1', 'yes');
|
||||
|
||||
useResponsesStore.getState().clearResponses();
|
||||
const next = [response({ id: 'bob', answer: 'no', has_match: false, match_id: undefined })];
|
||||
useResponsesStore.setState({ responses: next });
|
||||
|
||||
resolve({ success: true, new_match: { id: 'm2' } });
|
||||
// The caller still hears what the server did for the account that asked.
|
||||
await expect(staleEdit).resolves.toMatchObject({ success: true });
|
||||
|
||||
expect(useResponsesStore.getState().responses).toBe(next);
|
||||
expect(useResponsesStore.getState().responses[0]).toMatchObject({
|
||||
id: 'bob', answer: 'no', has_match: false,
|
||||
});
|
||||
expect(fetchMatches).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('state controls', () => {
|
||||
it('sets and toggles grouping options', () => {
|
||||
useResponsesStore.getState().setGroupBy('pack');
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { type CoupleStreak, useStreakStore } from '@/store/streakStore';
|
||||
|
||||
jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn() } }));
|
||||
|
||||
const apiGet = apiClient.get as jest.Mock;
|
||||
|
||||
function streak(overrides: Partial<CoupleStreak> = {}): CoupleStreak {
|
||||
return {
|
||||
couple_id: 'couple1', current_streak: 1, longest_streak: 1,
|
||||
last_active_date: '2026-08-27', last_completed_date: '2026-08-27',
|
||||
you_answered_today: true, partner_answered_today: false,
|
||||
partner_name: 'Partner', timezone: 'UTC', streak_celebrated_at: 0,
|
||||
created_at: '2026-08-01T00:00:00.000Z', updated_at: '2026-08-27T00:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('streakStore account isolation (generation tokens)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
useStreakStore.setState({ streak: null, isLoading: false, error: null, loadedAt: null, generation: 0 });
|
||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
|
||||
});
|
||||
|
||||
it('does not let a stale fetchStreak response populate the store after clearStreak, and lets the next account start immediately', async () => {
|
||||
let resolve!: (value: unknown) => void;
|
||||
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||
const staleFetch = useStreakStore.getState().fetchStreak();
|
||||
|
||||
// Simulates sign-out/account switch while the request is still in flight.
|
||||
useStreakStore.getState().clearStreak();
|
||||
resolve({ streak: streak({ couple_id: 'stale-couple' }) });
|
||||
await staleFetch;
|
||||
|
||||
expect(useStreakStore.getState().streak).toBeNull();
|
||||
expect(useStreakStore.getState().loadedAt).toBeNull();
|
||||
|
||||
apiGet.mockResolvedValueOnce({ streak: streak({ couple_id: 'fresh-couple' }) });
|
||||
await useStreakStore.getState().fetchStreak();
|
||||
expect(useStreakStore.getState().streak?.couple_id).toBe('fresh-couple');
|
||||
expect(useStreakStore.getState().loadedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it('refreshStreak silently reloads only an already-loaded streak', async () => {
|
||||
await useStreakStore.getState().refreshStreak();
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
|
||||
apiGet.mockResolvedValueOnce({ streak: streak() });
|
||||
await useStreakStore.getState().fetchStreak();
|
||||
expect(useStreakStore.getState().loadedAt).not.toBeNull();
|
||||
|
||||
apiGet.mockResolvedValueOnce({ streak: streak({ current_streak: 2 }) });
|
||||
await useStreakStore.getState().refreshStreak();
|
||||
expect(useStreakStore.getState().streak?.current_streak).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -43,12 +43,12 @@ export function streakStatus(streak: CoupleStreak): { headline: string; status:
|
||||
* Displays the couple's shared streak. The flame pulses while the streak is alive.
|
||||
*/
|
||||
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
|
||||
const { streak, fetchStreak } = useStreakStore();
|
||||
const { streak, ensureStreakLoaded } = useStreakStore();
|
||||
|
||||
// Fetch streak on mount
|
||||
useEffect(() => {
|
||||
fetchStreak();
|
||||
}, [fetchStreak]);
|
||||
void ensureStreakLoaded();
|
||||
}, [ensureStreakLoaded]);
|
||||
|
||||
// Flame pulse animation
|
||||
const scale = useSharedValue(1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
|
||||
import { useLocalSearchParams, useRouter } from "expo-router";
|
||||
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
|
||||
@@ -60,20 +60,31 @@ export const ChatScreen: React.FC = () => {
|
||||
|
||||
const isFocused = useIsFocused();
|
||||
|
||||
// Typing indicator hook
|
||||
// Each hook needs a value the other produces: the message poll reports typing
|
||||
// state, and an arriving message clears the indicator. The ref breaks the cycle.
|
||||
const clearTypingRef = useRef<() => void>(() => undefined);
|
||||
const handleNewMessage = useCallback(() => clearTypingRef.current(), []);
|
||||
|
||||
// Message subscription hook
|
||||
const { messages, setMessages, partnerTyping: syncedTyping } = useMessageSubscription({
|
||||
matchId,
|
||||
userId: user?.id,
|
||||
onNewMessage: handleNewMessage,
|
||||
isFocused,
|
||||
});
|
||||
|
||||
// Typing indicator hook, reading the state the message poll already fetched
|
||||
const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({
|
||||
channelName: `chat:${matchId}`,
|
||||
matchId,
|
||||
userId: user?.id,
|
||||
isFocused,
|
||||
externalTyping: syncedTyping,
|
||||
});
|
||||
|
||||
// Message subscription hook
|
||||
const { messages, setMessages, isFocusedRef } = useMessageSubscription({
|
||||
matchId,
|
||||
userId: user?.id,
|
||||
onNewMessage: clearTypingIndicator,
|
||||
});
|
||||
useEffect(() => {
|
||||
clearTypingRef.current = clearTypingIndicator;
|
||||
}, [clearTypingIndicator]);
|
||||
|
||||
|
||||
// Message actions hook for deletion and reporting
|
||||
@@ -174,16 +185,12 @@ export const ChatScreen: React.FC = () => {
|
||||
// Track active chat to prevent notifications for current chat
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
isFocusedRef.current = true;
|
||||
if (matchId) {
|
||||
setActiveMatchId(matchId);
|
||||
markMatchMessagesAsRead(matchId);
|
||||
Events.matchViewed();
|
||||
}
|
||||
return () => {
|
||||
isFocusedRef.current = false;
|
||||
setActiveMatchId(null);
|
||||
};
|
||||
return () => setActiveMatchId(null);
|
||||
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { useIsFocused } from "@react-navigation/native";
|
||||
import Animated, { FadeIn } from "react-native-reanimated";
|
||||
import { GradientBackground } from "../../components/ui";
|
||||
import { Paywall } from "../../components/paywall";
|
||||
@@ -20,7 +21,8 @@ const TABS: { key: Tab; label: string }[] = [
|
||||
];
|
||||
|
||||
export function DaresScreen() {
|
||||
const dares = useDares();
|
||||
const isFocused = useIsFocused();
|
||||
const dares = useDares({ isFocused });
|
||||
const [tab, setTab] = useState<Tab>("active");
|
||||
const [sheetPack, setSheetPack] = useState<DarePack | null>(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { daresApi } from "../../../lib/daresApi";
|
||||
import { ApiError } from "../../../lib/apiClient";
|
||||
import { usePolling } from "../../../hooks/usePolling";
|
||||
import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types";
|
||||
|
||||
/** Matches the chat poll cadence; the API has no realtime channel. */
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
|
||||
export interface UseDaresConfig {
|
||||
/**
|
||||
* Supplied by the screen rather than read from navigation here, so the hook
|
||||
* stays renderable without a navigator. Polling stops when the screen is not
|
||||
* on top; `usePolling` additionally stops it when the app backgrounds.
|
||||
*/
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
export interface UseDaresReturn {
|
||||
active: SentDare[];
|
||||
history: SentDare[];
|
||||
@@ -25,7 +35,19 @@ export interface UseDaresReturn {
|
||||
cancel: (dareId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDares(): UseDaresReturn {
|
||||
/**
|
||||
* Identity of the active set as the user experiences it. History, stats and the
|
||||
* remaining-send allowance only move when a dare enters or leaves this set or
|
||||
* changes status, so this is the signal that says the slower endpoints are worth
|
||||
* re-reading.
|
||||
*/
|
||||
const activeSignature = (dares: SentDare[]): string =>
|
||||
dares
|
||||
.map(dare => `${dare.id}:${dare.status}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
|
||||
export function useDares({ isFocused = true }: UseDaresConfig = {}): UseDaresReturn {
|
||||
const [active, setActive] = useState<SentDare[]>([]);
|
||||
const [history, setHistory] = useState<SentDare[]>([]);
|
||||
const [catalog, setCatalog] = useState<DareCatalog | null>(null);
|
||||
@@ -36,42 +58,63 @@ export function useDares(): UseDaresReturn {
|
||||
const [paywallReason, setPaywallReason] = useState<string | null>(null);
|
||||
const fetchingRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const signatureRef = useRef<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* `full` reads everything; the polling path reads only the active list and
|
||||
* follows up on the rest when that list actually changed. A failure updates
|
||||
* the UI's error state either way, but is only rethrown for the poll path
|
||||
* (`throwOnError`): `usePolling` needs the rejection to drive its backoff,
|
||||
* while pull-to-refresh and post-mutation callers already surface the error
|
||||
* themselves and must not turn it into an unhandled rejection.
|
||||
*/
|
||||
const load = useCallback(async (full: boolean, throwOnError = false) => {
|
||||
if (fetchingRef.current) return;
|
||||
fetchingRef.current = true;
|
||||
try {
|
||||
const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([
|
||||
daresApi.list("active"),
|
||||
daresApi.list("history"),
|
||||
daresApi.catalog(),
|
||||
daresApi.stats(),
|
||||
]);
|
||||
const activeResult = await daresApi.list("active");
|
||||
if (!mountedRef.current) return;
|
||||
setActive(activeResult.dares);
|
||||
setHistory(historyResult.dares);
|
||||
setCatalog(catalogResult);
|
||||
setStats(statsResult);
|
||||
|
||||
const signature = activeSignature(activeResult.dares);
|
||||
const changed = signature !== signatureRef.current;
|
||||
signatureRef.current = signature;
|
||||
|
||||
if (full || changed) {
|
||||
const [historyResult, catalogResult, statsResult] = await Promise.all([
|
||||
daresApi.list("history"),
|
||||
daresApi.catalog(),
|
||||
daresApi.stats(),
|
||||
]);
|
||||
if (!mountedRef.current) return;
|
||||
setHistory(historyResult.dares);
|
||||
setCatalog(catalogResult);
|
||||
setStats(statsResult);
|
||||
}
|
||||
setError(null);
|
||||
} catch (cause) {
|
||||
if (mountedRef.current) {
|
||||
setError(cause instanceof ApiError ? cause.message : "Could not load dares");
|
||||
}
|
||||
if (throwOnError) throw cause;
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
if (mountedRef.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
void refresh();
|
||||
const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS);
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [refresh]);
|
||||
/** Pull-to-refresh and post-mutation reloads always read the slow endpoints too. */
|
||||
const refresh = useCallback(() => load(true), [load]);
|
||||
const poll = useCallback(() => load(false, true), [load]);
|
||||
|
||||
usePolling(poll, { intervalMs: POLL_INTERVAL_MS, enabled: isFocused });
|
||||
|
||||
/** Premium refusals steer to the paywall; everything else surfaces inline. */
|
||||
const handleFailure = useCallback((cause: unknown) => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
@@ -35,6 +36,7 @@ export const LiveDrawScreen: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { user } = useAuthStore();
|
||||
const coupleId = coupleIdParam || user?.couple_id;
|
||||
const isFocused = useIsFocused();
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]);
|
||||
@@ -95,6 +97,7 @@ export const LiveDrawScreen: React.FC = () => {
|
||||
const sync = useDrawingSync({
|
||||
coupleId: coupleId!,
|
||||
userId: user?.id,
|
||||
isFocused,
|
||||
onStrokeStart: handleStrokeStartFromPartner,
|
||||
onStrokeContinue: handleStrokeContinueFromPartner,
|
||||
onStrokeEnd: handleStrokeEndFromPartner,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { ApiError } from '../../../lib/apiClient';
|
||||
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
|
||||
import { usePolling } from '../../../hooks/usePolling';
|
||||
import type { StrokeSegment, StrokePoint } from '../types';
|
||||
import { updateWidget } from '../utils/widgetBridge';
|
||||
|
||||
/** Fast enough for a shared canvas to feel live, and only while one is on screen. */
|
||||
const POLL_INTERVAL_MS = 750;
|
||||
|
||||
interface UseDrawingSyncConfig {
|
||||
coupleId: string;
|
||||
userId: string | undefined;
|
||||
@@ -14,6 +18,12 @@ interface UseDrawingSyncConfig {
|
||||
onUndo: (strokeId: string) => void;
|
||||
onRedo: (stroke: StrokeSegment) => void;
|
||||
onLoadStrokes: (strokes: StrokeSegment[]) => void;
|
||||
/**
|
||||
* Supplied by the screen rather than read from navigation here, so the hook
|
||||
* stays renderable without a navigator. A canvas nobody is looking at polls
|
||||
* nothing; `usePolling` also stops it when the app goes to the background.
|
||||
*/
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
interface UseDrawingSyncReturn {
|
||||
@@ -52,7 +62,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen
|
||||
}
|
||||
|
||||
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
|
||||
const { coupleId, userId, onLoadStrokes } = config;
|
||||
const { coupleId, userId, onLoadStrokes, isFocused = true } = config;
|
||||
const revisionRef = useRef(0);
|
||||
const baselineRef = useRef<StrokeSegment[]>([]);
|
||||
const workingRef = useRef<StrokeSegment[]>([]);
|
||||
@@ -132,19 +142,25 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
|
||||
void appDataApi.getLiveDraw().then(acceptState).catch(error => console.error('Failed to load live drawing:', error));
|
||||
}, [coupleId, acceptState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!coupleId || !userId) return;
|
||||
let cancelled = false;
|
||||
const poll = async () => {
|
||||
if (pendingWritesRef.current > 0) return;
|
||||
try {
|
||||
const state = await appDataApi.getLiveDraw();
|
||||
if (!cancelled && state.revision > revisionRef.current) acceptState(state);
|
||||
} catch (error) { console.error('Failed to poll live drawing:', error); }
|
||||
};
|
||||
const timer = setInterval(() => void poll(), 750);
|
||||
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); };
|
||||
}, [coupleId, userId, acceptState]);
|
||||
// Our own writes already return the authoritative state, so reading over the top
|
||||
// of one in flight would only race it back to a revision we are about to replace.
|
||||
const poll = useCallback(async () => {
|
||||
if (pendingWritesRef.current > 0) return;
|
||||
const state = await appDataApi.getLiveDraw();
|
||||
if (state.revision > revisionRef.current) acceptState(state);
|
||||
}, [acceptState]);
|
||||
|
||||
// `leading: false` because the mount effect above already read the initial state;
|
||||
// usePolling adds the overlap guard, backoff and background pause the raw
|
||||
// interval had none of.
|
||||
usePolling(poll, {
|
||||
intervalMs: POLL_INTERVAL_MS,
|
||||
enabled: Boolean(coupleId) && Boolean(userId) && isFocused,
|
||||
leading: false,
|
||||
resetKey: `${coupleId}:${userId ?? ''}`,
|
||||
});
|
||||
|
||||
useEffect(() => () => { if (throttleRef.current) clearTimeout(throttleRef.current); }, []);
|
||||
|
||||
const persistStrokes = useCallback((strokes: StrokeSegment[]) => {
|
||||
if (throttleRef.current) clearTimeout(throttleRef.current);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useFocusEffect, useLocalSearchParams } from "expo-router";
|
||||
|
||||
import { useAuthStore, usePacksStore, useStreakStore } from "../../../store";
|
||||
import { useAuthStore, usePacksStore, useResponsesStore, useStreakStore } from "../../../store";
|
||||
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
|
||||
import { ApiError, apiClient } from "../../../lib/apiClient";
|
||||
import { Events } from "../../../lib/analytics";
|
||||
@@ -352,6 +352,12 @@ export const useSwipeScreen = () => {
|
||||
// wrong partner state for the rest of the session. Refreshing it must never
|
||||
// interfere with recording the answer, so it stays off the critical path.
|
||||
void useStreakStore.getState().fetchStreak().catch(() => undefined);
|
||||
|
||||
// This answer changed pack progress and the answer history. Marking their
|
||||
// caches stale costs no request: whichever screen the user opens next
|
||||
// reloads once, and the ones they do not open stay quiet.
|
||||
usePacksStore.getState().invalidatePacks();
|
||||
useResponsesStore.getState().invalidateResponses();
|
||||
} catch (error) {
|
||||
// A 429 means the server rejected this answer outright. Do not advance the
|
||||
// index, or the question is silently skipped and lost for the day.
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export { useAmbientOrbAnimation } from './useAmbientOrbAnimation';
|
||||
export { usePolling } from './usePolling';
|
||||
export type { UsePollingOptions } from './usePolling';
|
||||
export { useCoupleSync, SYNC_INTERVAL_MS } from './useCoupleSync';
|
||||
export type { AmbientOrbConfig } from './useAmbientOrbAnimation';
|
||||
export { useFeatureInterest } from './useFeatureInterest';
|
||||
export { useMediaPicker } from './useMediaPicker';
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { appDataApi, type SyncSummary } from '../lib/appDataApi';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useMatchStore } from '../store/matchStore';
|
||||
import { useMessageStore } from '../store/messageStore';
|
||||
import { usePacksStore } from '../store/packsStore';
|
||||
import { useStreakStore } from '../store/streakStore';
|
||||
import { usePolling } from './usePolling';
|
||||
|
||||
/**
|
||||
* Slower than the five-second full refresh it replaces, because the request it
|
||||
* makes is a change summary rather than five payloads: the app reacts to a
|
||||
* partner's action within this window, but pays one small query for it.
|
||||
*/
|
||||
export const SYNC_INTERVAL_MS = 15_000;
|
||||
|
||||
/**
|
||||
* Keeps cached domain data honest without re-fetching it. Each poll reads one
|
||||
* summary of the couple's state and refreshes only the domains whose markers
|
||||
* moved — and only the ones already on screen, so a view nobody has opened is
|
||||
* not loaded in the background.
|
||||
*/
|
||||
export function useCoupleSync(userId: string | undefined, coupleId: string | null | undefined): void {
|
||||
const previousRef = useRef<SyncSummary | null>(null);
|
||||
const identityRef = useRef<string>('');
|
||||
// `sync` is memoized on a stable callback, so `taskRef` inside usePolling
|
||||
// never has to restart the schedule on every render; it reads the current
|
||||
// identity through this ref instead of closing over the arguments from
|
||||
// whichever render created it.
|
||||
const identityValuesRef = useRef({ userId, coupleId });
|
||||
identityValuesRef.current = { userId, coupleId };
|
||||
|
||||
const latestIdentity = useCallback(() => {
|
||||
const { userId, coupleId } = identityValuesRef.current;
|
||||
return `${userId ?? ''}:${coupleId ?? ''}`;
|
||||
}, []);
|
||||
|
||||
const sync = useCallback(async () => {
|
||||
const identity = latestIdentity();
|
||||
if (identity !== identityRef.current) {
|
||||
// A new account's first summary must not be diffed against the prior
|
||||
// account's markers, or every domain would silently "refresh" on login.
|
||||
identityRef.current = identity;
|
||||
previousRef.current = null;
|
||||
}
|
||||
|
||||
const summary = await appDataApi.syncSummary();
|
||||
// The account can change while this request is in flight. The response
|
||||
// describes the identity that asked for it, so once that is no longer the
|
||||
// current one it may not touch anything: not the badge, not the baseline
|
||||
// the next account will diff against, and not a single refresh decision.
|
||||
// Both checks matter — `identityRef` catches a newer sync that has already
|
||||
// started, and `latestIdentity()` catches a switch that has only rendered.
|
||||
if (identityRef.current !== identity || latestIdentity() !== identity) return;
|
||||
|
||||
const previous = previousRef.current;
|
||||
previousRef.current = summary;
|
||||
|
||||
// The badge count comes straight out of the summary, so keeping it current
|
||||
// costs nothing beyond this request.
|
||||
if (summary.unread_total !== previous?.unread_total) {
|
||||
useMessageStore.getState().setUnreadCount(summary.unread_total);
|
||||
}
|
||||
// The first poll only establishes the baseline: every screen loads its own
|
||||
// data on mount, and refreshing it again here would undo the saving.
|
||||
if (!previous) return;
|
||||
|
||||
const auth = useAuthStore.getState();
|
||||
const matchStore = useMatchStore.getState();
|
||||
const refreshes: Promise<unknown>[] = [];
|
||||
|
||||
if (summary.profile_updated_at !== previous.profile_updated_at || summary.couple_id !== previous.couple_id) {
|
||||
// fetchUser also refreshes the couple when one is set.
|
||||
refreshes.push(auth.fetchUser());
|
||||
} else if (summary.partner_id !== previous.partner_id || summary.partner_updated_at !== previous.partner_updated_at) {
|
||||
refreshes.push(auth.fetchCouple());
|
||||
}
|
||||
|
||||
if (matchStore.loadedAt.active !== undefined && (
|
||||
summary.match_count !== previous.match_count ||
|
||||
summary.new_match_count !== previous.new_match_count ||
|
||||
summary.latest_match_at !== previous.latest_match_at ||
|
||||
// Neither count moves when a partner edits an existing match's type or
|
||||
// response summary through response editing.
|
||||
summary.match_state_fingerprint !== previous.match_state_fingerprint ||
|
||||
// `unread_total` can hold steady while unread redistributes between
|
||||
// matches (one read, another receives a message), which reorders the
|
||||
// unread-first list without moving the total.
|
||||
summary.match_unread_fingerprint !== previous.match_unread_fingerprint)) {
|
||||
refreshes.push(matchStore.fetchMatches(true, { silent: true }));
|
||||
}
|
||||
if (matchStore.loadedAt.pending !== undefined && summary.pending_yours !== previous.pending_yours) {
|
||||
refreshes.push(matchStore.fetchPendingQuestions({ silent: true }));
|
||||
}
|
||||
if (matchStore.loadedAt.their_turn !== undefined && summary.pending_theirs !== previous.pending_theirs) {
|
||||
refreshes.push(matchStore.fetchTheirTurnQuestions({ silent: true }));
|
||||
}
|
||||
// Only refresh a domain the user has actually opened, matching the rule
|
||||
// that unopened domains stay unloaded.
|
||||
if (usePacksStore.getState().enabledPacksLoadedAt !== null
|
||||
&& summary.enabled_packs_fingerprint !== previous.enabled_packs_fingerprint) {
|
||||
refreshes.push(usePacksStore.getState().fetchEnabledPacks());
|
||||
}
|
||||
if (summary.streak_updated_at !== previous.streak_updated_at) {
|
||||
refreshes.push(useStreakStore.getState().refreshStreak());
|
||||
}
|
||||
|
||||
await Promise.allSettled(refreshes);
|
||||
}, [latestIdentity]);
|
||||
|
||||
usePolling(sync, {
|
||||
intervalMs: SYNC_INTERVAL_MS,
|
||||
enabled: Boolean(userId),
|
||||
resetKey: `${userId ?? ''}:${coupleId ?? ''}`,
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { chatApi } from '../lib/chatApi';
|
||||
import { chatApi, type MessagesPage } from '../lib/chatApi';
|
||||
import { usePolling } from './usePolling';
|
||||
import type { Message } from '../features/chat/types';
|
||||
|
||||
export interface UseMessageSubscriptionConfig {
|
||||
@@ -7,51 +8,115 @@ export interface UseMessageSubscriptionConfig {
|
||||
userId: string | undefined;
|
||||
onNewMessage?: () => void;
|
||||
pollInterval?: number;
|
||||
/** Whether the chat is on screen. Polling and read receipts stop when it is not. */
|
||||
isFocused?: boolean;
|
||||
}
|
||||
|
||||
export interface UseMessageSubscriptionReturn {
|
||||
messages: Message[];
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||
loading: boolean;
|
||||
isFocusedRef: React.MutableRefObject<boolean>;
|
||||
/** Partner typing state folded into the message poll; undefined until the server reports it. */
|
||||
partnerTyping: boolean | undefined;
|
||||
/** Fetches the next delta now, e.g. straight after sending. */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** Authenticated API polling replaces Supabase Realtime for self-hosted chat. */
|
||||
export const useMessageSubscription = ({ matchId, userId, onNewMessage, pollInterval = 1_500 }: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => {
|
||||
const timeOf = (message: Message): number => {
|
||||
const parsed = message.created_at ? Date.parse(message.created_at) : Number.NaN;
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
|
||||
/** Newest first, matching the order the API returns a snapshot in. */
|
||||
const byNewest = (a: Message, b: Message): number => {
|
||||
const delta = timeOf(b) - timeOf(a);
|
||||
if (delta !== 0) return delta;
|
||||
return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the previous array unchanged when a delta carries nothing, so an idle
|
||||
* poll re-renders neither this hook's consumers nor the message list.
|
||||
*/
|
||||
export const mergeMessagePage = (previous: Message[], page: MessagesPage): Message[] => {
|
||||
if (page.complete !== false) return page.messages;
|
||||
const removed = page.removed_ids ?? [];
|
||||
if (page.messages.length === 0 && removed.length === 0) return previous;
|
||||
|
||||
const byId = new Map(previous.map(message => [message.id, message]));
|
||||
for (const message of page.messages) byId.set(message.id, message);
|
||||
for (const id of removed) byId.delete(id);
|
||||
return [...byId.values()].sort(byNewest);
|
||||
};
|
||||
|
||||
/**
|
||||
* Authenticated API polling replaces Supabase Realtime for self-hosted chat.
|
||||
*
|
||||
* Each poll asks only for what changed since the cursor the server issued last
|
||||
* time, so an idle conversation costs an all-but-empty response instead of the
|
||||
* five hundred messages the screen already has. Read receipts are written only
|
||||
* when the response actually contains an unread message from the partner, which
|
||||
* on an idle chat is never.
|
||||
*/
|
||||
export const useMessageSubscription = ({
|
||||
matchId,
|
||||
userId,
|
||||
onNewMessage,
|
||||
pollInterval = 2_000,
|
||||
isFocused = true,
|
||||
}: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const isFocusedRef = useRef(false);
|
||||
const [loading, setLoading] = useState(Boolean(matchId && userId));
|
||||
const [partnerTyping, setPartnerTyping] = useState<boolean | undefined>(undefined);
|
||||
const sinceRef = useRef<string | null>(null);
|
||||
const knownIdsRef = useRef<Set<string>>(new Set());
|
||||
const fetchingRef = useRef(false);
|
||||
const loadingRef = useRef(true);
|
||||
const notifyRef = useRef(onNewMessage);
|
||||
notifyRef.current = onNewMessage;
|
||||
|
||||
// A different conversation shares none of this one's state, and the poll below
|
||||
// restarts on the same key so the new one loads immediately.
|
||||
useEffect(() => {
|
||||
sinceRef.current = null;
|
||||
knownIdsRef.current = new Set();
|
||||
loadingRef.current = Boolean(matchId && userId);
|
||||
setMessages([]);
|
||||
setPartnerTyping(undefined);
|
||||
setLoading(loadingRef.current);
|
||||
}, [matchId, userId]);
|
||||
|
||||
const refresh = useCallback(async (initial = false) => {
|
||||
if (!matchId || !userId || fetchingRef.current) return;
|
||||
fetchingRef.current = true;
|
||||
const refresh = useCallback(async () => {
|
||||
if (!matchId || !userId) return;
|
||||
const since = sinceRef.current;
|
||||
try {
|
||||
const response = await chatApi.listMessages(matchId);
|
||||
const partnerArrived = !initial && response.messages.some(message => message.user_id !== userId && !knownIdsRef.current.has(message.id));
|
||||
knownIdsRef.current = new Set(response.messages.map(message => message.id));
|
||||
setMessages(response.messages);
|
||||
if (isFocusedRef.current) await chatApi.markRead(matchId);
|
||||
if (partnerArrived) onNewMessage?.();
|
||||
const page = await chatApi.listMessages(matchId, { ...(since ? { since } : {}), typing: true });
|
||||
// Without a cursor the server cannot answer with deltas, so keep asking
|
||||
// for snapshots rather than silently freezing on a stale page.
|
||||
sinceRef.current = page.server_time ?? null;
|
||||
if (page.typing) setPartnerTyping(page.typing.typing);
|
||||
|
||||
const arrivals = page.messages.filter(message => message.user_id !== userId && !knownIdsRef.current.has(message.id));
|
||||
for (const message of page.messages) knownIdsRef.current.add(message.id);
|
||||
for (const id of page.removed_ids ?? []) knownIdsRef.current.delete(id);
|
||||
setMessages(previous => mergeMessagePage(previous, page));
|
||||
|
||||
if (isFocused && page.messages.some(message => message.user_id !== userId && !message.read_at)) {
|
||||
await chatApi.markRead(matchId);
|
||||
}
|
||||
if (!loadingRef.current && arrivals.length > 0) notifyRef.current?.();
|
||||
} finally {
|
||||
fetchingRef.current = false;
|
||||
if (initial) setLoading(false);
|
||||
if (loadingRef.current) {
|
||||
loadingRef.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [matchId, userId, onNewMessage]);
|
||||
}, [matchId, userId, isFocused]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchId || !userId) {
|
||||
setMessages([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
knownIdsRef.current = new Set();
|
||||
void refresh(true).catch(() => undefined);
|
||||
const timer = setInterval(() => void refresh(false).catch(() => undefined), pollInterval);
|
||||
return () => clearInterval(timer);
|
||||
}, [matchId, userId, pollInterval, refresh]);
|
||||
usePolling(refresh, {
|
||||
intervalMs: pollInterval,
|
||||
enabled: Boolean(matchId && userId && isFocused),
|
||||
resetKey: `${matchId ?? ''}:${userId ?? ''}`,
|
||||
});
|
||||
|
||||
return { messages, setMessages, loading, isFocusedRef };
|
||||
return { messages, setMessages, loading, partnerTyping, refresh };
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { isForeground, subscribeToForeground } from '../lib/appForeground';
|
||||
|
||||
export interface UsePollingOptions {
|
||||
intervalMs: number;
|
||||
/** Caller-owned relevance: screen focus, a loaded id, a paired couple. */
|
||||
enabled?: boolean;
|
||||
/** Run once as soon as polling becomes active. Default true. */
|
||||
leading?: boolean;
|
||||
/** Ceiling for the exponential backoff applied after consecutive failures. */
|
||||
maxIntervalMs?: number;
|
||||
/**
|
||||
* Restarts the schedule when it changes, so a caller that switches subject —
|
||||
* a different chat, a different couple — gets an immediate run instead of
|
||||
* waiting out the interval left over from the previous one.
|
||||
*/
|
||||
resetKey?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A recurring task with the three properties every poller in this app needs and
|
||||
* none of them had: it stops while the app is backgrounded, it never runs twice
|
||||
* concurrently, and it backs off instead of hammering an API that is failing.
|
||||
*
|
||||
* The next run is scheduled after the previous one settles rather than on a fixed
|
||||
* `setInterval`, so a slow response delays the next request instead of queueing
|
||||
* one behind it. `task` is read through a ref, so a caller may pass a fresh
|
||||
* closure every render without restarting the schedule.
|
||||
*/
|
||||
export function usePolling(task: () => Promise<void>, options: UsePollingOptions): void {
|
||||
const { intervalMs, enabled = true, leading = true, maxIntervalMs = intervalMs * 8, resetKey } = options;
|
||||
const taskRef = useRef(task);
|
||||
taskRef.current = task;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
let cancelled = false;
|
||||
let running = false;
|
||||
let failures = 0;
|
||||
let foreground = isForeground();
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clear = () => {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
const schedule = () => {
|
||||
if (cancelled || !foreground || timer !== null) return;
|
||||
const delay = Math.min(intervalMs * 2 ** failures, maxIntervalMs);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
void run();
|
||||
}, delay);
|
||||
};
|
||||
const run = async () => {
|
||||
if (cancelled || running || !foreground) return;
|
||||
running = true;
|
||||
try {
|
||||
await taskRef.current();
|
||||
failures = 0;
|
||||
} catch {
|
||||
failures += 1;
|
||||
} finally {
|
||||
running = false;
|
||||
schedule();
|
||||
}
|
||||
};
|
||||
const unsubscribe = subscribeToForeground((next) => {
|
||||
if (next === foreground) return;
|
||||
foreground = next;
|
||||
if (!foreground) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
// Back from the background: the cached data is as stale as the absence
|
||||
// was long, so catch up now rather than after another full interval.
|
||||
failures = 0;
|
||||
void run();
|
||||
});
|
||||
|
||||
if (leading) void run();
|
||||
else schedule();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clear();
|
||||
unsubscribe();
|
||||
};
|
||||
}, [enabled, intervalMs, leading, maxIntervalMs, resetKey]);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { chatApi } from '../lib/chatApi';
|
||||
import { usePolling } from './usePolling';
|
||||
|
||||
export interface UseTypingIndicatorConfig {
|
||||
channelName: string;
|
||||
@@ -9,6 +10,12 @@ export interface UseTypingIndicatorConfig {
|
||||
isFocused?: boolean;
|
||||
throttleInterval?: number;
|
||||
pollInterval?: number;
|
||||
/**
|
||||
* Typing state already known from another request. When supplied this hook
|
||||
* stops polling for it, which is how the chat screen gets typing state for
|
||||
* free out of the message poll instead of a second request every interval.
|
||||
*/
|
||||
externalTyping?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface UseTypingIndicatorReturn {
|
||||
@@ -17,31 +24,43 @@ export interface UseTypingIndicatorReturn {
|
||||
clearTypingIndicator: () => void;
|
||||
}
|
||||
|
||||
export const useTypingIndicator = ({ channelName, userId, matchId = channelName.replace(/^chat:/, ''), isFocused = true, throttleInterval = 2_000, pollInterval = 1_500 }: UseTypingIndicatorConfig): UseTypingIndicatorReturn => {
|
||||
export const useTypingIndicator = ({
|
||||
channelName,
|
||||
userId,
|
||||
matchId = channelName.replace(/^chat:/, ''),
|
||||
isFocused = true,
|
||||
throttleInterval = 2_000,
|
||||
pollInterval = 1_500,
|
||||
externalTyping,
|
||||
}: UseTypingIndicatorConfig): UseTypingIndicatorReturn => {
|
||||
const [partnerTyping, setPartnerTyping] = useState(false);
|
||||
const lastTypingEventRef = useRef(0);
|
||||
const external = externalTyping !== undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!matchId || !userId || !isFocused) {
|
||||
if (externalTyping !== undefined) setPartnerTyping(externalTyping);
|
||||
}, [externalTyping]);
|
||||
|
||||
// Losing the subject stops both sources of truth, so drop the stale indicator
|
||||
// rather than leaving the partner typing forever in a chat nobody is watching.
|
||||
useEffect(() => {
|
||||
if (!matchId || !userId || !isFocused) setPartnerTyping(false);
|
||||
}, [matchId, userId, isFocused]);
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
const state = await chatApi.getTyping(matchId);
|
||||
setPartnerTyping(state.typing);
|
||||
} catch {
|
||||
setPartnerTyping(false);
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const state = await chatApi.getTyping(matchId);
|
||||
if (active) setPartnerTyping(state.typing);
|
||||
} catch {
|
||||
if (active) setPartnerTyping(false);
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
const timer = setInterval(() => void poll(), pollInterval);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [matchId, userId, isFocused, pollInterval]);
|
||||
}, [matchId]);
|
||||
|
||||
usePolling(poll, {
|
||||
intervalMs: pollInterval,
|
||||
enabled: !external && Boolean(matchId) && Boolean(userId) && isFocused,
|
||||
resetKey: matchId,
|
||||
});
|
||||
|
||||
const sendTypingEvent = useCallback(() => {
|
||||
if (!matchId || !userId) return;
|
||||
|
||||
@@ -10,7 +10,36 @@ export interface LiveDrawState {
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A change summary, not a payload: markers the client compares against the ones
|
||||
* it saw last time to decide which domain, if any, is worth refetching. It
|
||||
* replaces a periodic full refresh of profile, couple, matches, pending
|
||||
* questions, packs and unread counts with one small request.
|
||||
*/
|
||||
export interface SyncSummary {
|
||||
server_time: string;
|
||||
couple_id: string | null;
|
||||
profile_updated_at: string | null;
|
||||
partner_id: string | null;
|
||||
partner_updated_at: string | null;
|
||||
match_count: number;
|
||||
new_match_count: number;
|
||||
latest_match_at: string | null;
|
||||
pending_yours: number;
|
||||
pending_theirs: number;
|
||||
unread_total: number;
|
||||
/** couple_packs carries no timestamp, so equal-and-opposite toggles are caught by a digest. */
|
||||
enabled_packs_fingerprint: string | null;
|
||||
/** Digest of each visible match's type and response summary; moves on an in-place response edit that the count/latest-at markers miss. */
|
||||
match_state_fingerprint: string;
|
||||
/** Digest of each visible match's unread count; moves when unread redistributes between matches without changing the total. */
|
||||
match_unread_fingerprint: string;
|
||||
/** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */
|
||||
streak_updated_at: string | null;
|
||||
}
|
||||
|
||||
export const appDataApi = {
|
||||
syncSummary: () => apiClient.get<SyncSummary>('/v1/me/sync'),
|
||||
packContext: (packId: string) => apiClient.get<{ pack: { id: string; name: string; icon: string | null } }>(`/v1/packs/${encodeURIComponent(packId)}/context`),
|
||||
packQuestions: (packId: string) => apiClient.get<{ questions: Question[] }>(`/v1/packs/${encodeURIComponent(packId)}/questions`),
|
||||
packTeaser: (packId: string) => apiClient.get<{ questions: Array<{ id: string; text: string; intensity: number }> }>(`/v1/packs/${encodeURIComponent(packId)}/teaser`),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { AppState, type AppStateStatus } from 'react-native';
|
||||
|
||||
/**
|
||||
* `background` is a real suspension and `inactive` is the moment behind the app
|
||||
* switcher, a call, or a system prompt. Anything else — including states a test
|
||||
* harness never sets — counts as foreground, so recurring work fails towards
|
||||
* running rather than towards being silently disabled forever.
|
||||
*/
|
||||
export function isForegroundState(state: AppStateStatus | string | undefined): boolean {
|
||||
return state !== 'background' && state !== 'inactive';
|
||||
}
|
||||
|
||||
export function isForeground(): boolean {
|
||||
return isForegroundState(AppState.currentState as AppStateStatus | undefined);
|
||||
}
|
||||
|
||||
/** Reports the new foreground flag on every transition. Returns an unsubscribe. */
|
||||
export function subscribeToForeground(onChange: (foreground: boolean) => void): () => void {
|
||||
const subscription = AppState.addEventListener('change', (state) => onChange(isForegroundState(state)));
|
||||
return () => subscription.remove();
|
||||
}
|
||||
@@ -1,8 +1,40 @@
|
||||
import { apiClient } from './apiClient';
|
||||
import type { Message, ReportReason } from '../features/chat/types';
|
||||
|
||||
export interface PartnerTyping {
|
||||
typing: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A snapshot when `complete`, otherwise only what changed since the cursor the
|
||||
* previous page handed back. Delta rows are whole messages, not patches, so a
|
||||
* caller merges them by id without a second shape to understand.
|
||||
*/
|
||||
export interface MessagesPage {
|
||||
messages: Message[];
|
||||
/** Messages the caller deleted for themselves since the cursor. */
|
||||
removed_ids?: string[];
|
||||
/** Cursor to send as `since` on the next poll. */
|
||||
server_time?: string;
|
||||
complete?: boolean;
|
||||
/** Present when requested, saving a separate typing request. */
|
||||
typing?: PartnerTyping;
|
||||
}
|
||||
|
||||
export interface ListMessagesOptions {
|
||||
since?: string;
|
||||
typing?: boolean;
|
||||
}
|
||||
|
||||
export const chatApi = {
|
||||
listMessages: (matchId: string) => apiClient.get<{ messages: Message[] }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`),
|
||||
listMessages: (matchId: string, options: ListMessagesOptions = {}) => {
|
||||
const query: string[] = [];
|
||||
if (options.since) query.push(`since=${encodeURIComponent(options.since)}`);
|
||||
if (options.typing) query.push('typing=true');
|
||||
const suffix = query.length > 0 ? `?${query.join('&')}` : '';
|
||||
return apiClient.get<MessagesPage>(`/v1/matches/${encodeURIComponent(matchId)}/messages${suffix}`);
|
||||
},
|
||||
sendText: (matchId: string, content: string) => apiClient.post<{ message: Message }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`, { content }),
|
||||
unread: () => apiClient.get<{ total: number; by_match: Record<string, number> }>('/v1/chat/unread'),
|
||||
markRead: (matchId: string) => apiClient.post<{ updated: number; read_at: string }>(`/v1/matches/${encodeURIComponent(matchId)}/read`),
|
||||
@@ -11,5 +43,5 @@ export const chatApi = {
|
||||
deleteForEveryone: (messageId: string) => apiClient.delete<{ deleted: true; message: Message }>(`/v1/messages/${encodeURIComponent(messageId)}?scope=everyone`),
|
||||
report: (messageId: string, reason: ReportReason) => apiClient.post<{ reported: true }>(`/v1/messages/${encodeURIComponent(messageId)}/reports`, { reason }),
|
||||
setTyping: (matchId: string) => apiClient.put<{ typing: true }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
|
||||
getTyping: (matchId: string) => apiClient.get<{ typing: boolean; expires_at: string | null }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
|
||||
getTyping: (matchId: string) => apiClient.get<PartnerTyping>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
|
||||
};
|
||||
@@ -34,7 +34,8 @@ const getOtherStores = () => {
|
||||
const { useSubscriptionStore } = require("./subscriptionStore");
|
||||
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
|
||||
const { useStreakStore } = require("./streakStore");
|
||||
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore };
|
||||
const { useResponsesStore } = require("./responsesStore");
|
||||
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore };
|
||||
};
|
||||
|
||||
|
||||
@@ -87,13 +88,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
console.log("[Auth] Session rejected by API, signing out");
|
||||
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
useResponsesStore.getState().clearResponses();
|
||||
await authClient.auth.signOut();
|
||||
return;
|
||||
}
|
||||
@@ -142,13 +144,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
isLoading: false,
|
||||
});
|
||||
// Clear other stores
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
useResponsesStore.getState().clearResponses();
|
||||
|
||||
// Clear badge on sign out
|
||||
const { clearBadge } = require("../lib/badge");
|
||||
@@ -173,13 +176,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
});
|
||||
// Clear other stores when user signs out
|
||||
if (user === null) {
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
useResponsesStore.getState().clearResponses();
|
||||
// Clear badge on sign out
|
||||
const { clearBadge } = require("../lib/badge");
|
||||
clearBadge();
|
||||
|
||||
@@ -15,6 +15,20 @@ export interface PendingQuestion {
|
||||
|
||||
type MatchViewType = 'active' | 'archived' | 'pending' | 'their_turn';
|
||||
|
||||
/**
|
||||
* A refresh that must not disturb what the user is looking at: no spinner, and
|
||||
* the cached rows stay on screen until the replacement arrives.
|
||||
*/
|
||||
export interface FetchOptions { silent?: boolean }
|
||||
|
||||
// Overlap protection lives outside the store because the loading flags are now
|
||||
// presentational — a silent refresh sets none of them and still must not run twice.
|
||||
// Keyed to the generation that started the request (see `generation` below) rather
|
||||
// than a bare presence check: a stale generation's entry must not block the next
|
||||
// account's request for the same key, and a stale generation's `finally` must not
|
||||
// clear an entry a newer generation just claimed.
|
||||
const inFlight = new Map<string, number>();
|
||||
|
||||
// Rate limit: 12 hours in milliseconds
|
||||
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
@@ -32,6 +46,8 @@ interface MatchState {
|
||||
newMatchesCount: number;
|
||||
totalCount: number | null;
|
||||
isLoading: boolean;
|
||||
/** A user-initiated refresh over data already on screen; drives pull-to-refresh only. */
|
||||
isRefreshing: boolean;
|
||||
isLoadingMore: boolean;
|
||||
page: number;
|
||||
hasMore: boolean;
|
||||
@@ -48,11 +64,23 @@ interface MatchState {
|
||||
theirTurnQuestions: PendingQuestion[];
|
||||
isLoadingTheirTurn: boolean;
|
||||
currentView: MatchViewType;
|
||||
/**
|
||||
* When each view last loaded successfully. Tracked separately from the row
|
||||
* arrays because an empty view is still a loaded view, and revisiting one
|
||||
* must not refetch.
|
||||
*/
|
||||
loadedAt: Partial<Record<MatchViewType, number>>;
|
||||
// Nudge state
|
||||
nudgeCooldownUntil: Date | null;
|
||||
isNudging: boolean;
|
||||
/**
|
||||
* Bumped on every clear (sign-out/account switch). A fetch captures this before
|
||||
* it starts and checks it again before writing its result, so a response that
|
||||
* outlives the clear cannot land in the next account's store.
|
||||
*/
|
||||
generation: number;
|
||||
// Methods
|
||||
fetchMatches: (refresh?: boolean) => Promise<void>;
|
||||
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<void>;
|
||||
markAsSeen: (matchId: string) => Promise<void>;
|
||||
markAllAsSeen: () => Promise<void>;
|
||||
addMatch: (match: Match) => void;
|
||||
@@ -61,13 +89,14 @@ interface MatchState {
|
||||
// Archive methods
|
||||
archiveMatch: (matchId: string) => Promise<void>;
|
||||
unarchiveMatch: (matchId: string) => Promise<void>;
|
||||
fetchArchivedMatches: () => Promise<void>;
|
||||
fetchArchivedMatches: (options?: FetchOptions) => Promise<void>;
|
||||
toggleShowArchived: () => void;
|
||||
isMatchArchived: (matchId: string) => boolean;
|
||||
// Pending methods
|
||||
fetchPendingQuestions: () => Promise<void>;
|
||||
fetchTheirTurnQuestions: () => Promise<void>;
|
||||
fetchPendingQuestions: (options?: FetchOptions) => Promise<void>;
|
||||
fetchTheirTurnQuestions: (options?: FetchOptions) => Promise<void>;
|
||||
setCurrentView: (view: MatchViewType) => void;
|
||||
ensureViewLoaded: (view?: MatchViewType) => void;
|
||||
// Nudge methods
|
||||
sendNudge: () => Promise<{ success: boolean; notificationSent: boolean }>;
|
||||
checkNudgeCooldown: () => Promise<void>;
|
||||
@@ -78,6 +107,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
newMatchesCount: 0,
|
||||
totalCount: null,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
isLoadingMore: false,
|
||||
page: 0,
|
||||
hasMore: true,
|
||||
@@ -94,40 +124,53 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
theirTurnQuestions: [],
|
||||
isLoadingTheirTurn: false,
|
||||
currentView: 'pending' as MatchViewType,
|
||||
loadedAt: {},
|
||||
// Nudge state
|
||||
nudgeCooldownUntil: null,
|
||||
isNudging: false,
|
||||
generation: 0,
|
||||
|
||||
fetchMatches: async (refresh = false) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
fetchMatches: async (refresh = false, options = {}) => {
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
const myGeneration = get().generation;
|
||||
|
||||
// Early return if no couple - user isn't paired yet
|
||||
if (!coupleId) {
|
||||
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false });
|
||||
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const state = get();
|
||||
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
|
||||
if (inFlight.get('active') === myGeneration || (state.isLoadingMore && !refresh)) return;
|
||||
|
||||
// Nothing on screen yet is the only case that warrants blanking the list
|
||||
// for a spinner; every other refresh leaves the cached rows in place.
|
||||
const initial = state.loadedAt.active === undefined;
|
||||
if (refresh) {
|
||||
set({ isLoading: true, error: null, page: 0, hasMore: true });
|
||||
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
|
||||
} else {
|
||||
if (!state.hasMore) return;
|
||||
set({ isLoadingMore: true });
|
||||
}
|
||||
|
||||
inFlight.set('active', myGeneration);
|
||||
try {
|
||||
const currentPage = refresh ? 0 : state.page;
|
||||
const result = await apiClient.get<{ matches: Match[]; totalCount: number }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
|
||||
const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
|
||||
// A sign-out/account switch that happened while this request was in
|
||||
// flight must not let its response populate the next account's store.
|
||||
if (get().generation !== myGeneration) return;
|
||||
const nonArchivedMatches = result.matches;
|
||||
const totalCount = result.totalCount;
|
||||
const archivedMatchIds = state.archivedMatchIds;
|
||||
// The API reports this by reading one row past the page; the length
|
||||
// comparison is only a fallback for an older server.
|
||||
const hasMore = result.hasMore ?? nonArchivedMatches.length === BATCH_SIZE;
|
||||
const loadedAt = { ...state.loadedAt, active: Date.now() };
|
||||
|
||||
if (nonArchivedMatches.length === 0) {
|
||||
if (refresh) {
|
||||
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false, archivedMatchIds });
|
||||
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, archivedMatchIds, loadedAt });
|
||||
} else {
|
||||
set({ isLoadingMore: false, hasMore: false });
|
||||
}
|
||||
@@ -154,22 +197,34 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
return {
|
||||
matches: mergedMatches,
|
||||
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
|
||||
totalCount: refresh ? totalCount : state.totalCount,
|
||||
// Later pages no longer carry a total, so keep the one the refresh recorded.
|
||||
totalCount: refresh ? totalCount ?? state.totalCount : state.totalCount,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
isLoadingMore: false,
|
||||
page: currentPage + 1,
|
||||
hasMore: nonArchivedMatches.length === BATCH_SIZE,
|
||||
hasMore,
|
||||
archivedMatchIds: refresh ? archivedMatchIds : state.archivedMatchIds,
|
||||
loadedAt,
|
||||
};
|
||||
});
|
||||
} catch (err) {
|
||||
if (get().generation !== myGeneration) return;
|
||||
console.error("Error fetching matches:", err);
|
||||
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
|
||||
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
|
||||
} finally {
|
||||
// An old generation's cleanup must not delete a newer generation's guard.
|
||||
if (inFlight.get('active') === myGeneration) inFlight.delete('active');
|
||||
}
|
||||
},
|
||||
|
||||
markAsSeen: async (matchId) => {
|
||||
const myGeneration = get().generation;
|
||||
await apiClient.patch("/v1/matches/seen", { ids: [matchId] });
|
||||
// A clear while this was in flight leaves an empty list behind, and the
|
||||
// recount below would write that emptiness — and a zero badge — over
|
||||
// whatever the next account has already loaded.
|
||||
if (get().generation !== myGeneration) return;
|
||||
|
||||
const matches = get().matches.map((m) =>
|
||||
m.id === matchId ? { ...m, is_new: false } : m
|
||||
@@ -179,11 +234,13 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
},
|
||||
|
||||
markAllAsSeen: async () => {
|
||||
const myGeneration = get().generation;
|
||||
const newMatches = get().matches.filter((m) => m.is_new);
|
||||
if (newMatches.length === 0) return;
|
||||
|
||||
const newMatchIds = newMatches.map((m) => m.id);
|
||||
await apiClient.patch("/v1/matches/seen", { ids: newMatchIds });
|
||||
if (get().generation !== myGeneration) return;
|
||||
|
||||
const matches = get().matches.map((m) =>
|
||||
m.is_new ? { ...m, is_new: false } : m
|
||||
@@ -210,11 +267,12 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
||||
},
|
||||
|
||||
clearMatches: () => {
|
||||
set({
|
||||
set((state) => ({
|
||||
matches: [],
|
||||
newMatchesCount: 0,
|
||||
totalCount: null,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: null,
|
||||
page: 0,
|
||||
hasMore: true,
|
||||
@@ -228,9 +286,16 @@ clearMatches: () => {
|
||||
theirTurnQuestions: [],
|
||||
isLoadingTheirTurn: false,
|
||||
currentView: 'pending',
|
||||
loadedAt: {},
|
||||
nudgeCooldownUntil: null,
|
||||
isNudging: false,
|
||||
});
|
||||
// Invalidates any request already in flight: its generation check will
|
||||
// fail and it will not be able to write into the next account's store.
|
||||
generation: state.generation + 1,
|
||||
}));
|
||||
// A request belonging to the signed-out user must not suppress the next
|
||||
// one: the guard is a duplicate-request check, not a lock on the data.
|
||||
inFlight.clear();
|
||||
},
|
||||
|
||||
// Archive methods
|
||||
@@ -240,6 +305,7 @@ clearMatches: () => {
|
||||
|
||||
// Optimistically update UI
|
||||
const state = get();
|
||||
const myGeneration = state.generation;
|
||||
const matchToArchive = state.matches.find(m => m.id === matchId);
|
||||
|
||||
if (matchToArchive) {
|
||||
@@ -258,7 +324,11 @@ clearMatches: () => {
|
||||
await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: true });
|
||||
} catch (error) {
|
||||
console.error("Error archiving match:", error);
|
||||
// Revert on error
|
||||
// Revert on error — but only into the store the optimistic update was
|
||||
// applied to. After an account switch the row it would restore belongs
|
||||
// to the previous couple, and putting it back would show one couple's
|
||||
// match inside another's list.
|
||||
if (get().generation !== myGeneration) return;
|
||||
if (matchToArchive) {
|
||||
const revertArchivedIds = new Set(get().archivedMatchIds);
|
||||
revertArchivedIds.delete(matchId);
|
||||
@@ -279,6 +349,7 @@ clearMatches: () => {
|
||||
|
||||
// Optimistically update UI
|
||||
const state = get();
|
||||
const myGeneration = state.generation;
|
||||
const matchToUnarchive = state.archivedMatches.find(m => m.id === matchId);
|
||||
|
||||
if (matchToUnarchive) {
|
||||
@@ -297,7 +368,8 @@ clearMatches: () => {
|
||||
await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: false });
|
||||
} catch (error) {
|
||||
console.error("Error unarchiving match:", error);
|
||||
// Revert on error
|
||||
// Revert on error, and only for the account that started it.
|
||||
if (get().generation !== myGeneration) return;
|
||||
if (matchToUnarchive) {
|
||||
const revertArchivedIds = new Set(get().archivedMatchIds);
|
||||
revertArchivedIds.add(matchId);
|
||||
@@ -312,34 +384,35 @@ clearMatches: () => {
|
||||
}
|
||||
},
|
||||
|
||||
fetchArchivedMatches: async () => {
|
||||
fetchArchivedMatches: async (options = {}) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
|
||||
if (!coupleId || !userId) return;
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const state = get();
|
||||
if (state.isLoadingArchived) return;
|
||||
|
||||
set({ isLoadingArchived: true });
|
||||
if (inFlight.get('archived') === myGeneration) return;
|
||||
if (!options.silent || state.loadedAt.archived === undefined) set({ isLoadingArchived: true });
|
||||
|
||||
inFlight.set('archived', myGeneration);
|
||||
try {
|
||||
const result = await apiClient.get<{matches:Match[];totalCount:number}>(`/v1/matches?archived=true&limit=100`);
|
||||
const result = await apiClient.get<{matches:Match[];totalCount:number | null}>(`/v1/matches?archived=true&limit=100`);
|
||||
if (get().generation !== myGeneration) return;
|
||||
const archivedMatches = result.matches;
|
||||
const archivedIds = archivedMatches.map(match => match.id);
|
||||
if (archivedMatches.length === 0) {
|
||||
set({ archivedMatches: [], isLoadingArchived: false });
|
||||
return;
|
||||
}
|
||||
|
||||
set({
|
||||
set((current) => ({
|
||||
archivedMatches,
|
||||
isLoadingArchived: false,
|
||||
archivedMatchIds: new Set(archivedIds),
|
||||
});
|
||||
loadedAt: { ...current.loadedAt, archived: Date.now() },
|
||||
}));
|
||||
} catch (err) {
|
||||
if (get().generation !== myGeneration) return;
|
||||
console.error("Error fetching archived matches:", err);
|
||||
set({ isLoadingArchived: false });
|
||||
} finally {
|
||||
if (inFlight.get('archived') === myGeneration) inFlight.delete('archived');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -350,8 +423,8 @@ clearMatches: () => {
|
||||
set({ showArchived: newShowArchived });
|
||||
|
||||
// Fetch archived matches if switching to archived view and not yet loaded
|
||||
if (newShowArchived && state.archivedMatches.length === 0) {
|
||||
get().fetchArchivedMatches();
|
||||
if (newShowArchived && state.loadedAt.archived === undefined) {
|
||||
void get().fetchArchivedMatches();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -360,7 +433,7 @@ clearMatches: () => {
|
||||
},
|
||||
|
||||
// Pending (Your Turn) methods
|
||||
fetchPendingQuestions: async () => {
|
||||
fetchPendingQuestions: async (options = {}) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
|
||||
@@ -369,22 +442,27 @@ clearMatches: () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const state = get();
|
||||
if (state.isLoadingPending) return;
|
||||
|
||||
set({ isLoadingPending: true });
|
||||
if (inFlight.get('pending') === myGeneration) return;
|
||||
if (!options.silent || state.loadedAt.pending === undefined) set({ isLoadingPending: true });
|
||||
|
||||
inFlight.set('pending', myGeneration);
|
||||
try {
|
||||
const { questions: pendingQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=partner");
|
||||
set({ pendingQuestions, isLoadingPending: false });
|
||||
if (get().generation !== myGeneration) return;
|
||||
set((current) => ({ pendingQuestions, isLoadingPending: false, loadedAt: { ...current.loadedAt, pending: Date.now() } }));
|
||||
} catch (err) {
|
||||
if (get().generation !== myGeneration) return;
|
||||
console.error("Error fetching pending questions:", err);
|
||||
set({ isLoadingPending: false });
|
||||
} finally {
|
||||
if (inFlight.get('pending') === myGeneration) inFlight.delete('pending');
|
||||
}
|
||||
},
|
||||
|
||||
// Their Turn methods (user answered, partner hasn't yet)
|
||||
fetchTheirTurnQuestions: async () => {
|
||||
fetchTheirTurnQuestions: async (options = {}) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
|
||||
@@ -393,35 +471,43 @@ clearMatches: () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const state = get();
|
||||
if (state.isLoadingTheirTurn) return;
|
||||
|
||||
set({ isLoadingTheirTurn: true });
|
||||
if (inFlight.get('their_turn') === myGeneration) return;
|
||||
if (!options.silent || state.loadedAt.their_turn === undefined) set({ isLoadingTheirTurn: true });
|
||||
|
||||
inFlight.set('their_turn', myGeneration);
|
||||
try {
|
||||
const { questions: theirTurnQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=mine");
|
||||
set({ theirTurnQuestions, isLoadingTheirTurn: false });
|
||||
if (get().generation !== myGeneration) return;
|
||||
set((current) => ({ theirTurnQuestions, isLoadingTheirTurn: false, loadedAt: { ...current.loadedAt, their_turn: Date.now() } }));
|
||||
} catch (err) {
|
||||
if (get().generation !== myGeneration) return;
|
||||
console.error("Error fetching their turn questions:", err);
|
||||
set({ isLoadingTheirTurn: false });
|
||||
} finally {
|
||||
if (inFlight.get('their_turn') === myGeneration) inFlight.delete('their_turn');
|
||||
}
|
||||
},
|
||||
|
||||
setCurrentView: (view: MatchViewType) => {
|
||||
const state = get();
|
||||
|
||||
// Update showArchived for backwards compatibility
|
||||
const showArchived = view === 'archived';
|
||||
set({ currentView: view, showArchived });
|
||||
|
||||
// Fetch data for the selected view if not already loaded
|
||||
if (view === 'archived' && state.archivedMatches.length === 0) {
|
||||
get().fetchArchivedMatches();
|
||||
} else if (view === 'pending' && state.pendingQuestions.length === 0) {
|
||||
get().fetchPendingQuestions();
|
||||
} else if (view === 'their_turn' && state.theirTurnQuestions.length === 0) {
|
||||
get().fetchTheirTurnQuestions();
|
||||
}
|
||||
get().ensureViewLoaded(view);
|
||||
},
|
||||
|
||||
/**
|
||||
* Loads a view once. Emptiness is not evidence of never having loaded, so
|
||||
* returning to a legitimately empty view costs no request at all.
|
||||
*/
|
||||
ensureViewLoaded: (view = get().currentView) => {
|
||||
if (get().loadedAt[view] !== undefined) return;
|
||||
if (view === 'archived') void get().fetchArchivedMatches();
|
||||
else if (view === 'pending') void get().fetchPendingQuestions();
|
||||
else if (view === 'their_turn') void get().fetchTheirTurnQuestions();
|
||||
else void get().fetchMatches(true);
|
||||
},
|
||||
|
||||
// Nudge methods
|
||||
@@ -436,10 +522,14 @@ clearMatches: () => {
|
||||
return { success: false, notificationSent: false };
|
||||
}
|
||||
|
||||
const myGeneration = state.generation;
|
||||
set({ isNudging: true });
|
||||
|
||||
try {
|
||||
const data = await appDataApi.sendNudge();
|
||||
// The cooldown is per-account. Recording the previous account's window
|
||||
// would either silence the new account's nudge or clear a real cooldown.
|
||||
if (get().generation !== myGeneration) return { success: false, notificationSent: false };
|
||||
|
||||
// Success - update cooldown
|
||||
const cooldownUntil = data.next_nudge_available_at
|
||||
@@ -453,6 +543,7 @@ clearMatches: () => {
|
||||
notificationSent: data.notification_sent ?? false,
|
||||
};
|
||||
} catch (err) {
|
||||
if (get().generation !== myGeneration) return { success: false, notificationSent: false };
|
||||
if (err instanceof ApiError && err.status === 429) {
|
||||
const details = err.details as NudgeResponse | undefined;
|
||||
const cooldownUntil = details?.next_nudge_available_at
|
||||
@@ -471,8 +562,12 @@ clearMatches: () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const myGeneration = get().generation;
|
||||
try {
|
||||
const profile = await appDataApi.nudgeStatus();
|
||||
// Read for the previous account: neither its cooldown nor its absence
|
||||
// says anything about whether this one may nudge.
|
||||
if (get().generation !== myGeneration) return;
|
||||
|
||||
if (!profile.last_nudge_sent_at) {
|
||||
set({ nudgeCooldownUntil: null });
|
||||
|
||||
@@ -19,7 +19,14 @@ interface MessageState {
|
||||
unreadCount: number;
|
||||
lastMessage: MessageWithMatch | null;
|
||||
activeMatchId: string | null; // Track which chat is currently open
|
||||
/**
|
||||
* Bumped by `clearMessages` on sign-out or a couple change, so a reply that
|
||||
* outlives the account that asked for it cannot write into the next one's badge.
|
||||
*/
|
||||
generation: number;
|
||||
fetchUnreadCount: () => Promise<void>;
|
||||
/** Applies a count the sync summary already reported, without a second request. */
|
||||
setUnreadCount: (total: number) => void;
|
||||
addMessage: (message: MessageWithMatch) => void;
|
||||
clearLastMessage: () => void;
|
||||
setActiveMatchId: (matchId: string | null) => void;
|
||||
@@ -31,6 +38,7 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
unreadCount: 0,
|
||||
lastMessage: null,
|
||||
activeMatchId: null,
|
||||
generation: 0,
|
||||
|
||||
fetchUnreadCount: async () => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
@@ -39,10 +47,18 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const { total } = await chatApi.unread();
|
||||
// This total counts one account's messages; after a clear it would show
|
||||
// up as the next account's badge.
|
||||
if (get().generation !== myGeneration) return;
|
||||
set({ unreadCount: total });
|
||||
},
|
||||
|
||||
setUnreadCount: (total) => {
|
||||
set({ unreadCount: Math.max(0, total) });
|
||||
},
|
||||
|
||||
addMessage: (message) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
const activeMatchId = get().activeMatchId;
|
||||
@@ -68,18 +84,27 @@ export const useMessageStore = create<MessageState>((set, get) => ({
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const { updated: unreadInMatch } = await chatApi.markRead(matchId);
|
||||
if (unreadInMatch <= 0) return;
|
||||
|
||||
// Refetch global unread count
|
||||
await get().fetchUnreadCount();
|
||||
// Those messages belonged to a couple that is no longer loaded, and the
|
||||
// match they were in is not in the current list, so subtracting them now
|
||||
// would take the old badge off the new account's.
|
||||
if (get().generation !== myGeneration) return;
|
||||
|
||||
// Sync per-match unread count in matchStore
|
||||
if (unreadInMatch > 0) {
|
||||
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
|
||||
}
|
||||
// The server just told us exactly how many it cleared, so subtract instead
|
||||
// of spending a request to re-read the total; the sync summary corrects
|
||||
// any drift on its next pass.
|
||||
set((state) => ({ unreadCount: Math.max(0, state.unreadCount - unreadInMatch) }));
|
||||
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
|
||||
},
|
||||
|
||||
clearMessages: () => {
|
||||
set({ unreadCount: 0, lastMessage: null, activeMatchId: null });
|
||||
set((state) => ({
|
||||
unreadCount: 0, lastMessage: null, activeMatchId: null,
|
||||
// Invalidates any request already in flight for the account being left.
|
||||
generation: state.generation + 1,
|
||||
}));
|
||||
},
|
||||
}));
|
||||
@@ -4,6 +4,12 @@ import { Events } from "../lib/analytics";
|
||||
import type { QuestionPack, Category } from "@/types";
|
||||
import { useAuthStore } from "./authStore";
|
||||
|
||||
// The catalog load fans out into three requests, so a second caller arriving mid-flight
|
||||
// must wait for the first rather than triple the cost. Keyed to the generation that
|
||||
// started the request: a stale generation's entry must not block the next account's
|
||||
// request for the same key, and its `finally` must not clear a newer generation's guard.
|
||||
const inFlight = new Map<string, number>();
|
||||
|
||||
// Progress data for a pack
|
||||
export interface PackProgressData {
|
||||
totalQuestions: number;
|
||||
@@ -16,10 +22,23 @@ interface PacksState {
|
||||
enabledPackIds: string[];
|
||||
packProgress: Map<string, PackProgressData>;
|
||||
isLoading: boolean;
|
||||
/**
|
||||
* When the catalog, enabled set and progress were last read together. Null means
|
||||
* never — which is not the same as "empty", so a couple with no packs enabled
|
||||
* stops refetching after the first load.
|
||||
*/
|
||||
catalogLoadedAt: number | null;
|
||||
/** Tracked separately because the swipe screen loads the enabled set on its own. */
|
||||
enabledPacksLoadedAt: number | null;
|
||||
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
|
||||
generation: number;
|
||||
fetchPacks: () => Promise<void>;
|
||||
fetchEnabledPacks: () => Promise<void>;
|
||||
fetchPackProgress: () => Promise<void>;
|
||||
ensurePacksLoaded: () => Promise<void>;
|
||||
ensureEnabledPacksLoaded: () => Promise<void>;
|
||||
/** Marks the cached catalog stale without spending a request; the next ensure reloads it. */
|
||||
invalidatePacks: () => void;
|
||||
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
|
||||
clearPacks: () => void;
|
||||
getPackProgress: (packId: string) => PackProgressData | undefined;
|
||||
@@ -31,38 +50,61 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
enabledPackIds: [],
|
||||
packProgress: new Map(),
|
||||
isLoading: false,
|
||||
catalogLoadedAt: null,
|
||||
enabledPacksLoadedAt: null,
|
||||
generation: 0,
|
||||
|
||||
fetchPacks: async () => {
|
||||
const myGeneration = get().generation;
|
||||
if (inFlight.get('catalog') === myGeneration) return;
|
||||
inFlight.set('catalog', myGeneration);
|
||||
set({ isLoading: true });
|
||||
try {
|
||||
const catalog = await apiClient.get<{ categories: Category[]; packs: QuestionPack[] }>(
|
||||
'/v1/packs?showAllIntensities=false',
|
||||
);
|
||||
if (get().generation !== myGeneration) return;
|
||||
set({ packs: catalog.packs, categories: catalog.categories });
|
||||
await get().fetchEnabledPacks();
|
||||
await get().fetchPackProgress();
|
||||
if (get().generation !== myGeneration) return;
|
||||
set({ catalogLoadedAt: Date.now() });
|
||||
} finally {
|
||||
set({ isLoading: false });
|
||||
if (inFlight.get('catalog') === myGeneration) inFlight.delete('catalog');
|
||||
if (get().generation === myGeneration) set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
ensurePacksLoaded: async () => {
|
||||
if (get().catalogLoadedAt !== null) return;
|
||||
await get().fetchPacks();
|
||||
},
|
||||
|
||||
invalidatePacks: () => {
|
||||
set({ catalogLoadedAt: null });
|
||||
},
|
||||
|
||||
fetchEnabledPacks: async () => {
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
const myGeneration = get().generation;
|
||||
if (!coupleId) {
|
||||
set({ enabledPackIds: [] });
|
||||
set({ enabledPackIds: [], enabledPacksLoadedAt: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await apiClient.get<{ enabledPackIds: string[] }>("/v1/me/enabled-packs");
|
||||
set({ enabledPackIds: result.enabledPackIds });
|
||||
if (get().generation !== myGeneration) return;
|
||||
set({ enabledPackIds: result.enabledPackIds, enabledPacksLoadedAt: Date.now() });
|
||||
},
|
||||
|
||||
ensureEnabledPacksLoaded: async () => {
|
||||
// Only fetch if not already loaded - lightweight check for swipe screen
|
||||
// Only fetch if not already loaded - lightweight check for swipe screen.
|
||||
// Keyed off the load marker, not the array: "no packs enabled" is a real
|
||||
// answer and must not be retried on every visit.
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
if (!coupleId) return;
|
||||
|
||||
if (get().enabledPackIds.length === 0) {
|
||||
if (get().enabledPacksLoadedAt === null) {
|
||||
await get().fetchEnabledPacks();
|
||||
}
|
||||
},
|
||||
@@ -73,6 +115,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
return { success: false, reason: "no_couple" };
|
||||
}
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const isEnabled = get().enabledPackIds.includes(packId);
|
||||
const newValue = !isEnabled;
|
||||
|
||||
@@ -88,9 +131,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
`/v1/me/enabled-packs/${packId}`,
|
||||
{ enabled: newValue },
|
||||
);
|
||||
// These ids belong to the couple that asked; a clear while the request
|
||||
// was open means they would land in a different couple's selection.
|
||||
if (get().generation !== myGeneration) return { success: false, reason: "stale" };
|
||||
set({ enabledPackIds: result.enabledPackIds });
|
||||
} catch (error) {
|
||||
console.error("Error toggling pack:", error);
|
||||
// The optimistic update this reverts was cleared along with everything
|
||||
// else, so reverting now would only push a stale pack id into the next
|
||||
// couple's list.
|
||||
if (get().generation !== myGeneration) return { success: false, reason: "stale" };
|
||||
set({ enabledPackIds: isEnabled
|
||||
? [...new Set([...get().enabledPackIds, packId])]
|
||||
: get().enabledPackIds.filter(id => id !== packId) });
|
||||
@@ -108,6 +158,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
},
|
||||
|
||||
fetchPackProgress: async () => {
|
||||
const myGeneration = get().generation;
|
||||
if (!useAuthStore.getState().user?.id) {
|
||||
set({ packProgress: new Map() });
|
||||
return;
|
||||
@@ -115,6 +166,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
const result = await apiClient.get<{ progress: Array<PackProgressData & { packId: string }> }>(
|
||||
"/v1/me/pack-progress",
|
||||
);
|
||||
if (get().generation !== myGeneration) return;
|
||||
const progressMap = new Map<string, PackProgressData>();
|
||||
result.progress.forEach(({ packId, totalQuestions, answeredQuestions }) => {
|
||||
progressMap.set(packId, { totalQuestions, answeredQuestions });
|
||||
@@ -128,6 +180,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
||||
},
|
||||
|
||||
clearPacks: () => {
|
||||
set({ enabledPackIds: [], packProgress: new Map() });
|
||||
set((state) => ({
|
||||
enabledPackIds: [],
|
||||
packProgress: new Map(),
|
||||
catalogLoadedAt: null,
|
||||
enabledPacksLoadedAt: null,
|
||||
// Invalidates any request already in flight for the signed-out user.
|
||||
generation: state.generation + 1,
|
||||
}));
|
||||
// See matchStore.clearMatches: the previous user's in-flight catalog read
|
||||
// must not block the next user's.
|
||||
inFlight.clear();
|
||||
},
|
||||
}));
|
||||
@@ -36,16 +36,33 @@ export type DateSortOrder = "newest" | "oldest";
|
||||
|
||||
const BATCH_SIZE = 20;
|
||||
|
||||
// Overlap protection independent of the presentational flags: a refresh over cached
|
||||
// rows raises `isRefreshing`, not `isLoading`, and still must not run twice. Keyed to
|
||||
// the generation that started the request: a stale generation's entry must not block
|
||||
// the next account's request, and its `finally` must not clear a newer generation's guard.
|
||||
const inFlight = new Map<string, number>();
|
||||
|
||||
interface ResponsesState {
|
||||
responses: ResponseWithQuestion[];
|
||||
/** First load only. A refresh over rows already on screen must not blank the list. */
|
||||
isLoading: boolean;
|
||||
/** Refresh over cached rows; drives pull-to-refresh only. */
|
||||
isRefreshing: boolean;
|
||||
isLoadingMore: boolean;
|
||||
groupBy: GroupByOption;
|
||||
dateSortOrder: DateSortOrder;
|
||||
hasMore: boolean;
|
||||
page: number;
|
||||
totalCount: number | null;
|
||||
/** Null until the first successful read; an empty history is a loaded answer. */
|
||||
loadedAt: number | null;
|
||||
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
|
||||
generation: number;
|
||||
fetchResponses: (refresh?: boolean) => Promise<void>;
|
||||
/** Loads the first page once; revisiting the screen reuses what is cached. */
|
||||
ensureResponsesLoaded: () => Promise<void>;
|
||||
/** Marks the cache stale without spending a request; the next ensure reloads it. */
|
||||
invalidateResponses: () => void;
|
||||
updateResponse: (
|
||||
questionId: string,
|
||||
newAnswer: AnswerType,
|
||||
@@ -61,12 +78,15 @@ interface ResponsesState {
|
||||
export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||
responses: [],
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
isLoadingMore: false,
|
||||
groupBy: "date",
|
||||
dateSortOrder: "newest",
|
||||
hasMore: true,
|
||||
page: 0,
|
||||
totalCount: null,
|
||||
loadedAt: null,
|
||||
generation: 0,
|
||||
|
||||
fetchResponses: async (refresh = false) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
@@ -74,25 +94,31 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||
|
||||
if (!userId || !coupleId) return;
|
||||
|
||||
const myGeneration = get().generation;
|
||||
const state = get();
|
||||
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
|
||||
if (inFlight.get('responses') === myGeneration || (state.isLoadingMore && !refresh)) return;
|
||||
|
||||
// Nothing on screen yet is the only case that warrants a full-screen spinner;
|
||||
// every other refresh leaves the cached rows in place until replacements land.
|
||||
const initial = state.loadedAt === null;
|
||||
if (refresh) {
|
||||
set({ isLoading: true, page: 0, hasMore: true });
|
||||
set({ isLoading: initial, isRefreshing: !initial, page: 0, hasMore: true });
|
||||
} else {
|
||||
if (!state.hasMore) return;
|
||||
set({ isLoadingMore: true });
|
||||
}
|
||||
|
||||
inFlight.set('responses', myGeneration);
|
||||
try {
|
||||
const currentPage = refresh ? 0 : state.page;
|
||||
const result = await apiClient.get<{responses: ResponseWithQuestion[];totalCount:number}>(`/v1/me/responses?page=${currentPage}&limit=${BATCH_SIZE}`);
|
||||
if (get().generation !== myGeneration) return;
|
||||
const responses = result.responses;
|
||||
const totalCount = result.totalCount;
|
||||
|
||||
if (!responses || responses.length === 0) {
|
||||
if (refresh) {
|
||||
set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
|
||||
set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, loadedAt: Date.now() });
|
||||
} else {
|
||||
set({ isLoadingMore: false, hasMore: false });
|
||||
}
|
||||
@@ -107,22 +133,37 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||
: [...state.responses, ...transformedResponses],
|
||||
totalCount: refresh ? totalCount : state.totalCount,
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
isLoadingMore: false,
|
||||
page: currentPage + 1,
|
||||
hasMore: responses.length === BATCH_SIZE
|
||||
hasMore: responses.length === BATCH_SIZE,
|
||||
loadedAt: Date.now(),
|
||||
}));
|
||||
} catch (error) {
|
||||
if (get().generation !== myGeneration) return;
|
||||
console.error("Error in fetchResponses:", error);
|
||||
set({ isLoading: false, isLoadingMore: false });
|
||||
set({ isLoading: false, isRefreshing: false, isLoadingMore: false });
|
||||
} finally {
|
||||
if (inFlight.get('responses') === myGeneration) inFlight.delete('responses');
|
||||
}
|
||||
},
|
||||
|
||||
ensureResponsesLoaded: async () => {
|
||||
if (get().loadedAt !== null) return;
|
||||
await get().fetchResponses(true);
|
||||
},
|
||||
|
||||
invalidateResponses: () => {
|
||||
set({ loadedAt: null });
|
||||
},
|
||||
|
||||
updateResponse: async (
|
||||
questionId: string,
|
||||
newAnswer: AnswerType,
|
||||
confirmDelete = false,
|
||||
responseData?: ResponseData | null
|
||||
): Promise<UpdateResponseResult> => {
|
||||
const myGeneration = get().generation;
|
||||
try {
|
||||
const result = await apiClient.patch<UpdateResponseResult>(`/v1/responses/${questionId}`, {
|
||||
new_answer: newAnswer,
|
||||
@@ -130,6 +171,13 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||
response_data: responseData,
|
||||
});
|
||||
|
||||
// Rows are keyed by a question id from the shared catalogue, so once the
|
||||
// store has been cleared that same id can name a different account's
|
||||
// answer. Hand the caller the server's result — it is true of whoever
|
||||
// asked — but write nothing, and refresh no matches, for the account
|
||||
// that is loaded now.
|
||||
if (get().generation !== myGeneration) return result;
|
||||
|
||||
// If update was successful (not just requiring confirmation), update local state
|
||||
if (result.success && !result.requires_confirmation) {
|
||||
// Update local response state
|
||||
@@ -191,7 +239,15 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||
},
|
||||
|
||||
clearResponses: () => {
|
||||
set({ responses: [], isLoading: false, groupBy: "date", dateSortOrder: "newest", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
|
||||
set((state) => ({
|
||||
responses: [], isLoading: false, isRefreshing: false, groupBy: "date", dateSortOrder: "newest",
|
||||
page: 0, hasMore: true, isLoadingMore: false, totalCount: null, loadedAt: null,
|
||||
// Invalidates any request already in flight for the signed-out user.
|
||||
generation: state.generation + 1,
|
||||
}));
|
||||
// See matchStore.clearMatches: the previous user's in-flight page must not
|
||||
// block the next user's.
|
||||
inFlight.clear();
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -27,36 +27,84 @@ interface StreakState {
|
||||
streak: CoupleStreak | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
/**
|
||||
* Null until the first successful read. A couple with no streak yet resolves to a
|
||||
* null `streak`, which is a loaded answer rather than a reason to ask again.
|
||||
*/
|
||||
loadedAt: number | null;
|
||||
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
|
||||
generation: number;
|
||||
|
||||
// Actions
|
||||
fetchStreak: () => Promise<void>;
|
||||
/** Loads the streak once; later callers reuse the cached value. */
|
||||
ensureStreakLoaded: () => Promise<void>;
|
||||
/** Silent re-read of an already-loaded streak; leaves the cached value on screen if it fails. */
|
||||
refreshStreak: () => Promise<void>;
|
||||
clearStreak: () => void;
|
||||
}
|
||||
|
||||
export const useStreakStore = create<StreakState>((set) => ({
|
||||
// The streak is rendered by a badge, the home screen and the swipe screen, so
|
||||
// concurrent mounts must share one request rather than each issuing their own.
|
||||
// Tagged with the generation that started it: clearing/signing out bumps the
|
||||
// generation, so a stale response can no longer write into the next account's
|
||||
// store, and a stale `finally` cannot clear a newer generation's guard.
|
||||
let inFlight: { generation: number; promise: Promise<void> } | null = null;
|
||||
|
||||
export const useStreakStore = create<StreakState>((set, get) => ({
|
||||
streak: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
loadedAt: null,
|
||||
generation: 0,
|
||||
|
||||
fetchStreak: async () => {
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
const myGeneration = get().generation;
|
||||
if (!coupleId) {
|
||||
set({ streak: null, isLoading: false });
|
||||
set({ streak: null, isLoading: false, loadedAt: null });
|
||||
return;
|
||||
}
|
||||
|
||||
if (inFlight && inFlight.generation === myGeneration) return inFlight.promise;
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak");
|
||||
set({ streak, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error("Error fetching streak:", error);
|
||||
set({ error: "Failed to load streak", isLoading: false });
|
||||
}
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak");
|
||||
if (get().generation !== myGeneration) return;
|
||||
set({ streak, isLoading: false, loadedAt: Date.now() });
|
||||
} catch (error) {
|
||||
if (get().generation !== myGeneration) return;
|
||||
console.error("Error fetching streak:", error);
|
||||
set({ error: "Failed to load streak", isLoading: false });
|
||||
} finally {
|
||||
if (inFlight?.generation === myGeneration) inFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
inFlight = { generation: myGeneration, promise };
|
||||
return promise;
|
||||
},
|
||||
|
||||
ensureStreakLoaded: async () => {
|
||||
if (get().loadedAt !== null) return;
|
||||
await get().fetchStreak();
|
||||
},
|
||||
|
||||
refreshStreak: async () => {
|
||||
// Only silently refresh a streak the UI has actually loaded; an unopened
|
||||
// screen has nothing cached to keep honest.
|
||||
if (get().loadedAt === null) return;
|
||||
await get().fetchStreak();
|
||||
},
|
||||
|
||||
clearStreak: () => {
|
||||
set({ streak: null, isLoading: false, error: null });
|
||||
set((state) => ({
|
||||
streak: null, isLoading: false, error: null, loadedAt: null,
|
||||
// Invalidates any request already in flight for the signed-out user.
|
||||
generation: state.generation + 1,
|
||||
}));
|
||||
},
|
||||
}));
|
||||
+53
-16
@@ -1,21 +1,58 @@
|
||||
# Agent routing and effort
|
||||
|
||||
Use one implementation agent by default. Add independent agents only when work is
|
||||
actually separable or a risk trigger requires independent judgment.
|
||||
The primary agent owns scope, acceptance criteria, task decomposition, and the
|
||||
final answer. Use one implementation agent when a bounded handoff will keep noisy
|
||||
execution out of the primary context. Do not pay the coordination cost for a
|
||||
trivial one-file edit. Add other agents only for separable work or a risk trigger.
|
||||
|
||||
| Role | Default effort | Trigger | Do not use for |
|
||||
|---|---|---|---|
|
||||
| `repo-scout` | low | locate ownership, callers, contracts | editing or design decisions |
|
||||
| `implementation` | medium | bounded product or harness change | broad architecture review |
|
||||
| `test-writer` | medium | focused regression or missing contract test | rubber-stamping green output |
|
||||
| `verifier` | medium | independently drive changed behavior | editing implementation |
|
||||
| `database-reviewer` | medium | migrations, RLS, function/query boundaries | ordinary UI changes |
|
||||
| `security-reviewer` | high | auth, RLS, crypto, secrets, payments, destructive operations | every routine change |
|
||||
| `architecture-reviewer` | high | cross-cutting or hard-to-reverse decisions | local refactors |
|
||||
## Cost ladder
|
||||
|
||||
Escalate effort only when uncertainty, blast radius, or irreversibility justifies
|
||||
it. “Proactively use” is not a trigger. Verification agents return evidence and a
|
||||
verdict; they do not make changes.
|
||||
Project Codex defaults live in `.codex/config.toml`; task profiles live in
|
||||
`.codex/agents/`. An explicit task handoff may raise or lower reasoning effort.
|
||||
|
||||
Procedures belong in skills. Agents read the applicable skill and nearest
|
||||
`AGENTS.md`, then operate within the task's authorization.
|
||||
| Role | Codex model | Default effort | Trigger | Do not use for |
|
||||
|---|---|---:|---|---|
|
||||
| `repo_scout` | Luna | low | locate ownership, callers, contracts | editing or design decisions |
|
||||
| `implementation` | Terra | medium, overridable | bounded product or harness change | broad architecture review |
|
||||
| `test_writer` | Terra | medium | focused regression or missing contract test | rubber-stamping green output |
|
||||
| `verifier` | Terra | medium | independently drive implemented behavior | editing implementation |
|
||||
| `database_reviewer` | Terra | high | migrations, RLS, function/query boundaries | ordinary UI changes |
|
||||
| `security_reviewer` | Sol | high | auth, RLS, crypto, secrets, payments, destructive operations | every routine change |
|
||||
| `architecture_reviewer` | Sol | high | cross-cutting or hard-to-reverse decisions | local refactors |
|
||||
|
||||
Claude profiles under `.claude/agents/` provide equivalent roles when Claude is
|
||||
explicitly requested. They are not part of the normal Codex routing path.
|
||||
|
||||
## Implementation effort
|
||||
|
||||
Keep `implementation` on Terra and choose effort from the task shape:
|
||||
|
||||
- `low`: mechanical, fully specified, narrow change with an obvious check.
|
||||
- `medium`: normal feature or bug fix with a few files and known contracts.
|
||||
- `high`: ambiguous behavior, several interacting modules, concurrency, or
|
||||
difficult debugging.
|
||||
- `xhigh`: exceptional Terra implementation where high effort has already proved
|
||||
insufficient or the change has a large but non-security blast radius.
|
||||
|
||||
Security and architecture risk are different jobs, not reasons to silently turn
|
||||
the implementation agent into a premium generalist. Add the matching read-only
|
||||
Sol reviewer and keep implementation on Terra unless the user explicitly asks for
|
||||
a different model.
|
||||
|
||||
## Handoff contract
|
||||
|
||||
Every delegated task includes the objective, owned files or surface, acceptance
|
||||
criteria, applicable `AGENTS.md` and skill, allowed side effects, required checks,
|
||||
and the evidence to return. The agent must report changed files, commands and
|
||||
results, assumptions, and blockers. The primary agent independently reviews the
|
||||
diff and decides whether acceptance has been met.
|
||||
|
||||
Parallel agents are for independent read-heavy work or non-overlapping ownership.
|
||||
Avoid concurrent edits to the same files, shared database state, fixed local
|
||||
ports, or one simulator. Subagents consume additional tokens, so parallelism must
|
||||
buy meaningful time or independent judgment.
|
||||
|
||||
Procedures belong in deterministic skills rather than agent prompts. Agents read
|
||||
the nearest `AGENTS.md` and the applicable repository skill, then operate only
|
||||
within the task's authorization. Verification agents return evidence and a
|
||||
verdict; they do not fix implementation.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Client data freshness
|
||||
|
||||
The mobile app talks to a request-per-read API with no realtime channel. This
|
||||
document is the contract for how it stays current without spending a request
|
||||
every time the user looks at something they have already loaded.
|
||||
|
||||
## The invariant
|
||||
|
||||
**Cached data is usable data.** Once a screen has loaded, switching away from it
|
||||
and back must cost zero requests and must never blank what is on screen. A
|
||||
refresh happens for a reason — the user pulled to refresh, a mutation invalidated
|
||||
the data, or a change marker moved — never merely because a screen regained
|
||||
focus.
|
||||
|
||||
Three rules make that hold:
|
||||
|
||||
- **Loaded-ness is recorded, not inferred.** Stores keep a marker
|
||||
(`loadedAt`, `catalogLoadedAt`, `enabledPacksLoadedAt`) that is set on a
|
||||
successful load and cleared on sign-out or explicit invalidation. Emptiness is
|
||||
a legitimate answer, not evidence of never having loaded: keying off
|
||||
`array.length === 0` makes every visit to an empty list pay for the same empty
|
||||
response.
|
||||
- **Initial loading and refreshing are different states.** `isLoading` means
|
||||
there is nothing to show yet and drives the full-screen loader. `isRefreshing`
|
||||
drives pull-to-refresh only. A background refresh raises neither, so cached
|
||||
rows stay on screen and in place until the replacement arrives.
|
||||
- **Invalidation is free.** A mutation that changes another screen's data marks
|
||||
that cache stale (`invalidatePacks`, `invalidateResponses`) instead of
|
||||
refetching it. Whichever screen the user opens next reloads once; screens they
|
||||
do not open stay quiet.
|
||||
|
||||
Overlap protection lives in a module-scoped `inFlight` set rather than in the
|
||||
loading flags, because a silent refresh raises no flag and still must not issue a
|
||||
second request. The set is cleared alongside the store on sign-out, so a
|
||||
signed-out user's in-flight request cannot suppress the next user's.
|
||||
|
||||
## Recurring work
|
||||
|
||||
`usePolling` (`apps/mobile/src/hooks/usePolling.ts`) owns every recurring read.
|
||||
It schedules the next run after the previous one settles rather than on a fixed
|
||||
interval, so a slow response delays the next request instead of queueing one
|
||||
behind it, and it provides three properties no hand-rolled interval in this app
|
||||
had:
|
||||
|
||||
- it stops while the app is backgrounded or `inactive`, and catches up
|
||||
immediately on return;
|
||||
- it never runs two passes concurrently;
|
||||
- it backs off exponentially — to `maxIntervalMs`, eight intervals by default —
|
||||
while the API is failing.
|
||||
|
||||
Callers gate it further with `enabled`, which carries screen focus, a loaded id,
|
||||
or a paired couple. Screens pass focus in as a prop rather than having the hook
|
||||
read navigation state, so hooks stay renderable without a navigator.
|
||||
|
||||
| Poller | Interval | Runs while |
|
||||
|---|---|---|
|
||||
| Couple sync (`useCoupleSync`) | 15s | signed in, app foregrounded |
|
||||
| Chat messages | 2s | chat screen focused |
|
||||
| Dares | 5s | dares screen focused |
|
||||
| Live draw | 750ms | canvas focused |
|
||||
|
||||
Live draw keeps the sub-second cadence a shared canvas needs, but only for the
|
||||
seconds someone is actually looking at it.
|
||||
|
||||
## `GET /v1/me/sync`
|
||||
|
||||
One small query summarising everything the app used to discover by re-reading
|
||||
five endpoints. The client compares it against the previous summary and refreshes
|
||||
only the domains whose markers moved — and only those already loaded, so a view
|
||||
nobody has opened is never fetched in the background.
|
||||
|
||||
| Field | Moves when |
|
||||
|---|---|
|
||||
| `server_time` | every call |
|
||||
| `couple_id`, `profile_updated_at` | the user's own profile or couple membership changed |
|
||||
| `partner_id`, `partner_updated_at` | the partner's profile changed |
|
||||
| `match_count`, `new_match_count`, `latest_match_at` | matches were created, seen, or archived |
|
||||
| `pending_yours`, `pending_theirs` | either side answered a question the other is waiting on |
|
||||
| `unread_total` | chat unread count for this user changed |
|
||||
| `enabled_packs_fingerprint` | the couple's enabled pack set changed |
|
||||
| `match_state_fingerprint` | a visible match's type or response summary changed in place |
|
||||
| `match_unread_fingerprint` | unread moved between matches, with or without a change to the total |
|
||||
| `streak_updated_at` | the couple's streak row changed |
|
||||
|
||||
`unread_total` is applied directly to the badge, so keeping it current costs
|
||||
nothing beyond this request. The first poll after sign-in only establishes the
|
||||
baseline: every screen loads its own data on mount, and refreshing it again would
|
||||
undo the saving.
|
||||
|
||||
`enabled_packs_fingerprint` is an `md5` digest of the enabled pack ids rather
|
||||
than a timestamp, because `couple_packs` carries no `updated_at` and equal and
|
||||
opposite toggles would otherwise be invisible. Zero enabled packs is the stable
|
||||
digest of the empty string, not `null`.
|
||||
|
||||
The two match digests exist for the same reason in a different shape: the counts
|
||||
and `latest_match_at` all hold steady when a partner edits an existing match in
|
||||
place, and `unread_total` holds steady when unread merely moves between matches —
|
||||
which still reorders an unread-first list.
|
||||
|
||||
Every field is derived for the calling identity's own couple. The summary reveals
|
||||
nothing about any other couple, and reveals no partner content — only that
|
||||
something moved.
|
||||
|
||||
## Chat deltas
|
||||
|
||||
`GET /v1/matches/:id/messages` takes two optional parameters:
|
||||
|
||||
- `since` — an ISO 8601 timestamp with offset. Returns only rows whose visible
|
||||
state moved since then: created, delivered, read, deleted, media viewed, or a
|
||||
video whose `media_expires_at` fell inside the window. Messages are not
|
||||
editable, so there is no edit to report. A malformed value is rejected with
|
||||
`400 invalid_since` rather than silently treated as a full read.
|
||||
- `typing=true` — folds the partner's typing state into the same response, so a
|
||||
focused chat makes one request per pass instead of two.
|
||||
|
||||
The response is a `MessagesPage`:
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `messages` | rows to merge by id, newest first |
|
||||
| `removed_ids` | rows the client must drop (deleted, or deleted-for-self) |
|
||||
| `server_time` | cursor for the next `since` |
|
||||
| `complete` | true when this is a full snapshot rather than a delta |
|
||||
| `typing` | present only when `typing=true` was requested |
|
||||
|
||||
`server_time` is deliberately set two seconds behind `now()` so a row committed
|
||||
during the request cannot fall between two cursors. The overlap re-returns a
|
||||
handful of already-known rows, which is harmless because the client merges by id.
|
||||
|
||||
`media_expired` is derived in the query — `media_expired or media_expires_at <=
|
||||
now()` — rather than read from the stored column, which nothing ever writes. That
|
||||
is the rule the media endpoint already applies when it refuses an expired object,
|
||||
so a client is no longer handed a video it will not be allowed to fetch. Expiry is
|
||||
also the one visible change no row write announces, which is why the delta
|
||||
compares the deadline against the poll window rather than looking for a timestamp
|
||||
that moved; the row is emitted in the single window its deadline falls into.
|
||||
|
||||
`moderation_status`, `flag_reason` and `category` are returned in full but are
|
||||
**not** covered by `since`: the classifier and the admin tool rewrite them without
|
||||
advancing any timestamp, so a delta cannot see them move. No client renders them
|
||||
today, so nothing user-visible goes stale — but a client that started rendering
|
||||
them would need a cursor for them, which means a new column on `messages`.
|
||||
|
||||
Deltas do not change access control, ordering, media fields, or deletion
|
||||
semantics: a delta returns exactly the rows a full read would have returned for
|
||||
that window, and a caller outside the couple still gets `match_not_found`.
|
||||
|
||||
The client marks the conversation read only when a response actually contains an
|
||||
unread partner message, and `POST .../read` returns the number of rows it
|
||||
cleared, so the badge is adjusted by subtraction rather than by re-reading the
|
||||
total.
|
||||
|
||||
## Match pagination
|
||||
|
||||
`GET /v1/matches` returns `{ matches, totalCount, hasMore }`.
|
||||
|
||||
`totalCount` is a `number` on page 0 and **`null` on every later page**: the
|
||||
count is a full scan of the couple's matches and nothing in the UI needs it more
|
||||
than once per refresh. Clients keep the value from the refresh that started the
|
||||
sequence.
|
||||
|
||||
`hasMore` comes from reading `limit + 1` rows and reporting whether the extra one
|
||||
existed, so an exactly-full final page reports `false` correctly. Unread counts
|
||||
are aggregated once in a CTE rather than by a correlated subquery per row.
|
||||
Ordering (unread first, then newest) and couple isolation are unchanged.
|
||||
|
||||
Pagination itself is still page-number/`OFFSET`-based, not cursor-based. This
|
||||
pass removed the repeated `count(*)` and the per-row unread subquery — the two
|
||||
costs that scaled with the couple's history — but paging deep into a large
|
||||
history still pays an `OFFSET` scan. Cursor pagination would remove that too;
|
||||
it has not been implemented.
|
||||
|
||||
## `GET /v1/me` is idempotent
|
||||
|
||||
Bootstrapping a profile reads first and writes only what actually differs. An
|
||||
unchanged read leaves both `updated_at` and `auth_synced_at` alone, so a client
|
||||
polling `/v1/me` no longer produces a write per call. First use still creates
|
||||
the profile from the auth identity's name, avatar and email. On every later
|
||||
call only a changed **email** writes through, syncing the one field the auth
|
||||
provider is authoritative for; the product's display name and avatar are not
|
||||
touched, so a locally-edited name or avatar is never overwritten by
|
||||
potentially stale auth metadata.
|
||||
|
||||
## One database pool
|
||||
|
||||
The API process creates a single `pg` pool and passes it to every repository, so
|
||||
the process holds one bounded set of server connections rather than a
|
||||
default-sized pool per domain. Shutdown ends that pool once. Repositories still
|
||||
accept a connection string in tests, in which case they own and end the pool they
|
||||
created — see `resolvePool` / `closeResolvedPool` in `apps/api/src/db/pool.ts`.
|
||||
|
||||
Bounds come from `DATABASE_POOL_MAX` (default 10),
|
||||
`DATABASE_POOL_IDLE_TIMEOUT_MS` (30000) and
|
||||
`DATABASE_POOL_CONNECTION_TIMEOUT_MS` (10000). They are process-wide; keep the
|
||||
total across running instances inside the database's own connection limit.
|
||||
+9
-3
@@ -121,9 +121,15 @@ rejected.
|
||||
## Mobile
|
||||
|
||||
Reached from the home screen's `DaresTile`, which badges pending incoming dares. The
|
||||
screen has Active / Send / History tabs, polls on a 5s interval (there is no realtime
|
||||
channel), and opens the paywall both proactively and in reaction to a `402` — a client
|
||||
whose entitlement went stale still lands on the paywall rather than an error.
|
||||
screen has Active / Send / History tabs and opens the paywall both proactively and in
|
||||
reaction to a `402` — a client whose entitlement went stale still lands on the paywall
|
||||
rather than an error.
|
||||
|
||||
There is no realtime channel, so the screen polls on a 5s interval — but only while it is
|
||||
focused and the app is foregrounded, and each pass reads the active list alone. History,
|
||||
the catalogue and the stats are re-read only when the active set's ids or statuses
|
||||
actually changed, or on an explicit refresh or a mutation. See
|
||||
[Client data freshness](architecture/client-data-freshness.md).
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ historical notes. Agents should load only the section relevant to their task.
|
||||
## Maintained system documentation
|
||||
|
||||
- [System architecture](architecture/system.md)
|
||||
- [Client data freshness](architecture/client-data-freshness.md)
|
||||
- [Local full-stack development](development/local-stack.md)
|
||||
- [Testing strategy](testing/strategy.md)
|
||||
- [Database migration safety](operations/database-migrations.md)
|
||||
|
||||
@@ -23,6 +23,10 @@ function fail(file, message, fix) {
|
||||
errors.push(`${path.relative(root, file)}: ${message}\n Fix: ${fix}`);
|
||||
}
|
||||
|
||||
function tomlString(source, key) {
|
||||
return source.match(new RegExp(`^${key}\\s*=\\s*"([^"]+)"\\s*$`, 'm'))?.[1];
|
||||
}
|
||||
|
||||
const allFiles = await walk(root);
|
||||
for (const file of allFiles) {
|
||||
if (path.basename(file) === 'agents.md') {
|
||||
@@ -37,6 +41,57 @@ for (const file of allFiles) {
|
||||
|
||||
if (!scoped) {
|
||||
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
||||
const codexConfigFile = path.join(root, '.codex', 'config.toml');
|
||||
const codexConfig = await readFile(codexConfigFile, 'utf8');
|
||||
if (!/^\[agents\]$/m.test(codexConfig) || !/^enabled\s*=\s*true$/m.test(codexConfig)) {
|
||||
fail(codexConfigFile, 'project subagent routing is not enabled', 'restore the [agents] table with enabled = true');
|
||||
}
|
||||
if (tomlString(codexConfig, 'default_subagent_model') !== 'gpt-5.6-terra') {
|
||||
fail(codexConfigFile, 'ordinary subagents no longer default to the cost-balanced model', 'set default_subagent_model = "gpt-5.6-terra"');
|
||||
}
|
||||
if (tomlString(codexConfig, 'default_subagent_reasoning_effort') !== 'medium') {
|
||||
fail(codexConfigFile, 'ordinary subagent effort is no longer the balanced default', 'set default_subagent_reasoning_effort = "medium"');
|
||||
}
|
||||
const maxThreads = Number(codexConfig.match(/^max_concurrent_threads_per_session\s*=\s*(\d+)$/m)?.[1]);
|
||||
if (!Number.isInteger(maxThreads) || maxThreads < 1 || maxThreads > 3) {
|
||||
fail(codexConfigFile, 'subagent concurrency is missing or exceeds the repository cost cap', 'set max_concurrent_threads_per_session between 1 and 3');
|
||||
}
|
||||
|
||||
const agentContracts = {
|
||||
'repo-scout.toml': { name: 'repo_scout', model: 'gpt-5.6-luna', effort: 'low', readOnly: true },
|
||||
'implementation.toml': { name: 'implementation', model: 'gpt-5.6-terra', effort: null, flexibleEffort: true },
|
||||
'test-writer.toml': { name: 'test_writer', model: 'gpt-5.6-terra', effort: 'medium' },
|
||||
'verifier.toml': { name: 'verifier', model: 'gpt-5.6-terra', effort: 'medium' },
|
||||
'database-reviewer.toml': { name: 'database_reviewer', model: 'gpt-5.6-terra', effort: 'high', readOnly: true },
|
||||
'security-reviewer.toml': { name: 'security_reviewer', model: 'gpt-5.6-sol', effort: 'high', readOnly: true },
|
||||
'architecture-reviewer.toml': { name: 'architecture_reviewer', model: 'gpt-5.6-sol', effort: 'high', readOnly: true },
|
||||
};
|
||||
for (const [filename, contract] of Object.entries(agentContracts)) {
|
||||
const agentFile = path.join(root, '.codex', 'agents', filename);
|
||||
if (!(await exists(agentFile))) {
|
||||
fail(agentFile, 'required task-agent profile is missing', `restore .codex/agents/${filename}`);
|
||||
continue;
|
||||
}
|
||||
const source = await readFile(agentFile, 'utf8');
|
||||
if (tomlString(source, 'name') !== contract.name || tomlString(source, 'model') !== contract.model) {
|
||||
fail(agentFile, 'task-agent identity or cost tier drifted', `set name = "${contract.name}" and model = "${contract.model}"`);
|
||||
}
|
||||
const effort = tomlString(source, 'model_reasoning_effort');
|
||||
if (contract.flexibleEffort ? effort !== undefined : effort !== contract.effort) {
|
||||
fail(
|
||||
agentFile,
|
||||
contract.flexibleEffort ? 'implementation reasoning is pinned and cannot flex by task complexity' : 'task-agent reasoning effort drifted',
|
||||
contract.flexibleEffort ? 'omit model_reasoning_effort so the handoff or project default controls it' : `set model_reasoning_effort = "${contract.effort}"`,
|
||||
);
|
||||
}
|
||||
if (contract.readOnly && tomlString(source, 'sandbox_mode') !== 'read-only') {
|
||||
fail(agentFile, 'review-only agent can mutate the workspace', 'set sandbox_mode = "read-only"');
|
||||
}
|
||||
if (!/^description\s*=\s*".+"$/m.test(source) || !/developer_instructions\s*=\s*"""[\s\S]*\S[\s\S]*"""/m.test(source)) {
|
||||
fail(agentFile, 'custom agent is missing its required description or instructions', 'restore non-empty description and developer_instructions fields');
|
||||
}
|
||||
}
|
||||
|
||||
const mobilePackageFile = path.join(root, 'apps', 'mobile', 'package.json');
|
||||
const mobilePackageJson = JSON.parse(await readFile(mobilePackageFile, 'utf8'));
|
||||
const releaseBuildScripts = Object.entries(mobilePackageJson.scripts ?? {})
|
||||
|
||||
Reference in new issue
Block a user