feat: persist authenticated diagnoses
Implement diagnosis persistence routes with image upload, classification, and prediction storage. Add missing HTTP error helpers (unauthorized, forbidden) and update uploader client to use configurable base URL. - Create POST /api/v1/diagnoses to upload images and store classifications - Create GET /api/v1/diagnoses to list user's diagnoses (30 most recent) - Create GET /api/v1/diagnoses/:id to retrieve diagnosis with predictions - Add loadDiagnosisRecord helper for consistent diagnosis data loading - Update uploader-client.ts to use env.uploaderBaseUrl - Add unauthorized and forbidden error helpers to http-errors.ts - Register diagnosisRoutes in main app Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
5eb195cbeb
commit
1a36ae5cbc
@@ -4,6 +4,7 @@ import { healthRoutes } from './routes/health';
|
||||
import { statusRoutes } from './routes/status';
|
||||
import { diseaseRoutes } from './routes/diseases';
|
||||
import { classificationRoutes } from './routes/classifications';
|
||||
import { diagnosisRoutes } from './routes/diagnoses';
|
||||
import { dashboardRoutes } from './routes/dashboard';
|
||||
import { authRoutes } from './routes/auth';
|
||||
|
||||
@@ -15,6 +16,7 @@ const app = new Elysia()
|
||||
.use(authRoutes)
|
||||
.use(diseaseRoutes)
|
||||
.use(classificationRoutes)
|
||||
.use(diagnosisRoutes)
|
||||
.use(dashboardRoutes)
|
||||
.listen(env.port);
|
||||
|
||||
|
||||
@@ -5,6 +5,20 @@ export function badRequest(message: string): Response {
|
||||
});
|
||||
}
|
||||
|
||||
export function unauthorized(message: string): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export function forbidden(message: string): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export function notFound(message: string): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 404,
|
||||
|
||||
@@ -1,28 +1,34 @@
|
||||
import type { UploaderMetadata } from '@zeavis/shared';
|
||||
import { env } from '../config/env';
|
||||
|
||||
export async function uploadImageToStorage(file: File): Promise<UploaderMetadata> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('fileName', file.name);
|
||||
|
||||
const response = await fetch('https://upload.asepharyana.tech/api/upload', {
|
||||
const response = await fetch(`${env.uploaderBaseUrl}/api/upload`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Upload failed with status ${response.status}`);
|
||||
throw new Error(`Upload failed with HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as Record<string, unknown>;
|
||||
const payload = await response.json() as Partial<UploaderMetadata>;
|
||||
|
||||
if (!data.download_url) {
|
||||
throw new Error('Upload response missing download_url');
|
||||
if (!payload.public_id || !payload.download_url || !payload.file_name || !payload.mime_type || typeof payload.size_bytes !== 'number') {
|
||||
throw new Error('Upload response is missing required metadata');
|
||||
}
|
||||
|
||||
if (!data.public_id) {
|
||||
throw new Error('Upload response missing public_id');
|
||||
}
|
||||
|
||||
return data as UploaderMetadata;
|
||||
return {
|
||||
public_id: payload.public_id,
|
||||
telegram_file_id: payload.telegram_file_id,
|
||||
telegram_file_unique_id: payload.telegram_file_unique_id,
|
||||
file_name: payload.file_name,
|
||||
mime_type: payload.mime_type,
|
||||
size_bytes: payload.size_bytes,
|
||||
file_type: payload.file_type ?? 'image',
|
||||
download_url: payload.download_url,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { Elysia } from 'elysia';
|
||||
import type { DiagnosisPrediction, DiagnosisRecord, ExpertReviewRecord } 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 { toDisease } from '../lib/disease-mappers';
|
||||
import { env } from '../config/env';
|
||||
|
||||
function toReview(row: any): ExpertReviewRecord | null {
|
||||
if (!row.reviewId) return null;
|
||||
|
||||
return {
|
||||
id: row.reviewId,
|
||||
diagnosisId: row.reviewDiagnosisId,
|
||||
expertId: row.reviewExpertId,
|
||||
verdict: row.reviewVerdict,
|
||||
correctedDiseaseSlug: row.reviewCorrectedDiseaseSlug,
|
||||
notes: row.reviewNotes,
|
||||
createdAt: row.reviewCreatedAt.toISOString(),
|
||||
expert: {
|
||||
id: row.expertId,
|
||||
email: row.expertEmail,
|
||||
name: row.expertName,
|
||||
role: 'expert',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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 as any,
|
||||
modelLabel: prediction.modelLabel,
|
||||
confidence: prediction.confidence,
|
||||
rank: prediction.rank,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
predictedDiseaseSlug: row.predictedDiseaseSlug as any,
|
||||
confidence: row.confidence,
|
||||
status: row.status as any,
|
||||
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: row.disease?.slug ? toDisease(row.disease as any) : null,
|
||||
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'));
|
||||
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);
|
||||
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);
|
||||
return record!;
|
||||
}
|
||||
})
|
||||
.get('/diagnoses', async ({ request }) => {
|
||||
const user = await getCurrentUser(request.headers.get('cookie'));
|
||||
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(Boolean);
|
||||
} catch (error) {
|
||||
return serviceUnavailable('Database unavailable');
|
||||
}
|
||||
})
|
||||
.get('/diagnoses/:id', async ({ params, request }) => {
|
||||
const user = await getCurrentUser(request.headers.get('cookie'));
|
||||
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 };
|
||||
Reference in New Issue
Block a user