refactor(mitglieder): replace legacy status values (passiv/anwärter/ausgetreten/…) with aktiv/kind/jugend/reserve across backend, frontend, and sync

This commit is contained in:
Matthias Hochmeister
2026-04-15 19:43:18 +02:00
parent c1de8bd163
commit 260b71baf8
11 changed files with 84 additions and 67 deletions

View File

@@ -47,7 +47,7 @@ class MemberController {
pageSize,
} = req.query as Record<string, string | undefined>;
// Arrays can be sent as ?status[]=aktiv&status[]=passiv or CSV
// Arrays can be sent as ?status[]=aktiv&status[]=jugend or CSV
const statusParam = req.query['status'] as string | string[] | undefined;
const dienstgradParam = req.query['dienstgrad'] as string | string[] | undefined;

View File

@@ -0,0 +1,16 @@
-- Migration: 090_update_status_values
-- Replace old status values with FDISK-aligned values: aktiv, kind, jugend, reserve.
-- Old values passiv, ehrenmitglied, jugendfeuerwehr, anwärter, ausgetreten are removed.
-- Idempotent: safe to run multiple times.
-- 1. Drop existing CHECK constraint
ALTER TABLE mitglieder_profile DROP CONSTRAINT IF EXISTS mitglieder_profile_status_check;
-- 2. Migrate existing data
UPDATE mitglieder_profile SET status = 'jugend' WHERE status = 'jugendfeuerwehr';
UPDATE mitglieder_profile SET status = NULL
WHERE status IN ('passiv', 'ehrenmitglied', 'anwärter', 'ausgetreten');
-- 3. Re-add CHECK with new allowed values (NULL still allowed for profiles without FDISK sync)
ALTER TABLE mitglieder_profile ADD CONSTRAINT mitglieder_profile_status_check
CHECK (status IS NULL OR status IN ('aktiv', 'kind', 'jugend', 'reserve'));

View File

