Files
zeavis-edu/docs/superpowers/plans/2026-05-22-production-mvp.md

86 KiB

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:

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:

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
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:

"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:

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:

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
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:

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<CurrentUser | null> {
  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:

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<RegisterRequest> | 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<AuthRequest> | 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:

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
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:

import type { UploaderMetadata } from '@zeavis/shared';
import { env } from '../config/env';

export async function uploadImageToStorage(file: File): Promise<UploaderMetadata> {
  const formData = new FormData();
  formData.append('file', file);
  formData.append('fileName', file.name);

  const response = await fetch(`${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<UploaderMetadata>;

  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:

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<DiagnosisRecord | null> {
  const db = createDbClient();
  const filters = [eq(diagnoses.id, id)];
  if (!expertAccess && userId) {
    filters.push(eq(diagnoses.userId, userId));
  }

  const rows = await db
    .select({
      id: diagnoses.id,
      userId: diagnoses.userId,
      predictedDiseaseSlug: diagnoses.predictedDiseaseSlug,
      confidence: diagnoses.confidence,
      status: diagnoses.status,
      failureReason: diagnoses.failureReason,
      imageUrl: diagnoses.imageUrl,
      uploaderPublicId: diagnoses.uploaderPublicId,
      imageFileName: diagnoses.imageFileName,
      imageMimeType: diagnoses.imageMimeType,
      imageSizeBytes: diagnoses.imageSizeBytes,
      createdAt: diagnoses.createdAt,
      updatedAt: diagnoses.updatedAt,
      disease: {
        slug: diseaseCatalog.slug,
        label: diseaseCatalog.label,
        commonName: diseaseCatalog.commonName,
        summary: diseaseCatalog.summary,
        description: diseaseCatalog.description,
        symptoms: diseaseCatalog.symptoms,
        recommendations: diseaseCatalog.recommendations,
        riskLevel: diseaseCatalog.riskLevel,
        accentColor: diseaseCatalog.accentColor,
        displayOrder: diseaseCatalog.displayOrder,
      },
      reviewId: expertReviews.id,
      reviewDiagnosisId: expertReviews.diagnosisId,
      reviewExpertId: expertReviews.expertId,
      reviewVerdict: expertReviews.verdict,
      reviewCorrectedDiseaseSlug: expertReviews.correctedDiseaseSlug,
      reviewNotes: expertReviews.notes,
      reviewCreatedAt: expertReviews.createdAt,
      expertId: users.id,
      expertEmail: users.email,
      expertName: users.name,
    })
    .from(diagnoses)
    .leftJoin(diseaseCatalog, eq(diagnoses.predictedDiseaseSlug, diseaseCatalog.slug))
    .leftJoin(expertReviews, eq(expertReviews.diagnosisId, diagnoses.id))
    .leftJoin(users, eq(expertReviews.expertId, users.id))
    .where(and(...filters))
    .limit(1);

  const row = rows[0];
  if (!row) return null;

  const predictionRows = await db
    .select()
    .from(diagnosisPredictions)
    .where(eq(diagnosisPredictions.diagnosisId, row.id))
    .orderBy(diagnosisPredictions.rank);

  const predictions: DiagnosisPrediction[] = predictionRows.map((prediction) => ({
    id: prediction.id,
    diseaseSlug: prediction.diseaseSlug as any,
    modelLabel: prediction.modelLabel,
    confidence: prediction.confidence,
    rank: prediction.rank,
  }));

  return {
    id: row.id,
    userId: row.userId,
    predictedDiseaseSlug: row.predictedDiseaseSlug as any,
    confidence: row.confidence,
    status: row.status as any,
    failureReason: row.failureReason,
    imageUrl: row.imageUrl,
    uploaderPublicId: row.uploaderPublicId,
    imageFileName: row.imageFileName,
    imageMimeType: row.imageMimeType,
    imageSizeBytes: row.imageSizeBytes,
    createdAt: row.createdAt.toISOString(),
    updatedAt: row.updatedAt.toISOString(),
    disease: row.disease?.slug ? toDisease(row.disease as any) : null,
    predictions,
    latestReview: toReview(row),
  };
}

function getFileFromBody(body: unknown): File | null {
  if (body instanceof FormData) {
    const formFile = body.get('file');
    return formFile instanceof File ? formFile : null;
  }

  if (typeof body === 'object' && body !== null) {
    const bodyObj = body as Record<string, unknown>;
    if (bodyObj.file instanceof File) return bodyObj.file;
    if (Array.isArray(bodyObj.file) && bodyObj.file[0] instanceof File) return bodyObj.file[0];
  }

  return null;
}

export const diagnosisRoutes = new Elysia({ prefix: '/api/v1' })
  .post('/diagnoses', async ({ body, request }) => {
    const user = await getCurrentUser(request.headers.get('cookie'));
    if (!user) return unauthorized('Authentication required');

    const file = getFileFromBody(body);
    if (!file) return badRequest('Missing required field: file');
    if (file.size === 0) return badRequest('File is empty');
    if (file.size > env.uploadMaxBytes) return badRequest(`File must be smaller than ${env.uploadMaxBytes} bytes`);
    if (!env.uploadAllowedMimeTypes.includes(file.type)) return badRequest('File type is not allowed');

    let uploaderMetadata;
    try {
      uploaderMetadata = await uploadImageToStorage(file);
    } catch (error) {
      return badGateway(`Upload service error: ${error instanceof Error ? error.message : 'Unknown error'}`);
    }

    const db = createDbClient();

    try {
      const classification = await classifyImage(file);
      const status = classification.confidence >= 0.7 ? 'ai_verified' : 'needs_review';
      const inserted = await db
        .insert(diagnoses)
        .values({
          userId: user.id,
          predictedDiseaseSlug: classification.predictedDiseaseSlug,
          confidence: classification.confidence,
          status,
          imageUrl: uploaderMetadata.download_url,
          uploaderPublicId: uploaderMetadata.public_id,
          imageFileName: uploaderMetadata.file_name || file.name,
          imageMimeType: uploaderMetadata.mime_type || file.type,
          imageSizeBytes: uploaderMetadata.size_bytes || file.size,
        })
        .returning();

      await db.insert(diagnosisPredictions).values(
        classification.probabilities.map((prediction, index) => ({
          diagnosisId: inserted[0].id,
          diseaseSlug: prediction.diseaseSlug,
          modelLabel: prediction.label,
          confidence: prediction.confidence,
          rank: index + 1,
        })),
      );

      const record = await loadDiagnosisRecord(inserted[0].id, user.id, false);
      return record!;
    } catch (error) {
      const inserted = await db
        .insert(diagnoses)
        .values({
          userId: user.id,
          status: 'failed',
          failureReason: error instanceof Error ? error.message : 'Model inference failed',
          imageUrl: uploaderMetadata.download_url,
          uploaderPublicId: uploaderMetadata.public_id,
          imageFileName: uploaderMetadata.file_name || file.name,
          imageMimeType: uploaderMetadata.mime_type || file.type,
          imageSizeBytes: uploaderMetadata.size_bytes || file.size,
        })
        .returning();

      const record = await loadDiagnosisRecord(inserted[0].id, user.id, false);
      return record!;
    }
  })
  .get('/diagnoses', async ({ request }) => {
    const user = await getCurrentUser(request.headers.get('cookie'));
    if (!user) return unauthorized('Authentication required');

    try {
      const db = createDbClient();
      const rows = await db
        .select({ id: diagnoses.id })
        .from(diagnoses)
        .where(eq(diagnoses.userId, user.id))
        .orderBy(desc(diagnoses.createdAt))
        .limit(30);

      const records = await Promise.all(rows.map((row) => loadDiagnosisRecord(row.id, user.id, false)));
      return records.filter(Boolean);
    } catch (error) {
      return serviceUnavailable('Database unavailable');
    }
  })
  .get('/diagnoses/:id', async ({ params, request }) => {
    const user = await getCurrentUser(request.headers.get('cookie'));
    if (!user) return unauthorized('Authentication required');

    try {
      const record = await loadDiagnosisRecord(params.id, user.id, user.role === 'expert');
      if (!record) return notFound('Diagnosis not found');
      return record;
    } catch (error) {
      return serviceUnavailable('Database unavailable');
    }
  });

export { loadDiagnosisRecord };
  • Step 3: Register diagnosis routes

Modify apps/api/src/index.ts to import and use diagnosis routes:

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
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:

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<ReviewDiagnosisRequest> | 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:

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
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:

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:

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
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:

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<T>(endpoint: string, options?: RequestInit): Promise<T> {
  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<AuthMeResponse> {
    return fetchApi('/api/v1/auth/me');
  },

  async register(payload: RegisterRequest): Promise<AuthResponse> {
    return fetchApi('/api/v1/auth/register', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
  },

  async login(payload: AuthRequest): Promise<AuthResponse> {
    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<DiseaseCatalogItem[]> {
    return fetchApi('/api/v1/diseases');
  },

  async getDisease(slug: DiseaseSlug): Promise<DiseaseCatalogItem> {
    return fetchApi(`/api/v1/diseases/${slug}`);
  },

  async getManualClassifications(): Promise<ManualClassificationRecord[]> {
    return fetchApi('/api/v1/classifications/manual');
  },

  async createManualClassification(payload: ManualClassificationRequest): Promise<ManualClassificationRecord> {
    return fetchApi('/api/v1/classifications/manual', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
  },

  async getDiagnoses(): Promise<DiagnosisRecord[]> {
    return fetchApi('/api/v1/diagnoses');
  },

  async getDiagnosis(id: string): Promise<DiagnosisRecord> {
    return fetchApi(`/api/v1/diagnoses/${id}`);
  },

  async createDiagnosis(file: File): Promise<DiagnosisRecord> {
    const formData = new FormData();
    formData.append('file', file);

    return fetchApi('/api/v1/diagnoses', {
      method: 'POST',
      body: formData,
    });
  },

  async getExpertReviews(): Promise<DiagnosisRecord[]> {
    return fetchApi('/api/v1/expert/reviews');
  },

  async reviewDiagnosis(id: string, payload: ReviewDiagnosisRequest): Promise<DiagnosisRecord> {
    return fetchApi(`/api/v1/expert/reviews/${id}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(payload),
    });
  },

  async getDashboardSummary(): Promise<DashboardSummary> {
    return fetchApi('/api/v1/dashboard/summary');
  },
};
  • Step 2: Create auth store

