dashboard, diagnoses, expert routes now support Authorization header fallback for Android WebView Co-Authored-By: Claude <noreply@anthropic.com>
286 lines
10 KiB
TypeScript
286 lines
10 KiB
TypeScript
import { Elysia } from 'elysia';
|
|
import type { DiagnosisPrediction, DiagnosisRecord, ExpertReviewRecord, DiseaseCatalogItem } from '@zeavis/shared';
|
|
import { DiagnosisStatus, DiseaseSlug, isDiseaseSlug } from '@zeavis/shared';
|
|
import { and, desc, eq } from 'drizzle-orm';
|
|
import { createDbClient } from '../db/client';
|
|
import { diagnoses, diagnosisPredictions, diseaseCatalog, expertReviews, users } from '../db/schema';
|
|
import { badGateway, badRequest, notFound, serviceUnavailable, unauthorized } from '../lib/http-errors';
|
|
import { getCurrentUser } from '../lib/auth';
|
|
import { classifyImage } from '../lib/image-model';
|
|
import { uploadImageToStorage } from '../lib/uploader-client';
|
|
import { env } from '../config/env';
|
|
import { diagnosisCounter } from '../lib/telemetry';
|
|
|
|
interface ReviewRow {
|
|
reviewId: string | null;
|
|
reviewDiagnosisId: string | null;
|
|
reviewExpertId: string | null;
|
|
reviewVerdict: string | null;
|
|
reviewCorrectedDiseaseSlug: string | null;
|
|
reviewNotes: string | null;
|
|
reviewCreatedAt: Date | null;
|
|
expertId: string | null;
|
|
expertEmail: string | null;
|
|
expertName: string | null;
|
|
}
|
|
|
|
function toReview(row: ReviewRow): ExpertReviewRecord | null {
|
|
if (!row.reviewId) return null;
|
|
|
|
const verdict = row.reviewVerdict as 'verified' | 'corrected';
|
|
const correctedDiseaseSlug = row.reviewCorrectedDiseaseSlug && isDiseaseSlug(row.reviewCorrectedDiseaseSlug) ? row.reviewCorrectedDiseaseSlug : null;
|
|
const notes = row.reviewNotes || '';
|
|
|
|
return {
|
|
id: row.reviewId,
|
|
diagnosisId: row.reviewDiagnosisId!,
|
|
expertId: row.reviewExpertId!,
|
|
verdict,
|
|
correctedDiseaseSlug,
|
|
notes,
|
|
createdAt: row.reviewCreatedAt!.toISOString(),
|
|
expert: {
|
|
id: row.expertId!,
|
|
email: row.expertEmail!,
|
|
name: row.expertName!,
|
|
role: 'expert',
|
|
},
|
|
};
|
|
}
|
|
|
|
function isDiagnosisRecord(record: DiagnosisRecord | null): record is DiagnosisRecord {
|
|
return record !== null;
|
|
}
|
|
|
|
async function loadDiagnosisRecord(id: string, userId: string | null, expertAccess: boolean): Promise<DiagnosisRecord | null> {
|
|
const db = createDbClient();
|
|
const filters = [eq(diagnoses.id, id)];
|
|
if (!expertAccess && userId) {
|
|
filters.push(eq(diagnoses.userId, userId));
|
|
}
|
|
|
|
const rows = await db
|
|
.select({
|
|
id: diagnoses.id,
|
|
userId: diagnoses.userId,
|
|
predictedDiseaseSlug: diagnoses.predictedDiseaseSlug,
|
|
confidence: diagnoses.confidence,
|
|
status: diagnoses.status,
|
|
failureReason: diagnoses.failureReason,
|
|
imageUrl: diagnoses.imageUrl,
|
|
uploaderPublicId: diagnoses.uploaderPublicId,
|
|
imageFileName: diagnoses.imageFileName,
|
|
imageMimeType: diagnoses.imageMimeType,
|
|
imageSizeBytes: diagnoses.imageSizeBytes,
|
|
createdAt: diagnoses.createdAt,
|
|
updatedAt: diagnoses.updatedAt,
|
|
disease: {
|
|
slug: diseaseCatalog.slug,
|
|
label: diseaseCatalog.label,
|
|
commonName: diseaseCatalog.commonName,
|
|
summary: diseaseCatalog.summary,
|
|
description: diseaseCatalog.description,
|
|
symptoms: diseaseCatalog.symptoms,
|
|
recommendations: diseaseCatalog.recommendations,
|
|
riskLevel: diseaseCatalog.riskLevel,
|
|
accentColor: diseaseCatalog.accentColor,
|
|
displayOrder: diseaseCatalog.displayOrder,
|
|
},
|
|
reviewId: expertReviews.id,
|
|
reviewDiagnosisId: expertReviews.diagnosisId,
|
|
reviewExpertId: expertReviews.expertId,
|
|
reviewVerdict: expertReviews.verdict,
|
|
reviewCorrectedDiseaseSlug: expertReviews.correctedDiseaseSlug,
|
|
reviewNotes: expertReviews.notes,
|
|
reviewCreatedAt: expertReviews.createdAt,
|
|
expertId: users.id,
|
|
expertEmail: users.email,
|
|
expertName: users.name,
|
|
})
|
|
.from(diagnoses)
|
|
.leftJoin(diseaseCatalog, eq(diagnoses.predictedDiseaseSlug, diseaseCatalog.slug))
|
|
.leftJoin(expertReviews, eq(expertReviews.diagnosisId, diagnoses.id))
|
|
.leftJoin(users, eq(expertReviews.expertId, users.id))
|
|
.where(and(...filters))
|
|
.limit(1);
|
|
|
|
const row = rows[0];
|
|
if (!row) return null;
|
|
|
|
const predictionRows = await db
|
|
.select()
|
|
.from(diagnosisPredictions)
|
|
.where(eq(diagnosisPredictions.diagnosisId, row.id))
|
|
.orderBy(diagnosisPredictions.rank);
|
|
|
|
const predictions: DiagnosisPrediction[] = predictionRows.map((prediction) => ({
|
|
id: prediction.id,
|
|
diseaseSlug: prediction.diseaseSlug && isDiseaseSlug(prediction.diseaseSlug) ? prediction.diseaseSlug : null,
|
|
modelLabel: prediction.modelLabel,
|
|
confidence: prediction.confidence,
|
|
rank: prediction.rank,
|
|
}));
|
|
|
|
const predictedDiseaseSlug = row.predictedDiseaseSlug && isDiseaseSlug(row.predictedDiseaseSlug) ? row.predictedDiseaseSlug : null;
|
|
const status = row.status as DiagnosisStatus;
|
|
const disease = row.disease && row.disease.slug && isDiseaseSlug(row.disease.slug)
|
|
? ({
|
|
slug: row.disease.slug,
|
|
label: row.disease.label,
|
|
commonName: row.disease.commonName,
|
|
summary: row.disease.summary,
|
|
description: row.disease.description,
|
|
symptoms: row.disease.symptoms,
|
|
recommendations: row.disease.recommendations,
|
|
riskLevel: row.disease.riskLevel,
|
|
accentColor: row.disease.accentColor,
|
|
displayOrder: row.disease.displayOrder,
|
|
} as DiseaseCatalogItem)
|
|
: null;
|
|
|
|
return {
|
|
id: row.id,
|
|
userId: row.userId,
|
|
predictedDiseaseSlug,
|
|
confidence: row.confidence,
|
|
status,
|
|
failureReason: row.failureReason,
|
|
imageUrl: row.imageUrl,
|
|
uploaderPublicId: row.uploaderPublicId,
|
|
imageFileName: row.imageFileName,
|
|
imageMimeType: row.imageMimeType,
|
|
imageSizeBytes: row.imageSizeBytes,
|
|
createdAt: row.createdAt.toISOString(),
|
|
updatedAt: row.updatedAt.toISOString(),
|
|
disease,
|
|
predictions,
|
|
latestReview: toReview(row),
|
|
};
|
|
}
|
|
|
|
function getFileFromBody(body: unknown): File | null {
|
|
if (body instanceof FormData) {
|
|
const formFile = body.get('file');
|
|
return formFile instanceof File ? formFile : null;
|
|
}
|
|
|
|
if (typeof body === 'object' && body !== null) {
|
|
const bodyObj = body as Record<string, unknown>;
|
|
if (bodyObj.file instanceof File) return bodyObj.file;
|
|
if (Array.isArray(bodyObj.file) && bodyObj.file[0] instanceof File) return bodyObj.file[0];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export const diagnosisRoutes = new Elysia({ prefix: '/api/v1' })
|
|
.post('/diagnoses', async ({ body, request }) => {
|
|
const user = await getCurrentUser(request.headers.get('cookie'), request.headers);
|
|
if (!user) return unauthorized('Authentication required');
|
|
|
|
const file = getFileFromBody(body);
|
|
if (!file) return badRequest('Missing required field: file');
|
|
if (file.size === 0) return badRequest('File is empty');
|
|
if (file.size > env.uploadMaxBytes) return badRequest(`File must be smaller than ${env.uploadMaxBytes} bytes`);
|
|
if (!env.uploadAllowedMimeTypes.includes(file.type)) return badRequest('File type is not allowed');
|
|
|
|
let uploaderMetadata;
|
|
try {
|
|
uploaderMetadata = await uploadImageToStorage(file);
|
|
} catch (error) {
|
|
return badGateway(`Upload service error: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
}
|
|
|
|
const db = createDbClient();
|
|
|
|
try {
|
|
const classification = await classifyImage(file);
|
|
const status = classification.confidence >= 0.7 ? 'ai_verified' : 'needs_review';
|
|
const inserted = await db
|
|
.insert(diagnoses)
|
|
.values({
|
|
userId: user.id,
|
|
predictedDiseaseSlug: classification.predictedDiseaseSlug,
|
|
confidence: classification.confidence,
|
|
status,
|
|
imageUrl: uploaderMetadata.download_url,
|
|
uploaderPublicId: uploaderMetadata.public_id,
|
|
imageFileName: uploaderMetadata.file_name || file.name,
|
|
imageMimeType: uploaderMetadata.mime_type || file.type,
|
|
imageSizeBytes: uploaderMetadata.size_bytes || file.size,
|
|
})
|
|
.returning();
|
|
|
|
await db.insert(diagnosisPredictions).values(
|
|
classification.probabilities.map((prediction, index) => ({
|
|
diagnosisId: inserted[0].id,
|
|
diseaseSlug: prediction.diseaseSlug,
|
|
modelLabel: prediction.label,
|
|
confidence: prediction.confidence,
|
|
rank: index + 1,
|
|
})),
|
|
);
|
|
|
|
const record = await loadDiagnosisRecord(inserted[0].id, user.id, false);
|
|
if (!record) return serviceUnavailable('Database unavailable');
|
|
|
|
diagnosisCounter.labels(record.predictedDiseaseSlug ?? 'unknown').inc();
|
|
|
|
return record;
|
|
} catch (error) {
|
|
const inserted = await db
|
|
.insert(diagnoses)
|
|
.values({
|
|
userId: user.id,
|
|
status: 'failed',
|
|
failureReason: error instanceof Error ? error.message : 'Model inference failed',
|
|
imageUrl: uploaderMetadata.download_url,
|
|
uploaderPublicId: uploaderMetadata.public_id,
|
|
imageFileName: uploaderMetadata.file_name || file.name,
|
|
imageMimeType: uploaderMetadata.mime_type || file.type,
|
|
imageSizeBytes: uploaderMetadata.size_bytes || file.size,
|
|
})
|
|
.returning();
|
|
|
|
const record = await loadDiagnosisRecord(inserted[0].id, user.id, false);
|
|
if (!record) return serviceUnavailable('Database unavailable');
|
|
|
|
diagnosisCounter.labels('failed').inc();
|
|
|
|
return record;
|
|
}
|
|
})
|
|
.get('/diagnoses', async ({ request }) => {
|
|
const user = await getCurrentUser(request.headers.get('cookie'), request.headers);
|
|
if (!user) return unauthorized('Authentication required');
|
|
|
|
try {
|
|
const db = createDbClient();
|
|
const rows = await db
|
|
.select({ id: diagnoses.id })
|
|
.from(diagnoses)
|
|
.where(eq(diagnoses.userId, user.id))
|
|
.orderBy(desc(diagnoses.createdAt))
|
|
.limit(30);
|
|
|
|
const records = await Promise.all(rows.map((row) => loadDiagnosisRecord(row.id, user.id, false)));
|
|
return records.filter(isDiagnosisRecord);
|
|
} catch (error) {
|
|
return serviceUnavailable('Database unavailable');
|
|
}
|
|
})
|
|
.get('/diagnoses/:id', async ({ params, request }) => {
|
|
const user = await getCurrentUser(request.headers.get('cookie'), request.headers);
|
|
if (!user) return unauthorized('Authentication required');
|
|
|
|
try {
|
|
const record = await loadDiagnosisRecord(params.id, user.id, user.role === 'expert');
|
|
if (!record) return notFound('Diagnosis not found');
|
|
return record;
|
|
} catch (error) {
|
|
return serviceUnavailable('Database unavailable');
|
|
}
|
|
});
|
|
|
|
export { loadDiagnosisRecord };
|