Merge branch 'worktree-production-mvp'

This commit is contained in:
Asep Haryana Saputra
2026-05-22 17:46:40 +00:00
33 changed files with 2931 additions and 351 deletions
+111
View File
@@ -0,0 +1,111 @@
CREATE TABLE IF NOT EXISTS "diagnoses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"predicted_disease_slug" varchar(80),
"confidence" real,
"status" varchar(40) NOT NULL,
"failure_reason" text,
"image_url" text NOT NULL,
"uploader_public_id" varchar(160) NOT NULL,
"image_file_name" varchar(240) NOT NULL,
"image_mime_type" varchar(120) NOT NULL,
"image_size_bytes" integer NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "diagnosis_predictions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"diagnosis_id" uuid NOT NULL,
"disease_slug" varchar(80),
"model_label" varchar(120) NOT NULL,
"confidence" real NOT NULL,
"rank" integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "expert_reviews" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"diagnosis_id" uuid NOT NULL,
"expert_id" uuid NOT NULL,
"verdict" varchar(20) NOT NULL,
"corrected_disease_slug" varchar(80),
"notes" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token_hash" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE IF NOT EXISTS "users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" varchar(240) NOT NULL,
"name" varchar(160) NOT NULL,
"password_hash" text,
"role" varchar(20) DEFAULT 'user' NOT NULL,
"google_id" varchar(240),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "diagnoses" ADD CONSTRAINT "diagnoses_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "diagnoses" ADD CONSTRAINT "diagnoses_predicted_disease_slug_disease_catalog_slug_fk" FOREIGN KEY ("predicted_disease_slug") REFERENCES "public"."disease_catalog"("slug") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "diagnosis_predictions" ADD CONSTRAINT "diagnosis_predictions_diagnosis_id_diagnoses_id_fk" FOREIGN KEY ("diagnosis_id") REFERENCES "public"."diagnoses"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "diagnosis_predictions" ADD CONSTRAINT "diagnosis_predictions_disease_slug_disease_catalog_slug_fk" FOREIGN KEY ("disease_slug") REFERENCES "public"."disease_catalog"("slug") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "expert_reviews" ADD CONSTRAINT "expert_reviews_diagnosis_id_diagnoses_id_fk" FOREIGN KEY ("diagnosis_id") REFERENCES "public"."diagnoses"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "expert_reviews" ADD CONSTRAINT "expert_reviews_expert_id_users_id_fk" FOREIGN KEY ("expert_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "expert_reviews" ADD CONSTRAINT "expert_reviews_corrected_disease_slug_disease_catalog_slug_fk" FOREIGN KEY ("corrected_disease_slug") REFERENCES "public"."disease_catalog"("slug") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "diagnoses_user_id_idx" ON "diagnoses" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "diagnoses_status_idx" ON "diagnoses" USING btree ("status");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "diagnosis_predictions_diagnosis_id_idx" ON "diagnosis_predictions" USING btree ("diagnosis_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "expert_reviews_diagnosis_id_idx" ON "expert_reviews" USING btree ("diagnosis_id");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "expert_reviews_expert_id_idx" ON "expert_reviews" USING btree ("expert_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "sessions_token_hash_idx" ON "sessions" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "sessions_user_id_idx" ON "sessions" USING btree ("user_id");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "users_email_idx" ON "users" USING btree ("email");--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "users_google_id_idx" ON "users" USING btree ("google_id");
+840
View File
@@ -0,0 +1,840 @@
{
"id": "da5776ab-55b4-4aa6-85da-61db5fc31546",
"prevId": "16700e2c-ff8f-4864-a71a-6f4b98a1313a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.app_events": {
"name": "app_events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "varchar(120)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.diagnoses": {
"name": "diagnoses",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"predicted_disease_slug": {
"name": "predicted_disease_slug",
"type": "varchar(80)",
"primaryKey": false,
"notNull": false
},
"confidence": {
"name": "confidence",
"type": "real",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "varchar(40)",
"primaryKey": false,
"notNull": true
},
"failure_reason": {
"name": "failure_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"uploader_public_id": {
"name": "uploader_public_id",
"type": "varchar(160)",
"primaryKey": false,
"notNull": true
},
"image_file_name": {
"name": "image_file_name",
"type": "varchar(240)",
"primaryKey": false,
"notNull": true
},
"image_mime_type": {
"name": "image_mime_type",
"type": "varchar(120)",
"primaryKey": false,
"notNull": true
},
"image_size_bytes": {
"name": "image_size_bytes",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"diagnoses_user_id_idx": {
"name": "diagnoses_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"diagnoses_status_idx": {
"name": "diagnoses_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"diagnoses_user_id_users_id_fk": {
"name": "diagnoses_user_id_users_id_fk",
"tableFrom": "diagnoses",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"diagnoses_predicted_disease_slug_disease_catalog_slug_fk": {
"name": "diagnoses_predicted_disease_slug_disease_catalog_slug_fk",
"tableFrom": "diagnoses",
"tableTo": "disease_catalog",
"columnsFrom": [
"predicted_disease_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.diagnosis_predictions": {
"name": "diagnosis_predictions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"diagnosis_id": {
"name": "diagnosis_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"disease_slug": {
"name": "disease_slug",
"type": "varchar(80)",
"primaryKey": false,
"notNull": false
},
"model_label": {
"name": "model_label",
"type": "varchar(120)",
"primaryKey": false,
"notNull": true
},
"confidence": {
"name": "confidence",
"type": "real",
"primaryKey": false,
"notNull": true
},
"rank": {
"name": "rank",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"diagnosis_predictions_diagnosis_id_idx": {
"name": "diagnosis_predictions_diagnosis_id_idx",
"columns": [
{
"expression": "diagnosis_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"diagnosis_predictions_diagnosis_id_diagnoses_id_fk": {
"name": "diagnosis_predictions_diagnosis_id_diagnoses_id_fk",
"tableFrom": "diagnosis_predictions",
"tableTo": "diagnoses",
"columnsFrom": [
"diagnosis_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"diagnosis_predictions_disease_slug_disease_catalog_slug_fk": {
"name": "diagnosis_predictions_disease_slug_disease_catalog_slug_fk",
"tableFrom": "diagnosis_predictions",
"tableTo": "disease_catalog",
"columnsFrom": [
"disease_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.disease_catalog": {
"name": "disease_catalog",
"schema": "",
"columns": {
"slug": {
"name": "slug",
"type": "varchar(80)",
"primaryKey": true,
"notNull": true
},
"label": {
"name": "label",
"type": "varchar(80)",
"primaryKey": false,
"notNull": true
},
"common_name": {
"name": "common_name",
"type": "varchar(120)",
"primaryKey": false,
"notNull": true
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"symptoms": {
"name": "symptoms",
"type": "text[]",
"primaryKey": false,
"notNull": true
},
"recommendations": {
"name": "recommendations",
"type": "text[]",
"primaryKey": false,
"notNull": true
},
"risk_level": {
"name": "risk_level",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"accent_color": {
"name": "accent_color",
"type": "varchar(40)",
"primaryKey": false,
"notNull": true
},
"display_order": {
"name": "display_order",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.expert_reviews": {
"name": "expert_reviews",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"diagnosis_id": {
"name": "diagnosis_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"expert_id": {
"name": "expert_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"verdict": {
"name": "verdict",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true
},
"corrected_disease_slug": {
"name": "corrected_disease_slug",
"type": "varchar(80)",
"primaryKey": false,
"notNull": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"expert_reviews_diagnosis_id_idx": {
"name": "expert_reviews_diagnosis_id_idx",
"columns": [
{
"expression": "diagnosis_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"expert_reviews_expert_id_idx": {
"name": "expert_reviews_expert_id_idx",
"columns": [
{
"expression": "expert_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"expert_reviews_diagnosis_id_diagnoses_id_fk": {
"name": "expert_reviews_diagnosis_id_diagnoses_id_fk",
"tableFrom": "expert_reviews",
"tableTo": "diagnoses",
"columnsFrom": [
"diagnosis_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"expert_reviews_expert_id_users_id_fk": {
"name": "expert_reviews_expert_id_users_id_fk",
"tableFrom": "expert_reviews",
"tableTo": "users",
"columnsFrom": [
"expert_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
},
"expert_reviews_corrected_disease_slug_disease_catalog_slug_fk": {
"name": "expert_reviews_corrected_disease_slug_disease_catalog_slug_fk",
"tableFrom": "expert_reviews",
"tableTo": "disease_catalog",
"columnsFrom": [
"corrected_disease_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.image_classifications": {
"name": "image_classifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"predicted_disease_slug": {
"name": "predicted_disease_slug",
"type": "varchar(80)",
"primaryKey": false,
"notNull": true
},
"confidence": {
"name": "confidence",
"type": "real",
"primaryKey": false,
"notNull": true
},
"probabilities": {
"name": "probabilities",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"original_file_name": {
"name": "original_file_name",
"type": "varchar(240)",
"primaryKey": false,
"notNull": true
},
"uploader_public_id": {
"name": "uploader_public_id",
"type": "varchar(160)",
"primaryKey": false,
"notNull": true
},
"uploader_payload": {
"name": "uploader_payload",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"image_classifications_predicted_disease_slug_disease_catalog_slug_fk": {
"name": "image_classifications_predicted_disease_slug_disease_catalog_slug_fk",
"tableFrom": "image_classifications",
"tableTo": "disease_catalog",
"columnsFrom": [
"predicted_disease_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.manual_classifications": {
"name": "manual_classifications",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"disease_slug": {
"name": "disease_slug",
"type": "varchar(80)",
"primaryKey": false,
"notNull": true
},
"observation": {
"name": "observation",
"type": "text",
"primaryKey": false,
"notNull": true
},
"location": {
"name": "location",
"type": "varchar(160)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"manual_classifications_disease_slug_disease_catalog_slug_fk": {
"name": "manual_classifications_disease_slug_disease_catalog_slug_fk",
"tableFrom": "manual_classifications",
"tableTo": "disease_catalog",
"columnsFrom": [
"disease_slug"
],
"columnsTo": [
"slug"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"sessions_token_hash_idx": {
"name": "sessions_token_hash_idx",
"columns": [
{
"expression": "token_hash",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"sessions_user_id_idx": {
"name": "sessions_user_id_idx",
"columns": [
{
"expression": "user_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "varchar(240)",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "varchar(160)",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"role": {
"name": "role",
"type": "varchar(20)",
"primaryKey": false,
"notNull": true,
"default": "'user'"
},
"google_id": {
"name": "google_id",
"type": "varchar(240)",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"users_email_idx": {
"name": "users_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"users_google_id_idx": {
"name": "users_google_id_idx",
"columns": [
{
"expression": "google_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -15,6 +15,13 @@
"when": 1779461286348,
"tag": "0001_cute_lyja",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1779470332261,
"tag": "0002_lumpy_smasher",
"breakpoints": true
}
]
}
+3
View File
@@ -11,8 +11,10 @@
"db:migrate": "drizzle-kit migrate"
},
"dependencies": {
"@elysiajs/cors": "1.4.2",
"@tensorflow/tfjs": "4.22.0",
"@zeavis/shared": "workspace:*",
"bcryptjs": "^2.4.3",
"drizzle-orm": "^0.36.4",
"elysia": "^1.1.25",
"jpeg-js": "0.4.4",
@@ -20,6 +22,7 @@
"postgres": "^3.4.5"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/bun": "latest",
"drizzle-kit": "^0.27.1",
"typescript": "^5.6.3"
+32
View File
@@ -1,4 +1,36 @@
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,
);
const webAppUrl = Bun.env.WEB_APP_URL ?? 'http://localhost:5173';
const secureCookies = Bun.env.SECURE_COOKIES === 'true' || webAppUrl.startsWith('https://');
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,
secureCookies,
};
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');
}
}
+80 -1
View File
@@ -1,4 +1,15 @@
import { pgTable, timestamp, uuid, varchar, text, integer, jsonb, real } from 'drizzle-orm/pg-core';
import {
index,
integer,
jsonb,
pgTable,
real,
text,
timestamp,
uniqueIndex,
uuid,
varchar,
} from 'drizzle-orm/pg-core';
export const appEvents = pgTable('app_events', {
id: uuid('id').primaryKey().defaultRandom(),
@@ -21,6 +32,31 @@ export const diseaseCatalog = pgTable('disease_catalog', {
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const users = pgTable('users', {
id: uuid('id').primaryKey().defaultRandom(),
email: varchar('email', { length: 240 }).notNull(),
name: varchar('name', { length: 160 }).notNull(),
passwordHash: text('password_hash'),
role: varchar('role', { length: 20 }).notNull().default('user'),
googleId: varchar('google_id', { length: 240 }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
emailIdx: uniqueIndex('users_email_idx').on(table.email),
googleIdx: uniqueIndex('users_google_id_idx').on(table.googleId),
}));
export const sessions = pgTable('sessions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id),
tokenHash: text('token_hash').notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
tokenIdx: uniqueIndex('sessions_token_hash_idx').on(table.tokenHash),
userIdx: index('sessions_user_id_idx').on(table.userId),
}));
export const manualClassifications = pgTable('manual_classifications', {
id: uuid('id').primaryKey().defaultRandom(),
diseaseSlug: varchar('disease_slug', { length: 80 })
@@ -44,3 +80,46 @@ export const imageClassifications = pgTable('image_classifications', {
uploaderPayload: jsonb('uploader_payload').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
});
export const diagnoses = pgTable('diagnoses', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull().references(() => users.id),
predictedDiseaseSlug: varchar('predicted_disease_slug', { length: 80 }).references(() => diseaseCatalog.slug),
confidence: real('confidence'),
status: varchar('status', { length: 40 }).notNull(),
failureReason: text('failure_reason'),
imageUrl: text('image_url').notNull(),
uploaderPublicId: varchar('uploader_public_id', { length: 160 }).notNull(),
imageFileName: varchar('image_file_name', { length: 240 }).notNull(),
imageMimeType: varchar('image_mime_type', { length: 120 }).notNull(),
imageSizeBytes: integer('image_size_bytes').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
userIdx: index('diagnoses_user_id_idx').on(table.userId),
statusIdx: index('diagnoses_status_idx').on(table.status),
}));
export const diagnosisPredictions = pgTable('diagnosis_predictions', {
id: uuid('id').primaryKey().defaultRandom(),
diagnosisId: uuid('diagnosis_id').notNull().references(() => diagnoses.id),
diseaseSlug: varchar('disease_slug', { length: 80 }).references(() => diseaseCatalog.slug),
modelLabel: varchar('model_label', { length: 120 }).notNull(),
confidence: real('confidence').notNull(),
rank: integer('rank').notNull(),
}, (table) => ({
diagnosisIdx: index('diagnosis_predictions_diagnosis_id_idx').on(table.diagnosisId),
}));
export const expertReviews = pgTable('expert_reviews', {
id: uuid('id').primaryKey().defaultRandom(),
diagnosisId: uuid('diagnosis_id').notNull().references(() => diagnoses.id),
expertId: uuid('expert_id').notNull().references(() => users.id),
verdict: varchar('verdict', { length: 20 }).notNull(),
correctedDiseaseSlug: varchar('corrected_disease_slug', { length: 80 }).references(() => diseaseCatalog.slug),
notes: text('notes').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
diagnosisIdx: index('expert_reviews_diagnosis_id_idx').on(table.diagnosisId),
expertIdx: index('expert_reviews_expert_id_idx').on(table.expertId),
}));
+9 -1
View File
@@ -1,8 +1,9 @@
import { diseaseCatalogSeed } from '@zeavis/shared';
import type { InferInsertModel } from 'drizzle-orm';
import { diseaseCatalog } from './schema';
import { diseaseCatalog, users } from './schema';
export type DiseaseCatalogRow = InferInsertModel<typeof diseaseCatalog>;
export type UserRow = InferInsertModel<typeof users>;
export const diseaseCatalogRows: DiseaseCatalogRow[] = diseaseCatalogSeed.map((item) => ({
slug: item.slug,
@@ -16,3 +17,10 @@ export const diseaseCatalogRows: DiseaseCatalogRow[] = diseaseCatalogSeed.map((i
accentColor: item.accentColor,
displayOrder: item.displayOrder,
}));
export const demoExpertUser = {
email: 'expert@zeavis.local',
name: 'ZeaVis Expert',
role: 'expert' as const,
password: Bun.env.DEMO_EXPERT_PASSWORD ?? 'password123',
};
+14 -1
View File
@@ -1,16 +1,29 @@
import { Elysia } from 'elysia';
import { env } from './config/env';
import cors from '@elysiajs/cors';
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 { diagnosisRoutes } from './routes/diagnoses';
import { dashboardRoutes } from './routes/dashboard';
import { authRoutes } from './routes/auth';
import { expertRoutes } from './routes/expert';
assertRequiredEnv();
const app = new Elysia()
.use(cors({
origin: env.webAppUrl,
credentials: true,
}))
.use(healthRoutes)
.use(statusRoutes)
.use(authRoutes)
.use(diseaseRoutes)
.use(classificationRoutes)
.use(diagnosisRoutes)
.use(expertRoutes)
.use(dashboardRoutes)
.listen(env.port);
+115
View File
@@ -0,0 +1,115 @@
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;
const sameSite = env.secureCookies ? 'SameSite=None; Secure' : 'SameSite=Lax';
return `${sessionCookieName}=${token}; HttpOnly; Path=/; ${sameSite}; Max-Age=${maxAge}`;
}
export function clearSessionCookie() {
const sameSite = env.secureCookies ? 'SameSite=None; Secure' : 'SameSite=Lax';
return `${sessionCookieName}=; HttpOnly; Path=/; ${sameSite}; 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;
}
+14
View File
@@ -5,6 +5,20 @@ export function badRequest(message: string): Response {
});
}
export function unauthorized(message: string): Response {
return new Response(JSON.stringify({ error: message }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
export function forbidden(message: string): Response {
return new Response(JSON.stringify({ error: message }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
});
}
export function notFound(message: string): Response {
return new Response(JSON.stringify({ error: message }), {
status: 404,
+16 -10
View File
@@ -1,28 +1,34 @@
import type { UploaderMetadata } from '@zeavis/shared';
import { env } from '../config/env';
export async function uploadImageToStorage(file: File): Promise<UploaderMetadata> {
const formData = new FormData();
formData.append('file', file);
formData.append('fileName', file.name);
const response = await fetch('https://upload.asepharyana.tech/api/upload', {
const response = await fetch(`${env.uploaderBaseUrl}/api/upload`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
throw new Error(`Upload failed with HTTP ${response.status}`);
}
const data = (await response.json()) as Record<string, unknown>;
const payload = await response.json() as Partial<UploaderMetadata>;
if (!data.download_url) {
throw new Error('Upload response missing download_url');
if (!payload.public_id || !payload.download_url || !payload.file_name || !payload.mime_type || typeof payload.size_bytes !== 'number') {
throw new Error('Upload response is missing required metadata');
}
if (!data.public_id) {
throw new Error('Upload response missing public_id');
}
return data as UploaderMetadata;
return {
public_id: payload.public_id,
telegram_file_id: payload.telegram_file_id,
telegram_file_unique_id: payload.telegram_file_unique_id,
file_name: payload.file_name,
mime_type: payload.mime_type,
size_bytes: payload.size_bytes,
file_type: payload.file_type ?? 'image',
download_url: payload.download_url,
};
}
+141
View File
@@ -0,0 +1,141 @@
import { Elysia } from 'elysia';
import type { AuthRequest, RegisterRequest } from '@zeavis/shared';
import { eq } from 'drizzle-orm';
import { createDbClient } from '../db/client';
import { users } from '../db/schema';
import { badRequest, serviceUnavailable } from '../lib/http-errors';
import {
clearSessionCookie,
createSession,
createSessionCookie,
deleteSession,
getAuthFeatures,
getCurrentUser,
hashPassword,
readSessionToken,
verifyPassword,
} from '../lib/auth';
import { env } from '../config/env';
function normalizeEmail(email: unknown) {
return typeof email === 'string' ? email.trim().toLowerCase() : '';
}
function normalizeName(name: unknown) {
return typeof name === 'string' ? name.trim() : '';
}
function validatePassword(password: unknown) {
return typeof password === 'string' && password.length >= 8;
}
export const authRoutes = new Elysia({ prefix: '/api/v1/auth' })
.get('/me', async ({ request }) => {
const user = await getCurrentUser(request.headers.get('cookie'));
return {
user,
features: getAuthFeatures(),
};
})
.post('/register', async ({ body, set }) => {
const req = body as Partial<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`;
});
+63 -77
View File
@@ -1,88 +1,74 @@
import { Elysia } from 'elysia';
import type { DashboardSummary, RiskLevel } from '@zeavis/shared';
import type { DashboardSummary } from '@zeavis/shared';
import { isDiseaseSlug } from '@zeavis/shared';
import { eq, desc, count } from 'drizzle-orm';
import { createDbClient } from '../db/client';
import { diseaseCatalog, imageClassifications, manualClassifications } from '../db/schema';
import { diseaseCatalog, manualClassifications, diagnoses } from '../db/schema';
import { serviceUnavailable } from '../lib/http-errors';
import { desc, eq } from 'drizzle-orm';
import { toDisease } from '../lib/disease-mappers';
import { getCurrentUser } from '../lib/auth';
import { loadDiagnosisRecord } from './diagnoses';
export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }).get('/dashboard/summary', async () => {
try {
const db = createDbClient();
export const dashboardRoutes = new Elysia({ prefix: '/api/v1' })
.get('/dashboard/summary', async ({ request }) => {
try {
const db = createDbClient();
const user = await getCurrentUser(request.headers.get('cookie'));
const diseaseCount = await db.select().from(diseaseCatalog);
const manualClassificationCount = await db.select().from(manualClassifications);
const imageClassificationCount = await db.select().from(imageClassifications);
const diseases = await db.select().from(diseaseCatalog).orderBy(diseaseCatalog.displayOrder);
const manualRows = await db.select().from(manualClassifications).orderBy(desc(manualClassifications.createdAt)).limit(1);
const latestClassificationRow = await db
.select({
id: manualClassifications.id,
diseaseSlug: manualClassifications.diseaseSlug,
observation: manualClassifications.observation,
location: manualClassifications.location,
createdAt: manualClassifications.createdAt,
disease: {
slug: diseaseCatalog.slug,
label: diseaseCatalog.label,
commonName: diseaseCatalog.commonName,
summary: diseaseCatalog.summary,
description: diseaseCatalog.description,
symptoms: diseaseCatalog.symptoms,
recommendations: diseaseCatalog.recommendations,
riskLevel: diseaseCatalog.riskLevel,
accentColor: diseaseCatalog.accentColor,
displayOrder: diseaseCatalog.displayOrder,
const diagnosisFilters = user ? eq(diagnoses.userId, user.id) : undefined;
const diagnosisRows = diagnosisFilters
? await db.select({ id: diagnoses.id }).from(diagnoses).where(diagnosisFilters).orderBy(desc(diagnoses.createdAt)).limit(1)
: [];
const diagnosisCountRows = user
? await db.select({ value: count() }).from(diagnoses).where(eq(diagnoses.userId, user.id))
: [{ value: 0 }];
const needsReviewRows = user?.role === 'expert'
? await db.select({ value: count() }).from(diagnoses).where(eq(diagnoses.status, 'needs_review'))
: [{ value: 0 }];
const latestDiagnosis = diagnosisRows[0]
? await loadDiagnosisRecord(diagnosisRows[0].id, user?.id ?? null, user?.role === 'expert')
: null;
const riskDistribution = diseases.reduce(
(acc, disease) => {
if (disease.riskLevel === 'high') acc.high += 1;
if (disease.riskLevel === 'medium') acc.medium += 1;
if (disease.riskLevel === 'low') acc.low += 1;
return acc;
},
})
.from(manualClassifications)
.innerJoin(diseaseCatalog, eq(manualClassifications.diseaseSlug, diseaseCatalog.slug))
.orderBy(desc(manualClassifications.createdAt))
.limit(1);
{ low: 0, medium: 0, high: 0 },
);
const latestClassification = latestClassificationRow[0]
? {
id: latestClassificationRow[0].id,
diseaseSlug: latestClassificationRow[0].diseaseSlug as any,
observation: latestClassificationRow[0].observation,
location: latestClassificationRow[0].location,
createdAt: latestClassificationRow[0].createdAt.toISOString(),
disease: {
slug: latestClassificationRow[0].disease.slug as any,
label: latestClassificationRow[0].disease.label as any,
commonName: latestClassificationRow[0].disease.commonName,
summary: latestClassificationRow[0].disease.summary,
description: latestClassificationRow[0].disease.description,
symptoms: latestClassificationRow[0].disease.symptoms,
recommendations: latestClassificationRow[0].disease.recommendations,
riskLevel: latestClassificationRow[0].disease.riskLevel as any,
accentColor: latestClassificationRow[0].disease.accentColor,
displayOrder: latestClassificationRow[0].disease.displayOrder,
},
}
: null;
const latestManual = manualRows[0];
const latestDisease = latestManual && isDiseaseSlug(latestManual.diseaseSlug)
? diseases.find((disease) => disease.slug === latestManual.diseaseSlug)
: null;
const riskDistributionRows = await db.select().from(diseaseCatalog);
const riskDistribution: Record<RiskLevel, number> = {
low: 0,
medium: 0,
high: 0,
};
const summary: DashboardSummary = {
diseaseCount: diseases.length,
classificationCount: manualRows.length,
imageClassificationCount: diagnosisCountRows[0]?.value ?? 0,
needsReviewCount: needsReviewRows[0]?.value ?? 0,
riskDistribution,
latestClassification: latestManual && isDiseaseSlug(latestManual.diseaseSlug) && latestDisease ? {
id: latestManual.id,
diseaseSlug: latestManual.diseaseSlug,
observation: latestManual.observation,
location: latestManual.location,
createdAt: latestManual.createdAt.toISOString(),
disease: toDisease(latestDisease),
} : null,
latestDiagnosis,
};
for (const row of riskDistributionRows) {
const risk = row.riskLevel as RiskLevel;
if (risk in riskDistribution) {
riskDistribution[risk]++;
}
return summary;
} catch (error) {
return serviceUnavailable('Database unavailable');
}
const summary: DashboardSummary = {
diseaseCount: diseaseCount.length,
classificationCount: manualClassificationCount.length + imageClassificationCount.length,
latestClassification,
riskDistribution,
};
return summary;
} catch (error) {
return serviceUnavailable('Database unavailable');
}
});
});
+278
View File
@@ -0,0 +1,278 @@
import { Elysia } from 'elysia';
import type { DiagnosisPrediction, DiagnosisRecord, ExpertReviewRecord, DiseaseCatalogItem } from '@zeavis/shared';
import { DiagnosisStatus, DiseaseSlug, isDiseaseSlug } from '@zeavis/shared';
import { and, desc, eq } from 'drizzle-orm';
import { createDbClient } from '../db/client';
import { diagnoses, diagnosisPredictions, diseaseCatalog, expertReviews, users } from '../db/schema';
import { badGateway, badRequest, notFound, serviceUnavailable, unauthorized } from '../lib/http-errors';
import { getCurrentUser } from '../lib/auth';
import { classifyImage } from '../lib/image-model';
import { uploadImageToStorage } from '../lib/uploader-client';
import { env } from '../config/env';
interface ReviewRow {
reviewId: string | null;
reviewDiagnosisId: string | null;
reviewExpertId: string | null;
reviewVerdict: string | null;
reviewCorrectedDiseaseSlug: string | null;
reviewNotes: string | null;
reviewCreatedAt: Date | null;
expertId: string | null;
expertEmail: string | null;
expertName: string | null;
}
function toReview(row: ReviewRow): ExpertReviewRecord | null {
if (!row.reviewId) return null;
const verdict = row.reviewVerdict as 'verified' | 'corrected';
const correctedDiseaseSlug = row.reviewCorrectedDiseaseSlug && isDiseaseSlug(row.reviewCorrectedDiseaseSlug) ? row.reviewCorrectedDiseaseSlug : null;
const notes = row.reviewNotes || '';
return {
id: row.reviewId,
diagnosisId: row.reviewDiagnosisId!,
expertId: row.reviewExpertId!,
verdict,
correctedDiseaseSlug,
notes,
createdAt: row.reviewCreatedAt!.toISOString(),
expert: {
id: row.expertId!,
email: row.expertEmail!,
name: row.expertName!,
role: 'expert',
},
};
}
function isDiagnosisRecord(record: DiagnosisRecord | null): record is DiagnosisRecord {
return record !== null;
}
async function loadDiagnosisRecord(id: string, userId: string | null, expertAccess: boolean): Promise<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 && isDiseaseSlug(prediction.diseaseSlug) ? prediction.diseaseSlug : null,
modelLabel: prediction.modelLabel,
confidence: prediction.confidence,
rank: prediction.rank,
}));
const predictedDiseaseSlug = row.predictedDiseaseSlug && isDiseaseSlug(row.predictedDiseaseSlug) ? row.predictedDiseaseSlug : null;
const status = row.status as DiagnosisStatus;
const disease = row.disease && row.disease.slug && isDiseaseSlug(row.disease.slug)
? ({
slug: row.disease.slug,
label: row.disease.label,
commonName: row.disease.commonName,
summary: row.disease.summary,
description: row.disease.description,
symptoms: row.disease.symptoms,
recommendations: row.disease.recommendations,
riskLevel: row.disease.riskLevel,
accentColor: row.disease.accentColor,
displayOrder: row.disease.displayOrder,
} as DiseaseCatalogItem)
: null;
return {
id: row.id,
userId: row.userId,
predictedDiseaseSlug,
confidence: row.confidence,
status,
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,
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);
if (!record) return serviceUnavailable('Database unavailable');
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);
if (!record) return serviceUnavailable('Database unavailable');
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(isDiagnosisRecord);
} 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 };
+108
View File
@@ -0,0 +1,108 @@
import { Elysia } from 'elysia';
import type { ReviewDiagnosisRequest } from '@zeavis/shared';
import { isDiseaseSlug } from '@zeavis/shared';
import { and, desc, eq } from 'drizzle-orm';
import { createDbClient } from '../db/client';
import { diagnoses, expertReviews } from '../db/schema';
import { badRequest, forbidden, notFound, serviceUnavailable, unauthorized } from '../lib/http-errors';
import { getCurrentUser } from '../lib/auth';
import { loadDiagnosisRecord } from './diagnoses';
type DiagnosisRecord = Awaited<ReturnType<typeof loadDiagnosisRecord>>;
function isDiagnosisRecordOrNull(record: unknown): record is DiagnosisRecord {
return record !== null;
}
export const expertRoutes = new Elysia({ prefix: '/api/v1/expert' })
.get('/reviews', async ({ request }) => {
const user = await getCurrentUser(request.headers.get('cookie'));
if (!user) return unauthorized('Authentication required');
if (user.role !== 'expert') return forbidden('Expert role required');
try {
const db = createDbClient();
const rows = await db
.select({ id: diagnoses.id })
.from(diagnoses)
.where(eq(diagnoses.status, 'needs_review'))
.orderBy(desc(diagnoses.createdAt))
.limit(50);
const records = await Promise.all(rows.map((row) => loadDiagnosisRecord(row.id, null, true)));
return records.filter(isDiagnosisRecordOrNull);
} catch (error) {
return serviceUnavailable('Database unavailable');
}
})
.post('/reviews/:diagnosisId', async ({ params, body, request }) => {
const user = await getCurrentUser(request.headers.get('cookie'));
if (!user) return unauthorized('Authentication required');
if (user.role !== 'expert') return forbidden('Expert role required');
const req = body as Partial<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') {
if (!req.correctedDiseaseSlug || !isDiseaseSlug(req.correctedDiseaseSlug)) {
return badRequest('Corrected disease slug is required for corrected reviews');
}
}
try {
const db = createDbClient();
const existing = await db
.select()
.from(diagnoses)
.where(eq(diagnoses.id, params.diagnosisId))
.limit(1);
const diagnosis = existing[0];
if (!diagnosis) return notFound('Diagnosis not found');
const verdict = req.verdict as 'verified' | 'corrected';
const correctedSlug = verdict === 'corrected' ? req.correctedDiseaseSlug : null;
// Wrap update and insert in a transaction for atomicity
const result = await db.transaction(async (tx) => {
// Perform conditional update first to determine if this review should proceed
const updated = await tx
.update(diagnoses)
.set({
status: verdict === 'corrected' ? 'expert_corrected' : 'expert_verified',
predictedDiseaseSlug: verdict === 'corrected' ? correctedSlug : diagnosis.predictedDiseaseSlug,
updatedAt: new Date(),
})
.where(and(eq(diagnoses.id, diagnosis.id), eq(diagnoses.status, 'needs_review')))
.returning();
if (updated.length === 0) {
throw new Error('DIAGNOSIS_NOT_PENDING_REVIEW');
}
// Only insert expert review after successful update
const reviewValues = {
diagnosisId: diagnosis.id,
expertId: user.id,
verdict,
correctedDiseaseSlug: correctedSlug,
notes,
};
await tx.insert(expertReviews).values(reviewValues);
return updated[0];
});
return await loadDiagnosisRecord(diagnosis.id, null, true);
} catch (error) {
if (error instanceof Error && error.message === 'DIAGNOSIS_NOT_PENDING_REVIEW') {
return badRequest('Diagnosis is not pending review');
}
return serviceUnavailable('Database unavailable');
}
});
+27 -9
View File
@@ -1,29 +1,47 @@
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 { DiagnosisDetailPage } from '@/pages/diagnosis-detail-page';
import { ExpertReviewsPage } from '@/pages/expert-reviews-page';
import { LoginPage } from '@/pages/login-page';
import { RegisterPage } from '@/pages/register-page';
const queryClient = new QueryClient();
const router = createBrowserRouter([
{
path: '/',
element: <LandingPage />,
},
{ path: '/', element: <LandingPage /> },
{ path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> },
{
path: '/dashboard',
element: <DashboardPage />,
element: (
<AuthGuard>
<DashboardPage />
</AuthGuard>
),
},
{
path: '/catalog',
element: <CatalogPage />,
path: '/diagnoses/:id',
element: (
<AuthGuard>
<DiagnosisDetailPage />
</AuthGuard>
),
},
{
path: '/catalog/:slug',
element: <DiseaseDetailPage />,
path: '/expert/reviews',
element: (
<AuthGuard requireExpert>
<ExpertReviewsPage />
</AuthGuard>
),
},
{ path: '/catalog', element: <CatalogPage /> },
{ path: '/catalog/:slug', element: <DiseaseDetailPage /> },
]);
export function App() {
+74
View File
@@ -0,0 +1,74 @@
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<unknown>;
onFieldChange?: () => void;
};
export function AuthForm({ mode, isSubmitting, error, googleOAuthEnabled, onSubmit, onFieldChange }: 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);
onFieldChange?.();
}} 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);
onFieldChange?.();
}} 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);
onFieldChange?.();
}} required />
</div>
{error && <p className="text-sm text-red-600" role="alert">{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>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { ReactNode, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Navigate } from 'react-router-dom';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/store/auth-store';
type AuthGuardProps = {
children: ReactNode;
requireExpert?: boolean;
};
export function AuthGuard({ children, requireExpert = false }: AuthGuardProps) {
const setUser = useAuthStore((state) => state.setUser);
const query = useQuery({
queryKey: ['auth', 'me'],
queryFn: () => apiClient.getMe(),
});
useEffect(() => {
if (query.data) {
setUser(query.data.user);
}
}, [query.data, setUser]);
if (query.isLoading) {
return <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}</>;
}
@@ -0,0 +1,28 @@
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>
);
}
@@ -0,0 +1,22 @@
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>;
}
@@ -1,14 +1,15 @@
import { useRef, useState } from 'react';
import type { ImageClassificationRecord } from '@zeavis/shared';
import { ChangeEvent, FormEvent, useRef, useState, useEffect } 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 { RiskBadge } from '@/components/risk-badge';
import { DiagnosisStatusBadge } from '@/components/diagnosis-status-badge';
export interface ImageClassificationFormProps {
type ImageClassificationFormProps = {
onSubmit: (file: File) => Promise<void>;
isSubmitting: boolean;
latestResult: ImageClassificationRecord | null;
}
latestResult: DiagnosisRecord | null;
};
export function ImageClassificationForm({
onSubmit,
@@ -16,140 +17,122 @@ export function ImageClassificationForm({
latestResult,
}: ImageClassificationFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const previewUrlRef = useRef<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (!file.type.startsWith('image/')) {
setError('Silakan pilih file gambar');
setSelectedFile(null);
return;
// Revoke object URL on component unmount
useEffect(() => {
return () => {
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setError(null);
setSelectedFile(file);
}
};
};
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
const selectedFile = event.target.files?.[0] ?? null;
setError(null);
setFile(selectedFile);
if (!selectedFile) {
setError('Silakan pilih file gambar');
// Revoke previous preview URL before replacing it
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
const newUrl = selectedFile ? URL.createObjectURL(selectedFile) : null;
previewUrlRef.current = newUrl;
setPreviewUrl(newUrl);
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!file) {
setError('Pilih gambar terlebih dahulu');
return;
}
try {
await onSubmit(selectedFile);
setSelectedFile(null);
await onSubmit(file);
// Revoke current preview URL after successful submit before setting null
if (previewUrlRef.current) {
URL.revokeObjectURL(previewUrlRef.current);
previewUrlRef.current = null;
}
setFile(null);
setPreviewUrl(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Terjadi kesalahan saat mengunggah gambar');
setError(err instanceof Error ? err.message : 'Gagal mengirim gambar');
}
};
const isFormValid = selectedFile !== null;
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Klasifikasi Gambar</CardTitle>
<CardDescription>Unggah foto daun jagung untuk klasifikasi otomatis</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-800">{error}</div>
)}
<div>
<label htmlFor="image-file" className="block text-sm font-medium">
Pilih Gambar
</label>
<input
ref={fileInputRef}
id="image-file"
type="file"
accept="image/*"
onChange={handleFileChange}
disabled={isSubmitting}
className="mt-1 block w-full text-sm file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90 disabled:opacity-50"
/>
{selectedFile && (
<p className="mt-2 text-sm text-muted-foreground">
File dipilih: {selectedFile.name}
</p>
)}
</div>
<Button
type="submit"
disabled={!isFormValid || isSubmitting}
className="w-full"
>
{isSubmitting ? 'Mengunggah...' : 'Unggah dan Klasifikasi'}
</Button>
</form>
</CardContent>
</Card>
{latestResult && (
<Card>
<CardHeader>
<CardTitle>Hasil Klasifikasi Terbaru</CardTitle>
<CardDescription>Prediksi penyakit dari gambar terakhir</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-border overflow-hidden">
<img
src={latestResult.imageUrl}
alt="Uploaded corn leaf"
className="w-full h-48 object-cover"
/>
</div>
<div className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<h4 className="font-semibold">{latestResult.disease.commonName}</h4>
<p className="text-sm text-muted-foreground">{latestResult.disease.label}</p>
</div>
<RiskBadge level={latestResult.disease.riskLevel} />
</div>
<div className="rounded-md bg-muted p-3">
<p className="text-sm font-medium">
Kepercayaan: {(latestResult.confidence * 100).toFixed(1)}%
</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Rekomendasi awal:</p>
<ul className="space-y-1 text-sm text-muted-foreground">
{latestResult.disease.recommendations.slice(0, 3).map((recommendation) => (
<li key={recommendation}> {recommendation}</li>
))}
</ul>
</div>
<p className="text-xs text-muted-foreground">
{new Date(latestResult.createdAt).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
<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">
<div>
<label htmlFor="image-file" className="block text-sm font-medium">
Pilih Gambar
</label>
<input
ref={fileInputRef}
id="image-file"
type="file"
accept="image/jpeg,image/png"
onChange={handleFileChange}
disabled={isSubmitting}
className="mt-1 block w-full text-sm file:mr-4 file:rounded-md file:border-0 file:bg-primary file:px-4 file:py-2 file:text-sm file:font-semibold file:text-primary-foreground hover:file:bg-primary/90 disabled:opacity-50"
/>
{file && (
<p className="mt-2 text-sm text-muted-foreground">
File dipilih: {file.name}
</p>
)}
</div>
{previewUrl && (
<img src={previewUrl} alt="Pratinjau gambar daun jagung untuk diagnosis" className="h-48 rounded-lg object-cover" />
)}
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" disabled={isSubmitting} className="w-full">
{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>
</CardContent>
</Card>
)}
</div>
<p className="text-sm text-muted-foreground">
{latestResult.confidence === null
? 'Tidak ada confidence'
: `Confidence ${(latestResult.confidence * 100).toFixed(1)}%`}
</p>
</div>
)}
</CardContent>
</Card>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { InputHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
'flex h-10 w-full rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { LabelHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
export function Label({ className, ...props }: LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label
className={cn('text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
{...props}
/>
);
}
+104 -7
View File
@@ -1,9 +1,15 @@
import type {
DiseaseSlug,
AuthMeResponse,
AuthRequest,
AuthResponse,
DiagnosisRecord,
DiseaseCatalogItem,
DiseaseSlug,
ImageClassificationRecord,
ManualClassificationRequest,
ManualClassificationRecord,
ImageClassificationRecord,
RegisterRequest,
ReviewDiagnosisRequest,
DashboardSummary,
} from '@zeavis/shared';
@@ -11,7 +17,11 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? '';
async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
const url = `${apiBaseUrl}${endpoint}`;
const response = await fetch(url, options);
const response = await fetch(url, {
credentials: 'include',
...options,
headers: options?.headers,
});
if (!response.ok) {
let errorMessage = `HTTP ${response.status}`;
@@ -28,7 +38,63 @@ async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T>
return response.json();
}
function toImageClassificationRecord(diagnosis: DiagnosisRecord): ImageClassificationRecord {
if (!diagnosis.disease) {
throw new Error('Diagnosis does not include disease details');
}
return {
id: diagnosis.id,
predictedDiseaseSlug: diagnosis.predictedDiseaseSlug || 'daun-sehat',
confidence: diagnosis.confidence || 0,
probabilities: diagnosis.predictions.map((p) => ({
diseaseSlug: p.diseaseSlug || 'daun-sehat',
label: p.modelLabel,
confidence: p.confidence,
})),
imageUrl: diagnosis.imageUrl,
originalFileName: diagnosis.imageFileName,
uploaderPublicId: diagnosis.uploaderPublicId,
uploader: {
public_id: diagnosis.uploaderPublicId,
file_name: diagnosis.imageFileName,
mime_type: diagnosis.imageMimeType,
size_bytes: diagnosis.imageSizeBytes,
file_type: diagnosis.imageMimeType.split('/')[1] || 'unknown',
download_url: diagnosis.imageUrl,
},
createdAt: diagnosis.createdAt,
disease: diagnosis.disease,
};
}
export const apiClient = {
// Auth methods
async getMe(): Promise<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' });
},
// Disease catalog methods
async getDiseases(): Promise<DiseaseCatalogItem[]> {
return fetchApi('/api/v1/diseases');
},
@@ -37,6 +103,7 @@ export const apiClient = {
return fetchApi(`/api/v1/diseases/${slug}`);
},
// Manual classification methods
async getManualClassifications(): Promise<ManualClassificationRecord[]> {
return fetchApi('/api/v1/classifications/manual');
},
@@ -53,21 +120,51 @@ export const apiClient = {
});
},
async getImageClassifications(): Promise<ImageClassificationRecord[]> {
return fetchApi('/api/v1/classifications/image');
// Diagnosis methods
async getDiagnoses(): Promise<DiagnosisRecord[]> {
return fetchApi('/api/v1/diagnoses');
},
async createImageClassification(file: File): Promise<ImageClassificationRecord> {
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/classifications/image', {
return fetchApi('/api/v1/diagnoses', {
method: 'POST',
body: formData,
});
},
// Expert review methods
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),
});
},
// Dashboard methods
async getDashboardSummary(): Promise<DashboardSummary> {
return fetchApi('/api/v1/dashboard/summary');
},
// Backwards-compatible aliases for old image classification methods
async getImageClassifications(): Promise<ImageClassificationRecord[]> {
const diagnoses = await this.getDiagnoses();
return diagnoses.map(toImageClassificationRecord);
},
async createImageClassification(file: File): Promise<ImageClassificationRecord> {
const diagnosis = await this.createDiagnosis(file);
return toImageClassificationRecord(diagnosis);
},
};
+70 -103
View File
@@ -1,19 +1,23 @@
import { useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { BookOpen, History, Leaf, LayoutDashboard, TrendingUp, Image } from 'lucide-react';
import { Link, useNavigate } from 'react-router-dom';
import { BookOpen, History, LayoutDashboard, TrendingUp, LogOut, CheckCircle2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ManualClassificationForm } from '@/components/manual-classification-form';
import { ImageClassificationForm } from '@/components/image-classification-form';
import { RiskBadge } from '@/components/risk-badge';
import { DiagnosisCard } from '@/components/diagnosis-card';
import { useAuthStore } from '@/store/auth-store';
import { useUiStore } from '@/store/ui-store';
import { apiClient } from '@/lib/api-client';
export function DashboardPage() {
const { user } = useAuthStore();
const setUser = useAuthStore((state) => state.setUser);
const { dashboardCompact, toggleDashboardCompact } = useUiStore();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [diseasesQuery, summaryQuery, classificationsQuery, imageClassificationsQuery] = useQueries({
const [diseasesQuery, summaryQuery, diagnosesQuery, classificationsQuery] = useQueries({
queries: [
{
queryKey: ['diseases'],
@@ -23,17 +27,27 @@ export function DashboardPage() {
queryKey: ['dashboard-summary'],
queryFn: () => apiClient.getDashboardSummary(),
},
{
queryKey: ['diagnoses'],
queryFn: () => apiClient.getDiagnoses(),
},
{
queryKey: ['manual-classifications'],
queryFn: () => apiClient.getManualClassifications(),
},
{
queryKey: ['image-classifications'],
queryFn: () => apiClient.getImageClassifications(),
},
],
});
const createDiagnosisMutation = useMutation({
mutationFn: async (file: File) => {
return await apiClient.createDiagnosis(file);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['diagnoses'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
},
});
const createClassificationMutation = useMutation({
mutationFn: async (payload: Parameters<typeof apiClient.createManualClassification>[0]) => {
await apiClient.createManualClassification(payload);
@@ -44,20 +58,21 @@ export function DashboardPage() {
},
});
const createImageClassificationMutation = useMutation({
mutationFn: async (file: File) => {
return await apiClient.createImageClassification(file);
const logoutMutation = useMutation({
mutationFn: async () => {
await apiClient.logout();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['image-classifications'] });
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
setUser(null);
queryClient.clear();
navigate('/login');
},
});
const diseases = diseasesQuery.data || [];
const summary = summaryQuery.data;
const diagnoses = diagnosesQuery.data || [];
const classifications = classificationsQuery.data || [];
const imageClassifications = imageClassificationsQuery.data || [];
const isLoadingData = diseasesQuery.isLoading || summaryQuery.isLoading;
const hasError = diseasesQuery.error || summaryQuery.error;
@@ -72,15 +87,28 @@ export function DashboardPage() {
</div>
<h1 className="text-3xl font-bold tracking-tight">ZeaVis Edu Workspace</h1>
<p className="text-muted-foreground">
Pantau penyakit daun jagung dan laporkan pengamatan Anda
{user?.name ? `Selamat datang, ${user.name}` : 'Pantau penyakit daun jagung dan laporkan pengamatan Anda'}
</p>
</div>
<div className="flex gap-3">
{user?.role === 'expert' && (
<Button asChild variant="outline">
<Link to="/expert/reviews">
<CheckCircle2 className="h-4 w-4 mr-2" />
Review Pakar
</Link>
</Button>
)}
<Button variant="outline" onClick={toggleDashboardCompact}>
{dashboardCompact ? 'Mode Nyaman' : 'Mode Ringkas'}
</Button>
<Button asChild>
<Link to="/">Kembali</Link>
<Button
variant="outline"
onClick={() => logoutMutation.mutate()}
disabled={logoutMutation.isPending}
>
<LogOut className="h-4 w-4 mr-2" />
{logoutMutation.isPending ? 'Keluar...' : 'Keluar'}
</Button>
</div>
</header>
@@ -115,11 +143,24 @@ export function DashboardPage() {
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Laporan
Total Diagnosis
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{summary.classificationCount}</div>
<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">
Menunggu Review
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-amber-600">
{summary.needsReviewCount}
</div>
</CardContent>
</Card>
@@ -135,19 +176,6 @@ export function DashboardPage() {
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-muted-foreground">
Risiko Sedang
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-yellow-600">
{summary.riskDistribution.medium}
</div>
</CardContent>
</Card>
</section>
)}
@@ -200,48 +228,12 @@ export function DashboardPage() {
</Card>
</section>
{summary?.latestClassification && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<History className="h-5 w-5" />
Laporan Terbaru
</CardTitle>
<CardDescription>
Pengamatan penyakit terakhir yang dilaporkan
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border border-border p-4">
<div className="flex items-start justify-between gap-4 mb-3">
<div>
<h4 className="font-semibold">{summary.latestClassification.disease.commonName}</h4>
<p className="text-sm text-muted-foreground">
{summary.latestClassification.disease.label}
</p>
</div>
<RiskBadge level={summary.latestClassification.disease.riskLevel} />
</div>
<p className="text-sm text-muted-foreground mb-2">
{summary.latestClassification.observation}
</p>
<p className="text-xs text-muted-foreground">
Lokasi: {summary.latestClassification.location}
</p>
<p className="text-xs text-muted-foreground mt-2">
{new Date(summary.latestClassification.createdAt).toLocaleDateString('id-ID')}
</p>
</div>
</CardContent>
</Card>
)}
<ImageClassificationForm
onSubmit={async (file) => {
await createImageClassificationMutation.mutateAsync(file);
await createDiagnosisMutation.mutateAsync(file);
}}
isSubmitting={createImageClassificationMutation.isPending}
latestResult={imageClassifications[0] ?? null}
isSubmitting={createDiagnosisMutation.isPending}
latestResult={diagnoses[0] ?? null}
/>
<ManualClassificationForm
@@ -252,46 +244,21 @@ export function DashboardPage() {
isSubmitting={createClassificationMutation.isPending}
/>
{imageClassifications.length > 0 && (
{diagnoses.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Image className="h-5 w-5" />
Riwayat Klasifikasi Gambar
<History className="h-5 w-5" />
Riwayat Diagnosis
</CardTitle>
<CardDescription>
{imageClassifications.length} gambar yang diklasifikasi
{diagnoses.length} diagnosis yang telah dibuat
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{imageClassifications.slice(0, 6).map((classification) => (
<div key={classification.id} className="rounded-lg border border-border overflow-hidden">
<img
src={classification.imageUrl}
alt="Classified corn leaf"
className="w-full h-32 object-cover"
/>
<div className="p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<h4 className="text-sm font-semibold">
{classification.disease.commonName}
</h4>
<p className="text-xs text-muted-foreground">
{classification.disease.label}
</p>
</div>
<RiskBadge level={classification.disease.riskLevel} className="text-xs" />
</div>
<div className="text-xs text-muted-foreground">
Kepercayaan: {(classification.confidence * 100).toFixed(1)}%
</div>
<p className="text-xs text-muted-foreground">
{new Date(classification.createdAt).toLocaleDateString('id-ID')}
</p>
</div>
</div>
<div className="space-y-3">
{diagnoses.slice(0, 6).map((diagnosis) => (
<DiagnosisCard key={diagnosis.id} diagnosis={diagnosis} />
))}
</div>
</CardContent>
@@ -0,0 +1,104 @@
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>
);
}
+277
View File
@@ -0,0 +1,277 @@
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 { 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 bg-background">
<header className="border-b bg-card">
<div className="mx-auto flex max-w-7xl items-center justify-between p-6">
<h1 className="text-2xl font-bold">Expert Reviews</h1>
<Link to="/dashboard">
<Button variant="outline">Dashboard</Button>
</Link>
</div>
</header>
<div className="mx-auto max-w-7xl p-6">
<div className="grid gap-6 lg:grid-cols-3">
{/* Left: List of reviews */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold">Diagnoses to Review</h2>
<span className="rounded-full bg-muted px-2.5 py-0.5 text-xs font-medium">{reviews.length}</span>
</div>
<div className="space-y-2">
{reviewsQuery.isPending || diseasesQuery.isPending ? (
<p className="text-sm text-muted-foreground">Loading reviews and diseases...</p>
) : reviews.length === 0 ? (
<p className="text-sm text-muted-foreground">No diagnoses pending review</p>
) : (
reviews.map((review) => (
<button
key={review.id}
type="button"
onClick={() => setSearchParams({ diagnosis: review.id })}
className={`w-full text-left transition ${
selected?.id === review.id
? 'ring-2 ring-primary'
: 'hover:border-primary'
}`}
>
<Card className="transition hover:border-primary">
<CardContent className="flex gap-3 p-3">
<img
src={review.imageUrl}
alt="Daun jagung"
className="h-20 w-20 rounded-lg object-cover"
/>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center justify-between gap-1">
<h3 className="text-sm font-semibold">{review.disease?.commonName ?? 'Diagnosis gagal'}</h3>
<DiagnosisStatusBadge status={review.status} className="text-xs" />
</div>
<p className="text-xs text-muted-foreground">
{review.confidence === null ? 'Tidak ada confidence' : `${(review.confidence * 100).toFixed(1)}%`}
</p>
<p className="text-xs text-muted-foreground">{new Date(review.createdAt).toLocaleString('id-ID')}</p>
</div>
</CardContent>
</Card>
</button>
))
)}
</div>
</div>
{/* Right: Detail and form */}
<div className="lg:col-span-2 space-y-6">
{reviewsQuery.isPending || diseasesQuery.isPending ? (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
Loading reviews and diseases...
</CardContent>
</Card>
) : selected ? (
<>
{/* Detail card */}
<Card>
<CardHeader>
<CardTitle>{selected.disease?.commonName ?? 'Diagnosis gagal'}</CardTitle>
<CardDescription>
{new Date(selected.createdAt).toLocaleString('id-ID')}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<img
src={selected.imageUrl}
alt={`Gambar daun jagung untuk ${selected.disease?.commonName ?? 'diagnosis'}`}
className="h-64 w-full rounded-lg object-cover"
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Status</span>
<DiagnosisStatusBadge status={selected.status} />
</div>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Confidence</span>
<span className="text-sm">
{selected.confidence === null ? 'N/A' : `${(selected.confidence * 100).toFixed(1)}%`}
</span>
</div>
</div>
{/* Top predictions */}
{selected.predictions.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium">Top Predictions</h4>
<div className="space-y-1">
{selected.predictions.map((pred) => (
<div key={pred.id} className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{pred.modelLabel}</span>
<span className="font-medium">{(pred.confidence * 100).toFixed(1)}%</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Review form */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Expert Review</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{/* Verdict selection */}
<div className="space-y-2">
<label className="text-sm font-medium">Verdict</label>
<div className="flex gap-3">
<label htmlFor="verdict-verified" className="flex items-center gap-2">
<input
id="verdict-verified"
type="radio"
name="verdict"
value="verified"
checked={verdict === 'verified'}
onChange={(e) => {
setVerdict(e.target.value as 'verified' | 'corrected');
setCorrectedDiseaseSlug('');
}}
className="h-4 w-4"
/>
<span className="text-sm">Verified</span>
</label>
<label htmlFor="verdict-corrected" className="flex items-center gap-2">
<input
id="verdict-corrected"
type="radio"
name="verdict"
value="corrected"
checked={verdict === 'corrected'}
onChange={(e) => setVerdict(e.target.value as 'verified' | 'corrected')}
className="h-4 w-4"
/>
<span className="text-sm">Corrected</span>
</label>
</div>
</div>
{/* Disease selection (only if corrected) */}
{verdict === 'corrected' && (
<div className="space-y-2">
<label htmlFor="disease" className="text-sm font-medium">
Correct Disease
</label>
<select
id="disease"
value={correctedDiseaseSlug}
onChange={(e) => setCorrectedDiseaseSlug(e.target.value as DiseaseSlug | '')}
required={verdict === 'corrected'}
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
>
<option value="">Select a disease...</option>
{diseases.map((disease) => (
<option key={disease.slug} value={disease.slug}>
{disease.commonName}
</option>
))}
</select>
</div>
)}
{/* Notes */}
<div className="space-y-2">
<label htmlFor="notes" className="text-sm font-medium">
Notes <span className="text-red-500">*</span>
</label>
<textarea
id="notes"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Enter your review notes..."
required
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
rows={4}
/>
</div>
{/* Submit button */}
<Button
type="submit"
disabled={mutation.isPending || (verdict === 'corrected' && !correctedDiseaseSlug) || !notes.trim()}
className="w-full"
>
{mutation.isPending ? 'Submitting...' : 'Submit Review'}
</Button>
{mutation.isError && (
<p className="text-sm text-red-500">
Error: {mutation.error instanceof Error ? mutation.error.message : 'Unknown error'}
</p>
)}
</form>
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent className="p-6 text-center text-muted-foreground">
No diagnoses available for review
</CardContent>
</Card>
)}
</div>
</div>
</div>
</main>
);
}
+45
View File
@@ -0,0 +1,45 @@
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 }) => {
setError(null);
return mutation.mutateAsync({ email, password });
}}
onFieldChange={() => setError(null)}
/>
<p className="text-center text-sm text-muted-foreground">
Belum punya akun? <Link className="text-primary" to="/register">Daftar</Link>
</p>
</div>
</main>
);
}
+45
View File
@@ -0,0 +1,45 @@
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 }) => {
setError(null);
return mutation.mutateAsync({ name: name ?? '', email, password });
}}
onFieldChange={() => setError(null)}
/>
<p className="text-center text-sm text-muted-foreground">
Sudah punya akun? <Link className="text-primary" to="/login">Masuk</Link>
</p>
</div>
</main>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { create } from 'zustand';
import type { AuthUser } from '@zeavis/shared';
type AuthState = {
user: AuthUser | null;
setUser: (user: AuthUser | null) => void;
};
export const useAuthStore = create<AuthState>((set) => ({
user: null,
setUser: (user) => set({ user }),
}));
+10 -1
View File
@@ -11,8 +11,10 @@
"name": "@zeavis/api",
"version": "0.1.0",
"dependencies": {
"@elysiajs/cors": "1.4.2",
"@tensorflow/tfjs": "4.22.0",
"@zeavis/shared": "packages/shared",
"bcryptjs": "^2.4.3",
"drizzle-orm": "^0.36.4",
"elysia": "^1.1.25",
"jpeg-js": "0.4.4",
@@ -20,6 +22,7 @@
"postgres": "^3.4.5",
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/bun": "latest",
"drizzle-kit": "^0.27.1",
"typescript": "^5.6.3",
@@ -105,6 +108,8 @@
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@elysiajs/cors": ["@elysiajs/cors@1.4.2", "", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-FTCcbH35brTLigF1W7BYySRZomgI/dBEMK9BgK9RP9Nez7zmpGh4koL/Yr1BFv8nYz7CfhRvcM8d/c+XnwMaVQ=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
@@ -283,6 +288,8 @@
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
"@types/bcryptjs": ["@types/bcryptjs@2.4.6", "", {}, "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -307,7 +314,7 @@
"@webgpu/types": ["@webgpu/types@0.1.38", "", {}, "sha512-7LrhVKz2PRh+DD7+S+PVaFd5HxaWQvoMqBbsV9fNJO1pjUs1P8bM2vQVNfk+3URTqbuTI7gkXi0rfsN0IadoBA=="],
"@zeavis/api": ["@zeavis/api@workspace:apps/api", { "dependencies": { "@tensorflow/tfjs": "4.22.0", "@zeavis/shared": "packages/shared", "drizzle-orm": "^0.36.4", "elysia": "^1.1.25", "jpeg-js": "0.4.4", "pngjs": "7.0.0", "postgres": "^3.4.5" }, "devDependencies": { "@types/bun": "latest", "drizzle-kit": "^0.27.1", "typescript": "^5.6.3" } }],
"@zeavis/api": ["@zeavis/api@workspace:apps/api", { "dependencies": { "@elysiajs/cors": "1.4.2", "@tensorflow/tfjs": "4.22.0", "@zeavis/shared": "packages/shared", "bcryptjs": "^2.4.3", "drizzle-orm": "^0.36.4", "elysia": "^1.1.25", "jpeg-js": "0.4.4", "pngjs": "7.0.0", "postgres": "^3.4.5" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", "@types/bun": "latest", "drizzle-kit": "^0.27.1", "typescript": "^5.6.3" } }],
"@zeavis/shared": ["@zeavis/shared@workspace:packages/shared", { "devDependencies": { "typescript": "^5.6.3" } }],
@@ -331,6 +338,8 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q=="],
"bcryptjs": ["bcryptjs@2.4.3", "", {}, "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
+102 -19
View File
@@ -1,4 +1,4 @@
import type { DiseaseCatalogItem, DiseaseSlug, RiskLevel } from './diseases';
import type { DiseaseCatalogItem, DiseaseSlug } from './diseases';
export type ManualClassificationRequest = {
diseaseSlug: DiseaseSlug;
@@ -6,36 +6,126 @@ export type ManualClassificationRequest = {
location: string;
};
export type ManualClassificationRecord = {
export type ManualClassificationRecord = ManualClassificationRequest & {
id: string;
diseaseSlug: DiseaseSlug;
observation: string;
location: string;
createdAt: string;
disease: DiseaseCatalogItem;
};
export type PredictionProbability = {
diseaseSlug: DiseaseSlug;
label: DiseaseCatalogItem['label'];
label: string;
confidence: number;
};
export type UploaderMetadata = {
public_id: string;
telegram_file_id?: string;
telegram_file_unique_id?: string;
file_name: string;
mime_type: string;
size_bytes: number;
file_type: string;
uploader_id?: number;
created_at: string;
telegram_file_id?: string;
telegram_file_unique_id?: string;
storage_chat_id?: number;
storage_message_id?: number;
download_url: string;
};
export type DiagnosisStatus =
| 'ai_verified'
| 'needs_review'
| 'expert_verified'
| 'expert_corrected'
| 'failed';
export type UserRole = 'user' | 'expert';
export type AuthUser = {
id: string;
email: string;
name: string;
role: UserRole;
};
export type AuthFeatures = {
googleOAuthEnabled: boolean;
};
export type AuthMeResponse = {
user: AuthUser | null;
features: AuthFeatures;
};
export type AuthRequest = {
email: string;
password: string;
};
export type RegisterRequest = AuthRequest & {
name: string;
};
export type AuthResponse = {
user: AuthUser;
features: AuthFeatures;
};
export type DiagnosisPrediction = {
id: string;
diseaseSlug: DiseaseSlug | null;
modelLabel: string;
confidence: number;
rank: number;
};
export type ExpertReviewRecord = {
id: string;
diagnosisId: string;
expertId: string;
verdict: 'verified' | 'corrected';
correctedDiseaseSlug: DiseaseSlug | null;
notes: string;
createdAt: string;
expert: AuthUser;
};
export type DiagnosisRecord = {
id: string;
userId: string;
predictedDiseaseSlug: DiseaseSlug | null;
confidence: number | null;
status: DiagnosisStatus;
failureReason: string | null;
imageUrl: string;
uploaderPublicId: string;
imageFileName: string;
imageMimeType: string;
imageSizeBytes: number;
createdAt: string;
updatedAt: string;
disease: DiseaseCatalogItem | null;
predictions: DiagnosisPrediction[];
latestReview: ExpertReviewRecord | null;
};
export type ReviewDiagnosisRequest = {
verdict: 'verified' | 'corrected';
correctedDiseaseSlug?: DiseaseSlug;
notes: string;
};
export type DashboardSummary = {
diseaseCount: number;
classificationCount: number;
imageClassificationCount: number;
needsReviewCount: number;
riskDistribution: {
low: number;
medium: number;
high: number;
};
latestClassification: ManualClassificationRecord | null;
latestDiagnosis: DiagnosisRecord | null;
};
export type ImageClassificationRecord = {
id: string;
predictedDiseaseSlug: DiseaseSlug;
@@ -48,10 +138,3 @@ export type ImageClassificationRecord = {
createdAt: string;
disease: DiseaseCatalogItem;
};
export type DashboardSummary = {
diseaseCount: number;
classificationCount: number;
latestClassification: ManualClassificationRecord | null;
riskDistribution: Record<RiskLevel, number>;
};
+12
View File
@@ -23,4 +23,16 @@ export type {
PredictionProbability,
UploaderMetadata,
ImageClassificationRecord,
DiagnosisStatus,
UserRole,
AuthUser,
AuthFeatures,
AuthMeResponse,
AuthRequest,
RegisterRequest,
AuthResponse,
DiagnosisPrediction,
ExpertReviewRecord,
DiagnosisRecord,
ReviewDiagnosisRequest,
} from './classifications';