import { Request, Response } from 'express'; import memberService from '../services/member.service'; import logger from '../utils/logger'; import { CreateMemberProfileSchema, UpdateMemberProfileSchema, SelfUpdateMemberProfileSchema, } from '../models/member.model'; // ---------------------------------------------------------------- // Role helpers // These helpers inspect req.user.role which is populated by the // requireRole / requirePermission middleware (see member.routes.ts). // ---------------------------------------------------------------- type AppRole = 'admin' | 'kommandant' | 'mitglied'; function getRole(req: Request): AppRole { // req.userRole is set by requirePermission() for non-owner paths. // Fall back to req.user.role (JWT claim) and finally to 'mitglied'. return (req as any).userRole ?? (req.user as any)?.role ?? 'mitglied'; } function canWrite(req: Request): boolean { const role = getRole(req); return role === 'admin' || role === 'kommandant'; } function isOwnProfile(req: Request, userId: string): boolean { return req.user?.id === userId; } // ---------------------------------------------------------------- // Controller // ---------------------------------------------------------------- class MemberController { /** * GET /api/members * Returns a paginated list of all active members. * Supports ?search=, ?status[]=, ?dienstgrad[]=, ?page=, ?pageSize= */ async getMembers(req: Request, res: Response): Promise { try { const { search, page, pageSize, } = req.query as Record; // Arrays can be sent as ?status[]=aktiv&status[]=passiv or CSV const statusParam = req.query['status'] as string | string[] | undefined; const dienstgradParam = req.query['dienstgrad'] as string | string[] | undefined; const normalizeArray = (v?: string | string[]): string[] | undefined => { if (!v) return undefined; return Array.isArray(v) ? v : v.split(',').map((s) => s.trim()).filter(Boolean); }; const { items, total } = await memberService.getAllMembers({ search, status: normalizeArray(statusParam) as any, dienstgrad: normalizeArray(dienstgradParam) as any, page: page ? parseInt(page, 10) || 1 : 1, pageSize: pageSize ? Math.min(parseInt(pageSize, 10) || 25, 100) : 25, }); res.status(200).json({ success: true, data: items, meta: { total, page: page ? parseInt(page, 10) : 1 }, }); } catch (error) { logger.error('getMembers error', { error }); res.status(500).json({ success: false, message: 'Fehler beim Laden der Mitglieder.' }); } } /** * GET /api/members/stats * Returns aggregate member counts for each status. * Must be registered BEFORE /:userId to avoid route collision. */ async getMemberStats(_req: Request, res: Response): Promise { try { const stats = await memberService.getMemberStats(); res.status(200).json({ success: true, data: stats }); } catch (error) { logger.error('getMemberStats error', { error }); res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistiken.' }); } } /** * GET /api/members/:userId * Returns full member detail including profile and rank history. * * Role rules: * - Kommandant/Admin: all fields * - Mitglied reading own profile: all fields * - Mitglied reading another member: geburtsdatum and emergency contact redacted */ async getMemberById(req: Request, res: Response): Promise { try { const { userId } = req.params as Record; const ownProfile = isOwnProfile(req, userId); const member = await memberService.getMemberById(userId); if (!member) { res.status(404).json({ success: false, message: 'Mitglied nicht gefunden.' }); return; } // Sensitive field masking for non-privileged reads of other members const canReadSensitive = canWrite(req) || ownProfile; if (!canReadSensitive && member.profile) { // Replace geburtsdatum with only the age (year of birth omitted for DSGVO) const ageMasked: any = { ...member.profile }; if (ageMasked.geburtsdatum) { const birthYear = new Date(ageMasked.geburtsdatum).getFullYear(); const age = new Date().getFullYear() - birthYear; ageMasked.geburtsdatum = null; ageMasked._age = age; // synthesised non-DB field } // Redact emergency contact entirely ageMasked.notfallkontakt_name = null; ageMasked.notfallkontakt_telefon = null; res.status(200).json({ success: true, data: { ...member, profile: ageMasked }, }); return; } res.status(200).json({ success: true, data: member }); } catch (error) { logger.error('getMemberById error', { error, userId: req.params.userId }); res.status(500).json({ success: false, message: 'Fehler beim Laden des Mitglieds.' }); } } /** * POST /api/members/:userId/profile * Creates the mitglieder_profile row for an existing auth user. * Restricted to Kommandant/Admin. */ async createMemberProfile(req: Request, res: Response): Promise { try { const { userId } = req.params as Record; const parseResult = CreateMemberProfileSchema.safeParse(req.body); if (!parseResult.success) { res.status(400).json({ success: false, message: 'Ungültige Eingabedaten.', errors: parseResult.error.flatten().fieldErrors, }); return; } const profile = await memberService.createMemberProfile(userId, parseResult.data); logger.info('createMemberProfile', { userId, createdBy: req.user!.id }); res.status(201).json({ success: true, data: profile }); } catch (error: any) { if (error?.message?.includes('existiert bereits')) { res.status(409).json({ success: false, message: error.message }); return; } logger.error('createMemberProfile error', { error, userId: req.params.userId }); res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Profils.' }); } } /** * PATCH /api/members/:userId * Updates the mitglieder_profile row. * * Role rules: * - Kommandant/Admin: full update (all fields) * - Mitglied (own profile only): restricted to SelfUpdateMemberProfileSchema fields */ async updateMember(req: Request, res: Response): Promise { try { const { userId } = req.params as Record; const updaterId = req.user!.id; const ownProfile = isOwnProfile(req, userId); if (!canWrite(req) && !ownProfile) { res.status(403).json({ success: false, message: 'Keine Berechtigung, dieses Profil zu bearbeiten.', }); return; } // Choose validation schema based on role const schema = canWrite(req) ? UpdateMemberProfileSchema : SelfUpdateMemberProfileSchema; const parseResult = schema.safeParse(req.body); if (!parseResult.success) { res.status(400).json({ success: false, message: 'Ungültige Eingabedaten.', errors: parseResult.error.flatten().fieldErrors, }); return; } await memberService.updateMemberProfile( userId, parseResult.data as any, updaterId ); // Return full MemberWithProfile so the frontend state stays consistent const fullMember = await memberService.getMemberById(userId); logger.info('updateMember', { userId, updatedBy: updaterId }); res.status(200).json({ success: true, data: fullMember }); } catch (error: any) { if (error?.message === 'Mitgliedsprofil nicht gefunden.') { res.status(404).json({ success: false, message: error.message }); return; } logger.error('updateMember error', { error, userId: req.params.userId }); res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Profils.' }); } } /** * GET /api/members/:userId/befoerderungen */ async getBefoerderungen(req: Request, res: Response): Promise { try { const { userId } = req.params as Record; const data = await memberService.getBefoerderungen(userId); res.status(200).json({ success: true, data }); } catch (error) { logger.error('getBefoerderungen error', { error, userId: req.params.userId }); res.status(500).json({ success: false, message: 'Fehler beim Laden der Beförderungen.' }); } } /** * GET /api/members/:userId/untersuchungen */ async getUntersuchungen(req: Request, res: Response): Promise { try { const { userId } = req.params as Record; const data = await memberService.getUntersuchungen(userId); res.status(200).json({ success: true, data }); } catch (error) { logger.error('getUntersuchungen error', { error, userId: req.params.userId }); res.status(500).json({ success: false, message: 'Fehler beim Laden der Untersuchungen.' }); } } /** * GET /api/members/:userId/fahrgenehmigungen */ async getFahrgenehmigungen(req: Request, res: Response): Promise { try { const { userId } = req.params as Record; const data = await memberService.getFahrgenehmigungen(userId); res.status(200).json({ success: true, data }); } catch (error) { logger.error('getFahrgenehmigungen error', { error, userId: req.params.userId }); res.status(500).json({ success: false, message: 'Fehler beim Laden der Fahrgenehmigungen.' }); } } /** * GET /api/members/:userId/ausbildungen */ async getAusbildungen(req: Request, res: Response): Promise { try { const { userId } = req.params as Record; const data = await memberService.getAusbildungen(userId); res.status(200).json({ success: true, data }); } catch (error) { logger.error('getAusbildungen error', { error, userId: req.params.userId }); res.status(500).json({ success: false, message: 'Fehler beim Laden der Ausbildungen.' }); } } } export default new MemberController();