diff --git a/src/main.ts b/src/main.ts index ddb2784..24b80fa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import { modulesRouter } from "./routes/modules.ts"; import { organizationsRouter } from "./routes/organizations.ts"; import { adminRouter } from "./routes/admin.ts"; import { partnershipsRouter } from "./routes/partnerships.ts"; +import { qualificationsRouter } from "./routes/qualifications.ts"; import { errorHandler } from "./middleware/error.ts"; import { requestLogger } from "./middleware/logger.ts"; import { initDB } from "./db/postgres.ts"; @@ -50,6 +51,8 @@ app.use(adminRouter.routes()); app.use(adminRouter.allowedMethods()); app.use(partnershipsRouter.routes()); app.use(partnershipsRouter.allowedMethods()); +app.use(qualificationsRouter.routes()); +app.use(qualificationsRouter.allowedMethods()); // Health check app.use((ctx) => { diff --git a/src/routes/qualifications.ts b/src/routes/qualifications.ts new file mode 100644 index 0000000..f6fbe00 --- /dev/null +++ b/src/routes/qualifications.ts @@ -0,0 +1,491 @@ +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 qualificationsRouter = new Router({ prefix: "/api/qualifications" }); + +// ============================================ +// QUALIFICATION TYPES +// ============================================ + +// GET /types - Alle verfügbaren Qualifikationstypen +qualificationsRouter.get("/types", authMiddleware, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + + // System-Typen + const systemTypes = await query(` + SELECT id, key, name, description, category, has_expiry, + validity_months, is_required, icon, sort_order, 'system' as source + FROM qualification_types + ORDER BY sort_order, name + `); + + // Custom-Typen der Organisation + const customTypes = await query(` + SELECT id, key, name, description, category, has_expiry, + validity_months, icon, sort_order, 'custom' as source + FROM org_qualification_types + WHERE org_id = $1 + ORDER BY sort_order, name + `, [orgId]); + + ctx.response.body = { + system: systemTypes, + custom: customTypes, + categories: [ + { key: 'legal', name: 'Gesetzliche Pflicht', icon: '⚖️' }, + { key: 'weapons', name: 'Waffen', icon: '🔫' }, + { key: 'safety', name: 'Sicherheit & Erste Hilfe', icon: '🏥' }, + { key: 'driving', name: 'Führerscheine', icon: '🚗' }, + { key: 'special', name: 'Spezial-Ausbildungen', icon: '⭐' }, + { key: 'language', name: 'Sprachen', icon: '🌍' }, + { key: 'custom', name: 'Eigene', icon: '📌' } + ] + }; +}); + +// POST /types - Custom-Typ erstellen +qualificationsRouter.post("/types", authMiddleware, requireChef, async (ctx) => { + const body = await ctx.request.body().value; + const { key, name, description, has_expiry, validity_months, icon } = body; + const orgId = ctx.state.auth.user.org_id; + + if (!key || !name) { + throw new AppError("key und name sind erforderlich", 400); + } + + try { + const result = await queryOne(` + INSERT INTO org_qualification_types + (org_id, key, name, description, has_expiry, validity_months, icon) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `, [orgId, key, name, description, has_expiry ?? true, validity_months, icon ?? '📜']); + + ctx.response.status = 201; + ctx.response.body = result; + } catch (error: any) { + if (error.message?.includes('unique') || error.message?.includes('duplicate')) { + throw new AppError("Ein Typ mit diesem Key existiert bereits", 400); + } + throw error; + } +}); + +// DELETE /types/:id - Custom-Typ löschen +qualificationsRouter.delete("/types/:id", authMiddleware, requireChef, async (ctx) => { + const typeId = ctx.params.id; + const orgId = ctx.state.auth.user.org_id; + + const result = await execute(` + DELETE FROM org_qualification_types + WHERE id = $1 AND org_id = $2 + `, [typeId, orgId]); + + if (result === 0) { + throw new AppError("Typ nicht gefunden oder ist System-Typ", 404); + } + + ctx.response.body = { success: true }; +}); + +// ============================================ +// EMPLOYEE QUALIFICATIONS +// ============================================ + +// GET / - Alle Qualifikationen der Organisation +qualificationsRouter.get("/", authMiddleware, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const userId = ctx.request.url.searchParams.get("user_id"); + const status = ctx.request.url.searchParams.get("status"); + const category = ctx.request.url.searchParams.get("category"); + const expiring = ctx.request.url.searchParams.get("expiring"); + + let whereClause = "WHERE eq.org_id = $1"; + const params: any[] = [orgId]; + let paramIndex = 2; + + if (userId) { + whereClause += ` AND eq.user_id = $${paramIndex++}`; + params.push(userId); + } + + if (status) { + whereClause += ` AND eq.status = $${paramIndex++}`; + params.push(status); + } + + if (category) { + whereClause += ` AND COALESCE(qt.category, oqt.category) = $${paramIndex++}`; + params.push(category); + } + + if (expiring) { + const days = parseInt(expiring); + whereClause += ` AND eq.expiry_date IS NOT NULL AND eq.expiry_date <= CURRENT_DATE + INTERVAL '${days} days' AND eq.expiry_date >= CURRENT_DATE`; + } + + const qualifications = await query(` + SELECT + eq.id, + eq.user_id, + eq.org_id, + u.first_name, + u.last_name, + u.email, + COALESCE(qt.key, oqt.key) as qualification_key, + COALESCE(qt.name, oqt.name) as qualification_name, + COALESCE(qt.category, oqt.category) as category, + COALESCE(qt.icon, oqt.icon) as icon, + COALESCE(qt.has_expiry, oqt.has_expiry) as has_expiry, + eq.issued_date, + eq.expiry_date, + eq.issuer, + eq.certificate_number, + eq.level, + eq.document_url, + eq.document_name, + eq.status, + eq.verified_by, + eq.verified_at, + eq.notes, + eq.created_at, + CASE + WHEN eq.expiry_date IS NULL THEN 'no_expiry' + WHEN eq.expiry_date < CURRENT_DATE THEN 'expired' + WHEN eq.expiry_date < CURRENT_DATE + INTERVAL '30 days' THEN 'expiring_soon' + ELSE 'valid' + END as expiry_status, + CASE + WHEN eq.expiry_date IS NOT NULL + THEN eq.expiry_date - CURRENT_DATE + ELSE NULL + END as days_until_expiry + FROM employee_qualifications eq + JOIN users u ON eq.user_id = u.id + LEFT JOIN qualification_types qt ON eq.qualification_type_id = qt.id + LEFT JOIN org_qualification_types oqt ON eq.org_qualification_type_id = oqt.id + ${whereClause} + ORDER BY eq.expiry_date ASC NULLS LAST, u.last_name, u.first_name + `, params); + + ctx.response.body = qualifications; +}); + +// GET /expiring - Ablaufende Qualifikationen +qualificationsRouter.get("/expiring", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + const days = parseInt(ctx.request.url.searchParams.get("days") || "30"); + + const qualifications = await query(` + SELECT + eq.id, + eq.user_id, + u.first_name, + u.last_name, + u.email, + COALESCE(qt.name, oqt.name) as qualification_name, + COALESCE(qt.icon, oqt.icon) as icon, + eq.expiry_date, + eq.expiry_date - CURRENT_DATE as days_until_expiry, + CASE + WHEN eq.expiry_date < CURRENT_DATE THEN 'expired' + ELSE 'expiring_soon' + END as expiry_status + FROM employee_qualifications eq + JOIN users u ON eq.user_id = u.id + LEFT JOIN qualification_types qt ON eq.qualification_type_id = qt.id + LEFT JOIN org_qualification_types oqt ON eq.org_qualification_type_id = oqt.id + WHERE eq.org_id = $1 + AND eq.expiry_date IS NOT NULL + AND eq.expiry_date <= CURRENT_DATE + INTERVAL '${days} days' + AND eq.status = 'active' + ORDER BY eq.expiry_date ASC + `, [orgId]); + + const expired = qualifications.filter((r: any) => r.expiry_status === 'expired'); + const expiringSoon = qualifications.filter((r: any) => r.expiry_status === 'expiring_soon'); + + ctx.response.body = { + expired, + expiring_soon: expiringSoon, + total_expired: expired.length, + total_expiring: expiringSoon.length + }; +}); + +// GET /matrix - Qualifikations-Matrix +qualificationsRouter.get("/matrix", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const orgId = ctx.state.auth.user.org_id; + + // Alle aktiven Mitarbeiter + const users = await query(` + SELECT id, first_name, last_name, email + FROM users + WHERE org_id = $1 AND active = true + ORDER BY last_name, first_name + `, [orgId]); + + // Alle Qualifikationstypen + const systemTypes = await query(` + SELECT id, key, name, icon, category, 'system' as source + FROM qualification_types + ORDER BY sort_order, name + `); + + const customTypes = await query(` + SELECT id, key, name, icon, category, 'custom' as source + FROM org_qualification_types + WHERE org_id = $1 + ORDER BY sort_order, name + `, [orgId]); + + const types = [...systemTypes, ...customTypes]; + + // Alle Qualifikationen + const qualifications = await query(` + SELECT + eq.user_id, + COALESCE(eq.qualification_type_id::text, eq.org_qualification_type_id::text) as type_id, + eq.status, + eq.expiry_date, + CASE + WHEN eq.expiry_date IS NULL THEN 'no_expiry' + WHEN eq.expiry_date < CURRENT_DATE THEN 'expired' + WHEN eq.expiry_date < CURRENT_DATE + INTERVAL '30 days' THEN 'expiring_soon' + ELSE 'valid' + END as expiry_status + FROM employee_qualifications eq + WHERE eq.org_id = $1 AND eq.status = 'active' + `, [orgId]); + + // Matrix bauen + const matrix: Record> = {}; + for (const user of users) { + matrix[(user as any).id] = {}; + for (const type of types) { + matrix[(user as any).id][(type as any).id] = null; + } + } + + for (const qual of qualifications as any[]) { + if (matrix[qual.user_id] && qual.type_id) { + matrix[qual.user_id][qual.type_id] = { + status: qual.status, + expiry_date: qual.expiry_date, + expiry_status: qual.expiry_status + }; + } + } + + ctx.response.body = { users, types, matrix }; +}); + +// GET /user/:userId - Qualifikationen eines Mitarbeiters +qualificationsRouter.get("/user/:userId", authMiddleware, async (ctx) => { + const targetUserId = ctx.params.userId; + const orgId = ctx.state.auth.user.org_id; + const currentUser = ctx.state.auth.user; + + // Mitarbeiter dürfen nur ihre eigenen sehen + if (currentUser.role === 'mitarbeiter' && currentUser.id !== targetUserId) { + throw new AppError("Keine Berechtigung", 403); + } + + const qualifications = await query(` + SELECT + eq.id, + COALESCE(qt.key, oqt.key) as qualification_key, + COALESCE(qt.name, oqt.name) as qualification_name, + COALESCE(qt.category, oqt.category) as category, + COALESCE(qt.icon, oqt.icon) as icon, + eq.issued_date, + eq.expiry_date, + eq.issuer, + eq.certificate_number, + eq.level, + eq.document_url, + eq.document_name, + eq.status, + eq.notes, + CASE + WHEN eq.expiry_date IS NULL THEN 'no_expiry' + WHEN eq.expiry_date < CURRENT_DATE THEN 'expired' + WHEN eq.expiry_date < CURRENT_DATE + INTERVAL '30 days' THEN 'expiring_soon' + ELSE 'valid' + END as expiry_status + FROM employee_qualifications eq + LEFT JOIN qualification_types qt ON eq.qualification_type_id = qt.id + LEFT JOIN org_qualification_types oqt ON eq.org_qualification_type_id = oqt.id + WHERE eq.user_id = $1 AND eq.org_id = $2 + ORDER BY category, qualification_name + `, [targetUserId, orgId]); + + ctx.response.body = qualifications; +}); + +// POST / - Neue Qualifikation hinzufügen +qualificationsRouter.post("/", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const body = await ctx.request.body().value; + const { + user_id, + qualification_type_id, + org_qualification_type_id, + issued_date, + expiry_date, + issuer, + certificate_number, + level, + document_url, + document_name, + notes + } = body; + + const orgId = ctx.state.auth.user.org_id; + const verifiedBy = ctx.state.auth.user.id; + + if (!user_id || (!qualification_type_id && !org_qualification_type_id)) { + throw new AppError("user_id und qualification_type_id/org_qualification_type_id erforderlich", 400); + } + + // Prüfen ob User zur Org gehört + const targetUser = await queryOne( + `SELECT id FROM users WHERE id = $1 AND org_id = $2`, + [user_id, orgId] + ); + if (!targetUser) { + throw new AppError("Mitarbeiter nicht gefunden", 404); + } + + const result = await queryOne(` + INSERT INTO employee_qualifications ( + user_id, org_id, qualification_type_id, org_qualification_type_id, + issued_date, expiry_date, issuer, certificate_number, level, + document_url, document_name, notes, status, verified_by, verified_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'active', $13, NOW()) + RETURNING * + `, [ + user_id, orgId, qualification_type_id || null, org_qualification_type_id || null, + issued_date || null, expiry_date || null, issuer || null, certificate_number || null, + level || null, document_url || null, document_name || null, notes || null, verifiedBy + ]); + + ctx.response.status = 201; + ctx.response.body = result; +}); + +// PUT /:id - Qualifikation aktualisieren +qualificationsRouter.put("/:id", authMiddleware, requireDisponentOrHigher, async (ctx) => { + const qualId = ctx.params.id; + const body = await ctx.request.body().value; + const orgId = ctx.state.auth.user.org_id; + + const result = await queryOne(` + UPDATE employee_qualifications SET + issued_date = COALESCE($1, issued_date), + expiry_date = COALESCE($2, expiry_date), + issuer = COALESCE($3, issuer), + certificate_number = COALESCE($4, certificate_number), + level = COALESCE($5, level), + document_url = COALESCE($6, document_url), + document_name = COALESCE($7, document_name), + status = COALESCE($8, status), + notes = COALESCE($9, notes), + updated_at = NOW() + WHERE id = $10 AND org_id = $11 + RETURNING * + `, [ + body.issued_date, body.expiry_date, body.issuer, body.certificate_number, + body.level, body.document_url, body.document_name, body.status, body.notes, + qualId, orgId + ]); + + if (!result) { + throw new AppError("Qualifikation nicht gefunden", 404); + } + + ctx.response.body = result; +}); + +// DELETE /:id - Qualifikation löschen +qualificationsRouter.delete("/:id", authMiddleware, requireChef, async (ctx) => { + const qualId = ctx.params.id; + const orgId = ctx.state.auth.user.org_id; + + const result = await execute(` + DELETE FROM employee_qualifications + WHERE id = $1 AND org_id = $2 + `, [qualId, orgId]); + + if (result === 0) { + throw new AppError("Qualifikation nicht gefunden", 404); + } + + ctx.response.body = { success: true }; +}); + +// GET /check/:userId - Prüfe ob User für bestimmte Anforderungen qualifiziert ist +qualificationsRouter.get("/check/:userId", authMiddleware, async (ctx) => { + const userId = ctx.params.userId; + const orgId = ctx.state.auth.user.org_id; + const requiredStr = ctx.request.url.searchParams.get("required"); + + if (!requiredStr) { + ctx.response.body = { qualified: true, missing: [], has: [] }; + return; + } + + const required = requiredStr.split(",").map(s => s.trim()); + + const qualifications = await query(` + SELECT COALESCE(qt.key, oqt.key) as qualification_key + FROM employee_qualifications eq + LEFT JOIN qualification_types qt ON eq.qualification_type_id = qt.id + LEFT JOIN org_qualification_types oqt ON eq.org_qualification_type_id = oqt.id + WHERE eq.user_id = $1 AND eq.org_id = $2 AND eq.status = 'active' + AND (eq.expiry_date IS NULL OR eq.expiry_date >= CURRENT_DATE) + `, [userId, orgId]); + + const userQuals = new Set((qualifications as any[]).map(r => r.qualification_key)); + const missing = required.filter(r => !userQuals.has(r)); + const has = required.filter(r => userQuals.has(r)); + + ctx.response.body = { + qualified: missing.length === 0, + missing, + has + }; +}); + +// POST /bulk - Mehrere Qualifikationen auf einmal hinzufügen +qualificationsRouter.post("/bulk", authMiddleware, requireChef, async (ctx) => { + const body = await ctx.request.body().value; + const { user_ids, qualification_type_id, issued_date, expiry_date, issuer } = body; + + if (!user_ids || !Array.isArray(user_ids) || !qualification_type_id) { + throw new AppError("user_ids Array und qualification_type_id erforderlich", 400); + } + + const orgId = ctx.state.auth.user.org_id; + const verifiedBy = ctx.state.auth.user.id; + + const results = []; + + for (const userId of user_ids) { + try { + await execute(` + INSERT INTO employee_qualifications ( + user_id, org_id, qualification_type_id, issued_date, expiry_date, + issuer, status, verified_by, verified_at + ) VALUES ($1, $2, $3, $4, $5, $6, 'active', $7, NOW()) + ON CONFLICT DO NOTHING + `, [userId, orgId, qualification_type_id, issued_date || null, expiry_date || null, issuer || null, verifiedBy]); + results.push({ user_id: userId, success: true }); + } catch { + results.push({ user_id: userId, success: false }); + } + } + + ctx.response.body = { results, total: results.length }; +});