feat: personal equipment tracking, order assignment, purge fix, widget consolidation
- Migration 084: new persoenliche_ausruestung table with catalog link, user assignment, soft delete; adds zuweisung_typ/ausruestung_id/persoenlich_id columns to ausruestung_anfrage_positionen; seeds feature group + 5 permissions - Fix user data purge: table was shop_anfragen, renamed to ausruestung_anfragen in mig 046 — caused full transaction rollback. Also keep mitglieder_profile row but NULL FDISK-synced fields (dienstgrad, geburtsdatum, etc.) instead of deleting the profile - Personal equipment CRUD: backend service/controller/routes at /api/persoenliche-ausruestung; frontend page with DataTable, user filter, catalog Autocomplete, FAB create dialog; widget in Status group; sidebar entry (Checkroom icon); card in MitgliedDetail Tab 0 - Ausruestungsanfrage item assignment: when a request reaches erledigt, auto-opens ItemAssignmentDialog listing all delivered positions; each item can be assigned as general equipment (vehicle/storage), personal item (user, prefilled with requester), or not tracked; POST /requests/:id/assign backend - StatCard refactored to use WidgetCard as outer shell for consistent header styling across all dashboard widget templates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -108,6 +108,7 @@ import checklistRoutes from './routes/checklist.routes';
|
||||
import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
|
||||
import ausruestungTypRoutes from './routes/ausruestungTyp.routes';
|
||||
import buchhaltungRoutes from './routes/buchhaltung.routes';
|
||||
import personalEquipmentRoutes from './routes/personalEquipment.routes';
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
@@ -138,6 +139,7 @@ app.use('/api/checklisten', checklistRoutes);
|
||||
app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
|
||||
app.use('/api/ausruestung-typen', ausruestungTypRoutes);
|
||||
app.use('/api/buchhaltung', buchhaltungRoutes);
|
||||
app.use('/api/persoenliche-ausruestung', personalEquipmentRoutes);
|
||||
|
||||
// Static file serving for uploads (authenticated)
|
||||
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
||||
|
||||
@@ -555,6 +555,54 @@ class AusruestungsanfrageController {
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Assignment of delivered items
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async assignDeliveredItems(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const { assignments } = req.body as {
|
||||
assignments?: Array<{
|
||||
positionId: number;
|
||||
typ: 'ausruestung' | 'persoenlich' | 'keine';
|
||||
fahrzeugId?: string;
|
||||
standort?: string;
|
||||
userId?: string;
|
||||
benutzerName?: string;
|
||||
groesse?: string;
|
||||
kategorie?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
if (!assignments || assignments.length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Mindestens eine Zuweisung ist erforderlich' });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const a of assignments) {
|
||||
if (!a.positionId || !a.typ) {
|
||||
res.status(400).json({ success: false, message: 'positionId und typ sind erforderlich' });
|
||||
return;
|
||||
}
|
||||
if (!['ausruestung', 'persoenlich', 'keine'].includes(a.typ)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültiger Zuweisungstyp' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await ausruestungsanfrageService.assignDeliveredItems(id, req.user!.id, assignments);
|
||||
res.status(200).json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'Anfrage nicht gefunden') {
|
||||
res.status(404).json({ success: false, message: error.message });
|
||||
return;
|
||||
}
|
||||
logger.error('AusruestungsanfrageController.assignDeliveredItems error', { error });
|
||||
res.status(500).json({ success: false, message: 'Zuweisung konnte nicht gespeichert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Widget overview (lightweight, for dashboard widget)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
177
backend/src/controllers/personalEquipment.controller.ts
Normal file
177
backend/src/controllers/personalEquipment.controller.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import personalEquipmentService from '../services/personalEquipment.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const uuidString = z.string().regex(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
'Ungültige UUID',
|
||||
);
|
||||
|
||||
const isoDate = z.string().regex(
|
||||
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
|
||||
'Erwartet ISO-Datum im Format YYYY-MM-DD',
|
||||
);
|
||||
|
||||
const ZustandEnum = z.enum(['gut', 'beschaedigt', 'abgaengig', 'verloren']);
|
||||
|
||||
const CreateSchema = z.object({
|
||||
bezeichnung: z.string().min(1).max(200),
|
||||
kategorie: z.string().max(100).optional(),
|
||||
artikel_id: z.number().int().positive().optional(),
|
||||
user_id: uuidString.optional(),
|
||||
benutzer_name: z.string().max(200).optional(),
|
||||
groesse: z.string().max(50).optional(),
|
||||
seriennummer: z.string().max(100).optional(),
|
||||
inventarnummer: z.string().max(50).optional(),
|
||||
anschaffung_datum: isoDate.optional(),
|
||||
zustand: ZustandEnum.optional(),
|
||||
notizen: z.string().max(2000).optional(),
|
||||
});
|
||||
|
||||
const UpdateSchema = z.object({
|
||||
bezeichnung: z.string().min(1).max(200).optional(),
|
||||
kategorie: z.string().max(100).nullable().optional(),
|
||||
artikel_id: z.number().int().positive().nullable().optional(),
|
||||
user_id: uuidString.nullable().optional(),
|
||||
benutzer_name: z.string().max(200).nullable().optional(),
|
||||
groesse: z.string().max(50).nullable().optional(),
|
||||
seriennummer: z.string().max(100).nullable().optional(),
|
||||
inventarnummer: z.string().max(50).nullable().optional(),
|
||||
anschaffung_datum: isoDate.nullable().optional(),
|
||||
zustand: ZustandEnum.optional(),
|
||||
notizen: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
|
||||
function isValidUUID(s: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
||||
}
|
||||
|
||||
class PersonalEquipmentController {
|
||||
async list(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const filters: Record<string, string> = {};
|
||||
if (req.query.user_id) filters.userId = String(req.query.user_id);
|
||||
if (req.query.kategorie) filters.kategorie = String(req.query.kategorie);
|
||||
if (req.query.zustand) filters.zustand = String(req.query.zustand);
|
||||
|
||||
const items = await personalEquipmentService.getAll(filters);
|
||||
res.status(200).json({ success: true, data: items });
|
||||
} catch (error) {
|
||||
logger.error('personalEquipment.list error', { error });
|
||||
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getMy(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const items = await personalEquipmentService.getByUserId(req.user!.id);
|
||||
res.status(200).json({ success: true, data: items });
|
||||
} catch (error) {
|
||||
logger.error('personalEquipment.getMy error', { error });
|
||||
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getByUser(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(userId)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige User-ID' });
|
||||
return;
|
||||
}
|
||||
const items = await personalEquipmentService.getByUserId(userId);
|
||||
res.status(200).json({ success: true, data: items });
|
||||
} catch (error) {
|
||||
logger.error('personalEquipment.getByUser error', { error });
|
||||
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getById(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige ID' });
|
||||
return;
|
||||
}
|
||||
const item = await personalEquipmentService.getById(id);
|
||||
if (!item) {
|
||||
res.status(404).json({ success: false, message: 'Nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true, data: item });
|
||||
} catch (error) {
|
||||
logger.error('personalEquipment.getById error', { error });
|
||||
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const parsed = CreateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ success: false, message: 'Validierungsfehler', errors: parsed.error.flatten().fieldErrors });
|
||||
return;
|
||||
}
|
||||
const item = await personalEquipmentService.create(parsed.data, req.user!.id);
|
||||
res.status(201).json({ success: true, data: item });
|
||||
} catch (error) {
|
||||
logger.error('personalEquipment.create error', { error });
|
||||
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige ID' });
|
||||
return;
|
||||
}
|
||||
const parsed = UpdateSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ success: false, message: 'Validierungsfehler', errors: parsed.error.flatten().fieldErrors });
|
||||
return;
|
||||
}
|
||||
if (Object.keys(parsed.data).length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||
return;
|
||||
}
|
||||
const item = await personalEquipmentService.update(id, parsed.data as any);
|
||||
if (!item) {
|
||||
res.status(404).json({ success: false, message: 'Nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true, data: item });
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'No fields to update') {
|
||||
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||
return;
|
||||
}
|
||||
logger.error('personalEquipment.update error', { error, id: req.params.id });
|
||||
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht aktualisiert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige ID' });
|
||||
return;
|
||||
}
|
||||
const deleted = await personalEquipmentService.delete(id);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ success: false, message: 'Nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true, message: 'Persönliche Ausrüstung gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('personalEquipment.delete error', { error, id: req.params.id });
|
||||
res.status(500).json({ success: false, message: 'Persönliche Ausrüstung konnte nicht gelöscht werden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new PersonalEquipmentController();
|
||||
@@ -0,0 +1,82 @@
|
||||
-- Migration: 084_persoenliche_ausruestung
|
||||
-- Creates persoenliche_ausruestung table, adds assignment columns to
|
||||
-- ausruestung_anfrage_positionen, and seeds feature_group + permissions.
|
||||
|
||||
-- 1. Create persoenliche_ausruestung table
|
||||
CREATE TABLE IF NOT EXISTS persoenliche_ausruestung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bezeichnung TEXT NOT NULL,
|
||||
kategorie TEXT,
|
||||
artikel_id INT REFERENCES ausruestung_artikel(id) ON DELETE SET NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
benutzer_name TEXT,
|
||||
groesse TEXT,
|
||||
seriennummer TEXT,
|
||||
inventarnummer TEXT,
|
||||
anschaffung_datum DATE,
|
||||
zustand TEXT DEFAULT 'gut' CHECK (zustand IN ('gut','beschaedigt','abgaengig','verloren')),
|
||||
notizen TEXT,
|
||||
anfrage_id INT REFERENCES ausruestung_anfragen(id) ON DELETE SET NULL,
|
||||
anfrage_position_id INT REFERENCES ausruestung_anfrage_positionen(id) ON DELETE SET NULL,
|
||||
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
erstellt_am TIMESTAMPTZ DEFAULT NOW(),
|
||||
aktualisiert_am TIMESTAMPTZ DEFAULT NOW(),
|
||||
geloescht_am TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persoenliche_ausruestung_user
|
||||
ON persoenliche_ausruestung(user_id) WHERE geloescht_am IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_persoenliche_ausruestung_artikel
|
||||
ON persoenliche_ausruestung(artikel_id);
|
||||
|
||||
-- Auto-update aktualisiert_am trigger (uses the aktualisiert_am variant from migration 018)
|
||||
CREATE TRIGGER trg_persoenliche_ausruestung_aktualisiert_am
|
||||
BEFORE UPDATE ON persoenliche_ausruestung
|
||||
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am_column();
|
||||
|
||||
-- 2. Add assignment columns to ausruestung_anfrage_positionen
|
||||
ALTER TABLE ausruestung_anfrage_positionen
|
||||
ADD COLUMN IF NOT EXISTS zuweisung_typ TEXT CHECK (zuweisung_typ IN ('ausruestung','persoenlich','keine'));
|
||||
|
||||
ALTER TABLE ausruestung_anfrage_positionen
|
||||
ADD COLUMN IF NOT EXISTS zuweisung_ausruestung_id UUID REFERENCES ausruestung(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ausruestung_anfrage_positionen
|
||||
ADD COLUMN IF NOT EXISTS zuweisung_persoenlich_id UUID REFERENCES persoenliche_ausruestung(id) ON DELETE SET NULL;
|
||||
|
||||
-- 3. Feature group + permissions
|
||||
INSERT INTO feature_groups (id, name, maintenance_mode)
|
||||
VALUES ('persoenliche_ausruestung', 'Persönliche Ausrüstung', false)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO permissions (id, feature_group_id, description) VALUES
|
||||
('persoenliche_ausruestung:view', 'persoenliche_ausruestung', 'Eigene persönliche Ausrüstung anzeigen'),
|
||||
('persoenliche_ausruestung:view_all', 'persoenliche_ausruestung', 'Alle persönliche Ausrüstung anzeigen'),
|
||||
('persoenliche_ausruestung:create', 'persoenliche_ausruestung', 'Persönliche Ausrüstung erstellen'),
|
||||
('persoenliche_ausruestung:edit', 'persoenliche_ausruestung', 'Persönliche Ausrüstung bearbeiten'),
|
||||
('persoenliche_ausruestung:delete', 'persoenliche_ausruestung', 'Persönliche Ausrüstung löschen')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Seed permissions for groups: admin, kommandant, gruppenkommandant, zeugmeister get all; others get view only
|
||||
INSERT INTO group_permissions (group_name, permission_id)
|
||||
SELECT g.name, p.id
|
||||
FROM (VALUES
|
||||
('dashboard_admin'),
|
||||
('dashboard_kommandant'),
|
||||
('dashboard_gruppenkommandant'),
|
||||
('dashboard_zeugmeister')
|
||||
) AS g(name)
|
||||
CROSS JOIN permissions p
|
||||
WHERE p.feature_group_id = 'persoenliche_ausruestung'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- All other groups get view only
|
||||
INSERT INTO group_permissions (group_name, permission_id)
|
||||
SELECT g.name, 'persoenliche_ausruestung:view'
|
||||
FROM (VALUES
|
||||
('dashboard_feuerwehrmitglied'),
|
||||
('dashboard_atemschutztraeger'),
|
||||
('dashboard_fahrmeister'),
|
||||
('dashboard_jugend')
|
||||
) AS g(name)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -71,5 +71,6 @@ router.patch('/positionen/:positionId/zurueckgegeben', authenticate, requirePerm
|
||||
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));
|
||||
router.post('/requests/:id/create-orders', authenticate, requirePermission('ausruestungsanfrage:link_orders'), ausruestungsanfrageController.createOrders.bind(ausruestungsanfrageController));
|
||||
router.post('/requests/:id/assign', authenticate, requirePermission('ausruestungsanfrage:approve'), ausruestungsanfrageController.assignDeliveredItems.bind(ausruestungsanfrageController));
|
||||
|
||||
export default router;
|
||||
|
||||
39
backend/src/routes/personalEquipment.routes.ts
Normal file
39
backend/src/routes/personalEquipment.routes.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Router } from 'express';
|
||||
import personalEquipmentController from '../controllers/personalEquipment.controller';
|
||||
import { authenticate } from '../middleware/auth.middleware';
|
||||
import { requirePermission } from '../middleware/rbac.middleware';
|
||||
import { permissionService } from '../services/permission.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Own items — any authenticated user with view permission
|
||||
router.get('/my', authenticate, personalEquipmentController.getMy.bind(personalEquipmentController));
|
||||
|
||||
// All items — requires view_all
|
||||
router.get('/', authenticate, requirePermission('persoenliche_ausruestung:view_all'), personalEquipmentController.list.bind(personalEquipmentController));
|
||||
|
||||
// By user — own data or view_all
|
||||
router.get('/user/:userId', authenticate, async (req, res, next) => {
|
||||
const isOwn = req.user!.id === req.params.userId;
|
||||
if (isOwn) {
|
||||
const hasView = await permissionService.hasPermission(req.user!.groups ?? [], 'persoenliche_ausruestung:view');
|
||||
if (hasView || req.user!.groups?.includes('dashboard_admin')) return next();
|
||||
}
|
||||
const hasViewAll = await permissionService.hasPermission(req.user!.groups ?? [], 'persoenliche_ausruestung:view_all');
|
||||
if (hasViewAll || req.user!.groups?.includes('dashboard_admin')) return next();
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
|
||||
}, personalEquipmentController.getByUser.bind(personalEquipmentController));
|
||||
|
||||
// Single item
|
||||
router.get('/:id', authenticate, personalEquipmentController.getById.bind(personalEquipmentController));
|
||||
|
||||
// Create
|
||||
router.post('/', authenticate, requirePermission('persoenliche_ausruestung:create'), personalEquipmentController.create.bind(personalEquipmentController));
|
||||
|
||||
// Update
|
||||
router.patch('/:id', authenticate, requirePermission('persoenliche_ausruestung:edit'), personalEquipmentController.update.bind(personalEquipmentController));
|
||||
|
||||
// Delete (soft)
|
||||
router.delete('/:id', authenticate, requirePermission('persoenliche_ausruestung:delete'), personalEquipmentController.delete.bind(personalEquipmentController));
|
||||
|
||||
export default router;
|
||||
@@ -889,6 +889,145 @@ async function getWidgetOverview() {
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Assignment of delivered items
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AssignmentInput {
|
||||
positionId: number;
|
||||
typ: 'ausruestung' | 'persoenlich' | 'keine';
|
||||
fahrzeugId?: string;
|
||||
standort?: string;
|
||||
userId?: string;
|
||||
benutzerName?: string;
|
||||
groesse?: string;
|
||||
kategorie?: string;
|
||||
}
|
||||
|
||||
async function assignDeliveredItems(
|
||||
anfrageId: number,
|
||||
requestingUserId: string,
|
||||
assignments: AssignmentInput[],
|
||||
) {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Load anfrage to get anfrager_id (default user for personal assignments)
|
||||
const anfrageResult = await client.query(
|
||||
'SELECT anfrager_id FROM ausruestung_anfragen WHERE id = $1',
|
||||
[anfrageId],
|
||||
);
|
||||
if (anfrageResult.rows.length === 0) {
|
||||
throw new Error('Anfrage nicht gefunden');
|
||||
}
|
||||
const anfragerId = anfrageResult.rows[0].anfrager_id;
|
||||
|
||||
let assigned = 0;
|
||||
|
||||
for (const a of assignments) {
|
||||
// Load position details
|
||||
const posResult = await client.query(
|
||||
'SELECT bezeichnung, artikel_id FROM ausruestung_anfrage_positionen WHERE id = $1 AND anfrage_id = $2',
|
||||
[a.positionId, anfrageId],
|
||||
);
|
||||
if (posResult.rows.length === 0) continue;
|
||||
const pos = posResult.rows[0];
|
||||
|
||||
if (a.typ === 'ausruestung') {
|
||||
// Look up kategorie_id from artikel if available
|
||||
let kategorieId: string | null = null;
|
||||
if (pos.artikel_id) {
|
||||
const artikelResult = await client.query(
|
||||
'SELECT kategorie_id FROM ausruestung_artikel WHERE id = $1',
|
||||
[pos.artikel_id],
|
||||
);
|
||||
if (artikelResult.rows[0]?.kategorie_id) {
|
||||
// artikel has kategorie_id (int FK to ausruestung_kategorien_katalog), but
|
||||
// ausruestung.kategorie_id is UUID FK to ausruestung_kategorien — look up by name
|
||||
const katNameResult = await client.query(
|
||||
'SELECT name FROM ausruestung_kategorien_katalog WHERE id = $1',
|
||||
[artikelResult.rows[0].kategorie_id],
|
||||
);
|
||||
if (katNameResult.rows[0]?.name) {
|
||||
const katResult = await client.query(
|
||||
'SELECT id FROM ausruestung_kategorien WHERE name = $1 LIMIT 1',
|
||||
[katNameResult.rows[0].name],
|
||||
);
|
||||
kategorieId = katResult.rows[0]?.id ?? null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: pick first category
|
||||
if (!kategorieId) {
|
||||
const fallback = await client.query('SELECT id FROM ausruestung_kategorien LIMIT 1');
|
||||
kategorieId = fallback.rows[0]?.id ?? null;
|
||||
}
|
||||
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO ausruestung (
|
||||
id, bezeichnung, kategorie_id, fahrzeug_id, standort, status, ist_wichtig
|
||||
) VALUES (gen_random_uuid(), $1, $2, $3, $4, 'einsatzbereit', false)
|
||||
RETURNING id`,
|
||||
[pos.bezeichnung, kategorieId, a.fahrzeugId ?? null, a.standort ?? 'Lager'],
|
||||
);
|
||||
const newId = insertResult.rows[0].id;
|
||||
|
||||
await client.query(
|
||||
`UPDATE ausruestung_anfrage_positionen
|
||||
SET zuweisung_typ = 'ausruestung', zuweisung_ausruestung_id = $1
|
||||
WHERE id = $2`,
|
||||
[newId, a.positionId],
|
||||
);
|
||||
} else if (a.typ === 'persoenlich') {
|
||||
const insertResult = await client.query(
|
||||
`INSERT INTO persoenliche_ausruestung (
|
||||
bezeichnung, kategorie, groesse, user_id, benutzer_name,
|
||||
anfrage_id, anfrage_position_id, artikel_id, erstellt_von
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING id`,
|
||||
[
|
||||
pos.bezeichnung,
|
||||
a.kategorie ?? null,
|
||||
a.groesse ?? null,
|
||||
a.userId ?? anfragerId,
|
||||
a.benutzerName ?? null,
|
||||
anfrageId,
|
||||
a.positionId,
|
||||
pos.artikel_id ?? null,
|
||||
requestingUserId,
|
||||
],
|
||||
);
|
||||
const newId = insertResult.rows[0].id;
|
||||
|
||||
await client.query(
|
||||
`UPDATE ausruestung_anfrage_positionen
|
||||
SET zuweisung_typ = 'persoenlich', zuweisung_persoenlich_id = $1
|
||||
WHERE id = $2`,
|
||||
[newId, a.positionId],
|
||||
);
|
||||
} else {
|
||||
// typ === 'keine'
|
||||
await client.query(
|
||||
`UPDATE ausruestung_anfrage_positionen SET zuweisung_typ = 'keine' WHERE id = $1`,
|
||||
[a.positionId],
|
||||
);
|
||||
}
|
||||
|
||||
assigned++;
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
return { assigned };
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
logger.error('ausruestungsanfrageService.assignDeliveredItems failed', { error, anfrageId });
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getAllUsers,
|
||||
getKategorien,
|
||||
@@ -919,4 +1058,5 @@ export default {
|
||||
createOrdersFromRequest,
|
||||
getOverview,
|
||||
getWidgetOverview,
|
||||
assignDeliveredItems,
|
||||
};
|
||||
|
||||
200
backend/src/services/personalEquipment.service.ts
Normal file
200
backend/src/services/personalEquipment.service.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
interface PersonalEquipmentFilters {
|
||||
userId?: string;
|
||||
kategorie?: string;
|
||||
zustand?: string;
|
||||
}
|
||||
|
||||
interface CreatePersonalEquipmentData {
|
||||
bezeichnung: string;
|
||||
kategorie?: string;
|
||||
artikel_id?: number;
|
||||
user_id?: string;
|
||||
benutzer_name?: string;
|
||||
groesse?: string;
|
||||
seriennummer?: string;
|
||||
inventarnummer?: string;
|
||||
anschaffung_datum?: string;
|
||||
zustand?: string;
|
||||
notizen?: string;
|
||||
}
|
||||
|
||||
interface UpdatePersonalEquipmentData {
|
||||
bezeichnung?: string;
|
||||
kategorie?: string;
|
||||
artikel_id?: number | null;
|
||||
user_id?: string | null;
|
||||
benutzer_name?: string | null;
|
||||
groesse?: string | null;
|
||||
seriennummer?: string | null;
|
||||
inventarnummer?: string | null;
|
||||
anschaffung_datum?: string | null;
|
||||
zustand?: string;
|
||||
notizen?: string | null;
|
||||
}
|
||||
|
||||
const BASE_SELECT = `
|
||||
SELECT pa.*,
|
||||
COALESCE(u.given_name || ' ' || u.family_name, u.name) AS user_display_name,
|
||||
aa.bezeichnung AS artikel_bezeichnung
|
||||
FROM persoenliche_ausruestung pa
|
||||
LEFT JOIN users u ON u.id = pa.user_id
|
||||
LEFT JOIN ausruestung_artikel aa ON aa.id = pa.artikel_id
|
||||
WHERE pa.geloescht_am IS NULL
|
||||
`;
|
||||
|
||||
async function getAll(filters: PersonalEquipmentFilters = {}) {
|
||||
try {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
|
||||
if (filters.userId) {
|
||||
params.push(filters.userId);
|
||||
conditions.push(`pa.user_id = $${params.length}`);
|
||||
}
|
||||
if (filters.kategorie) {
|
||||
params.push(filters.kategorie);
|
||||
conditions.push(`pa.kategorie = $${params.length}`);
|
||||
}
|
||||
if (filters.zustand) {
|
||||
params.push(filters.zustand);
|
||||
conditions.push(`pa.zustand = $${params.length}`);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? ` AND ${conditions.join(' AND ')}` : '';
|
||||
const result = await pool.query(
|
||||
`${BASE_SELECT}${where} ORDER BY pa.bezeichnung`,
|
||||
params,
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('personalEquipmentService.getAll failed', { error });
|
||||
throw new Error('Failed to fetch personal equipment');
|
||||
}
|
||||
}
|
||||
|
||||
async function getByUserId(userId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`${BASE_SELECT} AND pa.user_id = $1 ORDER BY pa.bezeichnung`,
|
||||
[userId],
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
logger.error('personalEquipmentService.getByUserId failed', { error, userId });
|
||||
throw new Error('Failed to fetch personal equipment for user');
|
||||
}
|
||||
}
|
||||
|
||||
async function getById(id: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`${BASE_SELECT} AND pa.id = $1`,
|
||||
[id],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
logger.error('personalEquipmentService.getById failed', { error, id });
|
||||
throw new Error('Failed to fetch personal equipment item');
|
||||
}
|
||||
}
|
||||
|
||||
async function create(data: CreatePersonalEquipmentData, requestingUserId: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO persoenliche_ausruestung (
|
||||
bezeichnung, kategorie, artikel_id, user_id, benutzer_name,
|
||||
groesse, seriennummer, inventarnummer, anschaffung_datum,
|
||||
zustand, notizen, erstellt_von
|
||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.bezeichnung,
|
||||
data.kategorie ?? null,
|
||||
data.artikel_id ?? null,
|
||||
data.user_id ?? null,
|
||||
data.benutzer_name ?? null,
|
||||
data.groesse ?? null,
|
||||
data.seriennummer ?? null,
|
||||
data.inventarnummer ?? null,
|
||||
data.anschaffung_datum ?? null,
|
||||
data.zustand ?? 'gut',
|
||||
data.notizen ?? null,
|
||||
requestingUserId,
|
||||
],
|
||||
);
|
||||
logger.info('Personal equipment created', { id: result.rows[0].id, by: requestingUserId });
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('personalEquipmentService.create failed', { error });
|
||||
throw new Error('Failed to create personal equipment');
|
||||
}
|
||||
}
|
||||
|
||||
async function update(id: string, data: UpdatePersonalEquipmentData) {
|
||||
try {
|
||||
const fields: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let p = 1;
|
||||
|
||||
const addField = (col: string, value: unknown) => {
|
||||
fields.push(`${col} = $${p++}`);
|
||||
values.push(value);
|
||||
};
|
||||
|
||||
if (data.bezeichnung !== undefined) addField('bezeichnung', data.bezeichnung);
|
||||
if (data.kategorie !== undefined) addField('kategorie', data.kategorie);
|
||||
if (data.artikel_id !== undefined) addField('artikel_id', data.artikel_id);
|
||||
if (data.user_id !== undefined) addField('user_id', data.user_id);
|
||||
if (data.benutzer_name !== undefined) addField('benutzer_name', data.benutzer_name);
|
||||
if (data.groesse !== undefined) addField('groesse', data.groesse);
|
||||
if (data.seriennummer !== undefined) addField('seriennummer', data.seriennummer);
|
||||
if (data.inventarnummer !== undefined) addField('inventarnummer', data.inventarnummer);
|
||||
if (data.anschaffung_datum !== undefined) addField('anschaffung_datum', data.anschaffung_datum);
|
||||
if (data.zustand !== undefined) addField('zustand', data.zustand);
|
||||
if (data.notizen !== undefined) addField('notizen', data.notizen);
|
||||
|
||||
if (fields.length === 0) {
|
||||
throw new Error('No fields to update');
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
const result = await pool.query(
|
||||
`UPDATE persoenliche_ausruestung SET ${fields.join(', ')} WHERE id = $${p} AND geloescht_am IS NULL RETURNING *`,
|
||||
values,
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) return null;
|
||||
logger.info('Personal equipment updated', { id });
|
||||
return result.rows[0];
|
||||
} catch (error) {
|
||||
logger.error('personalEquipmentService.update failed', { error, id });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function softDelete(id: string) {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE persoenliche_ausruestung SET geloescht_am = NOW() WHERE id = $1 AND geloescht_am IS NULL RETURNING id`,
|
||||
[id],
|
||||
);
|
||||
if (result.rows.length === 0) return false;
|
||||
logger.info('Personal equipment soft-deleted', { id });
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('personalEquipmentService.softDelete failed', { error, id });
|
||||
throw new Error('Failed to delete personal equipment');
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getAll,
|
||||
getByUserId,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: softDelete,
|
||||
};
|
||||
@@ -350,7 +350,13 @@ class UserService {
|
||||
|
||||
// User-owned data tables (DELETE rows)
|
||||
await purge('notifications');
|
||||
await purge('mitglieder_profile');
|
||||
// Keep mitglieder_profile row but NULL FDISK-synced fields
|
||||
await client.query(
|
||||
`UPDATE mitglieder_profile SET dienstgrad = NULL, dienstgrad_seit = NULL, eintrittsdatum = NULL, austrittsdatum = NULL, geburtsdatum = NULL, fuehrerscheinklassen = '{}' WHERE user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
results['mitglieder_profile_fdisk_cleared'] = 1;
|
||||
|
||||
await purge('dienstgrad_verlauf');
|
||||
await purge('atemschutz_traeger');
|
||||
await purge('ausbildungen');
|
||||
@@ -362,7 +368,14 @@ class UserService {
|
||||
await purge('veranstaltung_teilnahmen');
|
||||
await purge('veranstaltung_ical_tokens');
|
||||
await purge('fahrzeug_ical_tokens');
|
||||
await purge('shop_anfragen', 'anfrager_id');
|
||||
// Delete child positions before parent anfragen (FK constraint)
|
||||
const posResult = await client.query(
|
||||
'DELETE FROM ausruestung_anfrage_positionen WHERE anfrage_id IN (SELECT id FROM ausruestung_anfragen WHERE anfrager_id = $1)',
|
||||
[userId]
|
||||
);
|
||||
results['ausruestung_anfrage_positionen'] = posResult.rowCount ?? 0;
|
||||
await purge('ausruestung_anfragen', 'anfrager_id');
|
||||
await purge('persoenliche_ausruestung');
|
||||
|
||||
// Clear user preferences (widget layout, etc.)
|
||||
await client.query(
|
||||
|
||||
Reference in New Issue
Block a user