708 lines
23 KiB
TypeScript
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();
|