feat: Add all 8 remaining modules

Backend routes for:
- Shifts (Schichtplanung)
- Patrols (Wächterkontrolle)
- Incidents (Vorfallberichte)
- Vehicles (Fahrzeuge)
- Documents (Dokumente)
- Customers (CRM)
- Billing (Abrechnung)
- Objects (enhanced)

Database migration 004_all_modules.sql with all tables
This commit is contained in:
2026-03-12 21:09:21 +00:00
parent c0c129ea37
commit 3ad71d3afc
9 changed files with 1642 additions and 396 deletions

View File

@@ -11,6 +11,13 @@ import { adminRouter } from "./routes/admin.ts";
import { partnershipsRouter } from "./routes/partnerships.ts"; import { partnershipsRouter } from "./routes/partnerships.ts";
import { qualificationsRouter } from "./routes/qualifications.ts"; import { qualificationsRouter } from "./routes/qualifications.ts";
import { objectsRouter } from "./routes/objects.ts"; import { objectsRouter } from "./routes/objects.ts";
import { shiftsRouter } from "./routes/shifts.ts";
import { patrolsRouter } from "./routes/patrols.ts";
import { incidentsRouter } from "./routes/incidents.ts";
import { vehiclesRouter } from "./routes/vehicles.ts";
import { documentsRouter } from "./routes/documents.ts";
import { customersRouter } from "./routes/customers.ts";
import { billingRouter } from "./routes/billing.ts";
import { errorHandler } from "./middleware/error.ts"; import { errorHandler } from "./middleware/error.ts";
import { requestLogger } from "./middleware/logger.ts"; import { requestLogger } from "./middleware/logger.ts";
import { initDB } from "./db/postgres.ts"; import { initDB } from "./db/postgres.ts";
@@ -56,11 +63,25 @@ app.use(qualificationsRouter.routes());
app.use(qualificationsRouter.allowedMethods()); app.use(qualificationsRouter.allowedMethods());
app.use(objectsRouter.routes()); app.use(objectsRouter.routes());
app.use(objectsRouter.allowedMethods()); app.use(objectsRouter.allowedMethods());
app.use(shiftsRouter.routes());
app.use(shiftsRouter.allowedMethods());
app.use(patrolsRouter.routes());
app.use(patrolsRouter.allowedMethods());
app.use(incidentsRouter.routes());
app.use(incidentsRouter.allowedMethods());
app.use(vehiclesRouter.routes());
app.use(vehiclesRouter.allowedMethods());
app.use(documentsRouter.routes());
app.use(documentsRouter.allowedMethods());
app.use(customersRouter.routes());
app.use(customersRouter.allowedMethods());
app.use(billingRouter.routes());
app.use(billingRouter.allowedMethods());
// Health check // Health check
app.use((ctx) => { app.use((ctx) => {
if (ctx.request.url.pathname === "/health") { if (ctx.request.url.pathname === "/health") {
ctx.response.body = { status: "ok", service: "secu-backend", version: "1.0.0" }; ctx.response.body = { status: "ok", service: "secu-backend", version: "2.0.0" };
} }
}); });

323
src/routes/billing.ts Normal file
View File