@@ -29,11 +29,9 @@ export const DIENSTGRAD_VALUES = [
export const STATUS_VALUES = [
'aktiv',
'passiv',
'ehrenmitglied',
'jugendfeuerwehr',
'anwärter',
'ausgetreten',
'kind',
'jugend',
'reserve',
] as const;
export const FUNKTION_VALUES = [
@@ -232,9 +230,7 @@ export type SelfUpdateMemberProfileData = z.infer<typeof SelfUpdateMemberProfile
export interface MemberStats {
total: number;
aktiv: number;
passiv: number;
ehrenmitglied: number;
jugendfeuerwehr: number;
anwärter: number;
ausgetreten: number;
kind: number;
jugend: number;
reserve: number;
}

View File

@@ -23,7 +23,7 @@ class AtemschutzService {
result = await pool.query(`
SELECT *
FROM atemschutz_uebersicht
WHERE mitglied_status IS NULL OR mitglied_status IN ('aktiv', 'anwärter')
WHERE mitglied_status IS NULL OR mitglied_status IN ('aktiv')
ORDER BY user_family_name, user_given_name
`);
} else {
@@ -301,7 +301,7 @@ class AtemschutzService {
) AS leistungstest_bald_faellig,
COUNT(*) FILTER (WHERE einsatzbereit = TRUE) AS einsatzbereit
FROM atemschutz_uebersicht
WHERE mitglied_status IS NULL OR mitglied_status IN ('aktiv', 'anwärter')
WHERE mitglied_status IS NULL OR mitglied_status IN ('aktiv')
`);
const row = result.rows[0] ?? {};

View File

@@ -630,24 +630,20 @@ class MemberService {
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
COUNT(*)::INTEGER AS total,
COUNT(*) FILTER (WHERE status = 'aktiv')::INTEGER AS aktiv,
COUNT(*) FILTER (WHERE status = 'kind')::INTEGER AS kind,
COUNT(*) FILTER (WHERE status = 'jugend')::INTEGER AS jugend,
COUNT(*) FILTER (WHERE status = 'reserve')::INTEGER AS reserve
FROM mitglieder_profile
`);
return (result.rows[0] as MemberStats) ?? {
total: 0,
aktiv: 0,
passiv: 0,
ehrenmitglied: 0,
jugendfeuerwehr: 0,
'anwärter': 0,
ausgetreten: 0,
kind: 0,
jugend: 0,
reserve: 0,
};
} catch (error) {
logger.error('Error fetching member stats', { error });

View File

@@ -30,22 +30,22 @@ declare module 'multer' {
}
declare namespace Express {
namespace Multer {
interface File {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
size: number;
destination: string;
filename: string;
path: string;
buffer: Buffer;
}
}
interface Request {
file?: {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
size: number;
buffer: Buffer;
};
files?: {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
size: number;
buffer: Buffer;
}[];
file?: Multer.File;
files?: Multer.File[];
}
}

View File

@@ -70,7 +70,6 @@ import type {
Transaktion, TransaktionFilters,
TransaktionTyp,
AusgabenTyp,
WiederkehrendBuchung, WiederkehrendFormData,
WiederkehrendIntervall,
BudgetTyp,

View File

@@ -32,7 +32,7 @@ interface ApiItemResponse<T> {
/**
* Builds a URLSearchParams object from the filter object so query
* strings like ?status[]=aktiv&status[]=passiv are sent correctly.
* strings like ?status[]=aktiv&status[]=jugend are sent correctly.
*/
function buildParams(filters?: MemberFilters): URLSearchParams {
const params = new URLSearchParams();

View File

@@ -27,11 +27,9 @@ export const DIENSTGRAD_VALUES = [
export const STATUS_VALUES = [
'aktiv',
'passiv',
'ehrenmitglied',
'jugendfeuerwehr',
'anwärter',
'ausgetreten',
'kind',
'jugend',
'reserve',
] as const;
export const FUNKTION_VALUES = [
@@ -144,11 +142,9 @@ export type UpdateMemberProfileData = CreateMemberProfileData;
export interface MemberStats {
total: number;
aktiv: number;
passiv: number;
ehrenmitglied: number;
jugendfeuerwehr: number;
'anwärter': number;
ausgetreten: number;
kind: number;
jugend: number;
reserve: number;
}
// ----------------------------------------------------------------
@@ -184,21 +180,17 @@ export function formatPhone(raw: string | null | undefined): string {
/** Returns a human-readable status label */
export const STATUS_LABELS: Record<StatusEnum, string> = {
aktiv: 'Aktiv',
passiv: 'Passiv',
ehrenmitglied: 'Ehrenmitglied',
jugendfeuerwehr: 'Jugendfeuerwehr',
anwärter: 'Anwärter',
ausgetreten: 'Ausgetreten',
kind: 'Kind',
jugend: 'Jugend',
reserve: 'Reserve',
};
/** MUI Chip color for each status */
export const STATUS_COLORS: Record<StatusEnum, 'success' | 'warning' | 'error' | 'info' | 'default'> = {
aktiv: 'success',
passiv: 'warning',
ehrenmitglied: 'info',
jugendfeuerwehr: 'info',
anwärter: 'default',
ausgetreten: 'error',
kind: 'info',
jugend: 'info',
reserve: 'warning',
};
// ----------------------------------------------------------------

View File

@@ -33,6 +33,20 @@ async function dumpHtml(frame: Frame, label: string): Promise<void> {
}
}
/**
* Maps a raw FDISK status string to a dashboard status value.
* Returns null for unknown/unneeded statuses — those members should be skipped.
*/
function mapFdiskStatus(raw: string): 'aktiv' | 'kind' | 'jugend' | 'reserve' | null {
switch (raw.trim()) {
case 'Aktiv': return 'aktiv';
case 'Kind': return 'kind';
case 'Jugend': return 'jugend';
case 'Reserve': return 'reserve';
default: return null;
}
}
function log(msg: string) {
console.log(`[scraper] ${new Date().toISOString()} ${msg}`);
}
@@ -153,6 +167,8 @@ async function scrapeKnownMembers(
const members: FdiskMember[] = [];
for (const row of allRows) {
if (!row.standesbuchNr || !row.vorname || !row.zuname) continue;
const status = mapFdiskStatus(row.status);
if (!status) continue; // skip members with non-synced statuses
const abmeldedatum = parseDate(row.abmeldedatum);
members.push({
standesbuchNr: row.standesbuchNr,
@@ -163,7 +179,7 @@ async function scrapeKnownMembers(
svnr: row.svnr || null,
eintrittsdatum: parseDate(row.eintrittsdatum),
abmeldedatum,
status: abmeldedatum ? 'ausgetreten' : 'aktiv',
status,
detailUrl: row.href,
geburtsort: null,
geschlecht: null,
@@ -593,6 +609,8 @@ async function scrapeMembers(frame: Frame): Promise<FdiskMember[]> {
const members: FdiskMember[] = [];
for (const row of allRows) {
if (!row.standesbuchNr || !row.vorname || !row.zuname) continue;
const status = mapFdiskStatus(row.status);
if (!status) continue; // skip members with non-synced statuses
const abmeldedatum = parseDate(row.abmeldedatum);
members.push({
standesbuchNr: row.standesbuchNr,
@@ -603,7 +621,7 @@ async function scrapeMembers(frame: Frame): Promise<FdiskMember[]> {
svnr: row.svnr || null,
eintrittsdatum: parseDate(row.eintrittsdatum),
abmeldedatum,
status: abmeldedatum ? 'ausgetreten' : 'aktiv',
status,
detailUrl: row.href,
geburtsort: null,
geschlecht: null,

View File

@@ -7,8 +7,8 @@ export interface FdiskMember {
svnr: string | null;
eintrittsdatum: string | null;
abmeldedatum: string | null;
/** 'aktiv' if no Abmeldedatum, 'ausgetreten' otherwise */
status: 'aktiv' | 'ausgetreten';
/** Status mapped from FDISK column: Aktiv/Kind/Jugend/Reserve */
status: 'aktiv' | 'kind' | 'jugend' | 'reserve';
/** URL or identifier to navigate to the member detail page */
detailUrl: string | null;
/** Additional profile fields scraped from the detail form */