new features
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
176
backend/src/controllers/issue.controller.ts
Normal file
176
backend/src/controllers/issue.controller.ts
Normal 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();
|
||||
@@ -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
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
130
backend/src/database/migrations/043_feature_batch.sql
Normal file
130
backend/src/database/migrations/043_feature_batch.sql
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ──────────────────────────────────────────────────────
|
||||
|
||||
45
backend/src/routes/issue.routes.ts
Normal file
45
backend/src/routes/issue.routes.ts
Normal 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;
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
// =========================================================================
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
162
backend/src/services/issue.service.ts
Normal file
162
backend/src/services/issue.service.ts
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user