add features
This commit is contained in:
594
backend/src/services/member.service.ts
Normal file
594
backend/src/services/member.service.ts
Normal file
@@ -0,0 +1,594 @@
|
||||
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<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);
|
||||
}
|
||||
|
||||
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,
|
||||
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<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.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> = {
|
||||
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<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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
} catch (error) {
|
||||
logger.error('Error fetching member stats', { error });
|
||||
throw new Error('Failed to fetch member stats');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MemberService();
|
||||
Reference in New Issue
Block a user