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:
@@ -3,11 +3,13 @@ import postgres from 'postgres';
|
||||
import { env } from '../config/env';
|
||||
import * as schema from './schema';
|
||||
|
||||
let db: ReturnType<typeof drizzle<typeof schema>> | undefined;
|
||||
|
||||
export function createDbClient() {
|
||||
if (!env.databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required to create a database client');
|
||||
}
|
||||
|
||||
const client = postgres(env.databaseUrl);
|
||||
return drizzle(client, { schema });
|
||||
db ??= drizzle(postgres(env.databaseUrl), { schema });
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,32 @@
|
||||
import { pgTable, timestamp, uuid, varchar } from 'drizzle-orm/pg-core';
|
||||
import { pgTable, timestamp, uuid, varchar, text, integer } from 'drizzle-orm/pg-core';
|
||||
|
||||
export const appEvents = pgTable('app_events', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: varchar('name', { length: 120 }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const diseaseCatalog = pgTable('disease_catalog', {
|
||||
slug: varchar('slug', { length: 80 }).primaryKey(),
|
||||
label: varchar('label', { length: 80 }).notNull(),
|
||||
commonName: varchar('common_name', { length: 120 }).notNull(),
|
||||
summary: text('summary').notNull(),
|
||||
description: text('description').notNull(),
|
||||
symptoms: text('symptoms').array().notNull(),
|
||||
recommendations: text('recommendations').array().notNull(),
|
||||
riskLevel: varchar('risk_level', { length: 20 }).notNull(),
|
||||
accentColor: varchar('accent_color', { length: 40 }).notNull(),
|
||||
displayOrder: integer('display_order').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const manualClassifications = pgTable('manual_classifications', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
diseaseSlug: varchar('disease_slug', { length: 80 })
|
||||
.notNull()
|
||||
.references(() => diseaseCatalog.slug),
|
||||
observation: text('observation').notNull(),
|
||||
location: varchar('location', { length: 160 }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { diseaseCatalogSeed } from '@zeavis/shared';
|
||||
import type { InferInsertModel } from 'drizzle-orm';
|
||||
import { diseaseCatalog } from './schema';
|
||||
|
||||
export type DiseaseCatalogRow = InferInsertModel<typeof diseaseCatalog>;
|
||||
|
||||
export const diseaseCatalogRows: DiseaseCatalogRow[] = diseaseCatalogSeed.map((item) => ({
|
||||
slug: item.slug,
|
||||
label: item.label,
|
||||
commonName: item.commonName,
|
||||
summary: item.summary,
|
||||
description: item.description,
|
||||
symptoms: item.symptoms,
|
||||
recommendations: item.recommendations,
|
||||
riskLevel: item.riskLevel,
|
||||
accentColor: item.accentColor,
|
||||
displayOrder: item.displayOrder,
|
||||
}));
|
||||
+10
-1
@@ -2,8 +2,17 @@ import { Elysia } from 'elysia';
|
||||
import { env } from './config/env';
|
||||
import { healthRoutes } from './routes/health';
|
||||
import { statusRoutes } from './routes/status';
|
||||
import { diseaseRoutes } from './routes/diseases';
|
||||
import { classificationRoutes } from './routes/classifications';
|
||||
import { dashboardRoutes } from './routes/dashboard';
|
||||
|
||||
const app = new Elysia().use(healthRoutes).use(statusRoutes).listen(env.port);
|
||||
const app = new Elysia()
|
||||
.use(healthRoutes)
|
||||
.use(statusRoutes)
|
||||
.use(diseaseRoutes)
|
||||
.use(classificationRoutes)
|
||||
.use(dashboardRoutes)
|
||||
.listen(env.port);
|
||||
|
||||
console.log(`ZeaVis Edu API running at http://${app.server?.hostname}:${app.server?.port}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export function badRequest(message: string): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export function notFound(message: string): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
export function serviceUnavailable(message: string): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user