new features

This commit is contained in:
Matthias Hochmeister
2026-03-23 18:43:30 +01:00
parent 202a658b8d
commit 1b13e4f89e
31 changed files with 1022 additions and 517 deletions

View File

@@ -101,7 +101,7 @@ import serviceMonitorRoutes from './routes/serviceMonitor.routes';
import settingsRoutes from './routes/settings.routes';
import bannerRoutes from './routes/banner.routes';
import permissionRoutes from './routes/permission.routes';
import shopRoutes from './routes/shop.routes';
import ausruestungsanfrageRoutes from './routes/ausruestungsanfrage.routes';
import issueRoutes from './routes/issue.routes';
app.use('/api/auth', authRoutes);
@@ -126,7 +126,7 @@ app.use('/api/admin/settings', settingsRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/banners', bannerRoutes);
app.use('/api/permissions', permissionRoutes);
app.use('/api/shop', shopRoutes);
app.use('/api/ausruestungsanfragen', ausruestungsanfrageRoutes);
app.use('/api/issues', issueRoutes);
// Static file serving for uploads (authenticated)

View File

@@ -1,9 +1,9 @@
import { Request, Response } from 'express';
import shopService from '../services/shop.service';
import ausruestungsanfrageService from '../services/ausruestungsanfrage.service';
import notificationService from '../services/notification.service';
import logger from '../utils/logger';
class ShopController {
class AusruestungsanfrageController {
// -------------------------------------------------------------------------
// Catalog Items
// -------------------------------------------------------------------------
@@ -12,10 +12,10 @@ class ShopController {
try {
const kategorie = req.query.kategorie as string | undefined;
const aktiv = req.query.aktiv !== undefined ? req.query.aktiv === 'true' : undefined;
const items = await shopService.getItems({ kategorie, aktiv });
const items = await ausruestungsanfrageService.getItems({ kategorie, aktiv });
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('ShopController.getItems error', { error });
logger.error('AusruestungsanfrageController.getItems error', { error });
res.status(500).json({ success: false, message: 'Artikel konnten nicht geladen werden' });
}
}
@@ -23,14 +23,14 @@ class ShopController {
async getItemById(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
const item = await shopService.getItemById(id);
const item = await ausruestungsanfrageService.getItemById(id);
if (!item) {
res.status(404).json({ success: false, message: 'Artikel nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('ShopController.getItemById error', { error });
logger.error('AusruestungsanfrageController.getItemById error', { error });
res.status(500).json({ success: false, message: 'Artikel konnte nicht geladen werden' });
}
}
@@ -42,10 +42,10 @@ class ShopController {
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
return;
}
const item = await shopService.createItem(req.body, req.user!.id);
const item = await ausruestungsanfrageService.createItem(req.body, req.user!.id);
res.status(201).json({ success: true, data: item });
} catch (error) {
logger.error('ShopController.createItem error', { error });
logger.error('AusruestungsanfrageController.createItem error', { error });
res.status(500).json({ success: false, message: 'Artikel konnte nicht erstellt werden' });
}
}
@@ -53,14 +53,14 @@ class ShopController {
async updateItem(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
const item = await shopService.updateItem(id, req.body, req.user!.id);
const item = await ausruestungsanfrageService.updateItem(id, req.body, req.user!.id);
if (!item) {
res.status(404).json({ success: false, message: 'Artikel nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('ShopController.updateItem error', { error });
logger.error('AusruestungsanfrageController.updateItem error', { error });
res.status(500).json({ success: false, message: 'Artikel konnte nicht aktualisiert werden' });
}
}
@@ -68,20 +68,20 @@ class ShopController {
async deleteItem(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
await shopService.deleteItem(id);
await ausruestungsanfrageService.deleteItem(id);
res.status(200).json({ success: true, message: 'Artikel gelöscht' });
} catch (error) {
logger.error('ShopController.deleteItem error', { error });
logger.error('AusruestungsanfrageController.deleteItem error', { error });
res.status(500).json({ success: false, message: 'Artikel konnte nicht gelöscht werden' });
}
}
async getCategories(_req: Request, res: Response): Promise<void> {
try {
const categories = await shopService.getCategories();
const categories = await ausruestungsanfrageService.getCategories();
res.status(200).json({ success: true, data: categories });
} catch (error) {
logger.error('ShopController.getCategories error', { error });
logger.error('AusruestungsanfrageController.getCategories error', { error });
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
}
}
@@ -94,20 +94,20 @@ class ShopController {
try {
const status = req.query.status as string | undefined;
const anfrager_id = req.query.anfrager_id as string | undefined;
const requests = await shopService.getRequests({ status, anfrager_id });
const requests = await ausruestungsanfrageService.getRequests({ status, anfrager_id });
res.status(200).json({ success: true, data: requests });
} catch (error) {
logger.error('ShopController.getRequests error', { error });
logger.error('AusruestungsanfrageController.getRequests error', { error });
res.status(500).json({ success: false, message: 'Anfragen konnten nicht geladen werden' });
}
}
async getMyRequests(req: Request, res: Response): Promise<void> {
try {
const requests = await shopService.getMyRequests(req.user!.id);
const requests = await ausruestungsanfrageService.getMyRequests(req.user!.id);
res.status(200).json({ success: true, data: requests });
} catch (error) {
logger.error('ShopController.getMyRequests error', { error });
logger.error('AusruestungsanfrageController.getMyRequests error', { error });
res.status(500).json({ success: false, message: 'Anfragen konnten nicht geladen werden' });
}
}
@@ -115,14 +115,14 @@ class ShopController {
async getRequestById(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
const request = await shopService.getRequestById(id);
const request = await ausruestungsanfrageService.getRequestById(id);
if (!request) {
res.status(404).json({ success: false, message: 'Anfrage nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: request });
} catch (error) {
logger.error('ShopController.getRequestById error', { error });
logger.error('AusruestungsanfrageController.getRequestById error', { error });
res.status(500).json({ success: false, message: 'Anfrage konnte nicht geladen werden' });
}
}
@@ -150,10 +150,10 @@ class ShopController {
}
}
const request = await shopService.createRequest(req.user!.id, items, notizen);
const request = await ausruestungsanfrageService.createRequest(req.user!.id, items, notizen);
res.status(201).json({ success: true, data: request });
} catch (error) {
logger.error('ShopController.createRequest error', { error });
logger.error('AusruestungsanfrageController.createRequest error', { error });
res.status(500).json({ success: false, message: 'Anfrage konnte nicht erstellt werden' });
}
}
@@ -178,13 +178,13 @@ class ShopController {
}
// Fetch request to get anfrager_id for notification
const existing = await shopService.getRequestById(id);
const existing = await ausruestungsanfrageService.getRequestById(id);
if (!existing) {
res.status(404).json({ success: false, message: 'Anfrage nicht gefunden' });
return;
}
const updated = await shopService.updateRequestStatus(id, status, admin_notizen, req.user!.id);
const updated = await ausruestungsanfrageService.updateRequestStatus(id, status, admin_notizen, req.user!.id);
// Notify requester on status changes
if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) {
@@ -193,19 +193,19 @@ class ShopController {
: `#${id}`;
await notificationService.createNotification({
user_id: existing.anfrager_id,
typ: 'shop_anfrage',
typ: 'ausruestung_anfrage',
titel: status === 'genehmigt' ? 'Anfrage genehmigt' : status === 'abgelehnt' ? 'Anfrage abgelehnt' : `Anfrage ${status}`,
nachricht: `Deine Shop-Anfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`,
nachricht: `Deine Ausrüstungsanfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`,
schwere: status === 'abgelehnt' ? 'warnung' : 'info',
link: '/shop',
link: '/ausruestungsanfrage',
quell_id: String(id),
quell_typ: 'shop_anfrage',
quell_typ: 'ausruestung_anfrage',
});
}
res.status(200).json({ success: true, data: updated });
} catch (error) {
logger.error('ShopController.updateRequestStatus error', { error });
logger.error('AusruestungsanfrageController.updateRequestStatus error', { error });
res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' });
}
}
@@ -213,10 +213,10 @@ class ShopController {
async deleteRequest(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
await shopService.deleteRequest(id);
await ausruestungsanfrageService.deleteRequest(id);
res.status(200).json({ success: true, message: 'Anfrage gelöscht' });
} catch (error) {
logger.error('ShopController.deleteRequest error', { error });
logger.error('AusruestungsanfrageController.deleteRequest error', { error });
res.status(500).json({ success: false, message: 'Anfrage konnte nicht gelöscht werden' });
}
}
@@ -227,10 +227,10 @@ class ShopController {
async getOverview(_req: Request, res: Response): Promise<void> {
try {
const overview = await shopService.getOverview();
const overview = await ausruestungsanfrageService.getOverview();
res.status(200).json({ success: true, data: overview });
} catch (error) {
logger.error('ShopController.getOverview error', { error });
logger.error('AusruestungsanfrageController.getOverview error', { error });
res.status(500).json({ success: false, message: 'Übersicht konnte nicht geladen werden' });
}
}
@@ -249,10 +249,10 @@ class ShopController {
return;
}
await shopService.linkToOrder(anfrageId, bestellung_id);
await ausruestungsanfrageService.linkToOrder(anfrageId, bestellung_id);
res.status(200).json({ success: true, message: 'Verknüpfung erstellt' });
} catch (error) {
logger.error('ShopController.linkToOrder error', { error });
logger.error('AusruestungsanfrageController.linkToOrder error', { error });
res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht erstellt werden' });
}
}
@@ -261,13 +261,13 @@ class ShopController {
try {
const anfrageId = Number(req.params.id);
const bestellungId = Number(req.params.bestellungId);
await shopService.unlinkFromOrder(anfrageId, bestellungId);
await ausruestungsanfrageService.unlinkFromOrder(anfrageId, bestellungId);
res.status(200).json({ success: true, message: 'Verknüpfung entfernt' });
} catch (error) {
logger.error('ShopController.unlinkFromOrder error', { error });
logger.error('AusruestungsanfrageController.unlinkFromOrder error', { error });
res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht entfernt werden' });
}
}
}
export default new ShopController();
export default new AusruestungsanfrageController();

View File

@@ -67,13 +67,25 @@ class IssueController {
return;
}
try {
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canManage = permissionService.hasPermission(groups, 'issues:manage');
if (!canManage) {
const existing = await issueService.getIssueById(id);
if (!existing) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
const isOwner = existing.erstellt_von === userId;
if (!canManage && !isOwner) {
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
const issue = await issueService.updateIssue(id, req.body);
// Owners without manage permission can only change status
const updateData = canManage ? req.body : { status: req.body.status };
const issue = await issueService.updateIssue(id, updateData);
if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;

View File

@@ -242,6 +242,64 @@ class PermissionController {
res.status(500).json({ success: false, message: 'Fehler beim Laden der Benutzer' });
}
}
/**
* GET /api/permissions/debug/:userId
* Returns debug info for a specific user: their groups, resolved permissions,
* and maintenance flags. Admin only.
*/
async debugUser(req: Request, res: Response): Promise<void> {
try {
const userId = req.params.userId as string;
// Fetch user's Authentik groups from DB
const { pool } = await import('../config/database');
const userResult = await pool.query(
'SELECT authentik_groups, email, name FROM users WHERE id = $1',
[userId]
);
if (userResult.rows.length === 0) {
res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
return;
}
const user = userResult.rows[0];
const groups: string[] = user.authentik_groups ?? [];
const isAdmin = groups.includes('dashboard_admin');
// Resolve permissions for those groups
let permissions: string[];
if (isAdmin) {
const matrix = await permissionService.getMatrix();
permissions = matrix.permissions.map(p => p.id);
} else {
permissions = permissionService.getEffectivePermissions(groups);
}
// Maintenance flags
const maintenance = permissionService.getMaintenanceFlags();
const maintenanceActive = Object.entries(maintenance)
.filter(([, active]) => active)
.map(([featureGroup]) => featureGroup);
res.json({
success: true,
data: {
userId,
email: user.email,
name: user.name,
authentikGroups: groups,
isAdmin,
permissions,
maintenance,
maintenanceActiveFeatureGroups: maintenanceActive,
},
});
} catch (error) {
logger.error('Failed to debug user permissions', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Debug-Informationen' });
}
}
}
export default new PermissionController();

View File

@@ -0,0 +1,59 @@
-- Migration 046: Rename Shop → Ausrüstungsanfrage
-- Renames all shop_* tables and updates permission references.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Rename tables
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE IF EXISTS shop_artikel RENAME TO ausruestung_artikel;
ALTER TABLE IF EXISTS shop_anfragen RENAME TO ausruestung_anfragen;
ALTER TABLE IF EXISTS shop_anfrage_positionen RENAME TO ausruestung_anfrage_positionen;
ALTER TABLE IF EXISTS shop_anfrage_bestellung RENAME TO ausruestung_anfrage_bestellung;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Rename indexes
-- ═══════════════════════════════════════════════════════════════════════════
ALTER INDEX IF EXISTS idx_shop_artikel_kategorie RENAME TO idx_ausruestung_artikel_kategorie;
ALTER INDEX IF EXISTS idx_shop_artikel_aktiv RENAME TO idx_ausruestung_artikel_aktiv;
ALTER INDEX IF EXISTS idx_shop_anfragen_anfrager RENAME TO idx_ausruestung_anfragen_anfrager;
ALTER INDEX IF EXISTS idx_shop_anfragen_status RENAME TO idx_ausruestung_anfragen_status;
ALTER INDEX IF EXISTS idx_shop_anfrage_positionen_anfrage RENAME TO idx_ausruestung_anfrage_positionen_anfrage;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Rename triggers
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TRIGGER IF EXISTS trg_shop_artikel_aktualisiert ON ausruestung_artikel RENAME TO trg_ausruestung_artikel_aktualisiert;
ALTER TRIGGER IF EXISTS trg_shop_anfragen_aktualisiert ON ausruestung_anfragen RENAME TO trg_ausruestung_anfragen_aktualisiert;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Update feature_groups
-- ═══════════════════════════════════════════════════════════════════════════
UPDATE feature_groups SET id = 'ausruestungsanfrage' WHERE id = 'shop';
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. Update permissions
-- ═══════════════════════════════════════════════════════════════════════════
UPDATE permissions SET
id = REPLACE(id, 'shop:', 'ausruestungsanfrage:'),
feature_group_id = 'ausruestungsanfrage'
WHERE feature_group_id = 'shop';
-- ═══════════════════════════════════════════════════════════════════════════
-- 6. Update group_permissions
-- ═══════════════════════════════════════════════════════════════════════════
UPDATE group_permissions SET
permission_id = REPLACE(permission_id, 'shop:', 'ausruestungsanfrage:')
WHERE permission_id LIKE 'shop:%';
-- ═══════════════════════════════════════════════════════════════════════════
-- 7. Update notification quell_typ references
-- ═══════════════════════════════════════════════════════════════════════════
UPDATE benachrichtigungen SET quell_typ = 'ausruestung_anfrage' WHERE quell_typ = 'shop_anfrage';
UPDATE benachrichtigungen SET typ = 'ausruestung_anfrage' WHERE typ = 'shop_anfrage';
UPDATE benachrichtigungen SET link = '/ausruestungsanfrage' WHERE link = '/shop';

View File

@@ -265,6 +265,41 @@ router.delete(
}
);
// ---------------------------------------------------------------------------
// Reset / Truncate endpoints (no olderThanDays, just ?confirm=true)
// ---------------------------------------------------------------------------
type ResetTarget = 'reset-bestellungen' | 'reset-ausruestung-anfragen' | 'reset-issues' | 'issues-all';
const RESET_TARGETS: Record<ResetTarget, (confirm: boolean) => Promise<{ count: number; deleted: boolean }>> = {
'reset-bestellungen': (c) => cleanupService.resetBestellungenSequence(c),
'reset-ausruestung-anfragen': (c) => cleanupService.resetAusruestungAnfragenSequence(c),
'reset-issues': (c) => cleanupService.resetIssuesSequence(c),
'issues-all': (c) => cleanupService.resetIssuesSequence(c),
};
router.delete(
'/cleanup/:resetTarget(reset-bestellungen|reset-ausruestung-anfragen|reset-issues|issues-all)',
authenticate,
requirePermission('admin:write'),
async (req: Request, res: Response): Promise<void> => {
try {
const target = req.params.resetTarget as ResetTarget;
const handler = RESET_TARGETS[target];
if (!handler) {
res.status(400).json({ success: false, message: `Unknown reset target: ${target}` });
return;
}
const confirm = req.query.confirm === 'true';
const result = await handler(confirm);
res.json({ success: true, data: result });
} catch (error) {
logger.error('Reset failed', { error, target: req.params.resetTarget });
res.status(500).json({ success: false, message: 'Reset failed' });
}
}
);
// ---------------------------------------------------------------------------
// DELETE /api/admin/users/:userId/sync-data — selective sync data deletion
// ---------------------------------------------------------------------------

View File

@@ -0,0 +1,44 @@
import { Router } from 'express';
import ausruestungsanfrageController from '../controllers/ausruestungsanfrage.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
// ---------------------------------------------------------------------------
// Catalog Items
// ---------------------------------------------------------------------------
router.get('/items', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getItems.bind(ausruestungsanfrageController));
router.get('/items/:id', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getItemById.bind(ausruestungsanfrageController));
router.post('/items', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.createItem.bind(ausruestungsanfrageController));
router.patch('/items/:id', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.updateItem.bind(ausruestungsanfrageController));
router.delete('/items/:id', authenticate, requirePermission('ausruestungsanfrage:manage_catalog'), ausruestungsanfrageController.deleteItem.bind(ausruestungsanfrageController));
router.get('/categories', authenticate, requirePermission('ausruestungsanfrage:view'), ausruestungsanfrageController.getCategories.bind(ausruestungsanfrageController));
// ---------------------------------------------------------------------------
// Overview
// ---------------------------------------------------------------------------
router.get('/overview', authenticate, requirePermission('ausruestungsanfrage:view_overview'), ausruestungsanfrageController.getOverview.bind(ausruestungsanfrageController));
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
router.get('/requests', authenticate, requirePermission('ausruestungsanfrage:approve_requests'), ausruestungsanfrageController.getRequests.bind(ausruestungsanfrageController));
router.get('/requests/my', authenticate, ausruestungsanfrageController.getMyRequests.bind(ausruestungsanfrageController));
router.get('/requests/:id', authenticate, ausruestungsanfrageController.getRequestById.bind(ausruestungsanfrageController));
router.post('/requests', authenticate, requirePermission('ausruestungsanfrage:create_request'), ausruestungsanfrageController.createRequest.bind(ausruestungsanfrageController));
router.patch('/requests/:id/status', authenticate, requirePermission('ausruestungsanfrage:approve_requests'), ausruestungsanfrageController.updateRequestStatus.bind(ausruestungsanfrageController));
router.delete('/requests/:id', authenticate, requirePermission('ausruestungsanfrage:approve_requests'), ausruestungsanfrageController.deleteRequest.bind(ausruestungsanfrageController));
// ---------------------------------------------------------------------------
// Linking requests to orders
// ---------------------------------------------------------------------------
router.post('/requests/:id/link', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.linkToOrder.bind(ausruestungsanfrageController));
router.delete('/requests/:id/link/:bestellungId', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.unlinkFromOrder.bind(ausruestungsanfrageController));
export default router;

View File

@@ -19,5 +19,6 @@ router.put('/admin/group/:groupName', authenticate, requirePermission('admin:wri
router.delete('/admin/group/:groupName', authenticate, requirePermission('admin:write'), permissionController.deleteGroup.bind(permissionController));
router.put('/admin/bulk', authenticate, requirePermission('admin:write'), permissionController.setBulkPermissions.bind(permissionController));
router.put('/admin/maintenance/:featureGroupId', authenticate, requirePermission('admin:write'), permissionController.setMaintenanceFlag.bind(permissionController));
router.get('/debug/:userId', authenticate, requirePermission('admin:write'), permissionController.debugUser.bind(permissionController));
export default router;

View File

@@ -1,44 +0,0 @@
import { Router } from 'express';
import shopController from '../controllers/shop.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
// ---------------------------------------------------------------------------
// Catalog Items
// ---------------------------------------------------------------------------
router.get('/items', authenticate, requirePermission('shop:view'), shopController.getItems.bind(shopController));
router.get('/items/:id', authenticate, requirePermission('shop:view'), shopController.getItemById.bind(shopController));
router.post('/items', authenticate, requirePermission('shop:manage_catalog'), shopController.createItem.bind(shopController));
router.patch('/items/:id', authenticate, requirePermission('shop:manage_catalog'), shopController.updateItem.bind(shopController));
router.delete('/items/:id', authenticate, requirePermission('shop:manage_catalog'), shopController.deleteItem.bind(shopController));
router.get('/categories', authenticate, requirePermission('shop:view'), shopController.getCategories.bind(shopController));
// ---------------------------------------------------------------------------
// Overview
// ---------------------------------------------------------------------------
router.get('/overview', authenticate, requirePermission('shop:view_overview'), shopController.getOverview.bind(shopController));
// ---------------------------------------------------------------------------
// Requests
// ---------------------------------------------------------------------------
router.get('/requests', authenticate, requirePermission('shop:approve_requests'), shopController.getRequests.bind(shopController));
router.get('/requests/my', authenticate, shopController.getMyRequests.bind(shopController));
router.get('/requests/:id', authenticate, shopController.getRequestById.bind(shopController));
router.post('/requests', authenticate, requirePermission('shop:create_request'), shopController.createRequest.bind(shopController));
router.patch('/requests/:id/status', authenticate, requirePermission('shop:approve_requests'), shopController.updateRequestStatus.bind(shopController));
router.delete('/requests/:id', authenticate, requirePermission('shop:approve_requests'), shopController.deleteRequest.bind(shopController));
// ---------------------------------------------------------------------------
// Linking requests to orders
// ---------------------------------------------------------------------------
router.post('/requests/:id/link', authenticate, requirePermission('shop:link_orders'), shopController.linkToOrder.bind(shopController));
router.delete('/requests/:id/link/:bestellungId', authenticate, requirePermission('shop:link_orders'), shopController.unlinkFromOrder.bind(shopController));
export default router;

View File

@@ -2,7 +2,7 @@ import pool from '../config/database';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// Catalog Items (shop_artikel)
// Catalog Items (ausruestung_artikel)
// ---------------------------------------------------------------------------
async function getItems(filters?: { kategorie?: string; aktiv?: boolean }) {
@@ -20,14 +20,14 @@ async function getItems(filters?: { kategorie?: string; aktiv?: boolean }) {
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const result = await pool.query(
`SELECT * FROM shop_artikel ${where} ORDER BY kategorie, bezeichnung`,
`SELECT * FROM ausruestung_artikel ${where} ORDER BY kategorie, bezeichnung`,
params,
);
return result.rows;
}
async function getItemById(id: number) {
const result = await pool.query('SELECT * FROM shop_artikel WHERE id = $1', [id]);
const result = await pool.query('SELECT * FROM ausruestung_artikel WHERE id = $1', [id]);
return result.rows[0] || null;
}
@@ -42,7 +42,7 @@ async function createItem(
userId: string,
) {
const result = await pool.query(
`INSERT INTO shop_artikel (bezeichnung, beschreibung, kategorie, geschaetzter_preis, aktiv, erstellt_von)
`INSERT INTO ausruestung_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.aktiv ?? true, userId],
@@ -94,25 +94,25 @@ async function updateItem(
params.push(id);
const result = await pool.query(
`UPDATE shop_artikel SET ${fields.join(', ')} WHERE id = $${params.length} RETURNING *`,
`UPDATE ausruestung_artikel SET ${fields.join(', ')} WHERE id = $${params.length} RETURNING *`,
params,
);
return result.rows[0] || null;
}
async function deleteItem(id: number) {
await pool.query('DELETE FROM shop_artikel WHERE id = $1', [id]);
await pool.query('DELETE FROM ausruestung_artikel WHERE id = $1', [id]);
}
async function getCategories() {
const result = await pool.query(
'SELECT DISTINCT kategorie FROM shop_artikel WHERE kategorie IS NOT NULL ORDER BY kategorie',
'SELECT DISTINCT kategorie FROM ausruestung_artikel WHERE kategorie IS NOT NULL ORDER BY kategorie',
);
return result.rows.map((r: { kategorie: string }) => r.kategorie);
}
// ---------------------------------------------------------------------------
// Requests (shop_anfragen)
// Requests (ausruestung_anfragen)
// ---------------------------------------------------------------------------
async function getRequests(filters?: { status?: string; anfrager_id?: string }) {
@@ -133,8 +133,8 @@ async function getRequests(filters?: { status?: string; anfrager_id?: string })
`SELECT a.*,
u.vorname || ' ' || u.nachname AS anfrager_name,
u2.vorname || ' ' || u2.nachname AS bearbeitet_von_name,
(SELECT COUNT(*)::int FROM shop_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count
FROM shop_anfragen a
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count
FROM ausruestung_anfragen a
LEFT JOIN users u ON u.id = a.anfrager_id
LEFT JOIN users u2 ON u2.id = a.bearbeitet_von
${where}
@@ -147,8 +147,8 @@ async function getRequests(filters?: { status?: string; anfrager_id?: string })
async function getMyRequests(userId: string) {
const result = await pool.query(
`SELECT a.*,
(SELECT COUNT(*)::int FROM shop_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count
FROM shop_anfragen a
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count
FROM ausruestung_anfragen a
WHERE a.anfrager_id = $1
ORDER BY a.erstellt_am DESC`,
[userId],
@@ -161,7 +161,7 @@ async function getRequestById(id: number) {
`SELECT a.*,
u.vorname || ' ' || u.nachname AS anfrager_name,
u2.vorname || ' ' || u2.nachname AS bearbeitet_von_name
FROM shop_anfragen a
FROM ausruestung_anfragen a
LEFT JOIN users u ON u.id = a.anfrager_id
LEFT JOIN users u2 ON u2.id = a.bearbeitet_von
WHERE a.id = $1`,
@@ -171,8 +171,8 @@ async function getRequestById(id: number) {
const positionen = await pool.query(
`SELECT p.*, sa.bezeichnung AS artikel_bezeichnung, sa.kategorie AS artikel_kategorie
FROM shop_anfrage_positionen p
LEFT JOIN shop_artikel sa ON sa.id = p.artikel_id
FROM ausruestung_anfrage_positionen p
LEFT JOIN ausruestung_artikel sa ON sa.id = p.artikel_id
WHERE p.anfrage_id = $1
ORDER BY p.id`,
[id],
@@ -180,7 +180,7 @@ async function getRequestById(id: number) {
const bestellungen = await pool.query(
`SELECT b.*
FROM shop_anfrage_bestellung ab
FROM ausruestung_anfrage_bestellung ab
JOIN bestellungen b ON b.id = ab.bestellung_id
WHERE ab.anfrage_id = $1`,
[id],
@@ -206,14 +206,14 @@ async function createRequest(
const currentYear = new Date().getFullYear();
const maxResult = await client.query(
`SELECT COALESCE(MAX(bestell_nummer), 0) + 1 AS next_nr
FROM shop_anfragen
FROM ausruestung_anfragen
WHERE bestell_jahr = $1`,
[currentYear],
);
const nextNr = maxResult.rows[0].next_nr;
const anfrageResult = await client.query(
`INSERT INTO shop_anfragen (anfrager_id, notizen, bestell_nummer, bestell_jahr)
`INSERT INTO ausruestung_anfragen (anfrager_id, notizen, bestell_nummer, bestell_jahr)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[userId, notizen || null, nextNr, currentYear],
@@ -226,7 +226,7 @@ async function createRequest(
// If artikel_id is provided, copy bezeichnung from catalog
if (item.artikel_id) {
const artikelResult = await client.query(
'SELECT bezeichnung FROM shop_artikel WHERE id = $1',
'SELECT bezeichnung FROM ausruestung_artikel WHERE id = $1',
[item.artikel_id],
);
if (artikelResult.rows.length > 0) {
@@ -235,7 +235,7 @@ async function createRequest(
}
await client.query(
`INSERT INTO shop_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen)
`INSERT INTO ausruestung_anfrage_positionen (anfrage_id, artikel_id, bezeichnung, menge, notizen)
VALUES ($1, $2, $3, $4, $5)`,
[anfrage.id, item.artikel_id || null, bezeichnung, item.menge, item.notizen || null],
);
@@ -245,7 +245,7 @@ async function createRequest(
return getRequestById(anfrage.id);
} catch (error) {
await client.query('ROLLBACK');
logger.error('shopService.createRequest failed', { error });
logger.error('ausruestungsanfrageService.createRequest failed', { error });
throw error;
} finally {
client.release();
@@ -259,7 +259,7 @@ async function updateRequestStatus(
bearbeitetVon?: string,
) {
const result = await pool.query(
`UPDATE shop_anfragen
`UPDATE ausruestung_anfragen
SET status = $1,
admin_notizen = COALESCE($2, admin_notizen),
bearbeitet_von = COALESCE($3, bearbeitet_von),
@@ -272,16 +272,16 @@ async function updateRequestStatus(
}
async function deleteRequest(id: number) {
await pool.query('DELETE FROM shop_anfragen WHERE id = $1', [id]);
await pool.query('DELETE FROM ausruestung_anfragen WHERE id = $1', [id]);
}
// ---------------------------------------------------------------------------
// Linking (shop_anfrage_bestellung)
// Linking (ausruestung_anfrage_bestellung)
// ---------------------------------------------------------------------------
async function linkToOrder(anfrageId: number, bestellungId: number) {
await pool.query(
`INSERT INTO shop_anfrage_bestellung (anfrage_id, bestellung_id)
`INSERT INTO ausruestung_anfrage_bestellung (anfrage_id, bestellung_id)
VALUES ($1, $2)
ON CONFLICT DO NOTHING`,
[anfrageId, bestellungId],
@@ -290,7 +290,7 @@ async function linkToOrder(anfrageId: number, bestellungId: number) {
async function unlinkFromOrder(anfrageId: number, bestellungId: number) {
await pool.query(
'DELETE FROM shop_anfrage_bestellung WHERE anfrage_id = $1 AND bestellung_id = $2',
'DELETE FROM ausruestung_anfrage_bestellung WHERE anfrage_id = $1 AND bestellung_id = $2',
[anfrageId, bestellungId],
);
}
@@ -298,7 +298,7 @@ async function unlinkFromOrder(anfrageId: number, bestellungId: number) {
async function getLinkedOrders(anfrageId: number) {
const result = await pool.query(
`SELECT b.*
FROM shop_anfrage_bestellung ab
FROM ausruestung_anfrage_bestellung ab
JOIN bestellungen b ON b.id = ab.bestellung_id
WHERE ab.anfrage_id = $1`,
[anfrageId],
@@ -315,8 +315,8 @@ async function getOverview() {
`SELECT p.bezeichnung,
SUM(p.menge)::int AS total_menge,
COUNT(DISTINCT p.anfrage_id)::int AS anfrage_count
FROM shop_anfrage_positionen p
JOIN shop_anfragen a ON a.id = p.anfrage_id
FROM ausruestung_anfrage_positionen p
JOIN ausruestung_anfragen a ON a.id = p.anfrage_id
WHERE a.status IN ('offen', 'genehmigt')
GROUP BY p.bezeichnung
ORDER BY total_menge DESC, p.bezeichnung`,
@@ -327,10 +327,10 @@ async function getOverview() {
COUNT(*) FILTER (WHERE status = 'offen')::int AS pending_count,
COUNT(*) FILTER (WHERE status = 'genehmigt')::int AS approved_count,
COALESCE(SUM(sub.total), 0)::int AS total_items
FROM shop_anfragen a
FROM ausruestung_anfragen a
LEFT JOIN LATERAL (
SELECT SUM(p.menge) AS total
FROM shop_anfrage_positionen p
FROM ausruestung_anfrage_positionen p
WHERE p.anfrage_id = a.id
) sub ON true
WHERE a.status IN ('offen', 'genehmigt')`,

View File

@@ -197,8 +197,8 @@ async function createOrder(data: { bezeichnung: string; lieferant_id?: number; b
}
}
await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId);
await client.query('COMMIT');
await logAction(order.id, 'Bestellung erstellt', `Bestellung "${data.bezeichnung}" erstellt`, userId);
return order;
} catch (error) {
await client.query('ROLLBACK');

View File

@@ -126,6 +126,45 @@ class CleanupService {
logger.info(`Cleanup: deleted ${rowCount} equipment history entries older than ${olderThanDays} days`);
return { count: rowCount ?? 0, deleted: true };
}
async resetBestellungenSequence(confirm: boolean): Promise<CleanupResult> {
if (!confirm) {
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM bestellungen');
return { count: rows[0].count, deleted: false };
}
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM bestellungen');
const count = rows[0].count;
await pool.query('TRUNCATE bestellungen CASCADE');
await pool.query('ALTER SEQUENCE bestellungen_id_seq RESTART WITH 1');
logger.info(`Cleanup: truncated bestellungen (${count} rows) and reset sequence`);
return { count, deleted: true };
}
async resetAusruestungAnfragenSequence(confirm: boolean): Promise<CleanupResult> {
if (!confirm) {
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM ausruestung_anfragen');
return { count: rows[0].count, deleted: false };
}
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM ausruestung_anfragen');
const count = rows[0].count;
await pool.query('TRUNCATE ausruestung_anfragen CASCADE');
await pool.query('ALTER SEQUENCE ausruestung_anfragen_id_seq RESTART WITH 1');
logger.info(`Cleanup: truncated ausruestung_anfragen (${count} rows) and reset sequence`);
return { count, deleted: true };
}
async resetIssuesSequence(confirm: boolean): Promise<CleanupResult> {
if (!confirm) {
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM issues');
return { count: rows[0].count, deleted: false };
}
const { rows } = await pool.query('SELECT COUNT(*)::int AS count FROM issues');
const count = rows[0].count;
await pool.query('TRUNCATE issues CASCADE');
await pool.query('ALTER SEQUENCE issues_id_seq RESTART WITH 1');
logger.info(`Cleanup: truncated issues (${count} rows) and reset sequence`);
return { count, deleted: true };
}
}
export default new CleanupService();

View File

@@ -714,7 +714,8 @@ class EventsService {
FROM users
WHERE authentik_groups IS NOT NULL
) g
WHERE group_name != 'dashboard_admin'
WHERE group_name LIKE 'dashboard_%'
AND group_name != 'dashboard_admin'
ORDER BY group_name`
);