mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Completes the half-built dares feature: send a dare, respond, report done, confirm, and get a push notification on every transition. Backend (apps/api): - New `dares` domain with couple-shared premium entitlement, a free weekly send allowance, and server-enforced actor/state rules on every transition. - `submitted` status so the recipient reports completion and the sender confirms, rather than the sender guessing. - Notifications ride the existing operations outbox via a trigger on `sent_dares`. Push copy is generic; store compliance forbids dare text in a preview and a test asserts no payload leaks it. - Worker expiry sweep now covers `pending` as well as `active`/`submitted`; an invitation nobody opened previously stayed open forever. Fixes two latent defects: - Deleting any dare that had been sent aborted with a check violation, because `dare_id` is ON DELETE SET NULL while `sent_dares_dare_or_custom_check` required it. Dare text is now snapshotted at send time, which also keeps a couple's history stable across catalogue edits. - The free send allowance was raceable: concurrent requests all read the same count and every one succeeded. The count and insert are now serialized on the sender's profile row. Also ports `content_status` review metadata to the API catalogue, which had no store-safety gating at all, and mirrors the review decision already enforced on the legacy catalogue. Mobile: dares screen (active/send/history), send sheet with duration presets, home tile badging incoming dares, paywall on both proactive and 402 paths. Verified: lint, typecheck, 379 unit tests, build, 71 integration tests against local PostgreSQL. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
133 lines
6.2 KiB
TypeScript
133 lines
6.2 KiB
TypeScript
import type { ApiErrorResponse, MeResponse } from '@sauci/shared';
|
|
import { Hono } from 'hono';
|
|
import { z } from 'zod';
|
|
import { bearerToken, type AuthIdentity, type AuthVerifier } from './auth.js';
|
|
import type { ApiRepository } from './db/repository.js';
|
|
import type { AppDataRepository } from './domains/app-data/repository.js';
|
|
import { registerAppDataRoutes } from './domains/app-data/routes.js';
|
|
import type { BillingService } from './domains/billing/service.js';
|
|
import { registerBillingRoutes } from './domains/billing/routes.js';
|
|
import type { AccountOperationsService } from './domains/account-operations/service.js';
|
|
import { registerAccountOperationRoutes } from './domains/account-operations/routes.js';
|
|
import type { AnswersRepository } from './domains/answers/repository.js';
|
|
import { registerAnswerRoutes } from './domains/answers/routes.js';
|
|
import type { ChatRepository } from './domains/chat/repository.js';
|
|
import { registerChatRoutes } from './domains/chat/routes.js';
|
|
import type { DaresRepository } from './domains/dares/repository.js';
|
|
import { registerDareRoutes } from './domains/dares/routes.js';
|
|
import { registerCoupleRoutes } from './domains/couples/routes.js';
|
|
import type { CoupleService } from './domains/couples/service.js';
|
|
import type { PacksRepository } from './domains/packs/repository.js';
|
|
import { registerPackRoutes } from './domains/packs/routes.js';
|
|
import type { ProfileSettingsRepository } from './domains/profile-settings/repository.js';
|
|
import { registerProfileSettingsRoutes } from './domains/profile-settings/routes.js';
|
|
import type { MediaRepository } from './domains/media/repository.js';
|
|
import { registerMediaRoutes } from './domains/media/routes.js';
|
|
import type { FilesystemMediaStorage } from './domains/media/storage.js';
|
|
import type { AdminRequestAuth } from './domains/admin/auth.js';
|
|
import type { AdminRepository } from './domains/admin/repository.js';
|
|
import { registerAdminRoutes } from './domains/admin/routes.js';
|
|
|
|
type Variables = { identity: AuthIdentity };
|
|
|
|
export interface AppDependencies {
|
|
auth: AuthVerifier;
|
|
repository: ApiRepository;
|
|
coupleService?: CoupleService;
|
|
packsRepository?: PacksRepository;
|
|
answersRepository?: AnswersRepository;
|
|
chatRepository?: ChatRepository;
|
|
daresRepository?: DaresRepository;
|
|
profileSettingsRepository?: ProfileSettingsRepository;
|
|
accountOperationsService?: AccountOperationsService;
|
|
mediaRepository?: MediaRepository;
|
|
mediaStorage?: FilesystemMediaStorage;
|
|
appDataRepository?: AppDataRepository;
|
|
billingService?: BillingService;
|
|
adminRepository?: AdminRepository;
|
|
adminAuth?: AdminRequestAuth;
|
|
}
|
|
|
|
const featureSchema = z.string().min(1).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
|
|
|
function error(code: string, message: string): ApiErrorResponse {
|
|
return { error: { code, message } };
|
|
}
|
|
|
|
export function createApp(deps: AppDependencies): Hono<{ Variables: Variables }> {
|
|
const app = new Hono<{ Variables: Variables }>();
|
|
|
|
app.get('/health/live', (c) => c.json({ status: 'ok' }));
|
|
app.get('/health/ready', async (c) => {
|
|
try {
|
|
await deps.repository.ready();
|
|
return c.json({ status: 'ready' });
|
|
} catch {
|
|
return c.json(error('not_ready', 'Database is unavailable'), 503);
|
|
}
|
|
});
|
|
|
|
app.use('/v1/*', async (c, next) => {
|
|
// Admin routes accept either a hosted user token or the separately scoped
|
|
// MCP service credential and therefore own their authentication middleware.
|
|
if (c.req.path.startsWith('/v1/admin/')) {
|
|
await next();
|
|
return;
|
|
}
|
|
const token = bearerToken(c.req.header('authorization'));
|
|
if (!token) return c.json(error('unauthorized', 'A bearer token is required'), 401);
|
|
try {
|
|
c.set('identity', await deps.auth.verify(token));
|
|
} catch {
|
|
return c.json(error('unauthorized', 'The bearer token is invalid'), 401);
|
|
}
|
|
await next();
|
|
});
|
|
|
|
app.get('/v1/me', async (c) => {
|
|
const profile = await deps.repository.upsertProfile(c.get('identity'));
|
|
return c.json<MeResponse>({ profile });
|
|
});
|
|
|
|
app.get('/v1/me/feature-interests/:feature', async (c) => {
|
|
const parsed = featureSchema.safeParse(c.req.param('feature'));
|
|
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
|
|
return c.json(await deps.repository.getFeatureInterest(c.get('identity').id, parsed.data));
|
|
});
|
|
|
|
app.put('/v1/me/feature-interests/:feature', async (c) => {
|
|
const parsed = featureSchema.safeParse(c.req.param('feature'));
|
|
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
|
|
await deps.repository.upsertProfile(c.get('identity'));
|
|
return c.json(await deps.repository.putFeatureInterest(c.get('identity').id, parsed.data));
|
|
});
|
|
|
|
app.delete('/v1/me/feature-interests/:feature', async (c) => {
|
|
const parsed = featureSchema.safeParse(c.req.param('feature'));
|
|
if (!parsed.success) return c.json(error('invalid_feature', 'Feature must be a lowercase slug'), 400);
|
|
return c.json(await deps.repository.deleteFeatureInterest(c.get('identity').id, parsed.data));
|
|
});
|
|
|
|
if (deps.coupleService) registerCoupleRoutes(app, deps.coupleService);
|
|
if (deps.packsRepository) registerPackRoutes(app, deps.packsRepository);
|
|
if (deps.answersRepository) registerAnswerRoutes(app, deps.answersRepository);
|
|
if (deps.chatRepository) registerChatRoutes(app, deps.chatRepository);
|
|
if (deps.daresRepository) registerDareRoutes(app, deps.daresRepository);
|
|
if (deps.profileSettingsRepository) registerProfileSettingsRoutes(app, deps.profileSettingsRepository);
|
|
if (deps.accountOperationsService) registerAccountOperationRoutes(app, deps.accountOperationsService);
|
|
if (deps.mediaRepository && deps.mediaStorage) registerMediaRoutes(app, deps.mediaRepository, deps.mediaStorage);
|
|
if (deps.appDataRepository) registerAppDataRoutes(app, deps.appDataRepository);
|
|
if (deps.billingService) registerBillingRoutes(app, deps.billingService);
|
|
if (deps.adminRepository && deps.adminAuth) {
|
|
registerAdminRoutes(app, deps.adminRepository, deps.adminAuth, deps.mediaStorage);
|
|
}
|
|
|
|
app.notFound((c) => c.json(error('not_found', 'Route not found'), 404));
|
|
app.onError((cause, c) => {
|
|
console.error(cause);
|
|
return c.json(error('internal_error', 'An unexpected error occurred'), 500);
|
|
});
|
|
|
|
return app;
|
|
}
|