Write apps/web/src/store/auth-store.ts with this content:

import { create } from 'zustand';
import type { AuthUser } from '@zeavis/shared';

type AuthState = {
  user: AuthUser | null;
  setUser: (user: AuthUser | null) => void;
};

export const useAuthStore = create<AuthState>((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
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:

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<void>;
};

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<HTMLFormElement>) {
    event.preventDefault();
    await onSubmit({ name, email, password });
  }

  return (
    <Card className="mx-auto w-full max-w-md">
      <CardHeader>
        <CardTitle>{mode === 'login' ? 'Masuk ke ZeaVis Edu' : 'Buat akun ZeaVis Edu'}</CardTitle>
        <CardDescription>
          {mode === 'login'
            ? 'Masuk untuk melihat riwayat diagnosis daun jagung Anda.'
            : 'Daftar untuk menyimpan diagnosis dan mengikuti review pakar.'}
        </CardDescription>
      </CardHeader>
      <CardContent>
        <form className="space-y-4" onSubmit={handleSubmit}>
          {mode === 'register' && (
            <div className="space-y-2">
              <Label htmlFor="name">Nama</Label>
              <Input id="name" value={name} onChange={(event) => setName(event.target.value)} required />
            </div>
          )}
          <div className="space-y-2">
            <Label htmlFor="email">Email</Label>
            <Input id="email" type="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
          </div>
          <div className="space-y-2">
            <Label htmlFor="password">Password</Label>
            <Input id="password" type="password" minLength={8} value={password} onChange={(event) => setPassword(event.target.value)} required />
          </div>
          {error && <p className="text-sm text-red-600">{error}</p>}
          <Button className="w-full" type="submit" disabled={isSubmitting}>
            {isSubmitting ? 'Memproses...' : mode === 'login' ? 'Masuk' : 'Daftar'}
          </Button>
        </form>
        {googleOAuthEnabled && (
          <Button className="mt-3 w-full" variant="outline" asChild>
            <a href="/api/v1/auth/google">Masuk dengan Google</a>
          </Button>
        )}
      </CardContent>
    </Card>
  );
}
  • Step 2: Create auth guard

