feat(backend): add standalone data plane

Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
jjbrunton committed 2026-08-28 14:42:11 +01:00
1 parent 01bb8225ae
commit 006761d3b0
294 files changed
+16259 -6138

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
import { Pool, type QueryResultRow } from 'pg';
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);
}
}
export interface ChatRepository {
listMessages(userId: string, matchId: string): Promise<ChatMessage[]>;
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<{ typing: boolean; expires_at: string | null }>;
close(): Promise<void>;
}
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.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;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
}
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;
}
async listMessages(userId: string, matchId: string): Promise<ChatMessage[]> {
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);
}
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],
);
}
async getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }> {
await this.assertMatchAccess(userId, matchId);
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 close(): Promise<void> {
await this.pool.end();
}
}
+93
View File
@@ -0,0 +1,93 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { ChatError, type ChatRepository } from './repository.js';
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();
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
}
async function result<T>(operation: () => Promise<T>) {
try {
return { ok: true as const, value: await operation() };
} catch (cause) {
if (cause instanceof ChatError) return { ok: false as const, cause };
throw cause;
}
}
export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void {
app.get('/v1/chat/unread', async (c) => c.json(await repository.unreadCounts(c.get('identity').id)));
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);
});
app.post('/v1/matches/:matchId/messages', async (c) => {
const parsedId = id.safeParse(c.req.param('matchId'));
const parsedBody = sendBody.safeParse(await c.req.json().catch(() => ({})));
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
if (!parsedBody.success) return c.json(error('invalid_message', 'Message content is required'), 400);
const response = await result(() => repository.sendText(c.get('identity').id, parsedId.data, parsedBody.data.content));
return response.ok ? c.json({ message: response.value }, 201) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.post('/v1/matches/:matchId/read', 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.markMatchRead(c.get('identity').id, parsedId.data));
return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.put('/v1/matches/:matchId/typing', 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.setTyping(c.get('identity').id, parsedId.data, 4_000));
return response.ok ? c.json({ typing: true }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.get('/v1/matches/:matchId/typing', 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.getPartnerTyping(c.get('identity').id, parsedId.data));
return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.post('/v1/messages/:messageId/delivered', async (c) => {
const parsedId = id.safeParse(c.req.param('messageId'));
if (!parsedId.success) return c.json(error('invalid_message_id', 'A valid message ID is required'), 400);
const response = await result(() => repository.markDelivered(c.get('identity').id, parsedId.data));
return response.ok ? c.json({ message: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.delete('/v1/messages/:messageId', async (c) => {
const parsedId = id.safeParse(c.req.param('messageId'));
const scope = c.req.query('scope');
if (!parsedId.success) return c.json(error('invalid_message_id', 'A valid message ID is required'), 400);
if (scope !== 'self' && scope !== 'everyone') return c.json(error('invalid_delete_scope', 'Delete scope must be self or everyone'), 400);
if (scope === 'self') {
const response = await result(() => repository.deleteForSelf(c.get('identity').id, parsedId.data));
return response.ok ? c.json({ deleted: true }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
}
const response = await result(() => repository.deleteForEveryone(c.get('identity').id, parsedId.data));
return response.ok ? c.json({ deleted: true, message: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
app.post('/v1/messages/:messageId/reports', async (c) => {
const parsedId = id.safeParse(c.req.param('messageId'));
const parsedBody = reportBody.safeParse(await c.req.json().catch(() => ({})));
if (!parsedId.success) return c.json(error('invalid_message_id', 'A valid message ID is required'), 400);
if (!parsedBody.success) return c.json(error('invalid_report_reason', 'A valid report reason is required'), 400);
const response = await result(() => repository.report(c.get('identity').id, parsedId.data, parsedBody.data.reason));
return response.ok ? c.json({ reported: true }, 201) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
});
}
+49
View File
@@ -0,0 +1,49 @@
import { boolean, index, integer, jsonb, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { profiles } from '../../db/schema.js';
// `matches` is owned by the matches vertical (0003). Keeping the FK in SQL
// avoids importing another domain's schema fragment here.
export const messages = pgTable('messages', {
id: uuid('id').primaryKey().defaultRandom(),
matchId: uuid('match_id').notNull(),
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
content: text('content'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
readAt: timestamp('read_at', { withTimezone: true }),
deliveredAt: timestamp('delivered_at', { withTimezone: true }),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
mediaPath: text('media_path'),
mediaType: text('media_type'),
mediaExpiresAt: timestamp('media_expires_at', { withTimezone: true }),
mediaExpired: boolean('media_expired').notNull().default(false),
mediaViewedAt: timestamp('media_viewed_at', { withTimezone: true }),
version: integer('version').notNull().default(1),
encryptedContent: text('encrypted_content'),
encryptionIv: text('encryption_iv'),
keysMetadata: jsonb('keys_metadata'),
moderationStatus: text('moderation_status'),
flagReason: text('flag_reason'),
category: text('category'),
}, (table) => [index('messages_match_created_idx').on(table.matchId, table.createdAt)]);
export const messageDeletions = pgTable('message_deletions', {
messageId: uuid('message_id').notNull().references(() => messages.id, { onDelete: 'cascade' }),
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
deletedAt: timestamp('deleted_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [primaryKey({ columns: [table.messageId, table.userId] })]);
export const messageReports = pgTable('message_reports', {
id: uuid('id').primaryKey().defaultRandom(),
messageId: uuid('message_id').notNull().references(() => messages.id, { onDelete: 'cascade' }),
reporterId: uuid('reporter_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
reason: text('reason').notNull(),
status: text('status').notNull().default('pending'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [uniqueIndex('message_reports_message_reporter_idx').on(table.messageId, table.reporterId)]);
export const chatTypingStates = pgTable('chat_typing_states', {
matchId: uuid('match_id').notNull(),
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
}, (table) => [primaryKey({ columns: [table.matchId, table.userId] }), index('chat_typing_expires_idx').on(table.expiresAt)]);