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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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