mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Replaces the notify-me quiz stub with a full loop: quiz sessions per couple, answer-and-guess flow, dual-direction scoring, and a score-only share card (no question or answer content) captured via ViewShot with Sauci branding. API: migration 0019 (quiz_questions, quiz_sessions, quiz_answers, 12 seeded questions), new quiz domain with session start / current / answers / result routes, couple isolation, route + integration + scoring unit tests. Mobile: src/features/quiz feature (intro, per-question self+guess flow, waiting-for-partner nudge, results with breakdown), quizStore with sign-out clearing, QuizShareModal following SharePreviewModal, discovery QuizTile, analytics events, and tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
150 lines
7.0 KiB
TypeScript
150 lines
7.0 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 type { QuizRepository } from './domains/quiz/repository.js';
|
|
import { registerQuizRoutes } from './domains/quiz/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';
|
|
import { recordRequest } from './telemetry.js';
|
|
|
|
type Variables = { identity: AuthIdentity };
|
|
|
|
export interface AppDependencies {
|
|
auth: AuthVerifier;
|
|
repository: ApiRepository;
|
|
coupleService?: CoupleService;
|
|
packsRepository?: PacksRepository;
|
|
answersRepository?: AnswersRepository;
|
|
chatRepository?: ChatRepository;
|
|
daresRepository?: DaresRepository;
|
|
quizRepository?: QuizRepository;
|
|
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.use('*', async (c, next) => {
|
|
const startedAt = performance.now();
|
|
try {
|
|
await next();
|
|
} finally {
|
|
// Hono returns the matched template after dispatch, never the raw path.
|
|
const candidate = c.req.routePath;
|
|
const route = candidate && !/[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}/i.test(candidate) && !candidate.includes('?') ? candidate : 'unmatched';
|
|
recordRequest(c.req.method, route, c.res.status, startedAt);
|
|
}
|
|
});
|
|
|
|
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.quizRepository) registerQuizRoutes(app, deps.quizRepository);
|
|
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;
|
|
}
|