291 lines
10 KiB
TypeScript
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();
|