feat: user data purge, breadcrumbs, first-login dialog, widget consolidation, bookkeeping cascade

- Admin can purge all personal data for a user (POST /api/admin/users/:userId/purge-data)
  while keeping the account; clears profile, notifications, bookings, ical tokens, preferences
- Add isNewUser flag to auth callback response; first-login dialog prompts for Standesbuchnummer
- Add PageBreadcrumbs component and apply to 18 sub-pages across the app
- Cascade budget_typ changes from parent pot to all children recursively, converting amounts
  (detailliert→einfach: sum into budget_gesamt; einfach→detailliert: zero all for redistribution)
- Migrate NextcloudTalkWidget to use shared WidgetCard template for consistent header styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-04-13 16:15:28 +02:00
parent a0b3c0ec5c
commit b477e5dbe0
32 changed files with 485 additions and 49 deletions

View File

@@ -653,6 +653,36 @@ async function createKonto(
}
}
async function cascadeBudgetTypToChildren(parentId: number, newType: string): Promise<void> {
const children = await pool.query(
'SELECT id, budget_typ, budget_gwg, budget_anlagen, budget_instandhaltung, budget_gesamt FROM buchhaltung_konten WHERE parent_id = $1',
[parentId]
);
for (const child of children.rows) {
const oldType = child.budget_typ || 'detailliert';
if (oldType === newType) continue;
let gwg = 0, anlagen = 0, instandhaltung = 0, gesamt = 0;
if (oldType === 'detailliert' && newType === 'einfach') {
// Sum detail fields into gesamt
gesamt = (parseFloat(child.budget_gwg) || 0) + (parseFloat(child.budget_anlagen) || 0) + (parseFloat(child.budget_instandhaltung) || 0);
}
// einfach → detailliert: zero everything (user redistributes manually)
await pool.query(
`UPDATE buchhaltung_konten
SET budget_typ = $1, budget_gwg = $2, budget_anlagen = $3, budget_instandhaltung = $4, budget_gesamt = $5
WHERE id = $6`,
[newType, gwg, anlagen, instandhaltung, gesamt, child.id]
);
// Recurse into grandchildren
await cascadeBudgetTypToChildren(child.id, newType);
}
}
async function updateKonto(
id: number,
data: { konto_typ_id?: number; kontonummer?: string; bezeichnung?: string; parent_id?: number | null; budget_gwg?: number; budget_anlagen?: number; budget_instandhaltung?: number; notizen?: string; kategorie_id?: number | null; budget_typ?: string; budget_gesamt?: number }
@@ -711,6 +741,15 @@ async function updateKonto(
`UPDATE buchhaltung_konten SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`,
values
);
// Cascade budget_typ change to all children recursively
if (data.budget_typ !== undefined && result.rows[0]) {
const newBudgetTyp = result.rows[0].budget_typ;
// Fetch what the old type was before the update (the RETURNING row has the new value)
// We know a change happened if the user sent budget_typ, so cascade unconditionally
await cascadeBudgetTypToChildren(id, newBudgetTyp);
}
return result.rows[0] || null;
} catch (error: any) {
logger.error('BuchhaltungService.updateKonto failed', { error, id });

View File

@@ -322,6 +322,69 @@ class UserService {
}
}
/**
* Purge all data associated with a user, keeping the users record itself.
* Financial/audit records (buchhaltung, bestellung_historie, audit_log) are preserved.
*/
async purgeUserData(userId: string, requestingUserId: string): Promise<Record<string, number>> {
if (userId === requestingUserId) {
throw new Error('Cannot purge your own data');
}
const user = await this.findById(userId);
if (!user) {
throw new Error('User not found');
}
const client = await pool.connect();
try {
await client.query('BEGIN');
const results: Record<string, number> = {};
// Helper to delete and track row counts
const purge = async (table: string, column = 'user_id') => {
const r = await client.query(`DELETE FROM ${table} WHERE ${column} = $1`, [userId]);
results[table] = r.rowCount ?? 0;
};
// User-owned data tables (DELETE rows)
await purge('notifications');
await purge('mitglieder_profile');
await purge('dienstgrad_verlauf');
await purge('atemschutz_traeger');
await purge('ausbildungen');
await purge('untersuchungen');
await purge('fahrgenehmigungen');
await purge('befoerderungen');
await purge('einsatz_personal');
await purge('uebung_teilnahmen');
await purge('veranstaltung_teilnahmen');
await purge('veranstaltung_ical_tokens');
await purge('fahrzeug_ical_tokens');
await purge('shop_anfragen', 'anfrager_id');
// Clear user preferences (widget layout, etc.)
await client.query(
`UPDATE users SET preferences = NULL, nextcloud_login_name = NULL, nextcloud_app_password = NULL WHERE id = $1`,
[userId]
);
results['preferences_cleared'] = 1;
await client.query('COMMIT');
logger.info('User data purged', { userId, by: requestingUserId, results });
return results;
} catch (error) {
await client.query('ROLLBACK');
logger.error('Error purging user data', { error, userId, requestingUserId });
throw error;
} finally {
client.release();
}
}
/**
* Sync Authentik groups for a user
*/