# 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' && ( )}