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,43 @@
# Account operations and billing
This domain replaces the `delete-relationship`, `reset-couple-progress`,
`delete-account`, and `sync-subscription` Supabase Edge Functions. The request
identity always comes from the verified bearer token; no route accepts a user or
couple identifier from the client.
## Server-only configuration
- `SUPABASE_AUTH_URL`: hosted Supabase project URL (or its `/auth/v1`
endpoint), used only for Auth.
- `SUPABASE_AUTH_SERVICE_ROLE_KEY`: server-side Auth Admin credential required
for account deletion. Never expose this as an `EXPO_PUBLIC_*` value.
- `REVENUECAT_API_KEY`: secret RevenueCat REST API key used to verify the token
owner's entitlement.
- `REVENUECAT_ENTITLEMENT_ID`: entitlement checked by sync; defaults to
`Sauci Pro` to preserve the previous function behavior.
Missing provider secrets fail the affected operation with `503` before any
durable local change. Account deletion holds its local transaction open until
hosted Auth confirms deletion, and rolls the transaction back on Auth failure.
Provider error bodies are not returned to the mobile app.
## Application wiring
Create one `PostgresAccountOperationsRepository`, then construct:
```ts
const accountOperationsService = new AccountOperationsService(
accountOperationsRepository,
new SupabaseAuthAdminClient(process.env.SUPABASE_AUTH_URL, process.env.SUPABASE_AUTH_SERVICE_ROLE_KEY),
new HttpRevenueCatClient(process.env.REVENUECAT_API_KEY, process.env.REVENUECAT_ENTITLEMENT_ID),
new ExpoPartnerNotifier(),
);
```
Pass it to the app and call `registerAccountOperationRoutes`. Close the
repository during graceful shutdown. No schema migration is required for these
four operations: they use `profiles`, `couples`, `responses`, and `matches`.
Blob deletion must be handled by the self-hosted media/storage lifecycle when
that vertical is wired. These operations intentionally never call the hosted
Supabase database, Storage API, or Edge Functions.
@@ -0,0 +1,139 @@
import { z } from 'zod';
import { AccountOperationError } from './types.js';
export interface AuthAdminClient {
deleteUser(userId: string): Promise<void>;
}
export interface RevenueCatClient {
isEntitled(userId: string): Promise<boolean>;
}
export interface PartnerNotifier {
relationshipDeleted(pushToken: string): Promise<void>;
progressReset(pushToken: string): Promise<void>;
partnerAccountDeleted(pushToken: string): Promise<void>;
}
type Fetch = typeof globalThis.fetch;
const revenueCatResponse = z.object({
subscriber: z.object({
entitlements: z.record(z.object({ expires_date: z.string().datetime().nullable() }).passthrough()),
}).passthrough(),
}).passthrough();
export class SupabaseAuthAdminClient implements AuthAdminClient {
constructor(
private readonly authUrl: string | undefined,
private readonly serviceRoleKey: string | undefined,
private readonly request: Fetch = globalThis.fetch,
) {}
async deleteUser(userId: string): Promise<void> {
if (!this.authUrl || !this.serviceRoleKey) {
throw new AccountOperationError(
'account_deletion_unavailable',
'Account deletion is temporarily unavailable',
503,
);
}
const configuredUrl = this.authUrl.replace(/\/$/, '');
const authApiUrl = configuredUrl.endsWith('/auth/v1') ? configuredUrl : `${configuredUrl}/auth/v1`;
const response = await this.request(
`${authApiUrl}/admin/users/${encodeURIComponent(userId)}`,
{
method: 'DELETE',
headers: {
apikey: this.serviceRoleKey,
authorization: `Bearer ${this.serviceRoleKey}`,
},
signal: AbortSignal.timeout(10_000),
},
).catch(() => {
throw new AccountOperationError('auth_provider_unavailable', 'Authentication provider is unavailable', 502);
});
if (!response.ok) {
throw new AccountOperationError('auth_delete_failed', 'Failed to delete authentication record', 502);
}
}
}
export class HttpRevenueCatClient implements RevenueCatClient {
constructor(
private readonly apiKey: string | undefined,
private readonly entitlementId = 'Sauci Pro',
private readonly request: Fetch = globalThis.fetch,
private readonly now: () => Date = () => new Date(),
) {}
async isEntitled(userId: string): Promise<boolean> {
if (!this.apiKey) {
throw new AccountOperationError(
'subscription_sync_unavailable',
'Subscription verification is temporarily unavailable',
503,
);
}
const response = await this.request(
`https://api.revenuecat.com/v1/subscribers/${encodeURIComponent(userId)}`,
{
headers: {
authorization: `Bearer ${this.apiKey}`,
accept: 'application/json',
},
signal: AbortSignal.timeout(10_000),
},
).catch(() => {
throw new AccountOperationError('revenuecat_unavailable', 'Subscription provider is unavailable', 502);
});
if (!response.ok) {
throw new AccountOperationError('subscription_verification_failed', 'Failed to verify subscription', 502);
}
const parsed = revenueCatResponse.safeParse(await response.json().catch(() => null));
if (!parsed.success) {
throw new AccountOperationError('invalid_subscription_response', 'Subscription provider returned invalid data', 502);
}
const entitlement = parsed.data.subscriber.entitlements[this.entitlementId];
if (!entitlement) return false;
return entitlement.expires_date === null || new Date(entitlement.expires_date) > this.now();
}
}
export class ExpoPartnerNotifier implements PartnerNotifier {
constructor(private readonly request: Fetch = globalThis.fetch) {}
relationshipDeleted(pushToken: string): Promise<void> {
return this.send(pushToken, 'Relationship Deleted',
'Your partner has deleted all shared data. You can start fresh with a new partner anytime.',
'relationship_deleted');
}
progressReset(pushToken: string): Promise<void> {
return this.send(pushToken, 'Progress Reset',
'Your partner has reset all progress. Your matches and chats have been cleared to start fresh.',
'progress_reset');
}
partnerAccountDeleted(pushToken: string): Promise<void> {
return this.send(pushToken, 'Partner Left',
'Your partner has deleted their account. You can start fresh with a new partner anytime.',
'partner_account_deleted');
}
private async send(to: string, title: string, body: string, type: string): Promise<void> {
const response = await this.request('https://exp.host/--/api/v2/push/send', {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ to, title, body, sound: 'default', data: { type } }),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`Expo push failed with status ${response.status}`);
}
}
@@ -0,0 +1,6 @@
export * from './clients.js';
export * from './repository.js';
export * from './routes.js';
export * from './service.js';
export * from './types.js';
@@ -0,0 +1,141 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { AccountOperationError } from './types.js';
interface ProfileOperationRecord extends QueryResultRow {
couple_id: string | null;
}
interface PartnerRecord extends QueryResultRow {
push_token: string | null;
}
export interface DestructiveOperationResult {
partnerPushToken: string | null;
}
export interface AccountOperationsRepository {
deleteRelationship(userId: string): Promise<DestructiveOperationResult>;
resetProgress(userId: string): Promise<DestructiveOperationResult>;
deleteAccount(userId: string, deleteAuthUser: () => Promise<void>): Promise<DestructiveOperationResult>;
setPremium(userId: string, isPremium: boolean): Promise<void>;
close(): Promise<void>;
}
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('begin');
const result = await operation(client);
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
async function lockedProfile(client: PoolClient, userId: string): Promise<ProfileOperationRecord> {
const result = await client.query<ProfileOperationRecord>(
'select couple_id from profiles where id = $1 for update',
[userId],
);
const profile = result.rows[0];
if (!profile) throw new AccountOperationError('profile_not_found', 'Profile not found', 404);
return profile;
}
async function lockCoupleAndPartner(
client: PoolClient,
userId: string,
coupleId: string,
): Promise<PartnerRecord | undefined> {
await client.query('select id from couples where id = $1 for update', [coupleId]);
const partner = await client.query<PartnerRecord>(
`select push_token from profiles
where couple_id = $1 and id <> $2
for update`,
[coupleId, userId],
);
return partner.rows[0];
}
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
}
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
return transaction(this.pool, async (client) => {
const profile = await lockedProfile(client, userId);
if (!profile.couple_id) {
throw new AccountOperationError('not_paired', 'You are not in a relationship', 400);
}
const partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
await client.query(
'update profiles set couple_id = null, updated_at = now() where couple_id = $1',
[profile.couple_id],
);
await client.query('delete from couples where id = $1', [profile.couple_id]);
return { partnerPushToken: partner?.push_token ?? null };
});
}
async resetProgress(userId: string): Promise<DestructiveOperationResult> {
return transaction(this.pool, async (client) => {
const profile = await lockedProfile(client, userId);
if (!profile.couple_id) {
throw new AccountOperationError('not_paired', 'You are not in a relationship', 400);
}
const partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
// Matches cascade to messages and per-user match archives. Responses are separate.
await client.query('delete from matches where couple_id = $1', [profile.couple_id]);
await client.query('delete from responses where couple_id = $1', [profile.couple_id]);
return { partnerPushToken: partner?.push_token ?? null };
});
}
async deleteAccount(
userId: string,
deleteAuthUser: () => Promise<void>,
): Promise<DestructiveOperationResult> {
return transaction(this.pool, async (client) => {
const profileResult = await client.query<ProfileOperationRecord>(
'select couple_id from profiles where id = $1 for update',
[userId],
);
const profile = profileResult.rows[0];
let partner: PartnerRecord | undefined;
if (profile?.couple_id) {
partner = await lockCoupleAndPartner(client, userId, profile.couple_id);
await client.query(
'update profiles set couple_id = null, updated_at = now() where couple_id = $1 and id <> $2',
[profile.couple_id, userId],
);
await client.query('delete from couples where id = $1', [profile.couple_id]);
}
if (profile) await client.query('delete from profiles where id = $1', [userId]);
// Keep local deletion uncommitted until hosted Auth confirms deletion. A provider
// failure therefore rolls the entire local transaction back for a safe retry.
await deleteAuthUser();
return { partnerPushToken: partner?.push_token ?? null };
});
}
async setPremium(userId: string, isPremium: boolean): Promise<void> {
const result = await this.pool.query(
'update profiles set is_premium = $2, updated_at = now() where id = $1',
[userId, isPremium],
);
if (result.rowCount === 0) throw new AccountOperationError('profile_not_found', 'Profile not found', 404);
}
async close(): Promise<void> {
await this.pool.end();
}
}
@@ -0,0 +1,48 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import type { AuthIdentity } from '../../auth.js';
import type { AccountOperationsService } from './service.js';
import { AccountOperationError } from './types.js';
type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
}
async function run<T>(operation: () => Promise<T>): Promise<
{ ok: true; value: T } |
{ ok: false; body: ApiErrorResponse; status: 400 | 404 | 502 | 503 }
> {
try {
return { ok: true, value: await operation() };
} catch (cause) {
if (cause instanceof AccountOperationError) {
return { ok: false, body: error(cause.code, cause.message), status: cause.status };
}
throw cause;
}
}
export function registerAccountOperationRoutes(app: AuthenticatedApp, service: AccountOperationsService): void {
app.delete('/v1/couple/data', async (c) => {
const result = await run(() => service.deleteRelationship(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
app.delete('/v1/couple/progress', async (c) => {
const result = await run(() => service.resetProgress(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
app.delete('/v1/me', async (c) => {
const result = await run(() => service.deleteAccount(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
app.post('/v1/me/subscription/sync', async (c) => {
const result = await run(() => service.syncSubscription(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
}
@@ -0,0 +1,53 @@
import type { AuthAdminClient, PartnerNotifier, RevenueCatClient } from './clients.js';
import type { AccountOperationsRepository } from './repository.js';
import type { OperationResult, SubscriptionSyncResult } from './types.js';
export class AccountOperationsService {
constructor(
private readonly repository: AccountOperationsRepository,
private readonly authAdmin: AuthAdminClient,
private readonly revenueCat: RevenueCatClient,
private readonly notifier: PartnerNotifier,
) {}
async deleteRelationship(userId: string): Promise<OperationResult> {
const result = await this.repository.deleteRelationship(userId);
if (result.partnerPushToken) {
await this.notify(() => this.notifier.relationshipDeleted(result.partnerPushToken!));
}
return { success: true, message: 'Relationship data deleted successfully' };
}
async resetProgress(userId: string): Promise<OperationResult> {
const result = await this.repository.resetProgress(userId);
if (result.partnerPushToken) {
await this.notify(() => this.notifier.progressReset(result.partnerPushToken!));
}
return { success: true, message: 'Progress reset successfully' };
}
async deleteAccount(userId: string): Promise<OperationResult> {
const result = await this.repository.deleteAccount(userId, () => this.authAdmin.deleteUser(userId));
if (result.partnerPushToken) {
await this.notify(() => this.notifier.partnerAccountDeleted(result.partnerPushToken!));
}
return { success: true, message: 'Account deleted successfully' };
}
async syncSubscription(userId: string): Promise<SubscriptionSyncResult> {
const isPremium = await this.revenueCat.isEntitled(userId);
await this.repository.setPremium(userId, isPremium);
return { success: true, is_premium: isPremium };
}
private async notify(operation: () => Promise<void>): Promise<void> {
try {
await operation();
} catch (error) {
// Destructive data changes have already committed. Notification delivery is
// deliberately best-effort, matching the previous Edge Function behavior.
console.error('Partner notification failed', error);
}
}
}
@@ -0,0 +1,21 @@
export class AccountOperationError extends Error {
constructor(
readonly code: string,
message: string,
readonly status: 400 | 404 | 502 | 503,
) {
super(message);
this.name = 'AccountOperationError';
}
}
export interface OperationResult {
success: true;
message: string;
}
export interface SubscriptionSyncResult {
success: true;
is_premium: boolean;
}
+29
View File
@@ -0,0 +1,29 @@
# Admin API
Browser administrators authenticate with hosted Supabase Auth. The API then
loads the active `admin_users` row for the token subject and enforces each role
permission again server-side. UI role state is never trusted for authorization.
Non-browser clients can use `ADMIN_API_SERVICE_TOKEN` with
`ADMIN_API_SERVICE_USER_ID`. The opaque token is compared in constant time and
maps to an explicit active `admin_users` record, so its permissions and audit
actor are identical to a browser administrator. It does not grant Supabase
service-role access.
Every generic mutation is transacted with an `audit_logs` write. Sensitive
fields are redacted from audit payloads. Admin data routes are resource-
whitelisted; arbitrary table names are rejected.
Central application wiring must exempt `/v1/admin/*` from the general hosted
Auth middleware and pass `AdminRequestAuth` to `registerAdminRoutes`, which
accepts either hosted JWTs or the configured service token.
`SupabaseAdminAuthDirectory` may use the existing server-side Auth Admin
credential to enrich `/v1/admin/users` with email confirmation and last sign-in
timestamps. It talks only to hosted Auth, never the Supabase data plane, and
degrades to explicit null metadata when the credential or provider is absent.
`GET /v1/admin/media/:mediaId/url` requires `view_media` and issues a five-minute
signed URL with `private, no-store` response caching. The object must exist and
not be marked deleted. The opaque service credential can therefore request a
URL without being accepted by customer media authorization.
@@ -0,0 +1,54 @@
import { z } from 'zod';
type Fetch = typeof globalThis.fetch;
const authUser = z.object({
id: z.string().uuid(),
email: z.string().nullable().optional(),
last_sign_in_at: z.string().nullable().optional(),
email_confirmed_at: z.string().nullable().optional(),
confirmed_at: z.string().nullable().optional(),
}).passthrough();
export type AdminAuthUser = {
id: string;
email: string | null;
last_sign_in_at: string | null;
email_confirmed_at: string | null;
};
export interface AdminAuthDirectory {
users(userId?: string): Promise<AdminAuthUser[]>;
}
export class SupabaseAdminAuthDirectory implements AdminAuthDirectory {
constructor(
private readonly authUrl: string | undefined,
private readonly serviceRoleKey: string | undefined,
private readonly request: Fetch = globalThis.fetch,
) {}
async users(userId?: string): Promise<AdminAuthUser[]> {
if (!this.authUrl || !this.serviceRoleKey) return [];
const base = this.authUrl.replace(/\/$/, '').replace(/\/auth\/v1$/, '');
const url = userId
? `${base}/auth/v1/admin/users/${encodeURIComponent(userId)}`
: `${base}/auth/v1/admin/users?page=1&per_page=1000`;
const response = await this.request(url, {
headers: { apikey: this.serviceRoleKey, authorization: `Bearer ${this.serviceRoleKey}` },
signal: AbortSignal.timeout(10_000),
}).catch(() => null);
if (!response?.ok) return [];
const payload = await response.json().catch(() => null);
const candidates = userId ? [payload] : z.object({ users: z.array(z.unknown()) }).safeParse(payload).data?.users ?? [];
return candidates.flatMap((candidate) => {
const parsed = authUser.safeParse(candidate); if (!parsed.success) return [];
return [{
id: parsed.data.id,
email: parsed.data.email ?? null,
last_sign_in_at: parsed.data.last_sign_in_at ?? null,
email_confirmed_at: parsed.data.email_confirmed_at ?? parsed.data.confirmed_at ?? null,
}];
});
}
}
+26
View File
@@ -0,0 +1,26 @@
import { createHash, timingSafeEqual } from 'node:crypto';
import type { AuthIdentity, AuthVerifier } from '../../auth.js';
export class AdminRequestAuth {
private readonly serviceDigest?: Buffer;
constructor(
private readonly hostedAuth: AuthVerifier,
serviceToken?: string,
private readonly serviceUserId?: string,
) {
if (Boolean(serviceToken) !== Boolean(serviceUserId)) throw new Error('Admin service token and user ID must be configured together');
if (serviceToken && serviceToken.length < 32) throw new Error('Admin service token must contain at least 32 characters');
this.serviceDigest = serviceToken ? createHash('sha256').update(serviceToken).digest() : undefined;
}
async verify(token: string): Promise<AuthIdentity> {
if (this.serviceDigest && this.serviceUserId) {
const candidate = createHash('sha256').update(token).digest();
if (timingSafeEqual(candidate, this.serviceDigest)) {
return { id: this.serviceUserId, email: null, name: 'Admin API service', avatarUrl: null };
}
}
return this.hostedAuth.verify(token);
}
}
+340
View File
@@ -0,0 +1,340 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { readFile } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js';
import type { AdminAuthDirectory } from './auth-directory.js';
type Permission =
| 'manage_packs' | 'manage_questions' | 'manage_categories' | 'view_users'
| 'view_chats' | 'view_media' | 'view_matches' | 'view_responses' | 'view_activity'
| 'manage_feedback' | 'gift_premium' | 'manage_codes' | 'manage_admins'
| 'manage_ai_config' | 'manage_app_config' | 'view_audit_logs';
interface ResourceRule { read: Permission | Permission[]; write?: Permission; idColumn?: string }
const resources: Record<string, ResourceRule> = {
categories: { read: ['manage_categories', 'manage_packs', 'manage_questions', 'view_activity', 'view_users'], write: 'manage_categories' },
question_packs: { read: ['manage_packs', 'manage_questions', 'view_activity', 'view_users'], write: 'manage_packs' },
questions: { read: ['manage_questions', 'view_activity', 'view_responses', 'view_matches', 'view_users', 'manage_ai_config'], write: 'manage_questions' },
topics: { read: 'manage_packs', write: 'manage_packs' },
pack_topics: { read: 'manage_packs', write: 'manage_packs', idColumn: 'pack_id' },
profiles: { read: ['view_users', 'view_activity', 'manage_admins', 'manage_feedback', 'manage_ai_config', 'manage_codes'] }, couples: { read: ['view_users', 'view_activity'] },
responses: { read: ['view_responses', 'view_activity', 'view_users'] }, matches: { read: ['view_matches', 'view_activity', 'view_users'] },
messages: { read: ['view_chats', 'view_activity'], write: 'view_chats' },
message_reports: { read: 'view_chats', write: 'view_chats' },
feedback: { read: 'manage_feedback', write: 'manage_feedback' },
admin_users: { read: 'manage_admins', write: 'manage_admins' },
audit_logs: { read: 'view_audit_logs' },
redemption_codes: { read: 'manage_codes', write: 'manage_codes' },
code_redemptions: { read: 'manage_codes' },
ai_config: { read: 'manage_ai_config', write: 'manage_ai_config' },
app_config: { read: 'manage_app_config', write: 'manage_app_config' },
feature_interests: { read: 'view_users' }, couple_packs: { read: 'view_users' },
couple_streaks: { read: 'view_users' }, live_draw_sessions: { read: 'view_users' },
subscriptions: { read: 'view_users' },
media_objects: { read: 'view_media' },
dare_packs: { read: 'manage_packs', write: 'manage_packs' },
dares: { read: 'manage_questions', write: 'manage_questions' },
};
function identifier(value: string): string {
if (!/^[a-z_][a-z0-9_]*$/.test(value)) throw new AdminError('invalid_identifier', 'Invalid field name', 400);
return `"${value}"`;
}
function rule(resource: string): ResourceRule {
const value = resources[resource];
if (!value) throw new AdminError('unsupported_resource', 'Resource is not available to the admin API', 404);
return value;
}
function requirePermission(principal: AdminPrincipal, permission: Permission): void {
if (principal.role !== 'super_admin' && !principal.permissions.includes(permission)) {
throw new AdminError('forbidden', 'This administrator lacks the required permission', 403);
}
}
function requireAnyPermission(principal: AdminPrincipal, permissions: Permission | Permission[]): void {
const choices = Array.isArray(permissions) ? permissions : [permissions];
if (principal.role !== 'super_admin' && !choices.some((permission) => principal.permissions.includes(permission))) {
throw new AdminError('forbidden', 'This administrator lacks the required permission', 403);
}
}
function whereParts(query: AdminQuery): { sql: string; values: unknown[] } {
const values: unknown[] = [];
const clauses: string[] = [];
for (const filter of query.filters ?? []) {
const column = identifier(filter.column);
const position = values.length + 1;
if (filter.op === 'is' && filter.value === null) clauses.push(`${column} is null`);
else if (filter.op === 'neq' && filter.value === null) clauses.push(`${column} is not null`);
else if (filter.op === 'in') {
if (!Array.isArray(filter.value)) throw new AdminError('invalid_filter', 'in filters require an array', 400);
values.push(filter.value); clauses.push(`${column} = any($${position})`);
} else {
const operators = { eq: '=', neq: '<>', gte: '>=', lte: '<=', ilike: 'ilike' } as const;
const operator = operators[filter.op as keyof typeof operators];
if (!operator) throw new AdminError('invalid_filter', 'Unsupported filter', 400);
values.push(filter.value); clauses.push(`${column} ${operator} $${position}`);
}
}
return { sql: clauses.length ? ` where ${clauses.join(' and ')}` : '', values };
}
const restrictedReadColumns: Record<string, ReadonlySet<string>> = {
profiles: new Set(['push_token', 'public_key_jwk']),
messages: new Set(['encrypted_content', 'encryption_iv', 'keys_metadata']),
media_objects: new Set(['storage_key']),
};
function projection(resource: string, columns: string[] | undefined): string {
if (!columns) return '*';
const restricted = restrictedReadColumns[resource] ?? new Set<string>();
if (columns.some((column) => restricted.has(column))) {
throw new AdminError('restricted_column', 'The requested field is not available through generic admin queries', 403);
}
return columns.map(identifier).join(',');
}
function sanitizeRows(resource: string, rows: QueryResultRow[]): QueryResultRow[] {
const restricted = restrictedReadColumns[resource];
if (!restricted) return rows;
return rows.map((row) => Object.fromEntries(Object.entries(row).filter(([column]) => !restricted.has(column))));
}
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try { await client.query('begin'); const value = await operation(client); await client.query('commit'); return value; }
catch (error) { await client.query('rollback'); throw error; }
finally { client.release(); }
}
function redactAudit(value: unknown): unknown {
if (Array.isArray(value)) return value.map(redactAudit);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
/(key|secret|token|password|credential)/i.test(key) ? '[REDACTED]' : redactAudit(item),
]));
}
export interface AdminRepository {
principal(userId: string): Promise<AdminPrincipal>;
query(principal: AdminPrincipal, resource: string, query: AdminQuery): Promise<{ rows: Record<string, unknown>[]; count: number }>;
insert(principal: AdminPrincipal, resource: string, records: Record<string, unknown>[]): Promise<Record<string, unknown>[]>;
update(principal: AdminPrincipal, resource: string, id: string, values: Record<string, unknown>): Promise<Record<string, unknown>>;
delete(principal: AdminPrincipal, resource: string, id: string): Promise<void>;
dashboard(principal: AdminPrincipal): Promise<Record<string, number>>;
giftPremium(principal: AdminPrincipal, userId: string, days: number | null, reason?: string): Promise<{ subscription_id: string; expires_at: string | null }>;
featureInterestCounts(principal: AdminPrincipal): Promise<Array<{
feature_name: string; opt_in_count: number; opt_in_count_last_7_days: number;
}>>;
users(principal: AdminPrincipal, userId?: string): Promise<Record<string, unknown>[]>;
authorizeMedia(principal: AdminPrincipal, mediaId: string): Promise<void>;
decryptMessage(principal: AdminPrincipal, messageId: string): Promise<{ content: string | null }>;
decryptMedia(principal: AdminPrincipal, messageId: string): Promise<{ bytes: Buffer; mimeType: string }>;
close(): Promise<void>;
}
export class PostgresAdminRepository implements AdminRepository {
private readonly pool: Pool;
constructor(databaseUrl: string, private readonly options: {
adminPrivateKeyJwk?: string; mediaRoot?: string; authDirectory?: AdminAuthDirectory;
} = {}) { this.pool = new Pool({ connectionString: databaseUrl }); }
async principal(userId: string): Promise<AdminPrincipal> {
const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>(
'select id, user_id, role, permissions from admin_users where user_id=$1 and is_active=true', [userId],
);
const row = result.rows[0];
if (!row) throw new AdminError('admin_access_denied', 'Administrator access denied', 403);
return { adminId: row.id, userId: row.user_id, role: row.role, permissions: row.permissions ?? [] };
}
async query(principal: AdminPrincipal, resource: string, query: AdminQuery) {
requireAnyPermission(principal, rule(resource).read);
const where = whereParts(query);
const table = identifier(resource);
const selected = projection(resource, query.columns);
let pageSql = where.sql;
if (query.order) pageSql += ` order by ${identifier(query.order.column)} ${query.order.ascending === false ? 'desc' : 'asc'}`;
const pageValues = [...where.values, Math.min(Math.max(query.limit ?? 100, 1), 500)];
pageSql += ` limit $${pageValues.length}`;
if (query.offset) { pageValues.push(Math.max(query.offset, 0)); pageSql += ` offset $${pageValues.length}`; }
const [rows, count] = await Promise.all([
this.pool.query<QueryResultRow>(`select ${selected} from ${table}${pageSql}`, pageValues),
this.pool.query<{ count: string }>(`select count(*)::text count from ${table}${where.sql}`, where.values),
]);
return { rows: sanitizeRows(resource, rows.rows), count: Number(count.rows[0]?.count ?? 0) };
}
async insert(principal: AdminPrincipal, resource: string, records: Record<string, unknown>[]) {
const permission = rule(resource).write;
if (!permission) throw new AdminError('forbidden', 'Resource is read-only', 403);
requirePermission(principal, permission);
if (!records.length) throw new AdminError('invalid_records', 'At least one record is required', 400);
return transaction(this.pool, async (client) => {
const inserted: Record<string, unknown>[] = [];
for (const record of records) {
const keys = Object.keys(record); if (!keys.length) throw new AdminError('invalid_record', 'Record cannot be empty', 400);
const result = await client.query<QueryResultRow>(
`insert into ${identifier(resource)} (${keys.map(identifier).join(',')}) values (${keys.map((_, index) => `$${index + 1}`).join(',')}) returning *`,
keys.map((key) => record[key]),
);
const row = result.rows[0]!; inserted.push(row);
await this.audit(client, principal, resource, 'INSERT', String(row.id ?? row[rule(resource).idColumn ?? 'id'] ?? ''), null, row);
}
return inserted;
});
}
async update(principal: AdminPrincipal, resource: string, id: string, values: Record<string, unknown>) {
const resourceRule = rule(resource); if (!resourceRule.write) throw new AdminError('forbidden', 'Resource is read-only', 403);
requirePermission(principal, resourceRule.write);
const keys = Object.keys(values); if (!keys.length) throw new AdminError('invalid_record', 'Updates cannot be empty', 400);
const idColumn = resourceRule.idColumn ?? 'id';
return transaction(this.pool, async (client) => {
const old = await client.query<QueryResultRow>(`select * from ${identifier(resource)} where ${identifier(idColumn)}=$1 for update`, [id]);
if (!old.rows.length) throw new AdminError('not_found', 'Record not found', 404);
const updated = await client.query<QueryResultRow>(
`update ${identifier(resource)} set ${keys.map((key, index) => `${identifier(key)}=$${index + 2}`).join(',')} where ${identifier(idColumn)}=$1 returning *`,
[id, ...keys.map((key) => values[key])],
);
for (let index = 0; index < updated.rows.length; index += 1) {
const oldRow = old.rows[index]; const updatedRow = updated.rows[index]!;
await this.audit(client, principal, resource, 'UPDATE', String(updatedRow.id ?? id), oldRow, updatedRow);
}
return updated.rows[0]!;
});
}
async delete(principal: AdminPrincipal, resource: string, id: string): Promise<void> {
const resourceRule = rule(resource); if (!resourceRule.write) throw new AdminError('forbidden', 'Resource is read-only', 403);
requirePermission(principal, resourceRule.write);
const idColumn = resourceRule.idColumn ?? 'id';
await transaction(this.pool, async (client) => {
const deleted = await client.query<QueryResultRow>(`delete from ${identifier(resource)} where ${identifier(idColumn)}=$1 returning *`, [id]);
if (!deleted.rows.length) throw new AdminError('not_found', 'Record not found', 404);
for (const row of deleted.rows) {
await this.audit(client, principal, resource, 'DELETE', String(row.id ?? id), row, null);
}
});
}
async dashboard(principal: AdminPrincipal): Promise<Record<string, number>> {
const result = await this.pool.query<{ categories: number; packs: number; questions: number; users: number }>(
'select (select count(*)::int from categories) categories, (select count(*)::int from question_packs) packs, (select count(*)::int from questions) questions, (select count(*)::int from profiles) users',
);
return result.rows[0] ?? { categories: 0, packs: 0, questions: 0, users: 0 };
}
async giftPremium(principal: AdminPrincipal, userId: string, days: number | null, reason?: string) {
requirePermission(principal, 'gift_premium');
return transaction(this.pool, async (client) => {
const profile = await client.query('select id from profiles where id=$1 for update', [userId]);
if (!profile.rows[0]) throw new AdminError('user_not_found', 'User not found', 404);
const expiresAt = days === null ? null : new Date(Date.now() + days * 86_400_000);
const inserted = await client.query<{ id: string }>(
`insert into subscriptions(user_id,revenuecat_app_user_id,product_id,status,entitlement_ids,purchased_at,expires_at,store)
values($1,$2,'admin_premium','active',array['premium'],now(),$3,'manual') returning id`,
[userId, `admin_grant:${userId}`, expiresAt],
);
await client.query('update profiles set is_premium=true,updated_at=now() where id=$1', [userId]);
await client.query(
`insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,new_values)
values($1,$2,'subscriptions','ACTION',$3,$4)`,
[principal.adminId, principal.userId, inserted.rows[0]!.id, { action: 'gift_premium', target_user_id: userId, days, reason: reason ?? null, expires_at: expiresAt?.toISOString() ?? null }],
);
return { subscription_id: inserted.rows[0]!.id, expires_at: expiresAt?.toISOString() ?? null };
});
}
async featureInterestCounts(principal: AdminPrincipal) {
requirePermission(principal, 'view_users');
const result = await this.pool.query<{
feature_name: string; opt_in_count: number; opt_in_count_last_7_days: number;
}>(`select feature as feature_name,
count(*)::int as opt_in_count,
count(*) filter (where created_at >= now() - interval '7 days')::int as opt_in_count_last_7_days
from feature_interests group by feature order by feature`);
return result.rows;
}
async users(principal: AdminPrincipal, userId?: string) {
requirePermission(principal, 'view_users');
const result = await this.pool.query<QueryResultRow & { id: string }>(
`select p.*,coalesce(sum(mo.byte_size) filter(where mo.deleted_at is null),0)::bigint::text as storage_bytes
from profiles p left join media_objects mo on mo.owner_id=p.id
${userId ? 'where p.id=$1' : ''} group by p.id order by p.created_at desc limit 500`,
userId ? [userId] : [],
);
const authUsers = await this.options.authDirectory?.users(userId).catch(() => []) ?? [];
const authById = new Map(authUsers.map((user) => [user.id, user]));
return result.rows.map((profile) => {
const authUser = authById.get(profile.id);
return {
...profile,
email: authUser?.email ?? profile.email ?? null,
last_sign_in_at: authUser?.last_sign_in_at ?? null,
email_confirmed_at: authUser?.email_confirmed_at ?? null,
};
});
}
async authorizeMedia(principal: AdminPrincipal, mediaId: string): Promise<void> {
requirePermission(principal, 'view_media');
const result = await this.pool.query('select 1 from media_objects where id=$1 and deleted_at is null', [mediaId]);
if (!result.rows[0]) throw new AdminError('media_not_found', 'Media not found', 404);
}
private async messageForDecryption(messageId: string) {
const result = await this.pool.query<{
content: string | null; encrypted_content: string | null; encryption_iv: string | null;
keys_metadata: { admin_wrapped_key?: string } | null; version: number; media_path: string | null;
storage_key: string | null; mime_type: string | null;
}>(
`select m.content,m.encrypted_content,m.encryption_iv,m.keys_metadata,m.version,m.media_path,mo.storage_key,mo.mime_type
from messages m left join media_objects mo on m.media_path='media:'||mo.id::text where m.id=$1`, [messageId],
);
const row = result.rows[0]; if (!row) throw new AdminError('message_not_found', 'Message not found', 404); return row;
}
private async messageKey(row: { keys_metadata: { admin_wrapped_key?: string } | null }): Promise<CryptoKey> {
const wrapped = row.keys_metadata?.admin_wrapped_key;
if (!wrapped || !this.options.adminPrivateKeyJwk) throw new AdminError('decryption_unavailable', 'Admin decryption key is unavailable', 403);
const privateKey = await crypto.subtle.importKey('jwk', JSON.parse(this.options.adminPrivateKeyJwk) as JsonWebKey, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['decrypt']);
const raw = await crypto.subtle.decrypt({ name: 'RSA-OAEP' }, privateKey, Buffer.from(wrapped, 'base64'));
return crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['decrypt']);
}
async decryptMessage(principal: AdminPrincipal, messageId: string) {
requirePermission(principal, 'view_chats');
const row = await this.messageForDecryption(messageId);
if (row.version !== 2 || !row.encrypted_content) return { content: row.content };
if (!row.encryption_iv) throw new AdminError('invalid_encrypted_message', 'Message encryption metadata is incomplete', 409);
const key = await this.messageKey(row);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: Buffer.from(row.encryption_iv, 'base64') }, key, Buffer.from(row.encrypted_content, 'base64'));
return { content: new TextDecoder().decode(plain) };
}
async decryptMedia(principal: AdminPrincipal, messageId: string) {
requirePermission(principal, 'view_media');
const row = await this.messageForDecryption(messageId);
if (!row.storage_key || !row.mime_type) throw new AdminError('media_not_found', 'Message media not found', 404);
const root = resolve(this.options.mediaRoot ?? '/data/media'); const path = resolve(root, row.storage_key);
if (!path.startsWith(`${root}${sep}`)) throw new AdminError('invalid_media_path', 'Invalid media path', 400);
let bytes = await readFile(path);
if (row.version === 2) {
if (!row.encryption_iv) throw new AdminError('invalid_encrypted_message', 'Message encryption metadata is incomplete', 409);
const key = await this.messageKey(row);
bytes = Buffer.from(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: Buffer.from(row.encryption_iv, 'base64') }, key, bytes));
}
return { bytes, mimeType: row.mime_type };
}
private async audit(client: PoolClient, principal: AdminPrincipal, table: string, action: 'INSERT' | 'UPDATE' | 'DELETE', recordId: string, oldValues: unknown, newValues: unknown) {
await client.query('insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,old_values,new_values) values($1,$2,$3,$4,$5,$6,$7)', [principal.adminId, principal.userId, table, action, recordId || null, redactAudit(oldValues), redactAudit(newValues)]);
}
async close(): Promise<void> { await this.pool.end(); }
}
+122
View File
@@ -0,0 +1,122 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { bearerToken } from '../../auth.js';
import type { AdminRequestAuth } from './auth.js';
import type { AdminRepository } from './repository.js';
import { AdminError } from './types.js';
import type { FilesystemMediaStorage } from '../media/storage.js';
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
const resource = z.string().regex(/^[a-z_][a-z0-9_]*$/);
const queryBody = z.object({
columns: z.array(z.string().regex(/^[a-z_][a-z0-9_]*$/)).min(1).max(100).optional(),
filters: z.array(z.object({ column: z.string(), op: z.enum(['eq', 'neq', 'in', 'is', 'gte', 'lte', 'ilike']), value: z.unknown() })).optional(),
order: z.object({ column: z.string(), ascending: z.boolean().optional() }).optional(),
limit: z.number().int().positive().max(500).optional(), offset: z.number().int().nonnegative().optional(),
}).strict();
const insertBody = z.object({ records: z.array(z.record(z.unknown())).min(1) }).strict();
const updateBody = z.object({ values: z.record(z.unknown()) }).strict();
function failure(code: string, message: string): ApiErrorResponse { return { error: { code, message } }; }
async function execute<T>(operation: () => Promise<T>) {
try { return { ok: true as const, value: await operation() }; }
catch (cause) { if (cause instanceof AdminError) return { ok: false as const, cause }; throw cause; }
}
export function registerAdminRoutes(app: App, repository: AdminRepository, auth?: AdminRequestAuth, mediaStorage?: FilesystemMediaStorage): void {
if (auth) {
app.use('/v1/admin/*', async (c, next) => {
const token = bearerToken(c.req.header('authorization'));
if (!token) return c.json(failure('unauthorized', 'A bearer token is required'), 401);
try { c.set('identity', await auth.verify(token)); }
catch { return c.json(failure('unauthorized', 'The bearer token is invalid'), 401); }
await next();
});
}
app.get('/v1/admin/me', async (c) => {
const result = await execute(() => repository.principal(c.get('identity').id));
return result.ok ? c.json({ role: result.value.role, permissions: result.value.permissions }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.post('/v1/admin/query/:resource', async (c) => {
const parsedResource = resource.safeParse(c.req.param('resource'));
const parsedBody = queryBody.safeParse(await c.req.json().catch(() => ({})));
if (!parsedResource.success || !parsedBody.success) return c.json(failure('invalid_admin_query', 'Invalid admin query'), 400);
const result = await execute(async () => repository.query(await repository.principal(c.get('identity').id), parsedResource.data, parsedBody.data));
return result.ok ? c.json(result.value) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.post('/v1/admin/data/:resource', async (c) => {
const parsedResource = resource.safeParse(c.req.param('resource'));
const parsedBody = insertBody.safeParse(await c.req.json().catch(() => ({})));
if (!parsedResource.success || !parsedBody.success) return c.json(failure('invalid_admin_mutation', 'Invalid admin mutation'), 400);
const result = await execute(async () => repository.insert(await repository.principal(c.get('identity').id), parsedResource.data, parsedBody.data.records));
return result.ok ? c.json({ rows: result.value }, 201) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.patch('/v1/admin/data/:resource/:id', async (c) => {
const parsedResource = resource.safeParse(c.req.param('resource'));
const parsedBody = updateBody.safeParse(await c.req.json().catch(() => ({})));
if (!parsedResource.success || !parsedBody.success) return c.json(failure('invalid_admin_mutation', 'Invalid admin mutation'), 400);
const result = await execute(async () => repository.update(await repository.principal(c.get('identity').id), parsedResource.data, c.req.param('id'), parsedBody.data.values));
return result.ok ? c.json({ row: result.value }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.delete('/v1/admin/data/:resource/:id', async (c) => {
const parsedResource = resource.safeParse(c.req.param('resource'));
if (!parsedResource.success) return c.json(failure('invalid_admin_mutation', 'Invalid admin mutation'), 400);
const result = await execute(async () => repository.delete(await repository.principal(c.get('identity').id), parsedResource.data, c.req.param('id')));
return result.ok ? c.json({ deleted: true }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/admin/dashboard', async (c) => {
const result = await execute(async () => repository.dashboard(await repository.principal(c.get('identity').id)));
return result.ok ? c.json(result.value) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/admin/users', async (c) => {
const result = await execute(async () => repository.users(await repository.principal(c.get('identity').id), c.req.query('userId')));
return result.ok ? c.json({ users: result.value }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/admin/feature-interest-counts', async (c) => {
const result = await execute(async () => repository.featureInterestCounts(await repository.principal(c.get('identity').id)));
return result.ok ? c.json({ counts: result.value }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/admin/media/:mediaId/url', async (c) => {
const parsed = z.string().uuid().safeParse(c.req.param('mediaId'));
if (!parsed.success) return c.json(failure('invalid_media_id', 'A valid media ID is required'), 400);
if (!mediaStorage) return c.json(failure('media_unavailable', 'Media storage is unavailable'), 503);
const result = await execute(async () => {
await repository.authorizeMedia(await repository.principal(c.get('identity').id), parsed.data);
return mediaStorage.signedUrl(parsed.data, 300);
});
return result.ok ? c.json(result.value, 200, { 'cache-control': 'private, no-store' }) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.post('/v1/admin/users/:userId/gift-premium', async (c) => {
const parsed = z.object({ days: z.number().int().min(1).max(3650).nullable(), reason: z.string().trim().max(500).optional() }).strict()
.safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) return c.json(failure('invalid_gift', 'A valid premium duration is required'), 400);
const result = await execute(async () => repository.giftPremium(await repository.principal(c.get('identity').id), c.req.param('userId'), parsed.data.days, parsed.data.reason));
return result.ok ? c.json(result.value, 201) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.post('/v1/admin/actions/decrypt-message', async (c) => {
const parsed = z.object({ messageId: z.string().uuid() }).strict().safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) return c.json(failure('invalid_message_id', 'A valid message ID is required'), 400);
const result = await execute(async () => repository.decryptMessage(await repository.principal(c.get('identity').id), parsed.data.messageId));
return result.ok ? c.json(result.value) : c.json(failure(result.cause.code, result.cause.message), result.cause.status);
});
app.post('/v1/admin/actions/decrypt-media', async (c) => {
const parsed = z.object({ messageId: z.string().uuid() }).strict().safeParse(await c.req.json().catch(() => ({})));
if (!parsed.success) return c.json(failure('invalid_message_id', 'A valid message ID is required'), 400);
const result = await execute(async () => repository.decryptMedia(await repository.principal(c.get('identity').id), parsed.data.messageId));
if (!result.ok) return c.json(failure(result.cause.code, result.cause.message), result.cause.status);
return c.body(new Uint8Array(result.value.bytes), 200, { 'content-type': result.value.mimeType, 'cache-control': 'private, no-store' });
});
}
+24
View File
@@ -0,0 +1,24 @@
export type AdminRole = 'pack_creator' | 'super_admin';
export interface AdminPrincipal {
adminId: string;
userId: string;
role: AdminRole;
permissions: string[];
}
export type AdminFilterOperator = 'eq' | 'neq' | 'in' | 'is' | 'gte' | 'lte' | 'ilike';
export interface AdminFilter { column: string; op: AdminFilterOperator; value?: unknown }
export interface AdminQuery {
columns?: string[];
filters?: AdminFilter[];
order?: { column: string; ascending?: boolean };
limit?: number;
offset?: number;
}
export class AdminError extends Error {
constructor(public readonly code: string, message: string, public readonly status: 400 | 403 | 404 | 409) {
super(message);
}
}
+211
View File
@@ -0,0 +1,211 @@
import { Pool, type PoolClient } from 'pg';
import { AnswersError, calculateMatchType, type Answer, type MatchType, type QuestionType, type ResponseData } from './types.js';
interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean }
interface QuestionRow { id: string; pack_id: string; text: string; partner_text: string | null; intensity: number; allowed_couple_genders: string[] | null; target_user_genders: string[] | null; question_type: QuestionType; config: Record<string, unknown>; created_at: string }
interface ResponseRow { id: string; user_id: string; question_id: string; couple_id: string; answer: Answer; response_data: ResponseData; created_at: string }
interface MatchRow { id: string; couple_id: string; question_id: string; match_type: MatchType; is_new: boolean; response_summary: Record<string, unknown> | null; created_at: string }
export interface SubmitInput { questionId: string; answer: Answer; responseData?: ResponseData }
export interface UpdateInput extends SubmitInput { confirmDeleteMatch: boolean }
export interface AnswersRepository {
recommended(userId: string, packId?: string): Promise<QuestionRow[]>;
pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string): Promise<Array<{ id: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } }; partnerAnsweredAt: string }>>;
answerGap(userId: string): Promise<{ unanswered_by_partner: number; threshold: number; is_blocked: boolean }>;
dailyLimit(userId: string): Promise<{ responses_today: number; limit_value: number; remaining: number; reset_at: string | null; is_blocked: boolean }>;
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
markSeen(userId: string, ids: string[]): Promise<void>;
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
streak(userId: string): Promise<Record<string, unknown> | null>;
close(): Promise<void>;
}
export class PostgresAnswersRepository implements AnswersRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private async context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
const row = result.rows[0];
if (!row?.couple_id) throw new AnswersError('no_couple', 409, 'Join a couple before answering questions');
return row;
}
async recommended(userId: string, packId?: string): Promise<QuestionRow[]> {
const ctx = await this.context(this.pool, userId);
const partner = await this.pool.query<ContextRow & { id: string }>('select id, couple_id, gender, max_intensity, is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1', [ctx.couple_id, userId]);
const p = partner.rows[0];
const config = await this.config();
if (!packId && p && (await this.answerGap(userId)).is_blocked) return [];
if (!packId && !ctx.is_premium && !p?.is_premium && (await this.dailyLimit(userId)).is_blocked) return [];
const genders = ctx.gender && p?.gender ? [ctx.gender, p.gender].sort().join('+') : null;
const maxIntensity = config.couple_intensity_gate_enabled && p ? Math.min(ctx.max_intensity, p.max_intensity) : ctx.max_intensity;
const result = await this.pool.query<QuestionRow>(`
with configured as (select exists(select 1 from couple_packs where couple_id=$2) value),
active as (
select $3::uuid pack_id where $3::uuid is not null
union all select cp.pack_id from couple_packs cp, configured c where $3::uuid is null and cp.couple_id=$2 and cp.enabled and c.value
union all select qp.id from question_packs qp, configured c where $3::uuid is null and qp.is_public and not c.value
)
select q.id,q.pack_id,
case when q.partner_text is not null and pr.id is not null then q.partner_text else q.text end text,
q.partner_text,q.intensity,q.allowed_couple_genders,q.target_user_genders,q.question_type,q.config,q.created_at
from questions q join active a on a.pack_id=q.pack_id join question_packs qp on qp.id=q.pack_id
left join responses pr on pr.question_id=q.id and pr.couple_id=$2 and pr.user_id<>$1
where q.deleted_at is null and not exists(select 1 from responses r where r.user_id=$1 and r.question_id=q.id)
and (not qp.is_premium or $4)
and (q.allowed_couple_genders is null or $5::text is null or $5=any(q.allowed_couple_genders))
and (q.target_user_genders is null or $6=any(q.target_user_genders) or pr.id is not null)
and q.intensity <= $7
order by (pr.id is not null) desc,q.intensity,q.id`,
[userId, ctx.couple_id, packId ?? null, ctx.is_premium || !!p?.is_premium, genders, ctx.gender, maxIntensity]);
return result.rows.map(this.dates);
}
async pending(userId: string, direction: 'mine' | 'partner', startQuestionId?: string) {
const ctx = await this.context(this.pool, userId);
const result = await this.pool.query<{ id: string; created_at: string; question: QuestionRow & { pack: { id: string; name: string; icon: string | null } } }>(`
select r.id,r.created_at,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,
'intensity',q.intensity,'allowed_couple_genders',q.allowed_couple_genders,'target_user_genders',q.target_user_genders,
'question_type',q.question_type,'config',q.config,'created_at',q.created_at,
'pack',json_build_object('id',qp.id,'name',qp.name,'icon',qp.icon)) question
from responses r join questions q on q.id=r.question_id join question_packs qp on qp.id=q.pack_id
where r.couple_id=$2 and r.user_id ${direction === 'partner' ? '<>' : '='} $1 and q.deleted_at is null
and not exists(select 1 from responses other where other.question_id=r.question_id and other.user_id ${direction === 'partner' ? '=' : '<>'} $1 and other.couple_id=$2)
order by case when r.question_id=$3 then 0 else 1 end,r.created_at ${direction === 'partner' ? 'asc' : 'desc'}`,
[userId, ctx.couple_id, startQuestionId ?? null]);
return result.rows.map(row => ({ id: row.id, question: row.question, partnerAnsweredAt: new Date(row.created_at).toISOString() }));
}
private async config(client: Pool | PoolClient = this.pool) {
const result = await client.query<{ answer_gap_threshold: number; daily_response_limit: number; couple_intensity_gate_enabled: boolean }>('select answer_gap_threshold,daily_response_limit,couple_intensity_gate_enabled from app_config limit 1');
return result.rows[0] ?? { answer_gap_threshold: 10, daily_response_limit: 0, couple_intensity_gate_enabled: false };
}
async answerGap(userId: string) {
let ctx: ContextRow; try { ctx = await this.context(this.pool, userId); } catch { return { unanswered_by_partner: 0, threshold: 0, is_blocked: false }; }
const cfg = await this.config();
const partner = await this.pool.query<{ id: string; max_intensity: number }>('select id,max_intensity from profiles where couple_id=$1 and id<>$2 order by id limit 1', [ctx.couple_id, userId]);
if (!partner.rows[0] || cfg.answer_gap_threshold === 0) return { unanswered_by_partner: 0, threshold: cfg.answer_gap_threshold, is_blocked: false };
const gate = cfg.couple_intensity_gate_enabled ? Math.min(ctx.max_intensity, partner.rows[0].max_intensity) : 5;
const counts = await this.pool.query<{ mine: number; theirs: number }>(`
with enabled as(select pack_id from couple_packs where couple_id=$2 and enabled), eligible as(
select r.* from responses r join questions q on q.id=r.question_id
where r.couple_id=$2 and exists(select 1 from enabled) and q.pack_id in(select pack_id from enabled) and q.intensity <= $4)
select count(*) filter(where e.user_id=$1 and not exists(select 1 from eligible x where x.question_id=e.question_id and x.user_id=$3))::int mine,
count(*) filter(where e.user_id=$3 and not exists(select 1 from eligible x where x.question_id=e.question_id and x.user_id=$1))::int theirs from eligible e`,
[userId, ctx.couple_id, partner.rows[0].id, gate]);
const net = Math.max(0, (counts.rows[0]?.mine ?? 0) - (counts.rows[0]?.theirs ?? 0));
return { unanswered_by_partner: net, threshold: cfg.answer_gap_threshold, is_blocked: net >= cfg.answer_gap_threshold };
}
async dailyLimit(userId: string) {
return this.dailyLimitWith(this.pool,userId);
}
async submit(userId: string, input: SubmitInput) {
return this.tx(async client => {
// Profile lock serializes a user's daily-limit count across different questions.
const ctx = await this.lockedContext(client, userId);
// Question lock serializes both partners so the second transaction observes
// the first committed response and creates exactly one match.
const q = await this.question(client, input.questionId, true);
await this.assertEligible(client,userId,ctx,q);
const existing = await client.query('select id from responses where user_id=$1 and question_id=$2 for update', [userId,input.questionId]);
if (!existing.rowCount) {
const limit = await this.dailyLimitWith(client,userId); if (limit.is_blocked) throw new AnswersError('daily_limit',429,'Daily response limit reached',{ daily_limit: limit.limit_value, reset_at: limit.reset_at });
}
const response = await client.query<ResponseRow>(`insert into responses(id,user_id,question_id,couple_id,answer,response_data) values(gen_random_uuid(),$1,$2,$3,$4,$5)
on conflict(user_id,question_id) do update set answer=excluded.answer,response_data=excluded.response_data returning *`,[userId,input.questionId,ctx.couple_id,input.answer,input.responseData ?? null]);
const match = await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,input.responseData ?? null,true);
await this.touchStreak(client,userId,ctx.couple_id!);
return { response: this.dates(response.rows[0]), match: match ? { ...this.dates(match), question: this.dates(q) } : null };
});
}
async update(userId: string, input: UpdateInput) {
return this.tx(async client => {
const ctx = await this.lockedContext(client,userId); const q=await this.question(client,input.questionId,true);
const current=await client.query<ResponseRow>('select * from responses where user_id=$1 and question_id=$2 for update',[userId,input.questionId]);
if (!current.rows[0]) throw new AnswersError('response_not_found',404,'No existing response found for this question');
if (current.rows[0].answer===input.answer && typeof input.responseData==='undefined') return { success:true,message:'Answer unchanged' };
const existing=await client.query<MatchRow>('select * from matches where couple_id=$1 and question_id=$2 for update',[ctx.couple_id,input.questionId]);
if (input.answer==='no' && existing.rows[0] && !input.confirmDeleteMatch) {
const count=await client.query<{ count:number }>('select count(*)::int count from messages where match_id=$1',[existing.rows[0].id]);
return { success:false,requires_confirmation:true,match_id:existing.rows[0].id,message_count:count.rows[0]?.count ?? 0 };
}
const normalized=q.question_type!=='swipe' && input.answer!=='no' ? (typeof input.responseData==='undefined' ? current.rows[0].response_data : input.responseData) : null;
const before=existing.rows[0]; const match=await this.reconcileMatch(client,userId,ctx.couple_id!,q,input.answer,normalized,false);
await client.query('update responses set answer=$3,response_data=$4 where user_id=$1 and question_id=$2',[userId,input.questionId,input.answer,normalized]);
await this.touchStreak(client,userId,ctx.couple_id!);
return { success:true, ...(before&&!match?{match_deleted:true}:{ }), ...(!before&&match?{new_match:this.dates(match)}:{}), ...(before&&match?{match_type_updated:true}:{}) };
});
}
private async reconcileMatch(client:PoolClient,userId:string,coupleId:string,q:QuestionRow,answer:Answer,data:ResponseData,isNew:boolean) {
const partner=await client.query<ResponseRow>('select * from responses where couple_id=$1 and question_id=$2 and user_id<>$3 limit 1',[coupleId,q.id,userId]);
if(!partner.rows[0]) return null;
const type=calculateMatchType(q,answer,partner.rows[0].answer);
if(!type){ await client.query('delete from matches where couple_id=$1 and question_id=$2',[coupleId,q.id]); return null; }
const summary=type==='both_answered'?{[userId]:data,[partner.rows[0].user_id]:partner.rows[0].response_data}:null;
const result=await client.query<MatchRow>(`insert into matches(couple_id,question_id,match_type,is_new,response_summary) values($1,$2,$3,true,$4)
on conflict(couple_id,question_id) do update set match_type=excluded.match_type,response_summary=excluded.response_summary,is_new=case when $5 then true else matches.is_new end returning *`,[coupleId,q.id,type,summary,isNew]);
return result.rows[0];
}
private async touchStreak(client:PoolClient,userId:string,coupleId:string){
const users=await client.query<{id:string}>('select id from profiles where couple_id=$1 order by id',[coupleId]); if(users.rows.length<2)return;
const user1=users.rows[0].id===userId;
await client.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[coupleId]);
const locked=await client.query<{current_streak:number;longest_streak:number;last_active_date:string|null;last_completed_date:string|null;user1_answered_today:boolean;user2_answered_today:boolean}>(`select current_streak,longest_streak,last_active_date::text,last_completed_date::text,user1_answered_today,user2_answered_today from couple_streaks where couple_id=$1 for update`,[coupleId]);
const row=locked.rows[0]; const todayResult=await client.query<{today:string;yesterday:string}>('select current_date::text today,(current_date-1)::text yesterday'); const {today,yesterday}=todayResult.rows[0];
let first=row.last_active_date===today?row.user1_answered_today:false; let second=row.last_active_date===today?row.user2_answered_today:false;
if(user1)first=true;else second=true;
let current=row.last_completed_date && row.last_completed_date!==yesterday && row.last_completed_date!==today?0:row.current_streak; let completed=row.last_completed_date;
if(first&&second&&completed!==today){current=completed===yesterday?current+1:1;completed=today;}
await client.query(`update couple_streaks set current_streak=$2,longest_streak=greatest(longest_streak,$2),last_active_date=$3,last_completed_date=$4,user1_answered_today=$5,user2_answered_today=$6,updated_at=now() where couple_id=$1`,[coupleId,current,today,completed,first,second]);
}
async responses(userId:string,page:number,limit:number){
const ctx=await this.context(this.pool,userId); const offset=page*limit;
const [rows,count]=await Promise.all([
this.pool.query(`select r.id,r.question_id,r.answer,r.response_data,r.created_at,
json_build_object('id',q.id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'pack_id',q.pack_id,'question_type',q.question_type,'config',q.config,'created_at',q.created_at,'pack',json_build_object('id',qp.id,'name',qp.name,'icon',qp.icon)) question,
m.id is not null has_match,m.id match_id,pr.id is not null partner_answered
from responses r join questions q on q.id=r.question_id join question_packs qp on qp.id=q.pack_id left join matches m on m.couple_id=r.couple_id and m.question_id=r.question_id left join responses pr on pr.couple_id=r.couple_id and pr.question_id=r.question_id and pr.user_id<>$1 where r.user_id=$1 and r.couple_id=$2 order by r.created_at desc limit $3 offset $4`,[userId,ctx.couple_id,limit,offset]),
this.pool.query<{count:number}>('select count(*)::int count from responses where user_id=$1 and couple_id=$2',[userId,ctx.couple_id])]);
return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
}
async matches(userId:string,page:number,limit:number,archived:boolean){
const ctx=await this.context(this.pool,userId); const offset=page*limit;
const params=[userId,ctx.couple_id,archived,limit,offset];
const base=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1 where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
const [rows,count]=await Promise.all([this.pool.query(`select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,(select count(*)::int from messages msg where msg.match_id=m.id and msg.user_id<>$1 and msg.read_at is null) "unreadCount" ${base} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),this.pool.query<{count:number}>(`select count(*)::int count ${base}`,params.slice(0,3))]);
return {matches:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
}
async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);}
async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);}
async streak(userId:string){const ctx=await this.context(this.pool,userId);const r=await this.pool.query('select * from couple_streaks where couple_id=$1',[ctx.couple_id]);return r.rows[0]?this.dates(r.rows[0]):null;}
private async question(client:PoolClient,id:string,lock=false){const r=await client.query<QuestionRow>(`select * from questions where id=$1 and deleted_at is null${lock?' for update':''}`,[id]);if(!r.rows[0])throw new AnswersError('question_not_found',404,'Question was not found');return r.rows[0];}
private async lockedContext(client:PoolClient,userId:string){await client.query('select id from profiles where id=$1 for update',[userId]);return this.context(client,userId);}
private async dailyLimitWith(client:Pool|PoolClient,userId:string){
const cfg=await this.config(client);if(cfg.daily_response_limit===0)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
const premium=await client.query<{premium:boolean}>('select (p.is_premium or coalesce(bool_or(o.is_premium),false)) premium from profiles p left join profiles o on o.couple_id=p.couple_id and o.id<>p.id where p.id=$1 group by p.is_premium',[userId]);if(premium.rows[0]?.premium)return {responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false};
const r=await client.query<{count:number;reset_at:string}>("select count(*)::int count,((date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')+interval '1 day') reset_at from responses where user_id=$1 and created_at>=(date_trunc('day',now() at time zone 'UTC') at time zone 'UTC')",[userId]);const count=r.rows[0]?.count??0;return {responses_today:count,limit_value:cfg.daily_response_limit,remaining:Math.max(0,cfg.daily_response_limit-count),reset_at:new Date(r.rows[0].reset_at).toISOString(),is_blocked:count>=cfg.daily_response_limit};
}
private async assertEligible(client:PoolClient,userId:string,ctx:ContextRow,q:QuestionRow){
const partner=await client.query<ContextRow&{id:string}>('select id,couple_id,gender,max_intensity,is_premium from profiles where couple_id=$1 and id<>$2 order by id limit 1',[ctx.couple_id,userId]);const p=partner.rows[0];
const cfg=await this.config(client);const max=cfg.couple_intensity_gate_enabled&&p?Math.min(ctx.max_intensity,p.max_intensity):ctx.max_intensity;const genders=ctx.gender&&p?.gender?[ctx.gender,p.gender].sort().join('+'):null;
const allowed=!q.allowed_couple_genders||!genders||q.allowed_couple_genders.includes(genders);const targeted=!q.target_user_genders||!!ctx.gender&&q.target_user_genders.includes(ctx.gender)||!!(await client.query('select 1 from responses where couple_id=$1 and question_id=$2 and user_id<>$3',[ctx.couple_id,q.id,userId])).rowCount;
const pack=await client.query<{is_public:boolean;is_premium:boolean;enabled:boolean|null;configured:boolean}>(`select qp.is_public,qp.is_premium,cp.enabled,exists(select 1 from couple_packs where couple_id=$2) configured from question_packs qp left join couple_packs cp on cp.pack_id=qp.id and cp.couple_id=$2 where qp.id=$1`,[q.pack_id,ctx.couple_id]);const pr=pack.rows[0];const active=!!pr&&(pr.configured?pr.enabled===true:pr.is_public);const premium=!pr?.is_premium||ctx.is_premium||!!p?.is_premium;
if(!active||!premium||q.intensity>max||!allowed||!targeted)throw new AnswersError('question_not_eligible',404,'Question was not found');
}
private async tx<T>(fn:(client:PoolClient)=>Promise<T>):Promise<T>{const c=await this.pool.connect();try{await c.query('begin');const value=await fn(c);await c.query('commit');return value;}catch(e){await c.query('rollback');throw e;}finally{c.release();}}
private dates<T extends Record<string,any>>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;}
async close(){await this.pool.end();}
}
+28
View File
@@ -0,0 +1,28 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import type { AnswersRepository } from './repository.js';
import { AnswersError } from './types.js';
type App = Hono<{Variables:{identity:AuthIdentity}}>;
const uuid=z.string().uuid(); const page=z.coerce.number().int().min(0).default(0); const limit=z.coerce.number().int().min(1).max(100).default(20);
const responseData=z.record(z.unknown()).nullable();
const submit=z.object({question_id:uuid,answer:z.enum(['yes','no','maybe']),response_data:responseData.optional()}).strict();
const update=z.object({new_answer:z.enum(['yes','no','maybe']),response_data:responseData.optional(),confirm_delete_match:z.boolean().optional().default(false)}).strict();
const err=(code:string,message:string,details?:Record<string,unknown>):ApiErrorResponse & {details?:Record<string,unknown>}=>({error:{code,message},...(details?{details}:{})});
async function run<T>(op:()=>Promise<T>){try{return {ok:true as const,value:await op()};}catch(e){if(e instanceof AnswersError)return {ok:false as const,status:e.status,body:err(e.code,e.message,e.details)};throw e;}}
export function registerAnswerRoutes(app:App,repo:AnswersRepository):void{
app.get('/v1/questions/recommended',async c=>{const packId=c.req.query('packId');if(packId&&!uuid.safeParse(packId).success)return c.json(err('invalid_pack_id','Pack ID must be a UUID'),400);const r=await run(()=>repo.recommended(c.get('identity').id,packId));return r.ok?c.json({questions:r.value}):c.json(r.body,r.status);});
app.get('/v1/questions/pending',async c=>{const direction=c.req.query('direction')==='mine'?'mine':'partner';const start=c.req.query('startQuestionId');if(start&&!uuid.safeParse(start).success)return c.json(err('invalid_question_id','startQuestionId must be a UUID'),400);const r=await run(()=>repo.pending(c.get('identity').id,direction,start));return r.ok?c.json({questions:r.value}):c.json(r.body,r.status);});
app.get('/v1/me/answer-gap',async c=>c.json(await repo.answerGap(c.get('identity').id)));
app.get('/v1/me/daily-limit',async c=>c.json(await repo.dailyLimit(c.get('identity').id)));
app.post('/v1/responses',async c=>{const b=submit.safeParse(await c.req.json().catch(()=>null));if(!b.success)return c.json(err('invalid_request','question_id, answer, and valid response_data are required'),400);const r=await run(()=>repo.submit(c.get('identity').id,{questionId:b.data.question_id,answer:b.data.answer,responseData:b.data.response_data}));return r.ok?c.json(r.value):c.json(r.body,r.status);});
app.patch('/v1/responses/:questionId',async c=>{const id=uuid.safeParse(c.req.param('questionId'));const b=update.safeParse(await c.req.json().catch(()=>null));if(!id.success||!b.success)return c.json(err('invalid_request','A valid question ID and answer are required'),400);const r=await run(()=>repo.update(c.get('identity').id,{questionId:id.data,answer:b.data.new_answer,responseData:b.data.response_data,confirmDeleteMatch:b.data.confirm_delete_match}));return r.ok?c.json(r.value):c.json(r.body,r.status);});
app.get('/v1/me/responses',async c=>{const p=page.safeParse(c.req.query('page')),l=limit.safeParse(c.req.query('limit'));if(!p.success||!l.success)return c.json(err('invalid_pagination','page and limit are out of range'),400);return c.json(await repo.responses(c.get('identity').id,p.data,l.data));});
app.get('/v1/matches',async c=>{const p=page.safeParse(c.req.query('page')),l=limit.safeParse(c.req.query('limit'));if(!p.success||!l.success)return c.json(err('invalid_pagination','page and limit are out of range'),400);return c.json(await repo.matches(c.get('identity').id,p.data,l.data,c.req.query('archived')==='true'));});
app.patch('/v1/matches/seen',async c=>{const b=z.object({ids:z.array(uuid).min(1)}).safeParse(await c.req.json().catch(()=>null));if(!b.success)return c.json(err('invalid_request','ids must contain valid match IDs'),400);await repo.markSeen(c.get('identity').id,b.data.ids);return c.json({success:true});});
app.put('/v1/matches/:matchId/archive',async c=>{const id=uuid.safeParse(c.req.param('matchId'));const b=z.object({archived:z.boolean()}).safeParse(await c.req.json().catch(()=>null));if(!id.success||!b.success)return c.json(err('invalid_request','A valid match ID and archived flag are required'),400);const r=await run(()=>repo.archive(c.get('identity').id,id.data,b.data.archived));return r.ok?c.json({success:true}):c.json(r.body,r.status);});
app.get('/v1/me/streak',async c=>{const r=await run(()=>repo.streak(c.get('identity').id));return r.ok?c.json({streak:r.value}):c.json(r.body,r.status);});
}
+4
View File
@@ -0,0 +1,4 @@
// Registration contract: export this module from db/all-schema.ts when the
// application's Drizzle schema becomes authoritative. Migration 0003 is the
// current source of truth for this domain.
export const answersSchemaVersion = 3 as const;
+29
View File
@@ -0,0 +1,29 @@
export type Answer = 'yes' | 'no' | 'maybe';
export type QuestionType = 'swipe' | 'text_answer' | 'audio' | 'photo' | 'who_likely';
export type MatchType = 'yes_yes' | 'yes_maybe' | 'maybe_maybe' | 'both_answered';
export type ResponseData = Record<string, unknown> | null;
export interface QuestionRef {
id: string;
question_type: QuestionType;
}
export function calculateMatchType(question: QuestionRef, first: Answer, second: Answer): MatchType | null {
if (question.question_type === 'swipe') {
if (first === 'no' || second === 'no') return null;
if (first === 'yes' && second === 'yes') return 'yes_yes';
if (first === 'maybe' && second === 'maybe') return 'maybe_maybe';
return 'yes_maybe';
}
if (question.question_type === 'who_likely') return 'both_answered';
return first === 'no' || second === 'no' ? null : 'both_answered';
}
export class AnswersError extends Error {
constructor(
readonly code: 'no_couple' | 'question_not_found' | 'question_not_eligible' | 'response_not_found' | 'daily_limit' | 'match_not_found',
readonly status: 400 | 404 | 409 | 429,
message: string,
readonly details?: Record<string, unknown>,
) { super(message); }
}
+193
View File
@@ -0,0 +1,193 @@
import { Pool, type QueryResultRow } from 'pg';
export interface PackContext { id: string; name: string; icon: string | null }
export interface AppQuestion {
id: string; pack_id: string; text: string; partner_text: string | null; intensity: number;
allowed_couple_genders: string[] | null; target_user_genders: string[] | null;
question_type: string; config: Record<string, unknown>; created_at: string;
}
export interface MatchContext {
id: string; question_id: string; couple_id: string; match_type: string; created_at: string;
response_summary: Record<string, unknown> | null;
question: { id: string; text: string; partner_text: string | null };
responses: Array<{ user_id: string; answer: string; created_at: string; profiles: { name: string | null } }>;
}
export interface StrokePoint { x: number; y: number }
export interface StrokeSegment {
id: string; userId: string; points: StrokePoint[]; color: string; width: number;
timestamp: number; isEraser: boolean;
}
export interface LiveDrawState { strokes: StrokeSegment[]; revision: number; updated_at: string | null; updated_by: string | null }
export class AppDataError extends Error {
constructor(public readonly code: 'not_found' | 'no_couple' | 'not_recipient' | 'rate_limited' | 'revision_conflict', message: string, public readonly status: 403 | 404 | 409 | 429, public readonly details?: Record<string, unknown>) { super(message); }
}
export interface AppDataRepository {
packContext(userId: string, packId: string): Promise<PackContext>;
packQuestions(userId: string, packId: string): Promise<AppQuestion[]>;
packTeaser(userId: string, packId: string): Promise<Array<Pick<AppQuestion, 'id' | 'text' | 'intensity'>>>;
matchContext(userId: string, matchId: string): Promise<MatchContext>;
markMediaViewed(userId: string, messageId: string, expiresAt: string | null): Promise<{ media_viewed_at: string; media_expires_at: string | null }>;
nudgeStatus(userId: string): Promise<{ last_nudge_sent_at: string | null }>;
sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>;
getLiveDraw(userId: string): Promise<LiveDrawState>;
putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState>;
close(): Promise<void>;
}
interface QuestionRow extends QueryResultRow, Omit<AppQuestion, 'created_at'> { created_at: Date }
function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; }
export class PostgresAppDataRepository implements AppDataRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private async visiblePack(userId: string, packId: string): Promise<PackContext> {
const result = await this.pool.query<PackContext>(`
select qp.id, qp.name, qp.icon
from question_packs qp
left join categories c on c.id = qp.category_id
left join profiles p on p.id = $1
where qp.id = $2 and qp.is_public = true
and (qp.category_id is null or c.is_public = true)
and (qp.is_explicit = false or coalesce(p.hide_nsfw, true) = false)
limit 1`, [userId, packId]);
if (!result.rows[0]) throw new AppDataError('not_found', 'Pack not found', 404);
return result.rows[0];
}
async packContext(userId: string, packId: string): Promise<PackContext> { return this.visiblePack(userId, packId); }
async packQuestions(userId: string, packId: string): Promise<AppQuestion[]> {
await this.visiblePack(userId, packId);
const result = await this.pool.query<QuestionRow>(`
select id, pack_id, text, partner_text, intensity, allowed_couple_genders,
target_user_genders, question_type, config, created_at
from questions where pack_id = $1 and deleted_at is null
order by created_at, id`, [packId]);
return result.rows.map(question);
}
async packTeaser(userId: string, packId: string) {
await this.visiblePack(userId, packId);
const result = await this.pool.query<{ id: string; text: string; intensity: number }>(`
select id, text, intensity from questions
where pack_id = $1 and deleted_at is null
order by intensity, md5(id::text || current_date::text) limit 3`, [packId]);
return result.rows;
}
async matchContext(userId: string, matchId: string): Promise<MatchContext> {
const match = await this.pool.query<{
id: string; question_id: string; couple_id: string; match_type: string; created_at: Date;
response_summary: Record<string, unknown> | null; question: MatchContext['question'];
}>(`select m.id,m.question_id,m.couple_id,m.match_type,m.created_at,m.response_summary,
json_build_object('id',q.id,'text',q.text,'partner_text',q.partner_text) question
from matches m join questions q on q.id=m.question_id join profiles p on p.couple_id=m.couple_id
where p.id=$1 and m.id=$2 limit 1`, [userId, matchId]);
if (!match.rows[0]) throw new AppDataError('not_found', 'Match not found', 404);
const responses = await this.pool.query<{
user_id: string; answer: string; created_at: Date; profiles: { name: string | null };
}>(`select r.user_id,r.answer,r.created_at,json_build_object('name',p.name) profiles
from responses r join profiles p on p.id=r.user_id
where r.question_id=$1 and r.couple_id=$2 order by r.created_at,r.id`, [match.rows[0].question_id, match.rows[0].couple_id]);
return { ...match.rows[0], created_at: match.rows[0].created_at.toISOString(), responses: responses.rows.map(row => ({ ...row, created_at: row.created_at.toISOString() })) };
}
async markMediaViewed(userId: string, messageId: string, expiresAt: string | null) {
const result = await this.pool.query<{ media_viewed_at: Date; media_expires_at: Date | null }>(`
update messages m set media_viewed_at=coalesce(media_viewed_at,now()),
media_expires_at=case when m.media_type='video' then coalesce(media_expires_at,$3::timestamptz) else null end
from matches ma join profiles p on p.couple_id=ma.couple_id
where m.match_id=ma.id and p.id=$1 and m.id=$2 and m.user_id<>$1
returning m.media_viewed_at,m.media_expires_at`, [userId, messageId, expiresAt]);
if (!result.rows[0]) throw new AppDataError('not_recipient', 'Message not found', 404);
return { media_viewed_at: result.rows[0].media_viewed_at.toISOString(), media_expires_at: result.rows[0].media_expires_at?.toISOString() ?? null };
}
async nudgeStatus(userId: string) {
const result = await this.pool.query<{ last_nudge_sent_at: Date | null }>('select last_nudge_sent_at from profiles where id=$1', [userId]);
return { last_nudge_sent_at: result.rows[0]?.last_nudge_sent_at?.toISOString() ?? null };
}
async sendNudge(userId: string) {
const client = await this.pool.connect();
let senderName: string | null = null; let partnerToken: string | null = null; let nudgesEnabled = true; let nextAvailable = '';
try {
await client.query('begin');
const sender = await client.query<{ name: string | null; couple_id: string | null; last_nudge_sent_at: Date | null }>(
'select name,couple_id,last_nudge_sent_at from profiles where id=$1 for update', [userId]);
if (!sender.rows[0]) throw new AppDataError('not_found', 'Profile not found', 404);
if (!sender.rows[0].couple_id) throw new AppDataError('no_couple', 'Join a couple first', 409);
const now = new Date(); const last = sender.rows[0].last_nudge_sent_at;
if (last && now.getTime() - last.getTime() < 12 * 60 * 60 * 1000) {
const next = new Date(last.getTime() + 12 * 60 * 60 * 1000);
throw new AppDataError('rate_limited', 'Nudge cooldown is active', 429, {
cooldown_remaining_seconds: Math.ceil((next.getTime() - now.getTime()) / 1000), next_nudge_available_at: next.toISOString(),
});
}
const partner = await client.query<{ id: string; push_token: string | null; nudges_enabled: boolean | null }>(`
select p.id,p.push_token,np.nudges_enabled from profiles p
left join notification_preferences np on np.user_id=p.id
where p.couple_id=$1 and p.id<>$2 order by p.id limit 1`, [sender.rows[0].couple_id, userId]);
if (!partner.rows[0]) throw new AppDataError('not_found', 'Partner not found', 404);
await client.query('update profiles set last_nudge_sent_at=$2 where id=$1', [userId, now]);
await client.query('commit');
senderName = sender.rows[0].name; partnerToken = partner.rows[0].push_token; nudgesEnabled = partner.rows[0].nudges_enabled !== false;
nextAvailable = new Date(now.getTime() + 12 * 60 * 60 * 1000).toISOString();
} catch (cause) {
await client.query('rollback').catch(() => undefined);
throw cause;
} finally { client.release(); }
if (!partnerToken) return { success: true as const, notification_sent: false, reason: 'no_push_token', next_nudge_available_at: nextAvailable };
if (!nudgesEnabled) return { success: true as const, notification_sent: false, reason: 'nudges_disabled', next_nudge_available_at: nextAvailable };
const response = await fetch('https://exp.host/--/api/v2/push/send', {
method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ to: partnerToken, title: 'Partner nudge', body: `${senderName || 'Your partner'} wants you to catch up!`, sound: 'default', data: { type: 'nudge' } }),
});
if (!response.ok) throw new Error(`Expo push request failed with status ${response.status}`);
return { success: true as const, notification_sent: true, next_nudge_available_at: nextAvailable };
}
private async coupleId(userId: string): Promise<string> {
const result = await this.pool.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1', [userId]);
const coupleId = result.rows[0]?.couple_id;
if (!coupleId) throw new AppDataError('no_couple', 'Join a couple first', 409);
return coupleId;
}
async getLiveDraw(userId: string): Promise<LiveDrawState> {
const coupleId = await this.coupleId(userId);
const result = await this.pool.query<{ strokes: StrokeSegment[]; revision: string; updated_at: Date; updated_by: string }>(
'select strokes,revision::text,updated_at,updated_by from live_draw_sessions where couple_id=$1', [coupleId]);
const row = result.rows[0];
return row ? { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by } : { strokes: [], revision: 0, updated_at: null, updated_by: null };
}
async putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState> {
const coupleId = await this.coupleId(userId);
const memberIds = await this.pool.query<{ id: string }>('select id from profiles where couple_id=$1', [coupleId]);
const allowed = new Set(memberIds.rows.map(row => row.id));
if (strokes.some(stroke => !allowed.has(stroke.userId))) {
throw new AppDataError('not_recipient', 'Drawing contains a stroke from outside this couple', 403);
}
let result = await this.pool.query<{ strokes: StrokeSegment[]; revision: string; updated_at: Date; updated_by: string }>(`
update live_draw_sessions set strokes=$2::jsonb,updated_by=$3,revision=revision+1,updated_at=now()
where couple_id=$1 and revision=$4
returning strokes,revision::text,updated_at,updated_by`, [coupleId, JSON.stringify(strokes), userId, baseRevision]);
if (!result.rows[0] && baseRevision === 0) {
result = await this.pool.query<{ strokes: StrokeSegment[]; revision: string; updated_at: Date; updated_by: string }>(`
insert into live_draw_sessions(couple_id,strokes,updated_by) values($1,$2::jsonb,$3)
on conflict(couple_id) do nothing returning strokes,revision::text,updated_at,updated_by`, [coupleId, JSON.stringify(strokes), userId]);
}
const row = result.rows[0];
if (!row) {
const current = await this.getLiveDraw(userId);
throw new AppDataError('revision_conflict', 'Drawing changed on another device', 409, { current_state: current });
}
return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by };
}
async close(): Promise<void> { await this.pool.end(); }
}
+58
View File
@@ -0,0 +1,58 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { AppDataError, type AppDataRepository } from './repository.js';
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
const uuid = z.string().uuid();
const point = z.object({ x: z.number().finite().min(0).max(1), y: z.number().finite().min(0).max(1) }).strict();
const stroke = z.object({
id: z.string().min(1).max(120), userId: uuid, points: z.array(point).max(20_000),
color: z.string().min(1).max(64), width: z.number().finite().positive().max(100),
timestamp: z.number().finite().nonnegative(), isEraser: z.boolean(),
}).strict();
const liveDrawBody = z.object({ strokes: z.array(stroke).max(2_000), base_revision: z.number().int().nonnegative() }).strict();
const viewedBody = z.object({ expires_at: z.string().datetime().nullable() }).strict();
const error = (code: string, message: string): ApiErrorResponse => ({ error: { code, message } });
async function run<T>(op: () => Promise<T>) { try { return { ok: true as const, value: await op() }; } catch (cause) { if (cause instanceof AppDataError) return { ok: false as const, cause }; throw cause; } }
export function registerAppDataRoutes(app: App, repository: AppDataRepository): void {
app.get('/v1/packs/:packId/context', async c => {
const id = uuid.safeParse(c.req.param('packId')); if (!id.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
const result = await run(() => repository.packContext(c.get('identity').id, id.data));
return result.ok ? c.json({ pack: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/packs/:packId/questions', async c => {
const id = uuid.safeParse(c.req.param('packId')); if (!id.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
const result = await run(() => repository.packQuestions(c.get('identity').id, id.data));
return result.ok ? c.json({ questions: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/packs/:packId/teaser', async c => {
const id = uuid.safeParse(c.req.param('packId')); if (!id.success) return c.json(error('invalid_pack_id', 'A valid pack ID is required'), 400);
const result = await run(() => repository.packTeaser(c.get('identity').id, id.data));
return result.ok ? c.json({ questions: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/matches/:matchId/context', async c => {
const id = uuid.safeParse(c.req.param('matchId')); if (!id.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
const result = await run(() => repository.matchContext(c.get('identity').id, id.data));
return result.ok ? c.json({ match: result.value }) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
app.patch('/v1/messages/:messageId/media-viewed', async c => {
const id = uuid.safeParse(c.req.param('messageId')); const body = viewedBody.safeParse(await c.req.json().catch(() => null));
if (!id.success || !body.success) return c.json(error('invalid_request', 'A valid message ID and expires_at are required'), 400);
const result = await run(() => repository.markMediaViewed(c.get('identity').id, id.data, body.data.expires_at));
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id)));
app.post('/v1/me/nudge', async c => {
const result = await run(() => repository.sendNudge(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json({ ...error(result.cause.code, result.cause.message), ...result.cause.details }, result.cause.status);
});
app.get('/v1/live-draw', async c => { const result = await run(() => repository.getLiveDraw(c.get('identity').id)); return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status); });
app.put('/v1/live-draw', async c => {
const body = liveDrawBody.safeParse(await c.req.json().catch(() => null)); if (!body.success) return c.json(error('invalid_strokes', 'A valid strokes array is required'), 400);
const result = await run(() => repository.putLiveDraw(c.get('identity').id, body.data.strokes, body.data.base_revision));
return result.ok ? c.json(result.value) : c.json({ ...error(result.cause.code, result.cause.message), ...result.cause.details }, result.cause.status);
});
}
+12
View File
@@ -0,0 +1,12 @@
import { bigint, index, jsonb, pgTable, timestamp, uuid } from 'drizzle-orm/pg-core';
import { profiles } from '../../db/schema.js';
import { couples } from '../couples/schema.js';
export const liveDrawSessions = pgTable('live_draw_sessions', {
coupleId: uuid('couple_id').primaryKey().references(() => couples.id, { onDelete: 'cascade' }),
strokes: jsonb('strokes').notNull().default([]),
revision: bigint('revision', { mode: 'number' }).notNull().default(1),
updatedBy: uuid('updated_by').notNull().references(() => profiles.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index('live_draw_sessions_updated_at_idx').on(table.updatedAt)]);
+35
View File
@@ -0,0 +1,35 @@
# Billing and redemption
This domain owns RevenueCat webhook persistence and public promotional-code
redemption. Neither route uses the hosted Supabase data plane.
- `POST /webhooks/revenuecat` requires an exact
`Authorization: Bearer <REVENUECAT_WEBHOOK_SECRET>` header. A missing server
secret disables the webhook with `503`; it never becomes unauthenticated.
- `POST /public/v1/redemptions` accepts `{ email, code }` for the public web
redemption form. Validation, remaining-use checks, redemption recording, and
the premium grant run in one database transaction.
Both public endpoints have request-body limits (1 MiB for provider webhooks and
16 KiB for redemption). Webhook authorization is checked before JSON parsing.
Because the API cannot independently authenticate a proxy-supplied client IP,
redemption also uses a database-backed global ceiling of 60 attempts per minute
across all replicas. The production reverse proxy must additionally apply a
per-client-IP limit; forwarded-IP headers must not be trusted from the public
internet unless the proxy strips and rewrites them.
Webhook event insertion, subscription upsert, premium recomputation, and any
premium-pack disabling are atomic. The unique event ID makes provider retries
idempotent. A known event for an unknown user rolls back the event record so a
later retry can succeed after identity reconciliation. Unknown and `TEST` events
are recorded once and acknowledged without changing subscription state.
Wire a `PostgresBillingRepository`, construct `BillingService` with the
server-only `REVENUECAT_WEBHOOK_SECRET`, call `registerBillingRoutes`, and close
the repository during graceful shutdown. The web app should receive the API base
URL via `NEXT_PUBLIC_API_URL`; no server secret belongs in a `NEXT_PUBLIC_*`
variable.
Rate-limit the public redemption endpoint by client IP at the trusted reverse
proxy. The API limits request size and atomically locks codes, but deliberately
does not trust arbitrary forwarded-IP headers on its own.
+5
View File
@@ -0,0 +1,5 @@
export * from './repository.js';
export * from './routes.js';
export * from './service.js';
export * from './types.js';
+212
View File
@@ -0,0 +1,212 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js';
interface ProfileRecord extends QueryResultRow {
id: string;
couple_id: string | null;
is_premium: boolean;
}
interface CodeRecord extends QueryResultRow {
id: string;
max_uses: number | null;
current_uses: number;
expires_at: Date | null;
is_active: boolean;
}
export interface WebhookResult {
duplicate: boolean;
handled: boolean;
}
export interface BillingRepository {
processRevenueCatEvent(payload: RevenueCatWebhook, status: SubscriptionStatus | null): Promise<WebhookResult>;
redeem(email: string, code: string): Promise<RedemptionResult>;
close(): Promise<void>;
}
async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('begin');
const result = await operation(client);
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
export class PostgresBillingRepository implements BillingRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
}
async processRevenueCatEvent(
payload: RevenueCatWebhook,
status: SubscriptionStatus | null,
): Promise<WebhookResult> {
return transaction(this.pool, async (client) => {
const event = payload.event;
const inserted = await client.query(
`insert into revenuecat_webhook_events (event_id, event_type, app_user_id, payload)
values ($1, $2, $3, $4::jsonb)
on conflict (event_id) do nothing`,
[event.id, event.type, event.app_user_id, JSON.stringify(payload)],
);
if (inserted.rowCount === 0) return { duplicate: true, handled: status !== null };
if (status === null) return { duplicate: false, handled: false };
const profileResult = await client.query<ProfileRecord>(
'select id, couple_id, is_premium from profiles where id = $1 for update',
[event.app_user_id],
);
const profile = profileResult.rows[0];
if (!profile) throw new BillingError('user_not_found', 'User not found', 404);
await client.query(
`insert into subscriptions (
user_id, revenuecat_app_user_id, product_id, status, entitlement_ids,
purchased_at, expires_at, original_transaction_id, store, is_sandbox,
cancel_reason, grace_period_expires_at
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
on conflict (user_id, original_transaction_id) do update set
revenuecat_app_user_id = excluded.revenuecat_app_user_id,
product_id = excluded.product_id,
status = excluded.status,
entitlement_ids = excluded.entitlement_ids,
purchased_at = excluded.purchased_at,
expires_at = excluded.expires_at,
store = excluded.store,
is_sandbox = excluded.is_sandbox,
cancel_reason = excluded.cancel_reason,
grace_period_expires_at = excluded.grace_period_expires_at,
updated_at = now()`,
[
event.app_user_id,
event.app_user_id,
event.product_id,
status,
event.entitlement_ids,
new Date(event.purchased_at_ms),
event.expiration_at_ms === undefined ? null : new Date(event.expiration_at_ms),
event.original_transaction_id,
event.store,
event.environment !== 'PRODUCTION',
event.cancel_reason ?? null,
event.grace_period_expiration_at_ms === undefined ? null : new Date(event.grace_period_expiration_at_ms),
],
);
const premiumResult = await client.query<{ premium: boolean }>(
`select exists (
select 1 from subscriptions
where user_id = $1 and status = 'active'
and (expires_at is null or expires_at > now())
) as premium`,
[event.app_user_id],
);
const isPremium = premiumResult.rows[0]?.premium ?? false;
await client.query(
'update profiles set is_premium = $2, updated_at = now() where id = $1',
[event.app_user_id, isPremium],
);
if (profile.is_premium && !isPremium && profile.couple_id) {
const partnerPremium = await client.query<{ premium: boolean }>(
`select exists (
select 1 from profiles
where couple_id = $1 and id <> $2 and is_premium = true
) as premium`,
[profile.couple_id, event.app_user_id],
);
if (!partnerPremium.rows[0]?.premium) {
await client.query(
`update couple_packs cp set enabled = false
from question_packs qp
where cp.pack_id = qp.id and qp.is_premium = true
and cp.couple_id = $1 and cp.enabled = true`,
[profile.couple_id],
);
}
}
return { duplicate: false, handled: true };
});
}
async redeem(email: string, code: string): Promise<RedemptionResult> {
return transaction(this.pool, async (client) => {
const allowance = await client.query(
`insert into redemption_rate_limits (bucket, attempts)
values (date_trunc('minute', now()), 1)
on conflict (bucket) do update
set attempts = redemption_rate_limits.attempts + 1
where redemption_rate_limits.attempts < 60
returning attempts`,
);
if (allowance.rowCount === 0) {
throw new BillingError('redemption_rate_limited', 'Too many redemption attempts. Please try again shortly.', 429);
}
await client.query(
`delete from redemption_rate_limits where bucket < date_trunc('minute', now()) - interval '1 day'`,
);
const profileResult = await client.query<ProfileRecord>(
`select id, couple_id, is_premium from profiles
where lower(email) = lower($1)
order by created_at asc
limit 1
for update`,
[email],
);
const profile = profileResult.rows[0];
if (!profile) return { success: false, error: 'No account found with this email address' };
const codeResult = await client.query<CodeRecord>(
`select id, max_uses, current_uses, expires_at, is_active
from redemption_codes where upper(code) = upper($1) for update`,
[code],
);
const redemptionCode = codeResult.rows[0];
if (!redemptionCode) return { success: false, error: 'Invalid redemption code' };
if (!redemptionCode.is_active) return { success: false, error: 'This code is no longer active' };
if (redemptionCode.expires_at && redemptionCode.expires_at < new Date()) {
return { success: false, error: 'This code has expired' };
}
if (redemptionCode.max_uses !== null && redemptionCode.current_uses >= redemptionCode.max_uses) {
return { success: false, error: 'This code has reached its maximum number of uses' };
}
const existing = await client.query(
'select 1 from code_redemptions where code_id = $1 and user_id = $2',
[redemptionCode.id, profile.id],
);
if (existing.rowCount) return { success: false, error: 'You have already redeemed this code' };
await client.query(
'insert into code_redemptions (code_id, user_id) values ($1, $2)',
[redemptionCode.id, profile.id],
);
await client.query(
'update redemption_codes set current_uses = current_uses + 1, updated_at = now() where id = $1',
[redemptionCode.id],
);
await client.query(
'update profiles set is_premium = true, updated_at = now() where id = $1',
[profile.id],
);
return { success: true, message: 'Code redeemed successfully! You now have premium access.' };
});
}
async close(): Promise<void> {
await this.pool.end();
}
}
+49
View File
@@ -0,0 +1,49 @@
import { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import type { BillingService } from './service.js';
import { BillingError } from './types.js';
const redemptionSchema = z.object({
email: z.string().trim().min(1, 'Please enter your email address').max(320),
code: z.string().trim().min(1, 'Please enter a redemption code').max(255),
}).strict();
type ApiApp = Hono<{ Variables: { identity: AuthIdentity } }>;
export function registerBillingRoutes(app: ApiApp, service: BillingService): void {
app.use('/webhooks/revenuecat', bodyLimit({
maxSize: 1024 * 1024,
onError: (c) => c.json({ error: 'Webhook payload is too large' }, 413),
}));
app.post('/webhooks/revenuecat', async (c) => {
try {
service.assertWebhookAuthorization(c.req.header('authorization'));
const rawBody: unknown = await c.req.json().catch(() => null);
return c.json(await service.processWebhook(c.req.header('authorization'), rawBody));
} catch (cause) {
if (cause instanceof BillingError) return c.json({ error: cause.message }, cause.status);
throw cause;
}
});
app.use('/public/v1/redemptions', bodyLimit({
maxSize: 16 * 1024,
onError: (c) => c.json({ success: false, error: 'Request is too large' }, 413),
}));
app.post('/public/v1/redemptions', async (c) => {
try {
const rawBody: unknown = await c.req.json().catch(() => null);
const parsed = redemptionSchema.safeParse(rawBody);
if (!parsed.success) {
return c.json({ success: false, error: parsed.error.issues[0]?.message ?? 'Invalid request' }, 400);
}
const result = await service.redeem(parsed.data.email, parsed.data.code);
return c.json(result, result.success ? 200 : 400);
} catch (cause) {
if (cause instanceof BillingError) return c.json({ success: false, error: cause.message }, cause.status);
throw cause;
}
});
}
+61
View File
@@ -0,0 +1,61 @@
import { timingSafeEqual } from 'node:crypto';
import type { BillingRepository } from './repository.js';
import {
BillingError,
type BillingResult,
type RedemptionResult,
revenueCatWebhookSchema,
type SubscriptionStatus,
} from './types.js';
const statusByEvent: Record<string, SubscriptionStatus> = {
INITIAL_PURCHASE: 'active',
RENEWAL: 'active',
UNCANCELLATION: 'active',
PRODUCT_CHANGE: 'active',
CANCELLATION: 'cancelled',
EXPIRATION: 'expired',
BILLING_ISSUE: 'billing_issue',
SUBSCRIPTION_PAUSED: 'paused',
};
function secureEqual(actual: string, expected: string): boolean {
const actualBytes = Buffer.from(actual);
const expectedBytes = Buffer.from(expected);
return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);
}
export class BillingService {
constructor(
private readonly repository: BillingRepository,
private readonly revenueCatWebhookSecret: string | undefined,
) {}
assertWebhookAuthorization(authorization: string | undefined): void {
if (!this.revenueCatWebhookSecret) {
throw new BillingError('webhook_unavailable', 'Webhook is not configured', 503);
}
if (!authorization || !secureEqual(authorization, `Bearer ${this.revenueCatWebhookSecret}`)) {
throw new BillingError('unauthorized', 'Unauthorized', 401);
}
}
async processWebhook(authorization: string | undefined, rawPayload: unknown): Promise<BillingResult> {
this.assertWebhookAuthorization(authorization);
const parsed = revenueCatWebhookSchema.safeParse(rawPayload);
if (!parsed.success) throw new BillingError('invalid_payload', 'Invalid webhook payload', 400);
const payload = parsed.data;
const status = statusByEvent[payload.event.type] ?? null;
const result = await this.repository.processRevenueCatEvent(payload, status);
if (result.duplicate) return { success: true, message: 'Duplicate event' };
if (payload.event.type === 'TEST') return { success: true, message: 'Test event received' };
if (!result.handled) return { success: true, message: 'Event type not handled' };
return { success: true };
}
redeem(email: string, code: string): Promise<RedemptionResult> {
return this.repository.redeem(email.trim(), code.trim());
}
}
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod';
export const revenueCatWebhookSchema = z.object({
api_version: z.string().min(1),
event: z.object({
type: z.string().min(1),
id: z.string().min(1).max(255),
app_user_id: z.string().uuid(),
original_app_user_id: z.string().min(1),
product_id: z.string().min(1),
entitlement_ids: z.array(z.string()),
purchased_at_ms: z.number().int().nonnegative(),
expiration_at_ms: z.number().int().nonnegative().optional(),
original_transaction_id: z.string().min(1),
store: z.string().min(1),
environment: z.string().min(1),
cancel_reason: z.string().optional(),
grace_period_expiration_at_ms: z.number().int().nonnegative().optional(),
}).passthrough(),
}).passthrough();
export type RevenueCatWebhook = z.infer<typeof revenueCatWebhookSchema>;
export type SubscriptionStatus = 'active' | 'cancelled' | 'expired' | 'billing_issue' | 'paused';
export interface BillingResult {
success: true;
message?: string;
}
export type RedemptionResult =
| { success: true; message: string }
| { success: false; error: string };
export class BillingError extends Error {
constructor(
readonly code: string,
message: string,
readonly status: 400 | 401 | 404 | 429 | 503,
) {
super(message);
this.name = 'BillingError';
}
}
+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)]);
+204
View File
@@ -0,0 +1,204 @@
import type { Couple, Profile } from '@sauci/shared';
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { CoupleError, type CoupleStateResponse } from './types.js';
interface ProfileRecord extends QueryResultRow {
id: string;
name: string | null;
email: string | null;
avatar_url: string | null;
push_token: string | null;
is_premium: boolean;
couple_id: string | null;
gender: Profile['gender'];
show_explicit_content: boolean;
max_intensity: Profile['max_intensity'];
public_key_jwk: Record<string, unknown> | null;
hide_nsfw: boolean;
onboarding_completed: boolean;
onboarding_version: number;
created_at: Date;
updated_at: Date;
}
interface CoupleRecord extends QueryResultRow {
id: string;
invite_code: string;
created_at: Date;
}
export interface CoupleRepository {
getState(userId: string): Promise<CoupleStateResponse>;
create(userId: string, coupleId: string, inviteCode: string): Promise<Couple>;
join(userId: string, inviteCode: string): Promise<Couple>;
cancel(userId: string): Promise<void>;
close(): Promise<void>;
}
function toCouple(row: CoupleRecord): Couple {
return {
id: row.id,
invite_code: row.invite_code,
created_at: row.created_at.toISOString(),
};
}
function toProfile(row: ProfileRecord): Profile {
return {
id: row.id,
name: row.name,
email: row.email,
avatar_url: row.avatar_url,
push_token: row.push_token,
is_premium: row.is_premium,
couple_id: row.couple_id,
gender: row.gender,
show_explicit_content: row.show_explicit_content,
max_intensity: row.max_intensity,
public_key_jwk: row.public_key_jwk,
hide_nsfw: row.hide_nsfw,
onboarding_completed: row.onboarding_completed,
onboarding_version: row.onboarding_version,
created_at: row.created_at.toISOString(),
updated_at: row.updated_at.toISOString(),
};
}
async function profileForUpdate(client: PoolClient, userId: string): Promise<{ couple_id: string | null }> {
const result = await client.query<{ couple_id: string | null }>(
'select couple_id from profiles where id = $1 for update',
[userId],
);
const profile = result.rows[0];
if (!profile) {
throw new CoupleError('profile_not_found', 'Profile not found. Please complete signup first.', 404);
}
return profile;
}
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try {
await client.query('begin');
const result = await work(client);
await client.query('commit');
return result;
} catch (error) {
await client.query('rollback');
throw error;
} finally {
client.release();
}
}
export class PostgresCoupleRepository implements CoupleRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
}
async getState(userId: string): Promise<CoupleStateResponse> {
const profileResult = await this.pool.query<{ couple_id: string | null }>(
'select couple_id from profiles where id = $1',
[userId],
);
const profile = profileResult.rows[0];
if (!profile) {
throw new CoupleError('profile_not_found', 'Profile not found. Please complete signup first.', 404);
}
if (!profile.couple_id) return { couple: null, partner: null };
const [coupleResult, partnerResult] = await Promise.all([
this.pool.query<CoupleRecord>(
'select id, invite_code, created_at from couples where id = $1',
[profile.couple_id],
),
this.pool.query<ProfileRecord>(
`select id, name, email, avatar_url, push_token, is_premium, couple_id, gender,
show_explicit_content, max_intensity, public_key_jwk, hide_nsfw,
onboarding_completed, onboarding_version, created_at, updated_at
from profiles
where couple_id = $1 and id <> $2
limit 1`,
[profile.couple_id, userId],
),
]);
const couple = coupleResult.rows[0];
if (!couple) return { couple: null, partner: null };
return {
couple: toCouple(couple),
partner: partnerResult.rows[0] ? toProfile(partnerResult.rows[0]) : null,
};
}
async create(userId: string, coupleId: string, inviteCode: string): Promise<Couple> {
return transaction(this.pool, async (client) => {
const profile = await profileForUpdate(client, userId);
if (profile.couple_id) {
throw new CoupleError('already_paired', 'You are already in a couple', 409);
}
try {
const result = await client.query<CoupleRecord>(
'insert into couples (id, invite_code) values ($1, $2) returning id, invite_code, created_at',
[coupleId, inviteCode],
);
await client.query('update profiles set couple_id = $1, updated_at = now() where id = $2', [coupleId, userId]);
return toCouple(result.rows[0]!);
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && error.code === '23505') {
throw new CoupleError('invite_code_collision', 'Invite code collision', 409);
}
throw error;
}
});
}
async join(userId: string, inviteCode: string): Promise<Couple> {
return transaction(this.pool, async (client) => {
const profile = await profileForUpdate(client, userId);
if (profile.couple_id) {
throw new CoupleError('already_paired', 'You are already in a couple', 409);
}
const coupleResult = await client.query<CoupleRecord>(
'select id, invite_code, created_at from couples where invite_code = $1 for update',
[inviteCode],
);
const couple = coupleResult.rows[0];
if (!couple) {
throw new CoupleError('invalid_invite_code', 'Invalid invite code', 404);
}
const memberResult = await client.query<{ count: string }>(
'select count(*)::text as count from profiles where couple_id = $1',
[couple.id],
);
if (Number(memberResult.rows[0]?.count ?? 0) >= 2) {
throw new CoupleError('couple_full', 'This couple already has two partners', 409);
}
await client.query('update profiles set couple_id = $1, updated_at = now() where id = $2', [couple.id, userId]);
return toCouple(couple);
});
}
async cancel(userId: string): Promise<void> {
await transaction(this.pool, async (client) => {
const profile = await profileForUpdate(client, userId);
if (!profile.couple_id) {
throw new CoupleError('not_paired', 'You are not in a couple', 400);
}
await client.query('select id from couples where id = $1 for update', [profile.couple_id]);
await client.query(
'update profiles set couple_id = null, updated_at = now() where couple_id = $1',
[profile.couple_id],
);
await client.query('delete from couples where id = $1', [profile.couple_id]);
});
}
async close(): Promise<void> {
await this.pool.end();
}
}
+53
View File
@@ -0,0 +1,53 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { CoupleService } from './service.js';
import { CoupleError } from './types.js';
type Variables = { identity: AuthIdentity };
type AuthenticatedApp = Hono<{ Variables: Variables }>;
const joinBody = z.object({ invite_code: z.string() }).strict();
function error(code: string, message: string): ApiErrorResponse {
return { error: { code, message } };
}
async function route<T>(operation: () => Promise<T>): Promise<
{ ok: true; value: T } | { ok: false; body: ApiErrorResponse; status: 400 | 404 | 409 }
> {
try {
return { ok: true, value: await operation() };
} catch (cause) {
if (cause instanceof CoupleError) {
return { ok: false, body: error(cause.code, cause.message), status: cause.status };
}
throw cause;
}
}
export function registerCoupleRoutes(app: AuthenticatedApp, service: CoupleService): void {
app.get('/v1/couple', async (c) => {
const result = await route(() => service.getState(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
app.post('/v1/couple', async (c) => {
const rawBody: unknown = await c.req.json().catch(() => ({}));
if (typeof rawBody === 'object' && rawBody !== null && Object.keys(rawBody).length === 0) {
const result = await route(() => service.create(c.get('identity').id));
return result.ok ? c.json(result.value, 201) : c.json(result.body, result.status);
}
const parsed = joinBody.safeParse(rawBody);
if (!parsed.success) return c.json(error('invalid_invite_code', 'A valid invite_code is required'), 400);
const result = await route(() => service.join(c.get('identity').id, parsed.data.invite_code));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
app.delete('/v1/couple', async (c) => {
const result = await route(() => service.cancel(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(result.body, result.status);
});
}
+13
View File
@@ -0,0 +1,13 @@
import { text, timestamp, uuid, pgTable } from 'drizzle-orm/pg-core';
/**
* Domain-owned schema fragment. The root API schema can re-export this table
* when the couples vertical is registered with the application.
*/
export const couples = pgTable('couples', {
id: uuid('id').primaryKey(),
inviteCode: text('invite_code').notNull().unique(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export type CoupleRow = typeof couples.$inferSelect;
+44
View File
@@ -0,0 +1,44 @@
import { randomBytes, randomUUID } from 'node:crypto';
import type { CoupleRepository } from './repository.js';
import { CoupleError, type CoupleMutationResponse, type CoupleStateResponse } from './types.js';
const inviteAlphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
export function generateInviteCode(bytes: Uint8Array = randomBytes(8)): string {
return Array.from(bytes, (byte) => inviteAlphabet[byte % inviteAlphabet.length]).join('').slice(0, 8);
}
export class CoupleService {
constructor(private readonly repository: CoupleRepository) {}
getState(userId: string): Promise<CoupleStateResponse> {
return this.repository.getState(userId);
}
async create(userId: string): Promise<CoupleMutationResponse> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const inviteCode = generateInviteCode();
try {
const couple = await this.repository.create(userId, randomUUID(), inviteCode);
return { success: true, couple_id: couple.id, invite_code: couple.invite_code };
} catch (error) {
if (!(error instanceof CoupleError) || error.code !== 'invite_code_collision') throw error;
}
}
throw new Error('Unable to allocate a unique invite code');
}
async join(userId: string, inviteCode: string): Promise<CoupleMutationResponse> {
const canonicalCode = inviteCode.trim().toUpperCase();
if (!/^[A-Z0-9]{8}$/.test(canonicalCode)) {
throw new CoupleError('invalid_invite_code', 'Invalid invite code format', 400);
}
const couple = await this.repository.join(userId, canonicalCode);
return { success: true, couple_id: couple.id };
}
async cancel(userId: string): Promise<CoupleMutationResponse> {
await this.repository.cancel(userId);
return { success: true, couple_id: null };
}
}
+31
View File
@@ -0,0 +1,31 @@
import type { Couple, Profile } from '@sauci/shared';
export interface CoupleStateResponse {
couple: Couple | null;
partner: Profile | null;
}
export interface CoupleMutationResponse {
success: true;
couple_id: string | null;
invite_code?: string;
}
export type CoupleErrorCode =
| 'profile_not_found'
| 'already_paired'
| 'invalid_invite_code'
| 'couple_full'
| 'not_paired'
| 'invite_code_collision';
export class CoupleError extends Error {
constructor(
readonly code: CoupleErrorCode,
message: string,
readonly status: 400 | 404 | 409,
) {
super(message);
this.name = 'CoupleError';
}
}
+5
View File
@@ -0,0 +1,5 @@
export * from './repository.js';
export * from './janitor.js';
export * from './routes.js';
export * from './storage.js';
export * from './types.js';
+20
View File
@@ -0,0 +1,20 @@
import type { MediaRepository } from './repository.js';
import type { FilesystemMediaStorage } from './storage.js';
export class MediaJanitor {
private timer: NodeJS.Timeout | null = null;
constructor(private readonly repository: MediaRepository, private readonly storage: FilesystemMediaStorage) {}
async runOnce(limit=100): Promise<number> {
const keys=await this.repository.queuedDeletions(limit); let removed=0;
for(const key of keys) {
try { await this.storage.remove(key); await this.repository.acknowledgeDeletion(key); removed++; }
catch(cause) { console.error('Media cleanup failed', { storageKey:key, cause }); }
}
return removed;
}
start(intervalMs=60_000) {
if(this.timer) return; void this.runOnce();
this.timer=setInterval(()=>void this.runOnce(),intervalMs); this.timer.unref();
}
stop() { if(this.timer) clearInterval(this.timer); this.timer=null; }
}
+106
View File
@@ -0,0 +1,106 @@
import { randomUUID } from 'node:crypto';
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import type { MediaKind, MediaObject, MediaUploadContext } from './types.js';
interface MediaRow extends QueryResultRow {
id: string; owner_id: string; couple_id: string | null; kind: MediaKind;
storage_key: string; mime_type: string; byte_size: number; question_id: string | null;
match_id: string | null; expires_at: Date | null; deleted_at: Date | null;
}
export class MediaError extends Error {
constructor(readonly code: string, message: string, readonly status: 400 | 403 | 404 | 409) { super(message); }
}
export interface MediaRepository {
create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext): Promise<{ media: MediaObject; message?: Record<string, unknown> }>;
accessible(userId: string, mediaId: string): Promise<MediaObject>;
byId(mediaId: string): Promise<MediaObject | null>;
queuedDeletions(limit: number): Promise<string[]>;
acknowledgeDeletion(storageKey: string): Promise<void>;
close(): Promise<void>;
}
const columns = 'id, owner_id, couple_id, kind, storage_key, mime_type, byte_size, question_id, match_id, expires_at, deleted_at';
function object(row: MediaRow): MediaObject {
return { ...row, expires_at: row.expires_at?.toISOString() ?? null };
}
export class PostgresMediaRepository implements MediaRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
async create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext) {
const client = await this.pool.connect();
try {
await client.query('begin');
const profile = await client.query<{ couple_id: string | null }>('select couple_id from profiles where id=$1 for update', [userId]);
if (!profile.rows[0]) throw new MediaError('profile_not_found', 'Profile not found', 404);
const coupleId = profile.rows[0].couple_id;
if ((kind === 'response' || kind === 'chat') && !coupleId) throw new MediaError('couple_required', 'Join a couple before sharing media', 409);
if (kind === 'response') {
if (!context.questionId) throw new MediaError('question_required', 'A question ID is required', 400);
const question = await client.query('select 1 from questions where id=$1 and deleted_at is null', [context.questionId]);
if (!question.rowCount) throw new MediaError('question_not_found', 'Question not found', 404);
}
if (kind === 'chat') {
if (!context.matchId) throw new MediaError('match_required', 'A match ID is required', 400);
const match = await client.query('select 1 from matches where id=$1 and couple_id=$2', [context.matchId, coupleId]);
if (!match.rowCount) throw new MediaError('match_not_found', 'Match not found', 404);
}
const id = randomUUID();
const inserted = await client.query<MediaRow>(
`insert into media_objects(id,owner_id,couple_id,kind,storage_key,mime_type,byte_size,question_id,match_id)
values($1,$2,$3,$4,$5,$6,$7,$8,$9) returning ${columns}`,
[id, userId, coupleId, kind, storageKey, mimeType, byteSize, context.questionId ?? null, context.matchId ?? null],
);
let message: Record<string, unknown> | undefined;
if (kind === 'chat') {
const mediaType = mimeType.startsWith('video/') ? 'video' : 'image';
const result = await client.query<Record<string, unknown> & QueryResultRow>(
`insert into messages(match_id,user_id,content,media_path,media_type,media_expired,version)
values($1,$2,$3,$4,$5,false,1) returning *`,
[context.matchId, userId, mediaType === 'video' ? 'Sent a video' : 'Sent an image', `media:${id}`, mediaType],
);
message = result.rows[0];
}
if (kind === 'avatar') {
await client.query('update profiles set avatar_url=$2, updated_at=now() where id=$1', [userId, `media:${id}`]);
await client.query(`delete from media_objects where owner_id=$1 and kind='avatar' and id<>$2`, [userId, id]);
}
await client.query('commit');
return { media: object(inserted.rows[0]!), message };
} catch (cause) {
await client.query('rollback');
throw cause;
} finally { client.release(); }
}
async accessible(userId: string, mediaId: string): Promise<MediaObject> {
const result = await this.pool.query<MediaRow>(
`select mo.${columns.split(', ').join(', mo.')}
from media_objects mo join profiles viewer on viewer.id=$1
where mo.id=$2 and mo.deleted_at is null and (mo.expires_at is null or mo.expires_at>now())
and not exists(select 1 from messages m where m.media_path='media:'||mo.id::text and (m.media_expired or m.media_expires_at<=now()))
and (mo.owner_id=$1 or (mo.kind in ('avatar','response','chat') and mo.couple_id is not null and mo.couple_id=viewer.couple_id))`,
[userId, mediaId],
);
if (!result.rows[0]) throw new MediaError('media_not_found', 'Media not found', 404);
return object(result.rows[0]);
}
async byId(mediaId: string): Promise<MediaObject | null> {
const result = await this.pool.query<MediaRow>(`select ${columns} from media_objects mo where id=$1 and deleted_at is null and (expires_at is null or expires_at>now())
and not exists(select 1 from messages m where m.media_path='media:'||mo.id::text and (m.media_expired or m.media_expires_at<=now()))`, [mediaId]);
return result.rows[0] ? object(result.rows[0]) : null;
}
async queuedDeletions(limit: number): Promise<string[]> {
await this.pool.query(`delete from media_objects where expires_at<=now()`);
const result=await this.pool.query<{storage_key:string}>('select storage_key from media_deletion_queue order by queued_at limit $1',[limit]);
return result.rows.map(row=>row.storage_key);
}
async acknowledgeDeletion(storageKey: string): Promise<void> {
await this.pool.query('delete from media_deletion_queue where storage_key=$1',[storageKey]);
}
async close() { await this.pool.end(); }
}
+43
View File
@@ -0,0 +1,43 @@
import type { ApiErrorResponse } from '@sauci/shared';
import type { Hono } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { z } from 'zod';
import type { AuthIdentity } from '../../auth.js';
import { MediaError, type MediaRepository } from './repository.js';
import { FilesystemMediaStorage } from './storage.js';
import { mediaKinds } from './types.js';
type App = Hono<{ Variables: { identity: AuthIdentity } }>;
const uuid=z.string().uuid(); const kind=z.enum(mediaKinds);
const err=(code:string,message:string):ApiErrorResponse=>({error:{code,message}});
export function registerMediaRoutes(app: App, repository: MediaRepository, storage: FilesystemMediaStorage) {
app.use('/v1/media/*', bodyLimit({maxSize:50*1024*1024,onError:c=>c.json(err('upload_too_large','Upload exceeds the size limit'),413)}));
app.post('/v1/media/:kind', async c => {
const parsedKind=kind.safeParse(c.req.param('kind')); if(!parsedKind.success) return c.json(err('invalid_media_kind','Unsupported media kind'),400);
const mime=(c.req.header('content-type')??'').split(';')[0]!.trim().toLowerCase();
const lengthHeader=c.req.header('content-length'); const length=lengthHeader ? Number(lengthHeader) : null;
try { storage.validate(parsedKind.data,mime,length); } catch(e) { const code=e instanceof Error?e.message:'invalid_upload'; return c.json(err(code,code==='upload_too_large'?'Upload exceeds the size limit':'Unsupported or invalid media'),code==='upload_too_large'?413:415); }
const questionId=c.req.query('questionId'); const matchId=c.req.query('matchId');
if(questionId&&!uuid.safeParse(questionId).success) return c.json(err('invalid_question_id','A valid question ID is required'),400);
if(matchId&&!uuid.safeParse(matchId).success) return c.json(err('invalid_match_id','A valid match ID is required'),400);
const bytes=new Uint8Array(await c.req.arrayBuffer());
let stored: {key:string;size:number};
try { stored=await storage.put(parsedKind.data,mime,bytes); } catch(e) { const code=e instanceof Error?e.message:'invalid_upload'; return c.json(err(code,code==='upload_too_large'?'Upload exceeds the size limit':'File contents do not match the declared media type'),code==='upload_too_large'?413:415); }
try {
const result=await repository.create(c.get('identity').id,parsedKind.data,stored.key,mime,stored.size,{questionId,matchId});
return c.json({...result, reference:`media:${result.media.id}`},201);
} catch(e) { await storage.remove(stored.key); if(e instanceof MediaError) return c.json(err(e.code,e.message),e.status); throw e; }
});
app.get('/v1/media/:mediaId/url', async c => {
const parsed=uuid.safeParse(c.req.param('mediaId')); if(!parsed.success) return c.json(err('invalid_media_id','A valid media ID is required'),400);
try { await repository.accessible(c.get('identity').id,parsed.data); return c.json(storage.signedUrl(parsed.data)); }
catch(e) { if(e instanceof MediaError) return c.json(err(e.code,e.message),e.status); throw e; }
});
app.get('/media/:mediaId/content', async c => {
const parsed=uuid.safeParse(c.req.param('mediaId')); const expires=Number(c.req.query('expires')); const signature=c.req.query('signature')??'';
if(!parsed.success||!storage.verify(parsed.data,expires,signature)) return c.json(err('invalid_media_signature','Media URL is invalid or expired'),403);
const media=await repository.byId(parsed.data); if(!media) return c.json(err('media_not_found','Media not found'),404);
const bytes=await storage.read(media.storage_key).catch(()=>null); if(!bytes) return c.json(err('media_not_found','Media not found'),404);
return new Response(bytes,{headers:{'Content-Type':media.mime_type,'Content-Length':String(bytes.byteLength),'Cache-Control':'private, max-age=300','X-Content-Type-Options':'nosniff'}});
});
}
+64
View File
@@ -0,0 +1,64 @@
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
import { extname, resolve, sep } from 'node:path';
import type { MediaKind } from './types.js';
const allowed: Record<MediaKind, ReadonlySet<string>> = {
avatar: new Set(['image/jpeg', 'image/png', 'image/webp']),
response: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'audio/mp4', 'audio/m4a', 'audio/x-m4a', 'audio/mpeg', 'audio/wav', 'audio/webm', 'audio/aac', 'audio/x-caf']),
chat: new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'video/mp4', 'video/quicktime', 'video/webm']),
feedback: new Set(['image/jpeg', 'image/png', 'image/webp']),
};
const limits: Record<MediaKind, number> = { avatar: 10*1024*1024, response: 25*1024*1024, chat: 50*1024*1024, feedback: 10*1024*1024 };
const extensions: Record<string, string> = { 'image/jpeg':'.jpg','image/png':'.png','image/webp':'.webp','image/gif':'.gif','video/mp4':'.mp4','video/quicktime':'.mov','video/webm':'.webm','audio/mp4':'.m4a','audio/m4a':'.m4a','audio/x-m4a':'.m4a','audio/mpeg':'.mp3','audio/wav':'.wav','audio/webm':'.webm','audio/aac':'.aac','audio/x-caf':'.caf' };
export class FilesystemMediaStorage {
constructor(readonly root: string, private readonly signingSecret: string, private readonly publicBaseUrl: string) {}
validate(kind: MediaKind, mime: string, length: number | null) {
if (!allowed[kind].has(mime)) throw new Error('unsupported_media_type');
if (length !== null && (length < 1 || length > limits[kind])) throw new Error('upload_too_large');
}
private path(key: string) {
if (key.includes('..') || key.includes('\\') || key.startsWith('/')) throw new Error('invalid_storage_key');
const root = resolve(this.root); const candidate = resolve(root, key);
if (!candidate.startsWith(`${root}${sep}`)) throw new Error('invalid_storage_key');
return candidate;
}
async put(kind: MediaKind, mime: string, bytes: Uint8Array): Promise<{ key: string; size: number }> {
this.validate(kind, mime, bytes.byteLength);
if (!matchesMagic(mime, bytes)) throw new Error('media_signature_mismatch');
const date = new Date().toISOString().slice(0,10); const id = randomUUID();
const key = `${kind}/${date}/${id}${extensions[mime]}`; const target = this.path(key); const temp = this.path(`tmp/${id}.upload`);
await mkdir(resolve(this.root, 'tmp'), { recursive: true }); await mkdir(resolve(this.root, kind, date), { recursive: true });
await writeFile(temp, bytes, { flag: 'wx' }); await rename(temp, target);
return { key, size: bytes.byteLength };
}
async remove(key: string) {
try { await unlink(this.path(key)); }
catch (cause) { if (!(typeof cause==='object'&&cause!==null&&'code' in cause&&cause.code==='ENOENT')) throw cause; }
}
async read(key: string) { return readFile(this.path(key)); }
signedUrl(id: string, ttlSeconds=3600) {
const expires = Math.floor(Date.now()/1000)+ttlSeconds; const signature = this.sign(id, expires);
return { url: `${this.publicBaseUrl}/media/${id}/content?expires=${expires}&signature=${signature}`, expires_at: new Date(expires*1000).toISOString() };
}
verify(id: string, expires: number, signature: string) {
if (!Number.isSafeInteger(expires) || expires < Math.floor(Date.now()/1000)) return false;
const expected = Buffer.from(this.sign(id, expires)); const actual = Buffer.from(signature);
return expected.length === actual.length && timingSafeEqual(expected, actual);
}
private sign(id: string, expires: number) { return createHmac('sha256', this.signingSecret).update(`${id}.${expires}`).digest('base64url'); }
}
function matchesMagic(mime: string, bytes: Uint8Array) {
const b=Buffer.from(bytes); if (mime==='image/jpeg') return b[0]===0xff&&b[1]===0xd8&&b[2]===0xff;
if (mime==='image/png') return b.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10]));
if (mime==='image/gif') return b.subarray(0,4).toString()==='GIF8';
if (mime==='image/webp'||mime==='video/webm'||mime==='audio/webm') return mime==='image/webp' ? b.subarray(0,4).toString()==='RIFF'&&b.subarray(8,12).toString()==='WEBP' : b.subarray(0,4).equals(Buffer.from([0x1a,0x45,0xdf,0xa3]));
if (mime.startsWith('video/')||mime.includes('mp4')||mime.includes('m4a')) return b.subarray(4,8).toString()==='ftyp';
if (mime==='audio/mpeg') return (b[0]===0x49&&b[1]===0x44&&b[2]===0x33)||(b[0]===0xff&&(b[1]&0xe0)===0xe0);
if (mime==='audio/wav') return b.subarray(0,4).toString()==='RIFF'&&b.subarray(8,12).toString()==='WAVE';
if (mime==='audio/aac') return b[0]===0xff&&(b[1]&0xf6)===0xf0;
if (mime==='audio/x-caf') return b.subarray(0,4).toString()==='caff';
return false;
}
+21
View File
@@ -0,0 +1,21 @@
export const mediaKinds = ['avatar', 'response', 'chat', 'feedback'] as const;
export type MediaKind = typeof mediaKinds[number];
export interface MediaObject {
id: string;
owner_id: string;
couple_id: string | null;
kind: MediaKind;
storage_key: string;
mime_type: string;
byte_size: number;
question_id: string | null;
match_id: string | null;
expires_at: string | null;
}
export interface MediaUploadContext {
questionId?: string;
matchId?: string;
}
+30
View File
@@ -0,0 +1,30 @@
# Operations worker
`src/workers/index.ts` is the runtime for scheduled and asynchronous operations.
It polls every 30 seconds by default, bounds each claim, and uses a Postgres
advisory lock for producers plus `FOR UPDATE SKIP LOCKED` for delivery. Provider
work is represented by an `operations_outbox` row with a unique dedupe key.
Failed work retries with exponential backoff up to five attempts.
The worker replaces scheduled releases, streak milestones, session/match
digests, message notifications, pack-change notifications, weekly summaries,
unpaired and catch-up reminders, message classification, and product-used
Discord events. The older one-hour partner-activity sender and generic match
sender are consolidated into the five-minute session digest because the legacy
pair produced duplicate notifications for the same response session.
Pack writes must call the local SQL function
`queue_pack_change(couple_id, authenticated_user_id)` after enabling a pack.
The function verifies couple membership and applies the 30-minute debounce.
Classification defaults on. Production startup fails closed without
`OPENROUTER_API_KEY`; encrypted messages additionally require
`ADMIN_PRIVATE_KEY_JWK`. Provider calls are bounded and have timeouts. Expo
ticket-level errors are failures, and each notification includes the stable
outbox ID as `notification_id` for client deduplication.
The worker invokes the existing filesystem `MediaJanitor`; it does not recreate
the legacy Supabase Storage cleanup. Run only one janitor owner in production.
When the worker service is enabled, disable the API-process janitor to avoid
redundant scans. Deletion remains idempotent if both briefly overlap at rollout.
+5
View File
@@ -0,0 +1,5 @@
export * from './providers.js';
export * from './repository.js';
export * from './runner.js';
export * from './types.js';
@@ -0,0 +1,98 @@
import { readFile } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import type { OperationsRepository } from './repository.js';
import type { OperationItem } from './types.js';
type Fetch = typeof globalThis.fetch;
export class ExpoPushProvider {
constructor(private readonly request:Fetch=globalThis.fetch) {}
async send(item:OperationItem):Promise<void> {
if (!item.pushToken) return;
const title=typeof item.payload.title==='string'?item.payload.title:null;
const body=typeof item.payload.body==='string'?item.payload.body:null;
if (!title||!body) throw new Error('invalid_expo_payload');
const data=typeof item.payload.data==='object'&&item.payload.data?item.payload.data:{};
const response=await this.request('https://exp.host/--/api/v2/push/send',{method:'POST',headers:{'content-type':'application/json',accept:'application/json'},
body:JSON.stringify({to:item.pushToken,title,body,sound:'default',data:{...data,notification_id:item.id}}),signal:AbortSignal.timeout(10_000)});
if(!response.ok) throw new Error(`expo_http_${response.status}`);
const result=await response.json().catch(()=>null) as {data?:{status?:string;message?:string}}|null;
if(result?.data?.status==='error') throw new Error(`expo_ticket_${result.data.message??'error'}`);
}
}
export class DiscordProvider {
constructor(private readonly webhookUrl:string|undefined,private readonly request:Fetch=globalThis.fetch) {}
async send(item:OperationItem):Promise<void> {
if(!this.webhookUrl) return;
const event=typeof item.payload.event==='string'?item.payload.event:'operation';
const response=await this.request(this.webhookUrl,{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({username:'Sauci Notifications',content:event.replaceAll('_',' '),embeds:[{title:event.replaceAll('_',' '),description:formatPayload(item.payload),timestamp:new Date().toISOString()}]}),
signal:AbortSignal.timeout(10_000)});
if(!response.ok) throw new Error(`discord_http_${response.status}`);
}
}
function formatPayload(payload:Record<string,unknown>):string {
return Object.entries(payload).filter(([key])=>key!=='event').map(([key,value])=>`${key}: ${String(value??'')}`).join('\n').slice(0,1800);
}
const whitelist=new Set(['ok','okay','yes','no','maybe','thanks','thank you','lol','lmao','good night','goodnight','good morning','love you']);
const keywords=['suicide','kill myself','self harm','kill you','hurt you','rape','stab','shoot'];
export class MessageClassifier {
constructor(private readonly repository:OperationsRepository,private readonly options:{enabled:boolean;apiKey?:string;model:string;prompt:string;adminPrivateKeyJwk?:string;mediaRoot:string},private readonly request:Fetch=globalThis.fetch) {}
async classify(item:OperationItem):Promise<void> {
const saved=await this.repository.classifierConfig();
if(!(saved?.enabled ?? this.options.enabled)) return;
const messageId=typeof item.payload.message_id==='string'?item.payload.message_id:null;
if(!messageId) throw new Error('missing_message_id');
const message=await this.repository.message(messageId); if(!message) return;
let text=message.content??''; let aesKey:CryptoKey|null=null;
const getKey=async()=>{
if(aesKey)return aesKey;
const wrapped=message.keysMetadata?.admin_wrapped_key;
if(!wrapped||!this.options.adminPrivateKeyJwk) throw new Error('admin_decryption_key_unavailable');
const privateKey=await crypto.subtle.importKey('jwk',JSON.parse(this.options.adminPrivateKeyJwk) as JsonWebKey,{name:'RSA-OAEP',hash:'SHA-256'},false,['decrypt']);
const raw=await crypto.subtle.decrypt({name:'RSA-OAEP'},privateKey,Buffer.from(wrapped,'base64'));
aesKey=await crypto.subtle.importKey('raw',raw,{name:'AES-GCM'},false,['decrypt']); return aesKey;
};
if(message.version===2&&message.encryptedContent) {
if(!message.encryptionIv)throw new Error('missing_encryption_iv'); const key=await getKey();
const plain=await crypto.subtle.decrypt({name:'AES-GCM',iv:Buffer.from(message.encryptionIv,'base64')},key,Buffer.from(message.encryptedContent,'base64'));
text=new TextDecoder().decode(plain);
}
let media:string|null=null;
if(message.mediaStorageKey&&message.mediaMimeType?.startsWith('image/')) {
const root=resolve(this.options.mediaRoot);const path=resolve(root,message.mediaStorageKey);
if(!path.startsWith(`${root}${sep}`))throw new Error('invalid_media_path'); let bytes=await readFile(path);
if(message.version===2){if(!message.encryptionIv)throw new Error('missing_encryption_iv');const key=await getKey();bytes=Buffer.from(await crypto.subtle.decrypt({name:'AES-GCM',iv:Buffer.from(message.encryptionIv,'base64')},key,bytes));}
media=`data:${message.mediaMimeType};base64,${bytes.toString('base64')}`;
}
const normalized=text.trim().toLowerCase();
const customWhitelist=(saved?.heuristicWhitelist??'').split(/[\n,]/).map(value=>value.trim().toLowerCase()).filter(Boolean);
const customKeywords=(saved?.heuristicKeywordTriggers??'').split(/[\n,]/).map(value=>value.trim().toLowerCase()).filter(Boolean);
const allowed=new Set([...(saved?.heuristicUseDefaultWhitelist===false?[]:whitelist),...customWhitelist]);
const triggers=[...(saved?.heuristicUseDefaultKeywords===false?[]:keywords),...customKeywords];
const minLength=saved?.heuristicMinTextLength??12;
const whitelistMax=saved?.heuristicWhitelistMaxLength??30;
const noAlphaNumeric=!/[a-z0-9]/i.test(normalized);
const heuristicSafe=!media&&(
(allowed.has(normalized)&&normalized.length<=whitelistMax)||
(saved?.heuristicSkipIfNoAlnum!==false&&noAlphaNumeric)||
(!triggers.some(k=>normalized.includes(k))&&normalized.length<minLength)
)||(Boolean(media)&&!normalized&&saved?.heuristicSkipMediaWithoutText===true);
if((saved?.heuristicsEnabled??true)&&heuristicSafe) {
await this.repository.classify(messageId,'safe',null,'Neutral'); return;
}
const apiKey=saved?.apiKey??this.options.apiKey;
if(!apiKey)throw new Error('openrouter_api_key_unavailable');
const content:Array<Record<string,unknown>>=[];if(text.trim())content.push({type:'text',text});if(media)content.push({type:'image_url',image_url:{url:media}});
if(!content.length){await this.repository.classify(messageId,'safe','No content','Neutral');return;}
const response=await this.request('https://openrouter.ai/api/v1/chat/completions',{method:'POST',headers:{authorization:`Bearer ${apiKey}`,'content-type':'application/json','http-referer':'https://sauci.app','x-title':'Sauci'},
body:JSON.stringify({model:saved?.model??this.options.model,temperature:saved?.temperature??undefined,messages:[{role:'system',content:saved?.prompt??this.options.prompt},{role:'user',content}],response_format:{type:'json_object'}}),signal:AbortSignal.timeout(30_000)});
if(!response.ok)throw new Error(`openrouter_http_${response.status}`);
const result=await response.json() as {choices?:Array<{message?:{content?:string}}>};const raw=result.choices?.[0]?.message?.content;if(!raw)throw new Error('empty_classifier_response');
let parsed:{status?:string;reason?:string;category?:string};try{parsed=JSON.parse(raw) as typeof parsed;}catch{parsed={status:raw.toLowerCase().includes('flagged')?'flagged':'safe',reason:'Classifier returned invalid JSON'};}
await this.repository.classify(messageId,parsed.status==='flagged'?'flagged':'safe',parsed.reason??null,parsed.category??'Neutral');
}
}
@@ -0,0 +1,255 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import type { ClassifierRuntimeConfig, MessageForClassification, OperationItem, ProducerSummary } from './types.js';
interface OutboxRow extends QueryResultRow {
id: string; kind: OperationItem['kind']; dedupe_key: string; recipient_id: string | null;
push_token: string | null; payload: Record<string, unknown>; attempts: number;
}
export interface OperationsRepository {
produce(now: Date, limit?: number): Promise<ProducerSummary>;
claim(limit?: number): Promise<OperationItem[]>;
complete(id: string): Promise<void>;
fail(id: string, message: string): Promise<void>;
message(id: string): Promise<MessageForClassification | null>;
classifierConfig(): Promise<ClassifierRuntimeConfig | null>;
classify(id: string, status: 'safe' | 'flagged', reason: string | null, category: string): Promise<void>;
close(): Promise<void>;
}
function item(row: OutboxRow): OperationItem {
return { id: row.id, kind: row.kind, dedupeKey: row.dedupe_key, recipientId: row.recipient_id,
pushToken: row.push_token, payload: row.payload, attempts: row.attempts };
}
async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect();
try { await client.query('begin'); const value=await work(client); await client.query('commit'); return value; }
catch(cause) { await client.query('rollback'); throw cause; }
finally { client.release(); }
}
export class PostgresOperationsRepository implements OperationsRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
async produce(now: Date, limit=100): Promise<ProducerSummary> {
return transaction(this.pool, async (client) => {
const lock=await client.query<{locked:boolean}>("select pg_try_advisory_xact_lock(hashtext('sauci-operations-producers')) locked");
const empty={releasedPacks:0,streakMilestones:0,digests:0,packChanges:0,weeklySummaries:0,unpairedReminders:0,catchupReminders:0};
if (!lock.rows[0]?.locked) return empty;
const released=await client.query<{id:string;name:string}>(
`with candidates as (
select id from question_packs where is_public=false and release_notified=false
and scheduled_release_at is not null and scheduled_release_at<=$1
order by scheduled_release_at for update skip locked limit $2
) update question_packs qp set is_public=true,release_notified=true
from candidates c where qp.id=c.id returning qp.id,qp.name`, [now,limit]);
for (const pack of released.rows) {
await client.query(
`insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
select 'expo','new_pack:'||$1||':'||p.id,p.id,
jsonb_build_object('title','New pack available','body',$2||' is now available to play!',
'data',jsonb_build_object('type','new_pack','packId',$1))
from profiles p left join notification_preferences np on np.user_id=p.id
where p.push_token is not null and coalesce(np.new_packs_enabled,true)
on conflict(dedupe_key) do nothing`, [pack.id,pack.name]);
}
const milestones=await client.query<{couple_id:string;current_streak:number}>(
`with due as (
select couple_id,current_streak from couple_streaks
where current_streak=any(array[7,14,30,60,100]) and current_streak>streak_celebrated_at
order by couple_id for update skip locked limit $1
) update couple_streaks cs set streak_celebrated_at=d.current_streak,updated_at=$2
from due d where cs.couple_id=d.couple_id returning cs.couple_id,cs.current_streak`, [limit,now]);
for (const milestone of milestones.rows) {
const copy=streakCopy(milestone.current_streak);
await client.query(
`insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
select 'expo','streak:'||$1::uuid||':'||$2||':'||p.id,p.id,
jsonb_build_object('title',$3::text,'body',$4::text,'data',jsonb_build_object('type','streak_milestone','streak',$2::int))
from profiles p left join notification_preferences np on np.user_id=p.id
where p.couple_id=$1::uuid and p.push_token is not null and coalesce(np.streak_milestones_enabled,true)
on conflict(dedupe_key) do nothing`, [milestone.couple_id,milestone.current_streak,copy.title,copy.body]);
}
const digests=await client.query<{couple_id:string;active_user_id:string;match_count:number;latest_match_id:string|null}>(
`select couple_id,active_user_id,match_count,latest_match_id from pending_match_notifications
where notify_at<=$1 order by notify_at for update skip locked limit $2`, [now,limit]);
for (const digest of digests.rows) {
const active=await client.query<{name:string|null}>('select name from profiles where id=$1',[digest.active_user_id]);
const name=active.rows[0]?.name||'Your partner';
const body=digest.match_count===1?'You have a new match! Tap to see what you both said':digest.match_count>1?`You have ${digest.match_count} new matches! Tap to see what you both said`:'Tap to see what you both said';
await client.query(
`insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
select 'expo','digest:'||$1::uuid||':'||p.id||':'||extract(epoch from $5::timestamptz)::bigint,p.id,
jsonb_build_object('title',$2::text,'body',$3::text,'data',jsonb_build_object('type','match_digest','count',$4::int,'match_id',$6::text))
from profiles p left join notification_preferences np on np.user_id=p.id
where p.couple_id=$1::uuid and p.id<>$7::uuid and p.push_token is not null
and coalesce(np.partner_activity_enabled,true) and (p.last_active_at is null or p.last_active_at<$5::timestamptz-interval '5 minutes')
on conflict(dedupe_key) do nothing`,
[digest.couple_id,`${name} has been answering questions! 💕`,body,digest.match_count,now,digest.latest_match_id,digest.active_user_id]);
await client.query('delete from pending_match_notifications where couple_id=$1',[digest.couple_id]);
}
const packChanges=await client.query<{couple_id:string;changed_by_user_id:string}>(
`delete from pending_pack_notifications where couple_id in (
select couple_id from pending_pack_notifications where notify_at<=$1 order by notify_at for update skip locked limit $2
) returning couple_id,changed_by_user_id`, [now,limit]);
for (const change of packChanges.rows) await client.query(
`insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
select 'expo','pack_change:'||$1::uuid||':'||p.id||':'||to_char($3::timestamptz,'YYYYMMDDHH24MI'),p.id,
'{"title":"Question packs updated","body":"Your partner updated the question packs","data":{"type":"pack_change"}}'::jsonb
from profiles p left join notification_preferences np on np.user_id=p.id
where p.couple_id=$1::uuid and p.id<>$2::uuid and p.push_token is not null and coalesce(np.pack_changes_enabled,true)
on conflict(dedupe_key) do nothing`, [change.couple_id,change.changed_by_user_id,now]);
const weekly=await client.query(
`insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
select 'expo','weekly:'||to_char($1::timestamptz,'IYYY-IW')||':'||p.id,p.id,
jsonb_build_object('title','Your weekly recap','body',case when count(m.id)=0 then 'No new matches this week. Answer some questions to discover what you have in common!'
when count(m.id)<=3 then 'You matched on '||count(m.id)||' topics this week! Tap to start a conversation.'
else count(m.id)||' matches this week — you two are on a roll!' end,
'data',jsonb_build_object('type','weekly_summary'))
from profiles p left join notification_preferences np on np.user_id=p.id
left join matches m on m.couple_id=p.couple_id and m.created_at>$1::timestamptz-interval '7 days'
where extract(dow from $1::timestamptz)=0 and extract(hour from $1::timestamptz)>=10
and p.couple_id is not null and p.push_token is not null and coalesce(np.weekly_summary_enabled,true)
and exists(select 1 from responses r where r.couple_id=p.couple_id and r.created_at>$1::timestamptz-interval '7 days')
group by p.id on conflict(dedupe_key) do nothing`, [now]);
const unpaired=await client.query(
`with eligible as (
select p.id,p.couple_id is not null has_invite from profiles p left join notification_preferences np on np.user_id=p.id
where extract(hour from $1::timestamptz)>=18 and p.push_token is not null
and (p.couple_id is null or (select count(*) from profiles member where member.couple_id=p.couple_id)<2)
and coalesce(np.unpaired_reminders_enabled,true)
and (p.last_unpaired_reminder_at is null or p.last_unpaired_reminder_at<$1::timestamptz-interval '3 days')
order by p.id for update of p skip locked limit $2
), queued as (
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
select 'expo','unpaired:'||to_char($1::timestamptz,'YYYY-MM-DD')||':'||e.id,e.id,
jsonb_build_object('title','Sauci','body',case when e.has_invite
then 'Your invite code is waiting! Share it with your partner to start discovering what you have in common.'
else 'Ready to get started? Create an invite code and share it with your partner!' end,
'data',jsonb_build_object('type','unpaired_reminder'))
from eligible e on conflict(dedupe_key) do nothing returning recipient_id
) update profiles p set last_unpaired_reminder_at=$1 from queued q where p.id=q.recipient_id returning p.id`, [now,limit]);
const catchup=await this.produceCatchups(client,now,limit);
return {releasedPacks:released.rowCount??0,streakMilestones:milestones.rowCount??0,digests:digests.rowCount??0,
packChanges:packChanges.rowCount??0,weeklySummaries:weekly.rowCount??0,unpairedReminders:unpaired.rowCount??0,catchupReminders:catchup};
});
}
private async produceCatchups(client: PoolClient, now: Date, limit: number): Promise<number> {
await client.query(
`with behind as (
select me.id user_id,min(partner.name) partner_name,count(distinct pr.question_id)::int pending_count
from profiles me join profiles partner on partner.couple_id=me.couple_id and partner.id<>me.id
join responses pr on pr.user_id=partner.id
left join responses mine on mine.user_id=me.id and mine.question_id=pr.question_id
where mine.id is null group by me.id
) insert into catchup_reminder_tracking(user_id,pending_since)
select user_id,$1 from behind on conflict(user_id) do nothing`, [now]);
await client.query(
`delete from catchup_reminder_tracking t where not exists(
select 1 from profiles me join profiles partner on partner.couple_id=me.couple_id and partner.id<>me.id
join responses pr on pr.user_id=partner.id left join responses mine on mine.user_id=me.id and mine.question_id=pr.question_id
where me.id=t.user_id and mine.id is null)`, []);
const result=await client.query(
`with due as (
select me.id,min(partner.name) partner_name,count(distinct pr.question_id)::int pending_count,t.reminder_count
from catchup_reminder_tracking t join profiles me on me.id=t.user_id
join profiles partner on partner.couple_id=me.couple_id and partner.id<>me.id
join responses pr on pr.user_id=partner.id left join responses mine on mine.user_id=me.id and mine.question_id=pr.question_id
left join notification_preferences np on np.user_id=me.id
where extract(hour from $1::timestamptz)>=17 and mine.id is null and me.push_token is not null and coalesce(np.catchup_reminders_enabled,true)
and (me.last_active_at is null or me.last_active_at<$1::timestamptz-interval '6 hours')
and t.pending_since<=$1::timestamptz-interval '24 hours'
and (t.reminder_count=0 or t.last_reminder_sent_at<=$1::timestamptz-interval '3 days')
group by me.id,t.reminder_count order by me.id limit $2
), queued as (
insert into operations_outbox(kind,dedupe_key,recipient_id,payload)
select 'expo','catchup:'||to_char($1::timestamptz,'YYYY-MM-DD')||':'||d.id,d.id,
jsonb_build_object('title',case when d.reminder_count=0 then 'Questions waiting' else 'Still waiting on you' end,
'body',case when d.reminder_count=0 and d.pending_count>=6
then coalesce(d.partner_name,'Your partner')||' has answered '||d.pending_count||' questions you haven''t seen yet'
when d.reminder_count=0 then coalesce(d.partner_name,'Your partner')||' answered some questions — your turn!'
else 'You have '||d.pending_count||' questions waiting. Don''t leave '||coalesce(d.partner_name,'your partner')||' hanging!' end,
'data',jsonb_build_object('type','catchup_reminder'))
from due d on conflict(dedupe_key) do nothing returning recipient_id
) update catchup_reminder_tracking t set last_reminder_sent_at=$1,reminder_count=reminder_count+1
from queued q where t.user_id=q.recipient_id returning t.user_id`, [now,limit]);
return result.rowCount??0;
}
async claim(limit=25): Promise<OperationItem[]> {
return transaction(this.pool, async (client) => {
const result=await client.query<OutboxRow>(
`with selected as (
select id from operations_outbox where sent_at is null and attempts<5 and available_at<=now()
and (locked_at is null or locked_at<now()-interval '5 minutes')
order by available_at,created_at for update skip locked limit $1
), claimed as (
update operations_outbox o set locked_at=now(),attempts=attempts+1 from selected s where o.id=s.id returning o.*
) select c.id,c.kind,c.dedupe_key,c.recipient_id,
case when c.kind<>'expo' then null else case c.payload->'data'->>'type'
when 'message' then case when coalesce(np.messages_enabled,true) then p.push_token end
when 'match_digest' then case when coalesce(np.partner_activity_enabled,true) then p.push_token end
when 'pack_change' then case when coalesce(np.pack_changes_enabled,true) then p.push_token end
when 'new_pack' then case when coalesce(np.new_packs_enabled,true) then p.push_token end
when 'streak_milestone' then case when coalesce(np.streak_milestones_enabled,true) then p.push_token end
when 'weekly_summary' then case when coalesce(np.weekly_summary_enabled,true) then p.push_token end
when 'unpaired_reminder' then case when coalesce(np.unpaired_reminders_enabled,true) then p.push_token end
when 'catchup_reminder' then case when coalesce(np.catchup_reminders_enabled,true) then p.push_token end
else p.push_token end end push_token,
c.payload,c.attempts
from claimed c left join profiles p on p.id=c.recipient_id
left join notification_preferences np on np.user_id=c.recipient_id`, [limit]);
return result.rows.map(item);
});
}
async complete(id:string) { await this.pool.query('update operations_outbox set sent_at=now(),locked_at=null,last_error=null where id=$1',[id]); }
async fail(id:string,message:string) { await this.pool.query(
`update operations_outbox set locked_at=null,last_error=$2,available_at=now()+make_interval(secs=>least(3600,power(2,attempts)::int*30)) where id=$1`,[id,message.slice(0,500)]); }
async message(id:string): Promise<MessageForClassification|null> {
const r=await this.pool.query<{id:string;version:number;content:string|null;encrypted_content:string|null;encryption_iv:string|null;keys_metadata:{admin_wrapped_key?:string}|null;media_path:string|null;media_type:string|null;storage_key:string|null;mime_type:string|null}>(
`select m.id,m.version,m.content,m.encrypted_content,m.encryption_iv,m.keys_metadata,m.media_path,m.media_type,
mo.storage_key,mo.mime_type from messages m left join media_objects mo on m.media_path='media:'||mo.id::text where m.id=$1`,[id]);
const m=r.rows[0]; return m?{id:m.id,version:m.version,content:m.content,encryptedContent:m.encrypted_content,encryptionIv:m.encryption_iv,keysMetadata:m.keys_metadata,mediaPath:m.media_path,mediaType:m.media_type,mediaStorageKey:m.storage_key,mediaMimeType:m.mime_type}:null;
}
async classifierConfig(): Promise<ClassifierRuntimeConfig|null> {
const result=await this.pool.query<{
classifier_enabled:boolean|null;openrouter_api_key:string|null;classifier_model:string|null;
classifier_temperature:number|null;classifier_prompt:string|null;heuristics_enabled:boolean|null;
heuristic_min_text_length:number|null;heuristic_whitelist_max_length:number|null;
heuristic_skip_if_no_alnum:boolean|null;heuristic_skip_media_without_text:boolean|null;
heuristic_use_default_whitelist:boolean|null;heuristic_use_default_keywords:boolean|null;
heuristic_whitelist:string|null;heuristic_keyword_triggers:string|null;
}>(`select classifier_enabled,openrouter_api_key,classifier_model,classifier_temperature,classifier_prompt,
heuristics_enabled,heuristic_min_text_length,heuristic_whitelist_max_length,
heuristic_skip_if_no_alnum,heuristic_skip_media_without_text,
heuristic_use_default_whitelist,heuristic_use_default_keywords,
heuristic_whitelist,heuristic_keyword_triggers
from ai_config limit 1`);
const row=result.rows[0];if(!row)return null;
return {enabled:row.classifier_enabled,apiKey:row.openrouter_api_key,model:row.classifier_model,
temperature:row.classifier_temperature,prompt:row.classifier_prompt,heuristicsEnabled:row.heuristics_enabled,
heuristicMinTextLength:row.heuristic_min_text_length,heuristicWhitelistMaxLength:row.heuristic_whitelist_max_length,
heuristicSkipIfNoAlnum:row.heuristic_skip_if_no_alnum,heuristicSkipMediaWithoutText:row.heuristic_skip_media_without_text,
heuristicUseDefaultWhitelist:row.heuristic_use_default_whitelist,heuristicUseDefaultKeywords:row.heuristic_use_default_keywords,
heuristicWhitelist:row.heuristic_whitelist,heuristicKeywordTriggers:row.heuristic_keyword_triggers};
}
async classify(id:string,status:'safe'|'flagged',reason:string|null,category:string) {
await this.pool.query('update messages set moderation_status=$2,flag_reason=$3,category=$4 where id=$1',[id,status,reason,category]);
}
async close() { await this.pool.end(); }
}
function streakCopy(days:number) {
const copy:Record<number,[string,string]>={7:['1 week streak!','You and your partner have been connecting for 7 days straight. Keep it up!'],14:['2 week streak!','Two weeks of daily connection. You two are on fire!'],30:['1 month streak!',"A whole month of daily connection! You're building something special."],60:['2 month streak!','60 days of daily connection. Your commitment is inspiring!'],100:['100 day streak!',"100 days! You've made connection a habit. Incredible!"]};
const value=copy[days]??[`${days} day streak!`,`${days} days of connecting with your partner. Amazing!`]; return {title:value[0],body:value[1]};
}
+19
View File
@@ -0,0 +1,19 @@
import type { MediaJanitor } from '../media/janitor.js';
import type { OperationsRepository } from './repository.js';
import type { DiscordProvider, ExpoPushProvider, MessageClassifier } from './providers.js';
export class OperationsRunner {
constructor(private readonly repository:OperationsRepository,private readonly expo:ExpoPushProvider,private readonly discord:DiscordProvider,private readonly classifier:MessageClassifier,private readonly mediaJanitor?:MediaJanitor) {}
async runOnce(now=new Date(),limit=25) {
const produced=await this.repository.produce(now,100); if(this.mediaJanitor)await this.mediaJanitor.runOnce(100);
const items=await this.repository.claim(limit);let completed=0,failed=0;
for(const item of items) {
try {
if(item.kind==='expo')await this.expo.send(item);else if(item.kind==='discord')await this.discord.send(item);else await this.classifier.classify(item);
await this.repository.complete(item.id);completed++;
} catch(cause) { await this.repository.fail(item.id,cause instanceof Error?cause.message:'unknown error');failed++; }
}
return {produced,claimed:items.length,completed,failed};
}
}
+51
View File
@@ -0,0 +1,51 @@
export type OperationKind = 'expo' | 'discord' | 'classify';
export interface OperationItem {
id: string;
kind: OperationKind;
dedupeKey: string;
recipientId: string | null;
pushToken: string | null;
payload: Record<string, unknown>;
attempts: number;
}
export interface MessageForClassification {
id: string;
version: number;
content: string | null;
encryptedContent: string | null;
encryptionIv: string | null;
keysMetadata: { admin_wrapped_key?: string } | null;
mediaPath: string | null;
mediaType: string | null;
mediaStorageKey: string | null;
mediaMimeType: string | null;
}
export interface ClassifierRuntimeConfig {
enabled: boolean | null;
apiKey: string | null;
model: string | null;
temperature: number | null;
prompt: string | null;
heuristicsEnabled: boolean | null;
heuristicMinTextLength: number | null;
heuristicWhitelistMaxLength: number | null;
heuristicSkipIfNoAlnum: boolean | null;
heuristicSkipMediaWithoutText: boolean | null;
heuristicUseDefaultWhitelist: boolean | null;
heuristicUseDefaultKeywords: boolean | null;
heuristicWhitelist: string | null;
heuristicKeywordTriggers: string | null;
}
export interface ProducerSummary {
releasedPacks: number;
streakMilestones: number;
digests: number;
packChanges: number;
weeklySummaries: number;
unpairedReminders: number;
catchupReminders: number;
}
+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] })]);
@@ -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;
}