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:
@@ -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;
|
||||
|
||||
|
||||
16
backend/src/database/migrations/090_update_status_values.sql
Normal file
16
backend/src/database/migrations/090_update_status_values.sql
Normal 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'));
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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] ?? {};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
32
backend/src/types/multer.d.ts
vendored
32
backend/src/types/multer.d.ts
vendored
@@ -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[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ import type {
|
||||
Transaktion, TransaktionFilters,
|
||||
TransaktionTyp,
|
||||
|
||||
AusgabenTyp,
|
||||
WiederkehrendBuchung, WiederkehrendFormData,
|
||||
WiederkehrendIntervall,
|
||||
BudgetTyp,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user