Implement fullstack education catalog with disease detail pages, API routes, and shared types

- Add disease detail page component with data fetching and error handling
- Create shared types for diseases and classifications
- Implement API routes for diseases, classifications, and dashboard summary
- Develop reusable components for risk badge and disease card
- Build catalog page with search and filter functionality
- Update dashboard page with data-backed summary and manual classification form
- Register new routes in the web application
- Ensure type safety and consistency across shared modules
This commit is contained in:
Asep Haryana Saputra
2026-05-22 14:26:13 +00:00
parent d0c17d67b2
commit 84c1f46841
24 changed files with 2378 additions and 54 deletions
+121
View File
@@ -0,0 +1,121 @@
import { Elysia } from 'elysia';
import type { ManualClassificationRequest, ManualClassificationRecord } from '@zeavis/shared';
import { isDiseaseSlug } from '@zeavis/shared';
import { createDbClient } from '../db/client';
import { diseaseCatalog, manualClassifications } from '../db/schema';
import { badRequest, serviceUnavailable } from '../lib/http-errors';
import { desc, eq } from 'drizzle-orm';
export const classificationRoutes = new Elysia({ prefix: '/api/v1' })
.get('/classifications/manual', async () => {
try {
const db = createDbClient();
const rows = 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,
},
})
.from(manualClassifications)
.innerJoin(diseaseCatalog, eq(manualClassifications.diseaseSlug, diseaseCatalog.slug))
.orderBy(desc(manualClassifications.createdAt))
.limit(20);
const records: ManualClassificationRecord[] = rows.map((row) => ({
id: row.id,
diseaseSlug: row.diseaseSlug as any,
observation: row.observation,
location: row.location,
createdAt: row.createdAt.toISOString(),
disease: {
slug: row.disease.slug as any,
label: row.disease.label as any,
commonName: row.disease.commonName,
summary: row.disease.summary,
description: row.disease.description,
symptoms: row.disease.symptoms,
recommendations: row.disease.recommendations,
riskLevel: row.disease.riskLevel as any,
accentColor: row.disease.accentColor,
displayOrder: row.disease.displayOrder,
},
}));
return records;
} catch (error) {
return serviceUnavailable('Database unavailable');
}
})
.post('/classifications/manual', async ({ body }) => {
const req = body as Partial<ManualClassificationRequest> | undefined;
if (!req || typeof req !== 'object' || !req.diseaseSlug || !req.observation || !req.location) {
return badRequest('Missing required fields: diseaseSlug, observation, location');
}
if (!isDiseaseSlug(req.diseaseSlug)) {
return badRequest(`Unknown disease slug: "${req.diseaseSlug}"`);
}
try {
const db = createDbClient();
const diseaseExists = await db
.select()
.from(diseaseCatalog)
.where(eq(diseaseCatalog.slug, req.diseaseSlug))
.limit(1);
if (diseaseExists.length === 0) {
return badRequest(`Disease with slug "${req.diseaseSlug}" does not exist`);
}
const inserted = await db
.insert(manualClassifications)
.values({
diseaseSlug: req.diseaseSlug,
observation: req.observation,
location: req.location,
})
.returning();
const disease = diseaseExists[0];
const record: ManualClassificationRecord = {
id: inserted[0].id,
diseaseSlug: inserted[0].diseaseSlug as any,
observation: inserted[0].observation,
location: inserted[0].location,
createdAt: inserted[0].createdAt.toISOString(),
disease: {
slug: disease.slug as any,
label: disease.label as any,
commonName: disease.commonName,
summary: disease.summary,
description: disease.description,
symptoms: disease.symptoms,
recommendations: disease.recommendations,
riskLevel: disease.riskLevel as any,
accentColor: disease.accentColor,
displayOrder: disease.displayOrder,
},
};
return record;
} catch (error) {
return serviceUnavailable('Database unavailable');
}
});
+87
View File
@@ -0,0 +1,87 @@
import { Elysia } from 'elysia';
import type { DashboardSummary, RiskLevel } from '@zeavis/shared';
import { createDbClient } from '../db/client';
import { diseaseCatalog, manualClassifications } from '../db/schema';
import { serviceUnavailable } from '../lib/http-errors';
import { desc, eq } from 'drizzle-orm';
export const dashboardRoutes = new Elysia({ prefix: '/api/v1' }).get('/dashboard/summary', async () => {
try {
const db = createDbClient();
const diseaseCount = await db.select().from(diseaseCatalog);
const classificationCount = await db.select().from(manualClassifications);
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,
},
})
.from(manualClassifications)
.innerJoin(diseaseCatalog, eq(manualClassifications.diseaseSlug, diseaseCatalog.slug))
.orderBy(desc(manualClassifications.createdAt))
.limit(1);
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 riskDistributionRows = await db.select().from(diseaseCatalog);
const riskDistribution: Record<RiskLevel, number> = {
low: 0,
medium: 0,
high: 0,
};
for (const row of riskDistributionRows) {
const risk = row.riskLevel as RiskLevel;
if (risk in riskDistribution) {
riskDistribution[risk]++;
}
}
const summary: DashboardSummary = {
diseaseCount: diseaseCount.length,
classificationCount: classificationCount.length,
latestClassification,
riskDistribution,
};
return summary;
} catch (error) {
return serviceUnavailable('Database unavailable');
}
});
+65
View File
@@ -0,0 +1,65 @@
import { Elysia } from 'elysia';
import type { DiseaseCatalogItem, DiseaseSlug, DiseaseLabel, RiskLevel } from '@zeavis/shared';
import { createDbClient } from '../db/client';
import { diseaseCatalog } from '../db/schema';
import { notFound, serviceUnavailable } from '../lib/http-errors';
import { asc, eq } from 'drizzle-orm';
export const diseaseRoutes = new Elysia({ prefix: '/api/v1' })
.get('/diseases', async () => {
try {
const db = createDbClient();
const rows = await db
.select()
.from(diseaseCatalog)
.orderBy(asc(diseaseCatalog.displayOrder));
const items: DiseaseCatalogItem[] = rows.map((row) => ({
slug: row.slug as DiseaseSlug,
label: row.label as DiseaseLabel,
commonName: row.commonName,
summary: row.summary,
description: row.description,
symptoms: row.symptoms,
recommendations: row.recommendations,
riskLevel: row.riskLevel as RiskLevel,
accentColor: row.accentColor,
displayOrder: row.displayOrder,
}));
return items;
} catch (error) {
return serviceUnavailable('Database unavailable');
}
})
.get('/diseases/:slug', async ({ params }) => {
try {
const db = createDbClient();
const row = await db
.select()
.from(diseaseCatalog)
.where(eq(diseaseCatalog.slug, params.slug))
.limit(1);
if (row.length === 0) {
return notFound(`Disease with slug "${params.slug}" not found`);
}
const item: DiseaseCatalogItem = {
slug: row[0].slug as DiseaseSlug,
label: row[0].label as DiseaseLabel,
commonName: row[0].commonName,
summary: row[0].summary,
description: row[0].description,
symptoms: row[0].symptoms,
recommendations: row[0].recommendations,
riskLevel: row[0].riskLevel as RiskLevel,
accentColor: row[0].accentColor,
displayOrder: row[0].displayOrder,
};
return item;
} catch (error) {
return serviceUnavailable('Database unavailable');
}
});