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:
23
src/main.ts
23
src/main.ts
@@ -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
323
src/routes/billing.ts
Normal 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
182
src/routes/customers.ts
Normal 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
205
src/routes/documents.ts
Normal 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
191
src/routes/incidents.ts
Normal 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 };
|
||||||
|
});
|
||||||
@@ -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
197
src/routes/patrols.ts
Normal 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
210
src/routes/shifts.ts
Normal 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
217
src/routes/vehicles.ts
Normal 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" };
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user