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

+220
View File
@@ -0,0 +1,220 @@
import { Pool } from 'pg';
export interface PackCategory {
id: string;
name: string;
description: string | null;
icon: string | null;
color: string | null;
sort_order: number;
created_at: string;
is_public: boolean;
}
export interface PublicQuestionPack {
id: string;
name: string;
description: string | null;
icon: string | null;
is_premium: boolean;
is_public: boolean;
is_explicit: boolean;
min_intensity: number | null;
max_intensity: number | null;
avg_intensity: number | null;
sort_order: number;
category_id: string | null;
category: PackCategory | null;
questions: Array<{ count: number }>;
created_at: string;
}
export interface PacksCatalogResponse {
categories: PackCategory[];
packs: PublicQuestionPack[];
}
export interface EnabledPacksResponse {
enabledPackIds: string[];
}
export interface PackProgressItem {
packId: string;
totalQuestions: number;
answeredQuestions: number;
}
export interface PackProgressResponse {
progress: PackProgressItem[];
}
export class PacksDomainError extends Error {
constructor(readonly code: 'no_couple' | 'pack_not_found') {
super(code);
}
}
export interface PacksRepository {
getCatalog(userId: string, showAllIntensities: boolean): Promise<PacksCatalogResponse>;
getEnabledPacks(userId: string): Promise<EnabledPacksResponse>;
setPackEnabled(userId: string, packId: string, enabled: boolean): Promise<EnabledPacksResponse>;
getPackProgress(userId: string): Promise<PackProgressResponse>;
close(): Promise<void>;
}
interface ProfilePreferencesRow {
couple_id: string | null;
hide_nsfw: boolean;
}
export class PostgresPacksRepository implements PacksRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
}
private async profile(userId: string): Promise<ProfilePreferencesRow> {
const result = await this.pool.query<ProfilePreferencesRow>(
'select couple_id, hide_nsfw from profiles where id = $1 limit 1',
[userId],
);
// An identity that has not completed /v1/me bootstrapping gets the safest content defaults.
return result.rows[0] ?? { couple_id: null, hide_nsfw: true };
}
async getCatalog(userId: string, showAllIntensities: boolean): Promise<PacksCatalogResponse> {
const profile = await this.profile(userId);
const maxIntensity = profile.hide_nsfw ? 2 : 5;
const [categoryResult, packResult] = await Promise.all([
this.pool.query<PackCategory>(`
select id, name, description, icon, color, sort_order, created_at, is_public
from categories
where is_public = true
order by sort_order, name, id
`),
this.pool.query<PublicQuestionPack>(`
select
qp.id, qp.name, qp.description, qp.icon, qp.is_premium, qp.is_public,
qp.is_explicit, qp.min_intensity, qp.max_intensity,
qp.avg_intensity::double precision as avg_intensity,
qp.sort_order, qp.category_id, qp.created_at,
case when c.id is null then null else json_build_object(
'id', c.id, 'name', c.name, 'description', c.description, 'icon', c.icon,
'color', c.color, 'sort_order', c.sort_order, 'created_at', c.created_at,
'is_public', c.is_public
) end as category,
json_build_array(json_build_object('count', count(q.id)::integer)) as questions
from question_packs qp
left join categories c on c.id = qp.category_id
left join questions q on q.pack_id = qp.id
where qp.is_public = true
and (qp.category_id is null or c.is_public = true)
and ($1::boolean = false or qp.is_explicit = false)
and ($2::boolean = true or qp.max_intensity is null or qp.max_intensity <= $3::integer)
group by qp.id, c.id
order by qp.sort_order, qp.name, qp.id
`, [profile.hide_nsfw, showAllIntensities, maxIntensity]),
]);
return {
categories: categoryResult.rows.map((category) => ({
...category,
created_at: new Date(category.created_at).toISOString(),
})),
packs: packResult.rows.map((pack) => ({
...pack,
created_at: new Date(pack.created_at).toISOString(),
})),
};
}
async getEnabledPacks(userId: string): Promise<EnabledPacksResponse> {
const profile = await this.profile(userId);
if (!profile.couple_id) return { enabledPackIds: [] };
const result = await this.pool.query<{ pack_id: string }>(`
select cp.pack_id
from couple_packs cp
join question_packs qp on qp.id = cp.pack_id
left join categories c on c.id = qp.category_id
where cp.couple_id = $1 and cp.enabled = true and qp.is_public = true
and (qp.category_id is null or c.is_public = true)
order by qp.sort_order, cp.pack_id
`, [profile.couple_id]);
return { enabledPackIds: result.rows.map((row) => row.pack_id) };
}
async setPackEnabled(userId: string, packId: string, enabled: boolean): Promise<EnabledPacksResponse> {
const client = await this.pool.connect();
try {
await client.query('begin');
const profile = await client.query<ProfilePreferencesRow>(
'select couple_id, hide_nsfw from profiles where id = $1 limit 1 for share',
[userId],
);
const coupleId = profile.rows[0]?.couple_id;
if (!coupleId) throw new PacksDomainError('no_couple');
const pack = await client.query(`
select qp.id
from question_packs qp
left join categories c on c.id = qp.category_id
where qp.id = $1 and qp.is_public = true
and (qp.category_id is null or c.is_public = true)
limit 1
`, [packId]);
if (pack.rowCount === 0) throw new PacksDomainError('pack_not_found');
const previous = await client.query<{ enabled: boolean }>(`
select enabled from couple_packs
where couple_id = $1 and pack_id = $2
for update
`, [coupleId, packId]);
await client.query(`
insert into couple_packs (couple_id, pack_id, enabled)
values ($1, $2, $3)
on conflict (couple_id, pack_id)
do update set enabled = excluded.enabled
`, [coupleId, packId, enabled]);
if (enabled && previous.rows[0]?.enabled !== true) {
await client.query('select queue_pack_change($1, $2)', [coupleId, userId]);
}
await client.query('commit');
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
return this.getEnabledPacks(userId);
}
async getPackProgress(userId: string): Promise<PackProgressResponse> {
const result = await this.pool.query<{
pack_id: string;
total_questions: number;
answered_questions: number;
}>(`
select qp.id as pack_id,
count(distinct q.id)::integer as total_questions,
count(distinct r.question_id)::integer as answered_questions
from question_packs qp
left join categories c on c.id = qp.category_id
left join questions q on q.pack_id = qp.id
left join responses r on r.question_id = q.id and r.user_id = $1
where qp.is_public = true and (qp.category_id is null or c.is_public = true)
group by qp.id
order by qp.sort_order, qp.id
`, [userId]);
return {
progress: result.rows.map((row) => ({
packId: row.pack_id,
totalQuestions: row.total_questions,
answeredQuestions: row.answered_questions,
})),
};
}
async close(): Promise<void> {
await this.pool.end();
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { PacksDomainError, type PacksRepository } from './repository.js';
type PacksApp = Hono<{ Variables: { identity: AuthIdentity } }>;
const uuidSchema = z.string().uuid();
const toggleSchema = z.object({ enabled: z.boolean() }).strict();
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
}
export function registerPackRoutes(app: PacksApp, repository: PacksRepository): void {
app.get('/v1/packs', async (c) => {
const showAll = c.req.query('showAllIntensities') === 'true';
return c.json(await repository.getCatalog(c.get('identity').id, showAll));
});
app.get('/v1/me/enabled-packs', async (c) =>
c.json(await repository.getEnabledPacks(c.get('identity').id)));
app.put('/v1/me/enabled-packs/:packId', async (c) => {
const packId = uuidSchema.safeParse(c.req.param('packId'));
if (!packId.success) return c.json(error('invalid_pack_id', 'Pack ID must be a UUID'), 400);
const body = toggleSchema.safeParse(await c.req.json().catch(() => null));
if (!body.success) return c.json(error('invalid_request', 'enabled must be a boolean'), 400);
try {
return c.json(await repository.setPackEnabled(c.get('identity').id, packId.data, body.data.enabled));
} catch (cause) {
if (cause instanceof PacksDomainError && cause.code === 'no_couple') {
return c.json(error('no_couple', 'Join a couple before changing packs'), 409);
}
if (cause instanceof PacksDomainError && cause.code === 'pack_not_found') {
return c.json(error('pack_not_found', 'Pack was not found'), 404);
}
throw cause;
}
});
app.get('/v1/me/pack-progress', async (c) =>
c.json(await repository.getPackProgress(c.get('identity').id)));
}
+66
View File
@@ -0,0 +1,66 @@
import { boolean, index, integer, jsonb, numeric, pgTable, primaryKey, text, timestamp, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
import { profiles } from '../../db/schema.js';
import { couples } from '../couples/schema.js';
export const categories = pgTable('categories', {
id: uuid('id').primaryKey(),
name: text('name').notNull(),
description: text('description'),
icon: text('icon'),
color: text('color'),
sortOrder: integer('sort_order').notNull().default(0),
isPublic: boolean('is_public').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const questionPacks = pgTable('question_packs', {
id: uuid('id').primaryKey(),
name: text('name').notNull(),
description: text('description'),
icon: text('icon'),
isPremium: boolean('is_premium').notNull().default(false),
isPublic: boolean('is_public').notNull().default(true),
isExplicit: boolean('is_explicit').notNull().default(false),
minIntensity: integer('min_intensity'),
maxIntensity: integer('max_intensity'),
avgIntensity: numeric('avg_intensity', { precision: 3, scale: 2, mode: 'number' }),
sortOrder: integer('sort_order').notNull().default(0),
categoryId: uuid('category_id').references(() => categories.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]);
export const questions = pgTable('questions', {
id: uuid('id').primaryKey(),
packId: uuid('pack_id').notNull().references(() => questionPacks.id, { onDelete: 'cascade' }),
text: text('text').notNull(),
partnerText: text('partner_text'),
intensity: integer('intensity').notNull().default(1),
allowedCoupleGenders: text('allowed_couple_genders').array(),
targetUserGenders: text('target_user_genders').array(),
requiredProps: text('required_props').array(),
questionType: text('question_type').notNull().default('swipe'),
config: jsonb('config').notNull().default({}),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
inverseOf: uuid('inverse_of'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index('questions_pack_id_idx').on(table.packId)]);
export const responses = pgTable('responses', {
id: uuid('id').primaryKey(),
userId: uuid('user_id').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
questionId: uuid('question_id').notNull().references(() => questions.id, { onDelete: 'cascade' }),
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
answer: text('answer').notNull(),
responseData: jsonb('response_data'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [
uniqueIndex('responses_user_question_idx').on(table.userId, table.questionId),
index('responses_user_id_idx').on(table.userId),
]);
export const couplePacks = pgTable('couple_packs', {
coupleId: uuid('couple_id').notNull().references(() => couples.id, { onDelete: 'cascade' }),
packId: uuid('pack_id').notNull().references(() => questionPacks.id, { onDelete: 'cascade' }),
enabled: boolean('enabled').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [primaryKey({ columns: [table.coupleId, table.packId] })]);