diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 4c07b2c..c4ea953 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -7,6 +7,7 @@ import { classificationRoutes } from './routes/classifications'; import { diagnosisRoutes } from './routes/diagnoses'; import { dashboardRoutes } from './routes/dashboard'; import { authRoutes } from './routes/auth'; +import { expertRoutes } from './routes/expert'; assertRequiredEnv(); @@ -17,6 +18,7 @@ const app = new Elysia() .use(diseaseRoutes) .use(classificationRoutes) .use(diagnosisRoutes) + .use(expertRoutes) .use(dashboardRoutes) .listen(env.port); diff --git a/apps/api/src/routes/expert.ts b/apps/api/src/routes/expert.ts new file mode 100644 index 0000000..fb3a467 --- /dev/null +++ b/apps/api/src/routes/expert.ts @@ -0,0 +1,89 @@ +import { Elysia } from 'elysia'; +import type { ReviewDiagnosisRequest } from '@zeavis/shared'; +import { isDiseaseSlug } from '@zeavis/shared'; +import { desc, eq } from 'drizzle-orm'; +import { createDbClient } from '../db/client'; +import { diagnoses, expertReviews } from '../db/schema'; +import { badRequest, forbidden, notFound, serviceUnavailable, unauthorized } from '../lib/http-errors'; +import { getCurrentUser } from '../lib/auth'; +import { loadDiagnosisRecord } from './diagnoses'; + +function isDiagnosisRecordOrNull(record: unknown): record is ReturnType extends Promise ? T : never { + return record !== null; +} + +export const expertRoutes = new Elysia({ prefix: '/api/v1/expert' }) + .get('/reviews', async ({ request }) => { + const user = await getCurrentUser(request.headers.get('cookie')); + if (!user) return unauthorized('Authentication required'); + if (user.role !== 'expert') return forbidden('Expert role required'); + + try { + const db = createDbClient(); + const rows = await db + .select({ id: diagnoses.id }) + .from(diagnoses) + .where(eq(diagnoses.status, 'needs_review')) + .orderBy(desc(diagnoses.createdAt)) + .limit(50); + + const records = await Promise.all(rows.map((row) => loadDiagnosisRecord(row.id, null, true))); + return records.filter(isDiagnosisRecordOrNull); + } catch (error) { + return serviceUnavailable('Database unavailable'); + } + }) + .post('/reviews/:diagnosisId', async ({ params, body, request }) => { + const user = await getCurrentUser(request.headers.get('cookie')); + if (!user) return unauthorized('Authentication required'); + if (user.role !== 'expert') return forbidden('Expert role required'); + + const req = body as Partial | undefined; + if (!req || (req.verdict !== 'verified' && req.verdict !== 'corrected')) { + return badRequest('Verdict must be verified or corrected'); + } + + const notes = typeof req.notes === 'string' ? req.notes.trim() : ''; + if (!notes) return badRequest('Review notes are required'); + + if (req.verdict === 'corrected') { + if (!req.correctedDiseaseSlug || !isDiseaseSlug(req.correctedDiseaseSlug)) { + return badRequest('Corrected disease slug is required for corrected reviews'); + } + } + + try { + const db = createDbClient(); + const existing = await db + .select() + .from(diagnoses) + .where(eq(diagnoses.id, params.diagnosisId)) + .limit(1); + + const diagnosis = existing[0]; + if (!diagnosis) return notFound('Diagnosis not found'); + + const correctedSlug = req.verdict === 'corrected' ? req.correctedDiseaseSlug : null; + + await db.insert(expertReviews).values({ + diagnosisId: diagnosis.id, + expertId: user.id, + verdict: req.verdict, + correctedDiseaseSlug: correctedSlug, + notes, + }); + + await db + .update(diagnoses) + .set({ + status: req.verdict === 'corrected' ? 'expert_corrected' : 'expert_verified', + predictedDiseaseSlug: req.verdict === 'corrected' ? correctedSlug : diagnosis.predictedDiseaseSlug, + updatedAt: new Date(), + }) + .where(eq(diagnoses.id, diagnosis.id)); + + return await loadDiagnosisRecord(diagnosis.id, null, true); + } catch (error) { + return serviceUnavailable('Database unavailable'); + } + });