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,
|
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;
|
||||||
|
|
||||||
|
|||||||
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 = [
|
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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] ?? {};
|
||||||
|
|||||||
@@ -632,22 +632,18 @@ class MemberService {
|
|||||||
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 });
|
||||||
|
|||||||
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 {
|
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;
|
|
||||||
}[];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ import type {
|
|||||||
Transaktion, TransaktionFilters,
|
Transaktion, TransaktionFilters,
|
||||||
TransaktionTyp,
|
TransaktionTyp,
|
||||||
|
|
||||||
AusgabenTyp,
|
|
||||||
WiederkehrendBuchung, WiederkehrendFormData,
|
WiederkehrendBuchung, WiederkehrendFormData,
|
||||||
WiederkehrendIntervall,
|
WiederkehrendIntervall,
|
||||||
BudgetTyp,
|
BudgetTyp,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------------
|
// ----------------------------------------------------------------
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
Reference in New Issue
Block a user