new features

This commit is contained in:
Matthias Hochmeister
2026-03-23 16:09:42 +01:00
parent e9a9478aac
commit 8c66492b27
40 changed files with 2016 additions and 117 deletions

View File

@@ -87,6 +87,7 @@ class AuthController {
// Step 2: Get user info from Authentik
const userInfo = await authentikService.getUserInfo(tokens.access_token);
const groups = userInfo.groups ?? [];
const dashboardGroups = groups.filter((g: string) => g.startsWith('dashboard_'));
// Step 3: Verify ID token if present
if (tokens.id_token) {
@@ -119,8 +120,8 @@ class AuthController {
profile_picture_url: userInfo.picture,
});
await userService.updateGroups(user.id, groups);
logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length });
await userService.updateGroups(user.id, dashboardGroups);
logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length });
await memberService.ensureProfileExists(user.id);
// Audit: first-ever login (user record creation)
@@ -168,8 +169,8 @@ class AuthController {
});
await userService.updateLastLogin(user.id);
await userService.updateGroups(user.id, groups);
logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length });
await userService.updateGroups(user.id, dashboardGroups);
logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length });
await memberService.ensureProfileExists(user.id);
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);

View File

@@ -113,17 +113,29 @@ class BestellungController {
}
async createOrder(req: Request, res: Response): Promise<void> {
const { bezeichnung } = req.body;
const { bezeichnung, lieferant_id, budget, besteller_id } = req.body;
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
return;
}
if (lieferant_id != null && (!Number.isInteger(lieferant_id) || lieferant_id <= 0)) {
res.status(400).json({ success: false, message: 'Ungültige Lieferanten-ID' });
return;
}
if (budget != null && (typeof budget !== 'number' || budget < 0)) {
res.status(400).json({ success: false, message: 'Budget muss eine positive Zahl sein' });
return;
}
if (besteller_id != null && besteller_id !== '' && (typeof besteller_id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(besteller_id))) {
res.status(400).json({ success: false, message: 'Ungültige Besteller-ID' });
return;
}
try {
const order = await bestellungService.createOrder(req.body, req.user!.id);
res.status(201).json({ success: true, data: order });
} catch (error) {
logger.error('BestellungController.createOrder error', { error });
res.status(500).json({ success: false, message: 'Bestellung konnte nicht erstellt werden' });
res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Bestellung konnte nicht erstellt werden' });
}
}

View File

@@ -81,6 +81,17 @@ const CreateWartungslogSchema = z.object({
(url) => /^https?:\/\//i.test(url),
'Nur http/https URLs erlaubt'
).optional(),
naechste_pruefung_am: isoDate.optional(),
});
const UpdateWartungslogSchema = z.object({
datum: isoDate.optional(),
art: z.enum(['Prüfung', 'Reparatur', 'Sonstiges']).optional(),
beschreibung: z.string().min(1).max(2000).optional(),
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']).nullable().optional(),
kosten: z.number().min(0).nullable().optional(),
pruefende_stelle: z.string().max(150).nullable().optional(),
naechste_pruefung_am: isoDate.nullable().optional(),
});
// ── Helper ────────────────────────────────────────────────────────────────────
@@ -403,6 +414,43 @@ class EquipmentController {
}
}
async updateWartung(req: Request, res: Response): Promise<void> {
try {
const { id, wartungId } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
return;
}
const wId = parseInt(wartungId, 10);
if (isNaN(wId)) {
res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' });
return;
}
const parsed = UpdateWartungslogSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
if (Object.keys(parsed.data).length === 0) {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
const entry = await equipmentService.updateWartungslog(id, wId, parsed.data, getUserId(req));
res.status(200).json({ success: true, data: entry });
} catch (error: any) {
if (error?.message === 'Wartungseintrag nicht gefunden') {
res.status(404).json({ success: false, message: error.message });
return;
}
logger.error('updateWartung error', { error, id: req.params.id, wartungId: req.params.wartungId });
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht aktualisiert werden' });
}
}
async uploadWartungFile(req: Request, res: Response): Promise<void> {
const { wartungId } = req.params as Record<string, string>;
const id = parseInt(wartungId, 10);

View File

@@ -0,0 +1,176 @@
import { Request, Response } from 'express';
import issueService from '../services/issue.service';
import { permissionService } from '../services/permission.service';
import logger from '../utils/logger';
const param = (req: Request, key: string): string => req.params[key] as string;
class IssueController {
async getIssues(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
const issues = await issueService.getIssues(userId, canViewAll);
res.status(200).json({ success: true, data: issues });
} catch (error) {
logger.error('IssueController.getIssues error', { error });
res.status(500).json({ success: false, message: 'Issues konnten nicht geladen werden' });
}
}
async getIssue(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const issue = await issueService.getIssueById(id);
if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
if (!canViewAll && issue.erstellt_von !== userId) {
res.status(403).json({ success: false, message: 'Kein Zugriff' });
return;
}
res.status(200).json({ success: true, data: issue });
} catch (error) {
logger.error('IssueController.getIssue error', { error });
res.status(500).json({ success: false, message: 'Issue konnte nicht geladen werden' });
}
}
async createIssue(req: Request, res: Response): Promise<void> {
const { titel } = req.body;
if (!titel || typeof titel !== 'string' || titel.trim().length === 0) {
res.status(400).json({ success: false, message: 'Titel ist erforderlich' });
return;
}
try {
const issue = await issueService.createIssue(req.body, req.user!.id);
res.status(201).json({ success: true, data: issue });
} catch (error) {
logger.error('IssueController.createIssue error', { error });
res.status(500).json({ success: false, message: 'Issue konnte nicht erstellt werden' });
}
}
async updateIssue(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const groups: string[] = (req.user as any).groups || [];
const canManage = permissionService.hasPermission(groups, 'issues:manage');
if (!canManage) {
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
const issue = await issueService.updateIssue(id, req.body);
if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: issue });
} catch (error) {
logger.error('IssueController.updateIssue error', { error });
res.status(500).json({ success: false, message: 'Issue konnte nicht aktualisiert werden' });
}
}
async deleteIssue(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const issue = await issueService.getIssueById(id);
if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canManage = permissionService.hasPermission(groups, 'issues:manage');
if (!canManage && issue.erstellt_von !== userId) {
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
await issueService.deleteIssue(id);
res.status(200).json({ success: true, message: 'Issue gelöscht' });
} catch (error) {
logger.error('IssueController.deleteIssue error', { error });
res.status(500).json({ success: false, message: 'Issue konnte nicht gelöscht werden' });
}
}
async getComments(req: Request, res: Response): Promise<void> {
const issueId = parseInt(param(req, 'id'), 10);
if (isNaN(issueId)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const issue = await issueService.getIssueById(issueId);
if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
if (!canViewAll && issue.erstellt_von !== userId) {
res.status(403).json({ success: false, message: 'Kein Zugriff' });
return;
}
const comments = await issueService.getComments(issueId);
res.status(200).json({ success: true, data: comments });
} catch (error) {
logger.error('IssueController.getComments error', { error });
res.status(500).json({ success: false, message: 'Kommentare konnten nicht geladen werden' });
}
}
async addComment(req: Request, res: Response): Promise<void> {
const issueId = parseInt(param(req, 'id'), 10);
if (isNaN(issueId)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
const { inhalt } = req.body;
if (!inhalt || typeof inhalt !== 'string' || inhalt.trim().length === 0) {
res.status(400).json({ success: false, message: 'Kommentar darf nicht leer sein' });
return;
}
try {
const issue = await issueService.getIssueById(issueId);
if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
const canManage = permissionService.hasPermission(groups, 'issues:manage');
if (!canViewAll && !canManage && issue.erstellt_von !== userId) {
res.status(403).json({ success: false, message: 'Kein Zugriff' });
return;
}
const comment = await issueService.addComment(issueId, userId, inhalt.trim());
res.status(201).json({ success: true, data: comment });
} catch (error) {
logger.error('IssueController.addComment error', { error });
res.status(500).json({ success: false, message: 'Kommentar konnte nicht erstellt werden' });
}
}
}
export default new IssueController();

