From b347ed4fd178102bcf9654e0a62e2b04f6491f30 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 15:47:03 +0000 Subject: [PATCH 01/23] feat: define production diagnosis DTOs --- packages/shared/src/classifications.ts | 121 +++++++++++++++++++++---- packages/shared/src/index.ts | 12 +++ 2 files changed, 114 insertions(+), 19 deletions(-) diff --git a/packages/shared/src/classifications.ts b/packages/shared/src/classifications.ts index 15b7864..d265aaa 100644 --- a/packages/shared/src/classifications.ts +++ b/packages/shared/src/classifications.ts @@ -1,4 +1,4 @@ -import type { DiseaseCatalogItem, DiseaseSlug, RiskLevel } from './diseases'; +import type { DiseaseCatalogItem, DiseaseSlug } from './diseases'; export type ManualClassificationRequest = { diseaseSlug: DiseaseSlug; @@ -6,36 +6,126 @@ export type ManualClassificationRequest = { location: string; }; -export type ManualClassificationRecord = { +export type ManualClassificationRecord = ManualClassificationRequest & { id: string; - diseaseSlug: DiseaseSlug; - observation: string; - location: string; createdAt: string; disease: DiseaseCatalogItem; }; export type PredictionProbability = { diseaseSlug: DiseaseSlug; - label: DiseaseCatalogItem['label']; + label: string; confidence: number; }; export type UploaderMetadata = { public_id: string; + telegram_file_id?: string; + telegram_file_unique_id?: string; file_name: string; mime_type: string; size_bytes: number; file_type: string; - uploader_id?: number; - created_at: string; - telegram_file_id?: string; - telegram_file_unique_id?: string; - storage_chat_id?: number; - storage_message_id?: number; download_url: string; }; +export type DiagnosisStatus = + | 'ai_verified' + | 'needs_review' + | 'expert_verified' + | 'expert_corrected' + | 'failed'; + +export type UserRole = 'user' | 'expert'; + +export type AuthUser = { + id: string; + email: string; + name: string; + role: UserRole; +}; + +export type AuthFeatures = { + googleOAuthEnabled: boolean; +}; + +export type AuthMeResponse = { + user: AuthUser | null; + features: AuthFeatures; +}; + +export type AuthRequest = { + email: string; + password: string; +}; + +export type RegisterRequest = AuthRequest & { + name: string; +}; + +export type AuthResponse = { + user: AuthUser; + features: AuthFeatures; +}; + +export type DiagnosisPrediction = { + id: string; + diseaseSlug: DiseaseSlug | null; + modelLabel: string; + confidence: number; + rank: number; +}; + +export type ExpertReviewRecord = { + id: string; + diagnosisId: string; + expertId: string; + verdict: 'verified' | 'corrected'; + correctedDiseaseSlug: DiseaseSlug | null; + notes: string; + createdAt: string; + expert: AuthUser; +}; + +export type DiagnosisRecord = { + id: string; + userId: string; + predictedDiseaseSlug: DiseaseSlug | null; + confidence: number | null; + status: DiagnosisStatus; + failureReason: string | null; + imageUrl: string; + uploaderPublicId: string; + imageFileName: string; + imageMimeType: string; + imageSizeBytes: number; + createdAt: string; + updatedAt: string; + disease: DiseaseCatalogItem | null; + predictions: DiagnosisPrediction[]; + latestReview: ExpertReviewRecord | null; +}; + +export type ReviewDiagnosisRequest = { + verdict: 'verified' | 'corrected'; + correctedDiseaseSlug?: DiseaseSlug; + notes: string; +}; + +export type DashboardSummary = { + diseaseCount: number; + classificationCount: number; + imageClassificationCount: number; + needsReviewCount: number; + riskDistribution: { + low: number; + medium: number; + high: number; + }; + latestClassification: ManualClassificationRecord | null; + latestDiagnosis: DiagnosisRecord | null; +}; + export type ImageClassificationRecord = { id: string; predictedDiseaseSlug: DiseaseSlug; @@ -48,10 +138,3 @@ export type ImageClassificationRecord = { createdAt: string; disease: DiseaseCatalogItem; }; - -export type DashboardSummary = { - diseaseCount: number; - classificationCount: number; - latestClassification: ManualClassificationRecord | null; - riskDistribution: Record; -}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ca609a5..6f33fe0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -23,4 +23,16 @@ export type { PredictionProbability, UploaderMetadata, ImageClassificationRecord, + DiagnosisStatus, + UserRole, + AuthUser, + AuthFeatures, + AuthMeResponse, + AuthRequest, + RegisterRequest, + AuthResponse, + DiagnosisPrediction, + ExpertReviewRecord, + DiagnosisRecord, + ReviewDiagnosisRequest, } from './classifications'; From 794abfdb35cccb30ef0c8de36a8e7d77ace6fb38 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 15:53:48 +0000 Subject: [PATCH 02/23] feat: add production auth and diagnosis schema --- apps/api/package.json | 2 + apps/api/src/config/env.ts | 28 +++++++++++++ apps/api/src/db/schema.ts | 81 +++++++++++++++++++++++++++++++++++++- bun.lock | 8 +++- 4 files changed, 117 insertions(+), 2 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index c91f8ff..bac60fe 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,7 @@ "dependencies": { "@tensorflow/tfjs": "4.22.0", "@zeavis/shared": "workspace:*", + "bcryptjs": "^2.4.3", "drizzle-orm": "^0.36.4", "elysia": "^1.1.25", "jpeg-js": "0.4.4", @@ -20,6 +21,7 @@ "postgres": "^3.4.5" }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/bun": "latest", "drizzle-kit": "^0.27.1", "typescript": "^5.6.3" diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index a54a31c..b440e1c 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -1,4 +1,32 @@ +const uploadAllowedMimeTypes = (Bun.env.UPLOAD_ALLOWED_MIME_TYPES ?? 'image/jpeg,image/png') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + +const googleOAuthEnabled = Boolean( + Bun.env.GOOGLE_CLIENT_ID && Bun.env.GOOGLE_CLIENT_SECRET && Bun.env.GOOGLE_REDIRECT_URI, +); + export const env = { port: Number(Bun.env.API_PORT ?? 3000), databaseUrl: Bun.env.DATABASE_URL, + sessionSecret: Bun.env.SESSION_SECRET, + uploaderBaseUrl: Bun.env.UPLOADER_BASE_URL ?? 'https://upload.asepharyana.tech', + uploadMaxBytes: Number(Bun.env.UPLOAD_MAX_BYTES ?? 5 * 1024 * 1024), + uploadAllowedMimeTypes, + googleOAuthEnabled, + googleClientId: Bun.env.GOOGLE_CLIENT_ID, + googleClientSecret: Bun.env.GOOGLE_CLIENT_SECRET, + googleRedirectUri: Bun.env.GOOGLE_REDIRECT_URI, + webAppUrl: Bun.env.WEB_APP_URL ?? 'http://localhost:5173', }; + +export function assertRequiredEnv() { + if (!env.databaseUrl) { + throw new Error('DATABASE_URL is required'); + } + + if (!env.sessionSecret || env.sessionSecret.length < 32) { + throw new Error('SESSION_SECRET is required and must be at least 32 characters'); + } +} diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 2632e22..4695e2a 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,4 +1,15 @@ -import { pgTable, timestamp, uuid, varchar, text, integer, jsonb, real } from 'drizzle-orm/pg-core'; +import { + index, + integer, + jsonb, + pgTable, + real, + text, + timestamp, + uniqueIndex, + uuid, + varchar, +} from 'drizzle-orm/pg-core'; export const appEvents = pgTable('app_events', { id: uuid('id').primaryKey().defaultRandom(), @@ -21,6 +32,31 @@ export const diseaseCatalog = pgTable('disease_catalog', { updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + email: varchar('email', { length: 240 }).notNull(), + name: varchar('name', { length: 160 }).notNull(), + passwordHash: text('password_hash'), + role: varchar('role', { length: 20 }).notNull().default('user'), + googleId: varchar('google_id', { length: 240 }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + emailIdx: uniqueIndex('users_email_idx').on(table.email), + googleIdx: uniqueIndex('users_google_id_idx').on(table.googleId), +})); + +export const sessions = pgTable('sessions', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull().references(() => users.id), + tokenHash: text('token_hash').notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + tokenIdx: uniqueIndex('sessions_token_hash_idx').on(table.tokenHash), + userIdx: index('sessions_user_id_idx').on(table.userId), +})); + export const manualClassifications = pgTable('manual_classifications', { id: uuid('id').primaryKey().defaultRandom(), diseaseSlug: varchar('disease_slug', { length: 80 }) @@ -44,3 +80,46 @@ export const imageClassifications = pgTable('image_classifications', { uploaderPayload: jsonb('uploader_payload').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); + +export const diagnoses = pgTable('diagnoses', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull().references(() => users.id), + predictedDiseaseSlug: varchar('predicted_disease_slug', { length: 80 }).references(() => diseaseCatalog.slug), + confidence: real('confidence'), + status: varchar('status', { length: 40 }).notNull(), + failureReason: text('failure_reason'), + imageUrl: text('image_url').notNull(), + uploaderPublicId: varchar('uploader_public_id', { length: 160 }).notNull(), + imageFileName: varchar('image_file_name', { length: 240 }).notNull(), + imageMimeType: varchar('image_mime_type', { length: 120 }).notNull(), + imageSizeBytes: integer('image_size_bytes').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + userIdx: index('diagnoses_user_id_idx').on(table.userId), + statusIdx: index('diagnoses_status_idx').on(table.status), +})); + +export const diagnosisPredictions = pgTable('diagnosis_predictions', { + id: uuid('id').primaryKey().defaultRandom(), + diagnosisId: uuid('diagnosis_id').notNull().references(() => diagnoses.id), + diseaseSlug: varchar('disease_slug', { length: 80 }).references(() => diseaseCatalog.slug), + modelLabel: varchar('model_label', { length: 120 }).notNull(), + confidence: real('confidence').notNull(), + rank: integer('rank').notNull(), +}, (table) => ({ + diagnosisIdx: index('diagnosis_predictions_diagnosis_id_idx').on(table.diagnosisId), +})); + +export const expertReviews = pgTable('expert_reviews', { + id: uuid('id').primaryKey().defaultRandom(), + diagnosisId: uuid('diagnosis_id').notNull().references(() => diagnoses.id), + expertId: uuid('expert_id').notNull().references(() => users.id), + verdict: varchar('verdict', { length: 20 }).notNull(), + correctedDiseaseSlug: varchar('corrected_disease_slug', { length: 80 }).references(() => diseaseCatalog.slug), + notes: text('notes').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + diagnosisIdx: index('expert_reviews_diagnosis_id_idx').on(table.diagnosisId), + expertIdx: index('expert_reviews_expert_id_idx').on(table.expertId), +})); diff --git a/bun.lock b/bun.lock index 146b9ab..0382858 100755 --- a/bun.lock +++ b/bun.lock @@ -13,6 +13,7 @@ "dependencies": { "@tensorflow/tfjs": "4.22.0", "@zeavis/shared": "packages/shared", + "bcryptjs": "^2.4.3", "drizzle-orm": "^0.36.4", "elysia": "^1.1.25", "jpeg-js": "0.4.4", @@ -20,6 +21,7 @@ "postgres": "^3.4.5", }, "devDependencies": { + "@types/bcryptjs": "^2.4.6", "@types/bun": "latest", "drizzle-kit": "^0.27.1", "typescript": "^5.6.3", @@ -283,6 +285,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/bcryptjs": ["@types/bcryptjs@2.4.6", "", {}, "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -307,7 +311,7 @@ "@webgpu/types": ["@webgpu/types@0.1.38", "", {}, "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA=="], - "@zeavis/api": ["@zeavis/api@workspace:apps/api", { "dependencies": { "@tensorflow/tfjs": "4.22.0", "@zeavis/shared": "packages/shared", "drizzle-orm": "^0.36.4", "elysia": "^1.1.25", "jpeg-js": "0.4.4", "pngjs": "7.0.0", "postgres": "^3.4.5" }, "devDependencies": { "@types/bun": "latest", "drizzle-kit": "^0.27.1", "typescript": "^5.6.3" } }], + "@zeavis/api": ["@zeavis/api@workspace:apps/api", { "dependencies": { "@tensorflow/tfjs": "4.22.0", "@zeavis/shared": "packages/shared", "bcryptjs": "^2.4.3", "drizzle-orm": "^0.36.4", "elysia": "^1.1.25", "jpeg-js": "0.4.4", "pngjs": "7.0.0", "postgres": "^3.4.5" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/bun": "latest", "drizzle-kit": "^0.27.1", "typescript": "^5.6.3" } }], "@zeavis/shared": ["@zeavis/shared@workspace:packages/shared", { "devDependencies": { "typescript": "^5.6.3" } }], @@ -331,6 +335,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q=="], + "bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="], + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], From 5eb195cbeb917405f3c9e57f6b7312bbc2d86bf2 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 15:53:48 +0000 Subject: [PATCH 03/23] feat: add session auth routes --- apps/api/src/index.ts | 6 +- apps/api/src/lib/auth.ts | 113 +++++++++++++++++++++++++++++ apps/api/src/routes/auth.ts | 141 ++++++++++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/lib/auth.ts create mode 100644 apps/api/src/routes/auth.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d903997..3a14c38 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1,14 +1,18 @@ import { Elysia } from 'elysia'; -import { env } from './config/env'; +import { env, assertRequiredEnv } from './config/env'; import { healthRoutes } from './routes/health'; import { statusRoutes } from './routes/status'; import { diseaseRoutes } from './routes/diseases'; import { classificationRoutes } from './routes/classifications'; import { dashboardRoutes } from './routes/dashboard'; +import { authRoutes } from './routes/auth'; + +assertRequiredEnv(); const app = new Elysia() .use(healthRoutes) .use(statusRoutes) + .use(authRoutes) .use(diseaseRoutes) .use(classificationRoutes) .use(dashboardRoutes) diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts new file mode 100644 index 0000000..543df19 --- /dev/null +++ b/apps/api/src/lib/auth.ts @@ -0,0 +1,113 @@ +import { createHash, randomBytes } from 'crypto'; +import bcrypt from 'bcryptjs'; +import { and, eq, gt } from 'drizzle-orm'; +import type { AuthUser } from '@zeavis/shared'; +import { createDbClient } from '../db/client'; +import { sessions, users } from '../db/schema'; +import { env } from '../config/env'; + +export const sessionCookieName = 'zeavis_session'; + +export type CurrentUser = AuthUser; + +export function getAuthFeatures() { + return { + googleOAuthEnabled: env.googleOAuthEnabled, + }; +} + +export async function hashPassword(password: string) { + return bcrypt.hash(password, 12); +} + +export async function verifyPassword(password: string, passwordHash: string) { + return bcrypt.compare(password, passwordHash); +} + +function hashToken(token: string) { + return createHash('sha256').update(`${env.sessionSecret}:${token}`).digest('hex'); +} + +export function createSessionCookie(token: string) { + const maxAge = 60 * 60 * 24 * 30; + return `${sessionCookieName}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=${maxAge}`; +} + +export function clearSessionCookie() { + return `${sessionCookieName}=; HttpOnly; Path=/; SameSite=Lax; Max-Age=0`; +} + +export function readSessionToken(cookieHeader: string | null | undefined) { + if (!cookieHeader) return null; + + const cookies = cookieHeader.split(';').map((part) => part.trim()); + const sessionCookie = cookies.find((cookie) => cookie.startsWith(`${sessionCookieName}=`)); + if (!sessionCookie) return null; + + return decodeURIComponent(sessionCookie.slice(sessionCookieName.length + 1)); +} + +export async function createSession(userId: string) { + const db = createDbClient(); + const token = randomBytes(32).toString('base64url'); + const tokenHash = hashToken(token); + const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 30); + + await db.insert(sessions).values({ + userId, + tokenHash, + expiresAt, + }); + + return token; +} + +export async function deleteSession(token: string | null) { + if (!token) return; + + const db = createDbClient(); + await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token))); +} + +export async function getCurrentUser(cookieHeader: string | null | undefined): Promise { + const token = readSessionToken(cookieHeader); + if (!token) return null; + + const db = createDbClient(); + const rows = await db + .select({ + id: users.id, + email: users.email, + name: users.name, + role: users.role, + }) + .from(sessions) + .innerJoin(users, eq(sessions.userId, users.id)) + .where(and(eq(sessions.tokenHash, hashToken(token)), gt(sessions.expiresAt, new Date()))) + .limit(1); + + const user = rows[0]; + if (!user) return null; + + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role === 'expert' ? 'expert' : 'user', + }; +} + +export function requireUser(user: CurrentUser | null): CurrentUser { + if (!user) { + throw new Error('UNAUTHORIZED'); + } + return user; +} + +export function requireExpert(user: CurrentUser | null): CurrentUser { + const currentUser = requireUser(user); + if (currentUser.role !== 'expert') { + throw new Error('FORBIDDEN'); + } + return currentUser; +} diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..2d4fc81 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,141 @@ +import { Elysia } from 'elysia'; +import type { AuthRequest, RegisterRequest } from '@zeavis/shared'; +import { eq } from 'drizzle-orm'; +import { createDbClient } from '../db/client'; +import { users } from '../db/schema'; +import { badRequest, serviceUnavailable } from '../lib/http-errors'; +import { + clearSessionCookie, + createSession, + createSessionCookie, + deleteSession, + getAuthFeatures, + getCurrentUser, + hashPassword, + readSessionToken, + verifyPassword, +} from '../lib/auth'; +import { env } from '../config/env'; + +function normalizeEmail(email: unknown) { + return typeof email === 'string' ? email.trim().toLowerCase() : ''; +} + +function normalizeName(name: unknown) { + return typeof name === 'string' ? name.trim() : ''; +} + +function validatePassword(password: unknown) { + return typeof password === 'string' && password.length >= 8; +} + +export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) + .get('/me', async ({ request }) => { + const user = await getCurrentUser(request.headers.get('cookie')); + return { + user, + features: getAuthFeatures(), + }; + }) + .post('/register', async ({ body, set }) => { + const req = body as Partial | undefined; + const email = normalizeEmail(req?.email); + const name = normalizeName(req?.name); + + if (!email || !email.includes('@') || !name || !validatePassword(req?.password)) { + return badRequest('Name, valid email, and password with at least 8 characters are required'); + } + + try { + const db = createDbClient(); + const existing = await db.select().from(users).where(eq(users.email, email)).limit(1); + if (existing.length > 0) { + return badRequest('Email is already registered'); + } + + const passwordHash = await hashPassword(req!.password!); + const inserted = await db + .insert(users) + .values({ email, name, passwordHash, role: 'user' }) + .returning(); + + const user = inserted[0]; + const token = await createSession(user.id); + set.headers['Set-Cookie'] = createSessionCookie(token); + + return { + user: { + id: user.id, + email: user.email, + name: user.name, + role: 'user' as const, + }, + features: getAuthFeatures(), + }; + } catch (error) { + return serviceUnavailable('Database unavailable'); + } + }) + .post('/login', async ({ body, set }) => { + const req = body as Partial | undefined; + const email = normalizeEmail(req?.email); + + if (!email || !validatePassword(req?.password)) { + return badRequest('Valid email and password are required'); + } + + try { + const db = createDbClient(); + const rows = await db.select().from(users).where(eq(users.email, email)).limit(1); + const user = rows[0]; + + if (!user?.passwordHash || !(await verifyPassword(req!.password!, user.passwordHash))) { + return badRequest('Invalid email or password'); + } + + const token = await createSession(user.id); + set.headers['Set-Cookie'] = createSessionCookie(token); + + return { + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role === 'expert' ? 'expert' as const : 'user' as const, + }, + features: getAuthFeatures(), + }; + } catch (error) { + return serviceUnavailable('Database unavailable'); + } + }) + .post('/logout', async ({ request, set }) => { + await deleteSession(readSessionToken(request.headers.get('cookie'))); + set.headers['Set-Cookie'] = clearSessionCookie(); + return { ok: true }; + }) + .get('/google', ({ set }) => { + if (!env.googleOAuthEnabled) { + set.status = 404; + return { error: 'Google OAuth is not configured' }; + } + + const params = new URLSearchParams({ + client_id: env.googleClientId!, + redirect_uri: env.googleRedirectUri!, + response_type: 'code', + scope: 'openid email profile', + access_type: 'offline', + prompt: 'select_account', + }); + + set.redirect = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; + }) + .get('/google/callback', ({ set }) => { + if (!env.googleOAuthEnabled) { + set.status = 404; + return { error: 'Google OAuth is not configured' }; + } + + set.redirect = `${env.webAppUrl}/login?oauth=not-implemented`; + }); From 1a36ae5cbc6e1d57525f9802125306af480c08c4 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 16:08:18 +0000 Subject: [PATCH 04/23] 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 --- apps/api/src/index.ts | 2 + apps/api/src/lib/http-errors.ts | 14 ++ apps/api/src/lib/uploader-client.ts | 26 +-- apps/api/src/routes/diagnoses.ts | 238 ++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/routes/diagnoses.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3a14c38..4c07b2c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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); diff --git a/apps/api/src/lib/http-errors.ts b/apps/api/src/lib/http-errors.ts index 674a196..115ff04 100644 --- a/apps/api/src/lib/http-errors.ts +++ b/apps/api/src/lib/http-errors.ts @@ -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, diff --git a/apps/api/src/lib/uploader-client.ts b/apps/api/src/lib/uploader-client.ts index 526901d..bcdfcb8 100644 --- a/apps/api/src/lib/uploader-client.ts +++ b/apps/api/src/lib/uploader-client.ts @@ -1,28 +1,34 @@ import type { UploaderMetadata } from '@zeavis/shared'; +import { env } from '../config/env'; export async function uploadImageToStorage(file: File): Promise { 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; + const payload = await response.json() as Partial; - 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, + }; } diff --git a/apps/api/src/routes/diagnoses.ts b/apps/api/src/routes/diagnoses.ts new file mode 100644 index 0000000..75339f0 --- /dev/null +++ b/apps/api/src/routes/diagnoses.ts @@ -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 { + 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; + 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 }; From 2e692a4b8c7a096588bdd7d378c4e64ae1be521e Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 16:15:45 +0000 Subject: [PATCH 05/23] fix: tighten diagnosis route typing Replace unsafe `as any` casts with proper type guards using `isDiseaseSlug()` and explicit type casting to `DiagnosisStatus` and `DiseaseCatalogItem`. Replace non-null assertions with explicit null checks that return `serviceUnavailable()` errors when database operations fail. Co-Authored-By: Claude Opus 4.7 --- apps/api/src/routes/diagnoses.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/apps/api/src/routes/diagnoses.ts b/apps/api/src/routes/diagnoses.ts index 75339f0..4bbe740 100644 --- a/apps/api/src/routes/diagnoses.ts +++ b/apps/api/src/routes/diagnoses.ts @@ -1,5 +1,6 @@ import { Elysia } from 'elysia'; -import type { DiagnosisPrediction, DiagnosisRecord, ExpertReviewRecord } from '@zeavis/shared'; +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'; @@ -7,7 +8,6 @@ import { badGateway, badRequest, notFound, serviceUnavailable, unauthorized } fr 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 { @@ -93,18 +93,22 @@ async function loadDiagnosisRecord(id: string, userId: string | null, expertAcce const predictions: DiagnosisPrediction[] = predictionRows.map((prediction) => ({ id: prediction.id, - diseaseSlug: prediction.diseaseSlug as any, + 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 ? (row.disease as DiseaseCatalogItem) : null; + return { id: row.id, userId: row.userId, - predictedDiseaseSlug: row.predictedDiseaseSlug as any, + predictedDiseaseSlug, confidence: row.confidence, - status: row.status as any, + status, failureReason: row.failureReason, imageUrl: row.imageUrl, uploaderPublicId: row.uploaderPublicId, @@ -113,7 +117,7 @@ async function loadDiagnosisRecord(id: string, userId: string | null, expertAcce imageSizeBytes: row.imageSizeBytes, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), - disease: row.disease?.slug ? toDisease(row.disease as any) : null, + disease, predictions, latestReview: toReview(row), }; @@ -183,7 +187,8 @@ export const diagnosisRoutes = new Elysia({ prefix: '/api/v1' }) ); const record = await loadDiagnosisRecord(inserted[0].id, user.id, false); - return record!; + if (!record) return serviceUnavailable('Database unavailable'); + return record; } catch (error) { const inserted = await db .insert(diagnoses) @@ -200,7 +205,8 @@ export const diagnosisRoutes = new Elysia({ prefix: '/api/v1' }) .returning(); const record = await loadDiagnosisRecord(inserted[0].id, user.id, false); - return record!; + if (!record) return serviceUnavailable('Database unavailable'); + return record; } }) .get('/diagnoses', async ({ request }) => { From afa2e4999b5bfd1ae23136cb5b90a7ed9288c3e2 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 16:19:10 +0000 Subject: [PATCH 06/23] fix: type diagnosis record mapping - Replace `function toReview(row: any)` with typed ReviewRow interface - Reconstruct DiseaseCatalogItem explicitly from selected fields instead of casting - Replace `.filter(Boolean)` with typed isDiagnosisRecord predicate - Add proper type narrowing for verdict and correctedDiseaseSlug in toReview Co-Authored-By: Claude Opus 4.7 --- apps/api/src/routes/diagnoses.ts | 58 +++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/apps/api/src/routes/diagnoses.ts b/apps/api/src/routes/diagnoses.ts index 4bbe740..4d8f1fd 100644 --- a/apps/api/src/routes/diagnoses.ts +++ b/apps/api/src/routes/diagnoses.ts @@ -10,26 +10,47 @@ import { classifyImage } from '../lib/image-model'; import { uploadImageToStorage } from '../lib/uploader-client'; import { env } from '../config/env'; -function toReview(row: any): ExpertReviewRecord | null { +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: row.reviewVerdict, - correctedDiseaseSlug: row.reviewCorrectedDiseaseSlug, - notes: row.reviewNotes, - createdAt: row.reviewCreatedAt.toISOString(), + diagnosisId: row.reviewDiagnosisId!, + expertId: row.reviewExpertId!, + verdict, + correctedDiseaseSlug, + notes, + createdAt: row.reviewCreatedAt!.toISOString(), expert: { - id: row.expertId, - email: row.expertEmail, - name: row.expertName, + 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 { const db = createDbClient(); const filters = [eq(diagnoses.id, id)]; @@ -101,7 +122,20 @@ async function loadDiagnosisRecord(id: string, userId: string | null, expertAcce const predictedDiseaseSlug = row.predictedDiseaseSlug && isDiseaseSlug(row.predictedDiseaseSlug) ? row.predictedDiseaseSlug : null; const status = row.status as DiagnosisStatus; - const disease = row.disease && row.disease.slug ? (row.disease as DiseaseCatalogItem) : null; + 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, @@ -223,7 +257,7 @@ export const diagnosisRoutes = new Elysia({ prefix: '/api/v1' }) .limit(30); const records = await Promise.all(rows.map((row) => loadDiagnosisRecord(row.id, user.id, false))); - return records.filter(Boolean); + return records.filter(isDiagnosisRecord); } catch (error) { return serviceUnavailable('Database unavailable'); } From 59204b0fdedf1244d7a89abb2bb249e3a7471700 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 16:23:40 +0000 Subject: [PATCH 07/23] feat: add expert diagnosis review workflow --- apps/api/src/index.ts | 2 + apps/api/src/routes/expert.ts | 89 +++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 apps/api/src/routes/expert.ts 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'); + } + }); From d72eaffb6b7caca6e5c19a5e77beb09e13e795f6 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 16:30:17 +0000 Subject: [PATCH 08/23] fix: guard expert review state - Reject diagnoses not in needs_review status with badRequest - Make update conditional on both id and status needs_review using and() - Simplify isDiagnosisRecordOrNull type guard with type alias Co-Authored-By: Claude Opus 4.7 --- apps/api/src/routes/expert.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/api/src/routes/expert.ts b/apps/api/src/routes/expert.ts index fb3a467..dc1a9fb 100644 --- a/apps/api/src/routes/expert.ts +++ b/apps/api/src/routes/expert.ts @@ -1,14 +1,16 @@ import { Elysia } from 'elysia'; import type { ReviewDiagnosisRequest } from '@zeavis/shared'; import { isDiseaseSlug } from '@zeavis/shared'; -import { desc, eq } from 'drizzle-orm'; +import { and, 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 { +type DiagnosisRecord = Awaited>; + +function isDiagnosisRecordOrNull(record: unknown): record is DiagnosisRecord { return record !== null; } @@ -62,6 +64,7 @@ export const expertRoutes = new Elysia({ prefix: '/api/v1/expert' }) const diagnosis = existing[0]; if (!diagnosis) return notFound('Diagnosis not found'); + if (diagnosis.status !== 'needs_review') return badRequest('Diagnosis is not pending review'); const correctedSlug = req.verdict === 'corrected' ? req.correctedDiseaseSlug : null; @@ -80,7 +83,7 @@ export const expertRoutes = new Elysia({ prefix: '/api/v1/expert' }) predictedDiseaseSlug: req.verdict === 'corrected' ? correctedSlug : diagnosis.predictedDiseaseSlug, updatedAt: new Date(), }) - .where(eq(diagnoses.id, diagnosis.id)); + .where(and(eq(diagnoses.id, diagnosis.id), eq(diagnoses.status, 'needs_review'))); return await loadDiagnosisRecord(diagnosis.id, null, true); } catch (error) { From 7712a5c6a1997b9208fb9a47358d359f68c0c131 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 16:34:02 +0000 Subject: [PATCH 09/23] fix: prevent duplicate expert reviews In POST /reviews/:diagnosisId, perform the conditional diagnoses update first with .returning() to determine whether a row was updated. The update WHERE includes both id and status 'needs_review'. If no row is returned, return badRequest and do not insert an expert review. Only after a successful update insert expertReviews. This prevents concurrent reviewers from both inserting review rows when only one update should win. Co-Authored-By: Claude Opus 4.7 --- apps/api/src/routes/expert.ts | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/api/src/routes/expert.ts b/apps/api/src/routes/expert.ts index dc1a9fb..c922885 100644 --- a/apps/api/src/routes/expert.ts +++ b/apps/api/src/routes/expert.ts @@ -64,10 +64,25 @@ export const expertRoutes = new Elysia({ prefix: '/api/v1/expert' }) const diagnosis = existing[0]; if (!diagnosis) return notFound('Diagnosis not found'); - if (diagnosis.status !== 'needs_review') return badRequest('Diagnosis is not pending review'); const correctedSlug = req.verdict === 'corrected' ? req.correctedDiseaseSlug : null; + // Perform conditional update first to determine if this review should proceed + const updated = await db + .update(diagnoses) + .set({ + status: req.verdict === 'corrected' ? 'expert_corrected' : 'expert_verified', + predictedDiseaseSlug: req.verdict === 'corrected' ? correctedSlug : diagnosis.predictedDiseaseSlug, + updatedAt: new Date(), + }) + .where(and(eq(diagnoses.id, diagnosis.id), eq(diagnoses.status, 'needs_review'))) + .returning(); + + if (updated.length === 0) { + return badRequest('Diagnosis is not pending review'); + } + + // Only insert expert review after successful update await db.insert(expertReviews).values({ diagnosisId: diagnosis.id, expertId: user.id, @@ -76,15 +91,6 @@ export const expertRoutes = new Elysia({ prefix: '/api/v1/expert' }) 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(and(eq(diagnoses.id, diagnosis.id), eq(diagnoses.status, 'needs_review'))); - return await loadDiagnosisRecord(diagnosis.id, null, true); } catch (error) { return serviceUnavailable('Database unavailable'); From 02b002b021e598052f716dbb279c4ef2c388e0f0 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 16:36:53 +0000 Subject: [PATCH 10/23] feat: update dashboard for diagnosis workflow --- apps/api/src/db/seed-data.ts | 10 ++- apps/api/src/routes/dashboard.ts | 140 ++++++++++++++----------------- 2 files changed, 72 insertions(+), 78 deletions(-) diff --git a/apps/api/src/db/seed-data.ts b/apps/api/src/db/seed-data.ts index 2380cc5..5dc1601 100644 --- a/apps/api/src/db/seed-data.ts +++ b/apps/api/src/db/seed-data.ts @@ -1,8 +1,9 @@ import { diseaseCatalogSeed } from '@zeavis/shared'; import type { InferInsertModel } from 'drizzle-orm'; -import { diseaseCatalog } from './schema'; +import { diseaseCatalog, users } from './schema'; export type DiseaseCatalogRow = InferInsertModel; +export type UserRow = InferInsertModel; export const diseaseCatalogRows: DiseaseCatalogRow[] = diseaseCatalogSeed.map((item) => ({ slug: item.slug, @@ -16,3 +17,10 @@ export const diseaseCatalogRows: DiseaseCatalogRow[] = diseaseCatalogSeed.map((i accentColor: item.accentColor, displayOrder: item.displayOrder, })); + +export const demoExpertUser = { + email: 'expert@zeavis.local', + name: 'ZeaVis Expert', + role: 'expert' as const, + password: 'password123', +}; diff --git a/apps/api/src/routes/dashboard.ts b/apps/api/src/routes/dashboard.ts index dab1f08..ebb72a3 100644 --- a/apps/api/src/routes/dashboard.ts +++ b/apps/api/src/routes/dashboard.ts @@ -1,88 +1,74 @@ import { Elysia } from 'elysia'; -import type { DashboardSummary, RiskLevel } from '@zeavis/shared'; +import type { DashboardSummary } from '@zeavis/shared'; +import { isDiseaseSlug } from '@zeavis/shared'; +import { eq, desc, count } from 'drizzle-orm'; import { createDbClient } from '../db/client'; -import { diseaseCatalog, imageClassifications, manualClassifications } from '../db/schema'; +import { diseaseCatalog, manualClassifications, diagnoses } from '../db/schema'; import { serviceUnavailable } from '../lib/http-errors'; -import { desc, eq } from 'drizzle-orm'; +import { toDisease } from '../lib/disease-mappers'; +import { getCurrentUser } from '../lib/auth'; +import { loadDiagnosisRecord } from './diagnoses'; -export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }).get('/dashboard/summary', async () => { - try { - const db = createDbClient(); +export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }) + .get('/dashboard/summary', async ({ request }) => { + try { + const db = createDbClient(); + const user = await getCurrentUser(request.headers.get('cookie')); - const diseaseCount = await db.select().from(diseaseCatalog); - const manualClassificationCount = await db.select().from(manualClassifications); - const imageClassificationCount = await db.select().from(imageClassifications); + const diseases = await db.select().from(diseaseCatalog).orderBy(diseaseCatalog.displayOrder); + const manualRows = await db.select().from(manualClassifications).orderBy(desc(manualClassifications.createdAt)).limit(1); - const latestClassificationRow = await db - .select({ - id: manualClassifications.id, - diseaseSlug: manualClassifications.diseaseSlug, - observation: manualClassifications.observation, - location: manualClassifications.location, - createdAt: manualClassifications.createdAt, - 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, + const diagnosisFilters = user ? eq(diagnoses.userId, user.id) : undefined; + const diagnosisRows = diagnosisFilters + ? await db.select({ id: diagnoses.id }).from(diagnoses).where(diagnosisFilters).orderBy(desc(diagnoses.createdAt)).limit(1) + : []; + + const diagnosisCountRows = user + ? await db.select({ value: count() }).from(diagnoses).where(eq(diagnoses.userId, user.id)) + : [{ value: 0 }]; + + const needsReviewRows = user?.role === 'expert' + ? await db.select({ value: count() }).from(diagnoses).where(eq(diagnoses.status, 'needs_review')) + : [{ value: 0 }]; + + const latestDiagnosis = diagnosisRows[0] + ? await loadDiagnosisRecord(diagnosisRows[0].id, user?.id ?? null, user?.role === 'expert') + : null; + + const riskDistribution = diseases.reduce( + (acc, disease) => { + if (disease.riskLevel === 'high') acc.high += 1; + if (disease.riskLevel === 'medium') acc.medium += 1; + if (disease.riskLevel === 'low') acc.low += 1; + return acc; }, - }) - .from(manualClassifications) - .innerJoin(diseaseCatalog, eq(manualClassifications.diseaseSlug, diseaseCatalog.slug)) - .orderBy(desc(manualClassifications.createdAt)) - .limit(1); + { low: 0, medium: 0, high: 0 }, + ); - const latestClassification = latestClassificationRow[0] - ? { - id: latestClassificationRow[0].id, - diseaseSlug: latestClassificationRow[0].diseaseSlug as any, - observation: latestClassificationRow[0].observation, - location: latestClassificationRow[0].location, - createdAt: latestClassificationRow[0].createdAt.toISOString(), - disease: { - slug: latestClassificationRow[0].disease.slug as any, - label: latestClassificationRow[0].disease.label as any, - commonName: latestClassificationRow[0].disease.commonName, - summary: latestClassificationRow[0].disease.summary, - description: latestClassificationRow[0].disease.description, - symptoms: latestClassificationRow[0].disease.symptoms, - recommendations: latestClassificationRow[0].disease.recommendations, - riskLevel: latestClassificationRow[0].disease.riskLevel as any, - accentColor: latestClassificationRow[0].disease.accentColor, - displayOrder: latestClassificationRow[0].disease.displayOrder, - }, - } - : null; + const latestManual = manualRows[0]; + const latestDisease = latestManual + ? diseases.find((disease) => disease.slug === latestManual.diseaseSlug) + : null; - const riskDistributionRows = await db.select().from(diseaseCatalog); - const riskDistribution: Record = { - low: 0, - medium: 0, - high: 0, - }; + const summary: DashboardSummary = { + diseaseCount: diseases.length, + classificationCount: manualRows.length, + imageClassificationCount: diagnosisCountRows[0]?.value ?? 0, + needsReviewCount: needsReviewRows[0]?.value ?? 0, + riskDistribution, + latestClassification: latestManual && latestDisease ? { + id: latestManual.id, + diseaseSlug: latestManual.diseaseSlug as any, + observation: latestManual.observation, + location: latestManual.location, + createdAt: latestManual.createdAt.toISOString(), + disease: toDisease(latestDisease), + } : null, + latestDiagnosis, + }; - for (const row of riskDistributionRows) { - const risk = row.riskLevel as RiskLevel; - if (risk in riskDistribution) { - riskDistribution[risk]++; - } + return summary; + } catch (error) { + return serviceUnavailable('Database unavailable'); } - - const summary: DashboardSummary = { - diseaseCount: diseaseCount.length, - classificationCount: manualClassificationCount.length + imageClassificationCount.length, - latestClassification, - riskDistribution, - }; - - return summary; - } catch (error) { - return serviceUnavailable('Database unavailable'); - } -}); + }); From e9d6aabcdc3362078b3864426e1c183c1f3c93ec Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 16:39:38 +0000 Subject: [PATCH 11/23] fix: tighten dashboard summary types --- apps/api/src/db/seed-data.ts | 2 +- apps/api/src/routes/dashboard.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/api/src/db/seed-data.ts b/apps/api/src/db/seed-data.ts index 5dc1601..09fad02 100644 --- a/apps/api/src/db/seed-data.ts +++ b/apps/api/src/db/seed-data.ts @@ -22,5 +22,5 @@ export const demoExpertUser = { email: 'expert@zeavis.local', name: 'ZeaVis Expert', role: 'expert' as const, - password: 'password123', + password: Bun.env.DEMO_EXPERT_PASSWORD ?? 'password123', }; diff --git a/apps/api/src/routes/dashboard.ts b/apps/api/src/routes/dashboard.ts index ebb72a3..e0a3aa8 100644 --- a/apps/api/src/routes/dashboard.ts +++ b/apps/api/src/routes/dashboard.ts @@ -46,7 +46,7 @@ export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }) ); const latestManual = manualRows[0]; - const latestDisease = latestManual + const latestDisease = latestManual && isDiseaseSlug(latestManual.diseaseSlug) ? diseases.find((disease) => disease.slug === latestManual.diseaseSlug) : null; @@ -56,9 +56,9 @@ export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }) imageClassificationCount: diagnosisCountRows[0]?.value ?? 0, needsReviewCount: needsReviewRows[0]?.value ?? 0, riskDistribution, - latestClassification: latestManual && latestDisease ? { + latestClassification: latestManual && isDiseaseSlug(latestManual.diseaseSlug) && latestDisease ? { id: latestManual.id, - diseaseSlug: latestManual.diseaseSlug as any, + diseaseSlug: latestManual.diseaseSlug, observation: latestManual.observation, location: latestManual.location, createdAt: latestManual.createdAt.toISOString(), From 16a8935958773c11bda97d87de2e1569997548d4 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 16:43:25 +0000 Subject: [PATCH 12/23] feat: add frontend production API client --- apps/web/src/lib/api-client.ts | 141 +++++++++++++++++++++++++++++-- apps/web/src/store/auth-store.ts | 12 +++ 2 files changed, 146 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/store/auth-store.ts diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 3964a2f..6691b6e 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -1,9 +1,15 @@ import type { - DiseaseSlug, + AuthMeResponse, + AuthRequest, + AuthResponse, + DiagnosisRecord, DiseaseCatalogItem, + DiseaseSlug, + ImageClassificationRecord, ManualClassificationRequest, ManualClassificationRecord, - ImageClassificationRecord, + RegisterRequest, + ReviewDiagnosisRequest, DashboardSummary, } from '@zeavis/shared'; @@ -11,7 +17,11 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? ''; async function fetchApi(endpoint: string, options?: RequestInit): Promise { const url = `${apiBaseUrl}${endpoint}`; - const response = await fetch(url, options); + const response = await fetch(url, { + credentials: 'include', + ...options, + headers: options?.headers, + }); if (!response.ok) { let errorMessage = `HTTP ${response.status}`; @@ -29,6 +39,32 @@ async function fetchApi(endpoint: string, options?: RequestInit): Promise } export const apiClient = { + // Auth methods + async getMe(): Promise { + return fetchApi('/api/v1/auth/me'); + }, + + async register(payload: RegisterRequest): Promise { + return fetchApi('/api/v1/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + }, + + async login(payload: AuthRequest): Promise { + return fetchApi('/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + }, + + async logout(): Promise<{ ok: boolean }> { + return fetchApi('/api/v1/auth/logout', { method: 'POST' }); + }, + + // Disease catalog methods async getDiseases(): Promise { return fetchApi('/api/v1/diseases'); }, @@ -37,6 +73,7 @@ export const apiClient = { return fetchApi(`/api/v1/diseases/${slug}`); }, + // Manual classification methods async getManualClassifications(): Promise { return fetchApi('/api/v1/classifications/manual'); }, @@ -53,21 +90,111 @@ export const apiClient = { }); }, - async getImageClassifications(): Promise { - return fetchApi('/api/v1/classifications/image'); + // Diagnosis methods + async getDiagnoses(): Promise { + return fetchApi('/api/v1/diagnoses'); }, - async createImageClassification(file: File): Promise { + async getDiagnosis(id: string): Promise { + return fetchApi(`/api/v1/diagnoses/${id}`); + }, + + async createDiagnosis(file: File): Promise { const formData = new FormData(); formData.append('file', file); - return fetchApi('/api/v1/classifications/image', { + return fetchApi('/api/v1/diagnoses', { method: 'POST', body: formData, }); }, + // Expert review methods + async getExpertReviews(): Promise { + return fetchApi('/api/v1/expert/reviews'); + }, + + async reviewDiagnosis(id: string, payload: ReviewDiagnosisRequest): Promise { + return fetchApi(`/api/v1/expert/reviews/${id}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + }, + + // Dashboard methods async getDashboardSummary(): Promise { return fetchApi('/api/v1/dashboard/summary'); }, + + // Backwards-compatible aliases for old image classification methods + async getImageClassifications(): Promise { + const diagnoses = await this.getDiagnoses(); + return diagnoses.map((d) => ({ + id: d.id, + predictedDiseaseSlug: d.predictedDiseaseSlug || 'daun-sehat', + confidence: d.confidence || 0, + probabilities: d.predictions.map((p) => ({ + diseaseSlug: p.diseaseSlug || 'daun-sehat', + label: p.modelLabel, + confidence: p.confidence, + })), + imageUrl: d.imageUrl, + originalFileName: d.imageFileName, + uploaderPublicId: d.uploaderPublicId, + uploader: { + public_id: d.uploaderPublicId, + file_name: d.imageFileName, + mime_type: d.imageMimeType, + size_bytes: d.imageSizeBytes, + file_type: d.imageMimeType.split('/')[1] || 'unknown', + download_url: d.imageUrl, + }, + createdAt: d.createdAt, + disease: d.disease || { + slug: 'daun-sehat', + label: 'Daun Sehat', + commonName: 'Healthy Leaf', + description: '', + symptoms: [], + treatment: [], + riskLevel: 'low', + }, + })) as ImageClassificationRecord[]; + }, + + async createImageClassification(file: File): Promise { + const diagnosis = await this.createDiagnosis(file); + return { + id: diagnosis.id, + predictedDiseaseSlug: diagnosis.predictedDiseaseSlug || 'daun-sehat', + confidence: diagnosis.confidence || 0, + probabilities: diagnosis.predictions.map((p) => ({ + diseaseSlug: p.diseaseSlug || 'daun-sehat', + label: p.modelLabel, + confidence: p.confidence, + })), + imageUrl: diagnosis.imageUrl, + originalFileName: diagnosis.imageFileName, + uploaderPublicId: diagnosis.uploaderPublicId, + uploader: { + public_id: diagnosis.uploaderPublicId, + file_name: diagnosis.imageFileName, + mime_type: diagnosis.imageMimeType, + size_bytes: diagnosis.imageSizeBytes, + file_type: diagnosis.imageMimeType.split('/')[1] || 'unknown', + download_url: diagnosis.imageUrl, + }, + createdAt: diagnosis.createdAt, + disease: diagnosis.disease || { + slug: 'daun-sehat', + label: 'Daun Sehat', + commonName: 'Healthy Leaf', + description: '', + symptoms: [], + treatment: [], + riskLevel: 'low', + }, + } as ImageClassificationRecord; + }, }; diff --git a/apps/web/src/store/auth-store.ts b/apps/web/src/store/auth-store.ts new file mode 100644 index 0000000..aac63e5 --- /dev/null +++ b/apps/web/src/store/auth-store.ts @@ -0,0 +1,12 @@ +import { create } from 'zustand'; +import type { AuthUser } from '@zeavis/shared'; + +type AuthState = { + user: AuthUser | null; + setUser: (user: AuthUser | null) => void; +}; + +export const useAuthStore = create((set) => ({ + user: null, + setUser: (user) => set({ user }), +})); From 9250696f542a32071050415a53f526d41ef13a08 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 16:46:43 +0000 Subject: [PATCH 13/23] fix: simplify diagnosis API aliases Extract toImageClassificationRecord helper function to eliminate duplicated transformation code between getImageClassifications and createImageClassification. Replace hardcoded fake disease object with explicit error when diagnosis.disease is null, ensuring data integrity. Remove unnecessary type casts now that the helper is properly typed. Co-Authored-By: Claude Opus 4.7 --- apps/web/src/lib/api-client.ts | 94 ++++++++++++---------------------- 1 file changed, 32 insertions(+), 62 deletions(-) diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 6691b6e..cd93ede 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -38,6 +38,36 @@ async function fetchApi(endpoint: string, options?: RequestInit): Promise return response.json(); } +function toImageClassificationRecord(diagnosis: DiagnosisRecord): ImageClassificationRecord { + if (!diagnosis.disease) { + throw new Error('Diagnosis does not include disease details'); + } + + return { + id: diagnosis.id, + predictedDiseaseSlug: diagnosis.predictedDiseaseSlug || 'daun-sehat', + confidence: diagnosis.confidence || 0, + probabilities: diagnosis.predictions.map((p) => ({ + diseaseSlug: p.diseaseSlug || 'daun-sehat', + label: p.modelLabel, + confidence: p.confidence, + })), + imageUrl: diagnosis.imageUrl, + originalFileName: diagnosis.imageFileName, + uploaderPublicId: diagnosis.uploaderPublicId, + uploader: { + public_id: diagnosis.uploaderPublicId, + file_name: diagnosis.imageFileName, + mime_type: diagnosis.imageMimeType, + size_bytes: diagnosis.imageSizeBytes, + file_type: diagnosis.imageMimeType.split('/')[1] || 'unknown', + download_url: diagnosis.imageUrl, + }, + createdAt: diagnosis.createdAt, + disease: diagnosis.disease, + }; +} + export const apiClient = { // Auth methods async getMe(): Promise { @@ -130,71 +160,11 @@ export const apiClient = { // Backwards-compatible aliases for old image classification methods async getImageClassifications(): Promise { const diagnoses = await this.getDiagnoses(); - return diagnoses.map((d) => ({ - id: d.id, - predictedDiseaseSlug: d.predictedDiseaseSlug || 'daun-sehat', - confidence: d.confidence || 0, - probabilities: d.predictions.map((p) => ({ - diseaseSlug: p.diseaseSlug || 'daun-sehat', - label: p.modelLabel, - confidence: p.confidence, - })), - imageUrl: d.imageUrl, - originalFileName: d.imageFileName, - uploaderPublicId: d.uploaderPublicId, - uploader: { - public_id: d.uploaderPublicId, - file_name: d.imageFileName, - mime_type: d.imageMimeType, - size_bytes: d.imageSizeBytes, - file_type: d.imageMimeType.split('/')[1] || 'unknown', - download_url: d.imageUrl, - }, - createdAt: d.createdAt, - disease: d.disease || { - slug: 'daun-sehat', - label: 'Daun Sehat', - commonName: 'Healthy Leaf', - description: '', - symptoms: [], - treatment: [], - riskLevel: 'low', - }, - })) as ImageClassificationRecord[]; + return diagnoses.map(toImageClassificationRecord); }, async createImageClassification(file: File): Promise { const diagnosis = await this.createDiagnosis(file); - return { - id: diagnosis.id, - predictedDiseaseSlug: diagnosis.predictedDiseaseSlug || 'daun-sehat', - confidence: diagnosis.confidence || 0, - probabilities: diagnosis.predictions.map((p) => ({ - diseaseSlug: p.diseaseSlug || 'daun-sehat', - label: p.modelLabel, - confidence: p.confidence, - })), - imageUrl: diagnosis.imageUrl, - originalFileName: diagnosis.imageFileName, - uploaderPublicId: diagnosis.uploaderPublicId, - uploader: { - public_id: diagnosis.uploaderPublicId, - file_name: diagnosis.imageFileName, - mime_type: diagnosis.imageMimeType, - size_bytes: diagnosis.imageSizeBytes, - file_type: diagnosis.imageMimeType.split('/')[1] || 'unknown', - download_url: diagnosis.imageUrl, - }, - createdAt: diagnosis.createdAt, - disease: diagnosis.disease || { - slug: 'daun-sehat', - label: 'Daun Sehat', - commonName: 'Healthy Leaf', - description: '', - symptoms: [], - treatment: [], - riskLevel: 'low', - }, - } as ImageClassificationRecord; + return toImageClassificationRecord(diagnosis); }, }; From 84fb808b5381ccb446a6ce45539a61cfb4a33885 Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 16:51:01 +0000 Subject: [PATCH 14/23] feat: add frontend authentication flow --- apps/web/src/app.tsx | 26 +++++------ apps/web/src/components/auth-form.tsx | 64 ++++++++++++++++++++++++++ apps/web/src/components/auth-guard.tsx | 38 +++++++++++++++ apps/web/src/components/ui/input.tsx | 14 ++++++ apps/web/src/components/ui/label.tsx | 11 +++++ apps/web/src/pages/login-page.tsx | 41 +++++++++++++++++ apps/web/src/pages/register-page.tsx | 41 +++++++++++++++++ 7 files changed, 222 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/auth-form.tsx create mode 100644 apps/web/src/components/auth-guard.tsx create mode 100644 apps/web/src/components/ui/input.tsx create mode 100644 apps/web/src/components/ui/label.tsx create mode 100644 apps/web/src/pages/login-page.tsx create mode 100644 apps/web/src/pages/register-page.tsx diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index b667f42..9458d1d 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -1,29 +1,29 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { AuthGuard } from '@/components/auth-guard'; import { DashboardPage } from '@/pages/dashboard-page'; import { LandingPage } from '@/pages/landing-page'; import { CatalogPage } from '@/pages/catalog-page'; import { DiseaseDetailPage } from '@/pages/disease-detail-page'; +import { LoginPage } from '@/pages/login-page'; +import { RegisterPage } from '@/pages/register-page'; const queryClient = new QueryClient(); const router = createBrowserRouter([ - { - path: '/', - element: , - }, + { path: '/', element: }, + { path: '/login', element: }, + { path: '/register', element: }, { path: '/dashboard', - element: , - }, - { - path: '/catalog', - element: , - }, - { - path: '/catalog/:slug', - element: , + element: ( + + + + ), }, + { path: '/catalog', element: }, + { path: '/catalog/:slug', element: }, ]); export function App() { diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx new file mode 100644 index 0000000..b04f02f --- /dev/null +++ b/apps/web/src/components/auth-form.tsx @@ -0,0 +1,64 @@ +import { FormEvent, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +type AuthFormProps = { + mode: 'login' | 'register'; + isSubmitting: boolean; + error: string | null; + googleOAuthEnabled: boolean; + onSubmit: (payload: { name?: string; email: string; password: string }) => Promise; +}; + +export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubmit }: AuthFormProps) { + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + await onSubmit({ name, email, password }); + } + + return ( + + + {mode === 'login' ? 'Masuk ke ZeaVis Edu' : 'Buat akun ZeaVis Edu'} + + {mode === 'login' + ? 'Masuk untuk melihat riwayat diagnosis daun jagung Anda.' + : 'Daftar untuk menyimpan diagnosis dan mengikuti review pakar.'} + + + +
+ {mode === 'register' && ( +
+ + setName(event.target.value)} required /> +
+ )} +
+ + setEmail(event.target.value)} required /> +
+
+ + setPassword(event.target.value)} required /> +
+ {error &&

{error}

} + +
+ {googleOAuthEnabled && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/auth-guard.tsx b/apps/web/src/components/auth-guard.tsx new file mode 100644 index 0000000..ed4ac06 --- /dev/null +++ b/apps/web/src/components/auth-guard.tsx @@ -0,0 +1,38 @@ +import { ReactNode, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Navigate } from 'react-router-dom'; +import { apiClient } from '@/lib/api-client'; +import { useAuthStore } from '@/store/auth-store'; + +type AuthGuardProps = { + children: ReactNode; + requireExpert?: boolean; +}; + +export function AuthGuard({ children, requireExpert = false }: AuthGuardProps) { + const setUser = useAuthStore((state) => state.setUser); + const query = useQuery({ + queryKey: ['auth', 'me'], + queryFn: () => apiClient.getMe(), + }); + + useEffect(() => { + if (query.data) { + setUser(query.data.user); + } + }, [query.data, setUser]); + + if (query.isLoading) { + return
Memeriksa sesi...
; + } + + if (!query.data?.user) { + return ; + } + + if (requireExpert && query.data.user.role !== 'expert') { + return ; + } + + return <>{children}; +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 0000000..f2fe482 --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,14 @@ +import type { InputHTMLAttributes } from 'react'; +import { cn } from '@/lib/utils'; + +export function Input({ className, ...props }: InputHTMLAttributes) { + return ( + + ); +} diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 0000000..da28348 --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,11 @@ +import type { LabelHTMLAttributes } from 'react'; +import { cn } from '@/lib/utils'; + +export function Label({ className, ...props }: LabelHTMLAttributes) { + return ( +