diff --git a/src/main.ts b/src/main.ts index 24b80fa..e8e5835 100644 --- a/src/main.ts +++ b/src/main.ts @@ -10,6 +10,7 @@ import { organizationsRouter } from "./routes/organizations.ts"; import { adminRouter } from "./routes/admin.ts"; import { partnershipsRouter } from "./routes/partnerships.ts"; import { qualificationsRouter } from "./routes/qualifications.ts"; +import { objectsRouter } from "./routes/objects.ts"; import { errorHandler } from "./middleware/error.ts"; import { requestLogger } from "./middleware/logger.ts"; import { initDB } from "./db/postgres.ts"; @@ -53,6 +54,8 @@ app.use(partnershipsRouter.routes()); app.use(partnershipsRouter.allowedMethods()); app.use(qualificationsRouter.routes()); app.use(qualificationsRouter.allowedMethods()); +app.use(objectsRouter.routes()); +app.use(objectsRouter.allowedMethods()); // Health check app.use((ctx) => { diff --git a/src/routes/objects.ts b/src/routes/objects.ts new file mode 100644 index 0000000..a766d2c --- /dev/null +++ b/src/routes/objects.ts @@ -0,0 +1,440 @@ +import { Router } from "@oak/oak"; +import { query, queryOne, execute } from "../db/postgres.ts"; +import { authMiddleware, requireDisponentOrHigher, requireChef } from "../middleware/auth.ts"; +import { AppError } from "../middleware/error.ts"; + +export const objectsRouter = new Router({ prefix: "/api/objects" }); + +// ============================================ +// OBJECTS (Wachobjekte) +// ============================================ + +// GET / - Alle Objekte der Organisation +objectsRouter.get("/", authMiddleware, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const status = ctx.request.url.searchParams.get("status") || "active"; + const type = ctx.request.url.searchParams.get("type"); + const search = ctx.request.url.searchParams.get("search"); + + let whereClause = "WHERE org_id = $1"; + const params: any[] = [orgId]; + let paramIndex = 2; + + if (status !== "all") { + whereClause += ` AND status = $${paramIndex++}`; + params.push(status); + } + + if (type) { + whereClause += ` AND object_type = $${paramIndex++}`; + params.push(type); + } + + if (search) { + whereClause += ` AND (name ILIKE $${paramIndex} OR city ILIKE $${paramIndex} OR object_number ILIKE $${paramIndex})`; + params.push(`%${search}%`); + paramIndex++; + } + + const objects = await query(` + SELECT o.*, + (SELECT COUNT(*) FROM object_contacts WHERE object_id = o.id) as contact_count, + (SELECT COUNT(*) FROM object_checkpoints WHERE object_id = o.id AND active = true) as checkpoint_count + FROM objects o + ${whereClause} + ORDER BY o.name + `, params); + + ctx.response.body = objects; +}); + +// GET /types - Objekt-Typen +objectsRouter.get("/types", authMiddleware, (ctx) => { + ctx.response.body = [ + { key: 'building', name: 'Gebäude', icon: '🏢' }, + { key: 'event', name: 'Veranstaltung', icon: '🎪' }, + { key: 'construction', name: 'Baustelle', icon: '🏗️' }, + { key: 'retail', name: 'Einzelhandel', icon: '🛒' }, + { key: 'industrial', name: 'Industrie', icon: '🏭' }, + { key: 'residential', name: 'Wohnanlage', icon: '🏠' }, + { key: 'parking', name: 'Parkhaus/Parkplatz', icon: '🅿️' }, + { key: 'hospital', name: 'Krankenhaus/Klinik', icon: '🏥' }, + { key: 'school', name: 'Schule/Bildung', icon: '🏫' }, + { key: 'other', name: 'Sonstiges', icon: '📍' } + ]; +}); + +// GET /:id - Einzelnes Objekt mit Details +objectsRouter.get("/:id", authMiddleware, async (ctx) => { + const objectId = ctx.params.id; + const orgId = ctx.state.auth.user.org_id; + + const obj = await queryOne(` + SELECT * FROM objects WHERE id = $1 AND org_id = $2 + `, [objectId, orgId]); + + if (!obj) { + throw new AppError("Objekt nicht gefunden", 404); + } + + // Kontakte laden + const contacts = await query(` + SELECT * FROM object_contacts WHERE object_id = $1 ORDER BY is_primary DESC, is_emergency DESC, name + `, [objectId]); + + // Dienstanweisungen laden + const instructions = await query(` + SELECT * FROM object_instructions WHERE object_id = $1 ORDER BY sort_order, title + `, [objectId]); + + // Dokumente laden + const documents = await query(` + SELECT * FROM object_documents WHERE object_id = $1 ORDER BY uploaded_at DESC + `, [objectId]); + + // Checkpoints laden + const checkpoints = await query(` + SELECT * FROM object_checkpoints WHERE object_id = $1 ORDER BY sort_order, name + `, [objectId]); + + // Anforderungen laden + const requirements = await query(` + SELECT r.*, + COALESCE(qt.name, oqt.name) as qualification_name, + COALESCE(qt.icon, oqt.icon) as icon + FROM object_requirements r + LEFT JOIN qualification_types qt ON r.qualification_type_id = qt.id + LEFT JOIN org_qualification_types oqt ON r.org_qualification_type_id = oqt.id + WHERE r.object_id = $1 + `, [objectId]); + + ctx.response.body = { + ...obj, + contacts, + instructions, + documents, + checkpoints, + requirements + }; +}); + +// POST / - Neues Objekt erstellen +objectsRouter.post("/", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const body = await ctx.request.body().value; + const orgId = ctx.state.auth.user.org_id; + const userId = ctx.state.auth.user.id; + + const { + name, short_name, object_number, object_type, + street, house_number, postal_code, city, country, + latitude, longitude, phone, email, + description, size_sqm, floors, + access_info, parking_info, customer_name, image_url + } = body; + + if (!name) { + throw new AppError("Name ist erforderlich", 400); + } + + const result = await queryOne(` + INSERT INTO objects ( + org_id, name, short_name, object_number, object_type, + street, house_number, postal_code, city, country, + latitude, longitude, phone, email, + description, size_sqm, floors, + access_info, parking_info, customer_name, image_url, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22) + RETURNING * + `, [ + orgId, name, short_name, object_number, object_type || 'other', + street, house_number, postal_code, city, country || 'Deutschland', + latitude, longitude, phone, email, + description, size_sqm, floors, + access_info, parking_info, customer_name, image_url, userId + ]); + + ctx.response.status = 201; + ctx.response.body = result; +}); + +// PUT /:id - Objekt aktualisieren +objectsRouter.put("/:id", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const objectId = ctx.params.id; + const body = await ctx.request.body().value; + const orgId = ctx.state.auth.user.org_id; + + const result = await queryOne(` + UPDATE objects SET + name = COALESCE($1, name), + short_name = COALESCE($2, short_name), + object_number = COALESCE($3, object_number), + object_type = COALESCE($4, object_type), + street = COALESCE($5, street), + house_number = COALESCE($6, house_number), + postal_code = COALESCE($7, postal_code), + city = COALESCE($8, city), + country = COALESCE($9, country), + latitude = COALESCE($10, latitude), + longitude = COALESCE($11, longitude), + phone = COALESCE($12, phone), + email = COALESCE($13, email), + description = COALESCE($14, description), + size_sqm = COALESCE($15, size_sqm), + floors = COALESCE($16, floors), + access_info = COALESCE($17, access_info), + parking_info = COALESCE($18, parking_info), + customer_name = COALESCE($19, customer_name), + image_url = COALESCE($20, image_url), + status = COALESCE($21, status), + updated_at = NOW() + WHERE id = $22 AND org_id = $23 + RETURNING * + `, [ + body.name, body.short_name, body.object_number, body.object_type, + body.street, body.house_number, body.postal_code, body.city, body.country, + body.latitude, body.longitude, body.phone, body.email, + body.description, body.size_sqm, body.floors, + body.access_info, body.parking_info, body.customer_name, body.image_url, body.status, + objectId, orgId + ]); + + if (!result) { + throw new AppError("Objekt nicht gefunden", 404); + } + + ctx.response.body = result; +}); + +// DELETE /:id - Objekt löschen (oder archivieren) +objectsRouter.delete("/:id", authMiddleware, requireChef, async (ctx) => { + const objectId = ctx.params.id; + const orgId = ctx.state.auth.user.org_id; + const permanent = ctx.request.url.searchParams.get("permanent") === "true"; + + if (permanent) { + const result = await execute(` + DELETE FROM objects WHERE id = $1 AND org_id = $2 + `, [objectId, orgId]); + + if (result === 0) { + throw new AppError("Objekt nicht gefunden", 404); + } + } else { + // Soft delete - archivieren + const result = await execute(` + UPDATE objects SET status = 'archived', updated_at = NOW() + WHERE id = $1 AND org_id = $2 + `, [objectId, orgId]); + + if (result === 0) { + throw new AppError("Objekt nicht gefunden", 404); + } + } + + ctx.response.body = { success: true }; +}); + +// ============================================ +// CONTACTS +// ============================================ + +// POST /:id/contacts - Kontakt hinzufügen +objectsRouter.post("/:id/contacts", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const objectId = ctx.params.id; + const body = await ctx.request.body().value; + const orgId = ctx.state.auth.user.org_id; + + // Prüfen ob Objekt existiert + const obj = await queryOne(`SELECT id FROM objects WHERE id = $1 AND org_id = $2`, [objectId, orgId]); + if (!obj) { + throw new AppError("Objekt nicht gefunden", 404); + } + + const result = await queryOne(` + INSERT INTO object_contacts (object_id, name, role, company, phone, mobile, email, availability, is_primary, is_emergency, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING * + `, [ + objectId, body.name, body.role, body.company, body.phone, body.mobile, + body.email, body.availability, body.is_primary || false, body.is_emergency || false, body.notes + ]); + + ctx.response.status = 201; + ctx.response.body = result; +}); + +// DELETE /:id/contacts/:contactId - Kontakt löschen +objectsRouter.delete("/:id/contacts/:contactId", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const { id: objectId, contactId } = ctx.params; + const orgId = ctx.state.auth.user.org_id; + + // Prüfen ob Objekt zur Org gehört + const obj = await queryOne(`SELECT id FROM objects WHERE id = $1 AND org_id = $2`, [objectId, orgId]); + if (!obj) { + throw new AppError("Objekt nicht gefunden", 404); + } + + await execute(`DELETE FROM object_contacts WHERE id = $1 AND object_id = $2`, [contactId, objectId]); + ctx.response.body = { success: true }; +}); + +// ============================================ +// INSTRUCTIONS (Dienstanweisungen) +// ============================================ + +// POST /:id/instructions - Dienstanweisung hinzufügen +objectsRouter.post("/:id/instructions", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const objectId = ctx.params.id; + const body = await ctx.request.body().value; + const orgId = ctx.state.auth.user.org_id; + const userId = ctx.state.auth.user.id; + + const obj = await queryOne(`SELECT id FROM objects WHERE id = $1 AND org_id = $2`, [objectId, orgId]); + if (!obj) { + throw new AppError("Objekt nicht gefunden", 404); + } + + const result = await queryOne(` + INSERT INTO object_instructions (object_id, title, category, content, sort_order, is_critical, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `, [ + objectId, body.title, body.category || 'general', body.content, + body.sort_order || 0, body.is_critical || false, userId + ]); + + ctx.response.status = 201; + ctx.response.body = result; +}); + +// PUT /:id/instructions/:instructionId - Dienstanweisung aktualisieren +objectsRouter.put("/:id/instructions/:instructionId", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const { id: objectId, instructionId } = ctx.params; + const body = await ctx.request.body().value; + const orgId = ctx.state.auth.user.org_id; + + const obj = await queryOne(`SELECT id FROM objects WHERE id = $1 AND org_id = $2`, [objectId, orgId]); + if (!obj) { + throw new AppError("Objekt nicht gefunden", 404); + } + + const result = await queryOne(` + UPDATE object_instructions SET + title = COALESCE($1, title), + category = COALESCE($2, category), + content = COALESCE($3, content), + sort_order = COALESCE($4, sort_order), + is_critical = COALESCE($5, is_critical), + version = version + 1, + updated_at = NOW() + WHERE id = $6 AND object_id = $7 + RETURNING * + `, [body.title, body.category, body.content, body.sort_order, body.is_critical, instructionId, objectId]); + + if (!result) { + throw new AppError("Dienstanweisung nicht gefunden", 404); + } + + ctx.response.body = result; +}); + +// DELETE /:id/instructions/:instructionId +objectsRouter.delete("/:id/instructions/:instructionId", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const { id: objectId, instructionId } = ctx.params; + const orgId = ctx.state.auth.user.org_id; + + const obj = await queryOne(`SELECT id FROM objects WHERE id = $1 AND org_id = $2`, [objectId, orgId]); + if (!obj) { + throw new AppError("Objekt nicht gefunden", 404); + } + + await execute(`DELETE FROM object_instructions WHERE id = $1 AND object_id = $2`, [instructionId, objectId]); + ctx.response.body = { success: true }; +}); + +// ============================================ +// CHECKPOINTS (für Wächterkontrolle) +// ============================================ + +// POST /:id/checkpoints - Checkpoint hinzufügen +objectsRouter.post("/:id/checkpoints", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const objectId = ctx.params.id; + const body = await ctx.request.body().value; + const orgId = ctx.state.auth.user.org_id; + + const obj = await queryOne(`SELECT id FROM objects WHERE id = $1 AND org_id = $2`, [objectId, orgId]); + if (!obj) { + throw new AppError("Objekt nicht gefunden", 404); + } + + // Code generieren falls nicht angegeben + const code = body.code || `CP-${objectId}-${Date.now().toString(36).toUpperCase()}`; + + const result = await queryOne(` + INSERT INTO object_checkpoints (object_id, name, location_description, code, code_type, sort_order, latitude, longitude) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING * + `, [ + objectId, body.name, body.location_description, code, + body.code_type || 'qr', body.sort_order || 0, body.latitude, body.longitude + ]); + + ctx.response.status = 201; + ctx.response.body = result; +}); + +// DELETE /:id/checkpoints/:checkpointId +objectsRouter.delete("/:id/checkpoints/:checkpointId", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const { id: objectId, checkpointId } = ctx.params; + const orgId = ctx.state.auth.user.org_id; + + const obj = await queryOne(`SELECT id FROM objects WHERE id = $1 AND org_id = $2`, [objectId, orgId]); + if (!obj) { + throw new AppError("Objekt nicht gefunden", 404); + } + + await execute(`DELETE FROM object_checkpoints WHERE id = $1 AND object_id = $2`, [checkpointId, objectId]); + ctx.response.body = { success: true }; +}); + +// ============================================ +// REQUIREMENTS (Qualifikations-Anforderungen) +// ============================================ + +// POST /:id/requirements - Anforderung hinzufügen +objectsRouter.post("/:id/requirements", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const objectId = ctx.params.id; + const body = await ctx.request.body().value; + const orgId = ctx.state.auth.user.org_id; + + const obj = await queryOne(`SELECT id FROM objects WHERE id = $1 AND org_id = $2`, [objectId, orgId]); + if (!obj) { + throw new AppError("Objekt nicht gefunden", 404); + } + + const result = await queryOne(` + INSERT INTO object_requirements (object_id, qualification_type_id, org_qualification_type_id, is_mandatory, notes) + VALUES ($1, $2, $3, $4, $5) + RETURNING * + `, [ + objectId, body.qualification_type_id || null, body.org_qualification_type_id || null, + body.is_mandatory ?? true, body.notes + ]); + + ctx.response.status = 201; + ctx.response.body = result; +}); + +// DELETE /:id/requirements/:requirementId +objectsRouter.delete("/:id/requirements/:requirementId", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const { id: objectId, requirementId } = ctx.params; + const orgId = ctx.state.auth.user.org_id; + + const obj = await queryOne(`SELECT id FROM objects WHERE id = $1 AND org_id = $2`, [objectId, orgId]); + if (!obj) { + throw new AppError("Objekt nicht gefunden", 404); + } + + await execute(`DELETE FROM object_requirements WHERE id = $1 AND object_id = $2`, [requirementId, objectId]); + ctx.response.body = { success: true }; +});