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,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();
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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] })]);
|
||||
Reference in new issue
Block a user