From 7b2b2eb1f0fa9dcba8aeeb1fcc02ce213a3334cc Mon Sep 17 00:00:00 2001 From: Asep Haryana Saputra <90584806+MythEclipse@users.noreply.github.com> Date: Fri, 22 May 2026 15:43:54 +0000 Subject: [PATCH] Implement feature X to enhance user experience and optimize performance --- .../plans/2026-05-22-production-mvp.md | 2492 +++++++++++++++++ 1 file changed, 2492 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-22-production-mvp.md diff --git a/docs/superpowers/plans/2026-05-22-production-mvp.md b/docs/superpowers/plans/2026-05-22-production-mvp.md new file mode 100644 index 0000000..fc8191a --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-production-mvp.md @@ -0,0 +1,2492 @@ +# ZeaVis Edu Production MVP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an authenticated production-style MVP where users upload corn leaf images, receive persisted AI diagnoses, and low-confidence results are reviewed by expert users. + +**Architecture:** Extend the existing Bun + Moon monorepo without replacing its current catalog/classification foundation. Add session-based auth, PostgreSQL-backed diagnosis records, expert review workflow, and frontend pages that consume the new APIs. Keep image uploading behind the API uploader adapter and keep TensorFlow.js inference behind `image-model`. + +**Tech Stack:** Bun, TypeScript, Elysia, Drizzle ORM, PostgreSQL, React, Vite, React Router, TanStack Query, Zustand, Tailwind, TensorFlow.js. + +--- + +## File structure map + +### Backend + +- Modify `apps/api/package.json`: add password hashing/session/OAuth dependencies if chosen during implementation (`bcryptjs` and `@types/bcryptjs` are sufficient for password hashing; Google OAuth can be implemented with standard `fetch` and `URLSearchParams`). +- Modify `apps/api/src/config/env.ts`: expose required auth/uploader/upload/OAuth config. +- Modify `apps/api/src/db/schema.ts`: add `users`, `sessions`, `diagnoses`, `diagnosisPredictions`, and `expertReviews`; keep existing tables for compatibility during migration. +- Create `apps/api/src/lib/auth.ts`: password hashing, session token creation/hash, cookie helpers, current-user lookup, auth/role guards. +- Modify `apps/api/src/lib/uploader-client.ts`: read uploader base URL from env and return typed metadata. +- Modify `apps/api/src/lib/image-model.ts`: ensure probabilities are top-k sorted and reusable for diagnosis prediction rows. +- Create `apps/api/src/routes/auth.ts`: register/login/logout/me and optional Google OAuth endpoints. +- Create `apps/api/src/routes/diagnoses.ts`: authenticated diagnosis creation/list/detail. +- Create `apps/api/src/routes/expert.ts`: expert-only review queue and review submission. +- Modify `apps/api/src/routes/dashboard.ts`: make user-specific summaries if auth is present. +- Modify `apps/api/src/index.ts`: register new route modules. +- Modify `apps/api/src/db/seed-data.ts`: seed diseases and at least one expert account for demos. + +### Shared package + +- Modify `packages/shared/src/classifications.ts`: add diagnosis/review/auth-facing DTO types while preserving existing exported types until consumers are migrated. +- Modify `packages/shared/src/index.ts`: export new DTO types. + +### Frontend + +- Modify `apps/web/src/lib/api-client.ts`: send credentials, add auth/diagnosis/expert APIs, keep existing disease APIs. +- Create `apps/web/src/store/auth-store.ts`: lightweight current-user state if needed outside queries. +- Create `apps/web/src/components/auth-form.tsx`: shared login/register form. +- Create `apps/web/src/components/auth-guard.tsx`: route guard for authenticated and expert routes. +- Create `apps/web/src/components/diagnosis-status-badge.tsx`: renders diagnosis statuses. +- Create `apps/web/src/components/diagnosis-card.tsx`: reusable diagnosis history/review card. +- Modify `apps/web/src/components/image-classification-form.tsx`: submit through new diagnosis endpoint and display progress/errors. +- Create `apps/web/src/pages/login-page.tsx`. +- Create `apps/web/src/pages/register-page.tsx`. +- Modify `apps/web/src/pages/dashboard-page.tsx`: authenticated dashboard with upload, summaries, and diagnosis history. +- Create `apps/web/src/pages/diagnosis-detail-page.tsx`. +- Create `apps/web/src/pages/expert-reviews-page.tsx`. +- Modify `apps/web/src/app.tsx`: add auth routes and guarded diagnosis/expert routes. + +### Verification + +- Run `bun install` after dependency changes. +- Run `bun run typecheck`. +- Run `bun run build`. +- Run `cd apps/api && bun run db:generate` and apply migrations in an environment with `DATABASE_URL`. +- Run the app and manually verify the user and expert flows. + +--- + +## Task 1: Add shared auth, diagnosis, and review DTOs + +**Files:** +- Modify: `packages/shared/src/classifications.ts` +- Modify: `packages/shared/src/index.ts` + +- [ ] **Step 1: Inspect current shared classification types** + +Run: `grep -n "export type" packages/shared/src/classifications.ts` + +Expected: Output includes existing `ManualClassificationRecord`, `PredictionProbability`, `UploaderMetadata`, and `ImageClassificationRecord` exports. + +- [ ] **Step 2: Replace shared classification module with backwards-compatible DTOs** + +Write `packages/shared/src/classifications.ts` with this content: + +```ts +import type { DiseaseCatalogItem, DiseaseSlug } from './diseases'; + +export type ManualClassificationRequest = { + diseaseSlug: DiseaseSlug; + observation: string; + location: string; +}; + +export type ManualClassificationRecord = ManualClassificationRequest & { + id: string; + createdAt: string; + disease: DiseaseCatalogItem; +}; + +export type PredictionProbability = { + diseaseSlug: DiseaseSlug; + 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; + 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; + confidence: number; + probabilities: PredictionProbability[]; + imageUrl: string; + originalFileName: string; + uploaderPublicId: string; + uploader: UploaderMetadata; + createdAt: string; + disease: DiseaseCatalogItem; +}; +``` + +- [ ] **Step 3: Export the new types** + +Modify `packages/shared/src/index.ts` so the classification export block is: + +```ts +export type { + ManualClassificationRequest, + ManualClassificationRecord, + DashboardSummary, + PredictionProbability, + UploaderMetadata, + ImageClassificationRecord, + DiagnosisStatus, + UserRole, + AuthUser, + AuthFeatures, + AuthMeResponse, + AuthRequest, + RegisterRequest, + AuthResponse, + DiagnosisPrediction, + ExpertReviewRecord, + DiagnosisRecord, + ReviewDiagnosisRequest, +} from './classifications'; +``` + +- [ ] **Step 4: Run shared typecheck** + +Run: `bun run typecheck` + +Expected: It may fail because backend/frontend have not been updated for the new `DashboardSummary` fields yet. If it fails only because `DashboardSummary` is missing fields in existing backend code, continue to Task 2. + +- [ ] **Step 5: Commit** + +```bash +git add packages/shared/src/classifications.ts packages/shared/src/index.ts +git commit -m "feat: define production diagnosis DTOs" +``` + +--- + +## Task 2: Add backend configuration and database schema + +**Files:** +- Modify: `apps/api/package.json` +- Modify: `apps/api/src/config/env.ts` +- Modify: `apps/api/src/db/schema.ts` + +- [ ] **Step 1: Add password hashing dependency** + +Modify `apps/api/package.json` dependencies/devDependencies to include: + +```json +"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", + "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" +} +``` + +- [ ] **Step 2: Install dependencies** + +Run: `bun install` + +Expected: Bun updates `bun.lock` and installs `bcryptjs` plus types. + +- [ ] **Step 3: Replace env config** + +Write `apps/api/src/config/env.ts` with this content: + +```ts +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'); + } +} +``` + +- [ ] **Step 4: Replace database schema** + +Write `apps/api/src/db/schema.ts` with this content: + +```ts +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(), + name: varchar('name', { length: 120 }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const diseaseCatalog = pgTable('disease_catalog', { + slug: varchar('slug', { length: 80 }).primaryKey(), + label: varchar('label', { length: 80 }).notNull(), + commonName: varchar('common_name', { length: 120 }).notNull(), + summary: text('summary').notNull(), + description: text('description').notNull(), + symptoms: text('symptoms').array().notNull(), + recommendations: text('recommendations').array().notNull(), + riskLevel: varchar('risk_level', { length: 20 }).notNull(), + accentColor: varchar('accent_color', { length: 40 }).notNull(), + displayOrder: integer('display_order').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + 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 }) + .notNull() + .references(() => diseaseCatalog.slug), + observation: text('observation').notNull(), + location: varchar('location', { length: 160 }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}); + +export const imageClassifications = pgTable('image_classifications', { + id: uuid('id').primaryKey().defaultRandom(), + predictedDiseaseSlug: varchar('predicted_disease_slug', { length: 80 }) + .notNull() + .references(() => diseaseCatalog.slug), + confidence: real('confidence').notNull(), + probabilities: jsonb('probabilities').notNull(), + imageUrl: text('image_url').notNull(), + originalFileName: varchar('original_file_name', { length: 240 }).notNull(), + uploaderPublicId: varchar('uploader_public_id', { length: 160 }).notNull(), + 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), +})); +``` + +- [ ] **Step 5: Run typecheck** + +Run: `bun run typecheck` + +Expected: It may fail until route code is updated to use the expanded schema and summary type. Continue to Task 3 if failures are limited to missing new fields in code. + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/package.json bun.lock apps/api/src/config/env.ts apps/api/src/db/schema.ts +git commit -m "feat: add production auth and diagnosis schema" +``` + +--- + +## Task 3: Implement backend auth utilities and routes + +**Files:** +- Create: `apps/api/src/lib/auth.ts` +- Create: `apps/api/src/routes/auth.ts` +- Modify: `apps/api/src/index.ts` + +- [ ] **Step 1: Create auth utilities** + +Write `apps/api/src/lib/auth.ts` with this content: + +```ts +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; +} +``` + +- [ ] **Step 2: Create auth routes** + +Write `apps/api/src/routes/auth.ts` with this content: + +```ts +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`; + }); +``` + +- [ ] **Step 3: Register auth routes and required env assertion** + +Modify `apps/api/src/index.ts` to: + +```ts +import { Elysia } from 'elysia'; +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) + .listen(env.port); + +console.log(`ZeaVis Edu API running at http://${app.server?.hostname}:${app.server?.port}`); + +export type App = typeof app; +``` + +- [ ] **Step 4: Run typecheck** + +Run: `bun run typecheck` + +Expected: Auth files typecheck, but remaining failures may exist because diagnoses/expert/dashboard routes are not migrated yet. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/lib/auth.ts apps/api/src/routes/auth.ts apps/api/src/index.ts +git commit -m "feat: add session auth routes" +``` + +--- + +## Task 4: Implement diagnosis persistence routes + +**Files:** +- Create: `apps/api/src/routes/diagnoses.ts` +- Modify: `apps/api/src/index.ts` +- Modify: `apps/api/src/lib/uploader-client.ts` + +- [ ] **Step 1: Update uploader client to use env base URL** + +Write `apps/api/src/lib/uploader-client.ts` with this content: + +```ts +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(`${env.uploaderBaseUrl}/api/upload`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error(`Upload failed with HTTP ${response.status}`); + } + + const payload = await response.json() as Partial; + + 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'); + } + + 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, + }; +} +``` + +- [ ] **Step 2: Create diagnosis routes** + +Write `apps/api/src/routes/diagnoses.ts` with this content: + +```ts +import { Elysia } from 'elysia'; +import type { DiagnosisPrediction, DiagnosisRecord, ExpertReviewRecord } from '@zeavis/shared'; +import { and, desc, eq, inArray, or } from 'drizzle-orm'; +import { createDbClient } from '../db/client'; +import { diagnoses, diagnosisPredictions, diseaseCatalog, expertReviews, users } from '../db/schema'; +import { badGateway, badRequest, forbidden, 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 }; +``` + +- [ ] **Step 3: Register diagnosis routes** + +Modify `apps/api/src/index.ts` to import and use diagnosis routes: + +```ts +import { diagnosisRoutes } from './routes/diagnoses'; +``` + +Add `.use(diagnosisRoutes)` after `.use(classificationRoutes)`. + +- [ ] **Step 4: Run typecheck** + +Run: `bun run typecheck` + +Expected: Any remaining failures should be limited to dashboard summary fields or expert route not existing yet. + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/lib/uploader-client.ts apps/api/src/routes/diagnoses.ts apps/api/src/index.ts +git commit -m "feat: persist authenticated diagnoses" +``` + +--- + +## Task 5: Implement expert review routes + +**Files:** +- Create: `apps/api/src/routes/expert.ts` +- Modify: `apps/api/src/index.ts` + +- [ ] **Step 1: Create expert routes** + +Write `apps/api/src/routes/expert.ts` with this content: + +```ts +import { Elysia } from 'elysia'; +import type { ReviewDiagnosisRequest } 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 { isDiseaseSlug } from '@zeavis/shared'; +import { loadDiagnosisRecord } from './diagnoses'; + +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(Boolean); + } 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' && !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'); + + await db.insert(expertReviews).values({ + diagnosisId: diagnosis.id, + expertId: user.id, + verdict: req.verdict, + correctedDiseaseSlug: req.verdict === 'corrected' ? req.correctedDiseaseSlug! : null, + notes, + }); + + await db + .update(diagnoses) + .set({ + status: req.verdict === 'corrected' ? 'expert_corrected' : 'expert_verified', + predictedDiseaseSlug: req.verdict === 'corrected' ? req.correctedDiseaseSlug! : diagnosis.predictedDiseaseSlug, + updatedAt: new Date(), + }) + .where(eq(diagnoses.id, diagnosis.id)); + + return await loadDiagnosisRecord(diagnosis.id, null, true); + } catch (error) { + return serviceUnavailable('Database unavailable'); + } + }); +``` + +- [ ] **Step 2: Register expert routes** + +Modify `apps/api/src/index.ts` to import and use expert routes: + +```ts +import { expertRoutes } from './routes/expert'; +``` + +Add `.use(expertRoutes)` after `.use(diagnosisRoutes)`. + +- [ ] **Step 3: Run typecheck** + +Run: `bun run typecheck` + +Expected: Expert route typechecks. Remaining failures should be from dashboard/frontend migration. + +- [ ] **Step 4: Commit** + +```bash +git add apps/api/src/routes/expert.ts apps/api/src/index.ts +git commit -m "feat: add expert diagnosis review workflow" +``` + +--- + +## Task 6: Update dashboard summary and seed data + +**Files:** +- Modify: `apps/api/src/routes/dashboard.ts` +- Modify: `apps/api/src/db/seed-data.ts` + +- [ ] **Step 1: Update dashboard summary route** + +Modify `apps/api/src/routes/dashboard.ts` so it imports new tables and returns the expanded `DashboardSummary`: + +```ts +import { Elysia } from 'elysia'; +import type { DashboardSummary } from '@zeavis/shared'; +import { eq, desc, count } from 'drizzle-orm'; +import { createDbClient } from '../db/client'; +import { diseaseCatalog, manualClassifications, diagnoses } from '../db/schema'; +import { serviceUnavailable } from '../lib/http-errors'; +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 ({ request }) => { + try { + const db = createDbClient(); + const user = await getCurrentUser(request.headers.get('cookie')); + + const diseases = await db.select().from(diseaseCatalog).orderBy(diseaseCatalog.displayOrder); + const manualRows = await db.select().from(manualClassifications).orderBy(desc(manualClassifications.createdAt)).limit(1); + + 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; + }, + { low: 0, medium: 0, high: 0 }, + ); + + const latestManual = manualRows[0]; + const latestDisease = latestManual + ? diseases.find((disease) => disease.slug === latestManual.diseaseSlug) + : null; + + 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, + }; + + return summary; + } catch (error) { + return serviceUnavailable('Database unavailable'); + } + }); +``` + +- [ ] **Step 2: Ensure seed data includes an expert** + +Inspect `apps/api/src/db/seed-data.ts`. Add an expert user seed with email `expert@zeavis.local`, name `ZeaVis Expert`, role `expert`, and a bcrypt hash for password `password123` using `hashPassword` if the seed script is executable TypeScript. If seed-data currently exports only disease arrays and is not executable, add a named export: + +```ts +export const demoExpertUser = { + email: 'expert@zeavis.local', + name: 'ZeaVis Expert', + role: 'expert' as const, + password: 'password123', +}; +``` + +- [ ] **Step 3: Run typecheck** + +Run: `bun run typecheck` + +Expected: Backend dashboard errors should be resolved. Frontend may still fail because it expects old image classification APIs. + +- [ ] **Step 4: Commit** + +```bash +git add apps/api/src/routes/dashboard.ts apps/api/src/db/seed-data.ts +git commit -m "feat: update dashboard for diagnosis workflow" +``` + +--- + +## Task 7: Add frontend auth and diagnosis API client methods + +**Files:** +- Modify: `apps/web/src/lib/api-client.ts` +- Create: `apps/web/src/store/auth-store.ts` + +- [ ] **Step 1: Replace API client** + +Write `apps/web/src/lib/api-client.ts` with this content: + +```ts +import type { + AuthMeResponse, + AuthRequest, + AuthResponse, + DiagnosisRecord, + DiseaseCatalogItem, + DiseaseSlug, + ManualClassificationRequest, + ManualClassificationRecord, + RegisterRequest, + ReviewDiagnosisRequest, + DashboardSummary, +} from '@zeavis/shared'; + +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, { + credentials: 'include', + ...options, + headers: options?.headers, + }); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}`; + try { + const errorData = await response.json(); + if (errorData.error) { + errorMessage = errorData.error; + } + } catch { + } + throw new Error(errorMessage); + } + + return response.json(); +} + +export const apiClient = { + 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' }); + }, + + async getDiseases(): Promise { + return fetchApi('/api/v1/diseases'); + }, + + async getDisease(slug: DiseaseSlug): Promise { + return fetchApi(`/api/v1/diseases/${slug}`); + }, + + async getManualClassifications(): Promise { + return fetchApi('/api/v1/classifications/manual'); + }, + + async createManualClassification(payload: ManualClassificationRequest): Promise { + return fetchApi('/api/v1/classifications/manual', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + }, + + async getDiagnoses(): Promise { + return fetchApi('/api/v1/diagnoses'); + }, + + 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/diagnoses', { + method: 'POST', + body: formData, + }); + }, + + 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), + }); + }, + + async getDashboardSummary(): Promise { + return fetchApi('/api/v1/dashboard/summary'); + }, +}; +``` + +- [ ] **Step 2: Create auth store** + +Write `apps/web/src/store/auth-store.ts` with this content: + +```ts +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 }), +})); +``` + +- [ ] **Step 3: Run typecheck** + +Run: `bun run typecheck` + +Expected: Frontend page/component errors remain because UI still calls old image classification methods. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/lib/api-client.ts apps/web/src/store/auth-store.ts +git commit -m "feat: add frontend production API client" +``` + +--- + +## Task 8: Add frontend auth pages and guards + +**Files:** +- Create: `apps/web/src/components/auth-form.tsx` +- Create: `apps/web/src/components/auth-guard.tsx` +- Create: `apps/web/src/pages/login-page.tsx` +- Create: `apps/web/src/pages/register-page.tsx` +- Modify: `apps/web/src/app.tsx` + +- [ ] **Step 1: Create auth form component** + +Write `apps/web/src/components/auth-form.tsx` with this content: + +```tsx +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 && ( + + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Create auth guard** + +Write `apps/web/src/components/auth-guard.tsx` with this content: + +```tsx +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}; +} +``` + +- [ ] **Step 3: Create login page** + +Write `apps/web/src/pages/login-page.tsx` with this content: + +```tsx +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { AuthForm } from '@/components/auth-form'; +import { apiClient } from '@/lib/api-client'; +import { useAuthStore } from '@/store/auth-store'; + +export function LoginPage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const setUser = useAuthStore((state) => state.setUser); + const [error, setError] = useState(null); + const meQuery = useQuery({ queryKey: ['auth', 'me'], queryFn: () => apiClient.getMe() }); + + const mutation = useMutation({ + mutationFn: apiClient.login, + onSuccess: (response) => { + setUser(response.user); + queryClient.setQueryData(['auth', 'me'], response); + navigate('/dashboard'); + }, + onError: (err) => setError(err instanceof Error ? err.message : 'Login gagal'), + }); + + return ( +
+
+ mutation.mutateAsync({ email, password })} + /> +

