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:
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user