From a5b998b421bd74ff6687199aaaa78638e86a0891 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sun, 19 Jul 2026 01:53:43 +0200 Subject: [PATCH] =?UTF-8?q?Commit=202:=20REST-API=20f=C3=BCr=20Ger=C3=A4te?= =?UTF-8?q?=20und=20Dienste=20(Zod-Validierung,=20Drizzle-Repositories)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 22 +++- apps/backend/src/db/repositories/devices.ts | 86 +++++++++++++ apps/backend/src/db/repositories/services.ts | 122 +++++++++++++++++++ apps/backend/src/index.ts | 4 + apps/backend/src/routes/devices.ts | 53 ++++++++ apps/backend/src/routes/services.ts | 67 ++++++++++ docs/ROADMAP.md | 6 +- packages/shared/package.json | 3 + packages/shared/src/index.ts | 3 + packages/shared/src/schemas.ts | 48 ++++++++ pnpm-lock.yaml | 9 ++ 11 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 apps/backend/src/db/repositories/devices.ts create mode 100644 apps/backend/src/db/repositories/services.ts create mode 100644 apps/backend/src/routes/devices.ts create mode 100644 apps/backend/src/routes/services.ts create mode 100644 packages/shared/src/schemas.ts diff --git a/README.md b/README.md index f79e15f..9e0465c 100644 --- a/README.md +++ b/README.md @@ -58,17 +58,35 @@ Dieser erste Commit liefert ein lauffähiges Grundgerüst: - ✅ Tastaturkürzel `/` und `Strg+K` zum Fokussieren der Suche - ✅ Docker-Compose-Setup: `docker compose up -d --build` startet Frontend + Backend - ✅ Persistentes Docker-Volume für die SQLite-Datenbank +- ✅ REST-API für Geräte & Dienste (`/api/devices`, `/api/services`), Zod-validiert, + mit Repository-Layer über Drizzle (siehe `apps/backend/src/db/repositories`) Noch **nicht** enthalten (folgt in den nächsten Commits): - Scanner (FritzBox, DNS, HTTP/HTTPS, Portscan, Softwareerkennung) -- CRUD-API für Geräte/Dienste/Kategorien - Fuzzy-Suche im Frontend gegen echte Daten (Ranking-Logik existiert bereits in `packages/shared`, ist aber noch nicht ans UI angebunden) -- Adminbereich, Plugin-System, PWA-Manifest, TanStack Router/Query, shadcn/ui, Zod, RHF +- Adminbereich, Plugin-System, PWA-Manifest, TanStack Router/Query, shadcn/ui, RHF Siehe [`docs/ROADMAP.md`](./docs/ROADMAP.md) für die geplante Reihenfolge. +## API-Endpunkte (Stand Commit 2) + +``` +GET /api/health +GET /api/devices Liste aller Geräte inkl. ihrer Dienste +GET /api/devices/:id +POST /api/devices +PATCH /api/devices/:id +DELETE /api/devices/:id (löscht zugehörige Dienste per Cascade) + +GET /api/services optional ?deviceId=&category=&favorite=true +GET /api/services/:id +POST /api/services erfordert existierende deviceId +PATCH /api/services/:id +DELETE /api/services/:id +``` + ## Deployment auf dem Server (xlc-launchpad) ```bash diff --git a/apps/backend/src/db/repositories/devices.ts b/apps/backend/src/db/repositories/devices.ts new file mode 100644 index 0000000..56bce6c --- /dev/null +++ b/apps/backend/src/db/repositories/devices.ts @@ -0,0 +1,86 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import type { Device, DeviceCreateInput, DeviceUpdateInput } from "@launchpad/shared"; +import { db } from "../client.js"; +import { devices } from "../schema.js"; + +function nowIso(): string { + return new Date().toISOString(); +} + +function mapRow(row: typeof devices.$inferSelect): Device { + return { + id: row.id, + hostname: row.hostname, + ip: row.ip, + mac: row.mac, + manufacturer: row.manufacturer, + model: row.model, + online: row.online, + source: row.source as Device["source"], + lastScan: row.lastScan, + }; +} + +export function listDevices(): Device[] { + return db.select().from(devices).all().map(mapRow); +} + +export function getDevice(id: string): Device | null { + const row = db.select().from(devices).where(eq(devices.id, id)).get(); + return row ? mapRow(row) : null; +} + +export function createDevice(input: DeviceCreateInput): Device { + const id = randomUUID(); + const timestamp = nowIso(); + + db.insert(devices) + .values({ + id, + hostname: input.hostname, + ip: input.ip, + mac: input.mac ?? null, + manufacturer: input.manufacturer ?? null, + model: input.model ?? null, + online: input.online ?? false, + source: input.source ?? "manual", + lastScan: null, + createdAt: timestamp, + updatedAt: timestamp, + }) + .run(); + + return getDevice(id)!; +} + +/** + * Aktualisiert ein Gerät anhand von Benutzereingaben (z. B. über die Admin-UI). + * Ein separater Pfad für automatische Scan-Ergebnisse folgt in einem späteren + * Commit (siehe docs/ROADMAP.md, Commit 5 – Scanner). + */ +export function updateDevice(id: string, input: DeviceUpdateInput): Device | null { + const existing = getDevice(id); + if (!existing) return null; + + db.update(devices) + .set({ + ...(input.hostname !== undefined && { hostname: input.hostname }), + ...(input.ip !== undefined && { ip: input.ip }), + ...(input.mac !== undefined && { mac: input.mac }), + ...(input.manufacturer !== undefined && { manufacturer: input.manufacturer }), + ...(input.model !== undefined && { model: input.model }), + ...(input.online !== undefined && { online: input.online }), + ...(input.source !== undefined && { source: input.source }), + updatedAt: nowIso(), + }) + .where(eq(devices.id, id)) + .run(); + + return getDevice(id); +} + +export function deleteDevice(id: string): boolean { + const result = db.delete(devices).where(eq(devices.id, id)).run(); + return result.changes > 0; +} diff --git a/apps/backend/src/db/repositories/services.ts b/apps/backend/src/db/repositories/services.ts new file mode 100644 index 0000000..a8a506f --- /dev/null +++ b/apps/backend/src/db/repositories/services.ts @@ -0,0 +1,122 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import type { Service, ServiceCreateInput, ServiceUpdateInput } from "@launchpad/shared"; +import { db } from "../client.js"; +import { services } from "../schema.js"; + +function nowIso(): string { + return new Date().toISOString(); +} + +function mapRow(row: typeof services.$inferSelect): Service { + return { + id: row.id, + deviceId: row.deviceId, + displayName: row.displayName, + hostname: row.hostname, + url: row.url, + https: row.https, + port: row.port, + category: row.category, + icon: row.icon, + favicon: row.favicon, + description: row.description, + favorite: row.favorite, + alias: JSON.parse(row.alias) as string[], + order: row.order, + }; +} + +export interface ServiceFilter { + deviceId?: string; + category?: string; + favorite?: boolean; +} + +export function listServices(filter: ServiceFilter = {}): Service[] { + return db + .select() + .from(services) + .all() + .map(mapRow) + .filter((service) => { + if (filter.deviceId && service.deviceId !== filter.deviceId) return false; + if (filter.category && service.category !== filter.category) return false; + if (filter.favorite !== undefined && service.favorite !== filter.favorite) return false; + return true; + }); +} + +export function listServicesByDevice(deviceId: string): Service[] { + return listServices({ deviceId }); +} + +export function getService(id: string): Service | null { + const row = db.select().from(services).where(eq(services.id, id)).get(); + return row ? mapRow(row) : null; +} + +export function createService(input: ServiceCreateInput): Service { + const id = randomUUID(); + const timestamp = nowIso(); + + db.insert(services) + .values({ + id, + deviceId: input.deviceId, + displayName: input.displayName, + hostname: input.hostname, + url: input.url, + https: input.https ?? false, + port: input.port, + category: input.category ?? null, + icon: input.icon ?? null, + favicon: input.favicon ?? null, + description: input.description ?? null, + favorite: input.favorite ?? false, + alias: JSON.stringify(input.alias ?? []), + order: input.order ?? 0, + createdAt: timestamp, + updatedAt: timestamp, + }) + .run(); + + return getService(id)!; +} + +/** + * Aktualisiert einen Dienst anhand von Benutzereingaben (z. B. über die Admin-UI). + * Ein separater Pfad für automatische Scan-Ergebnisse folgt in einem späteren + * Commit (siehe docs/ROADMAP.md, Commit 5 – Scanner): Scans dürfen displayName, + * category, favorite, order, alias und icon niemals überschreiben. + */ +export function updateService(id: string, input: ServiceUpdateInput): Service | null { + const existing = getService(id); + if (!existing) return null; + + db.update(services) + .set({ + ...(input.displayName !== undefined && { displayName: input.displayName }), + ...(input.hostname !== undefined && { hostname: input.hostname }), + ...(input.url !== undefined && { url: input.url }), + ...(input.https !== undefined && { https: input.https }), + ...(input.port !== undefined && { port: input.port }), + ...(input.category !== undefined && { category: input.category }), + ...(input.icon !== undefined && { icon: input.icon }), + ...(input.favicon !== undefined && { favicon: input.favicon }), + ...(input.description !== undefined && { description: input.description }), + ...(input.favorite !== undefined && { favorite: input.favorite }), + ...(input.alias !== undefined && { alias: JSON.stringify(input.alias) }), + ...(input.order !== undefined && { order: input.order }), + updatedAt: nowIso(), + }) + .where(eq(services.id, id)) + .run(); + + return getService(id); +} + +export function deleteService(id: string): boolean { + const result = db.delete(services).where(eq(services.id, id)).run(); + return result.changes > 0; +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 673599d..fe4798b 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -2,6 +2,8 @@ import Fastify from "fastify"; import cors from "@fastify/cors"; import { ensureSchema } from "./db/client.js"; import { healthRoutes } from "./routes/health.js"; +import { deviceRoutes } from "./routes/devices.js"; +import { serviceRoutes } from "./routes/services.js"; const PORT = Number(process.env.PORT ?? 3001); const HOST = process.env.HOST ?? "0.0.0.0"; @@ -24,6 +26,8 @@ async function main() { ensureSchema(); await app.register(healthRoutes); + await app.register(deviceRoutes); + await app.register(serviceRoutes); app.get("/", async () => { return { name: "LaunchPad API", status: "running" }; diff --git a/apps/backend/src/routes/devices.ts b/apps/backend/src/routes/devices.ts new file mode 100644 index 0000000..73ddc2e --- /dev/null +++ b/apps/backend/src/routes/devices.ts @@ -0,0 +1,53 @@ +import type { FastifyInstance } from "fastify"; +import { DeviceCreateSchema, DeviceUpdateSchema } from "@launchpad/shared"; +import * as deviceRepo from "../db/repositories/devices.js"; +import * as serviceRepo from "../db/repositories/services.js"; + +export async function deviceRoutes(app: FastifyInstance): Promise { + app.get("/api/devices", async () => { + return deviceRepo.listDevices().map((device) => ({ + ...device, + services: serviceRepo.listServicesByDevice(device.id), + })); + }); + + app.get("/api/devices/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const device = deviceRepo.getDevice(id); + if (!device) { + return reply.code(404).send({ error: "Gerät nicht gefunden" }); + } + return { ...device, services: serviceRepo.listServicesByDevice(id) }; + }); + + app.post("/api/devices", async (request, reply) => { + const parsed = DeviceCreateSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues }); + } + const device = deviceRepo.createDevice(parsed.data); + return reply.code(201).send(device); + }); + + app.patch("/api/devices/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const parsed = DeviceUpdateSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues }); + } + const device = deviceRepo.updateDevice(id, parsed.data); + if (!device) { + return reply.code(404).send({ error: "Gerät nicht gefunden" }); + } + return device; + }); + + app.delete("/api/devices/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const deleted = deviceRepo.deleteDevice(id); + if (!deleted) { + return reply.code(404).send({ error: "Gerät nicht gefunden" }); + } + return reply.code(204).send(); + }); +} diff --git a/apps/backend/src/routes/services.ts b/apps/backend/src/routes/services.ts new file mode 100644 index 0000000..0a1b9b7 --- /dev/null +++ b/apps/backend/src/routes/services.ts @@ -0,0 +1,67 @@ +import type { FastifyInstance } from "fastify"; +import { ServiceCreateSchema, ServiceUpdateSchema } from "@launchpad/shared"; +import * as deviceRepo from "../db/repositories/devices.js"; +import * as serviceRepo from "../db/repositories/services.js"; + +interface ServiceListQuery { + deviceId?: string; + category?: string; + favorite?: string; +} + +export async function serviceRoutes(app: FastifyInstance): Promise { + app.get("/api/services", async (request) => { + const query = request.query as ServiceListQuery; + return serviceRepo.listServices({ + deviceId: query.deviceId, + category: query.category, + favorite: query.favorite === undefined ? undefined : query.favorite === "true", + }); + }); + + app.get("/api/services/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const service = serviceRepo.getService(id); + if (!service) { + return reply.code(404).send({ error: "Dienst nicht gefunden" }); + } + return service; + }); + + app.post("/api/services", async (request, reply) => { + const parsed = ServiceCreateSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues }); + } + + const device = deviceRepo.getDevice(parsed.data.deviceId); + if (!device) { + return reply.code(400).send({ error: `Gerät ${parsed.data.deviceId} existiert nicht` }); + } + + const service = serviceRepo.createService(parsed.data); + return reply.code(201).send(service); + }); + + app.patch("/api/services/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const parsed = ServiceUpdateSchema.safeParse(request.body); + if (!parsed.success) { + return reply.code(400).send({ error: "Ungültige Eingabe", issues: parsed.error.issues }); + } + const service = serviceRepo.updateService(id, parsed.data); + if (!service) { + return reply.code(404).send({ error: "Dienst nicht gefunden" }); + } + return service; + }); + + app.delete("/api/services/:id", async (request, reply) => { + const { id } = request.params as { id: string }; + const deleted = serviceRepo.deleteService(id); + if (!deleted) { + return reply.code(404).send({ error: "Dienst nicht gefunden" }); + } + return reply.code(204).send(); + }); +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 89b6299..ceae795 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -3,11 +3,11 @@ Geplante Reihenfolge der nächsten Commits, aufbauend auf dem lauffähigen Grundgerüst aus Commit 1. -## Commit 2 — Geräte- & Dienste-API +## ✅ Commit 2 — Geräte- & Dienste-API (erledigt) - REST-Endpunkte (Fastify) für `GET/POST/PATCH/DELETE` auf `devices` und `services` -- Zod-Validierung der Request-Bodies -- Repository-Layer über Drizzle statt Rohschema-Zugriff +- Zod-Validierung der Request-Bodies (`packages/shared/src/schemas.ts`) +- Repository-Layer über Drizzle (`apps/backend/src/db/repositories`) ## Commit 3 — Suche im Frontend diff --git a/packages/shared/package.json b/packages/shared/package.json index d680a1d..83bee8f 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -9,6 +9,9 @@ "build": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json --noEmit" }, + "dependencies": { + "zod": "^3.23.8" + }, "devDependencies": { "typescript": "^5.5.4" } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1abb66a..2884b8a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,6 +5,9 @@ * als auch vom Frontend (apps/frontend) verwendet werden. */ +export * from "./schemas.js"; + + export interface Device { id: string; hostname: string; diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts new file mode 100644 index 0000000..17613ab --- /dev/null +++ b/packages/shared/src/schemas.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +export const deviceSourceValues = [ + "fritzbox", + "dns", + "http", + "https", + "portscan", + "manual", +] as const; + +export const DeviceSourceSchema = z.enum(deviceSourceValues); + +export const DeviceCreateSchema = z.object({ + hostname: z.string().min(1, "hostname darf nicht leer sein"), + ip: z.string().min(1, "ip darf nicht leer sein"), + mac: z.string().optional(), + manufacturer: z.string().optional(), + model: z.string().optional(), + online: z.boolean().optional(), + source: DeviceSourceSchema.optional(), +}); +export type DeviceCreateInput = z.infer; + +export const DeviceUpdateSchema = DeviceCreateSchema.partial(); +export type DeviceUpdateInput = z.infer; + +export const ServiceCreateSchema = z.object({ + deviceId: z.string().min(1, "deviceId darf nicht leer sein"), + displayName: z.string().min(1, "displayName darf nicht leer sein"), + hostname: z.string().min(1, "hostname darf nicht leer sein"), + url: z.string().url("url muss eine gültige URL sein"), + https: z.boolean().optional(), + port: z.number().int().min(1).max(65535), + category: z.string().optional(), + icon: z.string().optional(), + favicon: z.string().optional(), + description: z.string().optional(), + favorite: z.boolean().optional(), + alias: z.array(z.string()).optional(), + order: z.number().optional(), +}); +export type ServiceCreateInput = z.infer; + +export const ServiceUpdateSchema = ServiceCreateSchema.omit({ + deviceId: true, +}).partial(); +export type ServiceUpdateInput = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c693914..6dde8e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,10 @@ importers: version: 5.4.21(@types/node@20.19.43) packages/shared: + dependencies: + zod: + specifier: ^3.23.8 + version: 3.25.76 devDependencies: typescript: specifier: ^5.5.4 @@ -1878,6 +1882,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -3356,3 +3363,5 @@ snapshots: wrappy@1.0.2: {} yallist@3.1.1: {} + + zod@3.25.76: {}