diff --git a/apps/api/drizzle/0002_lumpy_smasher.sql b/apps/api/drizzle/0002_lumpy_smasher.sql new file mode 100644 index 0000000..5f6b6a9 --- /dev/null +++ b/apps/api/drizzle/0002_lumpy_smasher.sql @@ -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"); \ No newline at end of file diff --git a/apps/api/drizzle/meta/0002_snapshot.json b/apps/api/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..3ca4325 --- /dev/null +++ b/apps/api/drizzle/meta/0002_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/api/drizzle/meta/_journal.json b/apps/api/drizzle/meta/_journal.json index a492b03..63d0d65 100644 --- a/apps/api/drizzle/meta/_journal.json +++ b/apps/api/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1779461286348, "tag": "0001_cute_lyja", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1779470332261, + "tag": "0002_lumpy_smasher", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index c91f8ff..ee15bb3 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index a54a31c..e9814a2 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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'); + } +} diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 2632e22..4695e2a 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,4 +1,15 @@ -import { pgTable, timestamp, uuid, varchar, text, integer, jsonb, real } from 'drizzle-orm/pg-core'; +import { + index, + integer, + jsonb, + pgTable, + real, + text, + timestamp, + uniqueIndex, + uuid, + varchar, +} from 'drizzle-orm/pg-core'; export const appEvents = pgTable('app_events', { id: uuid('id').primaryKey().defaultRandom(), @@ -21,6 +32,31 @@ export const diseaseCatalog = pgTable('disease_catalog', { updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); +export const users = pgTable('users', { + id: uuid('id').primaryKey().defaultRandom(), + email: varchar('email', { length: 240 }).notNull(), + name: varchar('name', { length: 160 }).notNull(), + passwordHash: text('password_hash'), + role: varchar('role', { length: 20 }).notNull().default('user'), + googleId: varchar('google_id', { length: 240 }), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + emailIdx: uniqueIndex('users_email_idx').on(table.email), + googleIdx: uniqueIndex('users_google_id_idx').on(table.googleId), +})); + +export const sessions = pgTable('sessions', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull().references(() => users.id), + tokenHash: text('token_hash').notNull(), + expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + tokenIdx: uniqueIndex('sessions_token_hash_idx').on(table.tokenHash), + userIdx: index('sessions_user_id_idx').on(table.userId), +})); + export const manualClassifications = pgTable('manual_classifications', { id: uuid('id').primaryKey().defaultRandom(), diseaseSlug: varchar('disease_slug', { length: 80 }) @@ -44,3 +80,46 @@ export const imageClassifications = pgTable('image_classifications', { uploaderPayload: jsonb('uploader_payload').notNull(), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }); + +export const diagnoses = pgTable('diagnoses', { + id: uuid('id').primaryKey().defaultRandom(), + userId: uuid('user_id').notNull().references(() => users.id), + predictedDiseaseSlug: varchar('predicted_disease_slug', { length: 80 }).references(() => diseaseCatalog.slug), + confidence: real('confidence'), + status: varchar('status', { length: 40 }).notNull(), + failureReason: text('failure_reason'), + imageUrl: text('image_url').notNull(), + uploaderPublicId: varchar('uploader_public_id', { length: 160 }).notNull(), + imageFileName: varchar('image_file_name', { length: 240 }).notNull(), + imageMimeType: varchar('image_mime_type', { length: 120 }).notNull(), + imageSizeBytes: integer('image_size_bytes').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + userIdx: index('diagnoses_user_id_idx').on(table.userId), + statusIdx: index('diagnoses_status_idx').on(table.status), +})); + +export const diagnosisPredictions = pgTable('diagnosis_predictions', { + id: uuid('id').primaryKey().defaultRandom(), + diagnosisId: uuid('diagnosis_id').notNull().references(() => diagnoses.id), + diseaseSlug: varchar('disease_slug', { length: 80 }).references(() => diseaseCatalog.slug), + modelLabel: varchar('model_label', { length: 120 }).notNull(), + confidence: real('confidence').notNull(), + rank: integer('rank').notNull(), +}, (table) => ({ + diagnosisIdx: index('diagnosis_predictions_diagnosis_id_idx').on(table.diagnosisId), +})); + +export const expertReviews = pgTable('expert_reviews', { + id: uuid('id').primaryKey().defaultRandom(), + diagnosisId: uuid('diagnosis_id').notNull().references(() => diagnoses.id), + expertId: uuid('expert_id').notNull().references(() => users.id), + verdict: varchar('verdict', { length: 20 }).notNull(), + correctedDiseaseSlug: varchar('corrected_disease_slug', { length: 80 }).references(() => diseaseCatalog.slug), + notes: text('notes').notNull(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), +}, (table) => ({ + diagnosisIdx: index('expert_reviews_diagnosis_id_idx').on(table.diagnosisId), + expertIdx: index('expert_reviews_expert_id_idx').on(table.expertId), +})); diff --git a/apps/api/src/db/seed-data.ts b/apps/api/src/db/seed-data.ts index 2380cc5..09fad02 100644 --- a/apps/api/src/db/seed-data.ts +++ b/apps/api/src/db/seed-data.ts @@ -1,8 +1,9 @@ import { diseaseCatalogSeed } from '@zeavis/shared'; import type { InferInsertModel } from 'drizzle-orm'; -import { diseaseCatalog } from './schema'; +import { diseaseCatalog, users } from './schema'; export type DiseaseCatalogRow = InferInsertModel; +export type UserRow = InferInsertModel; export const diseaseCatalogRows: DiseaseCatalogRow[] = diseaseCatalogSeed.map((item) => ({ slug: item.slug, @@ -16,3 +17,10 @@ export const diseaseCatalogRows: DiseaseCatalogRow[] = diseaseCatalogSeed.map((i accentColor: item.accentColor, displayOrder: item.displayOrder, })); + +export const demoExpertUser = { + email: 'expert@zeavis.local', + name: 'ZeaVis Expert', + role: 'expert' as const, + password: Bun.env.DEMO_EXPERT_PASSWORD ?? 'password123', +}; diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index d903997..2d80cc4 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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); diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts new file mode 100644 index 0000000..8e90768 --- /dev/null +++ b/apps/api/src/lib/auth.ts @@ -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 { + const token = readSessionToken(cookieHeader); + if (!token) return null; + + const db = createDbClient(); + const rows = await db + .select({ + id: users.id, + email: users.email, + name: users.name, + role: users.role, + }) + .from(sessions) + .innerJoin(users, eq(sessions.userId, users.id)) + .where(and(eq(sessions.tokenHash, hashToken(token)), gt(sessions.expiresAt, new Date()))) + .limit(1); + + const user = rows[0]; + if (!user) return null; + + return { + id: user.id, + email: user.email, + name: user.name, + role: user.role === 'expert' ? 'expert' : 'user', + }; +} + +export function requireUser(user: CurrentUser | null): CurrentUser { + if (!user) { + throw new Error('UNAUTHORIZED'); + } + return user; +} + +export function requireExpert(user: CurrentUser | null): CurrentUser { + const currentUser = requireUser(user); + if (currentUser.role !== 'expert') { + throw new Error('FORBIDDEN'); + } + return currentUser; +} diff --git a/apps/api/src/lib/http-errors.ts b/apps/api/src/lib/http-errors.ts index 674a196..115ff04 100644 --- a/apps/api/src/lib/http-errors.ts +++ b/apps/api/src/lib/http-errors.ts @@ -5,6 +5,20 @@ export function badRequest(message: string): Response { }); } +export function unauthorized(message: string): Response { + return new Response(JSON.stringify({ error: message }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export function forbidden(message: string): Response { + return new Response(JSON.stringify({ error: message }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); +} + export function notFound(message: string): Response { return new Response(JSON.stringify({ error: message }), { status: 404, diff --git a/apps/api/src/lib/uploader-client.ts b/apps/api/src/lib/uploader-client.ts index 526901d..bcdfcb8 100644 --- a/apps/api/src/lib/uploader-client.ts +++ b/apps/api/src/lib/uploader-client.ts @@ -1,28 +1,34 @@ import type { UploaderMetadata } from '@zeavis/shared'; +import { env } from '../config/env'; export async function uploadImageToStorage(file: File): Promise { const formData = new FormData(); formData.append('file', file); formData.append('fileName', file.name); - const response = await fetch('https://upload.asepharyana.tech/api/upload', { + const response = await fetch(`${env.uploaderBaseUrl}/api/upload`, { method: 'POST', body: formData, }); if (!response.ok) { - throw new Error(`Upload failed with status ${response.status}`); + throw new Error(`Upload failed with HTTP ${response.status}`); } - const data = (await response.json()) as Record; + const payload = await response.json() as Partial; - if (!data.download_url) { - throw new Error('Upload response missing download_url'); + if (!payload.public_id || !payload.download_url || !payload.file_name || !payload.mime_type || typeof payload.size_bytes !== 'number') { + throw new Error('Upload response is missing required metadata'); } - if (!data.public_id) { - throw new Error('Upload response missing public_id'); - } - - return data as UploaderMetadata; + return { + public_id: payload.public_id, + telegram_file_id: payload.telegram_file_id, + telegram_file_unique_id: payload.telegram_file_unique_id, + file_name: payload.file_name, + mime_type: payload.mime_type, + size_bytes: payload.size_bytes, + file_type: payload.file_type ?? 'image', + download_url: payload.download_url, + }; } diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..2d4fc81 --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,141 @@ +import { Elysia } from 'elysia'; +import type { AuthRequest, RegisterRequest } from '@zeavis/shared'; +import { eq } from 'drizzle-orm'; +import { createDbClient } from '../db/client'; +import { users } from '../db/schema'; +import { badRequest, serviceUnavailable } from '../lib/http-errors'; +import { + clearSessionCookie, + createSession, + createSessionCookie, + deleteSession, + getAuthFeatures, + getCurrentUser, + hashPassword, + readSessionToken, + verifyPassword, +} from '../lib/auth'; +import { env } from '../config/env'; + +function normalizeEmail(email: unknown) { + return typeof email === 'string' ? email.trim().toLowerCase() : ''; +} + +function normalizeName(name: unknown) { + return typeof name === 'string' ? name.trim() : ''; +} + +function validatePassword(password: unknown) { + return typeof password === 'string' && password.length >= 8; +} + +export const authRoutes = new Elysia({ prefix: '/api/v1/auth' }) + .get('/me', async ({ request }) => { + const user = await getCurrentUser(request.headers.get('cookie')); + return { + user, + features: getAuthFeatures(), + }; + }) + .post('/register', async ({ body, set }) => { + const req = body as Partial | undefined; + const email = normalizeEmail(req?.email); + const name = normalizeName(req?.name); + + if (!email || !email.includes('@') || !name || !validatePassword(req?.password)) { + return badRequest('Name, valid email, and password with at least 8 characters are required'); + } + + try { + const db = createDbClient(); + const existing = await db.select().from(users).where(eq(users.email, email)).limit(1); + if (existing.length > 0) { + return badRequest('Email is already registered'); + } + + const passwordHash = await hashPassword(req!.password!); + const inserted = await db + .insert(users) + .values({ email, name, passwordHash, role: 'user' }) + .returning(); + + const user = inserted[0]; + const token = await createSession(user.id); + set.headers['Set-Cookie'] = createSessionCookie(token); + + return { + user: { + id: user.id, + email: user.email, + name: user.name, + role: 'user' as const, + }, + features: getAuthFeatures(), + }; + } catch (error) { + return serviceUnavailable('Database unavailable'); + } + }) + .post('/login', async ({ body, set }) => { + const req = body as Partial | undefined; + const email = normalizeEmail(req?.email); + + if (!email || !validatePassword(req?.password)) { + return badRequest('Valid email and password are required'); + } + + try { + const db = createDbClient(); + const rows = await db.select().from(users).where(eq(users.email, email)).limit(1); + const user = rows[0]; + + if (!user?.passwordHash || !(await verifyPassword(req!.password!, user.passwordHash))) { + return badRequest('Invalid email or password'); + } + + const token = await createSession(user.id); + set.headers['Set-Cookie'] = createSessionCookie(token); + + return { + user: { + id: user.id, + email: user.email, + name: user.name, + role: user.role === 'expert' ? 'expert' as const : 'user' as const, + }, + features: getAuthFeatures(), + }; + } catch (error) { + return serviceUnavailable('Database unavailable'); + } + }) + .post('/logout', async ({ request, set }) => { + await deleteSession(readSessionToken(request.headers.get('cookie'))); + set.headers['Set-Cookie'] = clearSessionCookie(); + return { ok: true }; + }) + .get('/google', ({ set }) => { + if (!env.googleOAuthEnabled) { + set.status = 404; + return { error: 'Google OAuth is not configured' }; + } + + const params = new URLSearchParams({ + client_id: env.googleClientId!, + redirect_uri: env.googleRedirectUri!, + response_type: 'code', + scope: 'openid email profile', + access_type: 'offline', + prompt: 'select_account', + }); + + set.redirect = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`; + }) + .get('/google/callback', ({ set }) => { + if (!env.googleOAuthEnabled) { + set.status = 404; + return { error: 'Google OAuth is not configured' }; + } + + set.redirect = `${env.webAppUrl}/login?oauth=not-implemented`; + }); diff --git a/apps/api/src/routes/dashboard.ts b/apps/api/src/routes/dashboard.ts index dab1f08..e0a3aa8 100644 --- a/apps/api/src/routes/dashboard.ts +++ b/apps/api/src/routes/dashboard.ts @@ -1,88 +1,74 @@ import { Elysia } from 'elysia'; -import type { DashboardSummary, RiskLevel } from '@zeavis/shared'; +import type { DashboardSummary } from '@zeavis/shared'; +import { isDiseaseSlug } from '@zeavis/shared'; +import { eq, desc, count } from 'drizzle-orm'; import { createDbClient } from '../db/client'; -import { diseaseCatalog, imageClassifications, manualClassifications } from '../db/schema'; +import { diseaseCatalog, manualClassifications, diagnoses } from '../db/schema'; import { serviceUnavailable } from '../lib/http-errors'; -import { desc, eq } from 'drizzle-orm'; +import { toDisease } from '../lib/disease-mappers'; +import { getCurrentUser } from '../lib/auth'; +import { loadDiagnosisRecord } from './diagnoses'; -export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }).get('/dashboard/summary', async () => { - try { - const db = createDbClient(); +export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }) + .get('/dashboard/summary', async ({ request }) => { + try { + const db = createDbClient(); + const user = await getCurrentUser(request.headers.get('cookie')); - const diseaseCount = await db.select().from(diseaseCatalog); - const manualClassificationCount = await db.select().from(manualClassifications); - const imageClassificationCount = await db.select().from(imageClassifications); + const diseases = await db.select().from(diseaseCatalog).orderBy(diseaseCatalog.displayOrder); + const manualRows = await db.select().from(manualClassifications).orderBy(desc(manualClassifications.createdAt)).limit(1); - const latestClassificationRow = await db - .select({ - id: manualClassifications.id, - diseaseSlug: manualClassifications.diseaseSlug, - observation: manualClassifications.observation, - location: manualClassifications.location, - createdAt: manualClassifications.createdAt, - disease: { - slug: diseaseCatalog.slug, - label: diseaseCatalog.label, - commonName: diseaseCatalog.commonName, - summary: diseaseCatalog.summary, - description: diseaseCatalog.description, - symptoms: diseaseCatalog.symptoms, - recommendations: diseaseCatalog.recommendations, - riskLevel: diseaseCatalog.riskLevel, - accentColor: diseaseCatalog.accentColor, - displayOrder: diseaseCatalog.displayOrder, + const diagnosisFilters = user ? eq(diagnoses.userId, user.id) : undefined; + const diagnosisRows = diagnosisFilters + ? await db.select({ id: diagnoses.id }).from(diagnoses).where(diagnosisFilters).orderBy(desc(diagnoses.createdAt)).limit(1) + : []; + + const diagnosisCountRows = user + ? await db.select({ value: count() }).from(diagnoses).where(eq(diagnoses.userId, user.id)) + : [{ value: 0 }]; + + const needsReviewRows = user?.role === 'expert' + ? await db.select({ value: count() }).from(diagnoses).where(eq(diagnoses.status, 'needs_review')) + : [{ value: 0 }]; + + const latestDiagnosis = diagnosisRows[0] + ? await loadDiagnosisRecord(diagnosisRows[0].id, user?.id ?? null, user?.role === 'expert') + : null; + + const riskDistribution = diseases.reduce( + (acc, disease) => { + if (disease.riskLevel === 'high') acc.high += 1; + if (disease.riskLevel === 'medium') acc.medium += 1; + if (disease.riskLevel === 'low') acc.low += 1; + return acc; }, - }) - .from(manualClassifications) - .innerJoin(diseaseCatalog, eq(manualClassifications.diseaseSlug, diseaseCatalog.slug)) - .orderBy(desc(manualClassifications.createdAt)) - .limit(1); + { low: 0, medium: 0, high: 0 }, + ); - const latestClassification = latestClassificationRow[0] - ? { - id: latestClassificationRow[0].id, - diseaseSlug: latestClassificationRow[0].diseaseSlug as any, - observation: latestClassificationRow[0].observation, - location: latestClassificationRow[0].location, - createdAt: latestClassificationRow[0].createdAt.toISOString(), - disease: { - slug: latestClassificationRow[0].disease.slug as any, - label: latestClassificationRow[0].disease.label as any, - commonName: latestClassificationRow[0].disease.commonName, - summary: latestClassificationRow[0].disease.summary, - description: latestClassificationRow[0].disease.description, - symptoms: latestClassificationRow[0].disease.symptoms, - recommendations: latestClassificationRow[0].disease.recommendations, - riskLevel: latestClassificationRow[0].disease.riskLevel as any, - accentColor: latestClassificationRow[0].disease.accentColor, - displayOrder: latestClassificationRow[0].disease.displayOrder, - }, - } - : null; + const latestManual = manualRows[0]; + const latestDisease = latestManual && isDiseaseSlug(latestManual.diseaseSlug) + ? diseases.find((disease) => disease.slug === latestManual.diseaseSlug) + : null; - const riskDistributionRows = await db.select().from(diseaseCatalog); - const riskDistribution: Record = { - low: 0, - medium: 0, - high: 0, - }; + const summary: DashboardSummary = { + diseaseCount: diseases.length, + classificationCount: manualRows.length, + imageClassificationCount: diagnosisCountRows[0]?.value ?? 0, + needsReviewCount: needsReviewRows[0]?.value ?? 0, + riskDistribution, + latestClassification: latestManual && 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'); - } -}); + }); diff --git a/apps/api/src/routes/diagnoses.ts b/apps/api/src/routes/diagnoses.ts new file mode 100644 index 0000000..4d8f1fd --- /dev/null +++ b/apps/api/src/routes/diagnoses.ts @@ -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 { + 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; + 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 }; diff --git a/apps/api/src/routes/expert.ts b/apps/api/src/routes/expert.ts new file mode 100644 index 0000000..632a7cd --- /dev/null +++ b/apps/api/src/routes/expert.ts @@ -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>; + +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 | 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'); + } + }); diff --git a/apps/web/src/app.tsx b/apps/web/src/app.tsx index b667f42..29a38cc 100644 --- a/apps/web/src/app.tsx +++ b/apps/web/src/app.tsx @@ -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: , - }, + { path: '/', element: }, + { path: '/login', element: }, + { path: '/register', element: }, { path: '/dashboard', - element: , + element: ( + + + + ), }, { - path: '/catalog', - element: , + path: '/diagnoses/:id', + element: ( + + + + ), }, { - path: '/catalog/:slug', - element: , + path: '/expert/reviews', + element: ( + + + + ), }, + { path: '/catalog', element: }, + { path: '/catalog/:slug', element: }, ]); export function App() { diff --git a/apps/web/src/components/auth-form.tsx b/apps/web/src/components/auth-form.tsx new file mode 100644 index 0000000..0f08635 --- /dev/null +++ b/apps/web/src/components/auth-form.tsx @@ -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; + 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) { + event.preventDefault(); + await onSubmit({ name, email, password }); + } + + return ( + + + {mode === 'login' ? 'Masuk ke ZeaVis Edu' : 'Buat akun ZeaVis Edu'} + + {mode === 'login' + ? 'Masuk untuk melihat riwayat diagnosis daun jagung Anda.' + : 'Daftar untuk menyimpan diagnosis dan mengikuti review pakar.'} + + + +
+ {mode === 'register' && ( +
+ + { + setName(event.target.value); + onFieldChange?.(); + }} required /> +
+ )} +
+ + { + setEmail(event.target.value); + onFieldChange?.(); + }} required /> +
+
+ + { + setPassword(event.target.value); + onFieldChange?.(); + }} required /> +
+ {error &&

{error}

} + +
+ {googleOAuthEnabled && ( + + )} +
+
+ ); +} diff --git a/apps/web/src/components/auth-guard.tsx b/apps/web/src/components/auth-guard.tsx new file mode 100644 index 0000000..ed4ac06 --- /dev/null +++ b/apps/web/src/components/auth-guard.tsx @@ -0,0 +1,38 @@ +import { ReactNode, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Navigate } from 'react-router-dom'; +import { apiClient } from '@/lib/api-client'; +import { useAuthStore } from '@/store/auth-store'; + +type AuthGuardProps = { + children: ReactNode; + requireExpert?: boolean; +}; + +export function AuthGuard({ children, requireExpert = false }: AuthGuardProps) { + const setUser = useAuthStore((state) => state.setUser); + const query = useQuery({ + queryKey: ['auth', 'me'], + queryFn: () => apiClient.getMe(), + }); + + useEffect(() => { + if (query.data) { + setUser(query.data.user); + } + }, [query.data, setUser]); + + if (query.isLoading) { + return
Memeriksa sesi...
; + } + + if (!query.data?.user) { + return ; + } + + if (requireExpert && query.data.user.role !== 'expert') { + return ; + } + + return <>{children}; +} diff --git a/apps/web/src/components/diagnosis-card.tsx b/apps/web/src/components/diagnosis-card.tsx new file mode 100644 index 0000000..4d23af7 --- /dev/null +++ b/apps/web/src/components/diagnosis-card.tsx @@ -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 ( + + + + Daun jagung +
+
+

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

+ +
+

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

+

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

+
+
+
+ + ); +} diff --git a/apps/web/src/components/diagnosis-status-badge.tsx b/apps/web/src/components/diagnosis-status-badge.tsx new file mode 100644 index 0000000..385f0e0 --- /dev/null +++ b/apps/web/src/components/diagnosis-status-badge.tsx @@ -0,0 +1,22 @@ +import type { DiagnosisStatus } from '@zeavis/shared'; +import { cn } from '@/lib/utils'; + +const labels: Record = { + ai_verified: 'Terverifikasi AI', + needs_review: 'Menunggu review pakar', + expert_verified: 'Diverifikasi pakar', + expert_corrected: 'Dikoreksi pakar', + failed: 'Gagal diproses', +}; + +const styles: Record = { + ai_verified: 'bg-emerald-100 text-emerald-800', + needs_review: 'bg-amber-100 text-amber-800', + expert_verified: 'bg-blue-100 text-blue-800', + expert_corrected: 'bg-purple-100 text-purple-800', + failed: 'bg-red-100 text-red-800', +}; + +export function DiagnosisStatusBadge({ status, className }: { status: DiagnosisStatus; className?: string }) { + return {labels[status]}; +} diff --git a/apps/web/src/components/image-classification-form.tsx b/apps/web/src/components/image-classification-form.tsx index 568d4bf..87f259f 100644 --- a/apps/web/src/components/image-classification-form.tsx +++ b/apps/web/src/components/image-classification-form.tsx @@ -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; isSubmitting: boolean; - latestResult: ImageClassificationRecord | null; -} + latestResult: DiagnosisRecord | null; +}; export function ImageClassificationForm({ onSubmit, @@ -16,140 +17,122 @@ export function ImageClassificationForm({ latestResult, }: ImageClassificationFormProps) { const fileInputRef = useRef(null); - const [selectedFile, setSelectedFile] = useState(null); + const previewUrlRef = useRef(null); + const [file, setFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); const [error, setError] = useState(null); - const handleFileChange = (e: React.ChangeEvent) => { - 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) { + 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) { + 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 ( -
- - - Klasifikasi Gambar - Unggah foto daun jagung untuk klasifikasi otomatis - - -
- {error && ( -
{error}
- )} - -
- - - {selectedFile && ( -

- File dipilih: {selectedFile.name} -

- )} -
- - -
-
-
- - {latestResult && ( - - - Hasil Klasifikasi Terbaru - Prediksi penyakit dari gambar terakhir - - -
- Uploaded corn leaf -
- -
-
-
-

{latestResult.disease.commonName}

-

{latestResult.disease.label}

-
- -
- -
-

- Kepercayaan: {(latestResult.confidence * 100).toFixed(1)}% -

-
- -
-

Rekomendasi awal:

-
    - {latestResult.disease.recommendations.slice(0, 3).map((recommendation) => ( -
  • • {recommendation}
  • - ))} -
-
- -

- {new Date(latestResult.createdAt).toLocaleDateString('id-ID', { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - })} + + + + Diagnosis Gambar + + + Upload gambar daun jagung untuk klasifikasi AI dan review pakar jika confidence rendah. + + + +

+
+ + + {file && ( +

+ File dipilih: {file.name}

+ )} +
+ + {previewUrl && ( + Pratinjau gambar daun jagung untuk diagnosis + )} + + {error &&

{error}

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

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

+
- - - )} -
+

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

+
+ )} +
+
); } diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 0000000..f2fe482 --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,14 @@ +import type { InputHTMLAttributes } from 'react'; +import { cn } from '@/lib/utils'; + +export function Input({ className, ...props }: InputHTMLAttributes) { + return ( + + ); +} diff --git a/apps/web/src/components/ui/label.tsx b/apps/web/src/components/ui/label.tsx new file mode 100644 index 0000000..da28348 --- /dev/null +++ b/apps/web/src/components/ui/label.tsx @@ -0,0 +1,11 @@ +import type { LabelHTMLAttributes } from 'react'; +import { cn } from '@/lib/utils'; + +export function Label({ className, ...props }: LabelHTMLAttributes) { + return ( +

ZeaVis Edu Workspace

- Pantau penyakit daun jagung dan laporkan pengamatan Anda + {user?.name ? `Selamat datang, ${user.name}` : 'Pantau penyakit daun jagung dan laporkan pengamatan Anda'}

+ {user?.role === 'expert' && ( + + )} -
@@ -115,11 +143,24 @@ export function DashboardPage() { - Total Laporan + Total Diagnosis -
{summary.classificationCount}
+
{summary.imageClassificationCount}
+
+
+ + + + + Menunggu Review + + + +
+ {summary.needsReviewCount} +
@@ -135,19 +176,6 @@ export function DashboardPage() { - - - - - Risiko Sedang - - - -
- {summary.riskDistribution.medium} -
-
-
)} @@ -200,48 +228,12 @@ export function DashboardPage() { - {summary?.latestClassification && ( - - - - - Laporan Terbaru - - - Pengamatan penyakit terakhir yang dilaporkan - - - -
-
-
-

{summary.latestClassification.disease.commonName}

-

- {summary.latestClassification.disease.label} -

-
- -
-

- {summary.latestClassification.observation} -

-

- Lokasi: {summary.latestClassification.location} -

-

- {new Date(summary.latestClassification.createdAt).toLocaleDateString('id-ID')} -

-
-
-
- )} - { - await createImageClassificationMutation.mutateAsync(file); + await createDiagnosisMutation.mutateAsync(file); }} - isSubmitting={createImageClassificationMutation.isPending} - latestResult={imageClassifications[0] ?? null} + isSubmitting={createDiagnosisMutation.isPending} + latestResult={diagnoses[0] ?? null} /> - {imageClassifications.length > 0 && ( + {diagnoses.length > 0 && ( - - Riwayat Klasifikasi Gambar + + Riwayat Diagnosis - {imageClassifications.length} gambar yang diklasifikasi + {diagnoses.length} diagnosis yang telah dibuat -
- {imageClassifications.slice(0, 6).map((classification) => ( -
- Classified corn leaf -
-
-
-

- {classification.disease.commonName} -

-

- {classification.disease.label} -

-
- -
-
- Kepercayaan: {(classification.confidence * 100).toFixed(1)}% -
-

- {new Date(classification.createdAt).toLocaleDateString('id-ID')} -

-
-
+
+ {diagnoses.slice(0, 6).map((diagnosis) => ( + ))}
diff --git a/apps/web/src/pages/diagnosis-detail-page.tsx b/apps/web/src/pages/diagnosis-detail-page.tsx new file mode 100644 index 0000000..c9f45e6 --- /dev/null +++ b/apps/web/src/pages/diagnosis-detail-page.tsx @@ -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
Memuat diagnosis...
; + if (query.error || !query.data) return
Diagnosis tidak ditemukan
; + + const diagnosis = query.data; + + return ( +
+
+
+
+

Detail Diagnosis

+

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

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

{diagnosis.failureReason}

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

{diagnosis.disease.label}

+ +
+

{diagnosis.disease.summary}

+
+

Rekomendasi

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

{diagnosis.latestReview.notes}

+
+
+ )} +
+
+ ); +} diff --git a/apps/web/src/pages/expert-reviews-page.tsx b/apps/web/src/pages/expert-reviews-page.tsx new file mode 100644 index 0000000..98af3ce --- /dev/null +++ b/apps/web/src/pages/expert-reviews-page.tsx @@ -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(''); + const [notes, setNotes] = useState(''); + const queryClient = useQueryClient(); + + const [reviewsQuery, diseasesQuery] = useQueries({ + queries: [ + { queryKey: ['expert-reviews'], queryFn: () => apiClient.getExpertReviews() }, + { queryKey: ['diseases'], queryFn: () => apiClient.getDiseases() }, + ], + }); + + const reviews = reviewsQuery.data || []; + const diseases = diseasesQuery.data || []; + const selected = useMemo(() => reviews.find((item) => item.id === selectedId) ?? reviews[0] ?? null, [reviews, selectedId]); + + const mutation = useMutation({ + mutationFn: () => apiClient.reviewDiagnosis(selected!.id, { + verdict, + correctedDiseaseSlug: verdict === 'corrected' ? correctedDiseaseSlug || undefined : undefined, + notes, + }), + onSuccess: () => { + setNotes(''); + setVerdict('verified'); + setCorrectedDiseaseSlug(''); + queryClient.invalidateQueries({ queryKey: ['expert-reviews'] }); + queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] }); + }, + }); + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!selected) return; + await mutation.mutateAsync(); + } + + return ( +
+
+
+

Expert Reviews

+ + + +
+
+ +
+
+ {/* Left: List of reviews */} +
+
+

Diagnoses to Review

+ {reviews.length} +
+ +
+ {reviewsQuery.isPending || diseasesQuery.isPending ? ( +

Loading reviews and diseases...

+ ) : reviews.length === 0 ? ( +

No diagnoses pending review

+ ) : ( + reviews.map((review) => ( + + )) + )} +
+
+ + {/* Right: Detail and form */} +
+ {reviewsQuery.isPending || diseasesQuery.isPending ? ( + + + Loading reviews and diseases... + + + ) : selected ? ( + <> + {/* Detail card */} + + + {selected.disease?.commonName ?? 'Diagnosis gagal'} + + {new Date(selected.createdAt).toLocaleString('id-ID')} + + + + {`Gambar + +
+
+ Status + +
+
+ Confidence + + {selected.confidence === null ? 'N/A' : `${(selected.confidence * 100).toFixed(1)}%`} + +
+
+ + {/* Top predictions */} + {selected.predictions.length > 0 && ( +
+

Top Predictions

+
+ {selected.predictions.map((pred) => ( +
+ {pred.modelLabel} + {(pred.confidence * 100).toFixed(1)}% +
+ ))} +
+
+ )} +
+
+ + {/* Review form */} + + + Expert Review + + +
+ {/* Verdict selection */} +
+ +
+ + +
+
+ + {/* Disease selection (only if corrected) */} + {verdict === 'corrected' && ( +
+ + +
+ )} + + {/* Notes */} +
+ +