Files
Sauci/apps/api/src/domains/chat/repository.ts
T
jjbrunton c7ee8a4851 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.
2026-08-29 21:47:27 +01:00

340 lines
14 KiB
TypeScript

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';
export interface ChatMessage {
id: string;
match_id: string;
user_id: string;
content: string | null;
created_at: string;
read_at: string | null;
delivered_at: string | null;
deleted_at: string | null;
media_path: string | null;
media_type: ChatMediaType;
media_expires_at: string | null;
media_expired: boolean;
media_viewed_at: string | null;
version: number;
encrypted_content: string | null;
encryption_iv: string | null;
keys_metadata: Record<string, unknown> | null;
moderation_status: string | null;
flag_reason: string | null;
category: string | null;
}
interface MessageRecord extends QueryResultRow, Omit<ChatMessage, 'created_at' | 'read_at' | 'delivered_at' | 'deleted_at' | 'media_expires_at' | 'media_viewed_at'> {
created_at: Date;
read_at: Date | null;
delivered_at: Date | null;
deleted_at: Date | null;
media_expires_at: Date | null;
media_viewed_at: Date | null;
}
export class ChatError extends Error {
constructor(
public readonly code: string,
message: string,
public readonly status: 400 | 403 | 404 | 409,
) {
super(message);
}
}
/**
* 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, 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 }>;
markDelivered(userId: string, messageId: string): Promise<ChatMessage>;
deleteForSelf(userId: string, messageId: string): Promise<void>;
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<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 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 {
return value?.toISOString() ?? null;
}
function toMessage(row: MessageRecord): ChatMessage {
return {
...row,
created_at: row.created_at.toISOString(),
read_at: iso(row.read_at),
delivered_at: iso(row.delivered_at),
deleted_at: iso(row.deleted_at),
media_expires_at: iso(row.media_expires_at),
media_viewed_at: iso(row.media_viewed_at),
};
}
export class PostgresChatRepository implements ChatRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
}
private async assertMatchAccess(userId: string, matchId: string): Promise<void> {
const result = await this.pool.query(
`select 1
from profiles p
join matches ma on ma.couple_id = p.couple_id
where p.id = $1 and ma.id = $2`,
[userId, matchId],
);
if (!result.rowCount) throw new ChatError('match_not_found', 'Match not found', 404);
}
private async accessibleMessage(userId: string, messageId: string): Promise<MessageRecord> {
const result = await this.pool.query<MessageRecord>(
`select ${columns}
from messages m
join matches ma on ma.id = m.match_id
join profiles p on p.couple_id = ma.couple_id
where p.id = $1 and m.id = $2`,
[userId, messageId],
);
const row = result.rows[0];
if (!row) throw new ChatError('message_not_found', 'Message not found', 404);
return row;
}
/**
* 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 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> {
await this.assertMatchAccess(userId, matchId);
const result = await this.pool.query<MessageRecord>(
`insert into messages (match_id, user_id, content, version)
values ($1, $2, $3, 1)
returning id, match_id, user_id, content, created_at, read_at, delivered_at,
deleted_at, media_path, media_type, media_expires_at, media_expired,
media_viewed_at, version, encrypted_content, encryption_iv, keys_metadata,
moderation_status, flag_reason, category`,
[matchId, userId, content],
);
return toMessage(result.rows[0]!);
}
async unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }> {
const result = await this.pool.query<{ match_id: string; count: string }>(
`select m.match_id, count(*)::text as count
from messages m
join matches ma on ma.id = m.match_id
join profiles p on p.couple_id = ma.couple_id
where p.id = $1 and m.user_id <> $1 and m.read_at is null
and m.deleted_at is null
and not exists (
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $1
)
group by m.match_id`,
[userId],
);
const by_match = Object.fromEntries(result.rows.map((row) => [row.match_id, Number(row.count)]));
return { total: Object.values(by_match).reduce((sum, count) => sum + count, 0), by_match };
}
async markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }> {
await this.assertMatchAccess(userId, matchId);
const readAt = new Date();
const result = await this.pool.query(
`update messages
set delivered_at = coalesce(delivered_at, $3), read_at = $3
where match_id = $1 and user_id <> $2 and read_at is null`,
[matchId, userId, readAt],
);
return { updated: result.rowCount ?? 0, read_at: readAt.toISOString() };
}
async markDelivered(userId: string, messageId: string): Promise<ChatMessage> {
const message = await this.accessibleMessage(userId, messageId);
if (message.user_id === userId) throw new ChatError('invalid_recipient', 'Senders cannot mark their own message delivered', 403);
const result = await this.pool.query<MessageRecord>(
`update messages m set delivered_at = coalesce(m.delivered_at, now()) where m.id = $1 returning ${columns}`,
[messageId],
);
return toMessage(result.rows[0]!);
}
async deleteForSelf(userId: string, messageId: string): Promise<void> {
await this.accessibleMessage(userId, messageId);
await this.pool.query(
`insert into message_deletions (message_id, user_id) values ($1, $2) on conflict do nothing`,
[messageId, userId],
);
}
async deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage> {
const message = await this.accessibleMessage(userId, messageId);
if (message.user_id !== userId) throw new ChatError('not_message_author', 'Only the sender can delete a message for everyone', 403);
const result = await this.pool.query<MessageRecord>(
`update messages m set deleted_at = coalesce(m.deleted_at, now()) where m.id = $1 returning ${columns}`,
[messageId],
);
return toMessage(result.rows[0]!);
}
async report(userId: string, messageId: string, reason: ReportReason): Promise<void> {
const message = await this.accessibleMessage(userId, messageId);
if (message.user_id === userId) throw new ChatError('cannot_report_own_message', 'You cannot report your own message', 403);
try {
await this.pool.query(
`insert into message_reports (message_id, reporter_id, reason) values ($1, $2, $3)`,
[messageId, userId, reason],
);
} catch (cause) {
if (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code === '23505') {
throw new ChatError('already_reported', 'You have already reported this message', 409);
}
throw cause;
}
}
async setTyping(userId: string, matchId: string, ttlMs: number): Promise<void> {
await this.assertMatchAccess(userId, matchId);
await this.pool.query(
`insert into chat_typing_states (match_id, user_id, expires_at)
values ($1, $2, now() + ($3 * interval '1 millisecond'))
on conflict (match_id, user_id) do update set expires_at = excluded.expires_at`,
[matchId, userId, ttlMs],
);
}
/** 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()
order by expires_at desc limit 1`,
[matchId, userId],
);
const expiresAt = result.rows[0]?.expires_at ?? null;
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 closeResolvedPool(this.pool, this.ownsPool);
}
}