View File

@@ -188,11 +188,14 @@ class ShopController {
// Notify requester on status changes
if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) {
const orderLabel = existing.bestell_jahr && existing.bestell_nummer
? `${existing.bestell_jahr}/${String(existing.bestell_nummer).padStart(3, '0')}`
: `#${id}`;
await notificationService.createNotification({
user_id: existing.anfrager_id,
typ: 'shop_anfrage',
titel: status === 'genehmigt' ? 'Anfrage genehmigt' : status === 'abgelehnt' ? 'Anfrage abgelehnt' : `Anfrage ${status}`,
nachricht: `Deine Shop-Anfrage #${id} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`,
nachricht: `Deine Shop-Anfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`,
schwere: status === 'abgelehnt' ? 'warnung' : 'info',
link: '/shop',
quell_id: String(id),
@@ -218,6 +221,20 @@ class ShopController {
}
}
// -------------------------------------------------------------------------
// Overview
// -------------------------------------------------------------------------
async getOverview(_req: Request, res: Response): Promise<void> {
try {
const overview = await shopService.getOverview();
res.status(200).json({ success: true, data: overview });
} catch (error) {
logger.error('ShopController.getOverview error', { error });
res.status(500).json({ success: false, message: 'Übersicht konnte nicht geladen werden' });
}
}
// -------------------------------------------------------------------------
// Linking
// -------------------------------------------------------------------------

View File

@@ -86,6 +86,8 @@ const UpdateStatusSchema = z.object({
{ message: 'Enddatum muss nach Startdatum liegen', path: ['ausserDienstBis'] }
);
const ErgebnisEnum = z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']);
const CreateWartungslogSchema = z.object({
datum: isoDate,
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
@@ -94,6 +96,18 @@ const CreateWartungslogSchema = z.object({
kraftstoff_liter: z.number().min(0).optional(),
kosten: z.number().min(0).optional(),
externe_werkstatt: z.string().max(150).optional(),
ergebnis: ErgebnisEnum.optional(),
naechste_faelligkeit: isoDate.optional(),
});
const UpdateWartungslogSchema = z.object({
datum: isoDate,
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
beschreibung: z.string().min(1).max(2000),
km_stand: z.number().int().min(0).optional(),
externe_werkstatt: z.string().max(150).optional(),
ergebnis: ErgebnisEnum.optional(),
naechste_faelligkeit: isoDate.optional(),
});
// ── Helper ────────────────────────────────────────────────────────────────────
@@ -384,6 +398,34 @@ class VehicleController {
}
}
async updateWartung(req: Request, res: Response): Promise<void> {
try {
const { id, wartungId } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const parsed = UpdateWartungslogSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const entry = await vehicleService.updateWartungslog(wartungId, id, parsed.data, getUserId(req));
res.status(200).json({ success: true, data: entry });
} catch (error: any) {
if (error?.message === 'Wartungseintrag nicht gefunden') {
res.status(404).json({ success: false, message: 'Wartungseintrag nicht gefunden' });
return;
}
logger.error('updateWartung error', { error, id: req.params.id, wartungId: req.params.wartungId });
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht aktualisiert werden' });
}
}
async uploadWartungFile(req: Request, res: Response): Promise<void> {
const { wartungId } = req.params as Record<string, string>;
const id = parseInt(wartungId, 10);