Write apps/web/src/components/auth-guard.tsx with this content:

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 <main className="min-h-screen p-8 text-center text-muted-foreground">Memeriksa sesi...</main>;
  }

  if (!query.data?.user) {
    return <Navigate to="/login" replace />;
  }

  if (requireExpert && query.data.user.role !== 'expert') {
    return <Navigate to="/dashboard" replace />;
  }

  return <>{children}</>;
}
  • Step 3: Create login page

Write apps/web/src/pages/login-page.tsx with this content:

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<string | null>(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 (
    <main className="flex min-h-screen items-center justify-center px-6 py-12">
      <div className="w-full space-y-4">
        <AuthForm
          mode="login"
          isSubmitting={mutation.isPending}
          error={error}
          googleOAuthEnabled={Boolean(meQuery.data?.features.googleOAuthEnabled)}
          onSubmit={async ({ email, password }) => mutation.mutateAsync({ email, password })}
        />
        <p className="text-center text-sm text-muted-foreground">
          Belum punya akun? <Link className="text-primary" to="/register">Daftar</Link>
        </p>
      </div>
    </main>
  );
}
  • Step 4: Create register page

Write apps/web/src/pages/register-page.tsx with this content:

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<string | null>(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 (
    <main className="flex min-h-screen items-center justify-center px-6 py-12">
      <div className="w-full space-y-4">
        <AuthForm
          mode="register"
          isSubmitting={mutation.isPending}
          error={error}
          googleOAuthEnabled={Boolean(meQuery.data?.features.googleOAuthEnabled)}
          onSubmit={async ({ name, email, password }) => mutation.mutateAsync({ name: name ?? '', email, password })}
        />
        <p className="text-center text-sm text-muted-foreground">
          Sudah punya akun? <Link className="text-primary" to="/login">Masuk</Link>
        </p>
      </div>
    </main>
  );
}
  • Step 5: Add routes