@@ -0,0 +1,323 @@
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 billingRouter = new Router({ prefix: "/api/billing" });
// === RATES ===
// GET /rates - All billing rates
billingRouter.get("/rates", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const rates = await query(
`SELECT br.*, c.company_name as customer_name, o.name as object_name
FROM billing_rates br
LEFT JOIN customers c ON br.customer_id = c.id
LEFT JOIN objects o ON br.object_id = o.id
WHERE br.org_id = $1
ORDER BY br.is_default DESC, br.name`,
[orgId]
);
ctx.response.body = { rates };
});
// POST /rates - Create rate
billingRouter.post("/rates", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const body = await ctx.request.body.json();
const { customer_id, object_id, name, rate_type, amount, currency, valid_from, valid_until, is_default } = body;
if (!name || !amount) throw new AppError("Name und Betrag erforderlich", 400);
const result = await queryOne(
`INSERT INTO billing_rates (org_id, customer_id, object_id, name, rate_type, amount, currency, valid_from, valid_until, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[orgId, customer_id, object_id, name, rate_type || 'hourly', amount, currency || 'EUR', valid_from, valid_until, is_default || false]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// DELETE /rates/:id
billingRouter.delete("/rates/:id", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
await execute(`DELETE FROM billing_rates WHERE id = $1 AND org_id = $2`, [ctx.params.id, orgId]);
ctx.response.body = { message: "Satz gelöscht" };
});
// === INVOICES ===
// GET /invoices - All invoices
billingRouter.get("/invoices", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const status = ctx.request.url.searchParams.get("status");
const customerId = ctx.request.url.searchParams.get("customer_id");
let sql = `
SELECT i.*, c.company_name as customer_name,
(SELECT COUNT(*) FROM invoice_items WHERE invoice_id = i.id) as item_count
FROM invoices i
JOIN customers c ON i.customer_id = c.id
WHERE i.org_id = $1
`;
const params: unknown[] = [orgId];
if (status) {
params.push(status);
sql += ` AND i.status = $${params.length}`;
}
if (customerId) {
params.push(customerId);
sql += ` AND i.customer_id = $${params.length}`;
}
sql += ` ORDER BY i.invoice_date DESC`;
const invoices = await query(sql, params);
ctx.response.body = { invoices };
});
// GET /invoices/:id - Single invoice with items
billingRouter.get("/invoices/:id", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const invoice = await queryOne(
`SELECT i.*, c.company_name, c.address as customer_address, c.city, c.postal_code, c.tax_id
FROM invoices i
JOIN customers c ON i.customer_id = c.id
WHERE i.id = $1 AND i.org_id = $2`,
[ctx.params.id, orgId]
);
if (!invoice) throw new AppError("Rechnung nicht gefunden", 404);
const items = await query(
`SELECT ii.*, o.name as object_name
FROM invoice_items ii
LEFT JOIN objects o ON ii.object_id = o.id
WHERE ii.invoice_id = $1
ORDER BY ii.service_date, ii.description`,
[ctx.params.id]
);
const reminders = await query(
`SELECT * FROM payment_reminders WHERE invoice_id = $1 ORDER BY sent_at DESC`,
[ctx.params.id]
);
ctx.response.body = { ...invoice, items, reminders };
});
// POST /invoices - Create invoice
billingRouter.post("/invoices", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const userId = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
const { customer_id, invoice_date, due_date, period_start, period_end, tax_rate, notes } = body;
if (!customer_id) throw new AppError("Kunde erforderlich", 400);
// Generate invoice number
const year = new Date().getFullYear();
const countResult = await queryOne(
`SELECT COUNT(*) as count FROM invoices WHERE org_id = $1 AND EXTRACT(YEAR FROM invoice_date) = $2`,
[orgId, year]
);
const invoiceNumber = `RE-${year}-${String((Number(countResult?.count) || 0) + 1).padStart(4, '0')}`;
const result = await queryOne(
`INSERT INTO invoices (org_id, customer_id, invoice_number, invoice_date, due_date, period_start, period_end, tax_rate, notes, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`,
[orgId, customer_id, invoiceNumber, invoice_date || new Date(), due_date, period_start, period_end, tax_rate || 19, notes, userId]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id, invoice_number: invoiceNumber };
});
// POST /invoices/:id/items - Add item to invoice
billingRouter.post("/invoices/:id/items", authMiddleware, requireChef, async (ctx) => {
const body = await ctx.request.body.json();
const { description, quantity, unit, unit_price, object_id, service_date, timesheet_id } = body;
if (!description || !unit_price) throw new AppError("Beschreibung und Preis erforderlich", 400);
const total = (quantity || 1) * unit_price;
const result = await queryOne(
`INSERT INTO invoice_items (invoice_id, description, quantity, unit, unit_price, total, object_id, service_date, timesheet_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
[ctx.params.id, description, quantity || 1, unit || 'Stunden', unit_price, total, object_id, service_date, timesheet_id]
);
// Update invoice totals
await updateInvoiceTotals(ctx.params.id);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// DELETE /invoices/:id/items/:itemId - Remove item
billingRouter.delete("/invoices/:id/items/:itemId", authMiddleware, requireChef, async (ctx) => {
await execute(`DELETE FROM invoice_items WHERE id = $1 AND invoice_id = $2`, [ctx.params.itemId, ctx.params.id]);
await updateInvoiceTotals(ctx.params.id);
ctx.response.body = { message: "Position gelöscht" };
});
// Helper to update invoice totals
async function updateInvoiceTotals(invoiceId: string) {
const totals = await queryOne(
`SELECT COALESCE(SUM(total), 0) as subtotal FROM invoice_items WHERE invoice_id = $1`,
[invoiceId]
);
const invoice = await queryOne(`SELECT tax_rate FROM invoices WHERE id = $1`, [invoiceId]);
const subtotal = Number(totals?.subtotal) || 0;
const taxRate = Number(invoice?.tax_rate) || 19;
const taxAmount = subtotal * taxRate / 100;
const total = subtotal + taxAmount;
await execute(
`UPDATE invoices SET subtotal = $1, tax_amount = $2, total = $3, updated_at = NOW() WHERE id = $4`,
[subtotal, taxAmount, total, invoiceId]
);
}
// PUT /invoices/:id/send - Send invoice
billingRouter.put("/invoices/:id/send", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
await execute(
`UPDATE invoices SET status = 'sent', sent_at = NOW(), updated_at = NOW() WHERE id = $1 AND org_id = $2`,
[ctx.params.id, orgId]
);
ctx.response.body = { message: "Rechnung gesendet" };
});
// PUT /invoices/:id/pay - Mark as paid
billingRouter.put("/invoices/:id/pay", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const body = await ctx.request.body.json();
await execute(
`UPDATE invoices SET status = 'paid', paid_at = NOW(), payment_method = $1, updated_at = NOW()
WHERE id = $2 AND org_id = $3`,
[body.payment_method, ctx.params.id, orgId]
);
ctx.response.body = { message: "Rechnung als bezahlt markiert" };
});
// POST /invoices/:id/remind - Send payment reminder
billingRouter.post("/invoices/:id/remind", authMiddleware, requireChef, async (ctx) => {
const body = await ctx.request.body.json();
// Get current reminder level
const lastReminder = await queryOne(
`SELECT reminder_level FROM payment_reminders WHERE invoice_id = $1 ORDER BY sent_at DESC LIMIT 1`,
[ctx.params.id]
);
const newLevel = (lastReminder?.reminder_level || 0) + 1;
await execute(
`INSERT INTO payment_reminders (invoice_id, reminder_level, due_date, notes) VALUES ($1, $2, $3, $4)`,
[ctx.params.id, newLevel, body.due_date, body.notes]
);
// Update invoice status to overdue
await execute(`UPDATE invoices SET status = 'overdue' WHERE id = $1`, [ctx.params.id]);
ctx.response.status = 201;
ctx.response.body = { message: `Mahnung Stufe ${newLevel} versendet` };
});
// POST /invoices/generate - Generate invoice from timesheets
billingRouter.post("/invoices/generate", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const userId = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
const { customer_id, period_start, period_end, rate_id } = body;
if (!customer_id || !period_start || !period_end) {
throw new AppError("Kunde und Zeitraum erforderlich", 400);
}
// Get rate
const rate = await queryOne(`SELECT * FROM billing_rates WHERE id = $1`, [rate_id]);
const hourlyRate = rate?.amount || 0;
// Get timesheets for the period
const timesheets = await query(
`SELECT t.*, u.first_name, u.last_name, o.name as object_name, o.id as object_id
FROM timesheets t
JOIN users u ON t.user_id = u.id
LEFT JOIN orders ord ON t.order_id = ord.id
LEFT JOIN objects o ON ord.object_id = o.id
WHERE t.org_id = $1
AND t.date BETWEEN $2 AND $3
AND t.status = 'approved'
AND o.customer_id = $4`,
[orgId, period_start, period_end, customer_id]
);
if (timesheets.length === 0) {
throw new AppError("Keine genehmigten Stundenzettel im Zeitraum", 400);
}
// Create invoice
const year = new Date().getFullYear();
const countResult = await queryOne(
`SELECT COUNT(*) as count FROM invoices WHERE org_id = $1 AND EXTRACT(YEAR FROM invoice_date) = $2`,
[orgId, year]
);
const invoiceNumber = `RE-${year}-${String((Number(countResult?.count) || 0) + 1).padStart(4, '0')}`;
const invoiceResult = await queryOne(
`INSERT INTO invoices (org_id, customer_id, invoice_number, invoice_date, due_date, period_start, period_end, created_by)
VALUES ($1, $2, $3, CURRENT_DATE, CURRENT_DATE + 14, $4, $5, $6) RETURNING id`,
[orgId, customer_id, invoiceNumber, period_start, period_end, userId]
);
const invoiceId = invoiceResult?.id;
// Add items from timesheets
for (const ts of timesheets) {
const hours = Number(ts.hours_worked) || 0;
const total = hours * hourlyRate;
await execute(
`INSERT INTO invoice_items (invoice_id, description, quantity, unit, unit_price, total, object_id, service_date, timesheet_id)
VALUES ($1, $2, $3, 'Stunden', $4, $5, $6, $7, $8)`,
[invoiceId, `${ts.first_name} ${ts.last_name} - ${ts.object_name || 'Einsatz'}`, hours, hourlyRate, total, ts.object_id, ts.date, ts.id]
);
}
// Update totals
await updateInvoiceTotals(invoiceId);
ctx.response.status = 201;
ctx.response.body = { id: invoiceId, invoice_number: invoiceNumber };
});
// GET /stats - Billing stats
billingRouter.get("/stats", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const stats = await queryOne(
`SELECT
COUNT(*) FILTER (WHERE status = 'sent') as open_invoices,
COUNT(*) FILTER (WHERE status = 'overdue') as overdue_invoices,
COALESCE(SUM(total) FILTER (WHERE status = 'sent'), 0) as open_amount,
COALESCE(SUM(total) FILTER (WHERE status = 'overdue'), 0) as overdue_amount,
COALESCE(SUM(total) FILTER (WHERE status = 'paid' AND paid_at > NOW() - INTERVAL '30 days'), 0) as paid_last_30_days
FROM invoices WHERE org_id = $1`,
[orgId]
);
ctx.response.body = { stats };
});

182
src/routes/customers.ts Normal file
View File