+ Belum punya akun? Daftar +

+
+
+ ); +} +``` + +- [ ] **Step 4: Create register page** + +Write `apps/web/src/pages/register-page.tsx` with this content: + +```tsx +import { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { AuthForm } from '@/components/auth-form'; +import { apiClient } from '@/lib/api-client'; +import { useAuthStore } from '@/store/auth-store'; + +export function RegisterPage() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const setUser = useAuthStore((state) => state.setUser); + const [error, setError] = useState(null); + const meQuery = useQuery({ queryKey: ['auth', 'me'], queryFn: () => apiClient.getMe() }); + + const mutation = useMutation({ + mutationFn: apiClient.register, + onSuccess: (response) => { + setUser(response.user); + queryClient.setQueryData(['auth', 'me'], response); + navigate('/dashboard'); + }, + onError: (err) => setError(err instanceof Error ? err.message : 'Registrasi gagal'), + }); + + return ( +
+
+ mutation.mutateAsync({ name: name ?? '', email, password })} + /> +

+ Sudah punya akun? Masuk +

+
+
+ ); +} +``` + +- [ ] **Step 5: Add routes** + +Modify `apps/web/src/app.tsx` to include auth and guards: + +```tsx +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: '/login', element: }, + { path: '/register', element: }, + { + path: '/dashboard', + element: ( + + + + ), + }, + { path: '/catalog', element: }, + { path: '/catalog/:slug', element: }, +]); + +export function App() { + return ( + + + + ); +} +``` + +- [ ] **Step 6: Run typecheck** + +Run: `bun run typecheck` + +Expected: Remaining errors should be dashboard/image classification component references to old APIs or missing diagnosis pages. + +- [ ] **Step 7: Commit** + +```bash +git add apps/web/src/components/auth-form.tsx apps/web/src/components/auth-guard.tsx apps/web/src/pages/login-page.tsx apps/web/src/pages/register-page.tsx apps/web/src/app.tsx +git commit -m "feat: add frontend authentication flow" +``` + +--- + +## Task 9: Add diagnosis UI components and detail page + +**Files:** +- Create: `apps/web/src/components/diagnosis-status-badge.tsx` +- Create: `apps/web/src/components/diagnosis-card.tsx` +- Create: `apps/web/src/pages/diagnosis-detail-page.tsx` +- Modify: `apps/web/src/app.tsx` + +- [ ] **Step 1: Create status badge** + +Write `apps/web/src/components/diagnosis-status-badge.tsx` with this content: + +```tsx +import type { DiagnosisStatus } from '@zeavis/shared'; +import { cn } from '@/lib/utils'; + +const labels: Record = { + ai_verified: 'Terverifikasi AI', + needs_review: 'Menunggu review pakar', + expert_verified: 'Diverifikasi pakar', + expert_corrected: 'Dikoreksi pakar', + failed: 'Gagal diproses', +}; + +const styles: Record = { + ai_verified: 'bg-emerald-100 text-emerald-800', + needs_review: 'bg-amber-100 text-amber-800', + expert_verified: 'bg-blue-100 text-blue-800', + expert_corrected: 'bg-purple-100 text-purple-800', + failed: 'bg-red-100 text-red-800', +}; + +export function DiagnosisStatusBadge({ status, className }: { status: DiagnosisStatus; className?: string }) { + return {labels[status]}; +} +``` + +- [ ] **Step 2: Create diagnosis card** + +Write `apps/web/src/components/diagnosis-card.tsx` with this content: + +```tsx +import { Link } from 'react-router-dom'; +import type { DiagnosisRecord } from '@zeavis/shared'; +import { Card, CardContent } from '@/components/ui/card'; +import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge'; + +export function DiagnosisCard({ diagnosis, expertLink = false }: { diagnosis: DiagnosisRecord; expertLink?: boolean }) { + const href = expertLink ? `/expert/reviews?diagnosis=${diagnosis.id}` : `/diagnoses/${diagnosis.id}`; + + return ( + + + + Daun jagung +
+
+