Modify apps/web/src/app.tsx to include auth and guards:

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: <LandingPage /> },
  { path: '/login', element: <LoginPage /> },
  { path: '/register', element: <RegisterPage /> },
  {
    path: '/dashboard',
    element: (
      <AuthGuard>
        <DashboardPage />
      </AuthGuard>
    ),
  },
  { path: '/catalog', element: <CatalogPage /> },
  { path: '/catalog/:slug', element: <DiseaseDetailPage /> },
]);

export function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <RouterProvider router={router} />
    </QueryClientProvider>
  );
}
  • 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
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:

import type { DiagnosisStatus } from '@zeavis/shared';
import { cn } from '@/lib/utils';

const labels: Record<DiagnosisStatus, string> = {
  ai_verified: 'Terverifikasi AI',
  needs_review: 'Menunggu review pakar',
  expert_verified: 'Diverifikasi pakar',
  expert_corrected: 'Dikoreksi pakar',
  failed: 'Gagal diproses',
};

const styles: Record<DiagnosisStatus, string> = {
  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 <span className={cn('rounded-full px-3 py-1 text-xs font-semibold', styles[status], className)}>{labels[status]}</span>;
}
  • Step 2: Create diagnosis card

Write apps/web/src/components/diagnosis-card.tsx with this content:

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 (
    <Link to={href}>
      <Card className="transition hover:border-primary">
        <CardContent className="flex gap-4 p-4">
          <img src={diagnosis.imageUrl} alt="Daun jagung" className="h-24 w-24 rounded-lg object-cover" />
          <div className="min-w-0 flex-1 space-y-2">
            <div className="flex flex-wrap items-center justify-between gap-2">
              <h3 className="font-semibold">{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}</h3>
              <DiagnosisStatusBadge status={diagnosis.status} />
            </div>
            <p className="text-sm text-muted-foreground">
              {diagnosis.confidence === null ? 'Tidak ada confidence' : `Confidence ${(diagnosis.confidence * 100).toFixed(1)}%`}
            </p>
            <p className="text-xs text-muted-foreground">{new Date(diagnosis.createdAt).toLocaleString('id-ID')}</p>
          </div>
        </CardContent>
      </Card>
    </Link>
  );
}
  • Step 3: Create diagnosis detail page

