Add Qualifications API routes

This commit is contained in:
2026-03-12 19:47:14 +00:00
parent b5aa228183
commit 3ebf2dd461
2 changed files with 494 additions and 0 deletions

View File

@@ -9,6 +9,7 @@ import { modulesRouter } from "./routes/modules.ts";
import { organizationsRouter } from "./routes/organizations.ts"; import { organizationsRouter } from "./routes/organizations.ts";
import { adminRouter } from "./routes/admin.ts"; 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 { 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";
@@ -50,6 +51,8 @@ app.use(adminRouter.routes());
app.use(adminRouter.allowedMethods()); app.use(adminRouter.allowedMethods());
app.use(partnershipsRouter.routes()); app.use(partnershipsRouter.routes());
app.use(partnershipsRouter.allowedMethods()); app.use(partnershipsRouter.allowedMethods());
app.use(qualificationsRouter.routes());
app.use(qualificationsRouter.allowedMethods());
// Health check // Health check
app.use((ctx) => { app.use((ctx) => {

View File

@@ -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<string, Record<string, any>> = {};
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 };
});