@@ -0,0 +1,182 @@
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 customersRouter = new Router({ prefix: "/api/customers" });
// GET / - All customers
customersRouter.get("/", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const status = ctx.request.url.searchParams.get("status");
let sql = `
SELECT c.*,
(SELECT COUNT(*) FROM customer_contracts WHERE customer_id = c.id AND status = 'active') as active_contracts,
(SELECT COUNT(*) FROM objects WHERE customer_id = c.id) as object_count
FROM customers c
WHERE c.org_id = $1
`;
const params: unknown[] = [orgId];
if (status) {
params.push(status);
sql += ` AND c.status = $${params.length}`;
}
sql += ` ORDER BY c.company_name`;
const customers = await query(sql, params);
ctx.response.body = { customers };
});
// GET /:id - Single customer with details
customersRouter.get("/:id", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const customer = await queryOne(
`SELECT * FROM customers WHERE id = $1 AND org_id = $2`,
[ctx.params.id, orgId]
);
if (!customer) throw new AppError("Kunde nicht gefunden", 404);
const [contacts, contracts, objects, communications] = await Promise.all([
query(`SELECT * FROM customer_contacts WHERE customer_id = $1 ORDER BY is_primary DESC, name`, [ctx.params.id]),
query(`SELECT cc.*, o.name as object_name FROM customer_contracts cc
LEFT JOIN objects o ON cc.object_id = o.id
WHERE cc.customer_id = $1 ORDER BY cc.start_date DESC`, [ctx.params.id]),
query(`SELECT * FROM objects WHERE customer_id = $1 ORDER BY name`, [ctx.params.id]),
query(`SELECT cc.*, u.first_name, u.last_name FROM customer_communications cc
LEFT JOIN users u ON cc.user_id = u.id
WHERE cc.customer_id = $1 ORDER BY cc.occurred_at DESC LIMIT 20`, [ctx.params.id])
]);
ctx.response.body = { ...customer, contacts, contracts, objects, communications };
});
// POST / - Create customer
customersRouter.post("/", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const body = await ctx.request.body.json();
const { company_name, contact_person, email, phone, address, city, postal_code, country, tax_id, customer_number, notes } = body;
if (!company_name) throw new AppError("Firmenname erforderlich", 400);
const result = await queryOne(
`INSERT INTO customers (org_id, company_name, contact_person, email, phone, address, city, postal_code, country, tax_id, customer_number, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`,
[orgId, company_name, contact_person, email, phone, address, city, postal_code, country || 'Deutschland', tax_id, customer_number, notes]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// PUT /:id - Update customer
customersRouter.put("/:id", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const body = await ctx.request.body.json();
await execute(
`UPDATE customers SET
company_name = COALESCE($1, company_name),
contact_person = COALESCE($2, contact_person),
email = COALESCE($3, email),
phone = COALESCE($4, phone),
address = COALESCE($5, address),
city = COALESCE($6, city),
postal_code = COALESCE($7, postal_code),
tax_id = COALESCE($8, tax_id),
customer_number = COALESCE($9, customer_number),
status = COALESCE($10, status),
notes = COALESCE($11, notes),
updated_at = NOW()
WHERE id = $12 AND org_id = $13`,
[body.company_name, body.contact_person, body.email, body.phone, body.address,
body.city, body.postal_code, body.tax_id, body.customer_number, body.status, body.notes,
ctx.params.id, orgId]
);
ctx.response.body = { message: "Kunde aktualisiert" };
});
// DELETE /:id
customersRouter.delete("/:id", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
await execute(`DELETE FROM customers WHERE id = $1 AND org_id = $2`, [ctx.params.id, orgId]);
ctx.response.body = { message: "Kunde gelöscht" };
});
// === CONTACTS ===
customersRouter.post("/:id/contacts", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const body = await ctx.request.body.json();
const result = await queryOne(
`INSERT INTO customer_contacts (customer_id, name, role, email, phone, is_primary, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
[ctx.params.id, body.name, body.role, body.email, body.phone, body.is_primary || false, body.notes]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
customersRouter.delete("/:id/contacts/:contactId", authMiddleware, requireDisponentOrHigher, async (ctx) => {
await execute(`DELETE FROM customer_contacts WHERE id = $1 AND customer_id = $2`, [ctx.params.contactId, ctx.params.id]);
ctx.response.body = { message: "Kontakt gelöscht" };
});
// === CONTRACTS ===
customersRouter.post("/:id/contracts", authMiddleware, requireChef, async (ctx) => {
const body = await ctx.request.body.json();
const result = await queryOne(
`INSERT INTO customer_contracts (customer_id, object_id, contract_number, title, start_date, end_date,
monthly_value, hourly_rate, payment_terms, auto_renew, document_url, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`,
[ctx.params.id, body.object_id, body.contract_number, body.title, body.start_date, body.end_date,
body.monthly_value, body.hourly_rate, body.payment_terms || 14, body.auto_renew || false, body.document_url, body.notes]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
customersRouter.put("/:id/contracts/:contractId", authMiddleware, requireChef, async (ctx) => {
const body = await ctx.request.body.json();
await execute(
`UPDATE customer_contracts SET
title = COALESCE($1, title),
end_date = $2,
monthly_value = COALESCE($3, monthly_value),
hourly_rate = COALESCE($4, hourly_rate),
status = COALESCE($5, status),
notes = COALESCE($6, notes),
updated_at = NOW()
WHERE id = $7 AND customer_id = $8`,
[body.title, body.end_date, body.monthly_value, body.hourly_rate, body.status, body.notes,
ctx.params.contractId, ctx.params.id]
);
ctx.response.body = { message: "Vertrag aktualisiert" };
});
// === COMMUNICATIONS ===
customersRouter.post("/:id/communications", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const userId = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
const result = await queryOne(
`INSERT INTO customer_communications (customer_id, user_id, comm_type, subject, content, occurred_at)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
[ctx.params.id, userId, body.comm_type, body.subject, body.content, body.occurred_at || new Date()]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});

205
src/routes/documents.ts Normal file
View File

@@ -0,0 +1,205 @@
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 documentsRouter = new Router({ prefix: "/api/documents" });
// GET /categories - All document categories
documentsRouter.get("/categories", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const categories = await query(
`SELECT * FROM document_categories
WHERE org_id IS NULL OR org_id = $1
ORDER BY is_system DESC, name`,
[orgId]
);
ctx.response.body = { categories };
});
// POST /categories - Create category
documentsRouter.post("/categories", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const body = await ctx.request.body.json();
const { name, icon } = body;
if (!name) throw new AppError("Name erforderlich", 400);
const result = await queryOne(
`INSERT INTO document_categories (org_id, name, icon) VALUES ($1, $2, $3) RETURNING id`,
[orgId, name, icon || '📄']
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// GET / - All documents
documentsRouter.get("/", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const userId = ctx.state.auth.user.id;
const categoryId = ctx.request.url.searchParams.get("category_id");
const mandatory = ctx.request.url.searchParams.get("mandatory");
let sql = `
SELECT d.*,
dc.name as category_name, dc.icon as category_icon,
u.first_name as uploader_first, u.last_name as uploader_last,
EXISTS(SELECT 1 FROM document_acknowledgments da WHERE da.document_id = d.id AND da.user_id = $2) as acknowledged
FROM documents d
LEFT JOIN document_categories dc ON d.category_id = dc.id
LEFT JOIN users u ON d.uploaded_by = u.id
WHERE d.org_id = $1
`;
const params: unknown[] = [orgId, userId];
if (categoryId) {
params.push(categoryId);
sql += ` AND d.category_id = $${params.length}`;
}
if (mandatory === 'true') {
sql += ` AND d.is_mandatory = true`;
}
sql += ` ORDER BY d.created_at DESC`;
const documents = await query(sql, params);
ctx.response.body = { documents };
});
// GET /:id - Single document
documentsRouter.get("/:id", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const userId = ctx.state.auth.user.id;
const document = await queryOne(
`SELECT d.*,
dc.name as category_name,
u.first_name as uploader_first, u.last_name as uploader_last
FROM documents d
LEFT JOIN document_categories dc ON d.category_id = dc.id
LEFT JOIN users u ON d.uploaded_by = u.id
WHERE d.id = $1 AND d.org_id = $2`,
[ctx.params.id, orgId]
);
if (!document) throw new AppError("Dokument nicht gefunden", 404);
// Get acknowledgments
const acknowledgments = await query(
`SELECT da.*, u.first_name, u.last_name
FROM document_acknowledgments da
JOIN users u ON da.user_id = u.id
WHERE da.document_id = $1
ORDER BY da.acknowledged_at DESC`,
[ctx.params.id]
);
// Check if current user acknowledged
const myAck = acknowledgments.find((a: any) => a.user_id === userId);
ctx.response.body = { ...document, acknowledgments, acknowledged: !!myAck };
});
// POST / - Upload document
documentsRouter.post("/", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const userId = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
const { category_id, title, description, file_url, file_name, file_size,
requires_signature, is_mandatory, valid_from, valid_until } = body;
if (!title || !file_url) throw new AppError("Titel und Datei-URL erforderlich", 400);
const result = await queryOne(
`INSERT INTO documents (org_id, category_id, title, description, file_url, file_name, file_size,
requires_signature, is_mandatory, valid_from, valid_until, uploaded_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`,
[orgId, category_id, title, description, file_url, file_name, file_size,
requires_signature || false, is_mandatory || false, valid_from, valid_until, userId]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// PUT /:id - Update document
documentsRouter.put("/:id", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const body = await ctx.request.body.json();
await execute(
`UPDATE documents SET
category_id = COALESCE($1, category_id),
title = COALESCE($2, title),
description = COALESCE($3, description),
file_url = COALESCE($4, file_url),
requires_signature = COALESCE($5, requires_signature),
is_mandatory = COALESCE($6, is_mandatory),
valid_from = COALESCE($7, valid_from),
valid_until = $8,
updated_at = NOW()
WHERE id = $9 AND org_id = $10`,
[body.category_id, body.title, body.description, body.file_url,
body.requires_signature, body.is_mandatory, body.valid_from, body.valid_until,
ctx.params.id, orgId]
);
ctx.response.body = { message: "Dokument aktualisiert" };
});
// DELETE /:id
documentsRouter.delete("/:id", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
await execute(`DELETE FROM documents WHERE id = $1 AND org_id = $2`, [ctx.params.id, orgId]);
ctx.response.body = { message: "Dokument gelöscht" };
});
// POST /:id/acknowledge - Acknowledge document
documentsRouter.post("/:id/acknowledge", authMiddleware, async (ctx) => {
const userId = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
// Check if already acknowledged
const existing = await queryOne(
`SELECT id FROM document_acknowledgments WHERE document_id = $1 AND user_id = $2`,
[ctx.params.id, userId]
);
if (existing) {
ctx.response.body = { message: "Bereits bestätigt" };
return;
}
await execute(
`INSERT INTO document_acknowledgments (document_id, user_id, signature_url, ip_address)
VALUES ($1, $2, $3, $4)`,
[ctx.params.id, userId, body.signature_url, ctx.request.ip]
);
ctx.response.status = 201;
ctx.response.body = { message: "Dokument bestätigt" };
});
// GET /pending - Documents pending acknowledgment for current user
documentsRouter.get("/pending/list", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const userId = ctx.state.auth.user.id;
const documents = await query(
`SELECT d.*, dc.name as category_name, dc.icon as category_icon
FROM documents d
LEFT JOIN document_categories dc ON d.category_id = dc.id
WHERE d.org_id = $1
AND d.is_mandatory = true
AND NOT EXISTS (SELECT 1 FROM document_acknowledgments da WHERE da.document_id = d.id AND da.user_id = $2)
AND (d.valid_until IS NULL OR d.valid_until >= CURRENT_DATE)
ORDER BY d.created_at DESC`,
[orgId, userId]
);
ctx.response.body = { documents };
});