Write apps/web/src/pages/diagnosis-detail-page.tsx with this content:

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 <main className="p-8 text-center text-muted-foreground">Memuat diagnosis...</main>;
  if (query.error || !query.data) return <main className="p-8 text-center text-red-600">Diagnosis tidak ditemukan</main>;

  const diagnosis = query.data;

  return (
    <main className="min-h-screen px-6 py-8">
      <div className="mx-auto max-w-5xl space-y-6">
        <div className="flex items-center justify-between gap-4">
          <div>
            <p className="text-sm font-medium text-primary">Detail Diagnosis</p>
            <h1 className="text-3xl font-bold">{diagnosis.disease?.commonName ?? 'Diagnosis gagal'}</h1>
          </div>
          <Button asChild variant="outline"><Link to="/dashboard">Kembali</Link></Button>
        </div>

        <section className="grid gap-6 lg:grid-cols-[360px_1fr]">
          <Card>
            <CardContent className="p-4">
              <img src={diagnosis.imageUrl} alt="Daun jagung" className="w-full rounded-xl object-cover" />
            </CardContent>
          </Card>

          <Card>
            <CardHeader>
              <div className="flex items-center justify-between gap-3">
                <CardTitle>Hasil AI</CardTitle>
                <DiagnosisStatusBadge status={diagnosis.status} />
              </div>
              <CardDescription>
                {diagnosis.confidence === null ? 'Model gagal memproses gambar.' : `Confidence ${(diagnosis.confidence * 100).toFixed(1)}%`}
              </CardDescription>
            </CardHeader>
            <CardContent className="space-y-4">
              {diagnosis.status === 'needs_review' && (
                <div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
                  Hasil ini masih sementara karena confidence di bawah 70% dan sedang menunggu review pakar.
                </div>
              )}
              {diagnosis.failureReason && <p className="text-sm text-red-600">{diagnosis.failureReason}</p>}
              {diagnosis.disease && (
                <div className="space-y-3">
                  <div className="flex items-center gap-2">
                    <h2 className="text-xl font-semibold">{diagnosis.disease.label}</h2>
                    <RiskBadge level={diagnosis.disease.riskLevel} />
                  </div>
                  <p className="text-muted-foreground">{diagnosis.disease.summary}</p>
                  <div>
                    <h3 className="font-semibold">Rekomendasi</h3>
                    <ul className="mt-2 list-disc space-y-1 pl-5 text-sm text-muted-foreground">
                      {diagnosis.disease.recommendations.map((item) => <li key={item}>{item}</li>)}
                    </ul>
                  </div>
                </div>
              )}
            </CardContent>
          </Card>
        </section>

        <Card>
          <CardHeader>
            <CardTitle>Top Predictions</CardTitle>
          </CardHeader>
          <CardContent className="space-y-2">
            {diagnosis.predictions.map((prediction) => (
              <div key={prediction.id} className="flex items-center justify-between rounded-lg border p-3 text-sm">
                <span>{prediction.rank}. {prediction.modelLabel}</span>
                <span className="font-semibold">{(prediction.confidence * 100).toFixed(1)}%</span>
              </div>
            ))}
          </CardContent>
        </Card>

        {diagnosis.latestReview && (
          <Card>
            <CardHeader>
              <CardTitle>Catatan Pakar</CardTitle>
              <CardDescription>{diagnosis.latestReview.expert.name}</CardDescription>
            </CardHeader>
            <CardContent>
              <p className="text-sm text-muted-foreground">{diagnosis.latestReview.notes}</p>
            </CardContent>
          </Card>
        )}
      </div>
    </main>
  );
}
  • Step 4: Register diagnosis detail route

Modify apps/web/src/app.tsx imports:

import { DiagnosisDetailPage } from '@/pages/diagnosis-detail-page';

Add route:

{
  path: '/diagnoses/:id',
  element: (
    <AuthGuard>
      <DiagnosisDetailPage />
    </AuthGuard>
  ),
},
  • 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
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:

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<void>;
  isSubmitting: boolean;
  latestResult: DiagnosisRecord | null;
};

export function ImageClassificationForm({ onSubmit, isSubmitting, latestResult }: ImageClassificationFormProps) {
  const [file, setFile] = useState<File | null>(null);
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
    const selectedFile = event.target.files?.[0] ?? null;
    setError(null);
    setFile(selectedFile);
    setPreviewUrl(selectedFile ? URL.createObjectURL(selectedFile) : null);
  }

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    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 (
    <Card>
      <CardHeader>
        <CardTitle className="flex items-center gap-2"><Upload className="h-5 w-5" /> Diagnosis Gambar</CardTitle>
        <CardDescription>Upload gambar daun jagung untuk klasifikasi AI dan review pakar jika confidence rendah.</CardDescription>
      </CardHeader>
      <CardContent className="space-y-4">
        <form onSubmit={handleSubmit} className="space-y-4">
          <input type="file" accept="image/jpeg,image/png" onChange={handleFileChange} />
          {previewUrl && <img src={previewUrl} alt="Preview" className="h-48 rounded-lg object-cover" />}
          {error && <p className="text-sm text-red-600">{error}</p>}
          <Button type="submit" disabled={isSubmitting}>{isSubmitting ? 'Memproses...' : 'Upload dan Diagnosis'}</Button>
        </form>

        {latestResult && (
          <div className="rounded-lg border p-4">
            <div className="mb-2 flex items-center justify-between gap-3">
              <h3 className="font-semibold">{latestResult.disease?.commonName ?? 'Diagnosis gagal'}</h3>
              <DiagnosisStatusBadge status={latestResult.status} />
            </div>
            <p className="text-sm text-muted-foreground">
              {latestResult.confidence === null ? 'Tidak ada confidence' : `Confidence ${(latestResult.confidence * 100).toFixed(1)}%`}
            </p>
          </div>
        )}
      </CardContent>
    </Card>
  );
}
  • Step 2: Replace dashboard page

