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, pageSize,
} = req.query as Record<string, string | undefined>; } = 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 statusParam = req.query['status'] as string | string[] | undefined;
const dienstgradParam = req.query['dienstgrad'] 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 = [ export const STATUS_VALUES = [
'aktiv', 'aktiv',
'passiv', 'kind',
'ehrenmitglied', 'jugend',
'jugendfeuerwehr', 'reserve',
'anwärter',
'ausgetreten',
] as const; ] as const;
export const FUNKTION_VALUES = [ export const FUNKTION_VALUES = [
@@ -232,9 +230,7 @@ export type SelfUpdateMemberProfileData = z.infer<typeof SelfUpdateMemberProfile
export interface MemberStats { export interface MemberStats {
total: number; total: number;
aktiv: number; aktiv: number;
passiv: number; kind: number;
ehrenmitglied: number; jugend: number;
jugendfeuerwehr: number; reserve: number;
anwärter: number;
ausgetreten: number;
} }

View File

@@ -23,7 +23,7 @@ class AtemschutzService {
result = await pool.query(` result = await pool.query(`
SELECT * SELECT *
FROM atemschutz_uebersicht 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 ORDER BY user_family_name, user_given_name
`); `);
} else { } else {
@@ -301,7 +301,7 @@ class AtemschutzService {
) AS leistungstest_bald_faellig, ) AS leistungstest_bald_faellig,
COUNT(*) FILTER (WHERE einsatzbereit = TRUE) AS einsatzbereit COUNT(*) FILTER (WHERE einsatzbereit = TRUE) AS einsatzbereit
FROM atemschutz_uebersicht 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] ?? {}; const row = result.rows[0] ?? {};

View File

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

View File

@@ -30,22 +30,22 @@ declare module 'multer' {
} }
declare namespace Express { 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 { interface Request {
file?: { file?: Multer.File;
fieldname: string; files?: Multer.File[];
originalname: string;
encoding: string;
mimetype: string;
size: number;
buffer: Buffer;
};
files?: {
fieldname: string;
originalname: string;
encoding: string;
mimetype: string;
size: number;
buffer: Buffer;
}[];
} }
} }

View File

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

View File

@@ -32,7 +32,7 @@ interface ApiItemResponse<T> {
/** /**
* Builds a URLSearchParams object from the filter object so query * 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 { function buildParams(filters?: MemberFilters): URLSearchParams {
const params = new URLSearchParams(); const params = new URLSearchParams();

View File

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

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) { function log(msg: string) {
console.log(`[scraper] ${new Date().toISOString()} ${msg}`); console.log(`[scraper] ${new Date().toISOString()} ${msg}`);
} }
@@ -153,6 +167,8 @@ async function scrapeKnownMembers(
const members: FdiskMember[] = []; const members: FdiskMember[] = [];
for (const row of allRows) { for (const row of allRows) {
if (!row.standesbuchNr || !row.vorname || !row.zuname) continue; 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); const abmeldedatum = parseDate(row.abmeldedatum);
members.push({ members.push({
standesbuchNr: row.standesbuchNr, standesbuchNr: row.standesbuchNr,
@@ -163,7 +179,7 @@ async function scrapeKnownMembers(
svnr: row.svnr || null, svnr: row.svnr || null,
eintrittsdatum: parseDate(row.eintrittsdatum), eintrittsdatum: parseDate(row.eintrittsdatum),
abmeldedatum, abmeldedatum,
status: abmeldedatum ? 'ausgetreten' : 'aktiv', status,
detailUrl: row.href, detailUrl: row.href,
geburtsort: null, geburtsort: null,
geschlecht: null, geschlecht: null,
@@ -593,6 +609,8 @@ async function scrapeMembers(frame: Frame): Promise<FdiskMember[]> {
const members: FdiskMember[] = []; const members: FdiskMember[] = [];
for (const row of allRows) { for (const row of allRows) {
if (!row.standesbuchNr || !row.vorname || !row.zuname) continue; 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); const abmeldedatum = parseDate(row.abmeldedatum);
members.push({ members.push({
standesbuchNr: row.standesbuchNr, standesbuchNr: row.standesbuchNr,
@@ -603,7 +621,7 @@ async function scrapeMembers(frame: Frame): Promise<FdiskMember[]> {
svnr: row.svnr || null, svnr: row.svnr || null,
eintrittsdatum: parseDate(row.eintrittsdatum), eintrittsdatum: parseDate(row.eintrittsdatum),
abmeldedatum, abmeldedatum,
status: abmeldedatum ? 'ausgetreten' : 'aktiv', status,
detailUrl: row.href, detailUrl: row.href,
geburtsort: null, geburtsort: null,
geschlecht: null, geschlecht: null,

View File

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