new features
This commit is contained in:
@@ -215,6 +215,25 @@ class PermissionController {
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Speichern der Konfiguration' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/permissions/users-with?permission=bestellungen:create
|
||||
* Returns users who have a specific permission.
|
||||
*/
|
||||
async getUsersWithPermission(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const permission = req.query.permission as string;
|
||||
if (!permission) {
|
||||
res.status(400).json({ success: false, message: 'permission query parameter required' });
|
||||
return;
|
||||
}
|
||||
const users = await permissionService.getUsersWithPermission(permission);
|
||||
res.json({ success: true, data: users });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get users with permission', { error });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Laden der Benutzer' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new PermissionController();
|
||||
|
||||
@@ -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 pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const router = Router();
|
||||
@@ -264,4 +265,64 @@ router.delete(
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/admin/users/:userId/sync-data — selective sync data deletion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const syncDataBodySchema = z.object({
|
||||
types: z.array(z.enum(['profile', 'ausbildung', 'untersuchungen', 'fuehrerschein', 'befoerderungen'])).min(1),
|
||||
});
|
||||
|
||||
const SYNC_TABLE_MAP: Record<string, string> = {
|
||||
profile: 'mitglieder_profile',
|
||||
ausbildung: 'ausbildungen',
|
||||
untersuchungen: 'untersuchungen',
|
||||
fuehrerschein: 'fahrgenehmigungen',
|
||||
befoerderungen: 'befoerderungen',
|
||||
};
|
||||
|
||||
router.delete(
|
||||
'/users/:userId/sync-data',
|
||||
authenticate,
|
||||
requirePermission('admin:write'),
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const userId = req.params.userId;
|
||||
const { types } = syncDataBodySchema.parse(req.body);
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const results: Record<string, number> = {};
|
||||
|
||||
for (const type of types) {
|
||||
const table = SYNC_TABLE_MAP[type];
|
||||
if (!table) continue;
|
||||
const result = await client.query(
|
||||
`DELETE FROM ${table} WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
results[type] = result.rowCount ?? 0;
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
logger.info('Admin deleted sync data', { userId, types, results, admin: req.user?.id });
|
||||
res.json({ success: true, data: results });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({ success: false, message: 'Invalid parameters', errors: error.issues });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to delete sync data', { error, userId: req.params.userId });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Löschen der Sync-Daten' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -22,8 +22,8 @@ router.get('/:id/status-history', authenticate, equipmentController.getStatusHi
|
||||
router.post('/', authenticate, requirePermission('ausruestung:create'), equipmentController.createEquipment.bind(equipmentController));
|
||||
router.patch('/:id', authenticate, requirePermission('ausruestung:create'), equipmentController.updateEquipment.bind(equipmentController));
|
||||
router.patch('/:id/status', authenticate, requirePermission('ausruestung:create'), equipmentController.updateStatus.bind(equipmentController));
|
||||
router.post('/:id/wartung', authenticate, requirePermission('ausruestung:create'), equipmentController.addWartung.bind(equipmentController));
|
||||
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('ausruestung:create'), uploadWartung.single('datei'), equipmentController.uploadWartungFile.bind(equipmentController));
|
||||
router.post('/:id/wartung', authenticate, requirePermission('ausruestung:manage_maintenance'), equipmentController.addWartung.bind(equipmentController));
|
||||
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('ausruestung:manage_maintenance'), uploadWartung.single('datei'), equipmentController.uploadWartungFile.bind(equipmentController));
|
||||
|
||||
// ── Delete — admin only ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ const router = Router();
|
||||
|
||||
// ── User-facing (any authenticated user) ──────────────────────────────────
|
||||
router.get('/me', authenticate, permissionController.getMyPermissions.bind(permissionController));
|
||||
router.get('/users-with', authenticate, permissionController.getUsersWithPermission.bind(permissionController));
|
||||
|
||||
// ── Admin-only routes ─────────────────────────────────────────────────────
|
||||
router.get('/admin/matrix', authenticate, requirePermission('admin:view'), permissionController.getMatrix.bind(permissionController));
|
||||
|
||||
@@ -25,7 +25,7 @@ router.delete('/:id', authenticate, requirePermission('fahrzeuge:delete'), vehic
|
||||
// ── Status + maintenance log — gruppenfuehrer+ ──────────────────────────────
|
||||
|
||||
router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController));
|
||||
router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.addWartung.bind(vehicleController));
|
||||
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('fahrzeuge:change_status'), uploadWartung.single('datei'), vehicleController.uploadWartungFile.bind(vehicleController));
|
||||
router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:manage_maintenance'), vehicleController.addWartung.bind(vehicleController));
|
||||
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('fahrzeuge:manage_maintenance'), uploadWartung.single('datei'), vehicleController.uploadWartungFile.bind(vehicleController));
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -174,13 +174,14 @@ async function getOrderById(id: number) {
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrder(data: { bezeichnung: string; lieferant_id?: number; notizen?: string; budget?: number }, userId: string) {
|
||||
async function createOrder(data: { bezeichnung: string; lieferant_id?: number; besteller_id?: string; notizen?: string; budget?: number }, userId: string) {
|
||||
try {
|
||||
const bestellerId = data.besteller_id && data.besteller_id.trim() ? data.besteller_id.trim() : null;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO bestellungen (bezeichnung, lieferant_id, notizen, budget, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`INSERT INTO bestellungen (bezeichnung, lieferant_id, besteller_id, notizen, budget, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[data.bezeichnung, data.lieferant_id || null, data.notizen || null, data.budget || null, userId]
|
||||
[data.bezeichnung, data.lieferant_id || null, bestellerId, data.notizen || null, data.budget || null, userId]
|
||||
);
|
||||
const order = result.rows[0];
|
||||
await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId);
|
||||
|
||||
@@ -248,7 +248,8 @@ class EventsService {
|
||||
k.farbe AS kategorie_farbe,
|
||||
k.icon AS kategorie_icon,
|
||||
v.datum_von, v.datum_bis, v.ganztaegig,
|
||||
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich
|
||||
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich,
|
||||
v.wiederholung, v.wiederholung_parent_id
|
||||
FROM veranstaltungen v
|
||||
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
|
||||
WHERE (v.datum_von BETWEEN $1 AND $2 OR v.datum_bis BETWEEN $1 AND $2 OR (v.datum_von <= $1 AND v.datum_bis >= $2))
|
||||
@@ -274,7 +275,8 @@ class EventsService {
|
||||
k.farbe AS kategorie_farbe,
|
||||
k.icon AS kategorie_icon,
|
||||
v.datum_von, v.datum_bis, v.ganztaegig,
|
||||
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich
|
||||
v.alle_gruppen, v.zielgruppen, v.abgesagt, v.anmeldung_erforderlich,
|
||||
v.wiederholung, v.wiederholung_parent_id
|
||||
FROM veranstaltungen v
|
||||
LEFT JOIN veranstaltung_kategorien k ON k.id = v.kategorie_id
|
||||
WHERE v.datum_von > NOW()
|
||||
@@ -454,6 +456,8 @@ class EventsService {
|
||||
|
||||
/**
|
||||
* Updates an existing event.
|
||||
* If the event is a recurrence parent and wiederholung is provided,
|
||||
* it deletes all future instances and regenerates them.
|
||||
* Returns the updated record or null if not found.
|
||||
*/
|
||||
async updateEvent(id: string, data: UpdateVeranstaltungData): Promise<Veranstaltung | null> {
|
||||
@@ -475,6 +479,7 @@ class EventsService {
|
||||
max_teilnehmer: data.max_teilnehmer,
|
||||
anmeldung_erforderlich: data.anmeldung_erforderlich,
|
||||
anmeldung_bis: data.anmeldung_bis,
|
||||
wiederholung: data.wiederholung,
|
||||
};
|
||||
|
||||
for (const [col, val] of Object.entries(fieldMap)) {
|
||||
@@ -494,7 +499,58 @@ class EventsService {
|
||||
values
|
||||
);
|
||||
if (result.rows.length === 0) return null;
|
||||
return rowToVeranstaltung(result.rows[0]);
|
||||
|
||||
const updated = result.rows[0];
|
||||
|
||||
// If this is a recurrence parent and wiederholung was updated, regenerate instances
|
||||
if (data.wiederholung !== undefined && updated.wiederholung_parent_id === null) {
|
||||
// Delete all existing children of this parent
|
||||
await pool.query(
|
||||
`DELETE FROM veranstaltungen WHERE wiederholung_parent_id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (data.wiederholung) {
|
||||
// Regenerate instances from the (possibly new) dates and config
|
||||
const datumVon = data.datum_von ? new Date(data.datum_von) : new Date(updated.datum_von);
|
||||
const datumBis = data.datum_bis ? new Date(data.datum_bis) : new Date(updated.datum_bis);
|
||||
const occurrenceDates = this.generateRecurrenceDates(datumVon, datumBis, data.wiederholung);
|
||||
if (occurrenceDates.length > 0) {
|
||||
const duration = datumBis.getTime() - datumVon.getTime();
|
||||
for (const occDate of occurrenceDates) {
|
||||
const occBis = new Date(occDate.getTime() + duration);
|
||||
await pool.query(
|
||||
`INSERT INTO veranstaltungen (
|
||||
wiederholung_parent_id, titel, beschreibung, ort, ort_url, kategorie_id,
|
||||
datum_von, datum_bis, ganztaegig, zielgruppen, alle_gruppen,
|
||||
max_teilnehmer, anmeldung_erforderlich, erstellt_von
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
|
||||
[
|
||||
id,
|
||||
updated.titel,
|
||||
updated.beschreibung ?? null,
|
||||
updated.ort ?? null,
|
||||
updated.ort_url ?? null,
|
||||
updated.kategorie_id ?? null,
|
||||
occDate,
|
||||
occBis,
|
||||
updated.ganztaegig,
|
||||
updated.zielgruppen,
|
||||
updated.alle_gruppen,
|
||||
updated.max_teilnehmer ?? null,
|
||||
updated.anmeldung_erforderlich,
|
||||
updated.erstellt_von,
|
||||
]
|
||||
);
|
||||
}
|
||||
logger.info(`Regenerated ${occurrenceDates.length} recurrence instances for event ${id}`);
|
||||
}
|
||||
} else {
|
||||
logger.info(`Removed recurrence from event ${id}, all instances deleted`);
|
||||
}
|
||||
}
|
||||
|
||||
return rowToVeranstaltung(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -390,6 +390,31 @@ class PermissionService {
|
||||
]);
|
||||
return { groupHierarchy, permissionDeps };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns users whose Authentik groups grant a specific permission,
|
||||
* or who are dashboard_admin (always have all permissions).
|
||||
*/
|
||||
async getUsersWithPermission(permissionId: string): Promise<Array<{ id: string; name: string }>> {
|
||||
// Find all groups that have this permission
|
||||
const groupsWithPerm: string[] = [];
|
||||
for (const [group, perms] of this.groupPermissions.entries()) {
|
||||
if (perms.has(permissionId)) {
|
||||
groupsWithPerm.push(group);
|
||||
}
|
||||
}
|
||||
// Always include dashboard_admin
|
||||
groupsWithPerm.push('dashboard_admin');
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT DISTINCT u.id, COALESCE(u.name, u.email) AS name
|
||||
FROM users u
|
||||
WHERE u.authentik_groups && $1::text[]
|
||||
ORDER BY name ASC`,
|
||||
[groupsWithPerm]
|
||||
);
|
||||
return result.rows;
|
||||
}
|
||||
}
|
||||
|
||||
export const permissionService = new PermissionService();
|
||||
|
||||
@@ -37,16 +37,15 @@ async function createItem(
|
||||
beschreibung?: string;
|
||||
kategorie?: string;
|
||||
geschaetzter_preis?: number;
|
||||
url?: string;
|
||||
aktiv?: boolean;
|
||||
},
|
||||
userId: string,
|
||||
) {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, url, aktiv, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, $5, COALESCE($6, true), $7)
|
||||
`INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, aktiv, erstellt_von)
|
||||
VALUES ($1, $2, $3, $4, COALESCE($5, true), $6)
|
||||
RETURNING *`,
|
||||
[data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.url || null, data.aktiv ?? true, userId],
|
||||
[data.bezeichnung, data.beschreibung || null, data.kategorie || null, data.geschaetzter_preis || null, data.aktiv ?? true, userId],
|
||||
);
|
||||
return result.rows[0];
|
||||
}
|
||||
@@ -58,10 +57,9 @@ async function updateItem(
|
||||
beschreibung?: string;
|
||||
kategorie?: string;
|
||||
geschaetzter_preis?: number;
|
||||
url?: string;
|
||||
aktiv?: boolean;
|
||||
},
|
||||
userId: string,
|
||||
_userId: string,
|
||||
) {
|
||||
const fields: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
@@ -82,10 +80,6 @@ async function updateItem(
|
||||
params.push(data.geschaetzter_preis);
|
||||
fields.push(`geschaetzter_preis = $${params.length}`);
|
||||
}
|
||||
if (data.url !== undefined) {
|
||||
params.push(data.url);
|
||||
fields.push(`url = $${params.length}`);
|
||||
}
|
||||
if (data.aktiv !== undefined) {
|
||||
params.push(data.aktiv);
|
||||
fields.push(`aktiv = $${params.length}`);
|
||||
@@ -95,8 +89,6 @@ async function updateItem(
|
||||
return getItemById(id);
|
||||
}
|
||||
|
||||
params.push(userId);
|
||||
fields.push(`aktualisiert_von = $${params.length}`);
|
||||
params.push(new Date());
|
||||
fields.push(`aktualisiert_am = $${params.length}`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user