{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}

+ +
+

+ {diagnosis.confidence === null ? 'Tidak ada confidence' : `Confidence ${(diagnosis.confidence * 100).toFixed(1)}%`} +

+

{new Date(diagnosis.createdAt).toLocaleString('id-ID')}

+
+
+
+ + ); +} +``` + +- [ ] **Step 3: Create diagnosis detail page** + +Write `apps/web/src/pages/diagnosis-detail-page.tsx` with this content: + +```tsx +import { Link, useParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge'; +import { RiskBadge } from '@/components/risk-badge'; +import { apiClient } from '@/lib/api-client'; + +export function DiagnosisDetailPage() { + const { id } = useParams(); + const query = useQuery({ + queryKey: ['diagnosis', id], + queryFn: () => apiClient.getDiagnosis(id!), + enabled: Boolean(id), + }); + + if (query.isLoading) return
Memuat diagnosis...
; + if (query.error || !query.data) return
Diagnosis tidak ditemukan
; + + const diagnosis = query.data; + + return ( +
+
+
+
+

Detail Diagnosis

+

{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}

+
+ +
+ +
+ + + Daun jagung + + + + + +
+ Hasil AI + +
+ + {diagnosis.confidence === null ? 'Model gagal memproses gambar.' : `Confidence ${(diagnosis.confidence * 100).toFixed(1)}%`} + +
+ + {diagnosis.status === 'needs_review' && ( +
+ Hasil ini masih sementara karena confidence di bawah 70% dan sedang menunggu review pakar. +
+ )} + {diagnosis.failureReason &&

{diagnosis.failureReason}

} + {diagnosis.disease && ( +
+
+

{diagnosis.disease.label}

+ +
+

{diagnosis.disease.summary}

+
+

Rekomendasi

+
    + {diagnosis.disease.recommendations.map((item) =>
  • {item}
  • )} +
+
+
+ )} +
+
+
+ + + + Top Predictions + + + {diagnosis.predictions.map((prediction) => ( +
+ {prediction.rank}. {prediction.modelLabel} + {(prediction.confidence * 100).toFixed(1)}% +
+ ))} +
+
+ + {diagnosis.latestReview && ( + + + Catatan Pakar + {diagnosis.latestReview.expert.name} + + +

{diagnosis.latestReview.notes}

+
+
+ )} +
+
+ ); +} +``` + +- [ ] **Step 4: Register diagnosis detail route** + +Modify `apps/web/src/app.tsx` imports: + +```ts +import { DiagnosisDetailPage } from '@/pages/diagnosis-detail-page'; +``` + +Add route: + +```tsx +{ + path: '/diagnoses/:id', + element: ( + + + + ), +}, +``` + +- [ ] **Step 5: Run typecheck** + +Run: `bun run typecheck` + +Expected: Diagnosis detail components typecheck. Dashboard and expert page may still fail until updated. + +- [ ] **Step 6: Commit** + +```bash +git add apps/web/src/components/diagnosis-status-badge.tsx apps/web/src/components/diagnosis-card.tsx apps/web/src/pages/diagnosis-detail-page.tsx apps/web/src/app.tsx +git commit -m "feat: add diagnosis detail UI" +``` + +--- + +## Task 10: Migrate dashboard to authenticated diagnosis workflow + +**Files:** +- Modify: `apps/web/src/components/image-classification-form.tsx` +- Modify: `apps/web/src/pages/dashboard-page.tsx` + +- [ ] **Step 1: Replace image classification form props** + +Modify `apps/web/src/components/image-classification-form.tsx` so it accepts `latestResult: DiagnosisRecord | null` and displays diagnosis status. Use this complete component: + +```tsx +import { ChangeEvent, FormEvent, useState } from 'react'; +import type { DiagnosisRecord } from '@zeavis/shared'; +import { Upload } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge'; + +type ImageClassificationFormProps = { + onSubmit: (file: File) => Promise; + isSubmitting: boolean; + latestResult: DiagnosisRecord | null; +}; + +export function ImageClassificationForm({ onSubmit, isSubmitting, latestResult }: ImageClassificationFormProps) { + const [file, setFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [error, setError] = useState(null); + + function handleFileChange(event: ChangeEvent) { + const selectedFile = event.target.files?.[0] ?? null; + setError(null); + setFile(selectedFile); + setPreviewUrl(selectedFile ? URL.createObjectURL(selectedFile) : null); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!file) { + setError('Pilih gambar terlebih dahulu'); + return; + } + + try { + await onSubmit(file); + setFile(null); + setPreviewUrl(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Gagal mengirim gambar'); + } + } + + return ( + + + Diagnosis Gambar + Upload gambar daun jagung untuk klasifikasi AI dan review pakar jika confidence rendah. + + +
+ + {previewUrl && Preview} + {error &&

{error}

} + +
+ + {latestResult && ( +
+
+

{latestResult.disease?.commonName ?? 'Diagnosis gagal'}

+ +
+

+ {latestResult.confidence === null ? 'Tidak ada confidence' : `Confidence ${(latestResult.confidence * 100).toFixed(1)}%`} +

+
+ )} +
+
+ ); +} +``` + +- [ ] **Step 2: Replace dashboard page** + +Write `apps/web/src/pages/dashboard-page.tsx` with this content: + +```tsx +import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; +import { Link, useNavigate } from 'react-router-dom'; +import { BookOpen, History, LayoutDashboard, LogOut, ShieldCheck, TrendingUp } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { ImageClassificationForm } from '@/components/image-classification-form'; +import { DiagnosisCard } from '@/components/diagnosis-card'; +import { useUiStore } from '@/store/ui-store'; +import { useAuthStore } from '@/store/auth-store'; +import { apiClient } from '@/lib/api-client'; + +export function DashboardPage() { + const { dashboardCompact, toggleDashboardCompact } = useUiStore(); + const user = useAuthStore((state) => state.user); + const setUser = useAuthStore((state) => state.setUser); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const [diseasesQuery, summaryQuery, diagnosesQuery] = useQueries({ + queries: [ + { queryKey: ['diseases'], queryFn: () => apiClient.getDiseases() }, + { queryKey: ['dashboard-summary'], queryFn: () => apiClient.getDashboardSummary() }, + { queryKey: ['diagnoses'], queryFn: () => apiClient.getDiagnoses() }, + ], + }); + + const createDiagnosisMutation = useMutation({ + mutationFn: async (file: File) => apiClient.createDiagnosis(file), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['diagnoses'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] }); + }, + }); + + const logoutMutation = useMutation({ + mutationFn: () => apiClient.logout(), + onSuccess: () => { + setUser(null); + queryClient.clear(); + navigate('/login'); + }, + }); + + const diseases = diseasesQuery.data || []; + const summary = summaryQuery.data; + const diagnoses = diagnosesQuery.data || []; + const isLoadingData = diseasesQuery.isLoading || summaryQuery.isLoading || diagnosesQuery.isLoading; + const hasError = diseasesQuery.error || summaryQuery.error || diagnosesQuery.error; + + return ( +
+
+
+
+
+ Dashboard +
+

ZeaVis Edu Workspace

+

Masuk sebagai {user?.name ?? 'pengguna'} untuk diagnosis daun jagung.

+
+
+ {user?.role === 'expert' && } + + +
+
+ + {isLoadingData && Memuat data dashboard...} + {hasError && Gagal memuat data dashboard} + + {!isLoadingData && !hasError && ( + <> + {summary && ( +
+ Total Penyakit
{summary.diseaseCount}
+ Diagnosis Saya
{summary.imageClassificationCount}
+ Perlu Review
{summary.needsReviewCount}
+ Risiko Tinggi
{summary.riskDistribution.high}
+
+ )} + +
+ + Katalog PenyakitPelajari tentang {diseases.length} penyakit daun jagung + + + + Distribusi RisikoPenyakit berdasarkan tingkat risiko + {summary &&
Risiko Tinggi{summary.riskDistribution.high}
Risiko Sedang{summary.riskDistribution.medium}
Risiko Rendah{summary.riskDistribution.low}
}
+
+
+ + { await createDiagnosisMutation.mutateAsync(file); }} + isSubmitting={createDiagnosisMutation.isPending} + latestResult={diagnoses[0] ?? null} + /> + + + + Riwayat Diagnosis + {diagnoses.length} diagnosis tersimpan + + + {diagnoses.length === 0 &&

Belum ada diagnosis.

} + {diagnoses.slice(0, 8).map((diagnosis) => )} +
+
+ + )} +
+
+ ); +} +``` + +- [ ] **Step 3: Run typecheck** + +Run: `bun run typecheck` + +Expected: Remaining errors should be only missing expert review route/page or backend route type issues. + +- [ ] **Step 4: Commit** + +```bash +git add apps/web/src/components/image-classification-form.tsx apps/web/src/pages/dashboard-page.tsx +git commit -m "feat: migrate dashboard to diagnosis workflow" +``` + +--- + +## Task 11: Add expert review page + +**Files:** +- Create: `apps/web/src/pages/expert-reviews-page.tsx` +- Modify: `apps/web/src/app.tsx` + +- [ ] **Step 1: Create expert reviews page** + +Write `apps/web/src/pages/expert-reviews-page.tsx` with this content: + +```tsx +import { FormEvent, useMemo, useState } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; +import type { DiseaseSlug } from '@zeavis/shared'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { DiagnosisCard } from '@/components/diagnosis-card'; +import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge'; +import { apiClient } from '@/lib/api-client'; + +export function ExpertReviewsPage() { + const [searchParams, setSearchParams] = useSearchParams(); + const selectedId = searchParams.get('diagnosis'); + const [verdict, setVerdict] = useState<'verified' | 'corrected'>('verified'); + const [correctedDiseaseSlug, setCorrectedDiseaseSlug] = useState(''); + const [notes, setNotes] = useState(''); + const queryClient = useQueryClient(); + + const [reviewsQuery, diseasesQuery] = useQueries({ + queries: [ + { queryKey: ['expert-reviews'], queryFn: () => apiClient.getExpertReviews() }, + { queryKey: ['diseases'], queryFn: () => apiClient.getDiseases() }, + ], + }); + + const reviews = reviewsQuery.data || []; + const diseases = diseasesQuery.data || []; + const selected = useMemo(() => reviews.find((item) => item.id === selectedId) ?? reviews[0] ?? null, [reviews, selectedId]); + + const mutation = useMutation({ + mutationFn: () => apiClient.reviewDiagnosis(selected!.id, { + verdict, + correctedDiseaseSlug: verdict === 'corrected' ? correctedDiseaseSlug || undefined : undefined, + notes, + }), + onSuccess: () => { + setNotes(''); + setVerdict('verified'); + setCorrectedDiseaseSlug(''); + queryClient.invalidateQueries({ queryKey: ['expert-reviews'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] }); + }, + }); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!selected) return; + await mutation.mutateAsync(); + } + + return ( +
+
+
+
+

Expert Review

+

Antrean Review Pakar

+
+ +
+ +
+ + + Perlu Review + {reviews.length} diagnosis confidence rendah + + + {reviewsQuery.isLoading &&

Memuat antrean...

} + {reviews.length === 0 && !reviewsQuery.isLoading &&

Tidak ada antrean review.

} + {reviews.map((diagnosis) => ( + + ))} +
+
+ + {selected && ( + + +
+
+ {selected.disease?.commonName ?? 'Diagnosis gagal'} + Confidence {selected.confidence === null ? '-' : `${(selected.confidence * 100).toFixed(1)}%`} +
+ +
+
+ + Daun jagung +
+ {selected.predictions.map((prediction) => ( +
+ {prediction.rank}. {prediction.modelLabel} + {(prediction.confidence * 100).toFixed(1)}% +
+ ))} +
+
+
+ + +
+ {verdict === 'corrected' && ( + + )} +