191
src/routes/incidents.ts Normal file
View File

@@ -0,0 +1,191 @@
import { Router } from "@oak/oak";
import { query, queryOne, execute } from "../db/postgres.ts";
import { authMiddleware, requireDisponentOrHigher } from "../middleware/auth.ts";
import { AppError } from "../middleware/error.ts";
export const incidentsRouter = new Router({ prefix: "/api/incidents" });
// GET /categories - All incident categories
incidentsRouter.get("/categories", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const categories = await query(
`SELECT * FROM incident_categories
WHERE org_id IS NULL OR org_id = $1
ORDER BY is_system DESC, name`,
[orgId]
);
ctx.response.body = { categories };
});
// GET / - All incidents
incidentsRouter.get("/", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const status = ctx.request.url.searchParams.get("status");
const objectId = ctx.request.url.searchParams.get("object_id");
const severity = ctx.request.url.searchParams.get("severity");
let sql = `
SELECT i.*,
ic.name as category_name, ic.icon as category_icon,
o.name as object_name,
u.first_name as reporter_first, u.last_name as reporter_last,
(SELECT COUNT(*) FROM incident_attachments WHERE incident_id = i.id) as attachment_count
FROM incidents i
LEFT JOIN incident_categories ic ON i.category_id = ic.id
LEFT JOIN objects o ON i.object_id = o.id
JOIN users u ON i.reported_by = u.id
WHERE i.org_id = $1
`;
const params: unknown[] = [orgId];
if (status) {
params.push(status);
sql += ` AND i.status = $${params.length}`;
}
if (objectId) {
params.push(objectId);
sql += ` AND i.object_id = $${params.length}`;
}
if (severity) {
params.push(severity);
sql += ` AND i.severity = $${params.length}`;
}
sql += ` ORDER BY i.occurred_at DESC`;
const incidents = await query(sql, params);
ctx.response.body = { incidents };
});
// GET /:id - Single incident
incidentsRouter.get("/:id", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const incident = await queryOne(
`SELECT i.*,
ic.name as category_name, ic.icon as category_icon,
o.name as object_name, o.address as object_address,
u.first_name as reporter_first, u.last_name as reporter_last,
r.first_name as resolver_first, r.last_name as resolver_last
FROM incidents i
LEFT JOIN incident_categories ic ON i.category_id = ic.id
LEFT JOIN objects o ON i.object_id = o.id
JOIN users u ON i.reported_by = u.id
LEFT JOIN users r ON i.resolved_by = r.id
WHERE i.id = $1 AND i.org_id = $2`,
[ctx.params.id, orgId]
);
if (!incident) throw new AppError("Vorfall nicht gefunden", 404);
const attachments = await query(
`SELECT * FROM incident_attachments WHERE incident_id = $1 ORDER BY created_at`,
[ctx.params.id]
);
ctx.response.body = { ...incident, attachments };
});
// POST / - Create incident
incidentsRouter.post("/", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const userId = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
const {
object_id, category_id, title, description, severity,
occurred_at, location_description, latitude, longitude,
persons_involved, actions_taken, police_notified, police_reference
} = body;
if (!title) throw new AppError("Titel erforderlich", 400);
const result = await queryOne(
`INSERT INTO incidents (
org_id, object_id, category_id, reported_by, title, description, severity,
occurred_at, location_description, latitude, longitude,
persons_involved, actions_taken, police_notified, police_reference
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING id`,
[orgId, object_id, category_id, userId, title, description, severity || 2,
occurred_at || new Date(), location_description, latitude, longitude,
persons_involved, actions_taken, police_notified || false, police_reference]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// PUT /:id - Update incident
incidentsRouter.put("/:id", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const userId = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
// Check if resolving
let resolveFields = '';
const params: unknown[] = [];
if (body.status === 'resolved' || body.status === 'closed') {
resolveFields = `, resolved_at = NOW(), resolved_by = $${params.length + 1}`;
params.push(userId);
}
await execute(
`UPDATE incidents SET
title = COALESCE($1, title),
description = COALESCE($2, description),
severity = COALESCE($3, severity),
status = COALESCE($4, status),
actions_taken = COALESCE($5, actions_taken),
police_notified = COALESCE($6, police_notified),
police_reference = COALESCE($7, police_reference),
resolution_notes = COALESCE($8, resolution_notes),
updated_at = NOW()
${resolveFields}
WHERE id = $${params.length + 9} AND org_id = $${params.length + 10}`,
[body.title, body.description, body.severity, body.status,
body.actions_taken, body.police_notified, body.police_reference,
body.resolution_notes, ...params, ctx.params.id, orgId]
);
ctx.response.body = { message: "Vorfall aktualisiert" };
});
// POST /:id/attachments - Add attachment
incidentsRouter.post("/:id/attachments", authMiddleware, async (ctx) => {
const userId = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
const { file_url, file_name, file_type } = body;
if (!file_url) throw new AppError("Datei-URL erforderlich", 400);
const result = await queryOne(
`INSERT INTO incident_attachments (incident_id, file_url, file_name, file_type, uploaded_by)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
[ctx.params.id, file_url, file_name, file_type, userId]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// GET /stats - Dashboard stats
incidentsRouter.get("/stats/summary", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const stats = await queryOne(
`SELECT
COUNT(*) FILTER (WHERE status = 'open') as open_count,
COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress_count,
COUNT(*) FILTER (WHERE status = 'resolved') as resolved_count,
COUNT(*) FILTER (WHERE severity >= 4) as high_severity_count,
COUNT(*) FILTER (WHERE occurred_at > NOW() - INTERVAL '7 days') as last_7_days
FROM incidents WHERE org_id = $1`,
[orgId]
);
ctx.response.body = { stats };
});

View File

@@ -5,436 +5,136 @@ import { AppError } from "../middleware/error.ts";
export const objectsRouter = new Router({ prefix: "/api/objects" }); export const objectsRouter = new Router({ prefix: "/api/objects" });
// ============================================ // GET / - All objects
// OBJECTS (Wachobjekte)
// ============================================
// GET / - Alle Objekte der Organisation
objectsRouter.get("/", authMiddleware, async (ctx) => { objectsRouter.get("/", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id; 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 objects = await query(
const params: any[] = [orgId]; `SELECT o.*, c.company_name as customer_name,
let paramIndex = 2; (SELECT COUNT(*) FROM orders WHERE object_id = o.id) as order_count
FROM objects o
LEFT JOIN customers c ON o.customer_id = c.id
WHERE o.org_id = $1
ORDER BY o.name`,
[orgId]
);
if (status !== "all") { ctx.response.body = { objects };
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 // GET /:id - Single object with details
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) => { objectsRouter.get("/:id", authMiddleware, async (ctx) => {
const objectId = ctx.params.id;
const orgId = ctx.state.auth.user.org_id; const orgId = ctx.state.auth.user.org_id;
const objectId = ctx.params.id;
const obj = await queryOne(` const obj = await queryOne(
SELECT * FROM objects WHERE id = $1 AND org_id = $2 `SELECT * FROM objects WHERE id = $1 AND org_id = $2`,
`, [objectId, orgId]); [objectId, orgId]
);
if (!obj) { if (!obj) throw new AppError("Objekt nicht gefunden", 404);
throw new AppError("Objekt nicht gefunden", 404);
}
// Kontakte laden const [contacts, instructions, documents] = await Promise.all([
const contacts = await query(` query(`SELECT * FROM object_contacts WHERE object_id = $1 ORDER BY is_emergency_contact DESC, name`, [objectId]),
SELECT * FROM object_contacts WHERE object_id = $1 ORDER BY is_primary DESC, is_emergency DESC, name query(`SELECT * FROM object_instructions WHERE object_id = $1 ORDER BY priority DESC`, [objectId]),
`, [objectId]); query(`SELECT * FROM object_documents WHERE object_id = $1 ORDER BY created_at DESC`, [objectId])
]);
// Dienstanweisungen laden ctx.response.body = { ...obj, contacts, instructions, documents };
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 // POST / - Create object
objectsRouter.post("/", authMiddleware, requireDisponentOrHigher, async (ctx) => { objectsRouter.post("/", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const body = await ctx.request.body().value;
const orgId = ctx.state.auth.user.org_id; const orgId = ctx.state.auth.user.org_id;
const userId = ctx.state.auth.user.id; const body = await ctx.request.body.json();
const { const { name, address, type, size_sqm, floors, access_info, emergency_plan, key_info, customer_id, notes } = body;
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) { if (!name) throw new AppError("Name erforderlich", 400);
throw new AppError("Name ist erforderlich", 400);
}
const result = await queryOne(` const result = await queryOne(
INSERT INTO objects ( `INSERT INTO objects (org_id, name, address, type, size_sqm, floors, access_info, emergency_plan, key_info, customer_id, notes)
org_id, name, short_name, object_number, object_type, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
street, house_number, postal_code, city, country, RETURNING id`,
latitude, longitude, phone, email, [orgId, name, address, type, size_sqm, floors, access_info, emergency_plan, key_info, customer_id, notes]
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.status = 201;
ctx.response.body = result; ctx.response.body = { id: result?.id };
}); });
// PUT /:id - Objekt aktualisieren // PUT /:id - Update object
objectsRouter.put("/:id", authMiddleware, requireDisponentOrHigher, async (ctx) => { 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 orgId = ctx.state.auth.user.org_id;
const objectId = ctx.params.id;
const body = await ctx.request.body.json();
const result = await queryOne(` await execute(
UPDATE objects SET `UPDATE objects SET
name = COALESCE($1, name), name = COALESCE($1, name),
short_name = COALESCE($2, short_name), address = COALESCE($2, address),
object_number = COALESCE($3, object_number), type = COALESCE($3, type),
object_type = COALESCE($4, object_type), size_sqm = COALESCE($4, size_sqm),
street = COALESCE($5, street), floors = COALESCE($5, floors),
house_number = COALESCE($6, house_number), access_info = COALESCE($6, access_info),
postal_code = COALESCE($7, postal_code), emergency_plan = COALESCE($7, emergency_plan),
city = COALESCE($8, city), key_info = COALESCE($8, key_info),
country = COALESCE($9, country), customer_id = $9,
latitude = COALESCE($10, latitude), notes = COALESCE($10, notes),
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() updated_at = NOW()
WHERE id = $22 AND org_id = $23 WHERE id = $11 AND org_id = $12`,
RETURNING * [body.name, body.address, body.type, body.size_sqm, body.floors,
`, [ body.access_info, body.emergency_plan, body.key_info, body.customer_id,
body.name, body.short_name, body.object_number, body.object_type, body.notes, objectId, orgId]
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) { ctx.response.body = { message: "Objekt aktualisiert" };
throw new AppError("Objekt nicht gefunden", 404);
}
ctx.response.body = result;
}); });
// DELETE /:id - Objekt löschen (oder archivieren) // DELETE /:id
objectsRouter.delete("/:id", authMiddleware, requireChef, async (ctx) => { objectsRouter.delete("/:id", authMiddleware, requireChef, async (ctx) => {
const objectId = ctx.params.id;
const orgId = ctx.state.auth.user.org_id; const orgId = ctx.state.auth.user.org_id;
const permanent = ctx.request.url.searchParams.get("permanent") === "true"; await execute(`DELETE FROM objects WHERE id = $1 AND org_id = $2`, [ctx.params.id, orgId]);
ctx.response.body = { message: "Objekt gelöscht" };
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 ===
// CONTACTS
// ============================================
// POST /:id/contacts - Kontakt hinzufügen
objectsRouter.post("/:id/contacts", authMiddleware, requireDisponentOrHigher, async (ctx) => { objectsRouter.post("/:id/contacts", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const objectId = ctx.params.id; const objectId = ctx.params.id;
const body = await ctx.request.body().value; const body = await ctx.request.body.json();
const orgId = ctx.state.auth.user.org_id;
// Prüfen ob Objekt existiert const result = await queryOne(
const obj = await queryOne(`SELECT id FROM objects WHERE id = $1 AND org_id = $2`, [objectId, orgId]); `INSERT INTO object_contacts (object_id, name, role, phone, email, is_emergency_contact, notes)
if (!obj) { VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
throw new AppError("Objekt nicht gefunden", 404); [objectId, body.name, body.role, body.phone, body.email, body.is_emergency_contact, body.notes]
} );
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.status = 201;
ctx.response.body = result; ctx.response.body = { id: result?.id };
}); });
// DELETE /:id/contacts/:contactId - Kontakt löschen
objectsRouter.delete("/:id/contacts/:contactId", authMiddleware, requireDisponentOrHigher, async (ctx) => { objectsRouter.delete("/:id/contacts/:contactId", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const { id: objectId, contactId } = ctx.params; await execute(`DELETE FROM object_contacts WHERE id = $1 AND object_id = $2`, [ctx.params.contactId, ctx.params.id]);
const orgId = ctx.state.auth.user.org_id; ctx.response.body = { message: "Kontakt gelöscht" };
// 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 ===
// INSTRUCTIONS (Dienstanweisungen)
// ============================================
// POST /:id/instructions - Dienstanweisung hinzufügen
objectsRouter.post("/:id/instructions", authMiddleware, requireDisponentOrHigher, async (ctx) => { objectsRouter.post("/:id/instructions", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const objectId = ctx.params.id; 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 userId = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
const obj = await queryOne(`SELECT id FROM objects WHERE id = $1 AND org_id = $2`, [objectId, orgId]); const result = await queryOne(
if (!obj) { `INSERT INTO object_instructions (object_id, title, content, document_url, priority, valid_from, valid_until, created_by)
throw new AppError("Objekt nicht gefunden", 404); VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
} [objectId, body.title, body.content, body.document_url, body.priority || 0, body.valid_from, body.valid_until, userId]
);
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.status = 201;
ctx.response.body = result; ctx.response.body = { id: result?.id };
}); });
// PUT /:id/instructions/:instructionId - Dienstanweisung aktualisieren objectsRouter.delete("/:id/instructions/:instrId", authMiddleware, requireDisponentOrHigher, async (ctx) => {
objectsRouter.put("/:id/instructions/:instructionId", authMiddleware, requireDisponentOrHigher, async (ctx) => { await execute(`DELETE FROM object_instructions WHERE id = $1 AND object_id = $2`, [ctx.params.instrId, ctx.params.id]);
const { id: objectId, instructionId } = ctx.params; ctx.response.body = { message: "Anweisung gelöscht" };
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 };
}); });

