Files
dashboard/backend/src/controllers/member.controller.ts
Matthias Hochmeister 215528a521 update
2026-03-16 14:41:08 +01:00

291 lines
10 KiB
TypeScript

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<void> {
try {
const {
search,
page,
pageSize,
} = req.query as Record<string, string | undefined>;
// 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<void> {
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<void> {
try {
const { userId } = req.params as Record<string, string>;
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<void> {
try {
const { userId } = req.params as Record<string, string>;
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<void> {
try {
const { userId } = req.params as Record<string, string>;
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<void> {
try {
const { userId } = req.params as Record<string, string>;
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<void> {
try {
const { userId } = req.params as Record<string, string>;
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<void> {
try {
const { userId } = req.params as Record<string, string>;
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<void> {
try {
const { userId } = req.params as Record<string, string>;
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();