import pool from '../config/database'; import logger from '../utils/logger'; import { MitgliederProfile, MemberWithProfile, MemberListItem, MemberFilters, MemberStats, CreateMemberProfileData, UpdateMemberProfileData, DienstgradVerlaufEntry, } from '../models/member.model'; class MemberService { // ---------------------------------------------------------------- // Private helpers // ---------------------------------------------------------------- /** * Builds the SELECT columns and JOIN for a full MemberWithProfile query. * Returns raw pg rows that map to MemberWithProfile. */ private buildMemberSelectQuery(): string { return ` SELECT u.id, u.email, u.name, u.given_name, u.family_name, u.preferred_username, u.profile_picture_url, u.is_active, u.last_login_at, u.created_at, -- profile columns (aliased with mp_ prefix to avoid collision) mp.id AS mp_id, mp.user_id AS mp_user_id, mp.mitglieds_nr AS mp_mitglieds_nr, mp.dienstgrad AS mp_dienstgrad, mp.dienstgrad_seit AS mp_dienstgrad_seit, mp.funktion AS mp_funktion, mp.status AS mp_status, mp.eintrittsdatum AS mp_eintrittsdatum, mp.austrittsdatum AS mp_austrittsdatum, mp.geburtsdatum AS mp_geburtsdatum, mp.telefon_mobil AS mp_telefon_mobil, mp.telefon_privat AS mp_telefon_privat, mp.notfallkontakt_name AS mp_notfallkontakt_name, mp.notfallkontakt_telefon AS mp_notfallkontakt_telefon, mp.fuehrerscheinklassen AS mp_fuehrerscheinklassen, mp.tshirt_groesse AS mp_tshirt_groesse, mp.schuhgroesse AS mp_schuhgroesse, mp.bemerkungen AS mp_bemerkungen, mp.bild_url AS mp_bild_url, mp.created_at AS mp_created_at, mp.updated_at AS mp_updated_at FROM users u LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id `; } /** * Maps a raw pg result row (with mp_ prefixed columns) into a * MemberWithProfile object. Handles null profile gracefully. */ private mapRowToMemberWithProfile(row: any): MemberWithProfile { const hasProfile = row.mp_id !== null; return { id: row.id, email: row.email, name: row.name, given_name: row.given_name, family_name: row.family_name, preferred_username: row.preferred_username, profile_picture_url: row.profile_picture_url, is_active: row.is_active, last_login_at: row.last_login_at, created_at: row.created_at, profile: hasProfile ? { id: row.mp_id, user_id: row.mp_user_id, mitglieds_nr: row.mp_mitglieds_nr, dienstgrad: row.mp_dienstgrad, dienstgrad_seit: row.mp_dienstgrad_seit, funktion: row.mp_funktion ?? [], status: row.mp_status, eintrittsdatum: row.mp_eintrittsdatum, austrittsdatum: row.mp_austrittsdatum, geburtsdatum: row.mp_geburtsdatum, telefon_mobil: row.mp_telefon_mobil, telefon_privat: row.mp_telefon_privat, notfallkontakt_name: row.mp_notfallkontakt_name, notfallkontakt_telefon: row.mp_notfallkontakt_telefon, fuehrerscheinklassen: row.mp_fuehrerscheinklassen ?? [], tshirt_groesse: row.mp_tshirt_groesse, schuhgroesse: row.mp_schuhgroesse, bemerkungen: row.mp_bemerkungen, bild_url: row.mp_bild_url, created_at: row.mp_created_at, updated_at: row.mp_updated_at, } : null, }; } // ---------------------------------------------------------------- // Public API // ---------------------------------------------------------------- /** * Returns a paginated list of members with the minimal fields * required by the list view. Supports free-text search and * multi-value filter by status and dienstgrad. */ async getAllMembers(filters?: MemberFilters): Promise<{ items: MemberListItem[]; total: number }> { try { const { search, status, dienstgrad, page = 1, pageSize = 25, } = filters ?? {}; const conditions: string[] = ['u.is_active = TRUE']; const values: any[] = []; let paramIdx = 1; if (search) { conditions.push(`( u.name ILIKE $${paramIdx} OR u.email ILIKE $${paramIdx} OR u.given_name ILIKE $${paramIdx} OR u.family_name ILIKE $${paramIdx} OR mp.mitglieds_nr ILIKE $${paramIdx} )`); values.push(`%${search}%`); paramIdx++; } if (status && status.length > 0) { conditions.push(`mp.status = ANY($${paramIdx}::VARCHAR[])`); values.push(status); paramIdx++; } if (dienstgrad && dienstgrad.length > 0) { conditions.push(`mp.dienstgrad = ANY($${paramIdx}::VARCHAR[])`); values.push(dienstgrad); paramIdx++; } const whereClause = `WHERE ${conditions.join(' AND ')}`; const offset = (page - 1) * pageSize; const dataQuery = ` SELECT u.id, u.name, u.given_name, u.family_name, u.email, u.profile_picture_url, u.is_active, mp.id AS profile_id, mp.mitglieds_nr, mp.dienstgrad, mp.funktion, mp.status, mp.eintrittsdatum, mp.telefon_mobil FROM users u LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id ${whereClause} ORDER BY u.family_name ASC NULLS LAST, u.given_name ASC NULLS LAST LIMIT $${paramIdx} OFFSET $${paramIdx + 1} `; values.push(pageSize, offset); const countQuery = ` SELECT COUNT(*)::INTEGER AS total FROM users u LEFT JOIN mitglieder_profile mp ON mp.user_id = u.id ${whereClause} `; const [dataResult, countResult] = await Promise.all([ pool.query(dataQuery, values), pool.query(countQuery, values.slice(0, values.length - 2)), // exclude LIMIT/OFFSET ]); const items: MemberListItem[] = dataResult.rows.map((row) => ({ id: row.id, name: row.name, given_name: row.given_name, family_name: row.family_name, email: row.email, profile_picture_url: row.profile_picture_url, is_active: row.is_active, profile_id: row.profile_id ?? null, mitglieds_nr: row.mitglieds_nr ?? null, dienstgrad: row.dienstgrad ?? null, funktion: row.funktion ?? [], status: row.status ?? null, eintrittsdatum: row.eintrittsdatum ?? null, telefon_mobil: row.telefon_mobil ?? null, })); logger.debug('getAllMembers', { count: items.length, filters }); return { items, total: countResult.rows[0].total }; } catch (error) { logger.error('Error fetching member list', { error, filters }); throw new Error('Failed to fetch members'); } } /** * Returns a single member with their full profile (including rank history). * Returns null when the user does not exist. */ async getMemberById(userId: string): Promise { try { const query = `${this.buildMemberSelectQuery()} WHERE u.id = $1`; const result = await pool.query(query, [userId]); if (result.rows.length === 0) { logger.debug('getMemberById: not found', { userId }); return null; } const member = this.mapRowToMemberWithProfile(result.rows[0]); // Attach rank history when the profile exists if (member.profile) { member.dienstgrad_verlauf = await this.getDienstgradVerlauf(userId); } else { member.dienstgrad_verlauf = []; } return member; } catch (error) { logger.error('Error fetching member by id', { error, userId }); throw new Error('Failed to fetch member'); } } /** * Looks up a member by their Authentik OIDC subject identifier. * Useful for looking up the currently logged-in user's own profile. */ async getMemberByAuthentikSub(sub: string): Promise { try { const query = `${this.buildMemberSelectQuery()} WHERE u.authentik_sub = $1`; const result = await pool.query(query, [sub]); if (result.rows.length === 0) return null; const member = this.mapRowToMemberWithProfile(result.rows[0]); if (member.profile) { member.dienstgrad_verlauf = await this.getDienstgradVerlauf(member.id); } return member; } catch (error) { logger.error('Error fetching member by authentik sub', { error, sub }); throw new Error('Failed to fetch member'); } } /** * Creates the mitglieder_profile row for an existing auth user. * Throws if a profile already exists for this user_id. */ async createMemberProfile( userId: string, data: CreateMemberProfileData ): Promise { try { const query = ` INSERT INTO mitglieder_profile ( user_id, mitglieds_nr, dienstgrad, dienstgrad_seit, funktion, status, eintrittsdatum, austrittsdatum, geburtsdatum, telefon_mobil, telefon_privat, notfallkontakt_name, notfallkontakt_telefon, fuehrerscheinklassen, tshirt_groesse, schuhgroesse, bemerkungen, bild_url ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18 ) RETURNING * `; const values = [ userId, data.mitglieds_nr ?? null, data.dienstgrad ?? null, data.dienstgrad_seit ?? null, data.funktion ?? [], data.status ?? 'aktiv', data.eintrittsdatum ?? null, data.austrittsdatum ?? null, data.geburtsdatum ?? null, data.telefon_mobil ?? null, data.telefon_privat ?? null, data.notfallkontakt_name ?? null, data.notfallkontakt_telefon ?? null, data.fuehrerscheinklassen ?? [], data.tshirt_groesse ?? null, data.schuhgroesse ?? null, data.bemerkungen ?? null, data.bild_url ?? null, ]; const result = await pool.query(query, values); const profile = result.rows[0] as MitgliederProfile; // If a dienstgrad was set at creation, record it in history if (data.dienstgrad) { await this.writeDienstgradVerlauf( userId, data.dienstgrad, null, userId, // created-by = the user being set up (or override with system user) 'Initialer Dienstgrad bei Profilerstellung' ); } logger.info('Created mitglieder_profile', { userId, profileId: profile.id }); return profile; } catch (error: any) { if (error?.code === '23505') { // unique_violation on user_id throw new Error('Ein Profil für dieses Mitglied existiert bereits.'); } logger.error('Error creating mitglieder_profile', { error, userId }); throw new Error('Failed to create member profile'); } } /** * Partially updates the mitglieder_profile for the given user. * Only fields present in `data` are written (undefined = untouched). * * Rank changes are handled separately via updateDienstgrad() to ensure * the change is always logged in dienstgrad_verlauf. If `data.dienstgrad` * is present it will be delegated to that method automatically. */ async updateMemberProfile( userId: string, data: UpdateMemberProfileData, updatedBy: string ): Promise { const client = await pool.connect(); try { await client.query('BEGIN'); // Handle rank change through dedicated method to ensure audit log const { dienstgrad, dienstgrad_seit, ...rest } = data; if (dienstgrad !== undefined) { await this.updateDienstgrad(userId, dienstgrad, updatedBy, dienstgrad_seit, client); } else if (dienstgrad_seit !== undefined) { // dienstgrad_seit can be updated independently (rest as any).dienstgrad_seit = dienstgrad_seit; } // Build dynamic SET clause for remaining fields const updateFields: string[] = []; const values: any[] = []; let paramIdx = 1; const fieldMap: Record = { mitglieds_nr: rest.mitglieds_nr, funktion: rest.funktion, status: rest.status, eintrittsdatum: rest.eintrittsdatum, austrittsdatum: rest.austrittsdatum, geburtsdatum: rest.geburtsdatum, telefon_mobil: rest.telefon_mobil, telefon_privat: rest.telefon_privat, notfallkontakt_name: rest.notfallkontakt_name, notfallkontakt_telefon: rest.notfallkontakt_telefon, fuehrerscheinklassen: rest.fuehrerscheinklassen, tshirt_groesse: rest.tshirt_groesse, schuhgroesse: rest.schuhgroesse, bemerkungen: rest.bemerkungen, bild_url: rest.bild_url, dienstgrad_seit: (rest as any).dienstgrad_seit, }; for (const [column, value] of Object.entries(fieldMap)) { if (value !== undefined) { updateFields.push(`${column} = $${paramIdx++}`); values.push(value); } } let profile: MitgliederProfile; if (updateFields.length > 0) { values.push(userId); const query = ` UPDATE mitglieder_profile SET ${updateFields.join(', ')} WHERE user_id = $${paramIdx} RETURNING * `; const result = await client.query(query, values); if (result.rows.length === 0) { throw new Error('Mitgliedsprofil nicht gefunden.'); } profile = result.rows[0] as MitgliederProfile; } else { // Nothing to update (rank change only) — fetch current state const result = await client.query( 'SELECT * FROM mitglieder_profile WHERE user_id = $1', [userId] ); if (result.rows.length === 0) throw new Error('Mitgliedsprofil nicht gefunden.'); profile = result.rows[0] as MitgliederProfile; } await client.query('COMMIT'); logger.info('Updated mitglieder_profile', { userId, updatedBy }); return profile; } catch (error) { await client.query('ROLLBACK'); logger.error('Error updating mitglieder_profile', { error, userId }); throw error instanceof Error ? error : new Error('Failed to update member profile'); } finally { client.release(); } } /** * Sets a new Dienstgrad and writes an entry to dienstgrad_verlauf. * Can accept an optional pg PoolClient to participate in an outer transaction. */ async updateDienstgrad( userId: string, newDienstgrad: string, changedBy: string, since?: Date, existingClient?: any ): Promise { const executor = existingClient ?? pool; try { // Fetch current rank for the history entry const currentResult = await executor.query( 'SELECT dienstgrad FROM mitglieder_profile WHERE user_id = $1', [userId] ); const oldDienstgrad: string | null = currentResult.rows.length > 0 ? currentResult.rows[0].dienstgrad : null; // Update the profile await executor.query( `UPDATE mitglieder_profile SET dienstgrad = $1, dienstgrad_seit = $2 WHERE user_id = $3`, [newDienstgrad, since ?? new Date(), userId] ); // Write audit entry await this.writeDienstgradVerlauf( userId, newDienstgrad, oldDienstgrad, changedBy, null, existingClient ); logger.info('Updated Dienstgrad', { userId, oldDienstgrad, newDienstgrad, changedBy }); } catch (error) { logger.error('Error updating Dienstgrad', { error, userId, newDienstgrad }); throw new Error('Failed to update Dienstgrad'); } } /** * Internal helper: inserts one row into dienstgrad_verlauf. */ private async writeDienstgradVerlauf( userId: string, dienstgradNeu: string, dienstgradAlt: string | null, durchUserId: string | null, bemerkung: string | null = null, existingClient?: any ): Promise { const executor = existingClient ?? pool; await executor.query( `INSERT INTO dienstgrad_verlauf (user_id, dienstgrad_neu, dienstgrad_alt, durch_user_id, bemerkung) VALUES ($1, $2, $3, $4, $5)`, [userId, dienstgradNeu, dienstgradAlt, durchUserId, bemerkung] ); } /** * Fetches the rank change history for a member, newest first. */ async getDienstgradVerlauf(userId: string): Promise { try { const result = await pool.query( `SELECT dv.*, u.name AS durch_user_name FROM dienstgrad_verlauf dv LEFT JOIN users u ON u.id = dv.durch_user_id WHERE dv.user_id = $1 ORDER BY dv.datum DESC, dv.created_at DESC`, [userId] ); return result.rows as DienstgradVerlaufEntry[]; } catch (error) { logger.error('Error fetching Dienstgrad history', { error, userId }); return []; } } /** * Returns aggregate member counts, used by the dashboard KPI cards. * Optionally scoped to a single status value. */ async getMemberCount(status?: string): Promise { try { let query: string; let values: any[]; if (status) { query = ` SELECT COUNT(*)::INTEGER AS count FROM mitglieder_profile WHERE status = $1 `; values = [status]; } else { query = `SELECT COUNT(*)::INTEGER AS count FROM mitglieder_profile`; values = []; } const result = await pool.query(query, values); return result.rows[0].count; } catch (error) { logger.error('Error counting members', { error, status }); throw new Error('Failed to count members'); } } /** * Returns a full stats breakdown for all statuses (dashboard KPI widget). */ async getMemberStats(): Promise { try { const result = await pool.query(` SELECT COUNT(*)::INTEGER AS total, COUNT(*) FILTER (WHERE status = 'aktiv')::INTEGER AS aktiv, COUNT(*) FILTER (WHERE status = 'passiv')::INTEGER AS passiv, COUNT(*) FILTER (WHERE status = 'ehrenmitglied')::INTEGER AS ehrenmitglied, COUNT(*) FILTER (WHERE status = 'jugendfeuerwehr')::INTEGER AS jugendfeuerwehr, COUNT(*) FILTER (WHERE status = 'anwärter')::INTEGER AS "anwärter", COUNT(*) FILTER (WHERE status = 'ausgetreten')::INTEGER AS ausgetreten FROM mitglieder_profile `); return (result.rows[0] as MemberStats) ?? { total: 0, aktiv: 0, passiv: 0, ehrenmitglied: 0, jugendfeuerwehr: 0, 'anwärter': 0, ausgetreten: 0, }; } catch (error) { logger.error('Error fetching member stats', { error }); throw new Error('Failed to fetch member stats'); } } } export default new MemberService();