197
src/routes/patrols.ts Normal file
View File

@@ -0,0 +1,197 @@
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 patrolsRouter = new Router({ prefix: "/api/patrols" });
// === CHECKPOINTS ===
// GET /checkpoints - All checkpoints
patrolsRouter.get("/checkpoints", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const objectId = ctx.request.url.searchParams.get("object_id");
let sql = `SELECT c.*, o.name as object_name
FROM checkpoints c
JOIN objects o ON c.object_id = o.id
WHERE c.org_id = $1`;
const params: unknown[] = [orgId];
if (objectId) {
params.push(objectId);
sql += ` AND c.object_id = $${params.length}`;
}
sql += ` ORDER BY c.object_id, c.sort_order`;
const checkpoints = await query(sql, params);
ctx.response.body = { checkpoints };
});
// POST /checkpoints - Create checkpoint
patrolsRouter.post("/checkpoints", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const body = await ctx.request.body.json();
const { object_id, name, location_description, latitude, longitude, checkpoint_type, sort_order } = body;
if (!object_id || !name) throw new AppError("Objekt und Name erforderlich", 400);
// Generate unique code
const code = `CP-${Date.now()}-${Math.random().toString(36).substr(2, 6).toUpperCase()}`;
const result = await queryOne(
`INSERT INTO checkpoints (org_id, object_id, name, code, location_description, latitude, longitude, checkpoint_type, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, code`,
[orgId, object_id, name, code, location_description, latitude, longitude, checkpoint_type || 'qr', sort_order || 0]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id, code: result?.code };
});
// DELETE /checkpoints/:id
patrolsRouter.delete("/checkpoints/:id", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
await execute(`DELETE FROM checkpoints WHERE id = $1 AND org_id = $2`, [ctx.params.id, orgId]);
ctx.response.body = { message: "Checkpoint gelöscht" };
});
// === ROUTES ===
// GET /routes - All patrol routes
patrolsRouter.get("/routes", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const routes = await query(
`SELECT r.*, o.name as object_name,
array_length(r.checkpoint_ids, 1) as checkpoint_count
FROM patrol_routes r
JOIN objects o ON r.object_id = o.id
WHERE r.org_id = $1
ORDER BY o.name, r.name`,
[orgId]
);
ctx.response.body = { routes };
});
// POST /routes - Create route
patrolsRouter.post("/routes", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const body = await ctx.request.body.json();
const { object_id, name, checkpoint_ids, time_limit_minutes, interval_minutes, start_time, end_time } = body;
if (!object_id || !name) throw new AppError("Objekt und Name erforderlich", 400);
const result = await queryOne(
`INSERT INTO patrol_routes (org_id, object_id, name, checkpoint_ids, time_limit_minutes, interval_minutes, start_time, end_time)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
[orgId, object_id, name, checkpoint_ids || [], time_limit_minutes, interval_minutes, start_time, end_time]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// PUT /routes/:id
patrolsRouter.put("/routes/:id", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const body = await ctx.request.body.json();
await execute(
`UPDATE patrol_routes SET
name = COALESCE($1, name),
checkpoint_ids = COALESCE($2, checkpoint_ids),
time_limit_minutes = COALESCE($3, time_limit_minutes),
interval_minutes = COALESCE($4, interval_minutes),
start_time = COALESCE($5, start_time),
end_time = COALESCE($6, end_time),
is_active = COALESCE($7, is_active)
WHERE id = $8 AND org_id = $9`,
[body.name, body.checkpoint_ids, body.time_limit_minutes, body.interval_minutes,
body.start_time, body.end_time, body.is_active, ctx.params.id, orgId]
);
ctx.response.body = { message: "Route aktualisiert" };
});
// === SCANNING / LOGS ===
// POST /scan - Scan a checkpoint (for employees)
patrolsRouter.post("/scan", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const userId = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
const { code, latitude, longitude, notes, photo_url, route_id } = body;
if (!code) throw new AppError("Checkpoint-Code erforderlich", 400);
// Find checkpoint by code
const checkpoint = await queryOne(
`SELECT id FROM checkpoints WHERE code = $1 AND org_id = $2 AND is_active = true`,
[code, orgId]
);
if (!checkpoint) throw new AppError("Checkpoint nicht gefunden oder inaktiv", 404);
const result = await queryOne(
`INSERT INTO patrol_logs (org_id, route_id, user_id, checkpoint_id, latitude, longitude, notes, photo_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, scanned_at`,
[orgId, route_id, userId, checkpoint.id, latitude, longitude, notes, photo_url]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id, scanned_at: result?.scanned_at };
});
// GET /logs - Get patrol logs
patrolsRouter.get("/logs", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const objectId = ctx.request.url.searchParams.get("object_id");
const userId = ctx.request.url.searchParams.get("user_id");
const date = ctx.request.url.searchParams.get("date") || new Date().toISOString().split('T')[0];
let sql = `
SELECT pl.*, c.name as checkpoint_name, c.location_description,
u.first_name, u.last_name, o.name as object_name
FROM patrol_logs pl
JOIN checkpoints c ON pl.checkpoint_id = c.id
JOIN users u ON pl.user_id = u.id
JOIN objects o ON c.object_id = o.id
WHERE pl.org_id = $1 AND DATE(pl.scanned_at) = $2
`;
const params: unknown[] = [orgId, date];
if (objectId) {
params.push(objectId);
sql += ` AND c.object_id = $${params.length}`;
}
if (userId) {
params.push(userId);
sql += ` AND pl.user_id = $${params.length}`;
}
sql += ` ORDER BY pl.scanned_at DESC`;
const logs = await query(sql, params);
ctx.response.body = { logs };
});
// GET /my-routes - Routes for current employee's objects
patrolsRouter.get("/my-routes", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const routes = await query(
`SELECT r.*, o.name as object_name,
(SELECT json_agg(json_build_object('id', c.id, 'name', c.name, 'code', c.code, 'location', c.location_description))
FROM checkpoints c WHERE c.id = ANY(r.checkpoint_ids)) as checkpoints
FROM patrol_routes r
JOIN objects o ON r.object_id = o.id
WHERE r.org_id = $1 AND r.is_active = true
ORDER BY r.name`,
[orgId]
);
ctx.response.body = { routes };
});

210
src/routes/shifts.ts Normal file
View File

@@ -0,0 +1,210 @@
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 shiftsRouter = new Router({ prefix: "/api/shifts" });
// === SHIFT DEFINITIONS ===
// GET /definitions - All shift definitions
shiftsRouter.get("/definitions", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const shifts = await query(
`SELECT * FROM shifts WHERE org_id = $1 ORDER BY start_time`,
[orgId]
);
ctx.response.body = { shifts };
});
// POST /definitions - Create shift definition
shiftsRouter.post("/definitions", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const body = await ctx.request.body.json();
const { name, start_time, end_time, break_minutes, color, is_night_shift } = body;
if (!name || !start_time || !end_time) throw new AppError("Name, Start- und Endzeit erforderlich", 400);
const result = await queryOne(
`INSERT INTO shifts (org_id, name, start_time, end_time, break_minutes, color, is_night_shift)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
[orgId, name, start_time, end_time, break_minutes || 0, color || '#3B82F6', is_night_shift || false]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// DELETE /definitions/:id
shiftsRouter.delete("/definitions/:id", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
await execute(`DELETE FROM shifts WHERE id = $1 AND org_id = $2`, [ctx.params.id, orgId]);
ctx.response.body = { message: "Schicht gelöscht" };
});
// === ASSIGNMENTS ===
// GET /assignments - Get assignments for date range
shiftsRouter.get("/assignments", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const userId = ctx.state.auth.user.id;
const role = ctx.state.auth.user.role;
const startDate = ctx.request.url.searchParams.get("start") || new Date().toISOString().split('T')[0];
const endDate = ctx.request.url.searchParams.get("end") || startDate;
let sql = `
SELECT sa.*, s.name as shift_name, s.start_time, s.end_time, s.color,
u.first_name, u.last_name, o.name as object_name
FROM shift_assignments sa
JOIN shifts s ON sa.shift_id = s.id
JOIN users u ON sa.user_id = u.id
LEFT JOIN objects o ON sa.object_id = o.id
WHERE sa.org_id = $1 AND sa.date BETWEEN $2 AND $3
`;
// Mitarbeiter sees only their own
if (role === 'mitarbeiter') {
sql += ` AND sa.user_id = ${userId}`;
}
sql += ` ORDER BY sa.date, s.start_time`;
const assignments = await query(sql, [orgId, startDate, endDate]);
ctx.response.body = { assignments };
});
// POST /assignments - Create assignment
shiftsRouter.post("/assignments", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const createdBy = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
const { shift_id, user_id, object_id, date, notes } = body;
if (!shift_id || !user_id || !date) throw new AppError("Schicht, Mitarbeiter und Datum erforderlich", 400);
const result = await queryOne(
`INSERT INTO shift_assignments (org_id, shift_id, user_id, object_id, date, notes, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
[orgId, shift_id, user_id, object_id, date, notes, createdBy]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// PUT /assignments/:id - Update assignment
shiftsRouter.put("/assignments/:id", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const body = await ctx.request.body.json();
await execute(
`UPDATE shift_assignments SET
shift_id = COALESCE($1, shift_id),
user_id = COALESCE($2, user_id),
object_id = $3,
status = COALESCE($4, status),
notes = COALESCE($5, notes),
updated_at = NOW()
WHERE id = $6 AND org_id = $7`,
[body.shift_id, body.user_id, body.object_id, body.status, body.notes, ctx.params.id, orgId]
);
ctx.response.body = { message: "Zuweisung aktualisiert" };
});
// DELETE /assignments/:id
shiftsRouter.delete("/assignments/:id", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
await execute(`DELETE FROM shift_assignments WHERE id = $1 AND org_id = $2`, [ctx.params.id, orgId]);
ctx.response.body = { message: "Zuweisung gelöscht" };
});
// === SWAP REQUESTS ===
// POST /swaps - Request swap
shiftsRouter.post("/swaps", authMiddleware, async (ctx) => {
const userId = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
const { assignment_id, swap_with_user_id, reason } = body;
const result = await queryOne(
`INSERT INTO shift_swaps (original_assignment_id, requested_by, swap_with_user_id, reason)
VALUES ($1, $2, $3, $4) RETURNING id`,
[assignment_id, userId, swap_with_user_id, reason]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// GET /swaps - Get pending swaps
shiftsRouter.get("/swaps", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const swaps = await query(
`SELECT ss.*, sa.date, s.name as shift_name,
u1.first_name as requester_first, u1.last_name as requester_last,
u2.first_name as swap_first, u2.last_name as swap_last
FROM shift_swaps ss
JOIN shift_assignments sa ON ss.original_assignment_id = sa.id
JOIN shifts s ON sa.shift_id = s.id
JOIN users u1 ON ss.requested_by = u1.id
LEFT JOIN users u2 ON ss.swap_with_user_id = u2.id
WHERE sa.org_id = $1 AND ss.status = 'pending'
ORDER BY ss.created_at DESC`,
[orgId]
);
ctx.response.body = { swaps };
});
// PUT /swaps/:id - Approve/reject swap
shiftsRouter.put("/swaps/:id", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const body = await ctx.request.body.json();
const { status } = body;
if (!['approved', 'rejected'].includes(status)) throw new AppError("Ungültiger Status", 400);
await execute(
`UPDATE shift_swaps SET status = $1, responded_at = NOW() WHERE id = $2`,
[status, ctx.params.id]
);
// If approved, actually swap the users
if (status === 'approved') {
const swap = await queryOne(
`SELECT original_assignment_id, swap_with_user_id FROM shift_swaps WHERE id = $1`,
[ctx.params.id]
);
if (swap?.swap_with_user_id) {
await execute(
`UPDATE shift_assignments SET user_id = $1 WHERE id = $2`,
[swap.swap_with_user_id, swap.original_assignment_id]
);
}
}
ctx.response.body = { message: status === 'approved' ? "Tausch genehmigt" : "Tausch abgelehnt" };
});
// GET /my - Get my shifts (for employees)
shiftsRouter.get("/my", authMiddleware, async (ctx) => {
const userId = ctx.state.auth.user.id;
const startDate = ctx.request.url.searchParams.get("start") || new Date().toISOString().split('T')[0];
const shifts = await query(
`SELECT sa.*, s.name as shift_name, s.start_time, s.end_time, s.color,
o.name as object_name, o.address as object_address
FROM shift_assignments sa
JOIN shifts s ON sa.shift_id = s.id
LEFT JOIN objects o ON sa.object_id = o.id
WHERE sa.user_id = $1 AND sa.date >= $2
ORDER BY sa.date, s.start_time
LIMIT 30`,
[userId, startDate]
);
ctx.response.body = { shifts };
});

217
src/routes/vehicles.ts Normal file
View File

@@ -0,0 +1,217 @@
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 vehiclesRouter = new Router({ prefix: "/api/vehicles" });
// GET / - All vehicles
vehiclesRouter.get("/", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const vehicles = await query(
`SELECT v.*,
(SELECT COUNT(*) FROM vehicle_bookings WHERE vehicle_id = v.id AND status = 'active') as is_booked
FROM vehicles v
WHERE v.org_id = $1
ORDER BY v.license_plate`,
[orgId]
);
ctx.response.body = { vehicles };
});
// GET /:id - Single vehicle with details
vehiclesRouter.get("/:id", authMiddleware, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const vehicle = await queryOne(
`SELECT * FROM vehicles WHERE id = $1 AND org_id = $2`,
[ctx.params.id, orgId]
);
if (!vehicle) throw new AppError("Fahrzeug nicht gefunden", 404);
const [bookings, logs, maintenance] = await Promise.all([
query(`SELECT vb.*, u.first_name, u.last_name
FROM vehicle_bookings vb JOIN users u ON vb.user_id = u.id
WHERE vb.vehicle_id = $1 ORDER BY vb.start_time DESC LIMIT 10`, [ctx.params.id]),
query(`SELECT vl.*, u.first_name, u.last_name
FROM vehicle_logs vl LEFT JOIN users u ON vl.user_id = u.id
WHERE vl.vehicle_id = $1 ORDER BY vl.date DESC LIMIT 20`, [ctx.params.id]),
query(`SELECT * FROM vehicle_maintenance WHERE vehicle_id = $1 ORDER BY scheduled_date DESC LIMIT 10`, [ctx.params.id])
]);
ctx.response.body = { ...vehicle, bookings, logs, maintenance };
});
// POST / - Create vehicle
vehiclesRouter.post("/", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const body = await ctx.request.body.json();
const { license_plate, brand, model, year, color, vin, fuel_type, current_mileage, insurance_expires, tuev_expires, notes } = body;
if (!license_plate) throw new AppError("Kennzeichen erforderlich", 400);
const result = await queryOne(
`INSERT INTO vehicles (org_id, license_plate, brand, model, year, color, vin, fuel_type, current_mileage, insurance_expires, tuev_expires, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id`,
[orgId, license_plate, brand, model, year, color, vin, fuel_type || 'diesel', current_mileage || 0, insurance_expires, tuev_expires, notes]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// PUT /:id - Update vehicle
vehiclesRouter.put("/:id", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
const body = await ctx.request.body.json();
await execute(
`UPDATE vehicles SET
license_plate = COALESCE($1, license_plate),
brand = COALESCE($2, brand),
model = COALESCE($3, model),
year = COALESCE($4, year),
color = COALESCE($5, color),
fuel_type = COALESCE($6, fuel_type),
current_mileage = COALESCE($7, current_mileage),
status = COALESCE($8, status),
insurance_expires = COALESCE($9, insurance_expires),
tuev_expires = COALESCE($10, tuev_expires),
notes = COALESCE($11, notes),
updated_at = NOW()
WHERE id = $12 AND org_id = $13`,
[body.license_plate, body.brand, body.model, body.year, body.color, body.fuel_type,
body.current_mileage, body.status, body.insurance_expires, body.tuev_expires, body.notes,
ctx.params.id, orgId]
);
ctx.response.body = { message: "Fahrzeug aktualisiert" };
});
// DELETE /:id
vehiclesRouter.delete("/:id", authMiddleware, requireChef, async (ctx) => {
const orgId = ctx.state.auth.user.org_id;
await execute(`DELETE FROM vehicles WHERE id = $1 AND org_id = $2`, [ctx.params.id, orgId]);
ctx.response.body = { message: "Fahrzeug gelöscht" };
});
// === BOOKINGS ===
// POST /bookings - Book vehicle
vehiclesRouter.post("/bookings", authMiddleware, async (ctx) => {
const userId = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
const { vehicle_id, start_time, end_time, purpose } = body;
if (!vehicle_id || !start_time) throw new AppError("Fahrzeug und Startzeit erforderlich", 400);
// Check availability
const conflict = await queryOne(
`SELECT id FROM vehicle_bookings
WHERE vehicle_id = $1 AND status IN ('booked', 'active')
AND start_time < $3 AND (end_time IS NULL OR end_time > $2)`,
[vehicle_id, start_time, end_time || '2099-12-31']
);
if (conflict) throw new AppError("Fahrzeug in diesem Zeitraum nicht verfügbar", 409);
const result = await queryOne(
`INSERT INTO vehicle_bookings (vehicle_id, user_id, start_time, end_time, purpose)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
[vehicle_id, userId, start_time, end_time, purpose]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// PUT /bookings/:id/start - Start trip
vehiclesRouter.put("/bookings/:id/start", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json();
await execute(
`UPDATE vehicle_bookings SET status = 'active', start_mileage = $1 WHERE id = $2`,
[body.mileage, ctx.params.id]
);
ctx.response.body = { message: "Fahrt gestartet" };
});
// PUT /bookings/:id/end - End trip
vehiclesRouter.put("/bookings/:id/end", authMiddleware, async (ctx) => {
const body = await ctx.request.body.json();
await execute(
`UPDATE vehicle_bookings SET status = 'completed', end_time = NOW(), end_mileage = $1 WHERE id = $2`,
[body.mileage, ctx.params.id]
);
// Update vehicle mileage
const booking = await queryOne(`SELECT vehicle_id FROM vehicle_bookings WHERE id = $1`, [ctx.params.id]);
if (booking && body.mileage) {
await execute(`UPDATE vehicles SET current_mileage = $1 WHERE id = $2`, [body.mileage, booking.vehicle_id]);
}
ctx.response.body = { message: "Fahrt beendet" };
});
// === LOGS ===
// POST /logs - Add log entry (fuel, damage, etc.)
vehiclesRouter.post("/logs", authMiddleware, async (ctx) => {
const userId = ctx.state.auth.user.id;
const body = await ctx.request.body.json();
const { vehicle_id, log_type, date, mileage, fuel_liters, fuel_cost, description, photo_url } = body;
if (!vehicle_id || !log_type) throw new AppError("Fahrzeug und Typ erforderlich", 400);
const result = await queryOne(
`INSERT INTO vehicle_logs (vehicle_id, user_id, log_type, date, mileage, fuel_liters, fuel_cost, description, photo_url)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`,
[vehicle_id, userId, log_type, date || new Date(), mileage, fuel_liters, fuel_cost, description, photo_url]
);
// Update mileage if provided
if (mileage) {
await execute(`UPDATE vehicles SET current_mileage = GREATEST(current_mileage, $1) WHERE id = $2`, [mileage, vehicle_id]);
}
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// === MAINTENANCE ===
// POST /maintenance - Schedule maintenance
vehiclesRouter.post("/maintenance", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const body = await ctx.request.body.json();
const { vehicle_id, maintenance_type, scheduled_date, notes, next_due_date, next_due_mileage } = body;
if (!vehicle_id || !maintenance_type) throw new AppError("Fahrzeug und Wartungstyp erforderlich", 400);
const result = await queryOne(
`INSERT INTO vehicle_maintenance (vehicle_id, maintenance_type, scheduled_date, notes, next_due_date, next_due_mileage)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`,
[vehicle_id, maintenance_type, scheduled_date, notes, next_due_date, next_due_mileage]
);
ctx.response.status = 201;
ctx.response.body = { id: result?.id };
});
// PUT /maintenance/:id/complete - Complete maintenance
vehiclesRouter.put("/maintenance/:id/complete", authMiddleware, requireDisponentOrHigher, async (ctx) => {
const body = await ctx.request.body.json();
await execute(
`UPDATE vehicle_maintenance SET
completed_date = $1, mileage_at_service = $2, cost = $3, provider = $4
WHERE id = $5`,
[body.completed_date || new Date(), body.mileage, body.cost, body.provider, ctx.params.id]
);
ctx.response.body = { message: "Wartung abgeschlossen" };
});