new features

This commit is contained in:
Matthias Hochmeister
2026-03-23 16:09:42 +01:00
parent e9a9478aac
commit 8c66492b27
40 changed files with 2016 additions and 117 deletions

View File

@@ -102,6 +102,7 @@ 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 issueRoutes from './routes/issue.routes';
app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes);
@@ -126,6 +127,7 @@ app.use('/api/settings', settingsRoutes);
app.use('/api/banners', bannerRoutes);
app.use('/api/permissions', permissionRoutes);
app.use('/api/shop', shopRoutes);
app.use('/api/issues', issueRoutes);
// Static file serving for uploads (authenticated)
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');

View File

@@ -87,6 +87,7 @@ class AuthController {
// Step 2: Get user info from Authentik
const userInfo = await authentikService.getUserInfo(tokens.access_token);
const groups = userInfo.groups ?? [];
const dashboardGroups = groups.filter((g: string) => g.startsWith('dashboard_'));
// Step 3: Verify ID token if present
if (tokens.id_token) {
@@ -119,8 +120,8 @@ class AuthController {
profile_picture_url: userInfo.picture,
});
await userService.updateGroups(user.id, groups);
logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length });
await userService.updateGroups(user.id, dashboardGroups);
logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length });
await memberService.ensureProfileExists(user.id);
// Audit: first-ever login (user record creation)
@@ -168,8 +169,8 @@ class AuthController {
});
await userService.updateLastLogin(user.id);
await userService.updateGroups(user.id, groups);
logger.info('Groups synced for user', { userId: user.id, groupCount: groups.length });
await userService.updateGroups(user.id, dashboardGroups);
logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length });
await memberService.ensureProfileExists(user.id);
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);

View File

@@ -113,17 +113,29 @@ class BestellungController {
}
async createOrder(req: Request, res: Response): Promise<void> {
const { bezeichnung } = req.body;
const { bezeichnung, lieferant_id, budget, besteller_id } = req.body;
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
return;
}
if (lieferant_id != null && (!Number.isInteger(lieferant_id) || lieferant_id <= 0)) {
res.status(400).json({ success: false, message: 'Ungültige Lieferanten-ID' });
return;
}
if (budget != null && (typeof budget !== 'number' || budget < 0)) {
res.status(400).json({ success: false, message: 'Budget muss eine positive Zahl sein' });
return;
}
if (besteller_id != null && besteller_id !== '' && (typeof besteller_id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(besteller_id))) {
res.status(400).json({ success: false, message: 'Ungültige Besteller-ID' });
return;
}
try {
const order = await bestellungService.createOrder(req.body, req.user!.id);
res.status(201).json({ success: true, data: order });
} catch (error) {
logger.error('BestellungController.createOrder error', { error });
res.status(500).json({ success: false, message: 'Bestellung konnte nicht erstellt werden' });
res.status(500).json({ success: false, message: error instanceof Error ? error.message : 'Bestellung konnte nicht erstellt werden' });
}
}

View File

