mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
1 parent
01bb8225ae
commit
006761d3b0
294 files changed
+16259
-6138
No files matched your search
@@ -0,0 +1,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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)]);
|
||||
|
||||
Reference in new issue
Block a user