Files
dashboard/backend/src/services/member.service.ts
Matthias Hochmeister 8d03c13bee update
2026-03-14 14:10:05 +01:00

708 lines
23 KiB
TypeScript

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.fdisk_standesbuch_nr AS mp_fdisk_standesbuch_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.geburtsort AS mp_geburtsort,
mp.geschlecht AS mp_geschlecht,
mp.beruf AS mp_beruf,
mp.wohnort AS mp_wohnort,
mp.plz AS mp_plz,
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,
fdisk_standesbuch_nr: row.mp_fdisk_standesbuch_nr ?? null,
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,
geburtsort: row.mp_geburtsort ?? null,
geschlecht: row.mp_geschlecht ?? null,
beruf: row.mp_beruf ?? null,
wohnort: row.mp_wohnort ?? null,
plz: row.mp_plz ?? null,
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}
)`);
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.fdisk_standesbuch_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,
fdisk_standesbuch_nr: row.fdisk_standesbuch_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<MemberWithProfile | null> {
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<MemberWithProfile | null> {
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<MitgliederProfile> {
try {
const query = `
INSERT INTO mitglieder_profile (
user_id,
fdisk_standesbuch_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.fdisk_standesbuch_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<MitgliederProfile> {
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<string, any> = {
fdisk_standesbuch_nr: rest.fdisk_standesbuch_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<void> {
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<void> {
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<DienstgradVerlaufEntry[]> {
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 [];
}
}
/**
* Ensures a mitglieder_profile row exists for the given user.
* Safe to call on every login — idempotent via ON CONFLICT DO NOTHING.
* Errors are caught and logged without throwing.
*/
async ensureProfileExists(userId: string): Promise<void> {
try {
await pool.query(
`INSERT INTO mitglieder_profile (user_id, status)
VALUES ($1, 'aktiv')
ON CONFLICT (user_id) DO NOTHING`,
[userId]
);
} catch (error) {
logger.warn('ensureProfileExists failed (non-fatal)', { error, userId });
}
}
/**
* Returns aggregate member counts, used by the dashboard KPI cards.
* Optionally scoped to a single status value.
*/
async getMemberCount(status?: string): Promise<number> {
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<MemberStats> {
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');
}
}
/**
* Returns all Beförderungen for a member, newest first.
*/
async getBefoerderungen(userId: string): Promise<any[]> {
try {
const result = await pool.query(
`SELECT id, datum, dienstgrad, created_at
FROM befoerderungen
WHERE user_id = $1
ORDER BY datum DESC NULLS LAST, created_at DESC`,
[userId]
);
return result.rows;
} catch (error) {
logger.error('Error fetching Beförderungen', { error, userId });
return [];
}
}
/**
* Returns all Untersuchungen for a member, newest first.
*/
async getUntersuchungen(userId: string): Promise<any[]> {
try {
const result = await pool.query(
`SELECT id, datum, anmerkungen, art, ergebnis, created_at
FROM untersuchungen
WHERE user_id = $1
ORDER BY datum DESC NULLS LAST, created_at DESC`,
[userId]
);
return result.rows;
} catch (error) {
logger.error('Error fetching Untersuchungen', { error, userId });
return [];
}
}
/**
* Returns all Fahrgenehmigungen for a member, newest first.
*/
async getFahrgenehmigungen(userId: string): Promise<any[]> {
try {
const result = await pool.query(
`SELECT id, ausstellungsdatum, gueltig_bis, behoerde, nummer, klasse, created_at
FROM fahrgenehmigungen
WHERE user_id = $1
ORDER BY ausstellungsdatum DESC NULLS LAST, created_at DESC`,
[userId]
);
return result.rows;
} catch (error) {
logger.error('Error fetching Fahrgenehmigungen', { error, userId });
return [];
}
}
/**
* Returns all Ausbildungen (training courses) for a given user from the FDISK-synced table.
*/
async getAusbildungen(userId: string): Promise<any[]> {
try {
const result = await pool.query(
`SELECT id, kursname, kurs_datum, ablaufdatum, ort, bemerkung, status, created_at
FROM ausbildung
WHERE user_id = $1
ORDER BY kurs_datum DESC NULLS LAST, created_at DESC`,
[userId]
);
return result.rows;
} catch (error) {
logger.error('Error fetching Ausbildungen', { error, userId });
return [];
}
}
}
export default new MemberService();