@@ -81,6 +81,17 @@ const CreateWartungslogSchema = z.object({
(url) => /^https?:\/\//i.test(url),
'Nur http/https URLs erlaubt'
).optional(),
naechste_pruefung_am: isoDate.optional(),
});
const UpdateWartungslogSchema = z.object({
datum: isoDate.optional(),
art: z.enum(['Prüfung', 'Reparatur', 'Sonstiges']).optional(),
beschreibung: z.string().min(1).max(2000).optional(),
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']).nullable().optional(),
kosten: z.number().min(0).nullable().optional(),
pruefende_stelle: z.string().max(150).nullable().optional(),
naechste_pruefung_am: isoDate.nullable().optional(),
});
// ── Helper ────────────────────────────────────────────────────────────────────
@@ -403,6 +414,43 @@ class EquipmentController {
}
}
async updateWartung(req: Request, res: Response): Promise<void> {
try {
const { id, wartungId } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
return;
}
const wId = parseInt(wartungId, 10);
if (isNaN(wId)) {
res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' });
return;
}
const parsed = UpdateWartungslogSchema.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 entry = await equipmentService.updateWartungslog(id, wId, parsed.data, getUserId(req));
res.status(200).json({ success: true, data: entry });
} catch (error: any) {
if (error?.message === 'Wartungseintrag nicht gefunden') {
res.status(404).json({ success: false, message: error.message });
return;
}
logger.error('updateWartung error', { error, id: req.params.id, wartungId: req.params.wartungId });
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht aktualisiert werden' });
}
}
async uploadWartungFile(req: Request, res: Response): Promise<void> {
const { wartungId } = req.params as Record<string, string>;
const id = parseInt(wartungId, 10);

View File

@@ -0,0 +1,176 @@
import { Request, Response } from 'express';
import issueService from '../services/issue.service';
import { permissionService } from '../services/permission.service';
import logger from '../utils/logger';
const param = (req: Request, key: string): string => req.params[key] as string;
class IssueController {
async getIssues(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
const issues = await issueService.getIssues(userId, canViewAll);
res.status(200).json({ success: true, data: issues });
} catch (error) {
logger.error('IssueController.getIssues error', { error });
res.status(500).json({ success: false, message: 'Issues konnten nicht geladen werden' });
}
}
async getIssue(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const issue = await issueService.getIssueById(id);
if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
if (!canViewAll && issue.erstellt_von !== userId) {
res.status(403).json({ success: false, message: 'Kein Zugriff' });
return;
}
res.status(200).json({ success: true, data: issue });
} catch (error) {
logger.error('IssueController.getIssue error', { error });
res.status(500).json({ success: false, message: 'Issue konnte nicht geladen werden' });
}
}
async createIssue(req: Request, res: Response): Promise<void> {
const { titel } = req.body;
if (!titel || typeof titel !== 'string' || titel.trim().length === 0) {
res.status(400).json({ success: false, message: 'Titel ist erforderlich' });
return;
}
try {
const issue = await issueService.createIssue(req.body, req.user!.id);
res.status(201).json({ success: true, data: issue });
} catch (error) {
logger.error('IssueController.createIssue error', { error });
res.status(500).json({ success: false, message: 'Issue konnte nicht erstellt werden' });
}
}
async updateIssue(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const groups: string[] = (req.user as any).groups || [];
const canManage = permissionService.hasPermission(groups, 'issues:manage');
if (!canManage) {
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
const issue = await issueService.updateIssue(id, req.body);
if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: issue });
} catch (error) {
logger.error('IssueController.updateIssue error', { error });
res.status(500).json({ success: false, message: 'Issue konnte nicht aktualisiert werden' });
}
}
async deleteIssue(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'id'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const issue = await issueService.getIssueById(id);
if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canManage = permissionService.hasPermission(groups, 'issues:manage');
if (!canManage && issue.erstellt_von !== userId) {
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
await issueService.deleteIssue(id);
res.status(200).json({ success: true, message: 'Issue gelöscht' });
} catch (error) {
logger.error('IssueController.deleteIssue error', { error });
res.status(500).json({ success: false, message: 'Issue konnte nicht gelöscht werden' });
}
}
async getComments(req: Request, res: Response): Promise<void> {
const issueId = parseInt(param(req, 'id'), 10);
if (isNaN(issueId)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const issue = await issueService.getIssueById(issueId);
if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
if (!canViewAll && issue.erstellt_von !== userId) {
res.status(403).json({ success: false, message: 'Kein Zugriff' });
return;
}
const comments = await issueService.getComments(issueId);
res.status(200).json({ success: true, data: comments });
} catch (error) {
logger.error('IssueController.getComments error', { error });
res.status(500).json({ success: false, message: 'Kommentare konnten nicht geladen werden' });
}
}
async addComment(req: Request, res: Response): Promise<void> {
const issueId = parseInt(param(req, 'id'), 10);
if (isNaN(issueId)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
const { inhalt } = req.body;
if (!inhalt || typeof inhalt !== 'string' || inhalt.trim().length === 0) {
res.status(400).json({ success: false, message: 'Kommentar darf nicht leer sein' });
return;
}
try {
const issue = await issueService.getIssueById(issueId);
if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
const userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canViewAll = permissionService.hasPermission(groups, 'issues:view_all');
const canManage = permissionService.hasPermission(groups, 'issues:manage');
if (!canViewAll && !canManage && issue.erstellt_von !== userId) {
res.status(403).json({ success: false, message: 'Kein Zugriff' });
return;
}
const comment = await issueService.addComment(issueId, userId, inhalt.trim());
res.status(201).json({ success: true, data: comment });
} catch (error) {
logger.error('IssueController.addComment error', { error });
res.status(500).json({ success: false, message: 'Kommentar konnte nicht erstellt werden' });
}
}
}
export default new IssueController();

View File