Write apps/web/src/pages/dashboard-page.tsx with this content:

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 (
    <main className="min-h-screen px-6 py-8">
      <div className="mx-auto max-w-6xl space-y-8">
        <header className="flex flex-col gap-4 rounded-3xl border bg-card p-6 shadow-sm md:flex-row md:items-center md:justify-between">
          <div className="space-y-2">
            <div className="flex items-center gap-2 text-sm font-medium text-primary">
              <LayoutDashboard className="h-4 w-4" /> Dashboard
            </div>
            <h1 className="text-3xl font-bold tracking-tight">ZeaVis Edu Workspace</h1>
            <p className="text-muted-foreground">Masuk sebagai {user?.name ?? 'pengguna'} untuk diagnosis daun jagung.</p>
          </div>
          <div className="flex flex-wrap gap-3">
            {user?.role === 'expert' && <Button asChild variant="outline"><Link to="/expert/reviews"><ShieldCheck className="mr-2 h-4 w-4" /> Review Pakar</Link></Button>}
            <Button variant="outline" onClick={toggleDashboardCompact}>{dashboardCompact ? 'Mode Nyaman' : 'Mode Ringkas'}</Button>
            <Button variant="outline" onClick={() => logoutMutation.mutate()}><LogOut className="mr-2 h-4 w-4" /> Keluar</Button>
          </div>
        </header>

        {isLoadingData && <Card className="p-8 text-center text-muted-foreground">Memuat data dashboard...</Card>}
        {hasError && <Card className="p-8 text-center text-red-600">Gagal memuat data dashboard</Card>}

        {!isLoadingData && !hasError && (
          <>
            {summary && (
              <section className={dashboardCompact ? 'grid gap-4 md:grid-cols-4' : 'grid gap-6 md:grid-cols-4'}>
                <Card><CardHeader className="pb-3"><CardTitle className="text-sm font-medium text-muted-foreground">Total Penyakit</CardTitle></CardHeader><CardContent><div className="text-3xl font-bold">{summary.diseaseCount}</div></CardContent></Card>
                <Card><CardHeader className="pb-3"><CardTitle className="text-sm font-medium text-muted-foreground">Diagnosis Saya</CardTitle></CardHeader><CardContent><div className="text-3xl font-bold">{summary.imageClassificationCount}</div></CardContent></Card>
                <Card><CardHeader className="pb-3"><CardTitle className="text-sm font-medium text-muted-foreground">Perlu Review</CardTitle></CardHeader><CardContent><div className="text-3xl font-bold text-amber-600">{summary.needsReviewCount}</div></CardContent></Card>
                <Card><CardHeader className="pb-3"><CardTitle className="text-sm font-medium text-muted-foreground">Risiko Tinggi</CardTitle></CardHeader><CardContent><div className="text-3xl font-bold text-red-600">{summary.riskDistribution.high}</div></CardContent></Card>
              </section>
            )}

            <section className={dashboardCompact ? 'grid gap-4 md:grid-cols-2' : 'grid gap-6 md:grid-cols-2'}>
              <Card>
                <CardHeader><CardTitle className="flex items-center gap-2"><BookOpen className="h-5 w-5" /> Katalog Penyakit</CardTitle><CardDescription>Pelajari tentang {diseases.length} penyakit daun jagung</CardDescription></CardHeader>
                <CardContent><Button asChild className="w-full"><Link to="/catalog">Buka Katalog</Link></Button></CardContent>
              </Card>
              <Card>
                <CardHeader><CardTitle className="flex items-center gap-2"><TrendingUp className="h-5 w-5" /> Distribusi Risiko</CardTitle><CardDescription>Penyakit berdasarkan tingkat risiko</CardDescription></CardHeader>
                <CardContent>{summary && <div className="space-y-2 text-sm text-muted-foreground"><div className="flex justify-between"><span>Risiko Tinggi</span><b>{summary.riskDistribution.high}</b></div><div className="flex justify-between"><span>Risiko Sedang</span><b>{summary.riskDistribution.medium}</b></div><div className="flex justify-between"><span>Risiko Rendah</span><b>{summary.riskDistribution.low}</b></div></div>}</CardContent>
              </Card>
            </section>

            <ImageClassificationForm
              onSubmit={async (file) => { await createDiagnosisMutation.mutateAsync(file); }}
              isSubmitting={createDiagnosisMutation.isPending}
              latestResult={diagnoses[0] ?? null}
            />

            <Card>
              <CardHeader>
                <CardTitle className="flex items-center gap-2"><History className="h-5 w-5" /> Riwayat Diagnosis</CardTitle>
                <CardDescription>{diagnoses.length} diagnosis tersimpan</CardDescription>
              </CardHeader>
              <CardContent className="grid gap-4 md:grid-cols-2">
                {diagnoses.length === 0 && <p className="text-sm text-muted-foreground">Belum ada diagnosis.</p>}
                {diagnoses.slice(0, 8).map((diagnosis) => <DiagnosisCard key={diagnosis.id} diagnosis={diagnosis} />)}
              </CardContent>
            </Card>
          </>
        )}
      </div>
    </main>
  );
}
  • 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
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:

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<DiseaseSlug | ''>('');
  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<HTMLFormElement>) {
    event.preventDefault();
    if (!selected) return;
    await mutation.mutateAsync();
  }

  return (
    <main className="min-h-screen px-6 py-8">
      <div className="mx-auto max-w-6xl space-y-6">
        <header className="flex items-center justify-between gap-4">
          <div>
            <p className="text-sm font-medium text-primary">Expert Review</p>
            <h1 className="text-3xl font-bold">Antrean Review Pakar</h1>
          </div>
          <Button asChild variant="outline"><Link to="/dashboard">Dashboard</Link></Button>
        </header>

        <section className="grid gap-6 lg:grid-cols-[360px_1fr]">
          <Card>
            <CardHeader>
              <CardTitle>Perlu Review</CardTitle>
              <CardDescription>{reviews.length} diagnosis confidence rendah</CardDescription>
            </CardHeader>
            <CardContent className="space-y-3">
              {reviewsQuery.isLoading && <p className="text-sm text-muted-foreground">Memuat antrean...</p>}
              {reviews.length === 0 && !reviewsQuery.isLoading && <p className="text-sm text-muted-foreground">Tidak ada antrean review.</p>}
              {reviews.map((diagnosis) => (
                <button key={diagnosis.id} className="block w-full text-left" onClick={() => setSearchParams({ diagnosis: diagnosis.id })}>
                  <DiagnosisCard diagnosis={diagnosis} expertLink />
                </button>
              ))}
            </CardContent>
          </Card>

          {selected && (
            <Card>
              <CardHeader>
                <div className="flex items-center justify-between gap-3">
                  <div>
                    <CardTitle>{selected.disease?.commonName ?? 'Diagnosis gagal'}</CardTitle>
                    <CardDescription>Confidence {selected.confidence === null ? '-' : `${(selected.confidence * 100).toFixed(1)}%`}</CardDescription>
                  </div>
                  <DiagnosisStatusBadge status={selected.status} />
                </div>
              </CardHeader>
              <CardContent className="space-y-5">
                <img src={selected.imageUrl} alt="Daun jagung" className="max-h-80 rounded-xl object-cover" />
                <div className="space-y-2">
                  {selected.predictions.map((prediction) => (
                    <div key={prediction.id} className="flex justify-between rounded-lg border p-3 text-sm">
                      <span>{prediction.rank}. {prediction.modelLabel}</span>
                      <span className="font-semibold">{(prediction.confidence * 100).toFixed(1)}%</span>
                    </div>
                  ))}
                </div>
                <form className="space-y-4" onSubmit={handleSubmit}>
                  <div className="flex gap-4 text-sm">
                    <label><input type="radio" checked={verdict === 'verified'} onChange={() => setVerdict('verified')} /> Verifikasi hasil AI</label>
                    <label><input type="radio" checked={verdict === 'corrected'} onChange={() => setVerdict('corrected')} /> Koreksi label</label>
                  </div>
                  {verdict === 'corrected' && (
                    <select className="w-full rounded-md border bg-background p-2" value={correctedDiseaseSlug} onChange={(event) => setCorrectedDiseaseSlug(event.target.value as DiseaseSlug)} required>
                      <option value="">Pilih label koreksi</option>
                      {diseases.map((disease) => <option key={disease.slug} value={disease.slug}>{disease.commonName}</option>)}
                    </select>
                  )}
                  <textarea className="min-h-28 w-full rounded-md border bg-background p-3" value={notes} onChange={(event) => setNotes(event.target.value)} placeholder="Catatan pakar" required />
                  {mutation.error && <p className="text-sm text-red-600">{mutation.error instanceof Error ? mutation.error.message : 'Review gagal'}</p>}
                  <Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Menyimpan...' : 'Simpan Review'}</Button>
                </form>
              </CardContent>
            </Card>
          )}
        </section>
      </div>
    </main>
  );
}
  • Step 2: Register expert route

