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

@@ -0,0 +1,10 @@
export { PostgresProfileSettingsRepository } from './repository.js';
export type { ProfileSettingsRepository } from './repository.js';
export { registerProfileSettingsRoutes } from './routes.js';
export type {
FeedbackSubmission,
NotificationPreferenceKey,
NotificationPreferences,
ProfileUpdate,
} from './types.js';
@@ -0,0 +1,146 @@
import { randomUUID } from 'node:crypto';
import { Pool, type QueryResultRow } from 'pg';
import type {
FeedbackSubmission,
NotificationPreferenceKey,
NotificationPreferences,
ProfileUpdate,
} from './types.js';
interface PreferencesRow extends QueryResultRow {
user_id: string;
matches_enabled: boolean;
messages_enabled: boolean;
partner_activity_enabled: boolean;
nudges_enabled: boolean;
pack_changes_enabled: boolean;
new_packs_enabled: boolean;
streak_milestones_enabled: boolean;
weekly_summary_enabled: boolean;
unpaired_reminders_enabled: boolean;
catchup_reminders_enabled: boolean;
created_at: Date;
updated_at: Date;
}
export class ProfileSettingsError extends Error {
constructor(readonly code: 'profile_not_found') {
super(code === 'profile_not_found' ? 'Profile not found. Please complete signup first.' : code);
}
}
export interface ProfileSettingsRepository {
updateProfile(userId: string, update: ProfileUpdate): Promise<void>;
updateLastActive(userId: string): Promise<void>;
getNotificationPreferences(userId: string): Promise<NotificationPreferences>;
updateNotificationPreference(userId: string, key: NotificationPreferenceKey, value: boolean): Promise<NotificationPreferences>;
submitFeedback(userId: string, submission: FeedbackSubmission): Promise<{ id: string; created_at: string }>;
close(): Promise<void>;
}
const profileColumns: Record<keyof ProfileUpdate, string> = {
name: 'name',
gender: 'gender',
usage_reason: 'usage_reason',
max_intensity: 'max_intensity',
show_explicit_content: 'show_explicit_content',
hide_nsfw: 'hide_nsfw',
onboarding_completed: 'onboarding_completed',
onboarding_version: 'onboarding_version',
public_key_jwk: 'public_key_jwk',
push_token: 'push_token',
};
function toPreferences(row: PreferencesRow): NotificationPreferences {
return {
user_id: row.user_id,
matches_enabled: row.matches_enabled,
messages_enabled: row.messages_enabled,
partner_activity_enabled: row.partner_activity_enabled,
nudges_enabled: row.nudges_enabled,
pack_changes_enabled: row.pack_changes_enabled,
new_packs_enabled: row.new_packs_enabled,
streak_milestones_enabled: row.streak_milestones_enabled,
weekly_summary_enabled: row.weekly_summary_enabled,
unpaired_reminders_enabled: row.unpaired_reminders_enabled,
catchup_reminders_enabled: row.catchup_reminders_enabled,
created_at: row.created_at.toISOString(),
updated_at: row.updated_at.toISOString(),
};
}
function assertChanged(rowCount: number | null): void {
if (rowCount === 0) throw new ProfileSettingsError('profile_not_found');
}
export class PostgresProfileSettingsRepository implements ProfileSettingsRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
}
async updateProfile(userId: string, update: ProfileUpdate): Promise<void> {
const entries = Object.entries(update) as [keyof ProfileUpdate, ProfileUpdate[keyof ProfileUpdate]][];
if (entries.length === 0) return;
const assignments = entries.map(([key], index) => `${profileColumns[key]} = $${index + 2}`);
const values = entries.map(([, value]) => value);
const result = await this.pool.query(
`update profiles set ${assignments.join(', ')}, updated_at = now() where id = $1`,
[userId, ...values],
);
assertChanged(result.rowCount);
}
async updateLastActive(userId: string): Promise<void> {
const result = await this.pool.query(
'update profiles set last_active_at = now(), updated_at = now() where id = $1',
[userId],
);
assertChanged(result.rowCount);
}
async getNotificationPreferences(userId: string): Promise<NotificationPreferences> {
const result = await this.pool.query<PreferencesRow>(
`insert into notification_preferences (user_id) values ($1)
on conflict (user_id) do update set user_id = excluded.user_id
returning *`,
[userId],
);
return toPreferences(result.rows[0]!);
}
async updateNotificationPreference(
userId: string,
key: NotificationPreferenceKey,
value: boolean,
): Promise<NotificationPreferences> {
await this.getNotificationPreferences(userId);
const result = await this.pool.query<PreferencesRow>(
`update notification_preferences set ${key} = $2, updated_at = now() where user_id = $1 returning *`,
[userId, value],
);
return toPreferences(result.rows[0]!);
}
async submitFeedback(
userId: string,
submission: FeedbackSubmission,
): Promise<{ id: string; created_at: string }> {
const id = randomUUID();
const result = await this.pool.query<{ id: string; created_at: Date }>(
`insert into feedback (id, user_id, type, title, description, question_id, device_info, screenshot_media_id)
select $1, $2, $3, $4, $5, $6, $7, mo.id from (select $8::uuid id) requested
left join media_objects mo on mo.id=requested.id and mo.owner_id=$2 and mo.kind='feedback' and mo.deleted_at is null
where requested.id is null or mo.id is not null returning id, created_at`,
[id, userId, submission.type, submission.title, submission.description,
submission.question_id ?? null, submission.device_info ?? {}, submission.screenshot_media_id ?? null],
);
if (!result.rows[0]) throw new Error('Feedback screenshot is not accessible');
return { id: result.rows[0]!.id, created_at: result.rows[0]!.created_at.toISOString() };
}
async close(): Promise<void> {
await this.pool.end();
}
}
@@ -0,0 +1,89 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { ProfileSettingsError, type ProfileSettingsRepository } from './repository.js';
import { notificationPreferenceKeys } from './types.js';
type ProfileSettingsApp = Hono<{ Variables: { identity: AuthIdentity } }>;
const profileUpdateSchema = z.object({
name: z.string().trim().min(1).max(100).optional(),
gender: z.enum(['male', 'female', 'non-binary', 'prefer-not-to-say']).nullable().optional(),
usage_reason: z.enum(['improve_communication', 'spice_up_intimacy', 'deeper_connection', 'have_fun', 'strengthen_relationship']).nullable().optional(),
max_intensity: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]).optional(),
show_explicit_content: z.boolean().optional(),
hide_nsfw: z.boolean().optional(),
onboarding_completed: z.boolean().optional(),
onboarding_version: z.number().int().min(0).optional(),
public_key_jwk: z.record(z.unknown()).nullable().optional(),
push_token: z.string().trim().min(1).max(512).nullable().optional(),
}).strict().refine((body) => Object.keys(body).length > 0, 'At least one profile field is required');
const preferenceSchema = z.object({
key: z.enum(notificationPreferenceKeys),
value: z.boolean(),
}).strict();
const feedbackSchema = z.object({
type: z.enum(['bug', 'feature_request', 'general', 'question']),
title: z.string().trim().min(1).max(200),
description: z.string().trim().min(1).max(10_000),
question_id: z.string().uuid().nullable().optional(),
device_info: z.record(z.unknown()).optional(),
screenshot_media_id: z.string().uuid().nullable().optional(),
}).strict();
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
}
async function parseJson(c: { req: { json(): Promise<unknown> } }): Promise<unknown> {
return c.req.json().catch(() => null);
}
async function handleProfileError<T>(operation: () => Promise<T>): Promise<T | null> {
try {
return await operation();
} catch (cause) {
if (cause instanceof ProfileSettingsError) return null;
throw cause;
}
}
export function registerProfileSettingsRoutes(app: ProfileSettingsApp, repository: ProfileSettingsRepository): void {
app.patch('/v1/me/profile', async (c) => {
const parsed = profileUpdateSchema.safeParse(await parseJson(c));
if (!parsed.success) return c.json(error('invalid_profile_update', 'Profile update contains invalid or unsupported fields'), 400);
const result = await handleProfileError(() => repository.updateProfile(c.get('identity').id, parsed.data));
return result === null
? c.json(error('profile_not_found', 'Profile not found. Please complete signup first.'), 404)
: c.json({ updated: true });
});
app.post('/v1/me/activity', async (c) => {
const result = await handleProfileError(() => repository.updateLastActive(c.get('identity').id));
return result === null
? c.json(error('profile_not_found', 'Profile not found. Please complete signup first.'), 404)
: c.body(null, 204);
});
app.get('/v1/me/notification-preferences', async (c) =>
c.json(await repository.getNotificationPreferences(c.get('identity').id)));
app.patch('/v1/me/notification-preferences', async (c) => {
const parsed = preferenceSchema.safeParse(await parseJson(c));
if (!parsed.success) return c.json(error('invalid_preference', 'A supported preference key and boolean value are required'), 400);
return c.json(await repository.updateNotificationPreference(
c.get('identity').id,
parsed.data.key,
parsed.data.value,
));
});
app.post('/v1/feedback', async (c) => {
const parsed = feedbackSchema.safeParse(await parseJson(c));
if (!parsed.success) return c.json(error('invalid_feedback', 'Valid feedback fields are required'), 400);
return c.json(await repository.submitFeedback(c.get('identity').id, parsed.data), 201);
});
}
@@ -0,0 +1,42 @@
export const notificationPreferenceKeys = [
'matches_enabled',
'messages_enabled',
'partner_activity_enabled',
'nudges_enabled',
'pack_changes_enabled',
'new_packs_enabled',
'streak_milestones_enabled',
'weekly_summary_enabled',
'unpaired_reminders_enabled',
'catchup_reminders_enabled',
] as const;
export type NotificationPreferenceKey = typeof notificationPreferenceKeys[number];
export interface NotificationPreferences extends Record<NotificationPreferenceKey, boolean> {
user_id: string;
created_at: string;
updated_at: string;
}
export interface ProfileUpdate {
name?: string;
gender?: 'male' | 'female' | 'non-binary' | 'prefer-not-to-say' | null;
usage_reason?: 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship' | null;
max_intensity?: 1 | 2 | 3 | 4 | 5;
show_explicit_content?: boolean;
hide_nsfw?: boolean;
onboarding_completed?: boolean;
onboarding_version?: number;
public_key_jwk?: Record<string, unknown> | null;
push_token?: string | null;
}
export interface FeedbackSubmission {
type: 'bug' | 'feature_request' | 'general' | 'question';
title: string;
description: string;
question_id?: string | null;
device_info?: Record<string, unknown>;
screenshot_media_id?: string | null;
}