@@ -188,11 +188,14 @@ class ShopController {
// Notify requester on status changes
if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) {
const orderLabel = existing.bestell_jahr && existing.bestell_nummer
? `${existing.bestell_jahr}/${String(existing.bestell_nummer).padStart(3, '0')}`
: `#${id}`;
await notificationService.createNotification({
user_id: existing.anfrager_id,
typ: 'shop_anfrage',
titel: status === 'genehmigt' ? 'Anfrage genehmigt' : status === 'abgelehnt' ? 'Anfrage abgelehnt' : `Anfrage ${status}`,
nachricht: `Deine Shop-Anfrage #${id} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`,
nachricht: `Deine Shop-Anfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`,
schwere: status === 'abgelehnt' ? 'warnung' : 'info',
link: '/shop',
quell_id: String(id),
@@ -218,6 +221,20 @@ class ShopController {
}
}
// -------------------------------------------------------------------------
// Overview
// -------------------------------------------------------------------------
async getOverview(_req: Request, res: Response): Promise<void> {
try {
const overview = await shopService.getOverview();
res.status(200).json({ success: true, data: overview });
} catch (error) {
logger.error('ShopController.getOverview error', { error });
res.status(500).json({ success: false, message: 'Übersicht konnte nicht geladen werden' });
}
}
// -------------------------------------------------------------------------
// Linking
// -------------------------------------------------------------------------

View File

@@ -86,6 +86,8 @@ const UpdateStatusSchema = z.object({
{ message: 'Enddatum muss nach Startdatum liegen', path: ['ausserDienstBis'] }
);
const ErgebnisEnum = z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']);
const CreateWartungslogSchema = z.object({
datum: isoDate,
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
@@ -94,6 +96,18 @@ const CreateWartungslogSchema = z.object({
kraftstoff_liter: z.number().min(0).optional(),
kosten: z.number().min(0).optional(),
externe_werkstatt: z.string().max(150).optional(),
ergebnis: ErgebnisEnum.optional(),
naechste_faelligkeit: isoDate.optional(),
});
const UpdateWartungslogSchema = z.object({
datum: isoDate,
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
beschreibung: z.string().min(1).max(2000),
km_stand: z.number().int().min(0).optional(),
externe_werkstatt: z.string().max(150).optional(),
ergebnis: ErgebnisEnum.optional(),
naechste_faelligkeit: isoDate.optional(),
});
// ── Helper ────────────────────────────────────────────────────────────────────
@@ -384,6 +398,34 @@ class VehicleController {
}
}
async updateWartung(req: Request, res: Response): Promise<void> {
try {
const { id, wartungId } = req.params as Record<string, string>;
if (!isValidUUID(id)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const parsed = UpdateWartungslogSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const entry = await vehicleService.updateWartungslog(wartungId, id, parsed.data, getUserId(req));
res.status(200).json({ success: true, data: entry });
} catch (error: any) {
if (error?.message === 'Wartungseintrag nicht gefunden') {
res.status(404).json({ success: false, message: 'Wartungseintrag nicht gefunden' });
return;
}
logger.error('updateWartung error', { error, id: req.params.id, wartungId: req.params.wartungId });
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht aktualisiert werden' });
}
}
async uploadWartungFile(req: Request, res: Response): Promise<void> {
const { wartungId } = req.params as Record<string, string>;
const id = parseInt(wartungId, 10);

View File

@@ -0,0 +1,4 @@
-- Migration 042: Ensure ganztaegig column exists on fahrzeug_buchungen
-- Fixes case where migration 041 was tracked before this column was added.
ALTER TABLE fahrzeug_buchungen ADD COLUMN IF NOT EXISTS ganztaegig BOOLEAN DEFAULT FALSE;

View File

@@ -0,0 +1,130 @@
-- Migration 043: Feature batch
-- 1. Vehicle wartungslog: ergebnis + naechste_faelligkeit
-- 2. Shop anfragen: bestell_nummer + bestell_jahr
-- 3. Issues + issue_kommentare tables
-- 4. New feature group 'issues' + permissions
-- 5. New shop permissions (view_overview, order_for_user)
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Vehicle Wartungslog additions
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE fahrzeug_wartungslog
ADD COLUMN IF NOT EXISTS ergebnis VARCHAR(30)
CHECK (ergebnis IN ('bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden'));
ALTER TABLE fahrzeug_wartungslog
ADD COLUMN IF NOT EXISTS naechste_faelligkeit DATE;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Shop Anfragen: unique order ID per year
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE shop_anfragen
ADD COLUMN IF NOT EXISTS bestell_nummer INT;
ALTER TABLE shop_anfragen
ADD COLUMN IF NOT EXISTS bestell_jahr INT DEFAULT EXTRACT(YEAR FROM CURRENT_DATE);
-- Make bezeichnung optional on shop_anfragen (if it exists)
-- shop_anfragen doesn't have bezeichnung — it's on positionen. No change needed.
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Issues tables
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS issues (
id SERIAL PRIMARY KEY,
titel VARCHAR(500) NOT NULL,
beschreibung TEXT,
typ VARCHAR(50) NOT NULL DEFAULT 'bug'
CHECK (typ IN ('bug', 'feature', 'sonstiges')),
prioritaet VARCHAR(20) NOT NULL DEFAULT 'mittel'
CHECK (prioritaet IN ('niedrig', 'mittel', 'hoch')),
status VARCHAR(20) NOT NULL DEFAULT 'offen'
CHECK (status IN ('offen', 'in_bearbeitung', 'erledigt', 'abgelehnt')),
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
zugewiesen_an UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_issues_erstellt_von ON issues(erstellt_von);
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
CREATE INDEX IF NOT EXISTS idx_issues_typ ON issues(typ);
CREATE TABLE IF NOT EXISTS issue_kommentare (
id SERIAL PRIMARY KEY,
issue_id INT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
autor_id UUID REFERENCES users(id) ON DELETE SET NULL,
inhalt TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_issue_kommentare_issue ON issue_kommentare(issue_id);
-- Auto-update trigger for issues
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_issues_updated') THEN
CREATE TRIGGER trg_issues_updated BEFORE UPDATE ON issues
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
END IF;
END $$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Issues feature group + permissions
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO feature_groups (id, label, sort_order) VALUES
('issues', 'Issues', 13)
ON CONFLICT (id) DO NOTHING;
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('issues:create', 'issues', 'Erstellen', 'Issues erstellen', 1),
('issues:view_own', 'issues', 'Eigene ansehen', 'Eigene Issues einsehen', 2),
('issues:view_all', 'issues', 'Alle ansehen', 'Alle Issues einsehen', 3),
('issues:manage', 'issues', 'Verwalten', 'Issues bearbeiten, Status ändern, zuweisen', 4)
ON CONFLICT (id) DO NOTHING;
-- Seed: all groups get create + view_own; kommando gets all
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kommando: full access
('dashboard_kommando', 'issues:create'),
('dashboard_kommando', 'issues:view_own'),
('dashboard_kommando', 'issues:view_all'),
('dashboard_kommando', 'issues:manage'),
-- Fahrmeister
('dashboard_fahrmeister', 'issues:create'),
('dashboard_fahrmeister', 'issues:view_own'),
-- Zeugmeister
('dashboard_zeugmeister', 'issues:create'),
('dashboard_zeugmeister', 'issues:view_own'),
-- Chargen
('dashboard_chargen', 'issues:create'),
('dashboard_chargen', 'issues:view_own'),
-- Moderator
('dashboard_moderator', 'issues:create'),
('dashboard_moderator', 'issues:view_own'),
-- Atemschutz
('dashboard_atemschutz', 'issues:create'),
('dashboard_atemschutz', 'issues:view_own'),
-- Mitglied
('dashboard_mitglied', 'issues:create'),
('dashboard_mitglied', 'issues:view_own')
ON CONFLICT DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. New shop permissions
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('shop:view_overview', 'shop', 'Übersicht', 'Aggregierte Übersicht aller Anfragen', 7),
('shop:order_for_user', 'shop', 'Für Benutzer bestellen', 'Anfragen im Namen anderer erstellen', 8)
ON CONFLICT (id) DO NOTHING;
-- Kommando gets the new permissions
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_kommando', 'shop:view_overview'),
('dashboard_kommando', 'shop:order_for_user')
ON CONFLICT DO NOTHING;

View File

@@ -167,11 +167,22 @@ export interface UpdateAusruestungData {
}
export interface CreateAusruestungWartungslogData {
datum: string;
art: AusruestungWartungslogArt;
beschreibung: string;
ergebnis?: string;
kosten?: number;
pruefende_stelle?: string;
dokument_url?: string;
datum: string;
art: AusruestungWartungslogArt;
beschreibung: string;
ergebnis?: string;
kosten?: number;
pruefende_stelle?: string;
dokument_url?: string;
naechste_pruefung_am?: string;
}
export interface UpdateAusruestungWartungslogData {
datum?: string;
art?: AusruestungWartungslogArt;
beschreibung?: string;
ergebnis?: string | null;
kosten?: number | null;
pruefende_stelle?: string | null;
naechste_pruefung_am?: string | null;
}

View File

@@ -52,6 +52,11 @@ export interface Fahrzeug {
aktiver_lehrgang?: AktiverLehrgang | null;
}
export type WartungslogErgebnis =
| 'bestanden'
| 'bestanden_mit_maengeln'
| 'nicht_bestanden';
export interface FahrzeugWartungslog {
id: string;
fahrzeug_id: string;
@@ -62,6 +67,8 @@ export interface FahrzeugWartungslog {
kraftstoff_liter: number | null;
kosten: number | null;
externe_werkstatt: string | null;
ergebnis: WartungslogErgebnis | null;
naechste_faelligkeit: Date | null;
erfasst_von: string | null;
created_at: Date;
}
@@ -174,4 +181,16 @@ export interface CreateWartungslogData {
kraftstoff_liter?: number;
kosten?: number;
externe_werkstatt?: string;
ergebnis?: WartungslogErgebnis;
naechste_faelligkeit?: string;
}
export interface UpdateWartungslogData {
datum: string;
art?: WartungslogArt;
beschreibung: string;
km_stand?: number;
externe_werkstatt?: string;
ergebnis?: WartungslogErgebnis;
naechste_faelligkeit?: string;
}

View File

@@ -325,4 +325,34 @@ router.delete(
}
);
// ---------------------------------------------------------------------------
// DELETE /api/admin/debug/user/:userId/profile — delete mitglieder_profile row
// ---------------------------------------------------------------------------
router.delete(
'/debug/user/:userId/profile',
authenticate,
requirePermission('admin:write'),
async (req: Request, res: Response): Promise<void> => {
try {
const userId = req.params.userId;
const result = await pool.query(
'DELETE FROM mitglieder_profile WHERE user_id = $1',
[userId]
);
if ((result.rowCount ?? 0) === 0) {
res.status(404).json({ success: false, message: 'Kein Profil fuer diesen Benutzer gefunden' });
return;
}
logger.info('Admin deleted user profile data', { userId, admin: req.user?.id });
res.json({ success: true, message: 'Profildaten geloescht' });
} catch (error) {
logger.error('Failed to delete user profile', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Loeschen der Profildaten' });
}
}
);
export default router;

View File

@@ -23,6 +23,7 @@ router.post('/', authenticate, requirePermission('ausruestung:create')
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:manage_maintenance'), equipmentController.addWartung.bind(equipmentController));
router.patch('/:id/wartung/:wartungId', authenticate, requirePermission('ausruestung:manage_maintenance'), equipmentController.updateWartung.bind(equipmentController));
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('ausruestung:manage_maintenance'), uploadWartung.single('datei'), equipmentController.uploadWartungFile.bind(equipmentController));
// ── Delete — admin only ──────────────────────────────────────────────────────

View File

@@ -0,0 +1,45 @@
import { Router } from 'express';
import issueController from '../controllers/issue.controller';
import { authenticate } from '../middleware/auth.middleware';
import { requirePermission } from '../middleware/rbac.middleware';
const router = Router();
router.get(
'/',
authenticate,
issueController.getIssues.bind(issueController)
);
router.post(
'/',
authenticate,
requirePermission('issues:create'),
issueController.createIssue.bind(issueController)
);
router.get(
'/:id/comments',
authenticate,
issueController.getComments.bind(issueController)
);
router.post(
'/:id/comments',
authenticate,
issueController.addComment.bind(issueController)
);
router.patch(
'/:id',
authenticate,
issueController.updateIssue.bind(issueController)
);
router.delete(
'/:id',
authenticate,
issueController.deleteIssue.bind(issueController)
);
export default router;

View File

@@ -17,6 +17,12 @@ router.delete('/items/:id', authenticate, requirePermission('shop:manage_catalog
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
// ---------------------------------------------------------------------------

View File

@@ -26,6 +26,7 @@ router.delete('/:id', authenticate, requirePermission('fahrzeuge:delete'), vehic
router.patch('/:id/status', authenticate, requirePermission('fahrzeuge:change_status'), vehicleController.updateVehicleStatus.bind(vehicleController));
router.post('/:id/wartung', authenticate, requirePermission('fahrzeuge:manage_maintenance'), vehicleController.addWartung.bind(vehicleController));
router.patch('/:id/wartung/:wartungId', authenticate, requirePermission('fahrzeuge:manage_maintenance'), vehicleController.updateWartung.bind(vehicleController));
router.post('/wartung/:wartungId/upload', authenticate, requirePermission('fahrzeuge:manage_maintenance'), uploadWartung.single('datei'), vehicleController.uploadWartungFile.bind(vehicleController));
export default router;

View File

@@ -9,6 +9,7 @@ import {
CreateAusruestungData,
UpdateAusruestungData,
CreateAusruestungWartungslogData,
UpdateAusruestungWartungslogData,
AusruestungStatus,
EquipmentStats,
VehicleEquipmentWarning,
@@ -330,6 +331,15 @@ class EquipmentService {
const entry = result.rows[0] as AusruestungWartungslog;
logger.info('Equipment wartungslog entry added', { entryId: entry.id, equipmentId, by: createdBy });
// Auto-update next inspection date on the equipment when result is 'bestanden'
if (data.ergebnis === 'bestanden' && data.naechste_pruefung_am) {
await pool.query(
`UPDATE ausruestung SET naechste_pruefung_am = $1, letzte_pruefung_am = $2 WHERE id = $3`,
[data.naechste_pruefung_am, data.datum, equipmentId]
);
}
return entry;
} catch (error) {
logger.error('EquipmentService.addWartungslog failed', { error, equipmentId });
@@ -461,6 +471,70 @@ class EquipmentService {
}
}
// =========================================================================
// WARTUNGSLOG UPDATE
// =========================================================================
async updateWartungslog(
equipmentId: string,
wartungId: number,
data: UpdateAusruestungWartungslogData,
updatedBy: string
): Promise<AusruestungWartungslog> {
try {
// Verify the wartung entry belongs to this equipment
const check = await pool.query(
`SELECT id FROM ausruestung_wartungslog WHERE id = $1 AND ausruestung_id = $2`,
[wartungId, equipmentId]
);
if (check.rows.length === 0) {
throw new Error('Wartungseintrag nicht gefunden');
}
const fields: string[] = [];
const values: unknown[] = [];
let p = 1;
const addField = (col: string, value: unknown) => {
fields.push(`${col} = $${p++}`);
values.push(value);
};
if (data.datum !== undefined) addField('datum', data.datum);
if (data.art !== undefined) addField('art', data.art);
if (data.beschreibung !== undefined) addField('beschreibung', data.beschreibung);
if (data.ergebnis !== undefined) addField('ergebnis', data.ergebnis);
if (data.kosten !== undefined) addField('kosten', data.kosten);
if (data.pruefende_stelle !== undefined) addField('pruefende_stelle', data.pruefende_stelle);
if (fields.length === 0) {
throw new Error('No fields to update');
}
values.push(wartungId);
const result = await pool.query(
`UPDATE ausruestung_wartungslog SET ${fields.join(', ')} WHERE id = $${p} RETURNING *`,
values
);
const entry = result.rows[0] as AusruestungWartungslog;
// Auto-update next inspection date on the equipment when result is 'bestanden'
if (data.ergebnis === 'bestanden' && data.naechste_pruefung_am) {
await pool.query(
`UPDATE ausruestung SET naechste_pruefung_am = $1, letzte_pruefung_am = $2 WHERE id = $3`,
[data.naechste_pruefung_am, data.datum ?? entry.datum, equipmentId]
);
}
logger.info('Equipment wartungslog entry updated', { wartungId, equipmentId, by: updatedBy });
return entry;
} catch (error) {
logger.error('EquipmentService.updateWartungslog failed', { error, wartungId, equipmentId });
throw error;
}
}
// =========================================================================
// WARTUNGSLOG FILE UPLOAD
// =========================================================================

View File

@@ -367,6 +367,7 @@ class EventsService {
data.alle_gruppen,
data.max_teilnehmer ?? null,
data.anmeldung_erforderlich,
data.anmeldung_bis ?? null,
userId,
]);
}
@@ -376,8 +377,8 @@ class EventsService {
`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)`,
max_teilnehmer, anmeldung_erforderlich, anmeldung_bis, erstellt_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)`,
params
);
}

View File

@@ -0,0 +1,162 @@
import pool from '../config/database';
import logger from '../utils/logger';
async function getIssues(userId: string, canViewAll: boolean) {
try {
const query = `
SELECT i.*,
u1.name AS erstellt_von_name,
u2.name AS zugewiesen_an_name
FROM issues i
LEFT JOIN users u1 ON u1.id = i.erstellt_von
LEFT JOIN users u2 ON u2.id = i.zugewiesen_an
${canViewAll ? '' : 'WHERE i.erstellt_von = $1'}
ORDER BY i.created_at DESC
`;
const result = canViewAll
? await pool.query(query)
: await pool.query(query, [userId]);
return result.rows;
} catch (error) {
logger.error('IssueService.getIssues failed', { error });
throw new Error('Issues konnten nicht geladen werden');
}
}
async function getIssueById(id: number) {
try {
const result = await pool.query(
`SELECT i.*,
u1.name AS erstellt_von_name,
u2.name AS zugewiesen_an_name
FROM issues i
LEFT JOIN users u1 ON u1.id = i.erstellt_von
LEFT JOIN users u2 ON u2.id = i.zugewiesen_an
WHERE i.id = $1`,
[id]
);
return result.rows[0] || null;
} catch (error) {
logger.error('IssueService.getIssueById failed', { error, id });
throw new Error('Issue konnte nicht geladen werden');
}
}
async function createIssue(
data: { titel: string; beschreibung?: string; typ?: string; prioritaet?: string },
userId: string
) {
try {
const result = await pool.query(
`INSERT INTO issues (titel, beschreibung, typ, prioritaet, erstellt_von)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[
data.titel,
data.beschreibung || null,
data.typ || 'sonstiges',
data.prioritaet || 'mittel',
userId,
]
);
return result.rows[0];
} catch (error) {
logger.error('IssueService.createIssue failed', { error });
throw new Error('Issue konnte nicht erstellt werden');
}
}
async function updateIssue(
id: number,
data: {
titel?: string;
beschreibung?: string;
typ?: string;
prioritaet?: string;
status?: string;
zugewiesen_an?: string | null;
}
) {
try {
const result = await pool.query(
`UPDATE issues
SET titel = COALESCE($1, titel),
beschreibung = COALESCE($2, beschreibung),
typ = COALESCE($3, typ),
prioritaet = COALESCE($4, prioritaet),
status = COALESCE($5, status),
zugewiesen_an = COALESCE($6, zugewiesen_an),
updated_at = NOW()
WHERE id = $7
RETURNING *`,
[
data.titel,
data.beschreibung,
data.typ,
data.prioritaet,
data.status,
data.zugewiesen_an,
id,
]
);
return result.rows[0] || null;
} catch (error) {
logger.error('IssueService.updateIssue failed', { error, id });
throw new Error('Issue konnte nicht aktualisiert werden');
}
}
async function deleteIssue(id: number) {
try {
const result = await pool.query(
`DELETE FROM issues WHERE id = $1 RETURNING id`,
[id]
);
return result.rows.length > 0;
} catch (error) {
logger.error('IssueService.deleteIssue failed', { error, id });
throw new Error('Issue konnte nicht gelöscht werden');
}
}
async function getComments(issueId: number) {
try {
const result = await pool.query(
`SELECT c.*, u.name AS autor_name
FROM issue_kommentare c
LEFT JOIN users u ON u.id = c.autor_id
WHERE c.issue_id = $1
ORDER BY c.created_at ASC`,
[issueId]
);
return result.rows;
} catch (error) {
logger.error('IssueService.getComments failed', { error, issueId });
throw new Error('Kommentare konnten nicht geladen werden');
}
}
async function addComment(issueId: number, autorId: string, inhalt: string) {
try {
const result = await pool.query(
`INSERT INTO issue_kommentare (issue_id, autor_id, inhalt)
VALUES ($1, $2, $3)
RETURNING *`,
[issueId, autorId, inhalt]
);
return result.rows[0];
} catch (error) {
logger.error('IssueService.addComment failed', { error, issueId });
throw new Error('Kommentar konnte nicht erstellt werden');
}
}
export default {
getIssues,
getIssueById,
createIssue,
updateIssue,
deleteIssue,
getComments,
addComment,
};

View File

@@ -202,11 +202,21 @@ async function createRequest(
try {
await client.query('BEGIN');
// Get next bestell_nummer for the current year
const currentYear = new Date().getFullYear();
const maxResult = await client.query(
`SELECT COALESCE(MAX(bestell_nummer), 0) + 1 AS next_nr
FROM shop_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)
VALUES ($1, $2)
`INSERT INTO shop_anfragen (anfrager_id, notizen, bestell_nummer, bestell_jahr)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[userId, notizen || null],
[userId, notizen || null, nextNr, currentYear],
);
const anfrage = anfrageResult.rows[0];
@@ -296,6 +306,42 @@ async function getLinkedOrders(anfrageId: number) {
return result.rows;
}
// ---------------------------------------------------------------------------
// Overview (aggregated)
// ---------------------------------------------------------------------------
async function getOverview() {
const aggregated = await pool.query(
`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
WHERE a.status IN ('offen', 'genehmigt')
GROUP BY p.bezeichnung
ORDER BY total_menge DESC, p.bezeichnung`,
);
const counts = await pool.query(
`SELECT
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
LEFT JOIN LATERAL (
SELECT SUM(p.menge) AS total
FROM shop_anfrage_positionen p
WHERE p.anfrage_id = a.id
) sub ON true
WHERE a.status IN ('offen', 'genehmigt')`,
);
return {
items: aggregated.rows,
...counts.rows[0],
};
}
export default {
getItems,
getItemById,
@@ -312,4 +358,5 @@ export default {
linkToOrder,
unlinkFromOrder,
getLinkedOrders,
getOverview,
};

View File

@@ -8,6 +8,7 @@ import {
CreateFahrzeugData,
UpdateFahrzeugData,
CreateWartungslogData,
UpdateWartungslogData,
FahrzeugStatus,
VehicleStats,
InspectionAlert,
@@ -372,8 +373,9 @@ class VehicleService {
const result = await pool.query(
`INSERT INTO fahrzeug_wartungslog (
fahrzeug_id, datum, art, beschreibung,
km_stand, kraftstoff_liter, kosten, externe_werkstatt, erfasst_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
km_stand, kraftstoff_liter, kosten, externe_werkstatt,
ergebnis, naechste_faelligkeit, erfasst_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING *`,
[
fahrzeugId,
@@ -384,11 +386,21 @@ class VehicleService {
data.kraftstoff_liter ?? null,
data.kosten ?? null,
data.externe_werkstatt ?? null,
data.ergebnis ?? null,
data.naechste_faelligkeit ?? null,
createdBy,
]
);
const entry = result.rows[0] as FahrzeugWartungslog;
// Auto-update next service date on the vehicle when result is 'bestanden'
if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) {
await pool.query(
`UPDATE fahrzeuge SET naechste_wartung_am = $1 WHERE id = $2`,
[data.naechste_faelligkeit, fahrzeugId]
);
}
logger.info('Wartungslog entry added', { entryId: entry.id, fahrzeugId, by: createdBy });
return entry;
} catch (error) {
@@ -397,6 +409,54 @@ class VehicleService {
}
}
async updateWartungslog(
wartungId: string,
fahrzeugId: string,
data: UpdateWartungslogData,
updatedBy: string
): Promise<FahrzeugWartungslog> {
try {
const result = await pool.query(
`UPDATE fahrzeug_wartungslog
SET datum = $1, art = $2, beschreibung = $3, km_stand = $4,
externe_werkstatt = $5, ergebnis = $6, naechste_faelligkeit = $7
WHERE id = $8 AND fahrzeug_id = $9
RETURNING *`,
[
data.datum,
data.art ?? null,
data.beschreibung,
data.km_stand ?? null,
data.externe_werkstatt ?? null,
data.ergebnis ?? null,
data.naechste_faelligkeit ?? null,
wartungId,
fahrzeugId,
]
);
if (result.rows.length === 0) {
throw new Error('Wartungseintrag nicht gefunden');
}
const entry = result.rows[0] as FahrzeugWartungslog;
// Auto-update next service date on the vehicle when result is 'bestanden'
if (data.ergebnis === 'bestanden' && data.naechste_faelligkeit) {
await pool.query(
`UPDATE fahrzeuge SET naechste_wartung_am = $1 WHERE id = $2`,
[data.naechste_faelligkeit, fahrzeugId]
);
}
logger.info('Wartungslog entry updated', { wartungId, fahrzeugId, by: updatedBy });
return entry;
} catch (error) {
logger.error('VehicleService.updateWartungslog failed', { error, wartungId, fahrzeugId });
throw error;
}
}
async getWartungslogForVehicle(fahrzeugId: string): Promise<FahrzeugWartungslog[]> {
try {
const result = await pool.query(