Modify apps/web/src/app.tsx imports:

import { ExpertReviewsPage } from '@/pages/expert-reviews-page';

Add route:

{
  path: '/expert/reviews',
  element: (
    <AuthGuard requireExpert>
      <ExpertReviewsPage />
    </AuthGuard>
  ),
},
  • Step 3: Run typecheck

Run: bun run typecheck

Expected: Frontend routes typecheck.

  • Step 4: Commit
git add apps/web/src/pages/expert-reviews-page.tsx apps/web/src/app.tsx
git commit -m "feat: add expert review UI"

Task 12: Generate migrations and run full verification

Files:

  • Generated: apps/api/drizzle/*.sql

  • Generated/modified: apps/api/drizzle/meta/*.json

  • Verify: no source changes unless typecheck reveals required fixes

  • Step 1: Generate Drizzle migration

Run: cd apps/api && bun run db:generate

Expected: A new SQL migration is generated under apps/api/drizzle/ for users, sessions, diagnoses, diagnosis_predictions, and expert_reviews.

  • Step 2: Run full typecheck

Run: bun run typecheck

Expected: PASS.

  • Step 3: Run production build

Run: bun run build

Expected: PASS for shared, api, and web build tasks.

  • Step 4: Apply migration when DATABASE_URL is available

Run: cd apps/api && bun run db:migrate

Expected: PASS against the configured PostgreSQL database. If DATABASE_URL is not available, record that migration application was not run and do not claim DB verification.

  • Step 5: Run the app for manual browser verification

Run: bun run dev

Expected: API and web dev servers start through Moon.

  • Step 6: Manual happy path

In the browser:

  1. Open the web app.
  2. Register a user with a unique email and password at least 8 characters.
  3. Confirm redirect to /dashboard.
  4. Upload a JPEG or PNG corn leaf image.
  5. Confirm a diagnosis appears in history.
  6. Open the diagnosis detail page.
  7. Confirm image, disease, confidence, top predictions, status, and recommendations render.
  8. Log out.
  9. Log in as an expert user.
  10. Open /expert/reviews.
  11. Review a needs_review diagnosis.
  12. Confirm it disappears from the queue and the diagnosis detail shows expert review notes.

Expected: All steps pass. If no low-confidence image is naturally available, temporarily lower the review threshold in local code for manual testing, verify the flow, then revert the threshold before committing.

  • Step 7: Manual error path

In the browser or via API client:

  1. Upload a .txt file and confirm a validation error.
  2. Visit /dashboard after logout and confirm redirect to /login.
  3. Log in as a normal user and visit /expert/reviews; confirm redirect to /dashboard.
  4. Run without Google env and confirm the Google login button is hidden.

Expected: All error paths behave as specified.

  • Step 8: Commit migration and verification fixes
git add apps/api/drizzle apps/api/drizzle/meta
git commit -m "feat: add production MVP database migration"

Self-review notes

Spec coverage:

  • Auth email/password, sessions, roles, and optional Google OAuth are covered in Tasks 2, 3, 7, and 8.
  • PostgreSQL schema and migrations are covered in Tasks 2 and 12.
  • External uploader integration is covered in Task 4.
  • Backend TFJS classification persistence and top predictions are covered in Task 4.
  • User dashboard/history/detail pages are covered in Tasks 9 and 10.
  • Expert review queue and review submission are covered in Tasks 5 and 11.
  • Error handling and verification are covered in Tasks 3, 4, 5, 10, 11, and 12.

Red-flag scan: no unresolved planning markers are intentionally present. The only implementation choice left to workers is resolving compile errors revealed by real typecheck output, which must be fixed directly before completing each task.

Type consistency: shared DTO names are introduced first and reused by backend/frontend tasks. Diagnosis status strings match the design spec.


Catatan (2026-08-02): port produksi sekarang API 4006, nginx 4011, ML 4012; deploy Nix+systemd+Caddy.