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

@@ -100,6 +100,7 @@ class AuthController {
// Step 4: Find or create user in database
let user = await userService.findByAuthentikSub(userInfo.sub);
const isNewUser = !user;
if (!user) {
// User doesn't exist, create new user
@@ -230,6 +231,7 @@ class AuthController {
data: {
accessToken,
refreshToken,
isNewUser,
user: {
id: user.id,
email: user.email,

View File

@@ -20,6 +20,7 @@ import { requirePermission } from '../middleware/rbac.middleware';
import { auditExport } from '../middleware/audit.middleware';
import auditService, { AuditAction, AuditResourceType, AuditFilters } from '../services/audit.service';
import cleanupService from '../services/cleanup.service';
import userService from '../services/user.service';
import pool from '../config/database';
import logger from '../utils/logger';
@@ -440,4 +441,53 @@ router.delete(
}
);
// ---------------------------------------------------------------------------
// POST /api/admin/users/:userId/purge-data — delete all user-associated data
// (keeps the users record, financial records, and audit log)
// ---------------------------------------------------------------------------
router.post(
'/users/:userId/purge-data',
authenticate,
requirePermission('admin:write'),
async (req: Request, res: Response): Promise<void> => {
try {
const targetUserId = req.params.userId as string;
const requestingUserId = req.user!.id;
const targetUser = await userService.findById(targetUserId);
if (!targetUser) {
res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
return;
}
const result = await userService.purgeUserData(targetUserId, requestingUserId);
// Audit log
await auditService.logAudit({
user_id: requestingUserId,
user_email: req.user!.email,
action: AuditAction.DELETE,
resource_type: AuditResourceType.USER,
resource_id: targetUserId,
old_value: { email: targetUser.email, name: targetUser.name },
new_value: { purged_tables: result },
ip_address: req.ip ?? null,
user_agent: req.headers['user-agent'] ?? null,
metadata: { operation: 'purge_user_data' },
});
res.json({ success: true, data: result });
} catch (error) {
const message = error instanceof Error ? error.message : 'Fehler beim Löschen der Benutzerdaten';
if (message === 'Cannot purge your own data') {
res.status(400).json({ success: false, message: 'Eigene Daten können nicht gelöscht werden' });
return;
}
logger.error('Failed to purge user data', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Löschen der Benutzerdaten' });
}
}
);
export default router;

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
*/