Compare commits

...

439 Commits

Author SHA1 Message Date
Matthias Hochmeister
4ec719ad0a feat(persoenliche-ausruestung): add quantity field and article-grouped replacement flow in order dialog 2026-04-24 12:36:28 +02:00
Matthias Hochmeister
9410441ce2 feat(checklisten): rework checklist scheduling, overview, and execution UI 2026-04-20 18:37:00 +02:00
Matthias Hochmeister
c55ec55e1b feat(admin): move integration URLs and credentials to GUI settings 2026-04-20 16:29:12 +02:00
Matthias Hochmeister
65820805b0 style(frontend): format Lehrgang section in member detail to match other Atemschutz sub-sections 2026-04-20 11:53:48 +02:00
Matthias Hochmeister
99792d93dd fix(sync): calculate G26 gueltig_bis (default 5y, override via Anmerkungen) and load all Untersuchungen via URL param 2026-04-20 11:18:18 +02:00
Matthias Hochmeister
752dfe474c change dat format in member overview, sync exams to atemschutz tool, rework member detail page 2026-04-20 10:32:20 +02:00
Matthias Hochmeister
d5291360c9 fix(sync): use frequency-based date column detection for Untersuchungen to find all exam rows 2026-04-20 08:04:44 +02:00
Matthias Hochmeister
84254a0b71 fix(sync): use native form submit for Untersuchungen pagination instead of ViewState URL hack 2026-04-19 19:45:12 +02:00
Matthias Hochmeister
3b4a14661c fix(sync): use ViewState extraction for Untersuchungen pagination instead of form submit 2026-04-19 19:37:17 +02:00
Matthias Hochmeister
b401b75b9a fix(sync): await Untersuchungen form navigation to prevent Fahrgenehmigungen page abort 2026-04-19 19:33:51 +02:00
Matthias Hochmeister
d796fae978 feat(sync): fix exam sync pagination, add AGL/AT20-Theorie lehrgang variants with yellow checkmark 2026-04-19 19:28:22 +02:00
Matthias Hochmeister
ed3ee143dd fix(sync): load all Untersuchungen via URL param, accept AT20 "mit ausgezeichnetem Erfolg" for atemschutz lehrgang 2026-04-19 17:47:59 +02:00
Matthias Hochmeister
8c25cb0d40 feat(admin): add reset-atemschutz option to data management tab 2026-04-19 17:43:48 +02:00
Matthias Hochmeister
3f92156115 fix(sync): fix Untersuchungen column parsing, sync exams to atemschutz profile, handle legacy shifted data 2026-04-19 17:26:37 +02:00
Matthias Hochmeister
54a110d17b fix(sync): fix Untersuchungen column parsing and sync Leistungstest/Atemschutztauglichkeit dates to atemschutz profile 2026-04-19 17:08:29 +02:00
Matthias Hochmeister
0a5402a9e5 feat(admin): add system logs viewer, tabbed data management, fix AT20 sync 2026-04-18 18:31:22 +02:00
Matthias Hochmeister
0a6377a64f fix(sync): remove debug file writing and optimize DB queries 2026-04-18 18:15:40 +02:00
Matthias Hochmeister
26df8b427e fix(mitglieder): improve Fahrgenehmigungen labels, pagination, and AT20 sync 2026-04-18 18:00:54 +02:00
Matthias Hochmeister
e1c7f44e56 fix(sync): switch FDISK course scraper from Ausbildungen to Kurse page 2026-04-18 16:46:35 +02:00
Matthias Hochmeister
8e6868eb55 fix(permissions): restore sticky left column on permission rows to prevent checkbox overlap 2026-04-18 16:32:42 +02:00
Matthias Hochmeister
bef5a685a8 fix(permissions): flatten permission matrix table to fix column alignment and scroll lag 2026-04-18 16:20:44 +02:00
Matthias Hochmeister
fa9f50d982 fix(buchhaltung): show Übersicht tab first and count all booked transactions in budget overview 2026-04-18 16:16:50 +02:00
Matthias Hochmeister
219e5f1195 feat(geplante-nachrichten): show age in birthday list 2026-04-17 14:28:02 +02:00
Matthias Hochmeister
8ee2b9170d fix(geplante-nachrichten): resolve {{date_range}} template variable in content builders 2026-04-17 14:24:47 +02:00
Matthias Hochmeister
968b24156b fix(geplante-nachrichten): fix wrong column refs in content builders — abgesagt bool, join users for names, correct status values 2026-04-17 14:15:34 +02:00
Matthias Hochmeister
d44f53a8a9 fix(geplante-nachrichten): cache rooms response 60s and pass Nextcloud errors through to frontend 2026-04-17 13:33:07 +02:00
Matthias Hochmeister
53c8be0f6d add clear error message for bot api 2026-04-17 13:29:09 +02:00
Matthias Hochmeister
72b575478b add error handling for bad credentials 2026-04-17 13:24:40 +02:00
Matthias Hochmeister
e4c37ba219 fix(ausruestungsanfrage): correct API paths, assignment navigation, and pre-fill user on Zuweisung page 2026-04-17 13:07:15 +02:00
Matthias Hochmeister
68e4ed265f fix(geplante-nachrichten): use getAllConversations in getRooms to return full room list instead of top 3 2026-04-17 12:47:13 +02:00
Matthias Hochmeister
169d045e4c feat(ausruestungsanfrage): show vendor order status and delivery progress in request detail 2026-04-17 12:39:40 +02:00
Matthias Hochmeister
d8afcc1f63 fix(geplante-nachrichten): add /api prefix to all API paths, fix subscribe room token, unmask empty bot credentials, add Einzelnachrichten tab 2026-04-17 12:33:48 +02:00
Matthias Hochmeister
fcca04cc39 fix(bestellungen): automate delivery status transitions, enable received-qty input for creators, and add im_haus tracking to positionen 2026-04-17 11:42:12 +02:00
Matthias Hochmeister
7d2ea57c17 fix(frontend): order status button colors, delivery gate logic, partial delivery chips, and scheduled message form tweaks 2026-04-17 10:50:42 +02:00
Matthias Hochmeister
b91cf88812 add: add feature to schedule messages 2026-04-17 10:41:00 +02:00
Matthias Hochmeister
5811ac201e fix(geplante-nachrichten): distinguish unconfigured bot from unreachable Nextcloud in room picker 2026-04-17 09:47:31 +02:00
Matthias Hochmeister
510b44e48c fix(tool-config): merge partial updates instead of replacing, mask bot_app_password 2026-04-17 09:30:13 +02:00
Matthias Hochmeister
7532a19326 fix(geplante-nachrichten): read bot credentials from tool_config_nextcloud, add manual trigger endpoint 2026-04-17 09:20:18 +02:00
Matthias Hochmeister
8a0c4200ff feat(geplante-nachrichten): scheduled message rule engine with bot delivery, admin UI, and manual trigger 2026-04-17 09:10:57 +02:00
Matthias Hochmeister
6614fbaa68 feat(admin): centralize tool & module settings in Werkzeuge tab with per-tool permissions, DB-backed config, connection tests, and cog-button navigation 2026-04-17 08:37:29 +02:00
Matthias Hochmeister
6ead698294 refactor(sidebar): remove all dropdown sub-menus, flatten navigation to direct links 2026-04-16 16:25:06 +02:00
Matthias Hochmeister
1c071c7768 update docker version 2026-04-16 16:18:03 +02:00
Matthias Hochmeister
dfcdd44aa4 refactor(admin): consolidate 10 flat tabs into 4 grouped tabs with sub-tabs, remove Bestellungen tab, replace Debug with FDISK Sync 2026-04-16 16:14:25 +02:00
Matthias Hochmeister
e56075f38a fix(issues): allow priority+status change for assignees, dynamic owner transitions, kanban droppable columns; feat(persoenliche-ausruestung): configurable zustand 2026-04-16 14:08:05 +02:00
Matthias Hochmeister
2fe0db6d9a fix(persoenliche-ausruestung): wait for permissions before fetching overview to show all items 2026-04-16 09:17:36 +02:00
Matthias Hochmeister
a6aeab80d4 feat(persoenliche-ausruestung): show catalog category, remove size/date columns, make zustand admin-configurable 2026-04-16 08:22:01 +02:00
Matthias Hochmeister
058ee721e8 feat(persoenliche-ausruestung): show catalog category, remove size/date columns, make zustand admin-configurable 2026-04-16 08:19:38 +02:00
Matthias Hochmeister
dac0b79b3b eat(ausruestung): allow create role to view full list, add Mitglieder pagination, add admin reset for persoenliche Ausruestung 2026-04-16 07:52:36 +02:00
Matthias Hochmeister
3f8c4d151d fix(persoenliche-ausruestung): save characteristics on create/edit and add editable eigenschaft fields to assignment page 2026-04-15 20:06:02 +02:00
Matthias Hochmeister
260b71baf8 refactor(mitglieder): replace legacy status values (passiv/anwärter/ausgetreten/…) with aktiv/kind/jugend/reserve across backend, frontend, and sync 2026-04-15 19:43:18 +02:00
Matthias Hochmeister
c1de8bd163 fix(dienstgrad): add ASB→Abschnittssachbearbeiter, remove non-existent ranks (FA/FF/BOI/BAM variants), sync DB constraint, TS types, and display map 2026-04-15 19:26:21 +02:00
Matthias Hochmeister
eb2342684e feat(bestellungen): add optional "Für Mitglied" field, auto-populated from internal request submitter 2026-04-15 18:17:54 +02:00
Matthias Hochmeister
67fd0878ce fix(sync): add Sachbearbeiter to dienstgrad constraint; add catalog browser dialog for external order position 2026-04-15 18:05:39 +02:00
Matthias Hochmeister
9586822a32 fix(sync): scrape AusbildungenListEdit instead of KursteilnehmerListEdit, add selectAlleAnzeige, fix column detection; handle Sachbearbeiter dienstgrad and ignore placeholder handles 2026-04-15 17:40:08 +02:00
Matthias Hochmeister
55b2fc1cf4 fix(sync): switch to full member list scrape so all FDISK members are synced, not just known accounts 2026-04-15 14:44:39 +02:00
Matthias Hochmeister
719b7bfcdb feat(sync): sync all FDISK members, auto-creating dashboard accounts for users not yet logged in 2026-04-15 14:36:57 +02:00
Matthias Hochmeister
dab4a45b79 fix(shop): don't load assigned items until a user is explicitly selected 2026-04-15 14:21:30 +02:00
Matthias Hochmeister
c3fcbd1467 fix(bestellungen): use status label as button text, all progress buttons blue, keep red for reject only 2026-04-15 14:03:54 +02:00
Matthias Hochmeister
6ff531f79c refactor(mitglieder): split member profile into Stammdaten/Ausrüstung/Qualifikationen tabs with sub-tabs per qualification type 2026-04-15 13:55:16 +02:00
Matthias Hochmeister
50dbf6e9fd fix(multi): FDISK sync, order UX, Ausbildungen display, untracked items
FDISK sync:
- fix(sync): strip 'KFZ-Führerschein / ' prefix from license class select option text before whitelist validation
- fix(sync): fix navigateAndGetTableRows to pick date column with most matches (prevents sidebar tables from hijacking dateColIdx for Beförderungen)
- fix(sync): input.value fallback now falls through to textContent when value is empty
- feat(sync): expand Ausbildungen to capture Kursnummer, Kurz, Kurs (full name), Erfolgscode from FDISK table; add migration 086

External orders (Bestellungen):
- fix(bestellungen): allow erhalten_menge editing in lieferung_pruefen status (resolves deadlock preventing order completion)
- fix(bestellungen): show cost/received warnings for bestellt/teillieferung/lieferung_pruefen, not just when abgeschlossen is next
- feat(bestellungen): rename status labels to Eingereicht, Genehmigt, Teilweise geliefert, Vollständig geliefert
- fix(bestellungen): remove duplicate Bestelldatum from PDF export
- feat(bestellungen): add catalog item autocomplete to creation form (auto-fills bezeichnung + artikelnummer)

Internal orders (Ausruestungsanfrage):
- fix(ausruestung): untracked items with zuweisung_typ='keine' now appear in Nicht-zugewiesen tab (frontend filter was too strict)
- feat(ausruestung): load user-specific personal items when ordering for another user
- feat(ausruestung): auto-set ist_ersatz=true for items from personal equipment list; add toggle for catalog/free-text items
- feat(ausruestung): load item eigenschaften when personal item with artikel_id is checked

Ausbildungen display:
- feat(mitglieder): show kursname (full), kurs_kurzbezeichnung chip, erfolgscode chip (color-coded) per Ausbildung entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 13:22:04 +02:00
Matthias Hochmeister
916aa488d2 change label 2026-04-15 11:18:25 +02:00
Matthias Hochmeister
4c01683c10 fix(ausruestung): show untracked assignments, item traits in order wizard, receipt
gate for completion, PDF phone + last-row line
2026-04-15 10:58:51 +02:00
Matthias Hochmeister
279cc03b6b feat(ausruestung): catalog-driven item tracking, im_haus in overview, order quantity override, fix stale queries 2026-04-15 10:20:36 +02:00
Matthias Hochmeister
633a75cb0b feat(ausruestungsanfrage): add personal item tracking, catalog enforcement, and detail pages 2026-04-14 16:49:20 +02:00
Matthias Hochmeister
e6b6639fe9 fix(buchhaltung): format transaction IDs as YYYY/NR and deduplicate dashboard widgets 2026-04-14 15:00:38 +02:00
Matthias Hochmeister
a94d486a42 fix(buchhaltung): prevent form reset on query refetch during transaction edit 2026-04-14 14:46:34 +02:00
Matthias Hochmeister
7392bfc29f feat(buchhaltung): replace transaction dialog with dedicated form page, enforce full field validation before booking 2026-04-14 14:41:30 +02:00
Matthias Hochmeister
967cad5922 feat(buchhaltung): add edit support for pending transactions 2026-04-14 13:46:07 +02:00
Matthias Hochmeister
f403c73334 fix(buchhaltung): clean up tab labels, remove badge indicator, add session notification for pending transactions 2026-04-14 13:35:40 +02:00
Matthias Hochmeister
3a8f166121 refactor(buchhaltung): simplify transaction workflow to two states, reorder tabs, guard booking, add overview divider 2026-04-14 13:16:45 +02:00
Matthias Hochmeister
588d8e81db widget icon rework, widget grouping rework 2026-04-14 10:53:03 +02:00
Matthias Hochmeister
4fbea8af81 feat: widget icons, dark theme tables, breadcrumb removal, bookkeeping rework, personal equipment pages, PDF/order improvements 2026-04-14 10:35:40 +02:00
Matthias Hochmeister
4c4fb01e68 fix: correct table name ausbildung (not ausbildungen) in fdisk and purge handlers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:18:41 +02:00
Matthias Hochmeister
25d418539e fix(migration): make trigger creation idempotent with DROP IF EXISTS
Previous failed runs committed the CREATE TABLE/TRIGGER DDL outside a
transaction (pool.query BEGIN/ROLLBACK is not connection-pinned), leaving
the trigger in place. Re-runs then fail with 'already exists'.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:15:29 +02:00
Matthias Hochmeister
67adc4f5aa fix(migration): add required label+sort_order columns to permissions INSERT
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:06:06 +02:00
Matthias Hochmeister
bb95a66ffe fix(migration): fix feature_groups column names (label, sort_order not name/maintenance_mode)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:04:10 +02:00
Matthias Hochmeister
1ad328edd3 fix(migration): use correct column name authentik_group in group_permissions seed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 20:01:20 +02:00
Matthias Hochmeister
1215e9ea70 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>
2026-04-13 19:19:35 +02:00
Matthias Hochmeister
b477e5dbe0 feat: user data purge, breadcrumbs, first-login dialog, widget consolidation, bookkeeping cascade
- Admin can purge all personal data for a user (POST /api/admin/users/:userId/purge-data)
  while keeping the account; clears profile, notifications, bookings, ical tokens, preferences
- Add isNewUser flag to auth callback response; first-login dialog prompts for Standesbuchnummer
- Add PageBreadcrumbs component and apply to 18 sub-pages across the app
- Cascade budget_typ changes from parent pot to all children recursively, converting amounts
  (detailliert→einfach: sum into budget_gesamt; einfach→detailliert: zero all for redistribution)
- Migrate NextcloudTalkWidget to use shared WidgetCard template for consistent header styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 16:15:28 +02:00
Matthias Hochmeister
a0b3c0ec5c fix(dashboard): fix group/widget state loss due to partial preference saves and stale closures 2026-04-13 15:28:20 +02:00
Matthias Hochmeister
b275d4baa5 feat(dashboard): make widget groups reorderable via drag-and-drop 2026-04-13 15:15:50 +02:00
Matthias Hochmeister
dd5cd71fd1 feat(dashboard,admin): widget group customization and FDISK data purge 2026-04-13 15:06:34 +02:00
Matthias Hochmeister
f4690cf185 feat(frontend): visual design overhaul — Inter font, softer cards/shadows, red-themed profile banner, modern typography hierarchy, and refreshed color palette 2026-04-13 11:07:28 +02:00
Matthias Hochmeister
43ce1f930c feat(frontend): implement unified design system with 17 reusable template components, skeleton loading states, and golden-ratio-based layouts 2026-04-13 10:43:27 +02:00
Matthias Hochmeister
5acfd7cc4f feat(buchhaltung): add transfers, bank statements, Haushaltsplan, and PDF export 2026-03-30 17:05:18 +02:00
Matthias Hochmeister
2eb59e9ff1 feat(buchhaltung): add audit trail UI to konto detail transaction table 2026-03-30 16:03:58 +02:00
Matthias Hochmeister
d833b3c224 feat(buchhaltung): recurring job, budget alerts, audit endpoint, konto-typen CRUD 2026-03-30 15:04:06 +02:00
Matthias Hochmeister
bbbfc8eaaa fix(buchhaltung): remove budget utilization bars from accounts overview 2026-03-30 14:44:06 +02:00
Matthias Hochmeister
f27e3134ca fix(buchhaltung): respect budget_typ in konten manage table and pod detail subaccounts view 2026-03-30 14:37:04 +02:00
Matthias Hochmeister
293848c710 fix(buchhaltung): use theme-aware color for category header rows in dark mode 2026-03-30 14:22:56 +02:00
Matthias Hochmeister
fcfee85efd fix(buchhaltung): show budget-typ label in pod settings view mode 2026-03-30 14:20:57 +02:00
Matthias Hochmeister
b21abce9e3 feat(buchhaltung): budget types, erstattungen, recurring tab move, overview dividers, order completion guard 2026-03-30 14:07:04 +02:00
Matthias Hochmeister
13aa4be599 feat(buchhaltung): add categories, recurring tx scheduling, sub-pot budget validation, and UX polish 2026-03-30 12:56:33 +02:00
Matthias Hochmeister
86cb175aeb feat(buchhaltung): move collapse arrows to row end, always-visible filters, summary row, sortable transactions, account manage page 2026-03-30 11:59:05 +02:00
Matthias Hochmeister
4e42d4077a fix: hard-delete konten instead of soft-deactivate, convert kontonummer to INTEGER with arithmetic sub-account derivation 2026-03-30 11:25:48 +02:00
Matthias Hochmeister
e4f1d8864a refactor: change kontonummer to INTEGER, derive sub-account number as parent + suffix (arithmetic) 2026-03-30 11:23:07 +02:00
Matthias Hochmeister
5f25d644f4 feat: add Buchhaltung data reset (Transaktionen, Konten, Bankkonten) to admin DataManagementTab 2026-03-30 11:16:57 +02:00
Matthias Hochmeister
333b94f64e fix: read error message from ApiError.message instead of err.response in konto mutation onError 2026-03-30 11:11:17 +02:00
Matthias Hochmeister
75b07d6afc fix: use fahrzeuge_mit_pruefstatus view in notification job to resolve missing naechste_pruefung_tage column 2026-03-30 11:08:25 +02:00
Matthias Hochmeister
b7015ace84 fix: return 409 on duplicate kontonummer, show server error in snackbar, block save when sub-account suffix is empty 2026-03-30 11:03:53 +02:00
Matthias Hochmeister
2e736f7995 fix: invalidate kontenTree cache on konto mutations, remove Kontotyp field from dialog 2026-03-30 10:59:48 +02:00
Matthias Hochmeister
cdaaec2971 fix: auto-select open fiscal year on load, derive sub-account number from parent, replace flat konten table with collapsible tree 2026-03-30 10:51:55 +02:00
Matthias Hochmeister
0c5432b50e feat: add account hierarchy, budget types (GWG/Anlagen/Instandhaltung), and Buchhaltung UI overhaul with collapsible tree, pending badge, and konto detail page 2026-03-30 09:49:28 +02:00
Matthias Hochmeister
bc39963746 feat: add Buchhaltung dashboard widget, CSV export, Bestellungen linking, recurring bookings, and approval workflow 2026-03-28 20:34:53 +01:00
Matthias Hochmeister
c1fca5cc67 fix: disable Konto button without fiscal year selected, remove BIC from bank account form 2026-03-28 20:03:31 +01:00
Matthias Hochmeister
712afde30e ix: use DashboardLayout wrapper in Buchhaltung page 2026-03-28 19:59:39 +01:00
Matthias Hochmeister
3871efc026 fix: cast multer file type explicitly in uploadBeleg controller 2026-03-28 19:55:57 +01:00
Matthias Hochmeister
58585327d8 fix: remove nonexistent MainLayout wrapper from Buchhaltung page 2026-03-28 19:54:12 +01:00
Matthias Hochmeister
18b1300de8 feat: add Buchhaltung module with fiscal years, budget accounts, transactions, and approval workflow 2026-03-28 19:48:32 +01:00
Matthias Hochmeister
4349de9bc9 feat: checklist multi-type assignments, tab layouts for Fahrzeuge/Ausruestung, admin cleanup
- Migration 074: convert checklist vorlage single FK fields to junction tables
  (vorlage_fahrzeug_typen, vorlage_fahrzeuge, vorlage_ausruestung_typen, vorlage_ausruestungen)
- Backend checklist service: multi-type create/update/query with array fields
- Backend cleanup service: add checklist-history and reset-checklist-history targets
- Frontend types/service: singular FK fields replaced with arrays (fahrzeug_typ_ids, etc.)
- Frontend Checklisten.tsx: multi-select Autocomplete pickers for all assignment types
- Fahrzeuge.tsx/Ausruestung.tsx: add tab layout (Uebersicht + Einstellungen), inline type CRUD
- FahrzeugEinstellungen/AusruestungEinstellungen: replaced with redirects to tab URLs
- Sidebar: add Uebersicht sub-items, update Einstellungen paths to tab URLs
- DataManagementTab: add checklist-history cleanup and reset sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 18:57:46 +01:00
Matthias Hochmeister
893fbe43a0 feat: add hierarchical subitems to checklist templates and executions 2026-03-28 18:37:36 +01:00
Matthias Hochmeister
51be3b54f6 feat: add vehicle type assignment to vehicle edit/create form 2026-03-28 18:23:00 +01:00
Matthias Hochmeister
15337f768d feat: add inline editing for checklist vorlage items 2026-03-28 18:17:32 +01:00
Matthias Hochmeister
4c1f188371 fix: use ausruestung.bezeichnung in checklist queries and cast interval param in atemschutz 2026-03-28 18:13:07 +01:00
Matthias Hochmeister
0d6d5e4f54 fix: repair checklist create (intervall constraint) and start execution (stuck spinner), add equipment items display 2026-03-28 18:08:06 +01:00
Matthias Hochmeister
b62fd55246 fix: correct vorlageId casing in startExecution, make checklist rows clickable with alternating backgrounds 2026-03-28 17:57:45 +01:00
Matthias Hochmeister
a04de62634 fix: repair mangled CreateVorlagePayload/UpdateVorlagePayload types after interval union edit 2026-03-28 17:51:55 +01:00
Matthias Hochmeister
6091d6c4dd feat: always show checklists in overview and add quarterly/halfyearly intervals 2026-03-28 17:46:31 +01:00
Matthias Hochmeister
a52bb2a57c refactor: move equipment type assignment from detail page to settings page 2026-03-28 17:37:01 +01:00
Matthias Hochmeister
bccb0745b8 refactor: move vehicle type assignment from detail page to settings page 2026-03-28 17:34:29 +01:00
Matthias Hochmeister
534a24edbf fix: use PATCH for vehicle type update and correct permission check in FahrzeugEinstellungen 2026-03-28 17:30:40 +01:00
Matthias Hochmeister
6b46e97eb6 feat: vehicle/equipment type system, equipment checklist support, and checklist overview redesign 2026-03-28 17:27:01 +01:00
Matthias Hochmeister
692093cc85 fix: add checklisten sidebar sub-items and replace unicode escapes with proper umlauts 2026-03-28 16:27:32 +01:00
Matthias Hochmeister
b171c3e921 fix: sync nav DnD list when permissions load 2026-03-28 16:19:20 +01:00
Matthias Hochmeister
443f3569bd feat: move dashboard edit button to bottom with label, replace menu order arrows with drag-and-drop 2026-03-28 15:40:03 +01:00
Matthias Hochmeister
0a912e60b5 fix: suppress unused userId params in checklist and issue services 2026-03-28 15:27:15 +01:00
Matthias Hochmeister
0c2ea829aa feat: add issue kanban/attachments/deadlines, dashboard widget DnD, and checklisten system 2026-03-28 15:19:41 +01:00
Matthias Hochmeister
a1cda5be51 feat: add day separator labels between chat messages 2026-03-28 10:48:06 +01:00
Matthias Hochmeister
992a184784 fix: use isActive instead of chatPanelOpen in ChatMessageView so messages load on /chat page 2026-03-28 10:44:44 +01:00
Matthias Hochmeister
60e1329815 fix: render ChatContent as DashboardLayout child so useChat has ChatProvider in scope 2026-03-27 18:03:54 +01:00
Matthias Hochmeister
c1b4a92a12 feat: add full-page chat route and sidebar menu ordering 2026-03-27 18:00:58 +01:00
Matthias Hochmeister
1a66a66aab fix: guard vendor detail loading on instead of isPending to avoid stuck skeleton 2026-03-27 17:43:31 +01:00
Matthias Hochmeister
b36e05d192 fix: show read-only detail view after vendor creation instead of staying in edit mode 2026-03-27 17:38:23 +01:00
Matthias Hochmeister
82c386888f fix: reset editMode and form when navigating to vendor create page 2026-03-27 17:27:55 +01:00
Matthias Hochmeister
ae3f0c825b refactor: extract shared KatalogTab component, use it in both Bestellungen and Ausruestungsanfrage 2026-03-27 15:05:10 +01:00
Matthias Hochmeister
3f55990212 fix: add Katalog sub-item to Bestellungen sidebar navigation 2026-03-27 14:58:04 +01:00
Matthias Hochmeister
b5e8f11743 fix: add missing EditIcon import in AusruestungsanfrageArtikelDetail 2026-03-27 14:57:08 +01:00
Matthias Hochmeister
29d66e37a1 shared catalog in Bestellungen, catalog picker in line items, Ersatzbeschaffung flag, vendor detail flash fix 2026-03-27 14:50:31 +01:00
Matthias Hochmeister
c704e2c173 catalog search/sort, edit-page characteristics, preferred vendor per article 2026-03-27 13:46:29 +01:00
Matthias Hochmeister
6885cba3be catalog search/sort, edit-page characteristics, preferred vendor per article 2026-03-27 13:45:13 +01:00
Matthias Hochmeister
35b3718e38 catalog search/sort, edit-page characteristics, preferred vendor per article 2026-03-27 13:17:05 +01:00
Matthias Hochmeister
eb82fe29b7 add linking between internal and external orders 2026-03-27 12:28:25 +01:00
Matthias Hochmeister
90f691d607 add linking between internal and external orders 2026-03-27 11:18:06 +01:00
Matthias Hochmeister
75e533c1fc update 2026-03-27 07:39:25 +01:00
Matthias Hochmeister
03f489d546 update 2026-03-26 16:12:18 +01:00
Matthias Hochmeister
19dd528765 update 2026-03-26 16:02:05 +01:00
Matthias Hochmeister
80cfb244cf update 2026-03-26 15:29:11 +01:00
Matthias Hochmeister
0c101bea8b update 2026-03-26 15:24:29 +01:00
Matthias Hochmeister
ecee41b3aa update 2026-03-26 15:21:56 +01:00
Matthias Hochmeister
9c3dda337f update 2026-03-26 15:18:24 +01:00
Matthias Hochmeister
66916ce6b5 update 2026-03-26 15:07:11 +01:00
Matthias Hochmeister
671f0dedda update 2026-03-26 14:54:59 +01:00
Matthias Hochmeister
7b7d799238 update 2026-03-26 14:52:06 +01:00
Matthias Hochmeister
841b6e3775 update 2026-03-26 14:44:30 +01:00
Matthias Hochmeister
3e5086441e update 2026-03-26 14:38:31 +01:00
Matthias Hochmeister
c29b21f714 update 2026-03-26 14:22:35 +01:00
Matthias Hochmeister
3c95b7506b update 2026-03-26 13:01:59 +01:00
Matthias Hochmeister
507111e8e8 update 2026-03-26 12:12:18 +01:00
Matthias Hochmeister
d351ea2647 update 2026-03-26 11:32:50 +01:00
Matthias Hochmeister
d4adf9230d update 2026-03-26 11:25:28 +01:00
Matthias Hochmeister
3d03345107 update 2026-03-26 11:00:03 +01:00
Matthias Hochmeister
ca12a23a30 update 2026-03-26 10:56:39 +01:00
Matthias Hochmeister
d5e5f2d44e update 2026-03-26 09:29:59 +01:00
Matthias Hochmeister
884397b520 calendar and vehicle booking rework 2026-03-26 08:47:38 +01:00
Matthias Hochmeister
21bbe57d6f calendar and vehicle booking rework 2026-03-25 16:18:40 +01:00
Matthias Hochmeister
7dab359448 calendar and vehicle booking rework 2026-03-25 16:09:16 +01:00
Matthias Hochmeister
7cc4facc11 calendar and vehicle booking rework 2026-03-25 16:01:03 +01:00
Matthias Hochmeister
e78a23bc05 calendar and vehicle booking rework 2026-03-25 15:56:44 +01:00
Matthias Hochmeister
74d978171c calendar and vehicle booking rework 2026-03-25 15:44:11 +01:00
Matthias Hochmeister
e49639e2a6 reoccurring event fix 2026-03-25 15:22:31 +01:00
Matthias Hochmeister
51d8777d66 vehicle booking bug resolve 2026-03-25 15:17:54 +01:00
Matthias Hochmeister
0bb2feaba2 refactor external orders 2026-03-25 14:55:25 +01:00
Matthias Hochmeister
5add6590e5 refactor external orders 2026-03-25 14:26:41 +01:00
Matthias Hochmeister
561334791b rework external order tracking system 2026-03-25 13:24:52 +01:00
Matthias Hochmeister
e02ada8b95 rework from modal to page 2026-03-25 12:55:49 +01:00
Matthias Hochmeister
feb39d234f rework from modal to page 2026-03-25 10:23:28 +01:00
Matthias Hochmeister
4ad260ce66 rework from modal to page 2026-03-25 09:37:16 +01:00
Matthias Hochmeister
4ed76fe20d fix permissions 2026-03-25 09:07:31 +01:00
Matthias Hochmeister
5db4cc21b5 fix permissions 2026-03-25 09:02:49 +01:00
Matthias Hochmeister
6f39f22bf9 fix permissions 2026-03-25 08:55:11 +01:00
Matthias Hochmeister
eb92dfcc96 fix permissions 2026-03-25 08:42:45 +01:00
Matthias Hochmeister
43b7093996 fix permissions 2026-03-25 08:22:32 +01:00
Matthias Hochmeister
5ceae7c364 fix permissions 2026-03-25 07:54:40 +01:00
Matthias Hochmeister
59140939df fix permissions 2026-03-25 07:48:00 +01:00
Matthias Hochmeister
5a64987236 fix permissions 2026-03-25 07:24:57 +01:00
Matthias Hochmeister
86cd7b4ce0 fix permissions 2026-03-24 17:59:28 +01:00
Matthias Hochmeister
f228dd67ba fix permissions 2026-03-24 17:54:36 +01:00
Matthias Hochmeister
e6ddf67d95 fix permissions 2026-03-24 17:20:31 +01:00
Matthias Hochmeister
f9f54b7e07 fix permissions 2026-03-24 17:10:01 +01:00
Matthias Hochmeister
a0d99dce8d fix permissions 2026-03-24 15:47:57 +01:00
Matthias Hochmeister
f1bd3e162f rework issue system 2026-03-24 15:32:54 +01:00
Matthias Hochmeister
d8d2730547 rework issue system 2026-03-24 15:30:24 +01:00
Matthias Hochmeister
65994286b2 rework issue system 2026-03-24 14:44:21 +01:00
Matthias Hochmeister
0dd5033664 rework issue system 2026-03-24 14:23:50 +01:00
Matthias Hochmeister
6c7531438e rework issue system 2026-03-24 14:21:17 +01:00
Matthias Hochmeister
abb337c683 rework internal order system 2026-03-24 14:02:16 +01:00
Matthias Hochmeister
90944ca5f6 rework internal order system 2026-03-24 13:42:04 +01:00
Matthias Hochmeister
64663c0fe4 rework internal order system 2026-03-24 13:35:33 +01:00
Matthias Hochmeister
9a52e41372 rework internal order system 2026-03-24 13:28:46 +01:00
Matthias Hochmeister
50d963120a rework internal order system 2026-03-24 13:11:20 +01:00
Matthias Hochmeister
343cd7aee2 rework internal order system 2026-03-24 10:33:39 +01:00
Matthias Hochmeister
2b77ae5724 rework internal order system 2026-03-24 10:22:31 +01:00
Matthias Hochmeister
3ce8adfa07 rework internal order system 2026-03-24 10:07:35 +01:00
Matthias Hochmeister
0389c3d2aa rework internal order system 2026-03-24 09:58:33 +01:00
Matthias Hochmeister
209d5a676e rework internal order system 2026-03-24 09:35:37 +01:00
Matthias Hochmeister
39b8b30ca2 rework internal order system 2026-03-24 09:15:57 +01:00
Matthias Hochmeister
6ff5cc89ad rework internal order system 2026-03-24 08:59:46 +01:00
Matthias Hochmeister
3c0a8a6832 rework internal order system 2026-03-24 08:41:24 +01:00
Matthias Hochmeister
f982fbb2b6 rework internal order system 2026-03-24 08:13:43 +01:00
Matthias Hochmeister
99f02b8425 rework internal order system 2026-03-24 08:11:32 +01:00
Matthias Hochmeister
742c37b8de new features 2026-03-23 18:54:22 +01:00
Matthias Hochmeister
08249f1846 new features 2026-03-23 18:51:44 +01:00
Matthias Hochmeister
a63d78e9d9 new features 2026-03-23 18:49:48 +01:00
Matthias Hochmeister
135dbf6d71 new features 2026-03-23 18:47:45 +01:00
Matthias Hochmeister
c1313ce52e new features 2026-03-23 18:45:23 +01:00
Matthias Hochmeister
1b13e4f89e new features 2026-03-23 18:43:30 +01:00
Matthias Hochmeister
202a658b8d new features 2026-03-23 18:08:49 +01:00
Matthias Hochmeister
e720c52896 new features 2026-03-23 17:59:43 +01:00
Matthias Hochmeister
97c9af7f14 new features 2026-03-23 17:54:19 +01:00
Matthias Hochmeister
4c323748fd new features 2026-03-23 17:45:51 +01:00
Matthias Hochmeister
0ffa6feb4c new features 2026-03-23 17:38:25 +01:00
Matthias Hochmeister
f81d994e64 new features 2026-03-23 17:21:25 +01:00
Matthias Hochmeister
269b797f42 new features 2026-03-23 17:18:38 +01:00
Matthias Hochmeister
da08948ca8 new features 2026-03-23 17:01:32 +01:00
Matthias Hochmeister
55ded22a6f new features 2026-03-23 16:58:46 +01:00
Matthias Hochmeister
948b211f70 new features 2026-03-23 16:54:09 +01:00
Matthias Hochmeister
690f260b71 new features 2026-03-23 16:47:36 +01:00
Matthias Hochmeister
8c66492b27 new features 2026-03-23 16:09:42 +01:00
Matthias Hochmeister
e9a9478aac new features 2026-03-23 15:44:43 +01:00
Matthias Hochmeister
bfcf1556da new features 2026-03-23 15:07:17 +01:00
Matthias Hochmeister
34ee80b8c1 new features 2026-03-23 14:10:20 +01:00
Matthias Hochmeister
1d011ec2df new features 2026-03-23 14:08:01 +01:00
Matthias Hochmeister
3326156b15 new features 2026-03-23 14:01:39 +01:00
Matthias Hochmeister
d2dc64d54a new features 2026-03-23 13:14:33 +01:00
Matthias Hochmeister
9443c9457b new features 2026-03-23 13:08:38 +01:00
Matthias Hochmeister
5032e1593b new features 2026-03-23 13:08:19 +01:00
Matthias Hochmeister
83b84664ce rights system 2026-03-23 12:35:28 +01:00
Matthias Hochmeister
725d4d1729 rights system 2026-03-23 12:18:46 +01:00
Matthias Hochmeister
fa10467f21 rights system 2026-03-23 12:12:21 +01:00
Matthias Hochmeister
a575b61d26 rights system 2026-03-23 12:00:09 +01:00
Matthias Hochmeister
d173c8235e rights system 2026-03-23 11:48:00 +01:00
Matthias Hochmeister
515f14956e rights system 2026-03-23 10:50:52 +01:00
Matthias Hochmeister
2bb22850f4 rights system 2026-03-23 10:07:53 +01:00
Matthias Hochmeister
f976f36cbc update 2026-03-16 16:29:50 +01:00
Matthias Hochmeister
69c508a5d8 update 2026-03-16 16:26:56 +01:00
Matthias Hochmeister
177dd1395b update 2026-03-16 16:19:07 +01:00
Matthias Hochmeister
665eca24af update 2026-03-16 16:18:02 +01:00
Matthias Hochmeister
75cf07f402 update 2026-03-16 16:14:25 +01:00
Matthias Hochmeister
57ab1e8b25 update 2026-03-16 16:05:02 +01:00
Matthias Hochmeister
d276e45248 update 2026-03-16 16:04:05 +01:00
Matthias Hochmeister
9d1c796d1a update 2026-03-16 16:02:57 +01:00
Matthias Hochmeister
43e5f907d5 update 2026-03-16 16:01:30 +01:00
Matthias Hochmeister
aab63caf20 update 2026-03-16 15:48:20 +01:00
Matthias Hochmeister
3fca17f853 update 2026-03-16 15:47:28 +01:00
Matthias Hochmeister
41f45acd1c update 2026-03-16 15:47:11 +01:00
Matthias Hochmeister
a04577ac9e update 2026-03-16 15:36:41 +01:00
Matthias Hochmeister
c15d4a50e0 update 2026-03-16 15:26:43 +01:00
Matthias Hochmeister
023bd7acbb update 2026-03-16 15:17:28 +01:00
Matthias Hochmeister
d780a284d3 update 2026-03-16 15:04:15 +01:00
Matthias Hochmeister
f3ad989a9e update 2026-03-16 15:01:09 +01:00
Matthias Hochmeister
3c72fe627f update 2026-03-16 14:44:15 +01:00
Matthias Hochmeister
550a5a4883 update 2026-03-16 14:42:39 +01:00
Matthias Hochmeister
215528a521 update 2026-03-16 14:41:08 +01:00
Matthias Hochmeister
5f329bb5c1 update 2026-03-16 12:11:32 +01:00
Matthias Hochmeister
8d03c13bee update 2026-03-14 14:10:05 +01:00
Matthias Hochmeister
992ca8e104 update 2026-03-14 13:54:49 +01:00
Matthias Hochmeister
cf6b3ad2d6 update 2026-03-14 13:45:53 +01:00
Matthias Hochmeister
789f27c37e update 2026-03-13 21:50:40 +01:00
Matthias Hochmeister
ef9d2ff4a2 update 2026-03-13 21:49:42 +01:00
Matthias Hochmeister
e666ff434e update 2026-03-13 21:44:54 +01:00
Matthias Hochmeister
3171fe1ce5 update 2026-03-13 21:41:25 +01:00
Matthias Hochmeister
8941dc7e09 update 2026-03-13 21:35:12 +01:00
Matthias Hochmeister
7245cd577e update 2026-03-13 21:33:18 +01:00
Matthias Hochmeister
b3266afbf8 update 2026-03-13 21:27:07 +01:00
Matthias Hochmeister
0d4e7b480d update 2026-03-13 21:16:44 +01:00
Matthias Hochmeister
461d28fa0d update 2026-03-13 21:04:53 +01:00
Matthias Hochmeister
b7b4fe2fc9 update 2026-03-13 21:01:54 +01:00
Matthias Hochmeister
ab29c43735 update 2026-03-13 20:28:05 +01:00
Matthias Hochmeister
f009694da7 update 2026-03-13 20:26:33 +01:00
Matthias Hochmeister
8f454905b9 update 2026-03-13 20:12:54 +01:00
Matthias Hochmeister
f5d1f7b061 update 2026-03-13 20:02:46 +01:00
Matthias Hochmeister
1b1a53cd8f update 2026-03-13 19:47:07 +01:00
Matthias Hochmeister
37c719e983 update 2026-03-13 19:42:01 +01:00
Matthias Hochmeister
c174edbb0b update 2026-03-13 19:35:25 +01:00
Matthias Hochmeister
e1aa8fa59b update 2026-03-13 19:29:56 +01:00
Matthias Hochmeister
bc6d09200a update 2026-03-13 19:23:39 +01:00
Matthias Hochmeister
02d9d808b2 update 2026-03-13 16:12:11 +01:00
Matthias Hochmeister
602d9fd5b9 update 2026-03-13 16:05:48 +01:00
Matthias Hochmeister
bb6438a0b9 update 2026-03-13 15:59:21 +01:00
Matthias Hochmeister
03155dcf7a update 2026-03-13 15:49:58 +01:00
Matthias Hochmeister
75c919c063 update 2026-03-13 15:42:15 +01:00
Matthias Hochmeister
3dda069611 update 2026-03-13 15:31:47 +01:00
Matthias Hochmeister
d31f139d9a update 2026-03-13 15:27:21 +01:00
Matthias Hochmeister
ff72daa55e update 2026-03-13 15:22:58 +01:00
Matthias Hochmeister
165acfbece update 2026-03-13 15:13:09 +01:00
Matthias Hochmeister
20d2c9093a update 2026-03-13 15:09:43 +01:00
Matthias Hochmeister
7833dca29c update 2026-03-13 15:04:40 +01:00
Matthias Hochmeister
8d9388ca9a update 2026-03-13 14:50:59 +01:00
Matthias Hochmeister
02a5359c87 update 2026-03-13 14:41:37 +01:00
Matthias Hochmeister
1cdfde0128 update 2026-03-13 14:31:58 +01:00
Matthias Hochmeister
4c7c8f72d3 update 2026-03-13 14:23:40 +01:00
Matthias Hochmeister
3ecae37d72 update 2026-03-13 14:13:39 +01:00
Matthias Hochmeister
7215e7f472 update 2026-03-13 14:01:06 +01:00
Matthias Hochmeister
3361f1e28d update 2026-03-13 13:51:08 +01:00
Matthias Hochmeister
e26d77ef35 update nextcloud for file support 2026-03-13 13:46:08 +01:00
Matthias Hochmeister
e36de3199a update 2026-03-13 13:29:21 +01:00
Matthias Hochmeister
1d5122a2cd update 2026-03-13 13:25:43 +01:00
Matthias Hochmeister
86bb8a45c1 update 2026-03-13 13:21:19 +01:00
Matthias Hochmeister
072713ca3d update 2026-03-13 13:16:14 +01:00
Matthias Hochmeister
9d68b4fb28 update 2026-03-13 13:12:08 +01:00
Matthias Hochmeister
cfb70e62c7 update 2026-03-13 13:06:27 +01:00
Matthias Hochmeister
618f1d4996 update sync 2026-03-13 12:54:48 +01:00
Matthias Hochmeister
3c9ab02b93 update sync 2026-03-13 12:28:01 +01:00
Matthias Hochmeister
e49b4f63ae update nextcloud handling 2026-03-13 12:24:14 +01:00
Matthias Hochmeister
5f0e76155f update sync 2026-03-13 12:20:17 +01:00
Matthias Hochmeister
42b9937da4 fix(sync): move typescript to dependencies to fix Docker build
npm silently skips devDependencies in some Alpine+npm combinations,
causing tsc to be missing even after npm install. Moving typescript
to regular dependencies guarantees it is always installed and its
.bin symlink is created regardless of NODE_ENV.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 12:12:57 +01:00
Matthias Hochmeister
2ec587ac97 update sync 2026-03-13 11:45:17 +01:00
Matthias Hochmeister
7427d04cf9 update sync 2026-03-13 11:40:05 +01:00
Matthias Hochmeister
98c01d8a30 update sync 2026-03-13 11:35:38 +01:00
Matthias Hochmeister
80e7730c1e add env for FDSIK 2026-03-13 11:07:55 +01:00
Matthias Hochmeister
76327832d1 add env for FDSIK 2026-03-13 10:30:16 +01:00
Matthias Hochmeister
11fb533ad6 update FDISK sync 2026-03-13 10:27:57 +01:00
Matthias Hochmeister
501b697ca2 update FDISK sync 2026-03-13 08:46:12 +01:00
Matthias Hochmeister
243da302c7 update backend stuck/stall 2026-03-13 08:30:05 +01:00
Matthias Hochmeister
60488309ca higher the update rate of the chat 2026-03-13 08:16:00 +01:00
Matthias Hochmeister
f309096497 mark chat as read 2026-03-13 08:01:55 +01:00
Matthias Hochmeister
34f246af24 resolve issues with new features 2026-03-12 18:36:22 +01:00
Matthias Hochmeister
d1fed74f3b resolve issues with new features 2026-03-12 18:09:59 +01:00
Matthias Hochmeister
dc33d388a9 resolve issues with new features 2026-03-12 17:54:19 +01:00
Matthias Hochmeister
67b7d5ccd2 resolve issues with new features 2026-03-12 17:51:57 +01:00
Matthias Hochmeister
34ca007f9b resolve issues with new features 2026-03-12 17:20:32 +01:00
Matthias Hochmeister
68586b01dc resolve issues with new features 2026-03-12 16:42:21 +01:00
Matthias Hochmeister
5aa309b97a resolve issues with new features 2026-03-12 16:05:01 +01:00
Matthias Hochmeister
a5cd78f01f feat: bug fixes, layout improvements, and new features
Bug fixes:
- Remove non-existent `role` column from admin users SQL query (A1)
- Fix Nextcloud Talk chat API path v4 → v1 for messages/send/read (A2)
- Fix ServiceModeTab sync: useState → useEffect to reflect DB state (A3)
- Guard BookStack book_slug with book_id fallback to avoid broken URLs (A4)

Layout & UI:
- Chat panel: sticky full-height positioning, main content scrolls independently (B1)
- Vehicle booking datetime inputs: explicit text color for dark mode (B2)
- AnnouncementBanner moved into grid with full-width span (B3)

Features:
- Per-user widget visibility preferences stored in users.preferences JSONB (C1)
- Link collections: grouped external links in admin UI and dashboard widget (C2)
- Admin ping history: migration 026, checked_at timestamps, expandable history rows (C4)
- Service mode end date picker with scheduled deactivation display (C5)
- Vikunja startup config logging and configured:false warnings (C7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 14:57:54 +01:00
Matthias Hochmeister
81174c2498 layout issues 2026-03-12 14:23:57 +01:00
Matthias Hochmeister
21b7be22db feat: service mode (maintenance mode)
Admins can toggle maintenance mode from the admin dashboard (new
"Wartung" tab). When active, all non-admin users see a full-page
maintenance screen instead of the app.

- Backend: GET /api/config/service-mode endpoint (authenticated)
- Backend: stores state in app_settings key 'service_mode'
- Frontend: ServiceModeGuard wraps all ProtectedRoutes
- Frontend: ServiceModePage full-screen maintenance UI
- Frontend: ServiceModeTab in admin dashboard with toggle + message
- Admins (dashboard_admin group) always bypass the guard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 13:21:49 +01:00
Matthias Hochmeister
6c1cbb0ef3 fix: remove volatile NOW() from banner index predicate
PostgreSQL forbids volatile functions in partial index predicates.
Replace with two plain indexes on starts_at and ends_at instead —
the active-banner filtering is handled in the query WHERE clause.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 13:08:26 +01:00
Matthias Hochmeister
36c222d32a fix: remove Apple npm registry from lock files and .npmrc
Replace all artifacts.apple.com and npm.apple.com resolved URLs in
package-lock.json with registry.npmjs.org so `npm install` works on
any server without Apple VPN/proxy access.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 12:21:40 +01:00
Matthias Hochmeister
cf490cc9ad fix: install missing packages and fix TS errors
- Install @mui/x-data-grid in frontend (fixes AuditLog)
- Install jose in backend (fixes authentik service)
- Update .npmrc to use npm.apple.com proxy
- Fix AuditLog localeText to use MUI DataGrid v7 API keys
- Fix banner controller: cast req.params.id to string
- Remove unused logger import in banner.service.ts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 12:04:54 +01:00
Matthias Hochmeister
cd68bd3795 annoucement banners, calendar pdf export, vehicle booking quck-add, even quick-add 2026-03-12 11:47:08 +01:00
Matthias Hochmeister
71a04aee89 resolve issues with new features 2026-03-12 11:37:25 +01:00
Matthias Hochmeister
d5be68ca63 resolve issues with new features 2026-03-12 10:21:26 +01:00
Matthias Hochmeister
31f1414e06 adding chat features, admin features and bug fixes 2026-03-12 08:16:34 +01:00
Matthias Hochmeister
7b14e3d5ba fix bookstack search 2026-03-11 14:02:25 +01:00
root
34f577bf06 fix backend npm 2026-03-11 13:53:44 +01:00
Matthias Hochmeister
3c9b7d3446 apply security audit 2026-03-11 13:51:01 +01:00
Matthias Hochmeister
93a87a7ae9 apply security audit 2026-03-11 13:18:10 +01:00
Matthias Hochmeister
e9463c1c66 add vikunja integration 2026-03-05 18:07:18 +01:00
Matthias Hochmeister
fb5acd3d52 calendar download, date input validate, nc talk notification 2026-03-04 15:01:26 +01:00
Matthias Hochmeister
d27d2931a5 calendar download, date input validate, nc talk notification 2026-03-04 14:39:39 +01:00
Matthias Hochmeister
179bbabd58 change widget name in GUI 2026-03-04 08:08:00 +01:00
Matthias Hochmeister
029a721c43 add env vars for bookstack setup to docker 2026-03-04 08:00:10 +01:00
Matthias Hochmeister
11335748c2 fix bookstack display error 2026-03-04 07:52:11 +01:00
Matthias Hochmeister
32473f8329 new features, bookstack 2026-03-03 22:07:42 +01:00
Matthias Hochmeister
926f79b576 new features, bookstack 2026-03-03 21:45:47 +01:00
Matthias Hochmeister
d3561c1109 new features, bookstack 2026-03-03 21:30:38 +01:00
Matthias Hochmeister
817329db70 add features 2026-03-03 17:03:27 +01:00
Matthias Hochmeister
5a6fc85a75 add features 2026-03-03 17:01:53 +01:00
Matthias Hochmeister
92b05726d4 permission changes 2026-03-03 15:38:17 +01:00
Matthias Hochmeister
c431c1af83 userId bug 2026-03-03 15:00:53 +01:00
Matthias Hochmeister
b3a2fd9ff9 feat: responsive widgets, atemschutz permission UX, event hard-delete
- fix dashboard grid: use auto-fill instead of auto-fit for equal-width widgets
- atemschutz: skip stats/members API calls for non-privileged users, hide
  empty Aktionen column, add personal status subtitle
- kalender: add permanent delete option for events with confirmation dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 14:59:08 +01:00
Matthias Hochmeister
5dfaf7db54 bug fixes 2026-03-03 14:45:46 +01:00
Matthias Hochmeister
004b141cab bug fixes 2026-03-03 13:27:27 +01:00
Matthias Hochmeister
02cf5138cf fix: dashboard layout, widget caching, and backend stability
Layout:
- Remove Container maxWidth cap so widgets scale fluidly on wide screens
- Fix ActivityFeed Card missing height:100% and overflow:hidden that caused
  the timeline connector pseudo-element to bleed outside the card boundary

Performance (frontend):
- Migrate VehicleDashboardCard, EquipmentDashboardCard, AtemschutzDashboardCard,
  UpcomingEventsWidget, and PersonalWarningsBanner from useEffect+useState to
  TanStack Query — cached for 5 min, so navigating back to the dashboard no
  longer re-fires all 9 API requests
- Add gcTime:10min and refetchOnWindowFocus:false to QueryClient defaults to
  prevent spurious refetches on tab-switch

Backend stability:
- Raise default RATE_LIMIT_MAX from 100 to 300 req/15min — the previous limit
  was easily exceeded by a single active user during normal dashboard navigation
- Increase DB connectionTimeoutMillis from 2s to 5s to handle burst-load
  scenarios where multiple requests compete for pool slots simultaneously

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 12:55:16 +01:00
Matthias Hochmeister
d91f757f34 bug fixes 2026-03-03 11:45:08 +01:00
Matthias Hochmeister
3101f1a9c5 fix: five dashboard improvements across booking, vehicles, profile, and UI
- fix(auth): guard extractNames() against Authentik sending full name in
  given_name field (e.g. "Matthias Hochmeister" + family_name "Hochmeister");
  detect by checking given_name ends with family_name suffix, fall through
  to name-splitting so Vorname/Nachname display correctly in Profile

- fix(db): add migration 018 to repair broken BEFORE UPDATE triggers on
  veranstaltungen and veranstaltung_kategorien; old triggers called
  update_updated_at_column() which references NEW.updated_at, but both
  tables use aktualisiert_am, causing every category/event edit to fail

- feat(booking): open vehicle booking creation to all authenticated users;
  only dashboard_admin / dashboard_moderator can change the Buchungsart
  (type select disabled for regular members); edit and cancel still
  restricted to WRITE_GROUPS

- feat(vehicles): VehicleDashboardCard now fetches equipment warnings via
  equipmentApi.getVehicleWarnings() in parallel and shows an alert when
  any vehicle equipment is not einsatzbereit

- fix(ui): add MuiTextField defaultProps (InputLabelProps.shrink=true) and
  MuiOutlinedInput notch legend font-size override to theme to eliminate
  floating-label / border conflict on click

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 11:04:57 +01:00
Matthias Hochmeister
2306741c4d feat: dashboard widgets, auth fix, profile names, dynamic groups
- Add VehicleDashboardCard: self-contained widget modelled after
  AtemschutzDashboardCard, shows einsatzbereit ratio and inspection
  warnings inline; replaces StatsCard + InspectionAlerts in Dashboard

- Add EquipmentDashboardCard: consolidated equipment status widget
  showing only aggregated counts (no per-item listing); replaces
  EquipmentAlerts component in Dashboard

- Fix auth race condition: add authInitialized flag to api.ts so 401
  responses during initial token validation no longer trigger a
  spurious redirect to /login; save intended destination before login
  redirect and restore it after successful auth callback

- Fix profile firstname/lastname: add extractNames() helper to
  auth.controller.ts that falls back to splitting userinfo.name when
  Authentik does not provide separate given_name/family_name fields;
  applied on both create and update paths

- Dynamic groups endpoint: replace hardcoded KNOWN_GROUPS array in
  events.controller.ts with a DB query (SELECT DISTINCT unnest
  (authentik_groups) FROM users); known slugs get German labels via
  lookup map, unknown slugs are humanized automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 10:28:31 +01:00
Matthias Hochmeister
831927ae90 featuer change for calendar 2026-03-03 09:56:35 +01:00
Matthias Hochmeister
232d8aa872 featuer change for calendar 2026-03-03 09:55:18 +01:00
Matthias Hochmeister
64b23bae5c featuer change for calendar 2026-03-03 09:53:23 +01:00
Matthias Hochmeister
d9af34b744 featuer change for calendar 2026-03-03 09:52:10 +01:00
Matthias Hochmeister
146f79cf00 featuer change for calendar 2026-03-03 08:57:32 +01:00
Matthias Hochmeister
ad069fde10 calendar: add category-group links, fix iCal share URL, remove legend
- Link categories to user groups via new zielgruppen column on
  veranstaltung_kategorien (migration 017), editable in the category
  management UI with group checkboxes and chip display
- Fix broken iCal share link by adding ICAL_BASE_URL to docker-compose
  and falling back to CORS_ORIGIN when ICAL_BASE_URL is unset
- Remove the colored-dot legend footer from the month calendar view

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:10:33 +01:00
Matthias Hochmeister
9a6b9511c8 featuer change for calendar 2026-03-02 17:26:01 +01:00
Matthias Hochmeister
314b6c3bed featuer change for calendar 2026-03-02 16:59:24 +01:00
Matthias Hochmeister
355163820f bug fix for atemschutz 2026-03-01 19:21:34 +01:00
Matthias Hochmeister
6495ca94d1 bug fix for atemschutz 2026-03-01 19:19:12 +01:00
Matthias Hochmeister
2630224edd bug fix for atemschutz 2026-03-01 19:17:36 +01:00
Matthias Hochmeister
d9e6c0658f bug fix for atemschutz 2026-03-01 18:21:00 +01:00
Matthias Hochmeister
46d0895ebd bug fix for atemschutz 2026-03-01 18:15:14 +01:00
Matthias Hochmeister
5f0ed3c87e bug fix for atemschutz 2026-03-01 18:11:00 +01:00
Matthias Hochmeister
d074caa695 bug fix for atemschutz 2026-03-01 18:07:17 +01:00
Matthias Hochmeister
4328343f3e bug fix for atemschutz 2026-03-01 17:59:40 +01:00
Matthias Hochmeister
e0b687988b bug fix for atemschutz 2026-03-01 17:27:39 +01:00
Matthias Hochmeister
9b1f290a87 bug fix for atemschutz 2026-03-01 17:19:33 +01:00
Matthias Hochmeister
faa18b5688 bug fix for atemschutz 2026-03-01 15:03:27 +01:00
Matthias Hochmeister
4f7823ab16 bug fix for atemschutz 2026-03-01 15:01:30 +01:00
Matthias Hochmeister
a36e236175 bug fix for atemschutz 2026-03-01 14:53:46 +01:00
Matthias Hochmeister
064972e88a bug fix for atemschutz 2026-03-01 14:48:25 +01:00
Matthias Hochmeister
5b8f40ab9a add now features 2026-03-01 14:41:45 +01:00
Matthias Hochmeister
e76946ed8a add now features 2026-03-01 14:12:28 +01:00
Matthias Hochmeister
b7adf238ed chore: temporarily remove fdisk-sync from docker-compose and Makefile
Build is blocked by Apple npm proxy stripping devDependencies inside
Docker. Removing the service keeps the rest of the stack functional.
The sync/ directory and migration remain in place for when the build
issue is resolved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:07:28 +01:00
Matthias Hochmeister
b54e400c48 fix: add .npmrc to sync service to use public npm registry
The server has an Apple npm proxy that silently drops devDependencies.
Copying the same .npmrc fix used by the frontend (registry=registry.npmjs.org)
resolves the issue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 14:04:02 +01:00
Matthias Hochmeister
67ea0ba1f6 fix: move ENV PATH before npm ci to bust stale Docker cache layer
The cached npm ci layer predates the ENV PATH instruction, so tsc was
never on PATH when the build ran. Moving ENV PATH earlier changes the
cache key and forces a fresh install.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:59:19 +01:00
Matthias Hochmeister
a880e56bb1 fix: use node:20-alpine in sync builder, matching backend Dockerfile
node:20-slim picks up the Apple npm proxy which blocks installs.
node:20-alpine does not. Also add PATH for node_modules/.bin so tsc
is found, and remove committed dist/ since the build now works properly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:56:37 +01:00
Matthias Hochmeister
02797554aa fix: commit pre-compiled dist and simplify sync Dockerfile
Server npm proxy silently drops devDependencies, making TypeScript
unavailable in Docker. Solution: compile locally and commit dist/.
Dockerfile now only needs prod deps + Playwright, both of which
install cleanly via the public registry.

Also fix TS2688/TS2304 errors: add DOM to tsconfig lib and cast
querySelectorAll results to Element inside $$eval callbacks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:51:48 +01:00
Matthias Hochmeister
acd1506df8 fix: remove --ignore-scripts from npm install in sync builder
--ignore-scripts prevented @types/* packages from being installed,
causing tsc to fail on missing type definitions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:48:32 +01:00
Matthias Hochmeister
fad71d32fe fix: install typescript globally and use --ignore-scripts to avoid proxy failures
Install tsc globally so it's available on PATH regardless of
devDependency resolution issues. Use --ignore-scripts and explicit
registry to work around Apple npm proxy interference.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:46:14 +01:00
Matthias Hochmeister
2eeb206663 fix: copy node_modules from build context instead of running npm install
The server's npm proxy intercepts and silently fails devDependency
installs inside Docker. Bundle node_modules directly from the local
checkout where they are known-good.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:45:02 +01:00
Matthias Hochmeister
f559313eae fix: use public npm registry in sync builder to avoid Apple registry failures
npm.apple.com causes silent install failures for devDependencies inside
the Docker build context. Explicitly use registry.npmjs.org for the
builder stage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:42:37 +01:00
Matthias Hochmeister
c4d9be9027 debug: verify tsc presence after npm install in sync builder
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:39:34 +01:00
Matthias Hochmeister
9f5ef15590 fix: set NODE_ENV=development in sync builder to install devDependencies
node:20-slim defaults NODE_ENV=production which causes npm to skip dev
deps (typescript, ts-node), preventing tsc from being found.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:36:32 +01:00
Matthias Hochmeister
dee27200ce fix: use npm install and explicit package-lock.json COPY in sync Dockerfile
Avoids stale Docker layer cache issue where tsc was not found because
an old npm ci layer (without dev deps) was cached on the build host.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 13:33:42 +01:00
Matthias Hochmeister
e6c2a01b8a add now features 2026-03-01 12:36:53 +01:00
Matthias Hochmeister
3b7e1d0ed9 add now features 2026-03-01 12:14:38 +01:00
Matthias Hochmeister
2e08eef04e add now features 2026-03-01 12:11:39 +01:00
Matthias Hochmeister
e5986b5a8b add now features 2026-03-01 12:08:41 +01:00
Matthias Hochmeister
c5da8b07ae add now features 2026-03-01 12:03:11 +01:00
Matthias Hochmeister
681acd8203 add now features 2026-03-01 11:50:27 +01:00
Matthias Hochmeister
73ab6cea07 fix make 2026-02-28 17:38:49 +01:00
Matthias Hochmeister
4476ca82de fix login error 2026-02-28 17:35:57 +01:00
Matthias Hochmeister
e2be29c712 refine vehicle freatures 2026-02-28 17:19:18 +01:00
Matthias Hochmeister
0e81eabda6 fix: add React Router v7 future flags and defensive null guard for wartungslog 2026-02-28 16:10:26 +01:00
Matthias Hochmeister
f7b5261ad9 rework vehicle handling 2026-02-28 14:43:46 +01:00
Matthias Hochmeister
bb54af4630 rework vehicle handling 2026-02-28 14:30:20 +01:00
Matthias Hochmeister
b7b883649c rework vehicle handling 2026-02-28 14:13:56 +01:00
Matthias Hochmeister
06f94a6a48 rework vehicle handling 2026-02-28 14:02:31 +01:00
Matthias Hochmeister
1e478479be rework vehicle handling 2026-02-28 13:57:41 +01:00
Matthias Hochmeister
41fc41bee4 rework vehicle handling 2026-02-28 13:34:16 +01:00
Matthias Hochmeister
84cf505511 featur add fahrmeister 2026-02-27 21:55:13 +01:00
Matthias Hochmeister
dbe4f52871 featur add fahrmeister 2026-02-27 21:46:50 +01:00
Matthias Hochmeister
da4a56ba6b fix backend 2026-02-27 21:08:52 +01:00
Matthias Hochmeister
35d3fa0f16 fix backend 2026-02-27 20:49:35 +01:00
Matthias Hochmeister
d7a0d18899 fix backend 2026-02-27 20:46:21 +01:00
Matthias Hochmeister
8b3842a9fc fix backend 2026-02-27 20:39:11 +01:00
Matthias Hochmeister
46d3f5b351 add features 2026-02-27 20:33:43 +01:00
Matthias Hochmeister
e2713e25ba add features 2026-02-27 19:57:13 +01:00
Matthias Hochmeister
1c93399841 add features 2026-02-27 19:55:31 +01:00
Matthias Hochmeister
58fa420fea add features 2026-02-27 19:53:58 +01:00
Matthias Hochmeister
620bacc6b5 add features 2026-02-27 19:50:14 +01:00
Matthias Hochmeister
c5e8337a69 add features 2026-02-27 19:47:20 +01:00
Matthias Hochmeister
44e22a9fc6 fix authentication 2026-02-27 19:05:18 +01:00
Matthias Hochmeister
36ffe7e88e fix login 2026-02-27 14:42:51 +01:00
Matthias Hochmeister
2a70c274fb update env setup 2026-02-27 14:13:50 +01:00
Matthias Hochmeister
44c7958980 update env setup 2026-02-27 14:10:27 +01:00
Matthias Hochmeister
5e20cb9537 Merge branch 'main' of https://git.feuerwehr-rems.at/matthias/dashboard 2026-02-27 14:02:19 +01:00
Matthias Hochmeister
1c6c59c199 fix URLs 2026-02-27 14:02:03 +01:00
root
b86f3022f7 Merge branch 'main' of https://git.feuerwehr-rems.at/matthias/dashboard 2026-02-27 13:47:35 +01:00
root
e945cefbd3 fix frontend npm 2026-02-26 16:26:12 +01:00
503 changed files with 95130 additions and 1342 deletions

View File

@@ -0,0 +1,376 @@
# Buchhaltung System — Design
**Date:** 2026-03-28
**Status:** Approved
---
## Overview
A budget and transaction tracking system for the Feuerwehr. Tracks money flows against budget pots and real bank accounts, scoped by fiscal year. Integrates with the existing Bestellungen system and permission matrix.
---
## Full Cycle
```
Budget Plan → Fiscal Year → Freigaben → Bestellungen → Pending Transactions → Booked Transactions
```
---
## 1. Data Model
### `buchhaltung_konto_typen`
Dynamic list of bank account types (Girokonto, Tagesgeldkonto, Handkasse, PayPal, …). Managed in settings by users with `manage_settings`.
| Column | Type | Notes |
|--------|------|-------|
| id | serial PK | |
| name | varchar | |
| beschreibung | text | nullable |
| created_at | timestamptz | |
---
### `buchhaltung_bankkonten`
Real bank accounts (where money physically is).
| Column | Type | Notes |
|--------|------|-------|
| id | serial PK | |
| name | varchar | |
| typ_id | FK konto_typen | |
| iban | varchar | nullable |
| opening_balance | decimal(12,2) | starting balance when first added |
| aktiv | bool | default true |
| created_at | timestamptz | |
Balance = `opening_balance + SUM(booked transactions)`.
---
### `buchhaltung_haushaltsjahre`
Fiscal years. Bank accounts are not year-scoped; budget accounts are.
| Column | Type | Notes |
|--------|------|-------|
| id | serial PK | |
| jahr | int | unique (e.g. 2026) |
| start_datum | date | |
| end_datum | date | |
| abgeschlossen | bool | default false; manually closed by manage_settings user |
Closing a year locks all its transactions (no edits, no new bookings).
---
### `buchhaltung_konten`
Budget accounts / pots. Scoped per fiscal year.
| Column | Type | Notes |
|--------|------|-------|
| id | serial PK | |
| haushaltsjahr_id | FK haushaltsjahre | |
| name | varchar | |
| beschreibung | text | nullable |
| budget | decimal(12,2) | |
| alert_threshold | int | % (0-100); falls back to global setting if null |
| aktiv | bool | default true |
| created_at | timestamptz | |
---
### `buchhaltung_transaktionen`
Core transaction table.
| Column | Type | Notes |
|--------|------|-------|
| id | serial PK | internal DB key |
| haushaltsjahr_id | FK haushaltsjahre | |
| laufende_nummer | int | sequential per fiscal year; display as `2026/0001` |
| datum | date | |
| beschreibung | text | |
| betrag | decimal(12,2) | nullable for variable recurring transactions |
| typ | enum | `einnahme`, `ausgabe`, `transfer` |
| konto_id | FK konten | nullable (null for transfers) |
| bankkonto_id | FK bankkonten | source bank account |
| transfer_ziel_bankkonto_id | FK bankkonten | nullable; only for typ=transfer |
| bestellung_id | FK bestellungen | nullable; set when auto-created from order |
| split_gruppe_id | uuid | nullable; groups split transactions from same Bestellung |
| gebucht | bool | false = pending, true = booked |
| erstellt_von | FK users | |
| gebucht_von | FK users | nullable |
| gebucht_am | timestamptz | nullable |
| created_at | timestamptz | |
Display ID: `${jahr}/${laufende_nummer.toString().padStart(4, '0')}` e.g. `2026/0001`.
`laufende_nummer` is unique per `haushaltsjahr_id`, incremented per new transaction.
---
### `buchhaltung_belege`
Receipt/invoice attachments per transaction.
| Column | Type | Notes |
|--------|------|-------|
| id | serial PK | |
| transaktion_id | FK transaktionen | |
| dateiname | varchar | |
| pfad | varchar | disk path |
| mime_type | varchar | |
| erstellt_von | FK users | |
| created_at | timestamptz | |
Uses same multer disk storage pattern as Bestellungen (`uploads/buchhaltung/`).
---
### `buchhaltung_wiederkehrend`
Recurring transaction templates.
| Column | Type | Notes |
|--------|------|-------|
| id | serial PK | |
| beschreibung | varchar | |
| betrag | decimal(12,2) | nullable (e.g. variable energy bills) |
| typ | enum | `einnahme`, `ausgabe` |
| konto_id | FK konten | nullable |
| bankkonto_id | FK bankkonten | nullable |
| frequenz | enum | `monatlich`, `vierteljaehrlich`, `jaehrlich` |
| naechste_faelligkeit | date | advanced by job after each creation |
| aktiv | bool | |
| erstellt_von | FK users | |
Job runs daily → creates pending transaction → advances `naechste_faelligkeit`.
Betrag null → pending transaction has null betrag → Kassenwart fills it in before booking.
---
### `buchhaltung_freigaben`
Budget delegation — authorizes a user to spend against a budget account.
| Column | Type | Notes |
|--------|------|-------|
| id | serial PK | |
| konto_id | FK konten | budget account |
| user_id | FK users | authorized user |
| beschreibung | text | what this covers |
| betrag_limit | decimal(12,2) | nullable; max spending allowed |
| planposition_id | FK planpositionen | nullable; source plan item |
| aktiv | bool | |
| erstellt_von | FK users | |
| created_at | timestamptz | |
Amount is editable after creation (plan may change mid-year).
Users with a Freigabe can create Bestellungen against that account even without `buchhaltung:create`.
---
### `buchhaltung_audit`
Append-only audit log.
| Column | Type | Notes |
|--------|------|-------|
| id | serial PK | |
| transaktion_id | FK transaktionen | |
| aktion | enum | `erstellt`, `gebucht`, `bearbeitet`, `geloescht` |
| benutzer_id | FK users | |
| details | jsonb | before/after snapshot |
| created_at | timestamptz | |
---
### `buchhaltung_einstellungen`
Key/value store for global settings.
| Key | Default | Notes |
|-----|---------|-------|
| `default_alert_threshold` | `80` | % budget used before notification |
| `pdf_footer` | `''` | reuses PDF settings pattern |
---
### Budget Planning Tables
#### `buchhaltung_planung`
A plan for a future fiscal year.
| Column | Type | Notes |
|--------|------|-------|
| id | serial PK | |
| ziel_jahr | int | the year being planned |
| name | varchar | e.g. "Haushaltsplan 2027" |
| status | enum | `entwurf`, `genehmigt` |
| erstellt_von | FK users | |
| created_at | timestamptz | |
Starts pre-populated from current open budget accounts. New accounts can be added.
Approving locks the plan. Only approved plans can be used for fiscal year rollover.
#### `buchhaltung_plankonten`
Planned budget pots within a plan.
| Column | Type | Notes |
|--------|------|-------|
| id | serial PK | |
| planung_id | FK planung | |
| konto_referenz_id | FK konten | nullable; links to existing account if carried over |
| name | varchar | |
| beschreibung | text | nullable |
| sort_order | int | |
#### `buchhaltung_planpositionen`
Line items within a planned pot. Sum = pot total.
| Column | Type | Notes |
|--------|------|-------|
| id | serial PK | |
| plankonto_id | FK plankonten | |
| beschreibung | text | what it's for |
| betrag | decimal(12,2) | |
| sort_order | int | |
From approved plan items → manual Freigaben creation flow (UI, not automatic). Amount editable on Freigabe creation and after.
---
## 2. Permissions
New `buchhaltung` feature group in the existing permission matrix:
| Permission | Description |
|------------|-------------|
| `buchhaltung:view` | See transactions, accounts, bank accounts |
| `buchhaltung:create` | Add transactions manually |
| `buchhaltung:edit` | Edit unbooked transactions |
| `buchhaltung:delete` | Delete unbooked transactions |
| `buchhaltung:manage_accounts` | Create/edit/archive budget accounts, bank accounts, Freigaben |
| `buchhaltung:manage_settings` | Global settings, account types, fiscal year management, recurring templates |
| `buchhaltung:export` | PDF and CSV export |
| `buchhaltung:widget` | See the dashboard widget |
`manage_settings``dashboard_admin` — a dedicated Kassenwart can have this without being a global admin.
---
## 3. Backend Architecture
Follows the existing project pattern (service → controller → routes → app.ts).
**Routes** mounted at `/api/buchhaltung/`:
| Route | Description |
|-------|-------------|
| `bankkonten` | CRUD bank accounts |
| `konten` | CRUD budget accounts (scoped by fiscal year) |
| `transaktionen` | list, create, book, edit, delete |
| `transfers` | create bank-to-bank transfers |
| `freigaben` | CRUD budget delegations |
| `wiederkehrend` | CRUD recurring templates |
| `haushaltsjahre` | create new year, close year, list |
| `planung` | CRUD budget plans and plan items |
| `einstellungen` | key/value settings |
| `export/pdf` | annual report PDF |
| `export/csv` | transaction CSV export |
| `audit/:transaktionId` | audit trail for one transaction |
**Jobs:**
- `buchhaltung-recurring.job.ts` — runs daily; creates pending transactions from due recurring templates; advances `naechste_faelligkeit`
- Budget alert fires **inline** after each booking: recalculates account total → if crosses threshold → `notificationService.createNotification()` for `manage_accounts` users (deduped by DB constraint)
---
## 4. Bestellungen Integration
### Costs on positions
- `bestellpositionen` gets an `einzelpreis` / `gesamtpreis` field (if not already present)
- Status change to `vollstaendig`/`abgeschlossen` is **blocked** until all positions have costs entered
- Attempting the status change without costs → `showWarning` snackbar: *"Bestellung kann erst abgeschlossen werden, wenn Kosten für alle Positionen eingetragen wurden."*
- Backend enforces the same rule (400 response)
- The **order creator/manager** enters costs — not the Kassenwart
### Pending transaction creation
When a Bestellung reaches `abgeschlossen`:
1. `buchhaltungService.createPendingFromBestellung(bestellung)` is called
2. Creates a pending `ausgabe` transaction: betrag = sum of position costs, beschreibung = order title, bestellung_id = FK
3. Notifies users with `buchhaltung:create` or `buchhaltung:manage_accounts`
### Split booking
When booking a pending transaction from a Bestellung, the Kassenwart can split it into N parts:
- Each part: betrag, budget konto, bankkonto, Beleg (receipt)
- Parts sum to ≤ total (partial booking allowed; remainder stays pending)
- Each part becomes its own booked transaction with its own `<year>/<id>`
- All parts share the same `bestellung_id` and `split_gruppe_id` (visually grouped in audit trail)
---
## 5. Frontend Pages
### Navigation
New "Buchhaltung" sidebar group:
- Übersicht
- Transaktionen
- Bankkonten
- Konten & Haushaltsjahre
- Freigaben *(only with `manage_accounts`)*
- Haushaltsplan *(only with `manage_settings`)*
### Pages
**`Buchhaltung.tsx`** — overview. Year selector at top. Budget account cards: name, budget, spent, remaining, % bar, alert indicator. Pending transactions count with link.
**`Transaktionen.tsx`** — full list. Filter by year / account / bank account / typ. Columns: ID (`2026/0001`), Datum, Beschreibung, Konto, Bankkonto, Betrag, Status. Click → booking dialog.
**`BankkontoDetail.tsx`** — statement view. Running balance column per row. Date range filter. CSV/PDF export.
**`BuchhaltungKonten.tsx`** — tabs: Konten (current year) | Bankkonten | Haushaltsjahre. Year-end actions: "Jahr abschließen" (manual, locks year) + "Neues Haushaltsjahr" (from approved plan or copy previous).
**`BuchhaltungEinstellungen.tsx`** (or AdminDashboard tab) — Kontentypen list, default alert threshold, recurring templates.
**`Haushaltsplan.tsx`** — plan management. Pre-populated from current accounts. Add/edit/remove plankonten and planpositionen. Approve plan. From approved items → create Freigaben (manual flow, amounts editable).
### Booking Dialog
Used from pending transaction list:
- Pre-filled from Bestellung (amount, description, reference)
- "Split" button adds a row
- Each row: betrag, budget konto, bankkonto, Beleg upload
- Sum validation
- Confirm → books all rows at once
### Widget
`BuchhaltungWidget.tsx` — compact card in Status group. Shows pending transaction count. Tap → filtered pending list. Only shown with `buchhaltung:widget`.
---
## 6. PDF Export
Uses existing `addPdfHeader` / `addPdfFooter` from `frontend/src/utils/pdfExport` (same red header, logo, org name as Bestellungen and Kalender).
Content of annual report TBD (separate discussion).
---
## 7. Fiscal Year Rollover
1. **Close current year** — manual action by `manage_settings` user → sets `abgeschlossen = true` → all transactions locked
2. **Create new year** — choose source:
- **From approved plan** — plankonten become new konten, plankonto totals (sum of planpositionen) become budgets
- **Copy previous year** — all active konten copied with same budget amounts (editable before confirming)
3. Bank accounts carry over automatically (not year-scoped)
4. Old year remains readable/exportable forever
---
## 8. Future: Sub-Budget Flow from Plan Items
*(Planned feature — not in initial implementation)*
From an approved plan's items, a manual UI flow to create Freigaben:
- User selects plan items
- Sets/adjusts amount per Freigabe (editable at creation and later — plan may change mid-year)
- Assigns a user
- Creates the Freigabe linked to `planposition_id`
This closes the full loop: Plan item → Freigabe → Bestellung → Pending transaction → Booked transaction.

View File

@@ -0,0 +1,101 @@
# Buchhaltung Implementation Plan — 2026-03-30
## Priority Order
1. P1: Transfers (bank → bank) — needs migration first
2. P2: BankkontoDetail page — depends on P1 migration for transfer direction
3. P3: Konto-Typen + Einstellungen UI — pure frontend, self-contained
4. P4: Bestellungen integration — einzelpreis validation + auto-pending
5. P5: Haushaltsplan pages — large feature
6. P6: PDF Export — last, all data endpoints stable
## Key Findings
- `typ='transfer'` is NOT in the current DB CHECK constraint — migration required
- `einzelpreis` on `bestellpositionen` already exists (migration 038) — no schema change for P4
- `buchhaltung_planung` and `buchhaltung_planpositionen` tables exist (migrations 075/077), 2-level hierarchy (not 3-level as in original design sketch)
- All konto-typen and einstellungen backend routes exist — P3 is frontend-only
- Split-booking dialog and Freigaben-from-plan flow (design sections 4/8) are deferred
---
## P1: Transfers [x]
### Migration `082_buchhaltung_transfer.sql`
- Extend CHECK: add `'transfer'` to `typ` enum
- Add column `transfer_ziel_bankkonto_id INT REFERENCES buchhaltung_bankkonten(id) ON DELETE SET NULL`
### Backend
- `buchhaltung.service.ts` — add `createTransfer(data, userId)`: DB transaction creating two rows (debit on source, credit on target)
- `buchhaltung.controller.ts` — add `createTransfer` handler
- `buchhaltung.routes.ts``POST /transfers`
- Update `listTransaktionen` JOIN to include `transfer_ziel_bezeichnung`
### Frontend
- `buchhaltung.types.ts` — add `'transfer'` to `TransaktionTyp`; add `transfer_ziel_bankkonto_id?`, `transfer_ziel_bezeichnung?` to `Transaktion`; add `TransferFormData`
- `buchhaltung.ts` (service) — add `createTransfer` API method
- `Buchhaltung.tsx` — add `TransferDialog` component + "Transfer" button in TransaktionenTab toolbar; show Transfer chip in typ column
---
## P2: BankkontoDetail Page [x]
### Backend
- `buchhaltung.service.ts` — add `getBankkontoStatement(id, filters)`: queries transactions for one bank account, computes running balance per row (einnahme=+, ausgabe=, transfer direction from `transfer_ziel_bankkonto_id`)
- `buchhaltung.controller.ts` — add `getBankkontoStatement` handler
- `buchhaltung.routes.ts``GET /bankkonten/:id/transaktionen`
### Frontend
- `buchhaltung.types.ts` — add `BankkontoStatement` interface
- `buchhaltung.ts` — add `getBankkontoStatement(id, filters?)` API method
- New file `BuchhaltungBankkontoDetail.tsx` — date range filter, summary cards, table with running balance column
- `App.tsx` — add route `/buchhaltung/bankkonto/:id`
- `Buchhaltung.tsx` (KontenTab, Bankkonten sub-tab) — make table rows clickable → navigate to detail
---
## P3: Einstellungen UI [x]
### Frontend only
- `buchhaltung.ts` — add `getEinstellungen` + `setEinstellungen` API methods
- `Buchhaltung.tsx` — add 4th sub-tab "Einstellungen" in KontenTab:
- KontoTypen CRUD table + add/edit dialog
- Default alert threshold TextField + save button
- Gate with `hasPermission('buchhaltung:manage_settings')`
---
## P4: Bestellungen Integration [x]
### Backend
- `bestellung.service.ts` — block `vollstaendig`/`abgeschlossen` transition if any position has null/zero `einzelpreis`
- `buchhaltung.service.ts` — add `createPendingFromBestellung(bestellungId, userId)`: non-fatal, finds open Haushaltsjahr, inserts pending ausgabe transaction
- `bestellung.service.ts` — call `createPendingFromBestellung` after status → `abgeschlossen`
### Frontend
- `BestellungDetail.tsx` — show warning Alert when advancing to `abgeschlossen` but prices missing
---
## P5: Haushaltsplan [x]
### Backend
- `buchhaltung.service.ts` — Planung CRUD + Planposition CRUD + `createHaushaltsjahrFromPlan`
- `buchhaltung.controller.ts` — handlers for all
- `buchhaltung.routes.ts` — full set of Planung + Planposition routes + `POST /planung/:id/create-haushaltsjahr`
### Frontend
- `buchhaltung.types.ts` — add `Planung`, `Planposition`, `PlanungDetail`, form data interfaces
- `buchhaltung.ts` — add all Planung API methods
- New file `Haushaltsplan.tsx` — list view + detail view, position inline editing, "Haushaltsjahr erstellen" action
- `App.tsx` — routes `/haushaltsplan` and `/haushaltsplan/:id`
- `Sidebar.tsx` — add Haushaltsplan sub-item under Buchhaltung group
---
## P6: PDF Export (client-side jsPDF) [x]
### Frontend
- `Buchhaltung.tsx` (UebersichtTab) — add `generatePdf()` using jsPDF + `fetchPdfSettings()` pattern
- Cover: year name + totals summary
- Page 2+: konten tree table with budget/spent/utilization
- Page 3+: full transaction list sorted by date
- "PDF exportieren" button gated by `buchhaltung:export`

View File

@@ -0,0 +1,170 @@
# Feature Extensions Plan — Feuerwehr Dashboard
> Generated 2026-03-16. Features that extend existing functionality.
## Selected for Implementation (current sprint)
- #3 Inspection Schedule Export (PDF/CSV)
- #4 Vehicle Availability Calendar
- #8 Atemschutz Recertification Alert
- #19 Events Conflict Detection
- #21 Bulk Member Communication
- #22 Audit Log Alerts
---
## Backlog — Vehicles & Equipment
### Maintenance Cost Trend Reports [Medium]
- Aggregate costs from `wartungslog` by vehicle/type, with YoY trends
- Add new tab or section to `FahrzeugDetail.tsx` maintenance tab
- Backend: new `GET /api/vehicles/:id/wartung/stats` endpoint
- Frontend: bar/line chart using existing chart pattern from `IncidentStatsChart`
- Data available: `wartungslog.kosten`, `wartungslog.datum`, `wartungslog.art`
### Equipment Move History [Quick Win]
- Track when equipment moves between vehicles/storage via audit_log entries
- Show timeline in `AusruestungDetail.tsx` with location change history
- Backend: query audit_log for equipment UPDATE actions where fahrzeug_id changed
- Frontend: simple timeline list component
### Equipment Inventory by Category Report [Quick Win]
- Dashboard card showing equipment count by category/status with drill-down
- Use existing `equipmentService.getEquipmentStats()` data
- Frontend: new `EquipmentCategoryCard` in dashboard Status group
- Chip counts per category, color-coded by status
---
## Backlog — Personnel & Atemschutz
### Member Qualification Matrix Export [Medium]
- CSV/PDF showing all members × qualifications (licenses, certifications, training status)
- Backend: new `GET /api/members/qualifications/export` joining ausbildung, atemschutz_traeger, fahrgenehmigungen, untersuchungen
- Frontend: export button on Mitglieder page
- PDF template: table with member rows × qualification columns
### Rank Promotion Timeline [Quick Win]
- Visual timeline of rank changes per member
- Data: `dienstgrad_verlauf` table already has full history
- Frontend: timeline component in MitgliedDetail "Beförderungen" tab
- Use MUI Timeline component or simple vertical list
### Qualifications Expiring Soon Widget [Medium]
- Dashboard widget showing members with soon-to-expire qualifications
- Query: JOIN ausbildung.ablaufdatum, atemschutz.gueltig_bis, untersuchungen.gueltig_bis
- WHERE expiry date BETWEEN now AND now + 90 days
- Backend: new `GET /api/members/expiring-qualifications?days=90`
- Frontend: new dashboard card in Status group
### Member On-Duty Roster Widget [Quick Win]
- Quick view showing who is on active duty (status=aktiv + specific function)
- Backend: filter members by status and funktion
- Frontend: simple card widget showing avatar + name list
---
## Backlog — Incidents & Operations
### Response Time Analytics [Medium]
- Dashboard showing hilfsfrist trends, median times by incident type
- Data: `einsatz_statistik` materialized view has `hilfsfrist_min`
- Backend: new `GET /api/incidents/response-times?groupBy=einsatz_art`
- Frontend: line chart or box plot in Einsaetze page analytics section
### Personnel Availability During Incident [Medium]
- Incident detail view showing who was on-duty vs. who responded
- Cross-reference einsatz_personal with member status at time of incident
- Backend: extend `getIncidentById` to include non-responding active members
- Frontend: new section in EinsatzDetail with available/responded columns
### Vehicle Usage by Incident Type [Quick Win]
- Report: which vehicles deployed per incident type
- Backend: aggregate einsatz_fahrzeuge grouped by einsatz_art
- Simple query on existing data, return as stats
- Frontend: table or chart in Einsaetze page
### Incident Debriefing Template [Medium]
- Structured post-incident notes (what went well, improvements, training needs)
- Extend `bericht_text` or add new JSON column `debrief` with structured fields
- Backend: migration for new column, extend PATCH endpoint
- Frontend: collapsible section in EinsatzDetail with structured fields
### Callout Distribution Report [Quick Win]
- Personnel workload analysis: how many callouts per member
- Backend: `SELECT user_id, COUNT(*) FROM einsatz_personal GROUP BY user_id`
- Frontend: table or chart in admin dashboard or Mitglieder page
---
## Backlog — Training & Events
### Training Attendance Statistics [Medium]
- Member participation trends (% events attended, absences, by type)
- Backend: aggregate uebung_teilnahmen by user_id with status counts
- Frontend: new admin tab or member profile tab with charts
- Could show per-member attendance rate badge
### Training Requirement Tracking [Significant]
- Mark Übungen as mandatory for certain roles/ranks
- Schema: new `training_requirements` table (role, training_type, frequency)
- Backend: CRUD for requirements, validation check for members
- Frontend: admin config page + dashboard alerts for non-compliant members
- Notifications for members missing required training
### QR Code Check-In [Medium]
- Mobile-friendly quick check-in for training events
- Generate QR code containing `POST /api/training/:id/attendance` URL
- Frontend: QR code display on UebungDetail page
- Scanner: simple camera component or link to URL
---
## Backlog — Notifications & Admin
### Customizable Notification Preferences [Medium]
- Per-user opt-in/out of notification types (training, incidents, roster)
- Extend user preferences schema with notification_preferences object
- Backend: check preferences before creating notifications
- Frontend: new section in Settings page with toggle switches per type
---
## Backlog — Reporting & Analytics
### Annual Department Report (PDF) [Significant]
- Compile incident stats, training participation, member roster, equipment inventory
- Backend: new `GET /api/reports/annual?year=2025` generating comprehensive PDF
- Use existing PDF generation pattern from Kalender
- Sections: executive summary, incident stats, training stats, personnel changes, equipment inventory
### Member Hours Tracking [Medium]
- Calculate firefighter hours from training events + incidents + on-duty shifts
- Backend: aggregate event duration (datum_von to datum_bis) + incident time (alarm to einrueck)
- Frontend: member profile analytics tab + dashboard card
### Equipment Utilization Report [Quick Win]
- Usage by category/vehicle/incident type
- Backend: join ausruestung with einsatz_fahrzeuge to correlate
- Frontend: table in admin dashboard or Ausruestung page
### Member Skills Matrix [Medium]
- Export showing member name × all certifications
- Backend: JOIN across ausbildung, atemschutz_traeger, fahrgenehmigungen
- Frontend: exportable table with color-coded validity indicators
- PDF export using existing PDF generation pattern
---
## Backlog — System
### Incident Photo/Document Gallery [Medium]
- Upload photos/PDFs to incidents
- Schema: new `einsatz_dokumente` table (einsatz_id, filename, mime_type, storage_path)
- Backend: multipart upload endpoint, file storage (local or Nextcloud)
- Frontend: gallery component in EinsatzDetail with drag-and-drop
### Incident Stats Year Selector [Quick Win]
- View historical stats for previous years
- Backend: already supports `year` query parameter
- Frontend: add year selector dropdown to IncidentStatsChart
- Simple UI change, no backend work needed

View File

@@ -0,0 +1,186 @@
# Buchhaltung — Remaining Features Implementation Plan
## Context
The Buchhaltung system has core CRUD, transfers, bank statements, Haushaltsplan, and PDF export implemented. Three features from the original design doc remain:
1. **Budget Delegation Freigaben** — authorizes a user to spend against a budget account (distinct from the existing transaction-approval freigaben)
2. **Split Booking Dialog** — book a pending transaction into N parts across different budget accounts
3. **Sub-Budget Flow** — create budget delegation Freigaben from approved plan positions
---
## Task Dependency Graph
```
T1: Migration 083 (budget_freigaben) T2: Migration 084 (split_gruppe_id)
| |
v v
T3: Backend budget freigaben CRUD T4: Backend split booking endpoint
| |
+------------+ v
| | T7: Frontend BookingDialog
v v
T5: Backend plan→ T6: Frontend budget
freigabe endpoint freigaben tab + sidebar
| |
+-----+------+
v
T8: Frontend plan position → freigabe action
```
---
## Tasks
### T1 — Migration 083: `buchhaltung_budget_freigaben` table
**Teammate**: database | **blockedBy**: none
Create `backend/src/database/migrations/083_buchhaltung_budget_freigaben.sql`:
```sql
CREATE TABLE IF NOT EXISTS buchhaltung_budget_freigaben (
id SERIAL PRIMARY KEY,
konto_id INT NOT NULL REFERENCES buchhaltung_konten(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
beschreibung TEXT,
betrag_limit NUMERIC(12,2),
planposition_id INT REFERENCES buchhaltung_planpositionen(id) ON DELETE SET NULL,
aktiv BOOLEAN NOT NULL DEFAULT TRUE,
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_budget_freigaben_konto ON buchhaltung_budget_freigaben(konto_id);
CREATE INDEX idx_budget_freigaben_user ON buchhaltung_budget_freigaben(user_id);
```
### T2 — Migration 084: Add `split_gruppe_id` to transaktionen
**Teammate**: database | **blockedBy**: none
Create `backend/src/database/migrations/084_buchhaltung_split_gruppe.sql`:
```sql
ALTER TABLE buchhaltung_transaktionen
ADD COLUMN IF NOT EXISTS split_gruppe_id UUID;
CREATE INDEX IF NOT EXISTS idx_buch_trans_split_gruppe
ON buchhaltung_transaktionen(split_gruppe_id) WHERE split_gruppe_id IS NOT NULL;
```
### T3 — Backend: Budget Freigaben CRUD
**Teammate**: backend | **blockedBy**: T1
**Files**: `buchhaltung.service.ts`, `buchhaltung.controller.ts`, `buchhaltung.routes.ts`
Service functions (add before export block ~line 1937):
- `listBudgetFreigaben(filters: { konto_id?, user_id? })` — JOIN users + konten for display names
- `getMyBudgetFreigaben(userId)` — active freigaben for current user
- `createBudgetFreigabe(data, erstelltVon)` — INSERT
- `updateBudgetFreigabe(id, data, userId)` — UPDATE beschreibung/betrag_limit
- `deactivateBudgetFreigabe(id, userId)` — SET aktiv=false
Routes (add BEFORE `/:id` catch-all routes):
```
GET /budget-freigaben manage_accounts listBudgetFreigaben
GET /budget-freigaben/my buchhaltung:view getMyBudgetFreigaben
POST /budget-freigaben manage_accounts createBudgetFreigabe
PATCH /budget-freigaben/:id manage_accounts updateBudgetFreigabe
DELETE /budget-freigaben/:id manage_accounts deactivateBudgetFreigabe
```
### T4 — Backend: Split Booking Endpoint
**Teammate**: backend | **blockedBy**: T2
**Files**: `buchhaltung.service.ts`, `buchhaltung.controller.ts`
Modify `bookTransaktion(id, userId, splits?)`:
- No splits → existing behavior (unchanged)
- With splits `Array<{ betrag, konto_id, bankkonto_id? }>`:
1. Validate sum equals original betrag
2. BEGIN transaction
3. Generate UUID `split_gruppe_id`
4. Mark original as `status='gebucht'`, set `split_gruppe_id`
5. INSERT N new booked transactions copying fields from original but with split-specific betrag/konto_id/bankkonto_id, same `split_gruppe_id`
6. Budget alert check per split konto
7. Audit: `logAudit(id, 'split_gebucht', { splits, split_gruppe_id }, userId)`
Controller: read optional `req.body.splits`, pass to service.
### T5 — Backend: Plan Position → Freigabe Endpoint
**Teammate**: backend | **blockedBy**: T3
**Files**: `buchhaltung.service.ts`, `buchhaltung.controller.ts`, `buchhaltung.routes.ts`
New function `createBudgetFreigabeFromPlanposition(posId, { user_id, betrag_limit?, beschreibung? }, erstelltVon)`:
- Fetch planposition, verify konto_id exists
- Default betrag_limit to sum of budget_gwg + budget_anlagen + budget_instandhaltung
- Call `createBudgetFreigabe` with `planposition_id` set
Route: `POST /planung/positionen/:posId/freigabe` — manage_accounts
### T6 — Frontend: Budget Freigaben Tab + Sidebar
**Teammate**: frontend | **blockedBy**: T3
**Files**: `buchhaltung.types.ts`, `buchhaltung.ts`, `Buchhaltung.tsx`, `Sidebar.tsx`
Types:
- `BudgetFreigabe` — id, konto_id, user_id, beschreibung, betrag_limit, planposition_id, aktiv, konto_bezeichnung, user_name
- `BudgetFreigabeFormData` — konto_id, user_id, beschreibung?, betrag_limit?
Service: `getBudgetFreigaben`, `getMyBudgetFreigaben`, `createBudgetFreigabe`, `updateBudgetFreigabe`, `deleteBudgetFreigabe`
Buchhaltung.tsx:
- Add tab 3 "Freigaben" (visible only with `manage_accounts`)
- Table: Konto, Benutzer, Beschreibung, Betrag-Limit, Status, Aktionen
- Create/Edit dialog with Konto select, User select, Betrag-Limit, Beschreibung
- Pattern: follow TransaktionenTab Table+Dialog structure
Sidebar.tsx (~line 155): add `{ text: 'Freigaben', path: '/buchhaltung?tab=3' }` to buchhaltung subItems
### T7 — Frontend: Split Booking Dialog
**Teammate**: frontend | **blockedBy**: T4
**Files**: `buchhaltung.types.ts`, `buchhaltung.ts`, `Buchhaltung.tsx`
Types: add `SplitRow { betrag: number; konto_id: number; bankkonto_id?: number }`
Service: update `buchenTransaktion(id, splits?)` to POST body with optional splits array
Buchhaltung.tsx:
- Add `BookingDialog` component (inline):
- Shows transaction details (betrag, beschreibung)
- "Einfach buchen" button for simple booking (no splits)
- "Aufteilen" toggle reveals split rows
- Each row: betrag input, konto select, bankkonto select, remove button
- "Zeile hinzufuegen" button
- Sum validation with remaining amount display
- "Buchen" button
- Replace buchen IconButton click: open BookingDialog instead of direct mutation
- Update `buchenMut` to accept `{ id, splits? }`
### T8 — Frontend: Plan Position → Freigabe Action
**Teammate**: frontend | **blockedBy**: T5, T6
**Files**: `buchhaltung.ts`, `HaushaltsplanDetail.tsx`
Service: add `createBudgetFreigabeFromPosition(posId, { user_id, betrag_limit?, beschreibung? })`
HaushaltsplanDetail.tsx:
- Add "Freigabe erstellen" IconButton per position row (only when canManage && pos.konto_id set)
- Dialog: User select (fetch members), Betrag-Limit (pre-filled from position total), Beschreibung (pre-filled from position name)
- Pattern: follow existing position edit dialog
---
## Teammate Assignments
| Teammate | Tasks | Mode |
|----------|-------|------|
| database | T1, T2 | bypassPermissions |
| backend | T3, T4, T5 | bypassPermissions |
| frontend | T6, T7, T8 | bypassPermissions |
## Verification
1. Migration: check tables exist via backend startup logs
2. Backend: test budget-freigaben CRUD via curl/frontend
3. Frontend: verify Freigaben tab renders, BookingDialog opens on buchen click
4. Split booking: create pending transaction, open dialog, add split rows, book
5. Plan→Freigabe: open HaushaltsplanDetail, click freigabe button on position with konto_id

View File

@@ -0,0 +1 @@
{"sessionId":"7c1ca5d5-0a55-4bf7-b433-7d5e62543b8c","pid":94069,"acquiredAt":1776064212433}

View File

@@ -0,0 +1,121 @@
# Orchestrate Feature Implementation
You are a senior full-stack engineer orchestrating a team of Claude Code agents to implement
changes across database, backend, and frontend layers.
The user's input (the text after `/orchestrate`) describes the tasks to implement.
If no tasks are provided, ask before proceeding.
---
## Constraints
### Docker
- NEVER execute Docker commands — Docker is NOT available on this host
- ONLY produce deployment artifacts (Dockerfile, docker-compose.yml, .env.example)
### Migrations
- ALL migrations must be idempotent (safe to run multiple times)
- Verify column and table names match existing schema before writing
- Never rename env vars used by external systems without explicit approval
- "Purge user data" = delete DATA only — never delete user accounts
### Scope
- Each teammate only touches files in their assigned layer
- Cross-layer changes require explicit permission or a SendMessage to the relevant teammate
- When debugging, only touch files related to the change you made
### Team Orchestration
- Use TeamCreate before spawning teammates
- Use TaskCreate for ALL work items with clear descriptions and blockedBy dependencies
- Spawn teammates with Agent tool using team_name and descriptive names
- Teammates pick up tasks autonomously — do NOT micromanage
- Use SendMessage for coordination — teammates do NOT share context windows
### Task Tracking
- Mark tasks in_progress BEFORE starting, completed when done
- After completing a task, check TaskList for next available work
### General
- NEVER guess — ask if requirements are ambiguous
- Use brainstorm-to-plan skill during planning for non-trivial tasks
- Use commit-formatter skill for the final commit message
---
## Workflow
Not every task needs all stages.
- **Simple tasks:** skip explore, go straight to plan
- **Complex tasks:** run all stages
**Always present a plan and get user approval via `EnterPlanMode` before writing any code — no exceptions, even for trivial changes.**
---
### Stage 1 — Explore *(skip for simple tasks)*
Launch an Explore agent (`subagent_type="Explore"`) with targeted search queries. It returns:
1. Relevant file paths + one-line role descriptions
2. Patterns and conventions to follow
3. Hard constraints (API contracts, shared types, config shapes)
4. Files that need to be created
---
### Stage 2 — Plan
Use `EnterPlanMode` to get user approval before any execution.
Output:
1. Task summary (35 bullets)
2. Task list with dependencies:
- Parallel tasks (no blockedBy)
- Sequential tasks (blockedBy earlier tasks)
- Each task description is **SELF-CONTAINED**: context, file paths, patterns to follow,
constraints, and acceptance criteria. Teammates have NO implicit context.
3. Teammate assignments — which teammate handles which layer (database / backend / frontend)
---
### Stage 3 — Execute
**Create team and tasks**
Use `TeamCreate`. Then `TaskCreate` for every task with `blockedBy` dependencies set.
Example dependency chain:
```
Task 1: migration (no blockers) → database teammate
Task 2: service (blockedBy: 1) → backend teammate
Task 3: routes (blockedBy: 2) → backend teammate
Task 4: frontend (blockedBy: 2) → frontend teammate
```
**Spawn teammates in parallel** using Agent tool with `team_name`.
Each teammate prompt MUST include:
- Role and layer scope (which files/directories they may touch — no others)
- Instruction to check TaskList and claim available unblocked tasks
- Key context from the explore stage (file paths, patterns, constraints)
- Acceptance criteria they must verify before marking a task complete
- Instruction to SendMessage to teammates when producing artifacts they depend on
(e.g. schema shape, API response format, new TypeScript types)
**Coordinate**
Monitor via idle notifications and messages. Resolve blockers, answer questions,
reassign tasks if needed. When all tasks are complete, shut down teammates via SendMessage.
---
### Stage 4 — Verify
After all teammates finish and before the final commit:
1. `cd frontend && npx tsc --noEmit` — fix any type errors
2. `cd backend && npx tsc --noEmit` — fix any type errors
3. Confirm all new migrations are idempotent and column names match the existing schema
4. Verify all new UI components handle loading / error / empty states
5. Check modals open and close correctly; forms reset after submit
6. Verify list operations (add / remove / reorder) persist correctly
7. Confirm API response shapes match frontend TypeScript types
**Finalize:** use commit-formatter skill for the commit message.

View File

@@ -87,9 +87,9 @@ JWT_SECRET=your_jwt_secret_here
# The frontend URL that is allowed to make requests to the backend # The frontend URL that is allowed to make requests to the backend
# IMPORTANT: Must match your frontend URL exactly! # IMPORTANT: Must match your frontend URL exactly!
# Development: http://localhost:5173 (Vite dev server) # Development: http://localhost:5173 (Vite dev server)
# Production: https://dashboard.yourdomain.com # Production: https://portal.feuerwehr-rems.at
# Multiple origins: Use comma-separated values (if supported by your setup) # Multiple origins: Use comma-separated values (if supported by your setup)
CORS_ORIGIN=http://localhost:80 CORS_ORIGIN=https://portal.feuerwehr-rems.at
# ============================================================================ # ============================================================================
# FRONTEND CONFIGURATION # FRONTEND CONFIGURATION
@@ -103,16 +103,16 @@ FRONTEND_PORT=80
# API URL for frontend # API URL for frontend
# The URL where the frontend will send API requests # The URL where the frontend will send API requests
# Development: http://localhost:3000 # Development: http://localhost:3000
# Production: https://api.yourdomain.com # Production: https://portal.feuerwehr-rems.at (proxied via nginx /api/)
# IMPORTANT: Must be accessible from the user's browser! # IMPORTANT: Must be accessible from the user's browser!
VITE_API_URL=http://localhost:3000 VITE_API_URL=https://portal.feuerwehr-rems.at
# Authentik URL for frontend # Authentik URL for frontend
# The base URL of your Authentik instance (without application path) # The base URL of your Authentik instance (without application path)
# Development: http://localhost:9000 # Development: http://localhost:9000
# Production: https://auth.yourdomain.com # Production: https://auth.firesuite.feuerwehr-rems.at
# IMPORTANT: Used for OAuth redirect URL construction # IMPORTANT: Used for OAuth redirect URL construction
VITE_AUTHENTIK_URL=https://auth.yourdomain.com AUTHENTIK_URL=https://auth.firesuite.feuerwehr-rems.at
# ============================================================================ # ============================================================================
# AUTHENTIK OAUTH CONFIGURATION # AUTHENTIK OAUTH CONFIGURATION
@@ -122,7 +122,7 @@ VITE_AUTHENTIK_URL=https://auth.yourdomain.com
# OAuth Client ID # OAuth Client ID
# From Authentik: Applications → Providers → Your Provider # From Authentik: Applications → Providers → Your Provider
# REQUIRED for authentication to work! # Used by both backend and frontend. REQUIRED for authentication to work!
AUTHENTIK_CLIENT_ID=your_client_id_here AUTHENTIK_CLIENT_ID=your_client_id_here
# OAuth Client Secret # OAuth Client Secret
@@ -133,23 +133,77 @@ AUTHENTIK_CLIENT_SECRET=your_client_secret_here
# OAuth Issuer URL # OAuth Issuer URL
# From Authentik: Applications → Providers → Your Provider → OpenID Configuration # From Authentik: Applications → Providers → Your Provider → OpenID Configuration
# Format: https://auth.yourdomain.com/application/o/your-app-slug/ # Format: https://auth.firesuite.feuerwehr-rems.at/application/o/your-app-slug/
# IMPORTANT: Must end with a trailing slash (/) # IMPORTANT: Must end with a trailing slash (/)
# Development: http://localhost:9000/application/o/feuerwehr-dashboard/ # Development: http://localhost:9000/application/o/feuerwehr-dashboard/
# Production: https://auth.yourdomain.com/application/o/feuerwehr-dashboard/ # Production: https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/
AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr-dashboard/ AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/
# OAuth Redirect URI # OAuth Redirect URI
# The URL where Authentik will redirect after successful authentication # The URL where Authentik will redirect after successful authentication
# Must match EXACTLY what you configured in Authentik # Must match EXACTLY what you configured in Authentik
# Development: http://localhost:5173/auth/callback # Development: http://localhost:5173/auth/callback
# Production: https://dashboard.yourdomain.com/auth/callback # Production: https://portal.feuerwehr-rems.at/auth/callback
AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback AUTHENTIK_REDIRECT_URI=https://portal.feuerwehr-rems.at/auth/callback
# OAuth Scopes (optional, has defaults) # OAuth Scopes (optional, has defaults)
# Default: openid profile email # Default: openid profile email
# AUTHENTIK_SCOPES=openid profile email # AUTHENTIK_SCOPES=openid profile email
# ============================================================================
# NEXTCLOUD CONFIGURATION
# ============================================================================
# Nextcloud base URL
# The URL of your Nextcloud instance
# Used by the backend for Nextcloud integration
NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at
# ============================================================================
# BOOKSTACK CONFIGURATION
# ============================================================================
# BookStack base URL
# The URL of your BookStack instance (without trailing slash)
BOOKSTACK_URL=https://docs.feuerwehr-rems.at
# BookStack API Token ID
# Create via BookStack user profile → API Tokens
BOOKSTACK_TOKEN_ID=your_bookstack_token_id
# BookStack API Token Secret
# Create via BookStack user profile → API Tokens
# WARNING: Keep this secret!
BOOKSTACK_TOKEN_SECRET=your_bookstack_token_secret
# ============================================================================
# VIKUNJA CONFIGURATION
# ============================================================================
# Vikunja base URL
# The URL of your Vikunja instance (without trailing slash)
VIKUNJA_URL=https://tasks.feuerwehr-rems.at
# Vikunja API Token
# Create via Vikunja user settings → API Tokens
# WARNING: Keep this secret!
VIKUNJA_API_TOKEN=your_vikunja_api_token
# ============================================================================
# FDISK SYNC CONFIGURATION
# ============================================================================
# FDISK login credentials
# Used by the fdisk-sync service to scrape member data from app.fdisk.at
# REQUIRED for the sync service to work
FDISK_USERNAME=your_fdisk_username
FDISK_PASSWORD=your_fdisk_password
# Internal URL of the fdisk-sync control server
# Used by the backend to proxy manual trigger and log requests
# In Docker Compose this is fixed — only change if you remap the port
FDISK_SYNC_URL=http://fdisk-sync:3001
# ============================================================================ # ============================================================================
# LOGGING CONFIGURATION (Optional) # LOGGING CONFIGURATION (Optional)
# ============================================================================ # ============================================================================
@@ -210,8 +264,10 @@ AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback
# VITE_API_URL=http://localhost:3000 # VITE_API_URL=http://localhost:3000
# AUTHENTIK_CLIENT_ID=dev_client_id # AUTHENTIK_CLIENT_ID=dev_client_id
# AUTHENTIK_CLIENT_SECRET=dev_client_secret # AUTHENTIK_CLIENT_SECRET=dev_client_secret
# AUTHENTIK_URL=http://localhost:9000
# AUTHENTIK_ISSUER=http://localhost:9000/application/o/feuerwehr-dashboard/ # AUTHENTIK_ISSUER=http://localhost:9000/application/o/feuerwehr-dashboard/
# AUTHENTIK_REDIRECT_URI=http://localhost:5173/auth/callback # AUTHENTIK_REDIRECT_URI=http://localhost:5173/auth/callback
# NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at
# LOG_LEVEL=debug # LOG_LEVEL=debug
# #
# ============================================================================ # ============================================================================
@@ -227,13 +283,15 @@ AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback
# BACKEND_PORT=3000 # BACKEND_PORT=3000
# NODE_ENV=production # NODE_ENV=production
# JWT_SECRET=<generated-with-openssl-rand-base64-32> # JWT_SECRET=<generated-with-openssl-rand-base64-32>
# CORS_ORIGIN=https://dashboard.yourdomain.com # CORS_ORIGIN=https://portal.feuerwehr-rems.at
# FRONTEND_PORT=80 # FRONTEND_PORT=80
# VITE_API_URL=https://api.yourdomain.com # VITE_API_URL=https://portal.feuerwehr-rems.at
# AUTHENTIK_CLIENT_ID=<from-authentik> # AUTHENTIK_CLIENT_ID=<from-authentik>
# AUTHENTIK_CLIENT_SECRET=<from-authentik> # AUTHENTIK_CLIENT_SECRET=<from-authentik>
# AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr-dashboard/ # AUTHENTIK_URL=https://auth.firesuite.feuerwehr-rems.at
# AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback # AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/
# AUTHENTIK_REDIRECT_URI=https://portal.feuerwehr-rems.at/auth/callback
# NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at
# LOG_LEVEL=info # LOG_LEVEL=info
# #
# ============================================================================ # ============================================================================

View File

@@ -23,7 +23,7 @@ http://localhost:3000
### Production ### Production
``` ```
https://api.yourdomain.com https://start.feuerwehr-rems.at
``` ```
## Authentication ## Authentication
@@ -155,7 +155,7 @@ Check if the API is running and healthy.
**Request**: **Request**:
```http ```http
GET /health HTTP/1.1 GET /health HTTP/1.1
Host: api.yourdomain.com Host: start.feuerwehr-rems.at
``` ```
**Response**: **Response**:
@@ -197,7 +197,7 @@ Handle OAuth callback and exchange authorization code for tokens.
**Request Example**: **Request Example**:
```http ```http
POST /api/auth/callback HTTP/1.1 POST /api/auth/callback HTTP/1.1
Host: api.yourdomain.com Host: start.feuerwehr-rems.at
Content-Type: application/json Content-Type: application/json
``` ```
@@ -295,7 +295,7 @@ Refresh an expired access token using a refresh token.
Host: start.feuerwehr-rems.at Host: start.feuerwehr-rems.at
Content-Type: application/json Content-Type: application/json
``` ```
**Success Response**: **Success Response**:
```http ```http
@@ -370,7 +370,7 @@ Authorization: Bearer <access-token>
**Success Response**: **Success Response**:
```http ```http
HTTP/1.1 200 OK HTTP/1.1 200 OK
Content-Type: application/json Content-Type: application/json
``` ```
@@ -407,7 +407,7 @@ Authorization: Bearer <access-token>
**Success Response**: **Success Response**:
```http ```http
HTTP/1.1 200 OK HTTP/1.1 200 OK
Content-Type: application/json Content-Type: application/json
``` ```
@@ -479,10 +479,10 @@ HTTP/1.1 404 Not Found
redirect_uri: 'https://start.feuerwehr-rems.at/auth/callback', redirect_uri: 'https://start.feuerwehr-rems.at/auth/callback',
response_type: 'code', response_type: 'code',
scope: 'openid profile email' scope: 'openid profile email'
}); });
window.location.href = `${authentikAuthUrl}?${params}`; window.location.href = `${authentikAuthUrl}?${params}`;
``` ```
#### Step 2: Authentik Redirects Back #### Step 2: Authentik Redirects Back
@@ -494,13 +494,13 @@ window.location.href = `${authentikAuthUrl}?${params}`;
#### Step 3: Exchange Code for Tokens #### Step 3: Exchange Code for Tokens
```bash ```bash
curl -X POST https://api.yourdomain.com/api/auth/callback \ curl -X POST https://start.feuerwehr-rems.at/api/auth/callback \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"code": "abc123def456" "code": "abc123def456"
}' }'
``` ```
Response: Response:
```json ```json
{ {
@@ -532,14 +532,14 @@ Response:
#### Step 5: Refresh Token When Expired #### Step 5: Refresh Token When Expired
```bash ```bash
curl -X POST https://start.feuerwehr-rems.at/api/auth/refresh \ curl -X POST https://start.feuerwehr-rems.at/api/auth/refresh \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}' }'
``` ```
### JavaScript/TypeScript Examples ### JavaScript/TypeScript Examples
#### Using Axios #### Using Axios
@@ -553,7 +553,7 @@ curl -X POST https://api.yourdomain.com/api/auth/refresh \
const api = axios.create({ const api = axios.create({
baseURL: API_URL, baseURL: API_URL,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
@@ -612,32 +612,32 @@ export const logout = async () => {
#### Login Callback #### Login Callback
```bash ```bash
curl -X POST https://start.feuerwehr-rems.at/api/auth/callback \ curl -X POST https://start.feuerwehr-rems.at/api/auth/callback \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"code":"your_auth_code"}' -d '{"code":"your_auth_code"}'
``` ```
#### Get Current User #### Get Current User
```bash ```bash
curl -X GET https://start.feuerwehr-rems.at/api/user/me \ curl -X GET https://start.feuerwehr-rems.at/api/user/me \
-H "Authorization: Bearer your_access_token" -H "Authorization: Bearer your_access_token"
``` ```
#### Refresh Token #### Refresh Token
```bash ```bash
curl -X POST https://api.yourdomain.com/api/auth/refresh \ curl -X POST https://start.feuerwehr-rems.at/api/auth/refresh \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"refreshToken":"your_refresh_token"}' -d '{"refreshToken":"your_refresh_token"}'
``` ```
#### Logout #### Logout
```bash ```bash
curl -X POST https://start.feuerwehr-rems.at/api/auth/logout \ curl -X POST https://start.feuerwehr-rems.at/api/auth/logout \
-H "Authorization: Bearer your_access_token" -H "Authorization: Bearer your_access_token"
``` ```
## Security Considerations ## Security Considerations
### HTTPS Required in Production ### HTTPS Required in Production
Always use HTTPS for API requests in production to protect tokens and sensitive data. Always use HTTPS for API requests in production to protect tokens and sensitive data.
@@ -660,7 +660,7 @@ The API is configured to only accept requests from allowed origins:
``` ```
Ensure `CORS_ORIGIN` environment variable matches your frontend URL exactly. Ensure `CORS_ORIGIN` environment variable matches your frontend URL exactly.
### Rate Limiting ### Rate Limiting
Respect rate limits to avoid being temporarily blocked. Implement exponential backoff for failed requests. Respect rate limits to avoid being temporarily blocked. Implement exponential backoff for failed requests.

View File

@@ -21,8 +21,8 @@ Before you begin, you need:
- An Authentik instance (self-hosted or cloud) - An Authentik instance (self-hosted or cloud)
- Admin access to Authentik - Admin access to Authentik
- Your Feuerwehr Dashboard URL (e.g., `https://dashboard.yourdomain.com`) - Your Feuerwehr Dashboard URL (e.g., `https://start.feuerwehr-rems.at`)
- Your backend API URL (e.g., `https://api.yourdomain.com`) - Your backend API URL (e.g., `https://start.feuerwehr-rems.at`)
## Authentik Installation ## Authentik Installation
@@ -146,7 +146,7 @@ Protocol Settings:
``` ```
http://localhost:5173/auth/callback http://localhost:5173/auth/callback
http://localhost/auth/callback http://localhost/auth/callback
https://dashboard.yourdomain.com/auth/callback https://start.feuerwehr-rems.at/auth/callback
``` ```
Add one URI per line. Include all environments (development, staging, production). Add one URI per line. Include all environments (development, staging, production).
@@ -173,7 +173,7 @@ Configure the application:
Name: Feuerwehr Dashboard Name: Feuerwehr Dashboard
Slug: feuerwehr-dashboard Slug: feuerwehr-dashboard
Provider: Feuerwehr Dashboard Provider (select from dropdown) Provider: Feuerwehr Dashboard Provider (select from dropdown)
Launch URL: https://dashboard.yourdomain.com Launch URL: https://start.feuerwehr-rems.at
``` ```
**UI Settings** (optional): **UI Settings** (optional):
@@ -256,10 +256,10 @@ This is the Vite dev server URL.
### Production Environment ### Production Environment
``` ```
https://dashboard.yourdomain.com/auth/callback https://start.feuerwehr-rems.at/auth/callback
``` ```
Replace `yourdomain.com` with your actual domain. Replace `feuerwehr-rems.at` with your actual domain.
### Docker Local Testing ### Docker Local Testing
@@ -317,11 +317,11 @@ const scopes = 'openid profile email';
1. In the provider details, find **OpenID Configuration URL**: 1. In the provider details, find **OpenID Configuration URL**:
``` ```
https://auth.yourdomain.com/application/o/feuerwehr-dashboard/.well-known/openid-configuration https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/.well-known/openid-configuration
``` ```
2. Important URLs from this configuration: 2. Important URLs from this configuration:
- **Issuer**: `https://auth.yourdomain.com/application/o/feuerwehr-dashboard/` - **Issuer**: `https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/`
- **Authorization Endpoint**: Auto-discovered - **Authorization Endpoint**: Auto-discovered
- **Token Endpoint**: Auto-discovered - **Token Endpoint**: Auto-discovered
- **Userinfo Endpoint**: Auto-discovered - **Userinfo Endpoint**: Auto-discovered
@@ -334,8 +334,8 @@ Update your Feuerwehr Dashboard `.env` file:
# Authentik OAuth Configuration # Authentik OAuth Configuration
AUTHENTIK_CLIENT_ID=<your-client-id> AUTHENTIK_CLIENT_ID=<your-client-id>
AUTHENTIK_CLIENT_SECRET=<your-client-secret> AUTHENTIK_CLIENT_SECRET=<your-client-secret>
AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr-dashboard/ AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/
AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback
# For development, use: # For development, use:
# AUTHENTIK_ISSUER=http://localhost:9000/application/o/feuerwehr-dashboard/ # AUTHENTIK_ISSUER=http://localhost:9000/application/o/feuerwehr-dashboard/
@@ -361,7 +361,7 @@ AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback
2. **Open the dashboard** in your browser: 2. **Open the dashboard** in your browser:
``` ```
Development: http://localhost:5173 Development: http://localhost:5173
Production: https://dashboard.yourdomain.com Production: https://start.feuerwehr-rems.at
``` ```
3. **Click "Login" button** 3. **Click "Login" button**
@@ -441,7 +441,7 @@ In the dashboard:
**Solution**: **Solution**:
1. Ensure `CORS_ORIGIN` in backend `.env` matches frontend URL 1. Ensure `CORS_ORIGIN` in backend `.env` matches frontend URL
2. For development: `CORS_ORIGIN=http://localhost:5173` 2. For development: `CORS_ORIGIN=http://localhost:5173`
3. For production: `CORS_ORIGIN=https://dashboard.yourdomain.com` 3. For production: `CORS_ORIGIN=https://start.feuerwehr-rems.at`
4. Restart backend after changing CORS settings 4. Restart backend after changing CORS settings
### Issue 4: Token Validation Failed ### Issue 4: Token Validation Failed
@@ -561,7 +561,7 @@ After configuration, verify:
Client Type: Confidential Client Type: Confidential
Client ID: <auto-generated> Client ID: <auto-generated>
Client Secret: <auto-generated> Client Secret: <auto-generated>
Redirect URIs: https://dashboard.yourdomain.com/auth/callback Redirect URIs: https://start.feuerwehr-rems.at/auth/callback
Scopes: openid, profile, email Scopes: openid, profile, email
Access Token Validity: 3600 Access Token Validity: 3600
Refresh Token Validity: 86400 Refresh Token Validity: 86400
@@ -571,8 +571,8 @@ Refresh Token Validity: 86400
```bash ```bash
AUTHENTIK_CLIENT_ID=<from-authentik> AUTHENTIK_CLIENT_ID=<from-authentik>
AUTHENTIK_CLIENT_SECRET=<from-authentik> AUTHENTIK_CLIENT_SECRET=<from-authentik>
AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr-dashboard/ AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/
AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback
``` ```
## Security Best Practices ## Security Best Practices

24
CLAUDE.md Normal file
View File

@@ -0,0 +1,24 @@
# Feuerwehr Dashboard — Project Rules
## Stack
- **Frontend:** React / Vite / MUI / TypeScript (`frontend/`)
- **Backend:** Node / Express / TypeScript (`backend/`)
- **DB:** PostgreSQL — migrations in `backend/src/database/migrations/`
- **Deployment:** Docker Compose — never run Docker commands locally
## Migrations
- All migrations must be idempotent (safe to run multiple times)
- Verify column and table names match existing schema before writing
- Never rename env vars used by external systems without explicit approval
## Data Safety
- "Purge user data" means delete records / preferences / history only — never delete user accounts
## Debugging Scope
- When debugging, only touch files directly related to the change you made
- Never edit files in another layer without explicit permission
- If a fix requires a cross-layer change, report it rather than making it unilaterally
## Multi-Agent Work
- After all agents complete, run a verification pass: build, migrations, UI states, modal behavior
- All agent task descriptions must be self-contained (file paths, patterns, acceptance criteria)

View File

@@ -154,19 +154,19 @@ NODE_ENV=production
JWT_SECRET=<generated-jwt-secret> JWT_SECRET=<generated-jwt-secret>
# CORS - Set to your domain! # CORS - Set to your domain!
CORS_ORIGIN=https://dashboard.yourdomain.com CORS_ORIGIN=https://start.feuerwehr-rems.at
# Frontend # Frontend
FRONTEND_PORT=80 FRONTEND_PORT=80
# API URL - Set to your backend URL # API URL - Set to your backend URL
VITE_API_URL=https://api.yourdomain.com VITE_API_URL=https://start.feuerwehr-rems.at
# Authentik OAuth (from Authentik setup) # Authentik OAuth (from Authentik setup)
AUTHENTIK_CLIENT_ID=<your-client-id> AUTHENTIK_CLIENT_ID=<your-client-id>
AUTHENTIK_CLIENT_SECRET=<your-client-secret> AUTHENTIK_CLIENT_SECRET=<your-client-secret>
AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr/ AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr/
AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback
``` ```
Secure the .env file: Secure the .env file:
@@ -253,7 +253,7 @@ Key points for production:
Create `Caddyfile`: Create `Caddyfile`:
```caddy ```caddy
dashboard.yourdomain.com { start.feuerwehr-rems.at {
reverse_proxy localhost:80 reverse_proxy localhost:80
encode gzip encode gzip
@@ -265,7 +265,7 @@ dashboard.yourdomain.com {
} }
} }
api.yourdomain.com { start.feuerwehr-rems.at {
reverse_proxy localhost:3000 reverse_proxy localhost:3000
encode gzip encode gzip
} }
@@ -299,7 +299,7 @@ Create Nginx configuration (`/etc/nginx/sites-available/feuerwehr`):
```nginx ```nginx
server { server {
listen 80; listen 80;
server_name dashboard.yourdomain.com; server_name start.feuerwehr-rems.at;
location / { location / {
proxy_pass http://localhost:80; proxy_pass http://localhost:80;
@@ -312,7 +312,7 @@ server {
server { server {
listen 80; listen 80;
server_name api.yourdomain.com; server_name start.feuerwehr-rems.at;
location / { location / {
proxy_pass http://localhost:3000; proxy_pass http://localhost:3000;
@@ -330,7 +330,7 @@ Enable and obtain SSL:
sudo ln -s /etc/nginx/sites-available/feuerwehr /etc/nginx/sites-enabled/ sudo ln -s /etc/nginx/sites-available/feuerwehr /etc/nginx/sites-enabled/
sudo nginx -t sudo nginx -t
sudo systemctl reload nginx sudo systemctl reload nginx
sudo certbot --nginx -d dashboard.yourdomain.com -d api.yourdomain.com sudo certbot --nginx -d start.feuerwehr-rems.at -d start.feuerwehr-rems.at
``` ```
### Option 3: Using Docker with Traefik ### Option 3: Using Docker with Traefik

View File

@@ -217,8 +217,8 @@ docker run -p 80:80 feuerwehr-frontend:latest
# 1. Set production environment variables # 1. Set production environment variables
export POSTGRES_PASSWORD="secure_production_password" export POSTGRES_PASSWORD="secure_production_password"
export JWT_SECRET="secure_jwt_secret_min_32_chars" export JWT_SECRET="secure_jwt_secret_min_32_chars"
export CORS_ORIGIN="https://yourdomain.com" export CORS_ORIGIN="https://feuerwehr-rems.at"
export VITE_API_URL="https://api.yourdomain.com" export VITE_API_URL="https://start.feuerwehr-rems.at"
# 2. Build and start # 2. Build and start
docker-compose up -d docker-compose up -d

View File

@@ -1,4 +1,4 @@
.PHONY: help dev prod stop logs logs-dev logs-prod rebuild rebuild-dev clean install test .PHONY: help dev prod stop logs logs-dev logs-prod rebuild rebuild-dev clean install test migrate
# Default target # Default target
help: help:
@@ -10,21 +10,22 @@ help:
@echo " make rebuild-dev - Rebuild development services" @echo " make rebuild-dev - Rebuild development services"
@echo "" @echo ""
@echo "Production:" @echo "Production:"
@echo " make prod - Deploy production environment" @echo " make prod - Deploy production environment (includes migrations)"
@echo " make migrate - Run database migrations against production DB"
@echo " make logs-prod - Show production logs" @echo " make logs-prod - Show production logs"
@echo " make rebuild - Rebuild production services" @echo " make rebuild - Rebuild production services"
@echo "" @echo ""
@echo "General:" @echo "General:"
@echo " make stop - Stop all services" @echo " make stop - Stop all services"
@echo " make clean - Remove all containers and volumes" @echo " make clean - Remove all containers and volumes"
@echo " make install - Install dependencies for backend and frontend" @echo " make install - Install dependencies for backend, frontend and sync"
@echo " make test - Run tests" @echo " make test - Run tests"
@echo "" @echo ""
# Development # Development
dev: dev:
@echo "Starting local development database..." @echo "Starting local development database..."
docker-compose -f docker-compose.dev.yml up -d docker compose -f docker-compose.dev.yml up -d
@echo "" @echo ""
@echo "Database is ready at localhost:5432" @echo "Database is ready at localhost:5432"
@echo "Database: feuerwehr_dev" @echo "Database: feuerwehr_dev"
@@ -32,10 +33,10 @@ dev:
@echo "Password: dev_password" @echo "Password: dev_password"
logs-dev: logs-dev:
docker-compose -f docker-compose.dev.yml logs -f docker compose -f docker-compose.dev.yml logs -f
rebuild-dev: rebuild-dev:
docker-compose -f docker-compose.dev.yml up -d --build --force-recreate docker compose -f docker-compose.dev.yml up -d --build --force-recreate
# Production # Production
prod: prod:
@@ -46,24 +47,43 @@ prod:
exit 1; \ exit 1; \
fi fi
@echo "Starting production deployment..." @echo "Starting production deployment..."
docker-compose -f docker-compose.yml up -d --build docker compose -f docker-compose.yml up -d --build
@echo ""
@echo "Running database migrations..."
@$(MAKE) migrate
@echo "" @echo ""
@echo "Production services are running!" @echo "Production services are running!"
migrate:
@if [ ! -f .env ]; then \
echo "Error: .env file not found! Run 'cp .env.example .env' first."; \
exit 1; \
fi
@echo "Waiting for database to be ready..."
@until docker compose -f docker-compose.yml exec -T postgres \
pg_isready -U "$${POSTGRES_USER:-prod_user}" -d "$${POSTGRES_DB:-feuerwehr_prod}" \
> /dev/null 2>&1; do \
printf '.'; sleep 2; \
done
@echo ""
@echo "Running migrations..."
docker compose -f docker-compose.yml exec -T backend npm run migrate
@echo "Migrations complete!"
logs-prod: logs-prod:
docker-compose -f docker-compose.yml logs -f docker compose -f docker-compose.yml logs -f
logs: logs:
@make logs-prod @$(MAKE) logs-prod
rebuild: rebuild:
docker-compose -f docker-compose.yml up -d --build --force-recreate docker compose -f docker-compose.yml up -d --build --force-recreate
# General commands # General commands
stop: stop:
@echo "Stopping all services..." @echo "Stopping all services..."
docker-compose -f docker-compose.yml down 2>/dev/null || true docker compose -f docker-compose.yml down 2>/dev/null || true
docker-compose -f docker-compose.dev.yml down 2>/dev/null || true docker compose -f docker-compose.dev.yml down 2>/dev/null || true
@echo "All services stopped" @echo "All services stopped"
clean: clean:
@@ -71,8 +91,8 @@ clean:
@read -p "Are you sure? [y/N] " -n 1 -r; \ @read -p "Are you sure? [y/N] " -n 1 -r; \
echo; \ echo; \
if [ "$$REPLY" = "y" ] || [ "$$REPLY" = "Y" ]; then \ if [ "$$REPLY" = "y" ] || [ "$$REPLY" = "Y" ]; then \
docker-compose -f docker-compose.yml down -v 2>/dev/null || true; \ docker compose -f docker-compose.yml down -v 2>/dev/null || true; \
docker-compose -f docker-compose.dev.yml down -v 2>/dev/null || true; \ docker compose -f docker-compose.dev.yml down -v 2>/dev/null || true; \
docker images | grep feuerwehr | awk '{print $$3}' | xargs docker rmi -f 2>/dev/null || true; \ docker images | grep feuerwehr | awk '{print $$3}' | xargs docker rmi -f 2>/dev/null || true; \
echo "Cleanup complete!"; \ echo "Cleanup complete!"; \
else \ else \
@@ -87,6 +107,9 @@ install:
@echo "Installing frontend dependencies..." @echo "Installing frontend dependencies..."
cd frontend && npm install cd frontend && npm install
@echo "" @echo ""
@echo "Installing sync dependencies..."
cd sync && npm install
@echo ""
@echo "Dependencies installed!" @echo "Dependencies installed!"
# Run tests # Run tests

View File

@@ -1,2 +1 @@
registry=https://registry.npmjs.org/ registry=https://registry.npmjs.org/
omit-lockfile-registry-resolved=true

View File

@@ -55,6 +55,9 @@ COPY --from=builder /app/dist ./dist
# Copy database migrations (needed for runtime) # Copy database migrations (needed for runtime)
COPY --from=builder /app/src/database/migrations ./dist/database/migrations COPY --from=builder /app/src/database/migrations ./dist/database/migrations
# Create logs and uploads directories
RUN mkdir -p /app/logs /app/uploads/bestellungen/thumbnails
# Change ownership to non-root user # Change ownership to non-root user
RUN chown -R nodejs:nodejs /app RUN chown -R nodejs:nodejs /app

View File

@@ -15,6 +15,7 @@
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.2.1", "express-rate-limit": "^8.2.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jose": "^6.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"pg": "^8.18.0", "pg": "^8.18.0",
"winston": "^3.19.0", "winston": "^3.19.0",
@@ -1069,6 +1070,14 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/jose": {
"version": "6.2.1",
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/jsonwebtoken": { "node_modules/jsonwebtoken": {
"version": "9.0.3", "version": "9.0.3",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",

View File

@@ -7,6 +7,7 @@
"dev": "nodemon", "dev": "nodemon",
"build": "tsc", "build": "tsc",
"start": "node dist/server.js", "start": "node dist/server.js",
"migrate": "node -e \"require('./dist/config/database').runMigrations().then(() => { console.log('Done'); process.exit(0); }).catch((e) => { console.error(e.message); process.exit(1); })\"",
"test": "echo \"Error: no test specified\" && exit 1" "test": "echo \"Error: no test specified\" && exit 1"
}, },
"keywords": [], "keywords": [],
@@ -20,13 +21,16 @@
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.2.1", "express-rate-limit": "^8.2.1",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"jose": "^6.2.1",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"pg": "^8.18.0", "pg": "^8.18.0",
"multer": "^1.4.5-lts.2",
"winston": "^3.19.0", "winston": "^3.19.0",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.19", "@types/cors": "^2.8.19",
"@types/multer": "^1.4.12",
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0", "@types/node": "^25.3.0",

View File

@@ -2,12 +2,18 @@ import express, { Application, Request, Response } from 'express';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import path from 'path';
import environment from './config/environment'; import environment from './config/environment';
import logger from './utils/logger'; import logger from './utils/logger';
import { errorHandler, notFoundHandler } from './middleware/error.middleware'; import { errorHandler, notFoundHandler } from './middleware/error.middleware';
import { requestTimeout } from './middleware/request-timeout.middleware';
import { authenticate } from './middleware/auth.middleware';
const app: Application = express(); const app: Application = express();
// Trust proxy (required for correct IP detection behind Traefik/Nginx)
app.set('trust proxy', 1);
// Security middleware // Security middleware
app.use(helmet()); app.use(helmet());
@@ -17,21 +23,42 @@ app.use(cors({
credentials: true, credentials: true,
})); }));
// Rate limiting // Rate limiting - general API routes (applied below, after auth limiter)
const limiter = rateLimit({
// Rate limiting - auth routes (generous to avoid blocking logins during
// normal use; each OAuth flow = 1 callback + token exchange)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 60, // 60 auth attempts per window (allows ~20 full login cycles)
message: 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.',
standardHeaders: true,
legacyHeaders: false,
});
app.use('/api/auth', authLimiter);
// General rate limiter — skip auth routes (own limiter above) and authenticated
// requests (Bearer token present). Auth middleware validates the token downstream;
// rate-limiting authenticated dashboard polling would cause 429 floods.
app.use('/api', rateLimit({
windowMs: environment.rateLimit.windowMs, windowMs: environment.rateLimit.windowMs,
max: environment.rateLimit.max, max: environment.rateLimit.max,
message: 'Too many requests from this IP, please try again later.', message: 'Too many requests from this IP, please try again later.',
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
}); skip: (req) => {
if (req.path.startsWith('/auth')) return true;
app.use('/api', limiter); const auth = req.headers.authorization;
return typeof auth === 'string' && auth.startsWith('Bearer ');
},
}));
// Body parsing middleware // Body parsing middleware
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Request timeout middleware
app.use(requestTimeout);
// Request logging middleware // Request logging middleware
app.use((req: Request, _res: Response, next) => { app.use((req: Request, _res: Response, next) => {
logger.info('Incoming request', { logger.info('Incoming request', {
@@ -53,11 +80,74 @@ app.get('/health', (_req: Request, res: Response) => {
}); });
// API routes // API routes
import authRoutes from './routes/auth.routes'; import authRoutes from './routes/auth.routes';
import userRoutes from './routes/user.routes'; import userRoutes from './routes/user.routes';
import memberRoutes from './routes/member.routes';
import adminRoutes from './routes/admin.routes';
import trainingRoutes from './routes/training.routes';
import vehicleRoutes from './routes/vehicle.routes';
import incidentRoutes from './routes/incident.routes';
import equipmentRoutes from './routes/equipment.routes';
import nextcloudRoutes from './routes/nextcloud.routes';
import atemschutzRoutes from './routes/atemschutz.routes';
import eventsRoutes from './routes/events.routes';
import bookingRoutes from './routes/booking.routes';
import notificationRoutes from './routes/notification.routes';
import bookstackRoutes from './routes/bookstack.routes';
import vikunjaRoutes from './routes/vikunja.routes';
import bestellungRoutes from './routes/bestellung.routes';
import configRoutes from './routes/config.routes';
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
import settingsRoutes from './routes/settings.routes';
import bannerRoutes from './routes/banner.routes';
import permissionRoutes from './routes/permission.routes';
import ausruestungsanfrageRoutes from './routes/ausruestungsanfrage.routes';
import issueRoutes from './routes/issue.routes';
import buchungskategorieRoutes from './routes/buchungskategorie.routes';
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';
import toolConfigRoutes from './routes/toolConfig.routes';
import scheduledMessagesRoutes from './routes/scheduledMessages.routes';
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/user', userRoutes); app.use('/api/user', userRoutes);
app.use('/api/members', memberRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/training', trainingRoutes);
app.use('/api/vehicles', vehicleRoutes);
app.use('/api/incidents', incidentRoutes);
app.use('/api/equipment', equipmentRoutes);
app.use('/api/atemschutz', atemschutzRoutes);
app.use('/api/nextcloud/talk', nextcloudRoutes);
app.use('/api/events', eventsRoutes);
app.use('/api/bookings', bookingRoutes);
app.use('/api/notifications', notificationRoutes);
app.use('/api/bookstack', bookstackRoutes);
app.use('/api/vikunja', vikunjaRoutes);
app.use('/api/bestellungen', bestellungRoutes);
app.use('/api/config', configRoutes);
app.use('/api/admin', serviceMonitorRoutes);
app.use('/api/admin/settings', settingsRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/banners', bannerRoutes);
app.use('/api/permissions', permissionRoutes);
app.use('/api/ausruestungsanfragen', ausruestungsanfrageRoutes);
app.use('/api/issues', issueRoutes);
app.use('/api/buchungskategorien', buchungskategorieRoutes);
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);
app.use('/api/admin/tools', toolConfigRoutes);
app.use('/api/scheduled-messages', scheduledMessagesRoutes);
// Static file serving for uploads (authenticated)
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
app.use('/uploads', authenticate, express.static(uploadsDir));
// 404 handler // 404 handler
app.use(notFoundHandler); app.use(notFoundHandler);

View File

@@ -11,15 +11,23 @@ interface AuthentikConfig {
logoutEndpoint: string; logoutEndpoint: string;
} }
// Authentik's shared endpoints live at /application/o/, not at the per-app issuer path.
// Issuer example: https://auth.example.com/application/o/myapp/
// Token endpoint: https://auth.example.com/application/o/token/
const issuerUrl = new URL(environment.authentik.issuer);
const pathParts = issuerUrl.pathname.split('/').filter(Boolean);
const basePath = '/' + pathParts.slice(0, -1).join('/') + '/';
const baseEndpoint = `${issuerUrl.origin}${basePath}`;
const authentikConfig: AuthentikConfig = { const authentikConfig: AuthentikConfig = {
issuer: environment.authentik.issuer, issuer: environment.authentik.issuer,
clientId: environment.authentik.clientId, clientId: environment.authentik.clientId,
clientSecret: environment.authentik.clientSecret, clientSecret: environment.authentik.clientSecret,
redirectUri: environment.authentik.redirectUri, redirectUri: environment.authentik.redirectUri,
tokenEndpoint: `${environment.authentik.issuer}token/`, tokenEndpoint: `${baseEndpoint}token/`,
userInfoEndpoint: `${environment.authentik.issuer}userinfo/`, userInfoEndpoint: `${baseEndpoint}userinfo/`,
authorizeEndpoint: `${environment.authentik.issuer}authorize/`, authorizeEndpoint: `${baseEndpoint}authorize/`,
logoutEndpoint: `${environment.authentik.issuer}logout/`, logoutEndpoint: `${baseEndpoint}end-session/`,
}; };
export default authentikConfig; export default authentikConfig;

View File

@@ -1,18 +1,22 @@
import { Pool, PoolConfig } from 'pg'; import { Pool, PoolConfig, types } from 'pg';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import environment from './environment'; import environment from './environment';
import logger from '../utils/logger'; import logger from '../utils/logger';
// Override pg's default DATE parser: return plain 'YYYY-MM-DD' strings
// instead of JavaScript Date objects (which introduce timezone shifts).
types.setTypeParser(1082, (val: string) => val);
const poolConfig: PoolConfig = { const poolConfig: PoolConfig = {
host: environment.database.host, host: environment.database.host,
port: environment.database.port, port: environment.database.port,
database: environment.database.name, database: environment.database.name,
user: environment.database.user, user: environment.database.user,
password: environment.database.password, password: environment.database.password,
max: 20, // Maximum number of clients in the pool max: 30, // Maximum number of clients in the pool
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
connectionTimeoutMillis: 2000, // Return an error if connection takes longer than 2 seconds connectionTimeoutMillis: 5000, // Return an error if connection takes longer than 5 seconds
}; };
const pool = new Pool(poolConfig); const pool = new Pool(poolConfig);
@@ -22,6 +26,17 @@ pool.on('error', (err) => {
logger.error('Unexpected error on idle database client', err); logger.error('Unexpected error on idle database client', err);
}); });
// Log pool exhaustion warnings every 60s (only when requests are waiting)
setInterval(() => {
if (pool.waitingCount > 0) {
logger.warn('DB pool pressure detected', {
total: pool.totalCount,
idle: pool.idleCount,
waiting: pool.waitingCount,
});
}
}, 60_000).unref();
// Test database connection // Test database connection
export const testConnection = async (): Promise<boolean> => { export const testConnection = async (): Promise<boolean> => {
try { try {

View File

@@ -1,12 +1,9 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import path from 'path';
// Load environment-specific .env file // Load from root .env (project-wide single source of truth).
const envFile = process.env.NODE_ENV === 'production' // In Docker, env vars are already injected by docker-compose so this is a no-op.
? '.env.production' dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
: '.env.development';
dotenv.config({ path: path.resolve(__dirname, '../../', envFile) });
interface EnvironmentConfig { interface EnvironmentConfig {
nodeEnv: string; nodeEnv: string;
@@ -35,6 +32,16 @@ interface EnvironmentConfig {
clientSecret: string; clientSecret: string;
redirectUri: string; redirectUri: string;
}; };
nextcloudUrl: string;
bookstack: {
url: string;
tokenId: string;
tokenSecret: string;
};
vikunja: {
url: string;
apiToken: string;
};
} }
const environment: EnvironmentConfig = { const environment: EnvironmentConfig = {
@@ -48,7 +55,7 @@ const environment: EnvironmentConfig = {
password: process.env.DB_PASSWORD || 'dev_password', password: process.env.DB_PASSWORD || 'dev_password',
}, },
jwt: { jwt: {
secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production', secret: process.env.JWT_SECRET || '',
expiresIn: process.env.JWT_EXPIRES_IN || '24h', expiresIn: process.env.JWT_EXPIRES_IN || '24h',
}, },
cors: { cors: {
@@ -56,14 +63,51 @@ const environment: EnvironmentConfig = {
}, },
rateLimit: { rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10), max: parseInt(process.env.RATE_LIMIT_MAX || '300', 10),
}, },
authentik: { authentik: {
issuer: process.env.AUTHENTIK_ISSUER || 'https://authentik.yourdomain.com/application/o/your-app/', issuer: process.env.AUTHENTIK_ISSUER || 'https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/',
clientId: process.env.AUTHENTIK_CLIENT_ID || 'your_client_id_here', clientId: process.env.AUTHENTIK_CLIENT_ID || 'your_client_id_here',
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || 'your_client_secret_here', clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || 'your_client_secret_here',
redirectUri: process.env.AUTHENTIK_REDIRECT_URI || 'http://localhost:5173/auth/callback', redirectUri: process.env.AUTHENTIK_REDIRECT_URI || 'http://localhost:5173/auth/callback',
}, },
nextcloudUrl: process.env.NEXTCLOUD_URL || '',
bookstack: {
url: process.env.BOOKSTACK_URL || '',
tokenId: process.env.BOOKSTACK_TOKEN_ID || '',
tokenSecret: process.env.BOOKSTACK_TOKEN_SECRET || '',
},
vikunja: {
url: process.env.VIKUNJA_URL || '',
apiToken: process.env.VIKUNJA_API_TOKEN || '',
},
}; };
function validateEnvironment(env: EnvironmentConfig): void {
const secret = env.jwt.secret;
if (!secret) {
throw new Error(
'FATAL: JWT_SECRET is not set. ' +
'Set a strong, random secret of at least 32 characters before starting the server.'
);
}
if (secret === 'your-secret-key-change-in-production') {
throw new Error(
'FATAL: JWT_SECRET is still set to the known weak default value. ' +
'Replace it with a strong, random secret of at least 32 characters.'
);
}
if (secret.length < 32) {
throw new Error(
`FATAL: JWT_SECRET is too short (${secret.length} characters). ` +
'A minimum of 32 characters is required.'
);
}
}
validateEnvironment(environment);
export default environment; export default environment;

View File

@@ -0,0 +1,11 @@
import axios from 'axios';
import * as http from 'http';
import * as https from 'https';
const httpClient = axios.create({
timeout: 10_000,
httpAgent: new http.Agent({ keepAlive: true, maxSockets: 20 }),
httpsAgent: new https.Agent({ keepAlive: true, maxSockets: 20 }),
});
export default httpClient;

View File

@@ -0,0 +1,226 @@
import { Request, Response } from 'express';
import atemschutzService from '../services/atemschutz.service';
import notificationService from '../services/notification.service';
import { CreateAtemschutzSchema, UpdateAtemschutzSchema } from '../models/atemschutz.model';
import logger from '../utils/logger';
// ── UUID validation ───────────────────────────────────────────────────────────
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);
}
// ── Helper ────────────────────────────────────────────────────────────────────
function getUserId(req: Request): string {
return req.user!.id;
}
// ── Controller ────────────────────────────────────────────────────────────────
class AtemschutzController {
async list(req: Request, res: Response): Promise<void> {
try {
const userGroups: string[] = (req.user as any)?.groups ?? [];
const userId = getUserId(req);
const records = await atemschutzService.getAll(userGroups, userId);
res.status(200).json({ success: true, data: records });
} catch (error) {
logger.error('Atemschutz list error', { error });
res.status(500).json({ success: false, message: 'Atemschutzträger konnten nicht geladen werden' });
}
}
async getOne(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 Atemschutz-ID' });
return;
}
const record = await atemschutzService.getById(id);
if (!record) {
res.status(404).json({ success: false, message: 'Atemschutzträger nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: record });
} catch (error) {
logger.error('Atemschutz getOne error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht geladen werden' });
}
}
async getStats(req: Request, res: Response): Promise<void> {
try {
const userGroups: string[] = (req.user as any)?.groups ?? [];
const userId = getUserId(req);
const stats = await atemschutzService.getStats(userGroups, userId);
res.status(200).json({ success: true, data: stats });
} catch (error) {
logger.error('Atemschutz getStats error', { error });
res.status(500).json({ success: false, message: 'Atemschutz-Statistiken konnten nicht geladen werden' });
}
}
async create(req: Request, res: Response): Promise<void> {
try {
const parsed = CreateAtemschutzSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const record = await atemschutzService.create(parsed.data, getUserId(req));
res.status(201).json({ success: true, data: record });
} catch (error: any) {
if (error?.message?.includes('bereits ein Atemschutz-Eintrag')) {
res.status(409).json({ success: false, message: error.message });
return;
}
logger.error('Atemschutz create error', { error });
res.status(500).json({ success: false, message: 'Atemschutzträger 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 Atemschutz-ID' });
return;
}
const parsed = UpdateAtemschutzSchema.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 record = await atemschutzService.update(id, parsed.data, getUserId(req));
if (!record) {
res.status(404).json({ success: false, message: 'Atemschutzträger nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: record });
} 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('Atemschutz update error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht aktualisiert werden' });
}
}
async getByUserId(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 Benutzer-ID' });
return;
}
const callerId = getUserId(req);
const callerGroups: string[] = (req.user as any)?.groups ?? [];
const privileged = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
const isPrivileged = callerGroups.some((g) => privileged.includes(g));
if (userId !== callerId && !isPrivileged) {
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
const record = await atemschutzService.getByUserId(userId);
res.status(200).json({ success: true, data: record ?? null });
} catch (error) {
logger.error('Atemschutz getByUserId error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Atemschutzstatus konnte nicht geladen werden' });
}
}
async getMyStatus(req: Request, res: Response): Promise<void> {
try {
const userId = getUserId(req);
const record = await atemschutzService.getByUserId(userId);
if (!record) {
// User has no atemschutz entry — not an error, just no data
res.status(200).json({ success: true, data: null });
return;
}
res.status(200).json({ success: true, data: record });
} catch (error) {
logger.error('Atemschutz getMyStatus error', { error });
res.status(500).json({ success: false, message: 'Persönlicher Atemschutz-Status konnte nicht geladen werden' });
}
}
async getExpiring(_req: Request, res: Response): Promise<void> {
try {
const expiring = await atemschutzService.getExpiringCertifications(30);
// Side-effect: create notifications for expiring certifications (dedup via DB constraint)
for (const item of expiring) {
if (item.untersuchung_status !== 'ok') {
await notificationService.createNotification({
user_id: item.user_id,
typ: 'atemschutz_expiry',
titel: item.untersuchung_status === 'abgelaufen'
? 'Atemschutztauglichkeitsuntersuchung abgelaufen'
: 'Atemschutztauglichkeitsuntersuchung läuft bald ab',
nachricht: `Ihre Atemschutztauglichkeitsuntersuchung ${item.untersuchung_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`,
schwere: item.untersuchung_status === 'abgelaufen' ? 'fehler' : 'warnung',
quell_typ: 'atemschutz_untersuchung',
quell_id: item.id,
link: '/atemschutz',
});
}
if (item.leistungstest_status !== 'ok') {
await notificationService.createNotification({
user_id: item.user_id,
typ: 'atemschutz_expiry',
titel: item.leistungstest_status === 'abgelaufen'
? 'Leistungstest abgelaufen'
: 'Leistungstest läuft bald ab',
nachricht: `Ihr Leistungstest ${item.leistungstest_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`,
schwere: item.leistungstest_status === 'abgelaufen' ? 'fehler' : 'warnung',
quell_typ: 'atemschutz_leistungstest',
quell_id: item.id,
link: '/atemschutz',
});
}
}
res.status(200).json({ success: true, data: expiring });
} catch (error) {
logger.error('Atemschutz getExpiring error', { error });
res.status(500).json({ success: false, message: 'Ablaufende Zertifizierungen konnten nicht geladen 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 Atemschutz-ID' });
return;
}
const deleted = await atemschutzService.delete(id, getUserId(req));
if (!deleted) {
res.status(404).json({ success: false, message: 'Atemschutzträger nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Atemschutzträger gelöscht' });
} catch (error) {
logger.error('Atemschutz delete error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht gelöscht werden' });
}
}
}
export default new AtemschutzController();

View File

@@ -0,0 +1,130 @@
import { Request, Response } from 'express';
import ausruestungTypService from '../services/ausruestungTyp.service';
import logger from '../utils/logger';
const param = (req: Request, key: string): string => req.params[key] as string;
class AusruestungTypController {
async getAll(_req: Request, res: Response): Promise<void> {
try {
const types = await ausruestungTypService.getAll();
res.status(200).json({ success: true, data: types });
} catch (error) {
logger.error('AusruestungTypController.getAll error', { error });
res.status(500).json({ success: false, message: 'Ausrüstungs-Typen konnten nicht geladen werden' });
}
}
async getById(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 type = await ausruestungTypService.getById(id);
if (!type) {
res.status(404).json({ success: false, message: 'Ausrüstungs-Typ nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: type });
} catch (error) {
logger.error('AusruestungTypController.getById error', { error });
res.status(500).json({ success: false, message: 'Ausrüstungs-Typ konnte nicht geladen werden' });
}
}
async create(req: Request, res: Response): Promise<void> {
const { name } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
try {
const type = await ausruestungTypService.create(req.body);
res.status(201).json({ success: true, data: type });
} catch (error) {
logger.error('AusruestungTypController.create error', { error });
res.status(500).json({ success: false, message: 'Ausrüstungs-Typ konnte nicht erstellt werden' });
}
}
async update(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 type = await ausruestungTypService.update(id, req.body);
if (!type) {
res.status(404).json({ success: false, message: 'Ausrüstungs-Typ nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: type });
} catch (error) {
logger.error('AusruestungTypController.update error', { error });
res.status(500).json({ success: false, message: 'Ausrüstungs-Typ konnte nicht aktualisiert werden' });
}
}
async deleteTyp(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 type = await ausruestungTypService.delete(id);
if (!type) {
res.status(404).json({ success: false, message: 'Ausrüstungs-Typ nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Ausrüstungs-Typ gelöscht' });
} catch (error: any) {
if (error.message === 'Typ wird noch von Ausrüstung verwendet') {
res.status(409).json({ success: false, message: error.message });
return;
}
logger.error('AusruestungTypController.deleteTyp error', { error });
res.status(500).json({ success: false, message: 'Ausrüstungs-Typ konnte nicht gelöscht werden' });
}
}
async getTypesForEquipment(req: Request, res: Response): Promise<void> {
const ausruestungId = param(req, 'ausruestungId');
if (!ausruestungId) {
res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' });
return;
}
try {
const types = await ausruestungTypService.getTypesForEquipment(ausruestungId);
res.status(200).json({ success: true, data: types });
} catch (error) {
logger.error('AusruestungTypController.getTypesForEquipment error', { error });
res.status(500).json({ success: false, message: 'Ausrüstungs-Typen konnten nicht geladen werden' });
}
}
async setTypesForEquipment(req: Request, res: Response): Promise<void> {
const ausruestungId = param(req, 'ausruestungId');
if (!ausruestungId) {
res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' });
return;
}
const { typIds } = req.body;
if (!Array.isArray(typIds)) {
res.status(400).json({ success: false, message: 'typIds muss ein Array sein' });
return;
}
try {
const types = await ausruestungTypService.setTypesForEquipment(ausruestungId, typIds);
res.status(200).json({ success: true, data: types });
} catch (error) {
logger.error('AusruestungTypController.setTypesForEquipment error', { error });
res.status(500).json({ success: false, message: 'Ausrüstungs-Typen konnten nicht gesetzt werden' });
}
}
}
export default new AusruestungTypController();

View File

@@ -0,0 +1,657 @@
import { Request, Response } from 'express';
import ausruestungsanfrageService from '../services/ausruestungsanfrage.service';
import notificationService from '../services/notification.service';
import { permissionService } from '../services/permission.service';
import logger from '../utils/logger';
class AusruestungsanfrageController {
// -------------------------------------------------------------------------
// Categories (DB-backed)
// -------------------------------------------------------------------------
async getKategorien(_req: Request, res: Response): Promise<void> {
try {
const kategorien = await ausruestungsanfrageService.getKategorien();
res.status(200).json({ success: true, data: kategorien });
} catch (error) {
logger.error('AusruestungsanfrageController.getKategorien error', { error });
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
}
}
async createKategorie(req: Request, res: Response): Promise<void> {
try {
const { name, parent_id } = req.body;
if (!name || name.trim().length === 0) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
const kategorie = await ausruestungsanfrageService.createKategorie(name.trim(), parent_id ?? null);
res.status(201).json({ success: true, data: kategorie });
} catch (error: any) {
if (error?.constraint?.includes('unique') || error?.code === '23505') {
res.status(409).json({ success: false, message: 'Kategorie existiert bereits' });
return;
}
logger.error('AusruestungsanfrageController.createKategorie error', { error });
res.status(500).json({ success: false, message: 'Kategorie konnte nicht erstellt werden' });
}
}
async updateKategorie(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
const { name, parent_id } = req.body;
if (name !== undefined && (!name || name.trim().length === 0)) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
const kategorie = await ausruestungsanfrageService.updateKategorie(id, {
name: name?.trim(),
parent_id: parent_id !== undefined ? parent_id : undefined,
});
if (!kategorie) {
res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: kategorie });
} catch (error) {
logger.error('AusruestungsanfrageController.updateKategorie error', { error });
res.status(500).json({ success: false, message: 'Kategorie konnte nicht aktualisiert werden' });
}
}
async deleteKategorie(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
await ausruestungsanfrageService.deleteKategorie(id);
res.status(200).json({ success: true, message: 'Kategorie gelöscht' });
} catch (error) {
logger.error('AusruestungsanfrageController.deleteKategorie error', { error });
res.status(500).json({ success: false, message: 'Kategorie konnte nicht gelöscht werden' });
}
}
// -------------------------------------------------------------------------
// Catalog Items
// -------------------------------------------------------------------------
async getItems(req: Request, res: Response): Promise<void> {
try {
const kategorie = req.query.kategorie as string | undefined;
const kategorie_id = req.query.kategorie_id ? Number(req.query.kategorie_id) : undefined;
const aktiv = req.query.aktiv !== undefined ? req.query.aktiv === 'true' : undefined;
const search = req.query.search as string | undefined;
const items = await ausruestungsanfrageService.getItems({ kategorie, kategorie_id, aktiv, search });
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('AusruestungsanfrageController.getItems error', { error });
res.status(500).json({ success: false, message: 'Artikel konnten nicht geladen werden' });
}
}
async getItemById(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
const item = await ausruestungsanfrageService.getItemById(id);
if (!item) {
res.status(404).json({ success: false, message: 'Artikel nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('AusruestungsanfrageController.getItemById error', { error });
res.status(500).json({ success: false, message: 'Artikel konnte nicht geladen werden' });
}
}
async createItem(req: Request, res: Response): Promise<void> {
try {
const { bezeichnung } = req.body;
if (!bezeichnung || bezeichnung.trim().length === 0) {
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
return;
}
const item = await ausruestungsanfrageService.createItem(req.body, req.user!.id);
res.status(201).json({ success: true, data: item });
} catch (error) {
logger.error('AusruestungsanfrageController.createItem error', { error });
res.status(500).json({ success: false, message: 'Artikel konnte nicht erstellt werden' });
}
}
async updateItem(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
const item = await ausruestungsanfrageService.updateItem(id, req.body, req.user!.id);
if (!item) {
res.status(404).json({ success: false, message: 'Artikel nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('AusruestungsanfrageController.updateItem error', { error });
res.status(500).json({ success: false, message: 'Artikel konnte nicht aktualisiert werden' });
}
}
async deleteItem(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
await ausruestungsanfrageService.deleteItem(id);
res.status(200).json({ success: true, message: 'Artikel gelöscht' });
} catch (error) {
logger.error('AusruestungsanfrageController.deleteItem error', { error });
res.status(500).json({ success: false, message: 'Artikel konnte nicht gelöscht werden' });
}
}
async getCategories(_req: Request, res: Response): Promise<void> {
try {
const categories = await ausruestungsanfrageService.getCategories();
res.status(200).json({ success: true, data: categories });
} catch (error) {
logger.error('AusruestungsanfrageController.getCategories error', { error });
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
}
}
// -------------------------------------------------------------------------
// Artikel Eigenschaften (characteristics)
// -------------------------------------------------------------------------
async getArtikelEigenschaften(req: Request, res: Response): Promise<void> {
try {
const artikelId = Number(req.params.id);
const eigenschaften = await ausruestungsanfrageService.getArtikelEigenschaften(artikelId);
res.status(200).json({ success: true, data: eigenschaften });
} catch (error) {
logger.error('AusruestungsanfrageController.getArtikelEigenschaften error', { error });
res.status(500).json({ success: false, message: 'Eigenschaften konnten nicht geladen werden' });
}
}
async upsertArtikelEigenschaft(req: Request, res: Response): Promise<void> {
try {
const artikelId = Number(req.params.id);
const { name, typ, optionen, pflicht, sort_order, eigenschaft_id } = req.body;
if (!name || name.trim().length === 0) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
if (!typ || !['options', 'freitext'].includes(typ)) {
res.status(400).json({ success: false, message: 'Typ muss "options" oder "freitext" sein' });
return;
}
const eigenschaft = await ausruestungsanfrageService.upsertArtikelEigenschaft(artikelId, {
id: eigenschaft_id,
name: name.trim(),
typ,
optionen: optionen || undefined,
pflicht: pflicht ?? false,
sort_order: sort_order ?? 0,
});
res.status(eigenschaft_id ? 200 : 201).json({ success: true, data: eigenschaft });
} catch (error) {
logger.error('AusruestungsanfrageController.upsertArtikelEigenschaft error', { error });
res.status(500).json({ success: false, message: 'Eigenschaft konnte nicht gespeichert werden' });
}
}
async deleteArtikelEigenschaft(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.eigenschaftId);
await ausruestungsanfrageService.deleteArtikelEigenschaft(id);
res.status(200).json({ success: true, message: 'Eigenschaft gelöscht' });
} catch (error) {
logger.error('AusruestungsanfrageController.deleteArtikelEigenschaft error', { error });
res.status(500).json({ success: false, message: 'Eigenschaft konnte nicht gelöscht werden' });
}
}
// -------------------------------------------------------------------------
// Users (for "order on behalf of" autocomplete)
// -------------------------------------------------------------------------
async getAllUsers(_req: Request, res: Response): Promise<void> {
try {
const users = await ausruestungsanfrageService.getAllUsers();
res.status(200).json({ success: true, data: users });
} catch (error) {
logger.error('AusruestungsanfrageController.getAllUsers error', { error });
res.status(500).json({ success: false, message: 'Benutzer konnten nicht geladen werden' });
}
}
// -------------------------------------------------------------------------
// Requests
// -------------------------------------------------------------------------
async getRequests(req: Request, res: Response): Promise<void> {
try {
const status = req.query.status as string | undefined;
const anfrager_id = req.query.anfrager_id as string | undefined;
const requests = await ausruestungsanfrageService.getRequests({ status, anfrager_id });
res.status(200).json({ success: true, data: requests });
} catch (error) {
logger.error('AusruestungsanfrageController.getRequests error', { error });
res.status(500).json({ success: false, message: 'Anfragen konnten nicht geladen werden' });
}
}
async getMyRequests(req: Request, res: Response): Promise<void> {
try {
const requests = await ausruestungsanfrageService.getMyRequests(req.user!.id);
res.status(200).json({ success: true, data: requests });
} catch (error) {
logger.error('AusruestungsanfrageController.getMyRequests error', { error });
res.status(500).json({ success: false, message: 'Anfragen konnten nicht geladen werden' });
}
}
async getRequestById(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
const request = await ausruestungsanfrageService.getRequestById(id);
if (!request) {
res.status(404).json({ success: false, message: 'Anfrage nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: request });
} catch (error) {
logger.error('AusruestungsanfrageController.getRequestById error', { error });
res.status(500).json({ success: false, message: 'Anfrage konnte nicht geladen werden' });
}
}
async createRequest(req: Request, res: Response): Promise<void> {
try {
const { items, notizen, bezeichnung, fuer_benutzer_id, fuer_benutzer_name } = req.body as {
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[]; persoenlich_id?: string; neuer_zustand?: string }[];
notizen?: string;
bezeichnung?: string;
fuer_benutzer_id?: string;
fuer_benutzer_name?: string;
};
if (!items || items.length === 0) {
res.status(400).json({ success: false, message: 'Mindestens eine Position ist erforderlich' });
return;
}
for (const item of items) {
if (!item.persoenlich_id && (!item.bezeichnung || item.bezeichnung.trim().length === 0)) {
res.status(400).json({ success: false, message: 'Bezeichnung ist für alle Positionen erforderlich' });
return;
}
if (!item.menge || item.menge < 1) {
res.status(400).json({ success: false, message: 'Menge muss mindestens 1 sein' });
return;
}
}
// Determine anfrager: self or on behalf of another user
let anfragerId = req.user!.id;
let storedFuerBenutzerName: string | undefined;
if (fuer_benutzer_id && fuer_benutzer_id !== req.user!.id) {
const groups = req.user?.groups ?? [];
const canOrderForUser = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'ausruestungsanfrage:order_for_user');
if (!canOrderForUser) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für Bestellung im Auftrag' });
return;
}
anfragerId = fuer_benutzer_id;
} else if (fuer_benutzer_name && !fuer_benutzer_id) {
// Custom name for user not in system — keep anfrager_id as current user
const groups = req.user?.groups ?? [];
const canOrderForUser = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'ausruestungsanfrage:order_for_user');
if (!canOrderForUser) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für Bestellung im Auftrag' });
return;
}
storedFuerBenutzerName = fuer_benutzer_name;
}
const request = await ausruestungsanfrageService.createRequest(anfragerId, items, notizen, bezeichnung, storedFuerBenutzerName);
res.status(201).json({ success: true, data: request });
} catch (error) {
logger.error('AusruestungsanfrageController.createRequest error', { error });
res.status(500).json({ success: false, message: 'Anfrage konnte nicht erstellt werden' });
}
}
async updateRequest(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
const { bezeichnung, notizen, items } = req.body as {
bezeichnung?: string;
notizen?: string;
items?: { artikel_id?: number; bezeichnung: string; menge: number; notizen?: string; eigenschaften?: { eigenschaft_id: number; wert: string }[] }[];
};
// Validate items if provided
if (items) {
if (items.length === 0) {
res.status(400).json({ success: false, message: 'Mindestens eine Position ist erforderlich' });
return;
}
for (const item of items) {
if (!item.bezeichnung || item.bezeichnung.trim().length === 0) {
res.status(400).json({ success: false, message: 'Bezeichnung ist für alle Positionen erforderlich' });
return;
}
if (!item.menge || item.menge < 1) {
res.status(400).json({ success: false, message: 'Menge muss mindestens 1 sein' });
return;
}
}
}
const existing = await ausruestungsanfrageService.getRequestById(id);
if (!existing) {
res.status(404).json({ success: false, message: 'Anfrage nicht gefunden' });
return;
}
// Check permission: owner + status=offen, OR ausruestungsanfrage:edit
const groups = req.user?.groups ?? [];
const canEditAny = groups.includes('dashboard_admin') || permissionService.hasPermission(groups, 'ausruestungsanfrage:edit');
const isOwner = existing.anfrage.anfrager_id === req.user!.id;
if (!canEditAny && !(isOwner && existing.anfrage.status === 'offen')) {
res.status(403).json({ success: false, message: 'Keine Berechtigung zum Bearbeiten dieser Anfrage' });
return;
}
const updated = await ausruestungsanfrageService.updateRequest(id, { bezeichnung, notizen, items });
res.status(200).json({ success: true, data: updated });
} catch (error) {
logger.error('AusruestungsanfrageController.updateRequest error', { error });
res.status(500).json({ success: false, message: 'Anfrage konnte nicht aktualisiert werden' });
}
}
async updateRequestStatus(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
const { status, admin_notizen } = req.body as {
status?: string;
admin_notizen?: string;
};
if (!status) {
res.status(400).json({ success: false, message: 'Status ist erforderlich' });
return;
}
const validStatuses = ['offen', 'genehmigt', 'abgelehnt', 'bestellt', 'erledigt'];
if (!validStatuses.includes(status)) {
res.status(400).json({ success: false, message: `Ungültiger Status. Erlaubt: ${validStatuses.join(', ')}` });
return;
}
// Fetch request to get anfrager_id for notification
const existing = await ausruestungsanfrageService.getRequestById(id);
if (!existing) {
res.status(404).json({ success: false, message: 'Anfrage nicht gefunden' });
return;
}
const updated = await ausruestungsanfrageService.updateRequestStatus(id, status, admin_notizen, req.user!.id);
// Notify requester on status changes
if (['genehmigt', 'abgelehnt', 'bestellt', 'erledigt'].includes(status)) {
const orderLabel = existing.anfrage.bestell_jahr && existing.anfrage.bestell_nummer
? `${existing.anfrage.bestell_jahr}/${String(existing.anfrage.bestell_nummer).padStart(3, '0')}`
: `#${id}`;
await notificationService.createNotification({
user_id: existing.anfrage.anfrager_id,
typ: 'ausruestung_anfrage',
titel: status === 'genehmigt' ? 'Anfrage genehmigt' : status === 'abgelehnt' ? 'Anfrage abgelehnt' : `Anfrage ${status}`,
nachricht: `Deine Ausrüstungsanfrage ${orderLabel} wurde ${status === 'genehmigt' ? 'genehmigt' : status === 'abgelehnt' ? 'abgelehnt' : status}.`,
schwere: status === 'abgelehnt' ? 'warnung' : 'info',
link: '/ausruestungsanfrage',
quell_id: String(id),
quell_typ: 'ausruestung_anfrage',
});
}
res.status(200).json({ success: true, data: updated });
} catch (error) {
logger.error('AusruestungsanfrageController.updateRequestStatus error', { error });
res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' });
}
}
async deleteRequest(req: Request, res: Response): Promise<void> {
try {
const id = Number(req.params.id);
await ausruestungsanfrageService.deleteRequest(id);
res.status(200).json({ success: true, message: 'Anfrage gelöscht' });
} catch (error) {
logger.error('AusruestungsanfrageController.deleteRequest error', { error });
res.status(500).json({ success: false, message: 'Anfrage konnte nicht gelöscht werden' });
}
}
// -------------------------------------------------------------------------
// Position delivery tracking
// -------------------------------------------------------------------------
async updatePositionGeliefert(req: Request, res: Response): Promise<void> {
try {
const positionId = Number(req.params.positionId);
const { geliefert } = req.body as { geliefert?: boolean };
if (typeof geliefert !== 'boolean') {
res.status(400).json({ success: false, message: 'geliefert (boolean) ist erforderlich' });
return;
}
const position = await ausruestungsanfrageService.updatePositionGeliefert(positionId, geliefert);
if (!position) {
res.status(404).json({ success: false, message: 'Position nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: position });
} catch (error) {
logger.error('AusruestungsanfrageController.updatePositionGeliefert error', { error });
res.status(500).json({ success: false, message: 'Lieferstatus konnte nicht aktualisiert werden' });
}
}
async updatePositionZurueckgegeben(req: Request, res: Response): Promise<void> {
try {
const positionId = Number(req.params.positionId);
const { altes_geraet_zurueckgegeben } = req.body as { altes_geraet_zurueckgegeben?: boolean };
if (typeof altes_geraet_zurueckgegeben !== 'boolean') {
res.status(400).json({ success: false, message: 'altes_geraet_zurueckgegeben (boolean) ist erforderlich' });
return;
}
const position = await ausruestungsanfrageService.updatePositionZurueckgegeben(positionId, altes_geraet_zurueckgegeben);
if (!position) {
res.status(404).json({ success: false, message: 'Position nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: position });
} catch (error) {
logger.error('AusruestungsanfrageController.updatePositionZurueckgegeben error', { error });
res.status(500).json({ success: false, message: 'Rückgabestatus konnte nicht aktualisiert werden' });
}
}
// -------------------------------------------------------------------------
// Overview
// -------------------------------------------------------------------------
async getOverview(_req: Request, res: Response): Promise<void> {
try {
const overview = await ausruestungsanfrageService.getOverview();
res.status(200).json({ success: true, data: overview });
} catch (error) {
logger.error('AusruestungsanfrageController.getOverview error', { error });
res.status(500).json({ success: false, message: 'Übersicht konnte nicht geladen werden' });
}
}
// -------------------------------------------------------------------------
// Linking
// -------------------------------------------------------------------------
async linkToOrder(req: Request, res: Response): Promise<void> {
try {
const anfrageId = Number(req.params.id);
const { bestellung_id } = req.body as { bestellung_id?: number };
if (!bestellung_id) {
res.status(400).json({ success: false, message: 'bestellung_id ist erforderlich' });
return;
}
await ausruestungsanfrageService.linkToOrder(anfrageId, bestellung_id);
res.status(200).json({ success: true, message: 'Verknüpfung erstellt' });
} catch (error) {
logger.error('AusruestungsanfrageController.linkToOrder error', { error });
res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht erstellt werden' });
}
}
async unlinkFromOrder(req: Request, res: Response): Promise<void> {
try {
const anfrageId = Number(req.params.id);
const bestellungId = Number(req.params.bestellungId);
await ausruestungsanfrageService.unlinkFromOrder(anfrageId, bestellungId);
res.status(200).json({ success: true, message: 'Verknüpfung entfernt' });
} catch (error) {
logger.error('AusruestungsanfrageController.unlinkFromOrder error', { error });
res.status(500).json({ success: false, message: 'Verknüpfung konnte nicht entfernt werden' });
}
}
async createOrders(req: Request, res: Response): Promise<void> {
try {
const anfrageId = Number(req.params.id);
const { orders } = req.body as {
orders: Array<{
lieferant_id: number;
bezeichnung: string;
positionen: Array<{ position_id: number; bezeichnung: string; menge: number; einheit?: string; notizen?: string }>;
}>;
};
if (!orders || orders.length === 0) {
res.status(400).json({ success: false, message: 'Mindestens eine Bestellung ist erforderlich' });
return;
}
const created = await ausruestungsanfrageService.createOrdersFromRequest(anfrageId, orders, req.user!.id);
res.status(201).json({ success: true, data: { created_bestellungen: created } });
} catch (error) {
logger.error('AusruestungsanfrageController.createOrders error', { error });
res.status(500).json({ success: false, message: 'Bestellungen konnten nicht erstellt werden' });
}
}
// -------------------------------------------------------------------------
// 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;
eigenschaften?: Array<{ eigenschaft_id?: number; name: string; wert: string }>;
replacedItemIds?: 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)
// -------------------------------------------------------------------------
async getWidgetOverview(_req: Request, res: Response): Promise<void> {
try {
const overview = await ausruestungsanfrageService.getWidgetOverview();
res.status(200).json({ success: true, data: overview });
} catch (error) {
logger.error('AusruestungsanfrageController.getWidgetOverview error', { error });
res.status(500).json({ success: false, message: 'Widget-Daten konnten nicht geladen werden' });
}
}
// -------------------------------------------------------------------------
// Unassigned positions
// -------------------------------------------------------------------------
async getUnassignedPositions(_req: Request, res: Response): Promise<void> {
try {
const data = await ausruestungsanfrageService.getUnassignedPositions();
res.status(200).json({ success: true, data });
} catch (error) {
logger.error('AusruestungsanfrageController.getUnassignedPositions error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden' });
}
}
async updatePositionArtikelId(req: Request, res: Response): Promise<void> {
try {
const positionId = Number(req.params.positionId);
const artikelId = Number(req.body.artikel_id);
if (isNaN(positionId) || isNaN(artikelId) || artikelId <= 0) {
res.status(400).json({ success: false, message: 'Ungültige IDs' });
return;
}
const updated = await ausruestungsanfrageService.updatePositionArtikelId(positionId, artikelId);
if (!updated) {
res.status(404).json({ success: false, message: 'Position nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: updated });
} catch (error) {
logger.error('AusruestungsanfrageController.updatePositionArtikelId error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren' });
}
}
}
export default new AusruestungsanfrageController();

View File

@@ -1,9 +1,57 @@
import { Request, Response } from 'express'; import { Request, Response } from 'express';
import { z } from 'zod';
import authentikService from '../services/authentik.service'; import authentikService from '../services/authentik.service';
import tokenService from '../services/token.service'; import tokenService from '../services/token.service';
import userService from '../services/user.service'; import userService from '../services/user.service';
import memberService from '../services/member.service';
import logger from '../utils/logger'; import logger from '../utils/logger';
import { AuthRequest } from '../types/auth.types'; import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
import { getUserRole } from '../middleware/rbac.middleware';
import pool from '../config/database';
/**
* Extract given_name and family_name from Authentik userinfo.
* Falls back to splitting the `name` field if individual fields are missing or identical.
*/
function extractNames(userInfo: { name?: string; given_name?: string; family_name?: string }): {
given_name: string | undefined;
family_name: string | undefined;
} {
const givenName = userInfo.given_name?.trim();
const familyName = userInfo.family_name?.trim();
// If Authentik provides both and they differ, use them directly
// BUT: guard against the case where given_name is actually the full name
// (e.g. Authentik sends given_name="Matthias Hochmeister", family_name="Hochmeister")
if (givenName && familyName && givenName !== familyName) {
const looksLikeFullName =
givenName.includes(' ') &&
(givenName.endsWith(' ' + familyName) || givenName === familyName);
if (!looksLikeFullName) {
return { given_name: givenName, family_name: familyName };
}
// Fall through to split the name field
}
// Fall back to splitting the name field
if (userInfo.name) {
const parts = userInfo.name.trim().split(/\s+/);
if (parts.length >= 2) {
return {
given_name: parts[0],
family_name: parts.slice(1).join(' '),
};
}
// Single word name — use as given_name only
return {
given_name: parts[0],
family_name: undefined,
};
}
return { given_name: givenName, family_name: familyName };
}
class AuthController { class AuthController {
/** /**
@@ -11,11 +59,17 @@ class AuthController {
* POST /api/auth/callback * POST /api/auth/callback
*/ */
async handleCallback(req: Request, res: Response): Promise<void> { async handleCallback(req: Request, res: Response): Promise<void> {
try { const ip = extractIp(req);
const { code } = req.body as AuthRequest; const userAgent = extractUserAgent(req);
// Validate code try {
if (!code) { const callbackSchema = z.object({
code: z.string().min(1),
redirect_uri: z.string().url().optional(),
});
const parseResult = callbackSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
message: 'Authorization code is required', message: 'Authorization code is required',
@@ -23,6 +77,8 @@ class AuthController {
return; return;
} }
const { code } = parseResult.data;
logger.info('Processing OAuth callback', { hasCode: !!code }); logger.info('Processing OAuth callback', { hasCode: !!code });
// Step 1: Exchange code for tokens // Step 1: Exchange code for tokens
@@ -30,71 +86,157 @@ class AuthController {
// Step 2: Get user info from Authentik // Step 2: Get user info from Authentik
const userInfo = await authentikService.getUserInfo(tokens.access_token); 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 // Step 3: Verify ID token if present
if (tokens.id_token) { if (tokens.id_token) {
try { try {
authentikService.verifyIdToken(tokens.id_token); await authentikService.verifyIdToken(tokens.id_token);
} catch (error) { } catch (error) {
logger.warn('ID token verification failed', { error }); logger.error('ID token verification failed — continuing with userinfo (security event)', { error });
} }
} }
// Step 4: Find or create user in database // Step 4: Find or create user in database
let user = await userService.findByAuthentikSub(userInfo.sub); let user = await userService.findByAuthentikSub(userInfo.sub);
let isNewUser = !user;
// Check for a FDISK-pre-created account to claim on first Authentik login
if (!user) {
const { given_name: fdiskGivenName, family_name: fdiskFamilyName } = extractNames(userInfo);
if (fdiskGivenName && fdiskFamilyName) {
const fdiskUser = await userService.findFdiskUserByName(fdiskGivenName, fdiskFamilyName);
if (fdiskUser) {
user = await userService.claimFdiskUser(fdiskUser.id, userInfo.sub, userInfo.email);
if (user) {
isNewUser = false;
logger.info('Claimed FDISK-pre-created user on first login', { userId: fdiskUser.id, sub: userInfo.sub });
}
}
}
}
if (!user) { if (!user) {
// User doesn't exist, create new user // User doesn't exist, create new user
logger.info('Creating new user from Authentik', { logger.info('Creating new user from Authentik', {
sub: userInfo.sub, sub: userInfo.sub,
email: userInfo.email, email: userInfo.email,
}); });
const { given_name: newGivenName, family_name: newFamilyName } = extractNames(userInfo);
user = await userService.createUser({ user = await userService.createUser({
email: userInfo.email, email: userInfo.email,
authentik_sub: userInfo.sub, authentik_sub: userInfo.sub,
preferred_username: userInfo.preferred_username, preferred_username: userInfo.preferred_username,
given_name: userInfo.given_name, given_name: newGivenName,
family_name: userInfo.family_name, family_name: newFamilyName,
name: userInfo.name, name: userInfo.name,
profile_picture_url: userInfo.picture, profile_picture_url: userInfo.picture,
}); });
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)
auditService.logAudit({
user_id: user.id,
user_email: user.email,
action: AuditAction.LOGIN,
resource_type: AuditResourceType.USER,
resource_id: user.id,
old_value: null,
new_value: { event: 'first_login', email: user.email },
ip_address: ip,
user_agent: userAgent,
metadata: { new_account: true },
});
} else { } else {
// User exists, update last login // User exists — check active status BEFORE any mutations
if (!user.is_active) {
logger.warn('Inactive user attempted login', { userId: user.id });
auditService.logAudit({
user_id: user.id,
user_email: user.email,
action: AuditAction.PERMISSION_DENIED,
resource_type: AuditResourceType.USER,
resource_id: user.id,
old_value: null,
new_value: null,
ip_address: ip,
user_agent: userAgent,
metadata: { reason: 'account_inactive' },
});
res.status(403).json({
success: false,
message: 'User account is inactive',
});
return;
}
// User is active, proceed with login updates
logger.info('Existing user logging in', { logger.info('Existing user logging in', {
userId: user.id, userId: user.id,
email: user.email, email: user.email,
}); });
await userService.updateLastLogin(user.id); await userService.updateLastLogin(user.id);
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);
// Refresh profile fields from Authentik on every login (including profile picture)
await userService.updateUser(user.id, {
name: userInfo.name,
given_name: updatedGivenName,
family_name: updatedFamilyName,
preferred_username: userInfo.preferred_username,
profile_picture_url: userInfo.picture || undefined,
});
// Audit: returning user login
auditService.logAudit({
user_id: user.id,
user_email: user.email,
action: AuditAction.LOGIN,
resource_type: AuditResourceType.USER,
resource_id: user.id,
old_value: null,
new_value: null,
ip_address: ip,
user_agent: userAgent,
metadata: {},
});
} }
// Check if user is active // Extract normalised names once for use in the response
if (!user.is_active) { const { given_name: resolvedGivenName, family_name: resolvedFamilyName } = extractNames(userInfo);
logger.warn('Inactive user attempted login', { userId: user.id });
res.status(403).json({
success: false,
message: 'User account is inactive',
});
return;
}
// Step 5: Generate internal JWT token // Step 5: Generate internal JWT token
const role = await getUserRole(user.id);
const accessToken = tokenService.generateToken({ const accessToken = tokenService.generateToken({
userId: user.id, userId: user.id,
email: user.email, email: user.email,
authentikSub: user.authentik_sub, authentikSub: user.authentik_sub,
groups,
role,
}); });
// Generate refresh token // Generate refresh token
const refreshToken = tokenService.generateRefreshToken({ const refreshToken = tokenService.generateRefreshToken({
userId: user.id, userId: user.id,
email: user.email, email: user.email,
}); });
logger.info('User authenticated successfully', { logger.info('User authenticated successfully', {
userId: user.id, userId: user.id,
email: user.email, email: user.email,
}); });
// Step 6: Return tokens and user info // Step 6: Return tokens and user info
@@ -104,21 +246,40 @@ class AuthController {
data: { data: {
accessToken, accessToken,
refreshToken, refreshToken,
isNewUser,
user: { user: {
id: user.id, id: user.id,
email: user.email, email: user.email,
name: user.name, name: userInfo.name || user.name,
preferredUsername: user.preferred_username, preferredUsername: userInfo.preferred_username || user.preferred_username,
givenName: user.given_name, givenName: resolvedGivenName || user.given_name,
familyName: user.family_name, familyName: resolvedFamilyName || user.family_name,
profilePictureUrl: user.profile_picture_url, profilePictureUrl: user.profile_picture_url,
isActive: user.is_active, isActive: user.is_active,
groups,
}, },
}, },
}); });
} catch (error) { } catch (error) {
logger.error('OAuth callback error', { error }); logger.error('OAuth callback error', { error });
// Audit the failed login attempt (user_id unknown at this point)
auditService.logAudit({
user_id: null,
user_email: null,
action: AuditAction.PERMISSION_DENIED,
resource_type: AuditResourceType.SYSTEM,
resource_id: null,
old_value: null,
new_value: null,
ip_address: ip,
user_agent: userAgent,
metadata: {
reason: 'oauth_callback_error',
error: error instanceof Error ? error.message : 'unknown',
},
});
const message = const message =
error instanceof Error ? error.message : 'Authentication failed'; error instanceof Error ? error.message : 'Authentication failed';
@@ -134,14 +295,29 @@ class AuthController {
* POST /api/auth/logout * POST /api/auth/logout
*/ */
async handleLogout(req: Request, res: Response): Promise<void> { async handleLogout(req: Request, res: Response): Promise<void> {
try { const ip = extractIp(req);
// In a stateless JWT setup, logout is handled client-side by removing the token const userAgent = extractUserAgent(req);
// However, we can log the event for audit purposes
try {
// In a stateless JWT setup, logout is handled client-side by removing
// the token. We log the event for GDPR accountability.
if (req.user) { if (req.user) {
logger.info('User logged out', { logger.info('User logged out', {
userId: req.user.id, userId: req.user.id,
email: req.user.email, email: req.user.email,
});
auditService.logAudit({
user_id: req.user.id,
user_email: req.user.email,
action: AuditAction.LOGOUT,
resource_type: AuditResourceType.USER,
resource_id: req.user.id,
old_value: null,
new_value: null,
ip_address: ip,
user_agent: userAgent,
metadata: {},
}); });
} }
@@ -165,9 +341,12 @@ class AuthController {
*/ */
async handleRefresh(req: Request, res: Response): Promise<void> { async handleRefresh(req: Request, res: Response): Promise<void> {
try { try {
const { refreshToken } = req.body; const refreshSchema = z.object({
refreshToken: z.string().min(1),
});
if (!refreshToken) { const parseResult = refreshSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({ res.status(400).json({
success: false, success: false,
message: 'Refresh token is required', message: 'Refresh token is required',
@@ -175,6 +354,8 @@ class AuthController {
return; return;
} }
const { refreshToken } = parseResult.data;
// Verify refresh token // Verify refresh token
let decoded; let decoded;
try { try {
@@ -214,15 +395,24 @@ class AuthController {
} }
// Generate new access token // Generate new access token
const role = await getUserRole(user.id);
// Fetch groups from DB so refreshed tokens retain group info
const groupsResult = await pool.query(
'SELECT authentik_groups FROM users WHERE id = $1',
[user.id]
);
const groups: string[] = groupsResult.rows[0]?.authentik_groups ?? [];
const accessToken = tokenService.generateToken({ const accessToken = tokenService.generateToken({
userId: user.id, userId: user.id,
email: user.email, email: user.email,
authentikSub: user.authentik_sub, authentikSub: user.authentik_sub,
groups,
role,
}); });
logger.info('Token refreshed successfully', { logger.info('Token refreshed successfully', {
userId: user.id, userId: user.id,
email: user.email, email: user.email,
}); });
res.status(200).json({ res.status(200).json({

View File

@@ -0,0 +1,65 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import bannerService from '../services/banner.service';
import logger from '../utils/logger';
const createSchema = z.object({
message: z.string().min(1).max(2000),
level: z.enum(['info', 'important', 'critical']).default('info'),
show_as: z.enum(['banner', 'widget']).default('banner'),
starts_at: z.string().datetime().optional(),
ends_at: z.string().datetime().nullable().optional(),
});
class BannerController {
async getActive(_req: Request, res: Response): Promise<void> {
try {
const banners = await bannerService.getActive();
res.json({ success: true, data: banners });
} catch (error) {
logger.error('Failed to get banners', { error });
res.status(500).json({ success: false, message: 'Failed to get banners' });
}
}
async getAll(_req: Request, res: Response): Promise<void> {
try {
const banners = await bannerService.getAll();
res.json({ success: true, data: banners });
} catch (error) {
logger.error('Failed to get all banners', { error });
res.status(500).json({ success: false, message: 'Failed to get banners' });
}
}
async create(req: Request, res: Response): Promise<void> {
try {
const data = createSchema.parse(req.body);
const banner = await bannerService.create(data, req.user!.id);
res.status(201).json({ success: true, data: banner });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
return;
}
logger.error('Failed to create banner', { error });
res.status(500).json({ success: false, message: 'Failed to create banner' });
}
}
async delete(req: Request, res: Response): Promise<void> {
try {
const deleted = await bannerService.delete(req.params.id as string);
if (!deleted) {
res.status(404).json({ success: false, message: 'Banner not found' });
return;
}
res.json({ success: true });
} catch (error) {
logger.error('Failed to delete banner', { error });
res.status(500).json({ success: false, message: 'Failed to delete banner' });
}
}
}
export default new BannerController();

View File

@@ -0,0 +1,612 @@
import { Request, Response } from 'express';
import bestellungService from '../services/bestellung.service';
import { permissionService } from '../services/permission.service';
import logger from '../utils/logger';
import fs from 'fs';
// Helper to safely extract a route param as string
const param = (req: Request, key: string): string => req.params[key] as string;
class BestellungController {
// ---------------------------------------------------------------------------
// Members
// ---------------------------------------------------------------------------
async listMembers(_req: Request, res: Response): Promise<void> {
try {
const members = await bestellungService.getAllMembers();
res.status(200).json({ success: true, data: members });
} catch (error) {
logger.error('BestellungController.listMembers error', { error });
res.status(500).json({ success: false, message: 'Mitglieder konnten nicht geladen werden' });
}
}
// ---------------------------------------------------------------------------
// Catalog (shared ausruestung_artikel)
// ---------------------------------------------------------------------------
async listKatalogItems(req: Request, res: Response): Promise<void> {
try {
const search = req.query.search as string | undefined;
const kategorie = req.query.kategorie as string | undefined;
const items = await bestellungService.getKatalogItems({ search, kategorie });
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('BestellungController.listKatalogItems error', { error });
res.status(500).json({ success: false, message: 'Katalogartikel konnten nicht geladen werden' });
}
}
async getKatalogItem(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'itemId'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const item = await bestellungService.getKatalogItem(id);
if (!item) {
res.status(404).json({ success: false, message: 'Katalogartikel nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('BestellungController.getKatalogItem error', { error });
res.status(500).json({ success: false, message: 'Katalogartikel konnte nicht geladen werden' });
}
}
async listKatalogKategorien(_req: Request, res: Response): Promise<void> {
try {
const kategorien = await bestellungService.getKatalogKategorien();
res.status(200).json({ success: true, data: kategorien });
} catch (error) {
logger.error('BestellungController.listKatalogKategorien error', { error });
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
}
}
// ---------------------------------------------------------------------------
// Vendors
// ---------------------------------------------------------------------------
async listVendors(_req: Request, res: Response): Promise<void> {
try {
const vendors = await bestellungService.getVendors();
res.status(200).json({ success: true, data: vendors });
} catch (error) {
logger.error('BestellungController.listVendors error', { error });
res.status(500).json({ success: false, message: 'Lieferanten konnten nicht geladen werden' });
}
}
async getVendor(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 vendor = await bestellungService.getVendorById(id);
if (!vendor) {
res.status(404).json({ success: false, message: 'Lieferant nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: vendor });
} catch (error) {
logger.error('BestellungController.getVendor error', { error });
res.status(500).json({ success: false, message: 'Lieferant konnte nicht geladen werden' });
}
}
async getVendorOrders(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 orders = await bestellungService.getOrders({ lieferant_id: id });
res.status(200).json({ success: true, data: orders });
} catch (error) {
logger.error('BestellungController.getVendorOrders error', { error });
res.status(500).json({ success: false, message: 'Bestellungen konnten nicht geladen werden' });
}
}
async createVendor(req: Request, res: Response): Promise<void> {
const { name } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
try {
const vendor = await bestellungService.createVendor(req.body, req.user!.id);
res.status(201).json({ success: true, data: vendor });
} catch (error) {
logger.error('BestellungController.createVendor error', { error });
res.status(500).json({ success: false, message: 'Lieferant konnte nicht erstellt werden' });
}
}
async updateVendor(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 vendor = await bestellungService.updateVendor(id, req.body, req.user!.id);
if (!vendor) {
res.status(404).json({ success: false, message: 'Lieferant nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: vendor });
} catch (error) {
logger.error('BestellungController.updateVendor error', { error });
res.status(500).json({ success: false, message: 'Lieferant konnte nicht aktualisiert werden' });
}
}
async deleteVendor(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 deleted = await bestellungService.deleteVendor(id);
if (!deleted) {
res.status(404).json({ success: false, message: 'Lieferant nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Lieferant gelöscht' });
} catch (error) {
logger.error('BestellungController.deleteVendor error', { error });
res.status(500).json({ success: false, message: 'Lieferant konnte nicht gelöscht werden' });
}
}
// ---------------------------------------------------------------------------
// Orders
// ---------------------------------------------------------------------------
async listOrders(req: Request, res: Response): Promise<void> {
try {
const filters: { status?: string; lieferant_id?: number; besteller_id?: string } = {};
if (req.query.status) filters.status = req.query.status as string;
if (req.query.lieferant_id) filters.lieferant_id = parseInt(req.query.lieferant_id as string, 10);
if (req.query.besteller_id) filters.besteller_id = req.query.besteller_id as string;
const orders = await bestellungService.getOrders(filters);
res.status(200).json({ success: true, data: orders });
} catch (error) {
logger.error('BestellungController.listOrders error', { error });
res.status(500).json({ success: false, message: 'Bestellungen konnten nicht geladen werden' });
}
}
async getOrder(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 order = await bestellungService.getOrderById(id);
if (!order) {
res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: order });
} catch (error) {
logger.error('BestellungController.getOrder error', { error });
res.status(500).json({ success: false, message: 'Bestellung konnte nicht geladen werden' });
}
}
async createOrder(req: Request, res: Response): Promise<void> {
const { bezeichnung, lieferant_id, budget, besteller_id, positionen, mitglied_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;
}
if (mitglied_id != null && mitglied_id !== '' && (typeof mitglied_id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(mitglied_id))) {
res.status(400).json({ success: false, message: 'Ungültige Mitglied-ID' });
return;
}
if (positionen != null && !Array.isArray(positionen)) {
res.status(400).json({ success: false, message: 'Positionen muss ein Array sein' });
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: error instanceof Error ? error.message : 'Bestellung konnte nicht erstellt werden' });
}
}
async updateOrder(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 order = await bestellungService.updateOrder(id, req.body, req.user!.id);
if (!order) {
res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: order });
} catch (error) {
logger.error('BestellungController.updateOrder error', { error });
res.status(500).json({ success: false, message: 'Bestellung konnte nicht aktualisiert werden' });
}
}
async deleteOrder(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 deleted = await bestellungService.deleteOrder(id, req.user!.id);
if (!deleted) {
res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Bestellung gelöscht' });
} catch (error) {
logger.error('BestellungController.deleteOrder error', { error });
res.status(500).json({ success: false, message: 'Bestellung konnte nicht gelöscht werden' });
}
}
async updateStatus(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;
}
const { status, force } = req.body;
if (!status || typeof status !== 'string') {
res.status(400).json({ success: false, message: 'Status ist erforderlich' });
return;
}
try {
// For force override, require manage_orders
if (force) {
const canManage = permissionService.hasPermission(req.user!.groups || [], 'bestellungen:manage_orders');
if (!canManage) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für manuelle Statusänderung' });
return;
}
}
// For approval/rejection transitions, require bestellungen:approve
if (status === 'bereit_zur_bestellung' || status === 'entwurf') {
// Check if this is an approval/rejection (from wartet_auf_genehmigung)
const currentOrder = await bestellungService.getOrderById(id);
if (!currentOrder) {
res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' });
return;
}
const currentStatus = currentOrder.bestellung.status;
if (currentStatus === 'wartet_auf_genehmigung') {
const canApprove = permissionService.hasPermission(req.user!.groups || [], 'bestellungen:approve');
if (!canApprove) {
res.status(403).json({ success: false, message: 'Keine Berechtigung zur Genehmigung/Ablehnung von Bestellungen' });
return;
}
}
}
const order = await bestellungService.updateOrderStatus(id, status, req.user!.id, !!force, req.user!.id);
if (!order) {
res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: order });
} catch (error: any) {
if (error.message?.includes('Ungültiger Statusübergang')) {
res.status(400).json({ success: false, message: error.message });
return;
}
logger.error('BestellungController.updateStatus error', { error });
res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' });
}
}
// ---------------------------------------------------------------------------
// Line Items
// ---------------------------------------------------------------------------
async addLineItem(req: Request, res: Response): Promise<void> {
const bestellungId = parseInt(param(req, 'id'), 10);
if (isNaN(bestellungId)) {
res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' });
return;
}
const { bezeichnung, menge } = req.body;
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
return;
}
if (menge === undefined || menge === null || menge <= 0) {
res.status(400).json({ success: false, message: 'Menge muss größer als 0 sein' });
return;
}
try {
const item = await bestellungService.addLineItem(bestellungId, req.body, req.user!.id);
res.status(201).json({ success: true, data: item });
} catch (error) {
logger.error('BestellungController.addLineItem error', { error });
res.status(500).json({ success: false, message: 'Position konnte nicht hinzugefügt werden' });
}
}
async updateLineItem(req: Request, res: Response): Promise<void> {
const itemId = parseInt(param(req, 'itemId'), 10);
if (isNaN(itemId)) {
res.status(400).json({ success: false, message: 'Ungültige Position-ID' });
return;
}
try {
const item = await bestellungService.updateLineItem(itemId, req.body, req.user!.id);
if (!item) {
res.status(404).json({ success: false, message: 'Position nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('BestellungController.updateLineItem error', { error });
res.status(500).json({ success: false, message: 'Position konnte nicht aktualisiert werden' });
}
}
async deleteLineItem(req: Request, res: Response): Promise<void> {
const itemId = parseInt(param(req, 'itemId'), 10);
if (isNaN(itemId)) {
res.status(400).json({ success: false, message: 'Ungültige Position-ID' });
return;
}
try {
const deleted = await bestellungService.deleteLineItem(itemId, req.user!.id);
if (!deleted) {
res.status(404).json({ success: false, message: 'Position nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Position gelöscht' });
} catch (error) {
logger.error('BestellungController.deleteLineItem error', { error });
res.status(500).json({ success: false, message: 'Position konnte nicht gelöscht werden' });
}
}
async updateReceivedQuantity(req: Request, res: Response): Promise<void> {
const itemId = parseInt(param(req, 'itemId'), 10);
if (isNaN(itemId)) {
res.status(400).json({ success: false, message: 'Ungültige Position-ID' });
return;
}
const { menge } = req.body;
if (menge === undefined || menge === null || menge < 0) {
res.status(400).json({ success: false, message: 'Erhaltene Menge muss >= 0 sein' });
return;
}
try {
const item = await bestellungService.updateReceivedQuantity(itemId, menge, req.user!.id);
if (!item) {
res.status(404).json({ success: false, message: 'Position nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error: any) {
if (error.statusCode === 400) {
res.status(400).json({ success: false, message: error.message });
return;
}
logger.error('BestellungController.updateReceivedQuantity error', { error });
res.status(500).json({ success: false, message: 'Liefermenge konnte nicht aktualisiert werden' });
}
}
// ---------------------------------------------------------------------------
// Files
// ---------------------------------------------------------------------------
async uploadFile(req: Request, res: Response): Promise<void> {
const bestellungId = parseInt(param(req, 'id'), 10);
if (isNaN(bestellungId)) {
res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' });
return;
}
const file = (req as any).file;
if (!file) {
res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' });
return;
}
try {
const fileRecord = await bestellungService.addFile(bestellungId, {
dateiname: file.originalname,
dateipfad: file.path,
dateityp: file.mimetype,
dateigroesse: file.size,
}, req.user!.id);
res.status(201).json({ success: true, data: fileRecord });
} catch (error) {
logger.error('BestellungController.uploadFile error', { error });
res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' });
}
}
async deleteFile(req: Request, res: Response): Promise<void> {
const fileId = parseInt(param(req, 'fileId'), 10);
if (isNaN(fileId)) {
res.status(400).json({ success: false, message: 'Ungültige Datei-ID' });
return;
}
try {
const result = await bestellungService.deleteFile(fileId, req.user!.id);
if (!result) {
res.status(404).json({ success: false, message: 'Datei nicht gefunden' });
return;
}
// Remove from disk
try {
if (result.dateipfad && fs.existsSync(result.dateipfad)) {
fs.unlinkSync(result.dateipfad);
}
} catch (err) {
logger.warn('Failed to delete file from disk', { path: result.dateipfad, error: err });
}
res.status(200).json({ success: true, message: 'Datei gelöscht' });
} catch (error) {
logger.error('BestellungController.deleteFile error', { error });
res.status(500).json({ success: false, message: 'Datei konnte nicht gelöscht werden' });
}
}
async listFiles(req: Request, res: Response): Promise<void> {
const bestellungId = parseInt(param(req, 'id'), 10);
if (isNaN(bestellungId)) {
res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' });
return;
}
try {
const files = await bestellungService.getFilesByOrder(bestellungId);
res.status(200).json({ success: true, data: files });
} catch (error) {
logger.error('BestellungController.listFiles error', { error });
res.status(500).json({ success: false, message: 'Dateien konnten nicht geladen werden' });
}
}
// ---------------------------------------------------------------------------
// Reminders
// ---------------------------------------------------------------------------
async addReminder(req: Request, res: Response): Promise<void> {
const bestellungId = parseInt(param(req, 'id'), 10);
if (isNaN(bestellungId)) {
res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' });
return;
}
const { faellig_am } = req.body;
if (!faellig_am) {
res.status(400).json({ success: false, message: 'Fälligkeitsdatum ist erforderlich' });
return;
}
try {
const reminder = await bestellungService.addReminder(bestellungId, req.body, req.user!.id);
res.status(201).json({ success: true, data: reminder });
} catch (error) {
logger.error('BestellungController.addReminder error', { error });
res.status(500).json({ success: false, message: 'Erinnerung konnte nicht erstellt werden' });
}
}
async markReminderDone(req: Request, res: Response): Promise<void> {
const remId = parseInt(param(req, 'remId'), 10);
if (isNaN(remId)) {
res.status(400).json({ success: false, message: 'Ungültige Erinnerungs-ID' });
return;
}
try {
const reminder = await bestellungService.markReminderDone(remId, req.user!.id);
if (!reminder) {
res.status(404).json({ success: false, message: 'Erinnerung nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: reminder });
} catch (error) {
logger.error('BestellungController.markReminderDone error', { error });
res.status(500).json({ success: false, message: 'Erinnerung konnte nicht als erledigt markiert werden' });
}
}
async deleteReminder(req: Request, res: Response): Promise<void> {
const remId = parseInt(param(req, 'remId'), 10);
if (isNaN(remId)) {
res.status(400).json({ success: false, message: 'Ungültige Erinnerungs-ID' });
return;
}
try {
const deleted = await bestellungService.deleteReminder(remId);
if (!deleted) {
res.status(404).json({ success: false, message: 'Erinnerung nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Erinnerung gelöscht' });
} catch (error) {
logger.error('BestellungController.deleteReminder error', { error });
res.status(500).json({ success: false, message: 'Erinnerung konnte nicht gelöscht werden' });
}
}
// ---------------------------------------------------------------------------
// History
// ---------------------------------------------------------------------------
async getHistory(req: Request, res: Response): Promise<void> {
const bestellungId = parseInt(param(req, 'id'), 10);
if (isNaN(bestellungId)) {
res.status(400).json({ success: false, message: 'Ungültige Bestellungs-ID' });
return;
}
try {
const history = await bestellungService.getHistory(bestellungId);
res.status(200).json({ success: true, data: history });
} catch (error) {
logger.error('BestellungController.getHistory error', { error });
res.status(500).json({ success: false, message: 'Historie konnte nicht geladen werden' });
}
}
// ---------------------------------------------------------------------------
// Export (placeholder — returns order detail as JSON for now)
// ---------------------------------------------------------------------------
async exportOrder(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 order = await bestellungService.getOrderById(id);
if (!order) {
res.status(404).json({ success: false, message: 'Bestellung nicht gefunden' });
return;
}
const orderStatus = order.bestellung.status;
if (orderStatus === 'entwurf' || orderStatus === 'wartet_auf_genehmigung') {
res.status(403).json({ success: false, message: 'Export nur nach Genehmigung verfügbar' });
return;
}
res.status(200).json({ success: true, data: order });
} catch (error) {
logger.error('BestellungController.exportOrder error', { error });
res.status(500).json({ success: false, message: 'Export fehlgeschlagen' });
}
}
}
export default new BestellungController();

View File

@@ -0,0 +1,330 @@
import { Request, Response } from 'express';
import { ZodError } from 'zod';
import bookingService from '../services/booking.service';
import vehicleService from '../services/vehicle.service';
import pool from '../config/database';
import { permissionService } from '../services/permission.service';
import {
CreateBuchungSchema,
UpdateBuchungSchema,
CancelBuchungSchema,
} from '../models/booking.model';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
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);
}
function handleZodError(res: Response, err: ZodError): void {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: err.flatten().fieldErrors,
});
}
function handleConflictError(res: Response, err: Error): boolean {
if (err.message?.includes('außer Dienst')) {
res.status(409).json({ success: false, message: err.message, reason: 'out_of_service' });
return true;
}
if (err.message?.includes('bereits gebucht')) {
res.status(409).json({ success: false, message: err.message, reason: 'booking_conflict' });
return true;
}
return false;
}
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
class BookingController {
/**
* GET /api/bookings/vehicles
* Lightweight vehicle list for the booking form (no fahrzeuge:view needed).
*/
async getVehiclesForBooking(_req: Request, res: Response): Promise<void> {
try {
const result = await pool.query(
'SELECT id, bezeichnung, amtliches_kennzeichen FROM fahrzeuge ORDER BY bezeichnung'
);
res.json({ success: true, data: result.rows });
} catch (err) {
logger.error('Failed to fetch vehicles for booking', err);
res.status(500).json({ success: false, message: 'Fehler beim Laden der Fahrzeuge' });
}
}
/**
* GET /api/bookings/calendar?from=&to=&fahrzeugId=
* Returns all non-cancelled bookings overlapping the given date range.
*/
async getCalendarRange(req: Request, res: Response): Promise<void> {
try {
const { from, to, fahrzeugId } = req.query;
if (!from || !to) {
res.status(400).json({ success: false, message: 'from und to sind erforderlich' });
return;
}
const fromDate = new Date(from as string);
const toDate = new Date(to as string);
const [bookings, maintenanceWindows] = await Promise.all([
bookingService.getBookingsByRange(fromDate, toDate, fahrzeugId as string | undefined),
vehicleService.getMaintenanceWindows(fromDate, toDate),
]);
res.json({ success: true, data: { bookings, maintenanceWindows } });
} catch (error) {
logger.error('Booking getCalendarRange error', { error });
res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' });
}
}
/**
* GET /api/bookings/upcoming?limit=
* Returns the next upcoming non-cancelled bookings.
*/
async getUpcoming(req: Request, res: Response): Promise<void> {
try {
const limit = parseInt(req.query.limit as string) || 20;
const bookings = await bookingService.getUpcoming(limit);
res.json({ success: true, data: bookings });
} catch (error) {
logger.error('Booking getUpcoming error', { error });
res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' });
}
}
/**
* GET /api/bookings/availability?fahrzeugId=&from=&to=
* Returns { available: true } when the vehicle has no conflicting booking.
*/
async checkAvailability(req: Request, res: Response): Promise<void> {
try {
const { fahrzeugId, from, to, excludeId } = req.query;
if (!fahrzeugId || !from || !to) {
res
.status(400)
.json({ success: false, message: 'fahrzeugId, from und to sind erforderlich' });
return;
}
const beginn = new Date(from as string);
const ende = new Date(to as string);
const outOfService = await bookingService.checkOutOfServiceConflict(
fahrzeugId as string, beginn, ende
);
if (outOfService) {
res.json({
success: true,
data: {
available: false,
reason: 'out_of_service',
ausserDienstVon: outOfService.ausser_dienst_von.toISOString(),
ausserDienstBis: outOfService.ausser_dienst_bis.toISOString(),
},
});
return;
}
const hasConflict = await bookingService.checkConflict(
fahrzeugId as string, beginn, ende, excludeId as string | undefined
);
res.json({ success: true, data: { available: !hasConflict } });
} catch (error) {
logger.error('Booking checkAvailability error', { error });
res.status(500).json({ success: false, message: 'Verfügbarkeit konnte nicht geprüft werden' });
}
}
/**
* GET /api/bookings/:id
* Returns a single booking with all joined fields.
*/
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 Buchungs-ID' });
return;
}
const booking = await bookingService.getById(id);
if (!booking) {
res.status(404).json({ success: false, message: 'Buchung nicht gefunden' });
return;
}
res.json({ success: true, data: booking });
} catch (error) {
logger.error('Booking getById error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Buchung konnte nicht geladen werden' });
}
}
/**
* POST /api/bookings
* Creates a new vehicle booking.
*/
async create(req: Request, res: Response): Promise<void> {
try {
const parsed = CreateBuchungSchema.safeParse(req.body);
if (!parsed.success) {
handleZodError(res, parsed.error);
return;
}
const booking = await bookingService.create(parsed.data, req.user!.id, req.body.ignoreOutOfService === true);
res.status(201).json({ success: true, data: booking });
} catch (error: any) {
if (handleConflictError(res, error)) return;
logger.error('Booking create error', { error });
res.status(500).json({ success: false, message: 'Buchung konnte nicht erstellt werden' });
}
}
/**
* PATCH /api/bookings/:id
* Updates the provided fields of an existing booking.
*/
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 Buchungs-ID' });
return;
}
const parsed = UpdateBuchungSchema.safeParse(req.body);
if (!parsed.success) {
handleZodError(res, parsed.error);
return;
}
if (Object.keys(parsed.data).length === 0) {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
const booking = await bookingService.update(id, parsed.data);
if (!booking) {
res.status(404).json({ success: false, message: 'Buchung nicht gefunden' });
return;
}
res.json({ success: true, data: booking });
} catch (error: any) {
if (error?.message === 'No fields to update') {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
if (handleConflictError(res, error)) return;
logger.error('Booking update error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Buchung konnte nicht aktualisiert werden' });
}
}
/**
* DELETE /api/bookings/:id or PATCH /api/bookings/:id/cancel
* Soft-cancels a booking (sets abgesagt=TRUE).
* Allowed for booking creator or users with bookings:write permission.
*/
async cancel(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 Buchungs-ID' });
return;
}
// Check ownership: creator can cancel if they have cancel_own_bookings permission
const booking = await bookingService.getById(id);
if (!booking) {
res.status(404).json({ success: false, message: 'Buchung nicht gefunden' });
return;
}
const isOwner = booking.gebucht_von === req.user!.id;
const groups: string[] = req.user?.groups ?? [];
const isAdmin = groups.includes('dashboard_admin');
const canCancelOwn = isAdmin || permissionService.hasPermission(groups, 'fahrzeugbuchungen:manage');
const canCancelAny = isAdmin || permissionService.hasPermission(groups, 'fahrzeugbuchungen:manage');
if (!(isOwner && canCancelOwn) && !canCancelAny) {
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
const parsed = CancelBuchungSchema.safeParse(req.body);
if (!parsed.success) {
handleZodError(res, parsed.error);
return;
}
await bookingService.cancel(id, parsed.data.abgesagt_grund);
res.json({ success: true, message: 'Buchung wurde storniert' });
} catch (error) {
logger.error('Booking cancel error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Buchung konnte nicht storniert werden' });
}
}
/**
* DELETE /api/bookings/:id/force
* Hard-deletes a booking record (admin only).
*/
async hardDelete(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 Buchungs-ID' });
return;
}
await bookingService.delete(id);
res.json({ success: true, message: 'Buchung gelöscht' });
} catch (error) {
logger.error('Booking hardDelete error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Buchung konnte nicht gelöscht werden' });
}
}
/**
* GET /api/bookings/calendar-token
* Returns the user's iCal subscribe token and URL, creating it if needed.
*/
async getCalendarToken(req: Request, res: Response): Promise<void> {
try {
const result = await bookingService.getOrCreateIcalToken(req.user!.id);
res.json({ success: true, data: result });
} catch (error) {
logger.error('Booking getCalendarToken error', { error });
res.status(500).json({ success: false, message: 'Kalender-Token konnte nicht geladen werden' });
}
}
/**
* GET /api/bookings/calendar.ics?token=&fahrzeugId=
* Returns an iCal file for the subscriber. No authentication required
* (token-based access).
*/
async getIcalExport(req: Request, res: Response): Promise<void> {
try {
const { token, fahrzeugId } = req.query;
if (!token) {
res.status(400).send('Token required');
return;
}
const ical = await bookingService.getIcalExport(
token as string,
fahrzeugId as string | undefined
);
if (!ical) {
res.status(404).send('Invalid token');
return;
}
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="fahrzeugbuchungen.ics"');
res.send(ical);
} catch (error) {
logger.error('Booking getIcalExport error', { error });
res.status(500).send('Internal server error');
}
}
}
export default new BookingController();

View File

@@ -0,0 +1,63 @@
import { Request, Response } from 'express';
import bookstackService from '../services/bookstack.service';
import environment from '../config/environment';
import logger from '../utils/logger';
class BookStackController {
async getRecent(_req: Request, res: Response): Promise<void> {
if (!environment.bookstack.url) {
res.status(200).json({ success: true, data: [], configured: false });
return;
}
try {
const pages = await bookstackService.getRecentPages();
res.status(200).json({ success: true, data: pages, configured: true });
} catch (error) {
logger.error('BookStackController.getRecent error', { error });
res.status(500).json({ success: false, message: 'BookStack konnte nicht abgefragt werden' });
}
}
async search(req: Request, res: Response): Promise<void> {
if (!environment.bookstack.url) {
res.status(200).json({ success: true, data: [], configured: false });
return;
}
const query = req.query.query as string | undefined;
if (!query || query.trim().length === 0) {
res.status(400).json({ success: false, message: 'Suchbegriff fehlt' });
return;
}
if (query.trim().length > 500) {
res.status(400).json({ success: false, message: 'Suchanfrage zu lang' });
return;
}
try {
const results = await bookstackService.searchPages(query.trim());
res.status(200).json({ success: true, data: results, configured: true });
} catch (error) {
logger.error('BookStackController.search error', { error });
res.status(500).json({ success: false, message: 'BookStack-Suche fehlgeschlagen' });
}
}
async getPage(req: Request, res: Response): Promise<void> {
if (!environment.bookstack.url) {
res.status(200).json({ success: true, data: null, configured: false });
return;
}
const id = parseInt(String(req.params.id), 10);
if (isNaN(id) || id <= 0) {
res.status(400).json({ success: false, message: 'Ungültige Seiten-ID' });
return;
}
try {
const page = await bookstackService.getPageById(id);
res.status(200).json({ success: true, data: page, configured: true });
} catch (error) {
logger.error('BookStackController.getPage error', { error });
res.status(500).json({ success: false, message: 'BookStack-Seite konnte nicht geladen werden' });
}
}
}
export default new BookStackController();

View File

@@ -0,0 +1,761 @@
import { Request, Response } from 'express';
import buchhaltungService from '../services/buchhaltung.service';
import logger from '../utils/logger';
const param = (req: Request, key: string): string => req.params[key] as string;
class BuchhaltungController {
// ── Kategorien ──────────────────────────────────────────────────────────────
async listKategorien(req: Request, res: Response): Promise<void> {
const haushaltsjahrId = parseInt(req.query.haushaltsjahr_id as string, 10);
if (isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; }
try {
const data = await buchhaltungService.getKategorien(haushaltsjahrId);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listKategorien', { error });
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
}
}
async createKategorie(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createKategorie(req.body);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createKategorie', { error });
res.status(500).json({ success: false, message: 'Kategorie konnte nicht erstellt werden' });
}
}
async updateKategorie(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 data = await buchhaltungService.updateKategorie(id, req.body);
if (!data) { res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updateKategorie', { error });
res.status(500).json({ success: false, message: 'Kategorie konnte nicht aktualisiert werden' });
}
}
async deleteKategorie(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 {
await buchhaltungService.deleteKategorie(id);
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deleteKategorie', { error });
res.status(500).json({ success: false, message: 'Kategorie konnte nicht gelöscht werden' });
}
}
// ── Haushaltsjahre ──────────────────────────────────────────────────────────
async listHaushaltsjahre(_req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.getAllHaushaltsjahre();
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listHaushaltsjahre', { error });
res.status(500).json({ success: false, message: 'Haushaltsjahre konnten nicht geladen werden' });
}
}
async createHaushaltsjahr(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createHaushaltsjahr(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createHaushaltsjahr', { error });
res.status(500).json({ success: false, message: 'Haushaltsjahr konnte nicht erstellt werden' });
}
}
async updateHaushaltsjahr(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 data = await buchhaltungService.updateHaushaltsjahr(id, req.body);
if (!data) { res.status(404).json({ success: false, message: 'Haushaltsjahr nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updateHaushaltsjahr', { error });
res.status(500).json({ success: false, message: String(error) });
}
}
async closeHaushaltsjahr(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 data = await buchhaltungService.closeHaushaltsjahr(id);
if (!data) { res.status(404).json({ success: false, message: 'Haushaltsjahr nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.closeHaushaltsjahr', { error });
res.status(400).json({ success: false, message: String(error) });
}
}
// ── Konto-Typen ─────────────────────────────────────────────────────────────
async listKontoTypen(_req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.getAllKontoTypen();
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listKontoTypen', { error });
res.status(500).json({ success: false, message: 'Kontotypen konnten nicht geladen werden' });
}
}
async createKontoTyp(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createKontoTyp(req.body);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createKontoTyp', { error });
res.status(500).json({ success: false, message: 'Kontotyp konnte nicht erstellt werden' });
}
}
async updateKontoTyp(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 data = await buchhaltungService.updateKontoTyp(id, req.body);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updateKontoTyp', { error });
res.status(500).json({ success: false, message: 'Kontotyp konnte nicht aktualisiert werden' });
}
}
async deleteKontoTyp(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 {
await buchhaltungService.deleteKontoTyp(id);
res.json({ success: true });
} catch (error) {
const statusCode = (error as any).statusCode;
if (statusCode === 409) {
res.status(409).json({ success: false, message: (error as Error).message });
return;
}
logger.error('BuchhaltungController.deleteKontoTyp', { error });
res.status(500).json({ success: false, message: 'Kontotyp konnte nicht gelöscht werden' });
}
}
// ── Bankkonten ───────────────────────────────────────────────────────────────
async listBankkonten(_req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.getAllBankkonten();
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listBankkonten', { error });
res.status(500).json({ success: false, message: 'Bankkonten konnten nicht geladen werden' });
}
}
async createBankkonto(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createBankkonto(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createBankkonto', { error });
res.status(500).json({ success: false, message: 'Bankkonto konnte nicht erstellt werden' });
}
}
async updateBankkonto(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 data = await buchhaltungService.updateBankkonto(id, req.body);
if (!data) { res.status(404).json({ success: false, message: 'Bankkonto nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updateBankkonto', { error });
res.status(500).json({ success: false, message: 'Bankkonto konnte nicht aktualisiert werden' });
}
}
async deleteBankkonto(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 {
await buchhaltungService.deactivateBankkonto(id);
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deleteBankkonto', { error });
res.status(500).json({ success: false, message: 'Bankkonto konnte nicht deaktiviert werden' });
}
}
async getBankkontoStatement(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 filters = {
von: req.query.von as string | undefined,
bis: req.query.bis as string | undefined,
};
const data = await buchhaltungService.getBankkontoStatement(id, filters);
if (!data) { res.status(404).json({ success: false, message: 'Bankkonto nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getBankkontoStatement', { error });
res.status(500).json({ success: false, message: 'Kontoauszug konnte nicht geladen werden' });
}
}
// ── Konten ───────────────────────────────────────────────────────────────────
async listKonten(req: Request, res: Response): Promise<void> {
const haushaltsjahrId = parseInt(req.query.haushaltsjahr_id as string, 10);
if (isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; }
try {
const data = await buchhaltungService.getAllKonten(haushaltsjahrId);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listKonten', { error });
res.status(500).json({ success: false, message: 'Konten konnten nicht geladen werden' });
}
}
async getKontenTree(req: Request, res: Response): Promise<void> {
const haushaltsjahrId = req.query.haushaltsjahr_id ? parseInt(req.query.haushaltsjahr_id as string, 10) : undefined;
if (!haushaltsjahrId || isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; }
try {
const tree = await buchhaltungService.getKontenTree(haushaltsjahrId);
res.json(tree);
} catch (error) {
logger.error('BuchhaltungController.getKontenTree', { error });
res.status(500).json({ error: 'Fehler beim Laden des Kontenbaums' });
}
}
async getKontoDetail(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 detail = await buchhaltungService.getKontoDetail(id);
if (!detail) { res.status(404).json({ error: 'Konto nicht gefunden' }); return; }
res.json(detail);
} catch (error) {
logger.error('BuchhaltungController.getKontoDetail', { error });
res.status(500).json({ error: 'Fehler beim Laden des Kontos' });
}
}
async getPendingCount(req: Request, res: Response): Promise<void> {
try {
const haushaltsjahrId = req.query.haushaltsjahr_id ? parseInt(req.query.haushaltsjahr_id as string, 10) : undefined;
const count = await buchhaltungService.getPendingCount(haushaltsjahrId);
res.json({ count });
} catch (error) {
logger.error('BuchhaltungController.getPendingCount', { error });
res.status(500).json({ error: 'Fehler' });
}
}
async createKonto(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createKonto(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error: any) {
logger.error('BuchhaltungController.createKonto', { error });
const status = error.statusCode || 500;
res.status(status).json({ success: false, message: error.message || 'Konto konnte nicht erstellt werden' });
}
}
async updateKonto(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 data = await buchhaltungService.updateKonto(id, req.body);
if (!data) { res.status(404).json({ success: false, message: 'Konto nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error: any) {
logger.error('BuchhaltungController.updateKonto', { error });
const status = error.statusCode || 500;
res.status(status).json({ success: false, message: error.message || 'Konto konnte nicht aktualisiert werden' });
}
}
async deleteKonto(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 {
await buchhaltungService.deleteKonto(id);
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deleteKonto', { error });
res.status(500).json({ success: false, message: 'Konto konnte nicht gelöscht werden' });
}
}
async getKontoBudget(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 data = await buchhaltungService.getBudgetUtilisation(id);
if (!data) { res.status(404).json({ success: false, message: 'Konto nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getKontoBudget', { error });
res.status(500).json({ success: false, message: 'Budgetauslastung konnte nicht geladen werden' });
}
}
// ── Stats ────────────────────────────────────────────────────────────────────
async getStats(req: Request, res: Response): Promise<void> {
const haushaltsjahrId = parseInt(req.query.haushaltsjahr_id as string, 10);
if (isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; }
try {
const data = await buchhaltungService.getOverview(haushaltsjahrId);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getStats', { error });
res.status(500).json({ success: false, message: 'Statistik konnte nicht geladen werden' });
}
}
// ── Transaktionen ────────────────────────────────────────────────────────────
async listTransaktionen(req: Request, res: Response): Promise<void> {
try {
const filters = {
haushaltsjahr_id: req.query.haushaltsjahr_id ? parseInt(req.query.haushaltsjahr_id as string, 10) : undefined,
konto_id: req.query.konto_id ? parseInt(req.query.konto_id as string, 10) : undefined,
status: req.query.status as string | undefined,
typ: req.query.typ as string | undefined,
datum_von: req.query.datum_von as string | undefined,
datum_bis: req.query.datum_bis as string | undefined,
search: req.query.search as string | undefined,
};
const data = await buchhaltungService.listTransaktionen(filters);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listTransaktionen', { error });
res.status(500).json({ success: false, message: 'Transaktionen konnten nicht geladen werden' });
}
}
async getTransaktion(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 data = await buchhaltungService.getTransaktionById(id);
if (!data) { res.status(404).json({ success: false, message: 'Transaktion nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getTransaktion', { error });
res.status(500).json({ success: false, message: 'Transaktion konnte nicht geladen werden' });
}
}
async createTransaktion(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createTransaktion(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createTransaktion', { error });
res.status(500).json({ success: false, message: 'Transaktion konnte nicht erstellt werden' });
}
}
async updateTransaktion(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 data = await buchhaltungService.updateTransaktion(id, req.body, req.user!.id);
if (!data) { res.status(404).json({ success: false, message: 'Transaktion nicht gefunden oder nicht mehr bearbeitbar' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updateTransaktion', { error });
res.status(500).json({ success: false, message: 'Transaktion konnte nicht aktualisiert werden' });
}
}
async deleteTransaktion(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 deleted = await buchhaltungService.deleteTransaktion(id);
if (!deleted) { res.status(404).json({ success: false, message: 'Transaktion nicht gefunden oder nicht löschbar' }); return; }
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deleteTransaktion', { error });
res.status(500).json({ success: false, message: 'Transaktion konnte nicht gelöscht werden' });
}
}
async buchenTransaktion(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 data = await buchhaltungService.bookTransaktion(id, req.user!.id);
if (!data) { res.status(404).json({ success: false, message: 'Transaktion nicht gefunden oder bereits gebucht' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.buchenTransaktion', { error });
res.status(500).json({ success: false, message: 'Transaktion konnte nicht gebucht werden' });
}
}
async stornoTransaktion(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 data = await buchhaltungService.stornoTransaktion(id, req.user!.id);
if (!data) { res.status(404).json({ success: false, message: 'Transaktion nicht stornierbar' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.stornoTransaktion', { error });
res.status(500).json({ success: false, message: 'Transaktion konnte nicht storniert werden' });
}
}
// ── Transfers ─────────────────────────────────────────────────────────────────
async createTransfer(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createTransfer(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createTransfer', { error });
res.status(500).json({ success: false, message: 'Umbuchung konnte nicht erstellt werden' });
}
}
// ── Belege ───────────────────────────────────────────────────────────────────
async uploadBeleg(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; }
if (!req.file) { res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' }); return; }
try {
const f = req.file as Express.Multer.File;
const data = await buchhaltungService.uploadBeleg(id, { filename: f.filename, originalname: f.originalname, mimetype: f.mimetype, size: f.size }, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.uploadBeleg', { error });
res.status(500).json({ success: false, message: 'Beleg konnte nicht hochgeladen werden' });
}
}
async deleteBeleg(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 deleted = await buchhaltungService.deleteBeleg(id);
if (!deleted) { res.status(404).json({ success: false, message: 'Beleg nicht gefunden' }); return; }
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deleteBeleg', { error });
res.status(500).json({ success: false, message: 'Beleg konnte nicht gelöscht werden' });
}
}
// ── Einstellungen ────────────────────────────────────────────────────────────
async getEinstellungen(_req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.getEinstellungen();
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getEinstellungen', { error });
res.status(500).json({ success: false, message: 'Einstellungen konnten nicht geladen werden' });
}
}
async setEinstellungen(req: Request, res: Response): Promise<void> {
try {
await buchhaltungService.setEinstellungen(req.body);
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.setEinstellungen', { error });
res.status(500).json({ success: false, message: 'Einstellungen konnten nicht gespeichert werden' });
}
}
// ── Wiederkehrend ────────────────────────────────────────────────────────────
async listWiederkehrend(_req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.getAllWiederkehrend();
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listWiederkehrend', { error });
res.status(500).json({ success: false, message: 'Wiederkehrende Buchungen konnten nicht geladen werden' });
}
}
async createWiederkehrend(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createWiederkehrend(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createWiederkehrend', { error });
res.status(500).json({ success: false, message: 'Wiederkehrende Buchung konnte nicht erstellt werden' });
}
}
async updateWiederkehrend(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 data = await buchhaltungService.updateWiederkehrend(id, req.body);
if (!data) { res.status(404).json({ success: false, message: 'Wiederkehrende Buchung nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updateWiederkehrend', { error });
res.status(500).json({ success: false, message: 'Wiederkehrende Buchung konnte nicht aktualisiert werden' });
}
}
async deleteWiederkehrend(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 {
await buchhaltungService.deleteWiederkehrend(id);
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deleteWiederkehrend', { error });
res.status(500).json({ success: false, message: 'Wiederkehrende Buchung konnte nicht gelöscht werden' });
}
}
// ── CSV Export ───────────────────────────────────────────────────────────────
async exportCsv(req: Request, res: Response): Promise<void> {
const haushaltsjahrId = parseInt(req.query.haushaltsjahr_id as string, 10);
if (isNaN(haushaltsjahrId)) { res.status(400).json({ success: false, message: 'haushaltsjahr_id erforderlich' }); return; }
try {
const csv = await buchhaltungService.exportTransaktionenCsv(haushaltsjahrId);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="transaktionen_${haushaltsjahrId}.csv"`);
res.send(csv);
} catch (error) {
logger.error('BuchhaltungController.exportCsv', { error });
res.status(500).json({ success: false, message: 'CSV-Export fehlgeschlagen' });
}
}
// ── Audit ────────────────────────────────────────────────────────────────────
async getAudit(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'transaktionId'), 10);
if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.getAuditByTransaktion(id);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getAudit', { error });
res.status(500).json({ success: false, message: 'Audit konnte nicht geladen werden' });
}
}
// ── Erstattungen ────────────────────────────────────────────────────────────
async createErstattung(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createErstattung({
...req.body,
erstellt_von: req.user!.id,
});
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createErstattung', { error });
res.status(500).json({ success: false, message: 'Erstattung konnte nicht erstellt werden' });
}
}
async getErstattungLinks(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 data = await buchhaltungService.getErstattungLinks(id);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getErstattungLinks', { error });
res.status(500).json({ success: false, message: 'Erstattungsverknüpfungen konnten nicht geladen werden' });
}
}
// ── Planung ─────────────────────────────────────────────────────────────────
async listPlanungen(_req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.listPlanungen();
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listPlanungen', { error });
res.status(500).json({ success: false, message: 'Planungen konnten nicht geladen werden' });
}
}
async getPlanung(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 data = await buchhaltungService.getPlanungById(id);
if (!data) { res.status(404).json({ success: false, message: 'Planung nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.getPlanung', { error });
res.status(500).json({ success: false, message: 'Planung konnte nicht geladen werden' });
}
}
async createPlanung(req: Request, res: Response): Promise<void> {
try {
const data = await buchhaltungService.createPlanung(req.body, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createPlanung', { error });
res.status(500).json({ success: false, message: 'Planung konnte nicht erstellt werden' });
}
}
async updatePlanung(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 data = await buchhaltungService.updatePlanung(id, req.body);
if (!data) { res.status(404).json({ success: false, message: 'Planung nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updatePlanung', { error });
res.status(500).json({ success: false, message: 'Planung konnte nicht aktualisiert werden' });
}
}
async deletePlanung(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 {
await buchhaltungService.deletePlanung(id);
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deletePlanung', { error });
res.status(500).json({ success: false, message: 'Planung konnte nicht gelöscht werden' });
}
}
async createPlanposition(req: Request, res: Response): Promise<void> {
const planungId = parseInt(param(req, 'id'), 10);
if (isNaN(planungId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.createPlanposition(planungId, req.body);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.createPlanposition', { error });
res.status(500).json({ success: false, message: 'Planposition konnte nicht erstellt werden' });
}
}
async updatePlanposition(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 data = await buchhaltungService.updatePlanposition(id, req.body);
if (!data) { res.status(404).json({ success: false, message: 'Planposition nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.updatePlanposition', { error });
res.status(500).json({ success: false, message: 'Planposition konnte nicht aktualisiert werden' });
}
}
async deletePlanposition(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 {
await buchhaltungService.deletePlanposition(id);
res.json({ success: true });
} catch (error) {
logger.error('BuchhaltungController.deletePlanposition', { error });
res.status(500).json({ success: false, message: 'Planposition konnte nicht gelöscht werden' });
}
}
async listPlanpositionen(req: Request, res: Response): Promise<void> {
const planungId = parseInt(param(req, 'id'), 10);
if (isNaN(planungId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; }
try {
const data = await buchhaltungService.getPlanpositionenByPlanung(planungId);
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.listPlanpositionen', { error });
res.status(500).json({ success: false, message: 'Planpositionen konnten nicht geladen werden' });
}
}
async createHaushaltsjahrFromPlan(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 data = await buchhaltungService.createHaushaltsjahrFromPlan(id, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error: any) {
logger.error('BuchhaltungController.createHaushaltsjahrFromPlan', { error });
const status = error.statusCode || 500;
res.status(status).json({ success: false, message: error.message || 'Haushaltsjahr konnte nicht erstellt werden' });
}
}
// ── Freigaben ────────────────────────────────────────────────────────────────
async requestFreigabe(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 data = await buchhaltungService.createFreigabe(id, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.requestFreigabe', { error });
res.status(500).json({ success: false, message: 'Freigabe konnte nicht angefragt werden' });
}
}
async approveFreigabe(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 data = await buchhaltungService.approveFreigabe(id, req.body.kommentar, req.user!.id);
if (!data) { res.status(404).json({ success: false, message: 'Freigabe nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.approveFreigabe', { error });
res.status(500).json({ success: false, message: 'Freigabe konnte nicht genehmigt werden' });
}
}
async rejectFreigabe(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 data = await buchhaltungService.rejectFreigabe(id, req.body.kommentar, req.user!.id);
if (!data) { res.status(404).json({ success: false, message: 'Freigabe nicht gefunden' }); return; }
res.json({ success: true, data });
} catch (error) {
logger.error('BuchhaltungController.rejectFreigabe', { error });
res.status(500).json({ success: false, message: 'Freigabe konnte nicht abgelehnt werden' });
}
}
}
export default new BuchhaltungController();

View File

@@ -0,0 +1,101 @@
import { Request, Response } from 'express';
import buchungskategorieService from '../services/buchungskategorie.service';
import logger from '../utils/logger';
const param = (req: Request, key: string): string => req.params[key] as string;
class BuchungsKategorieController {
async list(_req: Request, res: Response): Promise<void> {
try {
const kategorien = await buchungskategorieService.getAll();
res.status(200).json({ success: true, data: kategorien });
} catch (error) {
logger.error('BuchungsKategorieController.list error', { error });
res.status(500).json({ success: false, message: 'Buchungskategorien konnten nicht geladen werden' });
}
}
async listActive(_req: Request, res: Response): Promise<void> {
try {
const kategorien = await buchungskategorieService.getActive();
res.status(200).json({ success: true, data: kategorien });
} catch (error) {
logger.error('BuchungsKategorieController.listActive error', { error });
res.status(500).json({ success: false, message: 'Buchungskategorien konnten nicht geladen werden' });
}
}
async create(req: Request, res: Response): Promise<void> {
const { bezeichnung } = req.body;
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
return;
}
try {
const kategorie = await buchungskategorieService.create(req.body);
res.status(201).json({ success: true, data: kategorie });
} catch (error) {
logger.error('BuchungsKategorieController.create error', { error });
res.status(500).json({ success: false, message: 'Buchungskategorie konnte nicht erstellt werden' });
}
}
async update(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 kategorie = await buchungskategorieService.update(id, req.body);
if (!kategorie) {
res.status(404).json({ success: false, message: 'Buchungskategorie nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: kategorie });
} catch (error) {
logger.error('BuchungsKategorieController.update error', { error });
res.status(500).json({ success: false, message: 'Buchungskategorie konnte nicht aktualisiert werden' });
}
}
async deactivate(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 kategorie = await buchungskategorieService.deactivate(id);
if (!kategorie) {
res.status(404).json({ success: false, message: 'Buchungskategorie nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: kategorie });
} catch (error) {
logger.error('BuchungsKategorieController.deactivate error', { error });
res.status(500).json({ success: false, message: 'Buchungskategorie konnte nicht deaktiviert werden' });
}
}
async remove(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 kategorie = await buchungskategorieService.remove(id);
if (!kategorie) {
res.status(404).json({ success: false, message: 'Buchungskategorie nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: kategorie });
} catch (error) {
logger.error('BuchungsKategorieController.remove error', { error });
res.status(500).json({ success: false, message: 'Buchungskategorie konnte nicht gelöscht werden' });
}
}
}
export default new BuchungsKategorieController();

View File

@@ -0,0 +1,485 @@
import { Request, Response } from 'express';
import checklistService from '../services/checklist.service';
import logger from '../utils/logger';
const param = (req: Request, key: string): string => req.params[key] as string;
class ChecklistController {
// --- Overview ---
async getOverviewItems(_req: Request, res: Response): Promise<void> {
try {
const data = await checklistService.getOverviewItems();
res.status(200).json({ success: true, data });
} catch (error) {
logger.error('ChecklistController.getOverviewItems error', { error });
res.status(500).json({ success: false, message: 'Übersichtsdaten konnten nicht geladen werden' });
}
}
// --- Vorlagen (Templates) ---
async getVorlagen(req: Request, res: Response): Promise<void> {
try {
const filter: { aktiv?: boolean } = {};
if (req.query.aktiv !== undefined) {
filter.aktiv = req.query.aktiv === 'true';
}
const vorlagen = await checklistService.getVorlagen(filter);
res.status(200).json({ success: true, data: vorlagen });
} catch (error) {
logger.error('ChecklistController.getVorlagen error', { error });
res.status(500).json({ success: false, message: 'Vorlagen konnten nicht geladen werden' });
}
}
async getVorlageById(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 vorlage = await checklistService.getVorlageById(id);
if (!vorlage) {
res.status(404).json({ success: false, message: 'Vorlage nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: vorlage });
} catch (error) {
logger.error('ChecklistController.getVorlageById error', { error });
res.status(500).json({ success: false, message: 'Vorlage konnte nicht geladen werden' });
}
}
async createVorlage(req: Request, res: Response): Promise<void> {
const { name } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
try {
const vorlage = await checklistService.createVorlage(req.body);
res.status(201).json({ success: true, data: vorlage });
} catch (error) {
logger.error('ChecklistController.createVorlage error', { error });
res.status(500).json({ success: false, message: 'Vorlage konnte nicht erstellt werden' });
}
}
async updateVorlage(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 vorlage = await checklistService.updateVorlage(id, req.body);
if (!vorlage) {
res.status(404).json({ success: false, message: 'Vorlage nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: vorlage });
} catch (error) {
logger.error('ChecklistController.updateVorlage error', { error });
res.status(500).json({ success: false, message: 'Vorlage konnte nicht aktualisiert werden' });
}
}
async deleteVorlage(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 vorlage = await checklistService.deleteVorlage(id);
if (!vorlage) {
res.status(404).json({ success: false, message: 'Vorlage nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Vorlage gelöscht' });
} catch (error) {
logger.error('ChecklistController.deleteVorlage error', { error });
res.status(500).json({ success: false, message: 'Vorlage konnte nicht gelöscht werden' });
}
}
// --- Vorlage Items ---
async getVorlageItems(req: Request, res: Response): Promise<void> {
const vorlageId = parseInt(param(req, 'id'), 10);
if (isNaN(vorlageId)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const items = await checklistService.getVorlageItems(vorlageId);
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('ChecklistController.getVorlageItems error', { error });
res.status(500).json({ success: false, message: 'Items konnten nicht geladen werden' });
}
}
async addVorlageItem(req: Request, res: Response): Promise<void> {
const vorlageId = parseInt(param(req, 'id'), 10);
if (isNaN(vorlageId)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
const { bezeichnung } = req.body;
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
return;
}
try {
const item = await checklistService.addVorlageItem(vorlageId, req.body);
res.status(201).json({ success: true, data: item });
} catch (error) {
logger.error('ChecklistController.addVorlageItem error', { error });
res.status(500).json({ success: false, message: 'Item konnte nicht erstellt werden' });
}
}
async updateVorlageItem(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 item = await checklistService.updateVorlageItem(id, req.body);
if (!item) {
res.status(404).json({ success: false, message: 'Item nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('ChecklistController.updateVorlageItem error', { error });
res.status(500).json({ success: false, message: 'Item konnte nicht aktualisiert werden' });
}
}
async deleteVorlageItem(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 item = await checklistService.deleteVorlageItem(id);
if (!item) {
res.status(404).json({ success: false, message: 'Item nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Item gelöscht' });
} catch (error) {
logger.error('ChecklistController.deleteVorlageItem error', { error });
res.status(500).json({ success: false, message: 'Item konnte nicht gelöscht werden' });
}
}
// --- Vehicle-specific items ---
async getVehicleItems(req: Request, res: Response): Promise<void> {
const fahrzeugId = param(req, 'fahrzeugId');
if (!fahrzeugId) {
res.status(400).json({ success: false, message: 'Fahrzeug-ID erforderlich' });
return;
}
try {
const items = await checklistService.getVehicleItems(fahrzeugId);
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('ChecklistController.getVehicleItems error', { error });
res.status(500).json({ success: false, message: 'Items konnten nicht geladen werden' });
}
}
async addVehicleItem(req: Request, res: Response): Promise<void> {
const fahrzeugId = param(req, 'fahrzeugId');
if (!fahrzeugId) {
res.status(400).json({ success: false, message: 'Fahrzeug-ID erforderlich' });
return;
}
const { bezeichnung } = req.body;
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
return;
}
try {
const item = await checklistService.addVehicleItem(fahrzeugId, req.body);
res.status(201).json({ success: true, data: item });
} catch (error) {
logger.error('ChecklistController.addVehicleItem error', { error });
res.status(500).json({ success: false, message: 'Item konnte nicht erstellt werden' });
}
}
async updateVehicleItem(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 item = await checklistService.updateVehicleItem(id, req.body);
if (!item) {
res.status(404).json({ success: false, message: 'Item nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('ChecklistController.updateVehicleItem error', { error });
res.status(500).json({ success: false, message: 'Item konnte nicht aktualisiert werden' });
}
}
async deleteVehicleItem(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 item = await checklistService.deleteVehicleItem(id);
if (!item) {
res.status(404).json({ success: false, message: 'Item nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Item deaktiviert' });
} catch (error) {
logger.error('ChecklistController.deleteVehicleItem error', { error });
res.status(500).json({ success: false, message: 'Item konnte nicht deaktiviert werden' });
}
}
// --- Templates for vehicle ---
async getTemplatesForVehicle(req: Request, res: Response): Promise<void> {
const fahrzeugId = param(req, 'fahrzeugId');
if (!fahrzeugId) {
res.status(400).json({ success: false, message: 'Fahrzeug-ID erforderlich' });
return;
}
try {
const templates = await checklistService.getTemplatesForVehicle(fahrzeugId);
res.status(200).json({ success: true, data: templates });
} catch (error) {
logger.error('ChecklistController.getTemplatesForVehicle error', { error });
res.status(500).json({ success: false, message: 'Checklisten konnten nicht geladen werden' });
}
}
// --- Templates for equipment ---
async getTemplatesForEquipment(req: Request, res: Response): Promise<void> {
const ausruestungId = param(req, 'ausruestungId');
if (!ausruestungId) {
res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' });
return;
}
try {
const templates = await checklistService.getTemplatesForEquipment(ausruestungId);
res.status(200).json({ success: true, data: templates });
} catch (error) {
logger.error('ChecklistController.getTemplatesForEquipment error', { error });
res.status(500).json({ success: false, message: 'Checklisten konnten nicht geladen werden' });
}
}
// --- Equipment-specific items ---
async getEquipmentItems(req: Request, res: Response): Promise<void> {
const ausruestungId = param(req, 'ausruestungId');
if (!ausruestungId) {
res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' });
return;
}
try {
const items = await checklistService.getEquipmentItems(ausruestungId);
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('ChecklistController.getEquipmentItems error', { error });
res.status(500).json({ success: false, message: 'Items konnten nicht geladen werden' });
}
}
async addEquipmentItem(req: Request, res: Response): Promise<void> {
const ausruestungId = param(req, 'ausruestungId');
if (!ausruestungId) {
res.status(400).json({ success: false, message: 'Ausrüstungs-ID erforderlich' });
return;
}
const { bezeichnung } = req.body;
if (!bezeichnung || typeof bezeichnung !== 'string' || bezeichnung.trim().length === 0) {
res.status(400).json({ success: false, message: 'Bezeichnung ist erforderlich' });
return;
}
try {
const item = await checklistService.addEquipmentItem(ausruestungId, req.body);
res.status(201).json({ success: true, data: item });
} catch (error) {
logger.error('ChecklistController.addEquipmentItem error', { error });
res.status(500).json({ success: false, message: 'Item konnte nicht erstellt werden' });
}
}
async updateEquipmentItem(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'itemId'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const item = await checklistService.updateEquipmentItem(id, req.body);
if (!item) {
res.status(404).json({ success: false, message: 'Item nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('ChecklistController.updateEquipmentItem error', { error });
res.status(500).json({ success: false, message: 'Item konnte nicht aktualisiert werden' });
}
}
async deleteEquipmentItem(req: Request, res: Response): Promise<void> {
const id = parseInt(param(req, 'itemId'), 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const item = await checklistService.deleteEquipmentItem(id);
if (!item) {
res.status(404).json({ success: false, message: 'Item nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Item deaktiviert' });
} catch (error) {
logger.error('ChecklistController.deleteEquipmentItem error', { error });
res.status(500).json({ success: false, message: 'Item konnte nicht deaktiviert werden' });
}
}
// --- Ausführungen (Executions) ---
async startExecution(req: Request, res: Response): Promise<void> {
const { fahrzeugId, ausruestungId, vorlageId } = req.body;
if (!vorlageId || (!fahrzeugId && !ausruestungId)) {
res.status(400).json({ success: false, message: 'vorlageId und entweder fahrzeugId oder ausruestungId sind erforderlich' });
return;
}
try {
const execution = await checklistService.startExecution(fahrzeugId || null, vorlageId, req.user!.id, ausruestungId || null);
res.status(201).json({ success: true, data: execution });
} catch (error) {
logger.error('ChecklistController.startExecution error', { error });
res.status(500).json({ success: false, message: 'Ausführung konnte nicht gestartet werden' });
}
}
async submitExecution(req: Request, res: Response): Promise<void> {
const id = param(req, 'id');
if (!id) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
const { items, notizen } = req.body;
if (!Array.isArray(items)) {
res.status(400).json({ success: false, message: 'items muss ein Array sein' });
return;
}
try {
const execution = await checklistService.submitExecution(id, items, notizen ?? null, req.user!.id);
res.status(200).json({ success: true, data: execution });
} catch (error) {
logger.error('ChecklistController.submitExecution error', { error });
res.status(500).json({ success: false, message: 'Ausführung konnte nicht abgeschlossen werden' });
}
}
async approveExecution(req: Request, res: Response): Promise<void> {
const id = param(req, 'id');
if (!id) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const execution = await checklistService.approveExecution(id, req.user!.id);
if (!execution) {
res.status(404).json({ success: false, message: 'Ausführung nicht gefunden oder Status ungültig' });
return;
}
res.status(200).json({ success: true, data: execution });
} catch (error) {
logger.error('ChecklistController.approveExecution error', { error });
res.status(500).json({ success: false, message: 'Freigabe konnte nicht erteilt werden' });
}
}
async getExecutions(req: Request, res: Response): Promise<void> {
try {
const filter: { fahrzeugId?: string; ausruestungId?: string; vorlageId?: number; status?: string } = {};
if (req.query.fahrzeugId) filter.fahrzeugId = req.query.fahrzeugId as string;
if (req.query.ausruestungId) filter.ausruestungId = req.query.ausruestungId as string;
if (req.query.vorlageId) filter.vorlageId = parseInt(req.query.vorlageId as string, 10);
if (req.query.status) filter.status = req.query.status as string;
const executions = await checklistService.getExecutions(filter);
res.status(200).json({ success: true, data: executions });
} catch (error) {
logger.error('ChecklistController.getExecutions error', { error });
res.status(500).json({ success: false, message: 'Ausführungen konnten nicht geladen werden' });
}
}
async getExecutionById(req: Request, res: Response): Promise<void> {
const id = param(req, 'id');
if (!id) {
res.status(400).json({ success: false, message: 'Ungültige ID' });
return;
}
try {
const execution = await checklistService.getExecutionById(id);
if (!execution) {
res.status(404).json({ success: false, message: 'Ausführung nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: execution });
} catch (error) {
logger.error('ChecklistController.getExecutionById error', { error });
res.status(500).json({ success: false, message: 'Ausführung konnte nicht geladen werden' });
}
}
// --- Fälligkeiten ---
async getOverdueChecklists(_req: Request, res: Response): Promise<void> {
try {
const overdue = await checklistService.getOverdueChecklists();
res.status(200).json({ success: true, data: overdue });
} catch (error) {
logger.error('ChecklistController.getOverdueChecklists error', { error });
res.status(500).json({ success: false, message: 'Überfällige Checklisten konnten nicht geladen werden' });
}
}
async getDueChecklists(req: Request, res: Response): Promise<void> {
const fahrzeugId = param(req, 'fahrzeugId');
if (!fahrzeugId) {
res.status(400).json({ success: false, message: 'Fahrzeug-ID erforderlich' });
return;
}
try {
const due = await checklistService.getDueChecklists(fahrzeugId);
res.status(200).json({ success: true, data: due });
} catch (error) {
logger.error('ChecklistController.getDueChecklists error', { error });
res.status(500).json({ success: false, message: 'Fälligkeiten konnten nicht geladen werden' });
}
}
}
export default new ChecklistController();

View File

@@ -0,0 +1,64 @@
import { Request, Response } from 'express';
import environment from '../config/environment';
import settingsService from '../services/settings.service';
class ConfigController {
async getServiceMode(_req: Request, res: Response): Promise<void> {
try {
const setting = await settingsService.get('service_mode');
const value = setting?.value ?? { active: false, message: '' };
res.json({ success: true, data: value });
} catch {
res.json({ success: true, data: { active: false, message: '' } });
}
}
async getPdfSettings(_req: Request, res: Response): Promise<void> {
try {
const [header, footer, logo, orgName, appLogo] = await Promise.all([
settingsService.get('pdf_header'),
settingsService.get('pdf_footer'),
settingsService.get('pdf_logo'),
settingsService.get('pdf_org_name'),
settingsService.get('app_logo'),
]);
res.json({
success: true,
data: {
pdf_header: header?.value ?? '',
pdf_footer: footer?.value ?? '',
pdf_logo: logo?.value ?? '',
pdf_org_name: orgName?.value ?? '',
app_logo: appLogo?.value ?? '',
},
});
} catch {
res.json({ success: true, data: { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '', app_logo: '' } });
}
}
async getExternalLinks(_req: Request, res: Response): Promise<void> {
const envLinks: Record<string, string> = {};
const nextcloudUrl = await settingsService.getSettingOrEnv('integration_nextcloud_url', environment.nextcloudUrl);
const bookstackUrl = await settingsService.getSettingOrEnv('integration_bookstack_url', environment.bookstack.url);
const vikunjaUrl = await settingsService.getSettingOrEnv('integration_vikunja_url', environment.vikunja.url);
if (nextcloudUrl) envLinks.nextcloud = nextcloudUrl;
if (bookstackUrl) envLinks.bookstack = bookstackUrl;
if (vikunjaUrl) envLinks.vikunja = vikunjaUrl;
const linkCollections = await settingsService.getExternalLinks();
res.status(200).json({
success: true,
data: {
...envLinks,
customLinks: linkCollections.flatMap(c => c.links),
linkCollections,
},
});
}
}
export default new ConfigController();

View File

@@ -0,0 +1,556 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import equipmentService from '../services/equipment.service';
import { AusruestungStatus } from '../models/equipment.model';
import logger from '../utils/logger';
// ── UUID validation ───────────────────────────────────────────────────────────
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);
}
// ── Zod Validation Schemas ────────────────────────────────────────────────────
const AusruestungStatusEnum = z.enum([
AusruestungStatus.Einsatzbereit,
AusruestungStatus.Beschaedigt,
AusruestungStatus.InWartung,
AusruestungStatus.AusserDienst,
]);
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 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 CreateAusruestungSchema = z.object({
bezeichnung: z.string().min(1).max(200),
kategorie_id: uuidString,
seriennummer: z.string().max(100).optional(),
inventarnummer: z.string().max(50).optional(),
hersteller: z.string().max(150).optional(),
baujahr: z.number().int().min(1950).max(2100).optional(),
status: AusruestungStatusEnum.optional(),
status_bemerkung: z.string().max(2000).optional(),
ist_wichtig: z.boolean().optional(),
fahrzeug_id: uuidString.optional(),
standort: z.string().min(1).max(150).optional(),
pruef_intervall_monate: z.number().int().min(1).optional(),
letzte_pruefung_am: isoDate.optional(),
naechste_pruefung_am: isoDate.optional(),
bemerkung: z.string().max(2000).optional(),
});
const UpdateAusruestungSchema = z.object({
bezeichnung: z.string().min(1).max(200).optional(),
kategorie_id: uuidString.optional(),
seriennummer: z.string().max(100).nullable().optional(),
inventarnummer: z.string().max(50).nullable().optional(),
hersteller: z.string().max(150).nullable().optional(),
baujahr: z.number().int().min(1950).max(2100).nullable().optional(),
status: AusruestungStatusEnum.optional(),
status_bemerkung: z.string().max(2000).nullable().optional(),
ist_wichtig: z.boolean().optional(),
fahrzeug_id: uuidString.nullable().optional(),
standort: z.string().min(1).max(150).optional(),
pruef_intervall_monate: z.number().int().min(1).nullable().optional(),
letzte_pruefung_am: isoDate.nullable().optional(),
naechste_pruefung_am: isoDate.nullable().optional(),
bemerkung: z.string().max(2000).nullable().optional(),
});
const UpdateStatusSchema = z.object({
status: AusruestungStatusEnum,
bemerkung: z.string().max(2000).optional().default(''),
});
const CreateWartungslogSchema = z.object({
datum: isoDate,
art: z.enum(['Prüfung', 'Reparatur', 'Sonstiges']),
beschreibung: z.string().min(1).max(2000),
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']).optional(),
kosten: z.number().min(0).optional(),
pruefende_stelle: z.string().max(150).optional(),
dokument_url: z.string().url().max(500).refine(
(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 ────────────────────────────────────────────────────────────────────
function getUserId(req: Request): string {
return req.user!.id;
}
function getUserGroups(req: Request): string[] {
return req.user?.groups ?? [];
}
/**
* Returns true if the user is authorised to write to equipment in the given
* category. Admin can write to any category. Fahrmeister can only write to
* motorised categories. Zeugmeister can only write to non-motorised categories.
*/
async function checkCategoryPermission(kategorieId: string, groups: string[]): Promise<boolean> {
if (groups.includes('dashboard_admin')) return true;
const result = await equipmentService.getCategoryById(kategorieId);
if (!result) return false; // unknown category → deny
if (result.motorisiert) {
return groups.includes('dashboard_fahrmeister');
} else {
return groups.includes('dashboard_zeugmeister');
}
}
// ── Controller ────────────────────────────────────────────────────────────────
class EquipmentController {
async listEquipment(_req: Request, res: Response): Promise<void> {
try {
const equipment = await equipmentService.getAllEquipment();
res.status(200).json({ success: true, data: equipment });
} catch (error) {
logger.error('listEquipment error', { error });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht geladen werden' });
}
}
async getEquipment(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 Ausrüstungs-ID' });
return;
}
const equipment = await equipmentService.getEquipmentById(id);
if (!equipment) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: equipment });
} catch (error) {
logger.error('getEquipment error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht geladen werden' });
}
}
async getByVehicle(req: Request, res: Response): Promise<void> {
try {
const { fahrzeugId } = req.params as Record<string, string>;
if (!isValidUUID(fahrzeugId)) {
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
return;
}
const equipment = await equipmentService.getEquipmentByVehicle(fahrzeugId);
res.status(200).json({ success: true, data: equipment });
} catch (error) {
logger.error('getByVehicle error', { error, fahrzeugId: req.params.fahrzeugId });
res.status(500).json({ success: false, message: 'Ausrüstung für Fahrzeug konnte nicht geladen werden' });
}
}
async getCategories(_req: Request, res: Response): Promise<void> {
try {
const categories = await equipmentService.getCategories();
res.status(200).json({ success: true, data: categories });
} catch (error) {
logger.error('getCategories error', { error });
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
}
}
async getStats(_req: Request, res: Response): Promise<void> {
try {
const stats = await equipmentService.getEquipmentStats();
res.status(200).json({ success: true, data: stats });
} catch (error) {
logger.error('getStats error', { error });
res.status(500).json({ success: false, message: 'Statistiken konnten nicht geladen werden' });
}
}
async getAlerts(req: Request, res: Response): Promise<void> {
try {
const raw = parseInt((req.query.daysAhead as string) || '30', 10);
if (isNaN(raw) || raw < 0) {
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
return;
}
const daysAhead = Math.min(raw, 365);
const alerts = await equipmentService.getUpcomingInspections(daysAhead);
res.status(200).json({ success: true, data: alerts });
} catch (error) {
logger.error('getAlerts error', { error });
res.status(500).json({ success: false, message: 'Prüfungshinweise konnten nicht geladen werden' });
}
}
async getVehicleWarnings(_req: Request, res: Response): Promise<void> {
try {
const warnings = await equipmentService.getVehicleWarnings();
res.status(200).json({ success: true, data: warnings });
} catch (error) {
logger.error('getVehicleWarnings error', { error });
res.status(500).json({ success: false, message: 'Fahrzeug-Warnungen konnten nicht geladen werden' });
}
}
async createEquipment(req: Request, res: Response): Promise<void> {
try {
const parsed = CreateAusruestungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const groups = getUserGroups(req);
const allowed = await checkCategoryPermission(parsed.data.kategorie_id, groups);
if (!allowed) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
return;
}
const equipment = await equipmentService.createEquipment(parsed.data, getUserId(req));
res.status(201).json({ success: true, data: equipment });
} catch (error) {
logger.error('createEquipment error', { error });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht erstellt werden' });
}
}
async updateEquipment(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 Ausrüstungs-ID' });
return;
}
const parsed = UpdateAusruestungSchema.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;
}
// Determine which category to check permissions against
const groups = getUserGroups(req);
if (!groups.includes('dashboard_admin')) {
// Always fetch existing equipment to check old category permission
const existing = await equipmentService.getEquipmentById(id);
if (!existing) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
// Check permission against the OLD category (must be allowed to move FROM it)
const allowedOld = await checkCategoryPermission(existing.kategorie_id, groups);
if (!allowedOld) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
return;
}
// If kategorie_id is being changed, also check permission against the NEW category
if (parsed.data.kategorie_id && parsed.data.kategorie_id !== existing.kategorie_id) {
const allowedNew = await checkCategoryPermission(parsed.data.kategorie_id, groups);
if (!allowedNew) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für die Ziel-Kategorie' });
return;
}
}
}
const equipment = await equipmentService.updateEquipment(id, parsed.data, getUserId(req));
if (!equipment) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: equipment });
} 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('updateEquipment error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht aktualisiert werden' });
}
}
async updateStatus(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 Ausrüstungs-ID' });
return;
}
const parsed = UpdateStatusSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const groups = getUserGroups(req);
if (!groups.includes('dashboard_admin')) {
const existing = await equipmentService.getEquipmentById(id);
if (!existing) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
const allowed = await checkCategoryPermission(existing.kategorie_id, groups);
if (!allowed) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
return;
}
}
await equipmentService.updateStatus(
id, parsed.data.status, parsed.data.bemerkung, getUserId(req)
);
res.status(200).json({ success: true, message: 'Status aktualisiert' });
} catch (error: any) {
if (error?.message === 'Equipment not found') {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
logger.error('updateStatus error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' });
}
}
async deleteEquipment(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 Ausrüstungs-ID' });
return;
}
const deleted = await equipmentService.deleteEquipment(id, getUserId(req));
if (!deleted) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Ausrüstung gelöscht' });
} catch (error) {
logger.error('deleteEquipment error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht gelöscht werden' });
}
}
async addWartung(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 Ausrüstungs-ID' });
return;
}
const parsed = CreateWartungslogSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const groups = getUserGroups(req);
if (!groups.includes('dashboard_admin')) {
const existing = await equipmentService.getEquipmentById(id);
if (!existing) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
const allowed = await checkCategoryPermission(existing.kategorie_id, groups);
if (!allowed) {
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
return;
}
}
const entry = await equipmentService.addWartungslog(id, parsed.data, getUserId(req));
res.status(201).json({ success: true, data: entry });
} catch (error: any) {
if (error?.message === 'Equipment not found') {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
logger.error('addWartung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
}
}
async getStatusHistory(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const history = await equipmentService.getStatusHistory(id);
res.status(200).json({ success: true, data: history });
} catch (error) {
logger.error('getStatusHistory error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Status-Historie konnte nicht geladen werden' });
}
}
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 createCategory(req: Request, res: Response): Promise<void> {
try {
const { name, kurzname, sortierung, motorisiert } = req.body;
if (!name || typeof name !== 'string' || !name.trim()) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
if (!kurzname || typeof kurzname !== 'string' || !kurzname.trim()) {
res.status(400).json({ success: false, message: 'Kurzname ist erforderlich' });
return;
}
const category = await equipmentService.createCategory({
name: name.trim(),
kurzname: kurzname.trim(),
sortierung: sortierung != null ? Number(sortierung) : undefined,
motorisiert: motorisiert != null ? Boolean(motorisiert) : undefined,
});
res.status(201).json({ success: true, data: category });
} catch (error) {
logger.error('createCategory error', { error });
res.status(500).json({ success: false, message: 'Kategorie konnte nicht erstellt werden' });
}
}
async updateCategory(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 Kategorie-ID' });
return;
}
const { name, kurzname, sortierung, motorisiert } = req.body;
const data: Record<string, unknown> = {};
if (name !== undefined) data.name = String(name).trim();
if (kurzname !== undefined) data.kurzname = String(kurzname).trim();
if (sortierung !== undefined) data.sortierung = Number(sortierung);
if (motorisiert !== undefined) data.motorisiert = Boolean(motorisiert);
if (Object.keys(data).length === 0) {
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
return;
}
const category = await equipmentService.updateCategory(id, data as any);
if (!category) {
res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: category });
} 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('updateCategory error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Kategorie konnte nicht aktualisiert werden' });
}
}
async deleteCategory(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 Kategorie-ID' });
return;
}
const result = await equipmentService.deleteCategory(id);
if (!result.deleted) {
res.status(result.error === 'Kategorie nicht gefunden' ? 404 : 409).json({
success: false,
message: result.error,
});
return;
}
res.status(200).json({ success: true, message: 'Kategorie gelöscht' });
} catch (error) {
logger.error('deleteCategory error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Kategorie konnte nicht gelöscht werden' });
}
}
async uploadWartungFile(req: Request, res: Response): Promise<void> {
const { wartungId } = req.params as Record<string, string>;
const id = parseInt(wartungId, 10);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' });
return;
}
const file = (req as any).file;
if (!file) {
res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' });
return;
}
try {
const result = await equipmentService.updateWartungslogFile(id, file.path);
res.status(200).json({ success: true, data: result });
} catch (error) {
logger.error('uploadWartungFile error', { error, wartungId });
res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' });
}
}
}
export default new EquipmentController();

View File

@@ -0,0 +1,405 @@
import { Request, Response } from 'express';
import eventsService from '../services/events.service';
import {
CreateKategorieSchema,
UpdateKategorieSchema,
CreateVeranstaltungSchema,
UpdateVeranstaltungSchema,
CancelVeranstaltungSchema,
} from '../models/events.model';
import logger from '../utils/logger';
// ---------------------------------------------------------------------------
// Helper — extract userGroups from request
// ---------------------------------------------------------------------------
function getUserGroups(req: Request): string[] {
return (req.user as any)?.groups ?? [];
}
// ---------------------------------------------------------------------------
// Controller
// ---------------------------------------------------------------------------
class EventsController {
// -------------------------------------------------------------------------
// GET /api/events/kategorien
// -------------------------------------------------------------------------
listKategorien = async (_req: Request, res: Response): Promise<void> => {
try {
const data = await eventsService.getKategorien();
res.json({ success: true, data });
} catch (error) {
logger.error('listKategorien error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Kategorien' });
}
};
// -------------------------------------------------------------------------
// POST /api/events/kategorien
// -------------------------------------------------------------------------
createKategorie = async (req: Request, res: Response): Promise<void> => {
try {
const parsed = CreateKategorieSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const data = await eventsService.createKategorie(parsed.data, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('createKategorie error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen der Kategorie' });
}
};
// -------------------------------------------------------------------------
// PATCH /api/events/kategorien/:id
// -------------------------------------------------------------------------
updateKategorie = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const parsed = UpdateKategorieSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const data = await eventsService.updateKategorie(id, parsed.data);
if (!data) {
res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' });
return;
}
res.json({ success: true, data });
} catch (error) {
logger.error('updateKategorie error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Kategorie' });
}
};
// -------------------------------------------------------------------------
// DELETE /api/events/kategorien/:id
// -------------------------------------------------------------------------
deleteKategorie = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
await eventsService.deleteKategorie(id);
res.json({ success: true, message: 'Kategorie wurde gelöscht' });
} catch (error: any) {
if (
error.message === 'Kategorie nicht gefunden' ||
error.message?.includes('noch Veranstaltungen')
) {
res.status(409).json({ success: false, message: error.message });
return;
}
logger.error('deleteKategorie error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Löschen der Kategorie' });
}
};
// -------------------------------------------------------------------------
// GET /api/events/groups
// -------------------------------------------------------------------------
getAvailableGroups = async (_req: Request, res: Response): Promise<void> => {
try {
const groups = await eventsService.getAvailableGroups();
res.json({ success: true, data: groups });
} catch (error) {
logger.error('getAvailableGroups error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Gruppen' });
}
};
// -------------------------------------------------------------------------
// GET /api/events/conflicts?from=<ISO>&to=<ISO>&excludeId=<uuid>
// -------------------------------------------------------------------------
checkConflicts = async (req: Request, res: Response): Promise<void> => {
try {
const fromStr = req.query.from as string | undefined;
const toStr = req.query.to as string | undefined;
const excludeId = req.query.excludeId as string | undefined;
if (!fromStr || !toStr) {
res.status(400).json({
success: false,
message: 'Query-Parameter "from" und "to" sind erforderlich (ISO-8601)',
});
return;
}
const from = new Date(fromStr);
const to = new Date(toStr);
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
res.status(400).json({ success: false, message: 'Ungültiges Datumsformat' });
return;
}
const data = await eventsService.checkConflicts(from, to, excludeId);
res.json({ success: true, data });
} catch (error) {
logger.error('checkConflicts error', { error });
res.status(500).json({ success: false, message: 'Fehler bei der Konfliktprüfung' });
}
};
// -------------------------------------------------------------------------
// GET /api/events/calendar?from=<ISO>&to=<ISO>
// -------------------------------------------------------------------------
getCalendarRange = async (req: Request, res: Response): Promise<void> => {
try {
const fromStr = req.query.from as string | undefined;
const toStr = req.query.to as string | undefined;
if (!fromStr || !toStr) {
res.status(400).json({
success: false,
message: 'Query-Parameter "from" und "to" sind erforderlich (ISO-8601)',
});
return;
}
const from = new Date(fromStr);
const to = new Date(toStr);
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
res.status(400).json({ success: false, message: 'Ungültiges Datumsformat' });
return;
}
if (to < from) {
res.status(400).json({ success: false, message: '"to" muss nach "from" liegen' });
return;
}
const userGroups = getUserGroups(req);
const data = await eventsService.getEventsByDateRange(from, to, userGroups);
res.json({ success: true, data });
} catch (error) {
logger.error('getCalendarRange error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalenders' });
}
};
// -------------------------------------------------------------------------
// GET /api/events/upcoming?limit=10
// -------------------------------------------------------------------------
getUpcoming = async (req: Request, res: Response): Promise<void> => {
try {
const limit = Math.min(Number(req.query.limit) || 10, 50);
const userGroups = getUserGroups(req);
const data = await eventsService.getUpcomingEvents(limit, userGroups);
res.json({ success: true, data });
} catch (error) {
logger.error('getUpcoming error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltungen' });
}
};
// -------------------------------------------------------------------------
// GET /api/events/:id
// -------------------------------------------------------------------------
getById = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const event = await eventsService.getById(id);
if (!event) {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
res.json({ success: true, data: event });
} catch (error) {
logger.error('getById error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// POST /api/events
// -------------------------------------------------------------------------
createEvent = async (req: Request, res: Response): Promise<void> => {
try {
const parsed = CreateVeranstaltungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const data = await eventsService.createEvent(parsed.data, req.user!.id);
res.status(201).json({ success: true, data });
} catch (error) {
logger.error('createEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// PATCH /api/events/:id
// -------------------------------------------------------------------------
updateEvent = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const parsed = UpdateVeranstaltungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const data = await eventsService.updateEvent(id, parsed.data);
if (!data) {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
res.json({ success: true, data });
} catch (error) {
logger.error('updateEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// DELETE /api/events/:id (soft cancel)
// -------------------------------------------------------------------------
cancelEvent = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const parsed = CancelVeranstaltungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
await eventsService.cancelEvent(id, parsed.data.abgesagt_grund, req.user!.id);
res.json({ success: true, message: 'Veranstaltung wurde abgesagt' });
} catch (error: any) {
if (error.message === 'Event not found') {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
logger.error('cancelEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Absagen der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// POST /api/events/:id/delete (hard delete)
// -------------------------------------------------------------------------
deleteEvent = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const mode = (req.body?.mode as string) || 'all';
if (!['all', 'single', 'future'].includes(mode)) {
res.status(400).json({ success: false, message: 'Ungültiger Löschmodus. Erlaubt: all, single, future' });
return;
}
const deleted = await eventsService.deleteEvent(id, mode as 'all' | 'single' | 'future');
if (!deleted) {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
res.json({ success: true, message: 'Veranstaltung wurde gelöscht' });
} catch (error) {
logger.error('deleteEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Löschen der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// GET /api/events/calendar-token
// -------------------------------------------------------------------------
getCalendarToken = async (req: Request, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const data = await eventsService.getOrCreateIcalToken(req.user.id);
res.json({ success: true, data });
} catch (error) {
logger.error('getCalendarToken error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalender-Tokens' });
}
};
// -------------------------------------------------------------------------
// GET /api/events/calendar.ics?token=<token>
// -------------------------------------------------------------------------
getIcalExport = async (req: Request, res: Response): Promise<void> => {
try {
const token = req.query.token as string | undefined;
if (!token) {
res.status(400).send('Token required');
return;
}
const ical = await eventsService.getIcalExport(token);
if (!ical) {
res.status(404).send('Invalid token');
return;
}
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="veranstaltungen.ics"');
// 30-minute cache — calendar clients typically re-fetch at this interval
res.setHeader('Cache-Control', 'max-age=1800, public');
res.send(ical);
} catch (error) {
logger.error('getIcalExport error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' });
}
};
// -------------------------------------------------------------------------
// POST /api/events/import
// -------------------------------------------------------------------------
importEvents = async (req: Request, res: Response): Promise<void> => {
try {
const { events } = req.body as { events: unknown[] };
if (!Array.isArray(events) || events.length === 0) {
res.status(400).json({ success: false, message: 'Keine Ereignisse zum Importieren' });
return;
}
const userId = (req.user as any)?.id ?? 'unknown';
const created: number[] = [];
const errors: string[] = [];
for (let i = 0; i < events.length; i++) {
try {
const parsed = CreateVeranstaltungSchema.safeParse(events[i]);
if (!parsed.success) {
errors.push(`Zeile ${i + 2}: ${parsed.error.issues.map((e) => e.message).join(', ')}`);
continue;
}
await eventsService.createEvent(parsed.data, userId);
created.push(i);
} catch (e) {
errors.push(`Zeile ${i + 2}: ${e instanceof Error ? e.message : 'Unbekannter Fehler'}`);
}
}
res.status(200).json({
success: true,
data: { created: created.length, errors },
});
} catch (error) {
logger.error('importEvents error', { error });
res.status(500).json({ success: false, message: 'Import fehlgeschlagen' });
}
};
}
export default new EventsController();

View File

@@ -0,0 +1,126 @@
import { Request, Response } from 'express';
import fahrzeugTypService from '../services/fahrzeugTyp.service';
import logger from '../utils/logger';
const param = (req: Request, key: string): string => req.params[key] as string;
class FahrzeugTypController {
async getAll(_req: Request, res: Response): Promise<void> {
try {
const types = await fahrzeugTypService.getAll();
res.status(200).json({ success: true, data: types });
} catch (error) {
logger.error('FahrzeugTypController.getAll error', { error });
res.status(500).json({ success: false, message: 'Fahrzeug-Typen konnten nicht geladen werden' });
}
}
async getById(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 type = await fahrzeugTypService.getById(id);
if (!type) {
res.status(404).json({ success: false, message: 'Fahrzeug-Typ nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: type });
} catch (error) {
logger.error('FahrzeugTypController.getById error', { error });
res.status(500).json({ success: false, message: 'Fahrzeug-Typ konnte nicht geladen werden' });
}
}
async create(req: Request, res: Response): Promise<void> {
const { name } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
try {
const type = await fahrzeugTypService.create(req.body);
res.status(201).json({ success: true, data: type });
} catch (error) {
logger.error('FahrzeugTypController.create error', { error });
res.status(500).json({ success: false, message: 'Fahrzeug-Typ konnte nicht erstellt werden' });
}
}
async update(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 type = await fahrzeugTypService.update(id, req.body);
if (!type) {
res.status(404).json({ success: false, message: 'Fahrzeug-Typ nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: type });
} catch (error) {
logger.error('FahrzeugTypController.update error', { error });
res.status(500).json({ success: false, message: 'Fahrzeug-Typ konnte nicht aktualisiert werden' });
}
}
async delete(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 type = await fahrzeugTypService.delete(id);
if (!type) {
res.status(404).json({ success: false, message: 'Fahrzeug-Typ nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Fahrzeug-Typ gelöscht' });
} catch (error) {
logger.error('FahrzeugTypController.delete error', { error });
res.status(500).json({ success: false, message: 'Fahrzeug-Typ konnte nicht gelöscht werden' });
}
}
async getTypesForVehicle(req: Request, res: Response): Promise<void> {
const fahrzeugId = param(req, 'fahrzeugId');
if (!fahrzeugId) {
res.status(400).json({ success: false, message: 'Fahrzeug-ID erforderlich' });
return;
}
try {
const types = await fahrzeugTypService.getTypesForVehicle(fahrzeugId);
res.status(200).json({ success: true, data: types });
} catch (error) {
logger.error('FahrzeugTypController.getTypesForVehicle error', { error });
res.status(500).json({ success: false, message: 'Fahrzeug-Typen konnten nicht geladen werden' });
}
}
async setTypesForVehicle(req: Request, res: Response): Promise<void> {
const fahrzeugId = param(req, 'fahrzeugId');
if (!fahrzeugId) {
res.status(400).json({ success: false, message: 'Fahrzeug-ID erforderlich' });
return;
}
const { typIds } = req.body;
if (!Array.isArray(typIds)) {
res.status(400).json({ success: false, message: 'typIds muss ein Array sein' });
return;
}
try {
const types = await fahrzeugTypService.setTypesForVehicle(fahrzeugId, typIds);
res.status(200).json({ success: true, data: types });
} catch (error) {
logger.error('FahrzeugTypController.setTypesForVehicle error', { error });
res.status(500).json({ success: false, message: 'Fahrzeug-Typen konnten nicht gesetzt werden' });
}
}
}
export default new FahrzeugTypController();

View File

@@ -0,0 +1,311 @@
import { Request, Response } from 'express';
import incidentService from '../services/incident.service';
import logger from '../utils/logger';
import { AppError } from '../middleware/error.middleware';
import { AppRole } from '../middleware/rbac.middleware';
import { permissionService } from '../services/permission.service';
import {
CreateEinsatzSchema,
UpdateEinsatzSchema,
AssignPersonnelSchema,
AssignVehicleSchema,
IncidentFiltersSchema,
} from '../models/incident.model';
// Extend Request type to carry the resolved role (set by requirePermission)
interface AuthenticatedRequest extends Request {
userRole?: AppRole;
}
class IncidentController {
// -------------------------------------------------------------------------
// GET /api/incidents
// -------------------------------------------------------------------------
async listIncidents(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const parseResult = IncidentFiltersSchema.safeParse(req.query);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Ungültige Filter-Parameter',
errors: parseResult.error.issues,
});
return;
}
const { items, total } = await incidentService.getAllIncidents(parseResult.data);
res.status(200).json({
success: true,
data: {
items,
total,
limit: parseResult.data.limit,
offset: parseResult.data.offset,
},
});
} catch (error) {
logger.error('List incidents error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Einsätze' });
}
}
// -------------------------------------------------------------------------
// GET /api/incidents/stats
// -------------------------------------------------------------------------
async getStats(req: Request, res: Response): Promise<void> {
try {
const year = req.query.year ? parseInt(req.query.year as string, 10) : undefined;
if (year !== undefined && (isNaN(year) || year < 2000 || year > 2100)) {
res.status(400).json({ success: false, message: 'Ungültiges Jahr' });
return;
}
const stats = await incidentService.getIncidentStats(year);
res.status(200).json({ success: true, data: stats });
} catch (error) {
logger.error('Get incident stats error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistik' });
}
}
// -------------------------------------------------------------------------
// GET /api/incidents/:id
// -------------------------------------------------------------------------
async getIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
// UUID validation
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
res.status(400).json({ success: false, message: 'Ungültige Einsatz-ID' });
return;
}
const incident = await incidentService.getIncidentById(id);
if (!incident) {
throw new AppError('Einsatz nicht gefunden', 404);
}
// Role-based redaction: check einsaetze:view_reports permission
const groups: string[] = req.user?.groups ?? [];
const canReadBerichtText =
groups.includes('dashboard_admin') ||
permissionService.hasPermission(groups, 'einsaetze:view_reports');
const responseData = {
...incident,
bericht_text: canReadBerichtText ? incident.bericht_text : undefined,
};
res.status(200).json({ success: true, data: responseData });
} catch (error) {
if (error instanceof AppError) {
res.status(error.statusCode).json({ success: false, message: error.message });
return;
}
logger.error('Get incident error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Einsatzes' });
}
}
// -------------------------------------------------------------------------
// POST /api/incidents
// -------------------------------------------------------------------------
async createIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const parseResult = CreateEinsatzSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parseResult.error.issues,
});
return;
}
const einsatz = await incidentService.createIncident(parseResult.data, req.user.id);
logger.info('Incident created via API', {
einsatzId: einsatz.id,
einsatz_nr: einsatz.einsatz_nr,
createdBy: req.user.id,
});
res.status(201).json({ success: true, data: einsatz });
} catch (error) {
logger.error('Create incident error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Einsatzes' });
}
}
// -------------------------------------------------------------------------
// PATCH /api/incidents/:id
// -------------------------------------------------------------------------
async updateIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const { id } = req.params as Record<string, string>;
const parseResult = UpdateEinsatzSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parseResult.error.issues,
});
return;
}
const einsatz = await incidentService.updateIncident(id, parseResult.data, req.user.id);
res.status(200).json({ success: true, data: einsatz });
} catch (error) {
if (error instanceof Error && error.message === 'Incident not found') {
res.status(404).json({ success: false, message: 'Einsatz nicht gefunden' });
return;
}
logger.error('Update incident error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Einsatzes' });
}
}
// -------------------------------------------------------------------------
// DELETE /api/incidents/:id (soft delete)
// -------------------------------------------------------------------------
async deleteIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const { id } = req.params as Record<string, string>;
await incidentService.deleteIncident(id, req.user.id);
res.status(200).json({ success: true, message: 'Einsatz archiviert' });
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
res.status(404).json({ success: false, message: 'Einsatz nicht gefunden' });
return;
}
logger.error('Delete incident error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Archivieren des Einsatzes' });
}
}
// -------------------------------------------------------------------------
// POST /api/incidents/:id/personnel
// -------------------------------------------------------------------------
async assignPersonnel(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const parseResult = AssignPersonnelSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parseResult.error.issues,
});
return;
}
await incidentService.assignPersonnel(id, parseResult.data);
res.status(200).json({ success: true, message: 'Person zugewiesen' });
} catch (error) {
logger.error('Assign personnel error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Zuweisen der Person' });
}
}
// -------------------------------------------------------------------------
// DELETE /api/incidents/:id/personnel/:userId
// -------------------------------------------------------------------------
async removePersonnel(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id, userId } = req.params as Record<string, string>;
await incidentService.removePersonnel(id, userId);
res.status(200).json({ success: true, message: 'Person entfernt' });
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
res.status(404).json({ success: false, message: 'Zuweisung nicht gefunden' });
return;
}
logger.error('Remove personnel error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Entfernen der Person' });
}
}
// -------------------------------------------------------------------------
// POST /api/incidents/:id/vehicles
// -------------------------------------------------------------------------
async assignVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const parseResult = AssignVehicleSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parseResult.error.issues,
});
return;
}
await incidentService.assignVehicle(id, parseResult.data);
res.status(200).json({ success: true, message: 'Fahrzeug zugewiesen' });
} catch (error) {
logger.error('Assign vehicle error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Zuweisen des Fahrzeugs' });
}
}
// -------------------------------------------------------------------------
// DELETE /api/incidents/:id/vehicles/:fahrzeugId
// -------------------------------------------------------------------------
async removeVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
try {
const { id, fahrzeugId } = req.params as Record<string, string>;
await incidentService.removeVehicle(id, fahrzeugId);
res.status(200).json({ success: true, message: 'Fahrzeug entfernt' });
} catch (error) {
if (error instanceof Error && error.message.includes('not found')) {
res.status(404).json({ success: false, message: 'Zuweisung nicht gefunden' });
return;
}
logger.error('Remove vehicle error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fehler beim Entfernen des Fahrzeugs' });
}
}
// -------------------------------------------------------------------------
// POST /api/incidents/refresh-stats (admin utility)
// -------------------------------------------------------------------------
async refreshStats(_req: Request, res: Response): Promise<void> {
try {
await incidentService.refreshStatistikView();
res.status(200).json({ success: true, message: 'Statistik-View aktualisiert' });
} catch (error) {
logger.error('Refresh stats view error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Statistik' });
}
}
}
export default new IncidentController();

View File

@@ -0,0 +1,581 @@
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');
// Parse filter query params
const filters: {
typ_id?: number[];
prioritaet?: string[];
status?: string[];
erstellt_von?: string;
zugewiesen_an?: string;
} = {};
if (req.query.typ_id) {
filters.typ_id = String(req.query.typ_id).split(',').map(Number).filter((n) => !isNaN(n));
}
if (req.query.prioritaet) {
filters.prioritaet = String(req.query.prioritaet).split(',');
}
if (req.query.status) {
filters.status = String(req.query.status).split(',');
}
if (req.query.erstellt_von) {
filters.erstellt_von = req.query.erstellt_von as string;
}
if (req.query.zugewiesen_an) {
filters.zugewiesen_an =
req.query.zugewiesen_an === 'me' ? userId : (req.query.zugewiesen_an as string);
}
const issues = await issueService.getIssues({ userId, canViewAll, filters });
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 && issue.zugewiesen_an !== 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 userId = req.user!.id;
const groups: string[] = (req.user as any).groups || [];
const canEdit = permissionService.hasPermission(groups, 'issues:edit');
const canChangeStatus = permissionService.hasPermission(groups, 'issues:change_status');
const existing = await issueService.getIssueById(id);
if (!existing) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
const isOwner = existing.erstellt_von === userId;
const isAssignee = existing.zugewiesen_an === userId;
// Determine what update data is allowed
let updateData: Record<string, any>;
if (canEdit) {
// Full edit access
updateData = { ...req.body };
// Explicit null for unassign is handled by 'zugewiesen_an' in data check in service
} else if (canChangeStatus || isAssignee) {
// Can change status and priority (+ kommentar is handled separately)
updateData = {};
if (req.body.status !== undefined) updateData.status = req.body.status;
if (req.body.prioritaet !== undefined) updateData.prioritaet = req.body.prioritaet;
} else if (isOwner) {
// Owner without change_status: can only close own issue or reopen from terminal status
updateData = {};
if (req.body.status !== undefined) {
const newStatus = req.body.status;
const allStatuses = await issueService.getIssueStatuses();
const targetDef = allStatuses.find((s: any) => s.schluessel === newStatus);
const currentDef = allStatuses.find((s: any) => s.schluessel === existing.status);
if (targetDef?.ist_abschluss) {
// Owner can close with any terminal status
updateData.status = newStatus;
} else if (targetDef?.ist_initial && currentDef?.ist_abschluss) {
// Owner can reopen from terminal → initial (requires kommentar)
if (!req.body.kommentar || typeof req.body.kommentar !== 'string' || req.body.kommentar.trim().length === 0) {
res.status(400).json({
success: false,
message: 'Beim Wiedereröffnen ist ein Kommentar erforderlich',
});
return;
}
updateData.status = newStatus;
} else {
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Statusänderung' });
return;
}
} else {
// Owner trying to change non-status fields without edit permission
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
} else {
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
return;
}
// Validate: if setting status to 'abgelehnt', check if type allows it
if (updateData.status === 'abgelehnt' && existing.typ_id) {
if (!existing.typ_erlaubt_abgelehnt) {
res.status(400).json({
success: false,
message: 'Dieser Issue-Typ erlaubt den Status "Abgelehnt" nicht',
});
return;
}
}
const issue = await issueService.updateIssue(id, updateData);
if (!issue) {
res.status(404).json({ success: false, message: 'Issue nicht gefunden' });
return;
}
// Log history entries for detected changes
const fieldLabels: Record<string, string> = {
status: 'Status geändert',
prioritaet: 'Priorität geändert',
zugewiesen_an: 'Zuweisung geändert',
titel: 'Titel geändert',
beschreibung: 'Beschreibung geändert',
typ_id: 'Typ geändert',
faellig_am: 'Fälligkeitsdatum geändert',
};
for (const [field, label] of Object.entries(fieldLabels)) {
if (field in updateData && updateData[field] !== existing[field]) {
const details: Record<string, unknown> = { von: existing[field], zu: updateData[field] };
if (field === 'zugewiesen_an') {
details.von_name = existing.zugewiesen_an_name || null;
details.zu_name = issue.zugewiesen_an_name || null;
}
if (field === 'status') {
details.von_label = existing.status;
details.zu_label = issue.status;
}
issueService.addHistoryEntry(id, label, details, userId);
}
}
// Handle reopen comment (owner reopen flow: terminal → initial)
if (isOwner && !canChangeStatus && updateData.status && req.body.kommentar) {
const allStatusesForComment = await issueService.getIssueStatuses();
const targetForComment = allStatusesForComment.find((s: any) => s.schluessel === updateData.status);
const currentForComment = allStatusesForComment.find((s: any) => s.schluessel === existing.status);
if (targetForComment?.ist_initial && currentForComment?.ist_abschluss) {
await issueService.addComment(id, userId, `[Wiedereröffnet] ${req.body.kommentar.trim()}`);
} else if (req.body.kommentar && updateData.status) {
await issueService.addComment(id, userId, req.body.kommentar.trim());
}
} else if (req.body.kommentar && updateData.status) {
// If kommentar was provided alongside a status change (non-owner flow)
await issueService.addComment(id, userId, req.body.kommentar.trim());
}
// Re-fetch to include any new comment info
const updated = await issueService.getIssueById(id);
res.status(200).json({ success: true, data: updated });
} 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 canDelete = permissionService.hasPermission(groups, 'issues:delete');
if (!canDelete && 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 && issue.zugewiesen_an !== 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 isOwner = issue.erstellt_von === userId;
const isAssignee = issue.zugewiesen_an === userId;
const canChangeStatus = permissionService.hasPermission(groups, 'issues:change_status');
const canEdit = permissionService.hasPermission(groups, 'issues:edit');
// Authorization: owner, assignee, change_status, or edit can comment
if (!isOwner && !isAssignee && !canChangeStatus && !canEdit) {
res.status(403).json({ success: false, message: 'Keine Berechtigung zum Kommentieren' });
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' });
}
}
// --- Type management ---
async getHistory(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 history = await issueService.getHistory(issueId);
res.status(200).json({ success: true, data: history });
} catch (error) {
logger.error('IssueController.getHistory error', { error });
res.status(500).json({ success: false, message: 'Historie konnte nicht geladen werden' });
}
}
async getTypes(_req: Request, res: Response): Promise<void> {
try {
const types = await issueService.getTypes();
res.status(200).json({ success: true, data: types });
} catch (error) {
logger.error('IssueController.getTypes error', { error });
res.status(500).json({ success: false, message: 'Issue-Typen konnten nicht geladen werden' });
}
}
async createType(req: Request, res: Response): Promise<void> {
const { name } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
return;
}
try {
const type = await issueService.createType(req.body);
res.status(201).json({ success: true, data: type });
} catch (error) {
logger.error('IssueController.createType error', { error });
res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht erstellt werden' });
}
}
async updateType(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 type = await issueService.updateType(id, req.body);
if (!type) {
res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: type });
} catch (error) {
logger.error('IssueController.updateType error', { error });
res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht aktualisiert werden' });
}
}
async deleteType(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 type = await issueService.deleteType(id);
if (!type) {
res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: type });
} catch (error) {
logger.error('IssueController.deleteType error', { error });
res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht gelöscht werden' });
}
}
async getMembers(_req: Request, res: Response): Promise<void> {
try {
const members = await issueService.getAssignableMembers();
res.status(200).json({ success: true, data: members });
} catch (error) {
logger.error('IssueController.getMembers error', { error });
res.status(500).json({ success: false, message: 'Mitglieder konnten nicht geladen werden' });
}
}
async getWidgetSummary(_req: Request, res: Response): Promise<void> {
try {
const counts = await issueService.getIssueCounts();
res.status(200).json({ success: true, data: counts });
} catch (error) {
logger.error('IssueController.getWidgetSummary error', { error });
res.status(500).json({ success: false, message: 'Issue-Counts konnten nicht geladen werden' });
}
}
async getIssueStatuses(_req: Request, res: Response): Promise<void> {
try {
const items = await issueService.getIssueStatuses();
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('IssueController.getIssueStatuses error', { error });
res.status(500).json({ success: false, message: 'Issue-Status konnten nicht geladen werden' });
}
}
async createIssueStatus(req: Request, res: Response): Promise<void> {
const { schluessel, bezeichnung } = req.body;
if (!schluessel?.trim() || !bezeichnung?.trim()) {
res.status(400).json({ success: false, message: 'Schlüssel und Bezeichnung sind erforderlich' });
return;
}
try {
const item = await issueService.createIssueStatus(req.body);
res.status(201).json({ success: true, data: item });
} catch (error) {
logger.error('IssueController.createIssueStatus error', { error });
res.status(500).json({ success: false, message: 'Issue-Status konnte nicht erstellt werden' });
}
}
async updateIssueStatus(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 item = await issueService.updateIssueStatus(id, req.body);
if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; }
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('IssueController.updateIssueStatus error', { error });
res.status(500).json({ success: false, message: 'Issue-Status konnte nicht aktualisiert werden' });
}
}
async deleteIssueStatus(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 item = await issueService.deleteIssueStatus(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('IssueController.deleteIssueStatus error', { error });
res.status(500).json({ success: false, message: 'Issue-Status konnte nicht gelöscht werden' });
}
}
async getIssuePriorities(_req: Request, res: Response): Promise<void> {
try {
const items = await issueService.getIssuePriorities();
res.status(200).json({ success: true, data: items });
} catch (error) {
logger.error('IssueController.getIssuePriorities error', { error });
res.status(500).json({ success: false, message: 'Prioritäten konnten nicht geladen werden' });
}
}
async createIssuePriority(req: Request, res: Response): Promise<void> {
const { schluessel, bezeichnung } = req.body;
if (!schluessel?.trim() || !bezeichnung?.trim()) {
res.status(400).json({ success: false, message: 'Schlüssel und Bezeichnung sind erforderlich' });
return;
}
try {
const item = await issueService.createIssuePriority(req.body);
res.status(201).json({ success: true, data: item });
} catch (error) {
logger.error('IssueController.createIssuePriority error', { error });
res.status(500).json({ success: false, message: 'Priorität konnte nicht erstellt werden' });
}
}
async updateIssuePriority(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 item = await issueService.updateIssuePriority(id, req.body);
if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; }
res.status(200).json({ success: true, data: item });
} catch (error) {
logger.error('IssueController.updateIssuePriority error', { error });
res.status(500).json({ success: false, message: 'Priorität konnte nicht aktualisiert werden' });
}
}
async deleteIssuePriority(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 item = await issueService.deleteIssuePriority(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('IssueController.deleteIssuePriority error', { error });
res.status(500).json({ success: false, message: 'Priorität konnte nicht gelöscht werden' });
}
}
// --- File management ---
async uploadFile(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 Issue-ID' });
return;
}
const file = req.file as Express.Multer.File | undefined;
if (!file) {
res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' });
return;
}
try {
const fileRecord = await issueService.addFile(issueId, {
dateiname: file.originalname,
dateipfad: file.path,
dateityp: file.mimetype,
dateigroesse: file.size,
}, req.user!.id);
res.status(201).json({ success: true, data: fileRecord });
} catch (error) {
logger.error('IssueController.uploadFile error', { error });
res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' });
}
}
async getFiles(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 files = await issueService.getFiles(issueId);
res.status(200).json({ success: true, data: files });
} catch (error) {
logger.error('IssueController.getFiles error', { error });
res.status(500).json({ success: false, message: 'Dateien konnten nicht geladen werden' });
}
}
async deleteFile(req: Request, res: Response): Promise<void> {
const fileId = param(req, 'fileId');
if (!fileId) {
res.status(400).json({ success: false, message: 'Ungültige Datei-ID' });
return;
}
try {
const result = await issueService.deleteFile(fileId, req.user!.id);
if (!result) {
res.status(404).json({ success: false, message: 'Datei nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Datei gelöscht' });
} catch (error) {
logger.error('IssueController.deleteFile error', { error });
res.status(500).json({ success: false, message: 'Datei konnte nicht gelöscht werden' });
}
}
}
export default new IssueController();

View File

@@ -0,0 +1,294 @@
import { Request, Response } from 'express';
import memberService from '../services/member.service';
import logger from '../utils/logger';
import {
CreateMemberProfileSchema,
UpdateMemberProfileSchema,
SelfUpdateMemberProfileSchema,
} from '../models/member.model';
// ----------------------------------------------------------------
// Role helpers
// These helpers inspect req.user.role which is populated by the
// requireRole / requirePermission middleware (see member.routes.ts).
// ----------------------------------------------------------------
type AppRole = 'admin' | 'kommandant' | 'mitglied';
function getRole(req: Request): AppRole {
// req.userRole is set by requirePermission() for non-owner paths.
// Fall back to req.user.role (JWT claim) and finally to 'mitglied'.
return (req as any).userRole ?? (req.user as any)?.role ?? 'mitglied';
}
function canWrite(req: Request): boolean {
const role = getRole(req);
return role === 'admin' || role === 'kommandant';
}
function isOwnProfile(req: Request, userId: string): boolean {
return req.user?.id === userId;
}
// ----------------------------------------------------------------
// Controller
// ----------------------------------------------------------------
class MemberController {
/**
* GET /api/members
* Returns a paginated list of all active members.
* Supports ?search=, ?status[]=, ?dienstgrad[]=, ?page=, ?pageSize=
*/
async getMembers(req: Request, res: Response): Promise<void> {
try {
const {
search,
page,
pageSize,
sortBy,
sortDir,
} = req.query as Record<string, string | undefined>;
// Arrays can be sent as ?status[]=aktiv&status[]=jugend or CSV
const statusParam = req.query['status'] as string | string[] | undefined;
const dienstgradParam = req.query['dienstgrad'] as string | string[] | undefined;
const normalizeArray = (v?: string | string[]): string[] | undefined => {
if (!v) return undefined;
return Array.isArray(v) ? v : v.split(',').map((s) => s.trim()).filter(Boolean);
};
const { items, total } = await memberService.getAllMembers({
search,
status: normalizeArray(statusParam) as any,
dienstgrad: normalizeArray(dienstgradParam) as any,
page: page ? parseInt(page, 10) || 1 : 1,
pageSize: pageSize ? (parseInt(pageSize, 10) === 0 ? 0 : Math.min(parseInt(pageSize, 10) || 25, 100)) : 25,
sortBy,
sortDir: sortDir === 'desc' ? 'desc' : sortDir === 'asc' ? 'asc' : undefined,
});
res.status(200).json({
success: true,
data: items,
meta: { total, page: page ? parseInt(page, 10) : 1 },
});
} catch (error) {
logger.error('getMembers error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Mitglieder.' });
}
}
/**
* GET /api/members/stats
* Returns aggregate member counts for each status.
* Must be registered BEFORE /:userId to avoid route collision.
*/
async getMemberStats(_req: Request, res: Response): Promise<void> {
try {
const stats = await memberService.getMemberStats();
res.status(200).json({ success: true, data: stats });
} catch (error) {
logger.error('getMemberStats error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistiken.' });
}
}
/**
* GET /api/members/:userId
* Returns full member detail including profile and rank history.
*
* Role rules:
* - Kommandant/Admin: all fields
* - Mitglied reading own profile: all fields
* - Mitglied reading another member: geburtsdatum and emergency contact redacted
*/
async getMemberById(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params as Record<string, string>;
const ownProfile = isOwnProfile(req, userId);
const member = await memberService.getMemberById(userId);
if (!member) {
res.status(404).json({ success: false, message: 'Mitglied nicht gefunden.' });
return;
}
// Sensitive field masking for non-privileged reads of other members
const canReadSensitive = canWrite(req) || ownProfile;
if (!canReadSensitive && member.profile) {
// Replace geburtsdatum with only the age (year of birth omitted for DSGVO)
const ageMasked: any = { ...member.profile };
if (ageMasked.geburtsdatum) {
const birthYear = new Date(ageMasked.geburtsdatum).getFullYear();
const age = new Date().getFullYear() - birthYear;
ageMasked.geburtsdatum = null;
ageMasked._age = age; // synthesised non-DB field
}
// Redact emergency contact entirely
ageMasked.notfallkontakt_name = null;
ageMasked.notfallkontakt_telefon = null;
res.status(200).json({
success: true,
data: { ...member, profile: ageMasked },
});
return;
}
res.status(200).json({ success: true, data: member });
} catch (error) {
logger.error('getMemberById error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Mitglieds.' });
}
}
/**
* POST /api/members/:userId/profile
* Creates the mitglieder_profile row for an existing auth user.
* Restricted to Kommandant/Admin.
*/
async createMemberProfile(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params as Record<string, string>;
const parseResult = CreateMemberProfileSchema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Ungültige Eingabedaten.',
errors: parseResult.error.flatten().fieldErrors,
});
return;
}
const profile = await memberService.createMemberProfile(userId, parseResult.data);
logger.info('createMemberProfile', { userId, createdBy: req.user!.id });
res.status(201).json({ success: true, data: profile });
} catch (error: any) {
if (error?.message?.includes('existiert bereits')) {
res.status(409).json({ success: false, message: error.message });
return;
}
logger.error('createMemberProfile error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Profils.' });
}
}
/**
* PATCH /api/members/:userId
* Updates the mitglieder_profile row.
*
* Role rules:
* - Kommandant/Admin: full update (all fields)
* - Mitglied (own profile only): restricted to SelfUpdateMemberProfileSchema fields
*/
async updateMember(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params as Record<string, string>;
const updaterId = req.user!.id;
const ownProfile = isOwnProfile(req, userId);
if (!canWrite(req) && !ownProfile) {
res.status(403).json({
success: false,
message: 'Keine Berechtigung, dieses Profil zu bearbeiten.',
});
return;
}
// Choose validation schema based on role
const schema = canWrite(req) ? UpdateMemberProfileSchema : SelfUpdateMemberProfileSchema;
const parseResult = schema.safeParse(req.body);
if (!parseResult.success) {
res.status(400).json({
success: false,
message: 'Ungültige Eingabedaten.',
errors: parseResult.error.flatten().fieldErrors,
});
return;
}
await memberService.updateMemberProfile(
userId,
parseResult.data as any,
updaterId
);
// Return full MemberWithProfile so the frontend state stays consistent
const fullMember = await memberService.getMemberById(userId);
logger.info('updateMember', { userId, updatedBy: updaterId });
res.status(200).json({ success: true, data: fullMember });
} catch (error: any) {
if (error?.message === 'Mitgliedsprofil nicht gefunden.') {
res.status(404).json({ success: false, message: error.message });
return;
}
logger.error('updateMember error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Profils.' });
}
}
/**
* GET /api/members/:userId/befoerderungen
*/
async getBefoerderungen(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params as Record<string, string>;
const data = await memberService.getBefoerderungen(userId);
res.status(200).json({ success: true, data });
} catch (error) {
logger.error('getBefoerderungen error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Beförderungen.' });
}
}
/**
* GET /api/members/:userId/untersuchungen
*/
async getUntersuchungen(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params as Record<string, string>;
const data = await memberService.getUntersuchungen(userId);
res.status(200).json({ success: true, data });
} catch (error) {
logger.error('getUntersuchungen error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Untersuchungen.' });
}
}
/**
* GET /api/members/:userId/fahrgenehmigungen
*/
async getFahrgenehmigungen(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params as Record<string, string>;
const data = await memberService.getFahrgenehmigungen(userId);
res.status(200).json({ success: true, data });
} catch (error) {
logger.error('getFahrgenehmigungen error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Fahrgenehmigungen.' });
}
}
/**
* GET /api/members/:userId/ausbildungen
*/
async getAusbildungen(req: Request, res: Response): Promise<void> {
try {
const { userId } = req.params as Record<string, string>;
const data = await memberService.getAusbildungen(userId);
res.status(200).json({ success: true, data });
} catch (error) {
logger.error('getAusbildungen error', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Ausbildungen.' });
}
}
}
export default new MemberController();

View File

@@ -0,0 +1,428 @@
import { Request, Response } from 'express';
import path from 'path';
import { z } from 'zod';
import nextcloudService from '../services/nextcloud.service';
import userService from '../services/user.service';
import logger from '../utils/logger';
const PollRequestSchema = z.object({
pollEndpoint: z.string().url(),
pollToken: z.string().min(1),
});
class NextcloudController {
async initiateConnect(_req: Request, res: Response): Promise<void> {
try {
const data = await nextcloudService.initiateLoginFlow();
res.status(200).json({ success: true, data });
} catch (error) {
logger.error('initiateConnect error', { error });
res.status(500).json({ success: false, message: 'Nextcloud-Verbindung konnte nicht gestartet werden' });
}
}
async pollConnect(req: Request, res: Response): Promise<void> {
try {
const parsed = PollRequestSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const result = await nextcloudService.pollLoginFlow(parsed.data.pollEndpoint, parsed.data.pollToken);
if (!result) {
res.status(200).json({ success: true, data: { completed: false } });
return;
}
await userService.updateNextcloudCredentials(req.user!.id, result.loginName, result.appPassword);
res.status(200).json({ success: true, data: { completed: true } });
} catch (error) {
logger.error('pollConnect error', { error });
res.status(500).json({ success: false, message: 'Nextcloud-Abfrage fehlgeschlagen' });
}
}
async getConversations(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) {
res.status(200).json({ success: true, data: { connected: false } });
return;
}
const { totalUnread, conversations } = await nextcloudService.getConversations(
credentials.loginName,
credentials.appPassword,
);
res.status(200).json({ success: true, data: { connected: true, totalUnread, conversations } });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(200).json({ success: true, data: { connected: false } });
return;
}
logger.error('getConversations error', { error });
res.status(500).json({ success: false, message: 'Nextcloud-Gespräche konnten nicht geladen werden' });
}
}
async disconnect(req: Request, res: Response): Promise<void> {
try {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(200).json({ success: true, data: null });
} catch (error) {
logger.error('disconnect error', { error });
res.status(500).json({ success: false, message: 'Nextcloud-Trennung fehlgeschlagen' });
}
}
async getRooms(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) {
res.status(200).json({ success: true, data: { connected: false, rooms: [] } });
return;
}
const rooms = await nextcloudService.getAllConversations(credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data: { connected: true, rooms, loginName: credentials.loginName } });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(200).json({ success: true, data: { connected: false, rooms: [] } });
return;
}
logger.error('getRooms error', { error });
res.status(500).json({ success: false, message: 'Nextcloud-Räume konnten nicht geladen werden' });
}
}
async getMessages(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) {
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
return;
}
const token = req.params.token as string;
if (!token) {
res.status(400).json({ success: false, message: 'Room token fehlt' });
return;
}
const lookIntoFuture = req.query.lookIntoFuture === '1';
const lastKnownMessageId = req.query.lastKnownMessageId
? parseInt(req.query.lastKnownMessageId as string, 10)
: undefined;
const timeout = req.query.timeout
? Math.min(parseInt(req.query.timeout as string, 10), 25)
: 25;
const messages = await nextcloudService.getMessages(token, credentials.loginName, credentials.appPassword, {
lookIntoFuture,
lastKnownMessageId,
timeout,
});
res.status(200).json({ success: true, data: messages });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(200).json({ success: true, data: { connected: false } });
return;
}
logger.error('getMessages error', { error });
res.status(500).json({ success: false, message: 'Nachrichten konnten nicht geladen werden' });
}
}
async sendMessage(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) {
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
return;
}
const token = req.params.token as string;
const { message, replyTo } = req.body;
if (!token || !message || typeof message !== 'string' || message.trim().length === 0) {
res.status(400).json({ success: false, message: 'Token und Nachricht erforderlich' });
return;
}
if (message.length > 32000) {
res.status(400).json({ success: false, message: 'Nachricht zu lang' });
return;
}
const replyToNum = (typeof replyTo === 'number' && replyTo > 0) ? replyTo : undefined;
await nextcloudService.sendMessage(token, message.trim(), credentials.loginName, credentials.appPassword, replyToNum);
res.status(200).json({ success: true, data: null });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(200).json({ success: true, data: { connected: false } });
return;
}
logger.error('sendMessage error', { error });
res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' });
}
}
async uploadFile(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) {
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
return;
}
const token = req.params.token as string;
if (!token) {
res.status(400).json({ success: false, message: 'Room token fehlt' });
return;
}
if (!req.file) {
res.status(400).json({ success: false, message: 'Keine Datei übermittelt' });
return;
}
await nextcloudService.uploadFileToTalk(
token,
req.file.buffer,
req.file.originalname,
req.file.mimetype,
credentials.loginName,
credentials.appPassword,
);
res.status(200).json({ success: true, data: null });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(200).json({ success: true, data: { connected: false } });
return;
}
logger.error('uploadFile error', { error });
res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' });
}
}
async downloadFile(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) {
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
return;
}
const filePath = req.query.path as string;
if (!filePath) {
res.status(400).json({ success: false, message: 'Dateipfad fehlt' });
return;
}
// Path traversal protection
const normalized = path.normalize(filePath);
if (normalized.includes('..') || !normalized.startsWith('/')) {
res.status(400).json({ success: false, message: 'Ungültiger Dateipfad' });
return;
}
const response = await nextcloudService.downloadFile(
filePath,
credentials.loginName,
credentials.appPassword,
);
const contentType = response.headers['content-type'] ?? 'application/octet-stream';
const contentDisposition = response.headers['content-disposition']
?? `attachment; filename="${String(req.params.fileId).replace(/["\r\n\\]/g, '_')}"`;
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Disposition', contentDisposition);
if (response.headers['content-length']) {
res.setHeader('Content-Length', response.headers['content-length']);
}
response.data.pipe(res);
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
return;
}
logger.error('downloadFile error', { error });
res.status(500).json({ success: false, message: 'Datei konnte nicht heruntergeladen werden' });
}
}
async getFilePreview(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) {
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
return;
}
const fileId = parseInt(req.params.fileId as string, 10);
if (isNaN(fileId)) {
res.status(400).json({ success: false, message: 'Ungültige Datei-ID' });
return;
}
const w = parseInt((req.query.w as string) ?? '400', 10) || 400;
const h = parseInt((req.query.h as string) ?? '400', 10) || 400;
const response = await nextcloudService.getFilePreview(
fileId,
Math.min(w, 1200),
Math.min(h, 1200),
credentials.loginName,
credentials.appPassword,
);
const contentType = response.headers['content-type'] ?? 'image/jpeg';
res.setHeader('Content-Type', contentType);
if (response.headers['content-length']) {
res.setHeader('Content-Length', response.headers['content-length']);
}
res.setHeader('Cache-Control', 'private, max-age=300');
response.data.pipe(res);
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
return;
}
logger.error('getFilePreview error', { error });
res.status(500).json({ success: false, message: 'Vorschau konnte nicht geladen werden' });
}
}
async markRoomAsRead(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) {
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
return;
}
const token = req.params.token as string;
if (!token) {
res.status(400).json({ success: false, message: 'Room token fehlt' });
return;
}
await nextcloudService.markAsRead(token, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data: null });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(200).json({ success: true, data: { connected: false } });
return;
}
logger.error('markRoomAsRead error', { error });
res.status(500).json({ success: false, message: 'Raum konnte nicht als gelesen markiert werden' });
}
}
async searchUsers(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) {
res.status(200).json({ success: true, data: [] });
return;
}
const query = (req.query.search as string) ?? '';
const results = await nextcloudService.searchUsers(query, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data: results });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(200).json({ success: true, data: [] });
return;
}
logger.error('searchUsers error', { error });
res.status(500).json({ success: false, message: 'Benutzersuche fehlgeschlagen' });
}
}
async createRoom(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) {
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
return;
}
const { roomType, invite, roomName } = req.body;
if (typeof roomType !== 'number' || !invite || typeof invite !== 'string') {
res.status(400).json({ success: false, message: 'roomType und invite erforderlich' });
return;
}
const result = await nextcloudService.createRoom(roomType, invite, roomName, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data: result });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
await userService.clearNextcloudCredentials(req.user!.id);
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
return;
}
logger.error('createRoom error', { error });
res.status(500).json({ success: false, message: 'Raum konnte nicht erstellt werden' });
}
}
async addReaction(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
const token = req.params.token as string;
const messageId = parseInt(req.params.messageId as string, 10);
const { reaction } = req.body;
if (!token || isNaN(messageId) || !reaction) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
await nextcloudService.addReaction(token, messageId, reaction, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data: null });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
logger.error('addReaction error', { error });
res.status(500).json({ success: false, message: 'Reaktion konnte nicht hinzugefügt werden' });
}
}
async removeReaction(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
const token = req.params.token as string;
const messageId = parseInt(req.params.messageId as string, 10);
const reaction = req.query.reaction as string;
if (!token || isNaN(messageId) || !reaction) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
await nextcloudService.removeReaction(token, messageId, reaction, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data: null });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
logger.error('removeReaction error', { error });
res.status(500).json({ success: false, message: 'Reaktion konnte nicht entfernt werden' });
}
}
async getReactions(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
const token = req.params.token as string;
const messageId = parseInt(req.params.messageId as string, 10);
if (!token || isNaN(messageId)) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
const data = await nextcloudService.getReactions(token, messageId, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
logger.error('getReactions error', { error });
res.status(500).json({ success: false, message: 'Reaktionen konnten nicht geladen werden' });
}
}
async getPoll(req: Request, res: Response): Promise<void> {
try {
const credentials = await userService.getNextcloudCredentials(req.user!.id);
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
const token = req.params.token as string;
const pollId = parseInt(req.params.pollId as string, 10);
if (!token || isNaN(pollId)) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
const data = await nextcloudService.getPollDetails(token, pollId, credentials.loginName, credentials.appPassword);
res.status(200).json({ success: true, data });
} catch (error: any) {
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
logger.error('getPoll error', { error });
res.status(500).json({ success: false, message: 'Abstimmung konnte nicht geladen werden' });
}
}
}
export default new NextcloudController();

View File

@@ -0,0 +1,100 @@
// =============================================================================
// Notification Controller
// =============================================================================
import { Request, Response } from 'express';
import notificationService from '../services/notification.service';
import logger from '../utils/logger';
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 NotificationController {
/** GET /api/notifications — returns all notifications for the authenticated user. */
async getNotifications(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.id;
const notifications = await notificationService.getByUser(userId);
res.status(200).json({ success: true, data: notifications });
} catch (error) {
logger.error('NotificationController.getNotifications error', { error });
res.status(500).json({ success: false, message: 'Notifications konnten nicht geladen werden' });
}
}
/** GET /api/notifications/count — returns unread count for the authenticated user. */
async getUnreadCount(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.id;
const count = await notificationService.getUnreadCount(userId);
res.status(200).json({ success: true, data: { count } });
} catch (error) {
logger.error('NotificationController.getUnreadCount error', { error });
res.status(500).json({ success: false, message: 'Anzahl konnte nicht geladen werden' });
}
}
/** PATCH /api/notifications/:id/read — marks a single notification as read. */
async markAsRead(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 Notification-ID' });
return;
}
const userId = req.user!.id;
const updated = await notificationService.markAsRead(id, userId);
if (!updated) {
res.status(404).json({ success: false, message: 'Notification nicht gefunden' });
return;
}
res.status(200).json({ success: true, message: 'Als gelesen markiert' });
} catch (error) {
logger.error('NotificationController.markAsRead error', { error });
res.status(500).json({ success: false, message: 'Notification konnte nicht aktualisiert werden' });
}
}
/** POST /api/notifications/mark-all-read — marks all notifications as read. */
async markAllRead(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.id;
await notificationService.markAllRead(userId);
res.status(200).json({ success: true, message: 'Alle als gelesen markiert' });
} catch (error) {
logger.error('NotificationController.markAllRead error', { error });
res.status(500).json({ success: false, message: 'Notifications konnten nicht aktualisiert werden' });
}
}
/** POST /api/notifications/dismiss-by-type — marks all unread notifications of a given type as read. */
async dismissByType(req: Request, res: Response): Promise<void> {
try {
const { quellTyp } = req.body;
if (!quellTyp || typeof quellTyp !== 'string') {
res.status(400).json({ success: false, message: 'quellTyp ist erforderlich' });
return;
}
const userId = req.user!.id;
await notificationService.dismissByType(userId, quellTyp);
res.status(200).json({ success: true, message: 'Notifications als gelesen markiert' });
} catch (error) {
logger.error('NotificationController.dismissByType error', { error });
res.status(500).json({ success: false, message: 'Notifications konnten nicht aktualisiert werden' });
}
}
/** DELETE /api/notifications/read — deletes all read notifications for the authenticated user. */
async deleteAllRead(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.id;
await notificationService.deleteAllRead(userId);
res.status(200).json({ success: true, message: 'Gelesene Notifications gelöscht' });
} catch (error) {
logger.error('NotificationController.deleteAllRead error', { error });
res.status(500).json({ success: false, message: 'Gelesene Notifications konnten nicht gelöscht werden' });
}
}
}
export default new NotificationController();

View File

@@ -0,0 +1,316 @@
import { Request, Response } from 'express';
import { permissionService } from '../services/permission.service';
import pool from '../config/database';
import logger from '../utils/logger';
class PermissionController {
/**
* GET /api/permissions/me
* Returns the current user's effective permissions.
*/
async getMyPermissions(req: Request, res: Response): Promise<void> {
try {
const groups: string[] = req.user?.groups ?? [];
const isAdmin = groups.includes('dashboard_admin');
let permissions: string[];
if (isAdmin) {
// Admin gets all permissions
const matrix = await permissionService.getMatrix();
permissions = matrix.permissions.map(p => p.id);
} else {
permissions = permissionService.getEffectivePermissions(groups);
}
const maintenance = permissionService.getMaintenanceFlags();
logger.debug('GET /api/permissions/me', {
email: req.user?.email,
groups,
isAdmin,
permissionsCount: permissions.length,
maintenanceWissen: maintenance['wissen'] ?? false,
maintenanceAusruestungsanfrage: maintenance['ausruestungsanfrage'] ?? false,
});
res.json({
success: true,
data: {
permissions,
maintenance,
isAdmin,
},
});
} catch (error) {
logger.error('Failed to get user permissions', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Berechtigungen' });
}
}
/**
* GET /api/admin/permissions/matrix
* Returns the full permission matrix for the admin UI.
*/
async getMatrix(_req: Request, res: Response): Promise<void> {
try {
const matrix = await permissionService.getMatrix();
res.json({ success: true, data: matrix });
} catch (error) {
logger.error('Failed to get permission matrix', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Berechtigungsmatrix' });
}
}
/**
* PUT /api/admin/permissions/group/:groupName
* Sets all permissions for a given Authentik group.
*/
async setGroupPermissions(req: Request, res: Response): Promise<void> {
try {
const groupName = req.params.groupName as string;
const { permissions } = req.body;
if (!Array.isArray(permissions)) {
res.status(400).json({ success: false, message: 'permissions must be an array' });
return;
}
await permissionService.setGroupPermissions(
groupName,
permissions,
req.user!.id
);
res.json({ success: true, message: 'Berechtigungen aktualisiert' });
} catch (error) {
logger.error('Failed to set group permissions', { error });
res.status(500).json({ success: false, message: 'Fehler beim Speichern der Berechtigungen' });
}
}
/**
* DELETE /api/admin/permissions/group/:groupName
* Removes a group and all its permissions from the matrix.
*/
async deleteGroup(req: Request, res: Response): Promise<void> {
try {
const groupName = req.params.groupName as string;
await permissionService.deleteGroup(groupName);
res.json({ success: true, message: 'Gruppe entfernt' });
} catch (error) {
logger.error('Failed to delete group', { error });
res.status(500).json({ success: false, message: 'Fehler beim Entfernen der Gruppe' });
}
}
/**
* PUT /api/admin/permissions/bulk
* Bulk-update permissions for multiple groups in one request.
* Body: { updates: [{ group: string, permissions: string[] }] }
*/
async setBulkPermissions(req: Request, res: Response): Promise<void> {
try {
const { updates } = req.body;
if (!Array.isArray(updates)) {
res.status(400).json({ success: false, message: 'updates must be an array' });
return;
}
for (const u of updates) {
if (typeof u.group !== 'string' || !Array.isArray(u.permissions)) {
res.status(400).json({ success: false, message: 'Each update must have group (string) and permissions (array)' });
return;
}
}
const result = await permissionService.setMultipleGroupPermissions(updates, req.user!.id);
if (result.droppedPermissions.length > 0) {
res.json({
success: true,
message: `Berechtigungen aktualisiert. Warnung: ${result.droppedPermissions.length} Berechtigung(en) existieren nicht in der Datenbank und wurden ignoriert: ${result.droppedPermissions.join(', ')}`,
droppedPermissions: result.droppedPermissions,
});
} else {
res.json({ success: true, message: 'Berechtigungen aktualisiert' });
}
} catch (error) {
logger.error('Failed to set bulk permissions', { error });
res.status(500).json({ success: false, message: 'Fehler beim Speichern der Berechtigungen' });
}
}
/**
* GET /api/admin/permissions/groups
* Returns all known Authentik groups from the permission table.
*/
async getGroups(_req: Request, res: Response): Promise<void> {
try {
const groups = await permissionService.getKnownGroups();
res.json({ success: true, data: groups });
} catch (error) {
logger.error('Failed to get groups', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Gruppen' });
}
}
/**
* GET /api/admin/permissions/unknown-groups
* Returns Authentik groups found in users table but not in the permission matrix.
*/
async getUnknownGroups(_req: Request, res: Response): Promise<void> {
try {
const groups = await permissionService.getUnknownGroups();
res.json({ success: true, data: groups });
} catch (error) {
logger.error('Failed to get unknown groups', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der unbekannten Gruppen' });
}
}
/**
* PUT /api/admin/permissions/maintenance/:featureGroupId
* Toggles maintenance mode for a feature group.
*/
async setMaintenanceFlag(req: Request, res: Response): Promise<void> {
try {
const featureGroupId = req.params.featureGroupId as string;
const { active } = req.body;
if (typeof active !== 'boolean') {
res.status(400).json({ success: false, message: 'active must be a boolean' });
return;
}
await permissionService.setMaintenanceFlag(featureGroupId, active);
res.json({ success: true, message: 'Wartungsmodus aktualisiert' });
} catch (error) {
logger.error('Failed to set maintenance flag', { error });
res.status(500).json({ success: false, message: 'Fehler beim Setzen des Wartungsmodus' });
}
}
/**
* GET /api/admin/permissions/config
* Returns the dependency configuration (group hierarchy + permission deps).
*/
async getDependencyConfig(_req: Request, res: Response): Promise<void> {
try {
const config = await permissionService.getDependencyConfig();
res.json({ success: true, data: config });
} catch (error) {
logger.error('Failed to get dependency config', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Konfiguration' });
}
}
/**
* PUT /api/admin/permissions/config
* Updates the dependency configuration.
* Body: { groupHierarchy?: Record<string, string[]>, permissionDeps?: Record<string, string[]> }
*/
async setDependencyConfig(req: Request, res: Response): Promise<void> {
try {
const { groupHierarchy, permissionDeps } = req.body;
if (groupHierarchy !== undefined) {
if (typeof groupHierarchy !== 'object' || groupHierarchy === null) {
res.status(400).json({ success: false, message: 'groupHierarchy must be an object' });
return;
}
await permissionService.setGroupHierarchy(groupHierarchy, req.user!.id);
}
if (permissionDeps !== undefined) {
if (typeof permissionDeps !== 'object' || permissionDeps === null) {
res.status(400).json({ success: false, message: 'permissionDeps must be an object' });
return;
}
await permissionService.setPermissionDeps(permissionDeps, req.user!.id);
}
res.json({ success: true, message: 'Konfiguration aktualisiert' });
} catch (error) {
logger.error('Failed to set dependency config', { error });
res.status(500).json({ success: false, message: 'Fehler beim Speichern der Konfiguration' });
}
}
/**
* GET /api/permissions/users-with?permission=bestellungen:create
* Returns users who have a specific permission.
*/
async getUsersWithPermission(req: Request, res: Response): Promise<void> {
try {
const permission = req.query.permission as string;
if (!permission) {
res.status(400).json({ success: false, message: 'permission query parameter required' });
return;
}
const users = await permissionService.getUsersWithPermission(permission);
res.json({ success: true, data: users });
} catch (error) {
logger.error('Failed to get users with permission', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Benutzer' });
}
}
/**
* GET /api/permissions/debug/:userId
* Returns debug info for a specific user: their groups, resolved permissions,
* and maintenance flags. Admin only.
*/
async debugUser(req: Request, res: Response): Promise<void> {
try {
const userId = req.params.userId as string;
// Fetch user's Authentik groups from DB
const userResult = await pool.query(
'SELECT authentik_groups, email, name FROM users WHERE id = $1',
[userId]
);
if (userResult.rows.length === 0) {
res.status(404).json({ success: false, message: 'Benutzer nicht gefunden' });
return;
}
const user = userResult.rows[0];
const groups: string[] = user.authentik_groups ?? [];
const isAdmin = groups.includes('dashboard_admin');
// Resolve permissions for those groups
let permissions: string[];
if (isAdmin) {
const matrix = await permissionService.getMatrix();
permissions = matrix.permissions.map(p => p.id);
} else {
permissions = permissionService.getEffectivePermissions(groups);
}
// Maintenance flags
const maintenance = permissionService.getMaintenanceFlags();
const maintenanceActive = Object.entries(maintenance)
.filter(([, active]) => active)
.map(([featureGroup]) => featureGroup);
res.json({
success: true,
data: {
userId,
email: user.email,
name: user.name,
authentikGroups: groups,
isAdmin,
permissions,
maintenance,
maintenanceActiveFeatureGroups: maintenanceActive,
},
});
} catch (error) {
logger.error('Failed to debug user permissions', { error, userId: req.params.userId });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Debug-Informationen' });
}
}
}
export default new PermissionController();

View File

@@ -0,0 +1,224 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import personalEquipmentService from '../services/personalEquipment.service';
import settingsService from '../services/settings.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.string().min(1).max(50);
const EigenschaftInput = z.object({
eigenschaft_id: z.number().int().positive().nullable().optional(),
name: z.string().min(1).max(200),
wert: z.string().max(500),
});
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(),
menge: z.number().int().min(1).default(1).optional(),
eigenschaften: z.array(EigenschaftInput).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(),
menge: z.number().int().min(1).nullable().optional(),
eigenschaften: z.array(EigenschaftInput).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' });
}
}
async getZustandOptions(_req: Request, res: Response): Promise<void> {
try {
const setting = await settingsService.get('personal_equipment_zustand_options');
const options = Array.isArray(setting?.value) ? setting!.value : [
{ key: 'gut', label: 'Gut', color: 'success' },
{ key: 'beschaedigt', label: 'Beschädigt', color: 'warning' },
{ key: 'abgaengig', label: 'Abgängig', color: 'error' },
{ key: 'verloren', label: 'Verloren', color: 'default' },
];
res.status(200).json({ success: true, data: options });
} catch (error) {
logger.error('personalEquipment.getZustandOptions error', { error });
res.status(500).json({ success: false, message: 'Zustände konnten nicht geladen werden' });
}
}
async updateZustandOptions(req: Request, res: Response): Promise<void> {
try {
const schema = z.array(z.object({
key: z.string().min(1).max(50),
label: z.string().min(1).max(100),
color: z.enum(['success', 'warning', 'error', 'default', 'primary', 'secondary', 'info']),
})).min(1);
const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ success: false, message: 'Validierungsfehler', errors: parsed.error.flatten() });
return;
}
await settingsService.set('personal_equipment_zustand_options', parsed.data, req.user!.id);
res.status(200).json({ success: true, data: parsed.data });
} catch (error) {
logger.error('personalEquipment.updateZustandOptions error', { error });
res.status(500).json({ success: false, message: 'Zustände konnten nicht gespeichert werden' });
}
}
}
export default new PersonalEquipmentController();

View File

@@ -0,0 +1,200 @@
import { Request, Response } from 'express';
import scheduledMessagesService from '../services/scheduledMessages.service';
import logger from '../utils/logger';
class ScheduledMessagesController {
async getAll(_req: Request, res: Response): Promise<void> {
try {
const rules = await scheduledMessagesService.getAll();
res.json({ success: true, data: rules });
} catch (error) {
logger.error('ScheduledMessagesController.getAll error', { error });
res.status(500).json({ success: false, message: 'Regeln konnten nicht geladen werden' });
}
}
async getById(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const userId = req.user?.id;
const rule = await scheduledMessagesService.getById(id, userId);
if (!rule) {
res.status(404).json({ success: false, message: 'Regel nicht gefunden' });
return;
}
res.json({ success: true, data: rule });
} catch (error) {
logger.error('ScheduledMessagesController.getById error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Regel konnte nicht geladen werden' });
}
}
async create(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.id;
const rule = await scheduledMessagesService.create(req.body, userId);
res.status(201).json({ success: true, data: rule });
} catch (error) {
logger.error('ScheduledMessagesController.create error', { error });
res.status(500).json({ success: false, message: 'Regel konnte nicht erstellt werden' });
}
}
async update(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const rule = await scheduledMessagesService.update(id, req.body);
if (!rule) {
res.status(404).json({ success: false, message: 'Regel nicht gefunden' });
return;
}
res.json({ success: true, data: rule });
} catch (error) {
logger.error('ScheduledMessagesController.update error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Regel konnte nicht aktualisiert werden' });
}
}
async delete(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const deleted = await scheduledMessagesService.delete(id);
if (!deleted) {
res.status(404).json({ success: false, message: 'Regel nicht gefunden' });
return;
}
res.status(204).send();
} catch (error) {
logger.error('ScheduledMessagesController.delete error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Regel konnte nicht gelöscht werden' });
}
}
async getRooms(_req: Request, res: Response): Promise<void> {
try {
const result = await scheduledMessagesService.getRooms();
if (!result.configured) {
res.json({ configured: false });
return;
}
res.json({ configured: true, data: result.data, error: result.error });
} catch (error) {
logger.error('ScheduledMessagesController.getRooms error', { error });
res.status(500).json({ success: false, message: 'Räume konnten nicht geladen werden' });
}
}
async subscribe(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const userId = req.user!.id;
const { room_token } = req.body as Record<string, string>;
if (!room_token) {
res.status(400).json({ success: false, message: 'room_token ist erforderlich' });
return;
}
await scheduledMessagesService.subscribe(id, userId, room_token);
res.json({ success: true });
} catch (error) {
logger.error('ScheduledMessagesController.subscribe error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Abonnement konnte nicht erstellt werden' });
}
}
async getMyBotRoom(req: Request, res: Response): Promise<void> {
try {
const userId = req.user!.id;
const result = await scheduledMessagesService.getOrCreateBotRoom(userId);
if (result === null) {
res.status(400).json({ success: false, configured: false, message: 'Bot nicht konfiguriert' });
return;
}
if (result === 'not_connected') {
res.status(400).json({ success: false, connected: false, message: 'Nextcloud-Konto nicht verbunden' });
return;
}
res.json({ success: true, data: { room_token: result } });
} catch (error) {
logger.error('ScheduledMessagesController.getMyBotRoom error', { error });
res.status(500).json({ success: false, message: 'Bot-Raum konnte nicht ermittelt werden' });
}
}
async unsubscribe(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const userId = req.user!.id;
await scheduledMessagesService.unsubscribe(id, userId);
res.status(204).send();
} catch (error) {
logger.error('ScheduledMessagesController.unsubscribe error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Abonnement konnte nicht entfernt werden' });
}
}
async triggerNow(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const rule = await scheduledMessagesService.getById(id);
if (!rule) {
res.status(404).json({ success: false, message: 'Regel nicht gefunden' });
return;
}
const rooms = await scheduledMessagesService.getRooms();
if (!rooms.configured) {
res.status(400).json({ success: false, configured: false, message: 'Bot nicht konfiguriert' });
return;
}
await scheduledMessagesService.buildAndSend(rule);
res.json({ success: true });
} catch (error) {
logger.error('ScheduledMessagesController.triggerNow error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' });
}
}
async getOneTimeMessages(_req: Request, res: Response): Promise<void> {
try {
const messages = await scheduledMessagesService.getOneTimeMessages();
res.json({ success: true, data: messages });
} catch (error) {
logger.error('ScheduledMessagesController.getOneTimeMessages error', { error });
res.status(500).json({ success: false, message: 'Einzelnachrichten konnten nicht geladen werden' });
}
}
async createOneTimeMessage(req: Request, res: Response): Promise<void> {
try {
const { message, target_room_token, target_room_name, send_at } = req.body as Record<string, string>;
if (!message || !target_room_token || !send_at) {
res.status(400).json({ success: false, message: 'message, target_room_token und send_at sind erforderlich' });
return;
}
const msg = await scheduledMessagesService.createOneTimeMessage(
{ message, target_room_token, target_room_name: target_room_name ?? null, send_at },
req.user!.id,
);
res.status(201).json({ success: true, data: msg });
} catch (error) {
logger.error('ScheduledMessagesController.createOneTimeMessage error', { error });
res.status(500).json({ success: false, message: 'Einzelnachricht konnte nicht erstellt werden' });
}
}
async deleteOneTimeMessage(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const deleted = await scheduledMessagesService.deleteOneTimeMessage(id);
if (!deleted) {
res.status(404).json({ success: false, message: 'Einzelnachricht nicht gefunden' });
return;
}
res.status(204).send();
} catch (error) {
logger.error('ScheduledMessagesController.deleteOneTimeMessage error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Einzelnachricht konnte nicht gelöscht werden' });
}
}
}
export default new ScheduledMessagesController();

View File

@@ -0,0 +1,240 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import serviceMonitorService from '../services/serviceMonitor.service';
import notificationService from '../services/notification.service';
import pool from '../config/database';
import logger from '../utils/logger';
const createServiceSchema = z.object({
name: z.string().min(1).max(200),
url: z.string().url().max(500),
});
const updateServiceSchema = z.object({
name: z.string().min(1).max(200).optional(),
url: z.string().url().max(500).optional(),
is_active: z.boolean().optional(),
});
const broadcastSchema = z.object({
titel: z.string().min(1).max(200),
nachricht: z.string().min(1).max(2000),
schwere: z.enum(['info', 'warnung', 'fehler']).default('info'),
targetGroup: z.string().optional(),
targetDienstgrad: z.array(z.string()).optional(),
});
const broadcastFilterSchema = z.object({
targetGroup: z.string().optional(),
targetDienstgrad: z.array(z.string()).optional(),
});
function buildFilteredUserQuery(filters: { targetGroup?: string; targetDienstgrad?: string[] }): { text: string; values: unknown[] } {
const conditions: string[] = ['u.is_active = TRUE'];
const values: unknown[] = [];
let paramIndex = 1;
if (filters.targetGroup) {
conditions.push(`$${paramIndex} = ANY(u.authentik_groups)`);
values.push(filters.targetGroup);
paramIndex++;
}
if (filters.targetDienstgrad && filters.targetDienstgrad.length > 0) {
conditions.push(`mp.dienstgrad = ANY($${paramIndex})`);
values.push(filters.targetDienstgrad);
paramIndex++;
}
const needsJoin = filters.targetDienstgrad && filters.targetDienstgrad.length > 0;
const text = `SELECT DISTINCT u.id FROM users u${needsJoin ? ' LEFT JOIN member_profiles mp ON mp.user_id = u.id' : ''} WHERE ${conditions.join(' AND ')}`;
return { text, values };
}
class ServiceMonitorController {
async getAll(_req: Request, res: Response): Promise<void> {
try {
const services = await serviceMonitorService.getAllServices();
res.json({ success: true, data: services });
} catch (error) {
logger.error('Failed to get services', { error });
res.status(500).json({ success: false, message: 'Failed to get services' });
}
}
async create(req: Request, res: Response): Promise<void> {
try {
const { name, url } = createServiceSchema.parse(req.body);
const service = await serviceMonitorService.createService(name, url);
res.status(201).json({ success: true, data: service });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
return;
}
logger.error('Failed to create service', { error });
res.status(500).json({ success: false, message: 'Failed to create service' });
}
}
async update(req: Request, res: Response): Promise<void> {
try {
const data = updateServiceSchema.parse(req.body);
const service = await serviceMonitorService.updateService(req.params.id as string, data);
res.json({ success: true, data: service });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
return;
}
logger.error('Failed to update service', { error });
res.status(500).json({ success: false, message: 'Failed to update service' });
}
}
async delete(req: Request, res: Response): Promise<void> {
try {
const deleted = await serviceMonitorService.deleteService(req.params.id as string);
if (!deleted) {
res.status(404).json({ success: false, message: 'Service not found or is internal' });
return;
}
res.json({ success: true });
} catch (error) {
logger.error('Failed to delete service', { error });
res.status(500).json({ success: false, message: 'Failed to delete service' });
}
}
async pingAll(_req: Request, res: Response): Promise<void> {
try {
const results = await serviceMonitorService.pingAll();
res.json({ success: true, data: results });
} catch (error) {
logger.error('Failed to ping services', { error });
res.status(500).json({ success: false, message: 'Failed to ping services' });
}
}
async getStatusSummary(_req: Request, res: Response): Promise<void> {
try {
const summary = await serviceMonitorService.getStatusSummary();
res.json({ success: true, data: summary });
} catch (error) {
logger.error('Failed to get status summary', { error });
res.status(500).json({ success: false, message: 'Failed to get status summary' });
}
}
async getSystemHealth(_req: Request, res: Response): Promise<void> {
try {
let dbStatus = false;
let dbSize = '0';
try {
await pool.query('SELECT 1');
dbStatus = true;
const sizeResult = await pool.query('SELECT pg_database_size(current_database()) as size');
dbSize = sizeResult.rows[0].size;
} catch {
// DB is down
}
const mem = process.memoryUsage();
res.json({
success: true,
data: {
nodeVersion: process.version,
uptime: process.uptime(),
memoryUsage: {
heapUsed: mem.heapUsed,
heapTotal: mem.heapTotal,
rss: mem.rss,
external: mem.external,
},
dbStatus,
dbSize,
},
});
} catch (error) {
logger.error('Failed to get system health', { error });
res.status(500).json({ success: false, message: 'Failed to get system health' });
}
}
async getUsers(_req: Request, res: Response): Promise<void> {
try {
const result = await pool.query(
`SELECT id, email, name, authentik_groups as groups, is_active, last_login_at
FROM users ORDER BY name`
);
res.json({ success: true, data: result.rows });
} catch (error) {
logger.error('Failed to get users', { error });
res.status(500).json({ success: false, message: 'Failed to get users' });
}
}
async getPingHistory(req: Request, res: Response): Promise<void> {
try {
const { serviceId } = req.params;
const data = await serviceMonitorService.getPingHistory(serviceId as string);
res.json({ success: true, data });
} catch (error) {
logger.error('Failed to get ping history', { error });
res.status(500).json({ success: false, message: 'Failed to get ping history' });
}
}
async broadcastNotification(req: Request, res: Response): Promise<void> {
try {
const { titel, nachricht, schwere, targetGroup, targetDienstgrad } = broadcastSchema.parse(req.body);
const query = buildFilteredUserQuery({ targetGroup, targetDienstgrad });
const result = await pool.query(query.text, query.values);
const users = result.rows;
let sent = 0;
for (const user of users) {
await notificationService.createNotification({
user_id: user.id,
typ: 'broadcast',
titel,
nachricht,
schwere,
});
sent++;
}
res.json({ success: true, data: { sent } });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
return;
}
logger.error('Failed to broadcast notification', { error });
res.status(500).json({ success: false, message: 'Failed to broadcast notification' });
}
}
async broadcastPreview(req: Request, res: Response): Promise<void> {
try {
const { targetGroup, targetDienstgrad } = broadcastFilterSchema.parse(req.body);
const query = buildFilteredUserQuery({ targetGroup, targetDienstgrad });
const result = await pool.query(query.text, query.values);
res.json({ success: true, data: { count: result.rows.length } });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
return;
}
logger.error('Failed to preview broadcast', { error });
res.status(500).json({ success: false, message: 'Failed to preview broadcast' });
}
}
}
export default new ServiceMonitorController();

View File

@@ -0,0 +1,104 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import settingsService from '../services/settings.service';
import pool from '../config/database';
import logger from '../utils/logger';
const updateSchema = z.object({
value: z.any(),
});
const externalLinkSchema = z.array(z.object({
id: z.string().min(1),
name: z.string().min(1).max(200),
links: z.array(z.object({
name: z.string().min(1).max(200),
url: z.string().url().max(500),
})),
}));
class SettingsController {
async getAll(_req: Request, res: Response): Promise<void> {
try {
const settings = await settingsService.getAll();
res.json({ success: true, data: settings });
} catch (error) {
logger.error('Failed to get settings', { error });
res.status(500).json({ success: false, message: 'Failed to get settings' });
}
}
async get(req: Request, res: Response): Promise<void> {
try {
const setting = await settingsService.get(req.params.key as string);
if (!setting) {
res.status(404).json({ success: false, message: 'Setting not found' });
return;
}
res.json({ success: true, data: setting });
} catch (error) {
logger.error('Failed to get setting', { error });
res.status(500).json({ success: false, message: 'Failed to get setting' });
}
}
async update(req: Request, res: Response): Promise<void> {
try {
const { value } = updateSchema.parse(req.body);
// Validate external_links specifically
if ((req.params.key as string) === 'external_links') {
externalLinkSchema.parse(value);
}
const setting = await settingsService.set(req.params.key as string, value, req.user!.id);
res.json({ success: true, data: setting });
} catch (error) {
if (error instanceof z.ZodError) {
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
return;
}
logger.error('Failed to update setting', { error });
res.status(500).json({ success: false, message: 'Failed to update setting' });
}
}
async getUserPreferences(req: Request, res: Response): Promise<void> {
try {
const userId = (req as any).user.id;
const result = await pool.query('SELECT preferences FROM users WHERE id = $1', [userId]);
const prefs = result.rows[0]?.preferences ?? {};
res.json({ success: true, data: prefs });
} catch (error) {
logger.error('Failed to get user preferences', { error });
res.status(500).json({ success: false, message: 'Failed to get user preferences' });
}
}
async updateUserPreferences(req: Request, res: Response): Promise<void> {
try {
const userId = (req as any).user.id;
const preferences = req.body;
// Basic validation — reject excessively large or non-object payloads
if (typeof preferences !== 'object' || preferences === null || Array.isArray(preferences)) {
res.status(400).json({ success: false, message: 'Preferences must be a JSON object' });
return;
}
if (JSON.stringify(preferences).length > 10_000) {
res.status(400).json({ success: false, message: 'Preferences payload too large' });
return;
}
await pool.query(
'UPDATE users SET preferences = $1 WHERE id = $2',
[JSON.stringify(preferences), userId]
);
res.json({ success: true });
} catch (error) {
logger.error('Failed to update user preferences', { error });
res.status(500).json({ success: false, message: 'Failed to update user preferences' });
}
}
}
export default new SettingsController();

View File

@@ -0,0 +1,184 @@
import { Request, Response } from 'express';
import httpClient from '../config/httpClient';
import toolConfigService from '../services/toolConfig.service';
import settingsService from '../services/settings.service';
import logger from '../utils/logger';
const TOOL_SETTINGS_KEYS: Record<string, string> = {
bookstack: 'tool_config_bookstack',
vikunja: 'tool_config_vikunja',
nextcloud: 'tool_config_nextcloud',
};
const MASKED_FIELDS = ['tokenSecret', 'apiToken', 'bot_app_password'];
function maskValue(value: string): string {
if (!value || value.length <= 4) return '****';
return '*'.repeat(value.length - 4) + value.slice(-4);
}
function maskConfig(config: Record<string, string>): Record<string, string> {
const result: Record<string, string> = {};
for (const [k, v] of Object.entries(config)) {
result[k] = (MASKED_FIELDS.includes(k) && v) ? maskValue(v) : v;
}
return result;
}
/**
* Validates that a URL is safe to use as an outbound service endpoint.
* Rejects non-http(s) protocols and private/loopback IP ranges to prevent SSRF.
*/
function isValidServiceUrl(raw: string): boolean {
let parsed: URL;
try {
parsed = new URL(raw);
} catch {
return false;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return false;
}
const hostname = parsed.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '::1') {
return false;
}
const ipv4Parts = hostname.split('.');
if (ipv4Parts.length === 4) {
const [a, b] = ipv4Parts.map(Number);
if (
a === 127 ||
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
) {
return false;
}
}
return true;
}
class ToolConfigController {
async getConfig(req: Request, res: Response): Promise<void> {
const tool = req.params.tool as string;
if (!TOOL_SETTINGS_KEYS[tool]) {
res.status(400).json({ success: false, message: 'Unbekanntes Tool' });
return;
}
try {
let config: Record<string, string>;
if (tool === 'bookstack') {
config = await toolConfigService.getBookstackConfig() as unknown as Record<string, string>;
} else if (tool === 'vikunja') {
config = await toolConfigService.getVikunjaConfig() as unknown as Record<string, string>;
} else {
config = await toolConfigService.getNextcloudConfig() as unknown as Record<string, string>;
}
res.status(200).json({ success: true, data: maskConfig(config) });
} catch (error) {
logger.error('ToolConfigController.getConfig error', { error, tool });
res.status(500).json({ success: false, message: 'Konfiguration konnte nicht geladen werden' });
}
}
async updateConfig(req: Request, res: Response): Promise<void> {
const tool = req.params.tool as string;
const settingsKey = TOOL_SETTINGS_KEYS[tool];
if (!settingsKey) {
res.status(400).json({ success: false, message: 'Unbekanntes Tool' });
return;
}
try {
const userId = req.user!.id;
const existing = await settingsService.get(settingsKey);
const current = existing?.value && typeof existing.value === 'object' ? existing.value as Record<string, unknown> : {};
const incoming: Record<string, unknown> = { ...req.body };
for (const key of Object.keys(incoming)) {
if (typeof incoming[key] === 'string' && /^\*+/.test(incoming[key] as string)) {
delete incoming[key];
}
}
const merged = { ...current, ...incoming };
await settingsService.set(settingsKey, merged, userId);
toolConfigService.clearCache();
res.status(200).json({ success: true, message: 'Konfiguration gespeichert' });
} catch (error) {
logger.error('ToolConfigController.updateConfig error', { error, tool });
res.status(500).json({ success: false, message: 'Konfiguration konnte nicht gespeichert werden' });
}
}
async testConnection(req: Request, res: Response): Promise<void> {
const tool = req.params.tool as string;
if (!TOOL_SETTINGS_KEYS[tool]) {
res.status(400).json({ success: false, message: 'Unbekanntes Tool' });
return;
}
try {
let url: string;
let requestConfig: { headers?: Record<string, string> } = {};
if (tool === 'bookstack') {
const current = await toolConfigService.getBookstackConfig();
const merged = { ...current, ...req.body };
url = merged.url;
if (!url || !isValidServiceUrl(url)) {
res.status(200).json({ success: false, message: 'Ungültige URL', latencyMs: 0 });
return;
}
requestConfig.headers = {
'Authorization': `Token ${merged.tokenId}:${merged.tokenSecret}`,
'Content-Type': 'application/json',
};
url = `${url}/api/books?count=1`;
} else if (tool === 'vikunja') {
const current = await toolConfigService.getVikunjaConfig();
const merged = { ...current, ...req.body };
url = merged.url;
if (!url || !isValidServiceUrl(url)) {
res.status(200).json({ success: false, message: 'Ungültige URL', latencyMs: 0 });
return;
}
requestConfig.headers = {
'Authorization': `Bearer ${merged.apiToken}`,
'Content-Type': 'application/json',
};
url = `${url}/api/v1/info`;
} else {
const current = await toolConfigService.getNextcloudConfig();
const merged = { ...current, ...req.body };
url = merged.url;
if (!url || !isValidServiceUrl(url)) {
res.status(200).json({ success: false, message: 'Ungültige URL', latencyMs: 0 });
return;
}
url = `${url}/status.php`;
}
const start = Date.now();
await httpClient.get(url, { ...requestConfig, timeout: 10_000 });
const latencyMs = Date.now() - start;
res.status(200).json({ success: true, message: 'Verbindung erfolgreich', latencyMs });
} catch (error: any) {
const latencyMs = 0;
const status = error?.response?.status;
const message = status
? `Verbindung fehlgeschlagen (HTTP ${status})`
: 'Verbindung fehlgeschlagen';
logger.error('ToolConfigController.testConnection error', { error, tool });
res.status(200).json({ success: false, message, latencyMs });
}
}
}
export default new ToolConfigController();

View File

@@ -0,0 +1,346 @@
import { Request, Response } from 'express';
import trainingService from '../services/training.service';
import {
CreateUebungSchema,
UpdateUebungSchema,
UpdateRsvpSchema,
MarkAttendanceSchema,
CancelEventSchema,
} from '../models/training.model';
import logger from '../utils/logger';
import environment from '../config/environment';
class TrainingController {
// -------------------------------------------------------------------------
// GET /api/training
// -------------------------------------------------------------------------
getUpcoming = async (req: Request, res: Response): Promise<void> => {
try {
const limit = Math.min(Number(req.query.limit ?? 10), 50);
const userId = req.user?.id;
const events = await trainingService.getUpcomingEvents(limit, userId);
res.json({ success: true, data: events });
} catch (error) {
logger.error('getUpcoming error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltungen' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/calendar?from=&to=
// -------------------------------------------------------------------------
getCalendarRange = async (req: Request, res: Response): Promise<void> => {
try {
const fromStr = req.query.from as string | undefined;
const toStr = req.query.to as string | undefined;
if (!fromStr || !toStr) {
res.status(400).json({
success: false,
message: 'Query-Parameter "from" und "to" sind erforderlich (ISO-8601)',
});
return;
}
const from = new Date(fromStr);
const to = new Date(toStr);
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
res.status(400).json({ success: false, message: 'Ungültiges Datumsformat' });
return;
}
if (to < from) {
res.status(400).json({ success: false, message: '"to" muss nach "from" liegen' });
return;
}
const userId = req.user?.id;
const events = await trainingService.getEventsByDateRange(from, to, userId);
res.json({ success: true, data: events });
} catch (error) {
logger.error('getCalendarRange error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalenders' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/calendar.ics?token=<calendarToken>
//
// Auth design: We use a per-user opaque token stored in `calendar_tokens`.
// This allows calendar clients (Apple Calendar, Google Calendar) to subscribe
// without sending a Bearer token on every refresh. The token is long-lived
// and can be rotated by calling POST /api/training/calendar-token/rotate.
//
// TRADEOFF DISCUSSION:
// Option A (used here) — Personal calendar token:
// + Works natively in all calendar apps (no auth headers needed)
// + Rotating the token invalidates stale subscriptions
// - Token embedded in URL is visible in server logs / browser history
// → Mitigation: tokens are 256-bit random, never contain PII
//
// Option B — Bearer auth only:
// + No token in URL
// - Most native calendar apps cannot send custom headers;
// requires a proxy or special client
// → Rejected for this use case (firefighter mobile phones)
//
// Option C — Publicly readable feed (no auth):
// + Simplest
// - Leaks member names and attendance status to anyone with the URL
// → Rejected on privacy grounds
// -------------------------------------------------------------------------
getIcalExport = async (req: Request, res: Response): Promise<void> => {
try {
const token = req.query.token as string | undefined;
let userId: string | undefined;
if (token) {
const resolved = await trainingService.resolveCalendarToken(token);
if (!resolved) {
res.status(401).json({ success: false, message: 'Ungültiger Kalender-Token' });
return;
}
userId = resolved;
} else if (req.user) {
// Also accept a normal Bearer token for direct browser access
userId = req.user.id;
}
// userId may be undefined → returns all events without personal RSVP info
const ics = await trainingService.getCalendarExport(userId);
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
res.setHeader('Content-Disposition', 'attachment; filename="feuerwehr-rems.ics"');
// 30-minute cache — calendar clients typically re-fetch at this interval
res.setHeader('Cache-Control', 'max-age=1800, public');
res.send(ics);
} catch (error) {
logger.error('getIcalExport error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/calendar-token (authenticated — get or create token)
// -------------------------------------------------------------------------
getCalendarToken = async (req: Request, res: Response): Promise<void> => {
try {
if (!req.user) {
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
return;
}
const token = await trainingService.getOrCreateCalendarToken(req.user.id);
const baseUrl = environment.cors.origin.replace(/\/$/, '');
const subscribeUrl = `${baseUrl.replace('localhost:3001', 'localhost:3000')}/api/training/calendar.ics?token=${token}`;
res.json({
success: true,
data: {
token,
subscribeUrl,
instructions: 'Kopiere diese URL und füge sie in "Kalender abonnieren" ein (Apple Kalender, Google Calendar, Thunderbird, etc.).',
},
});
} catch (error) {
logger.error('getCalendarToken error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalender-Tokens' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/stats?year=2026
// -------------------------------------------------------------------------
getStats = async (req: Request, res: Response): Promise<void> => {
try {
const year = Number(req.query.year ?? new Date().getFullYear());
if (isNaN(year) || year < 2000 || year > 2100) {
res.status(400).json({ success: false, message: 'Ungültiges Jahr' });
return;
}
const stats = await trainingService.getMemberParticipationStats(year);
res.json({ success: true, data: stats, meta: { year } });
} catch (error) {
logger.error('getStats error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistiken' });
}
};
// -------------------------------------------------------------------------
// GET /api/training/:id
// -------------------------------------------------------------------------
getById = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const userId = req.user?.id;
// Determine if the requester may see the full attendee list
// In v1 we use a simple check: if the user has training:write permission
// (Kommandant / Gruppenführer), populate teilnahmen.
// The permission flag is set on req by requirePermission middleware,
// but we check it here by convention.
const canSeeTeilnahmen = (req as any).canSeeTeilnahmen === true;
const event = await trainingService.getEventById(id, userId, canSeeTeilnahmen);
if (!event) {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
res.json({ success: true, data: event });
} catch (error) {
logger.error('getById error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// POST /api/training
// -------------------------------------------------------------------------
createEvent = async (req: Request, res: Response): Promise<void> => {
try {
const parsed = CreateUebungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const event = await trainingService.createEvent(parsed.data, req.user!.id);
res.status(201).json({ success: true, data: event });
} catch (error) {
logger.error('createEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erstellen der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// PATCH /api/training/:id
// -------------------------------------------------------------------------
updateEvent = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const parsed = UpdateUebungSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const event = await trainingService.updateEvent(id, parsed.data, req.user!.id);
res.json({ success: true, data: event });
} catch (error: any) {
if (error.message === 'Event not found') {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
logger.error('updateEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// DELETE /api/training/:id (soft cancel)
// -------------------------------------------------------------------------
cancelEvent = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const parsed = CancelEventSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
await trainingService.cancelEvent(id, parsed.data.absage_grund, req.user!.id);
res.json({ success: true, message: 'Veranstaltung wurde abgesagt' });
} catch (error: any) {
if (error.message === 'Event not found') {
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
return;
}
logger.error('cancelEvent error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Absagen der Veranstaltung' });
}
};
// -------------------------------------------------------------------------
// PATCH /api/training/:id/attendance — own RSVP
// -------------------------------------------------------------------------
updateRsvp = async (req: Request, res: Response): Promise<void> => {
try {
const { id: uebungId } = req.params as Record<string, string>;
const userId = req.user!.id;
const parsed = UpdateRsvpSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
await trainingService.updateAttendanceRSVP(
uebungId,
userId,
parsed.data.status,
parsed.data.bemerkung
);
res.json({ success: true, message: 'Rückmeldung gespeichert' });
} catch (error) {
logger.error('updateRsvp error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Speichern der Rückmeldung' });
}
};
// -------------------------------------------------------------------------
// POST /api/training/:id/attendance/mark — bulk mark as erschienen
// -------------------------------------------------------------------------
markAttendance = async (req: Request, res: Response): Promise<void> => {
try {
const { id: uebungId } = req.params as Record<string, string>;
const parsed = MarkAttendanceSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
await trainingService.markAttendance(uebungId, parsed.data.userIds, req.user!.id);
res.json({
success: true,
message: `${parsed.data.userIds.length} Personen als erschienen markiert`,
});
} catch (error) {
logger.error('markAttendance error', { error });
res.status(500).json({ success: false, message: 'Fehler beim Erfassen der Anwesenheit' });
}
};
}
export default new TrainingController();

View File

@@ -0,0 +1,463 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import vehicleService from '../services/vehicle.service';
import equipmentService from '../services/equipment.service';
import scheduledMessagesService from '../services/scheduledMessages.service';
import { FahrzeugStatus } from '../models/vehicle.model';
import logger from '../utils/logger';
// ── UUID validation ───────────────────────────────────────────────────────────
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);
}
// ── Zod Validation Schemas ────────────────────────────────────────────────────
const FahrzeugStatusEnum = z.enum([
FahrzeugStatus.Einsatzbereit,
FahrzeugStatus.AusserDienstWartung,
FahrzeugStatus.AusserDienstSchaden,
]);
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 CreateFahrzeugSchema = z.object({
bezeichnung: z.string().min(1).max(100),
kurzname: z.string().max(20).optional(),
amtliches_kennzeichen: z.string().max(20).optional(),
fahrgestellnummer: z.string().max(50).optional(),
baujahr: z.number().int().min(1950).max(2100).optional(),
hersteller: z.string().max(100).optional(),
typ_schluessel: z.string().max(30).optional(),
besatzung_soll: z.string().max(10).optional(),
status: FahrzeugStatusEnum.optional(),
status_bemerkung: z.string().max(500).optional(),
standort: z.string().min(1).max(100).optional(),
bild_url: z.string().url().max(500).refine(
(url) => /^https?:\/\//i.test(url),
'Nur http/https URLs erlaubt'
).optional(),
paragraph57a_faellig_am: isoDate.optional(),
naechste_wartung_am: isoDate.optional(),
});
const UpdateFahrzeugSchema = z.object({
bezeichnung: z.string().min(1).max(100).optional(),
kurzname: z.string().max(20).nullable().optional(),
amtliches_kennzeichen: z.string().max(20).nullable().optional(),
fahrgestellnummer: z.string().max(50).nullable().optional(),
baujahr: z.number().int().min(1950).max(2100).nullable().optional(),
hersteller: z.string().max(100).nullable().optional(),
typ_schluessel: z.string().max(30).nullable().optional(),
besatzung_soll: z.string().max(10).nullable().optional(),
status: FahrzeugStatusEnum.optional(),
status_bemerkung: z.string().max(500).nullable().optional(),
standort: z.string().min(1).max(100).optional(),
bild_url: z.string().url().max(500).refine(
(url) => /^https?:\/\//i.test(url),
'Nur http/https URLs erlaubt'
).nullable().optional(),
paragraph57a_faellig_am: isoDate.nullable().optional(),
naechste_wartung_am: isoDate.nullable().optional(),
});
const isoDatetime = z.string().datetime({ offset: true, message: 'Erwartet ISO-8601 Datum mit Zeitzone' });
const UpdateStatusSchema = z.object({
status: FahrzeugStatusEnum,
bemerkung: z.string().max(500).optional().default(''),
ausserDienstVon: isoDatetime.optional(),
ausserDienstBis: isoDatetime.optional(),
}).refine(
(d) => {
const isAusserDienst = d.status === FahrzeugStatus.AusserDienstWartung || d.status === FahrzeugStatus.AusserDienstSchaden;
if (!isAusserDienst) return true;
return !!d.ausserDienstVon && !!d.ausserDienstBis;
},
{ message: 'Außer-Dienst-Zeitraum (von + bis) ist bei diesem Status erforderlich', path: ['ausserDienstVon'] }
).refine(
(d) => {
if (!d.ausserDienstVon || !d.ausserDienstBis) return true;
return new Date(d.ausserDienstBis) > new Date(d.ausserDienstVon);
},
{ 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(),
beschreibung: z.string().min(1).max(2000),
km_stand: z.number().int().min(0).optional(),
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 ────────────────────────────────────────────────────────────────────
function getUserId(req: Request): string {
return req.user!.id;
}
// ── Controller ────────────────────────────────────────────────────────────────
class VehicleController {
async listVehicles(_req: Request, res: Response): Promise<void> {
try {
const vehicles = await vehicleService.getAllVehicles();
res.status(200).json({ success: true, data: vehicles });
} catch (error) {
logger.error('listVehicles error', { error });
res.status(500).json({ success: false, message: 'Fahrzeuge konnten nicht geladen werden' });
}
}
async getStats(_req: Request, res: Response): Promise<void> {
try {
const stats = await vehicleService.getVehicleStats();
res.status(200).json({ success: true, data: stats });
} catch (error) {
logger.error('getStats error', { error });
res.status(500).json({ success: false, message: 'Statistiken konnten nicht geladen werden' });
}
}
async getAlerts(req: Request, res: Response): Promise<void> {
try {
const raw = parseInt((req.query.daysAhead as string) || '30', 10);
if (isNaN(raw) || raw < 0) {
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
return;
}
const daysAhead = Math.min(raw, 365);
const alerts = await vehicleService.getUpcomingInspections(daysAhead);
res.status(200).json({ success: true, data: alerts });
} catch (error) {
logger.error('getAlerts error', { error });
res.status(500).json({ success: false, message: 'Prüfungshinweise konnten nicht geladen werden' });
}
}
async exportAlerts(_req: Request, res: Response): Promise<void> {
try {
const escape = (v: unknown): string => {
if (v === null || v === undefined) return '';
const str = String(v);
let safe = str.replace(/"/g, '""');
if (/^[=+@\-]/.test(safe)) safe = "'" + safe;
return `"${safe}"`;
};
const formatDate = (d: Date | string | null): string => {
if (!d) return '';
const date = typeof d === 'string' ? new Date(d) : d;
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}.${month}.${year}`;
};
const [vehicleAlerts, equipmentAlerts] = await Promise.all([
vehicleService.getUpcomingInspections(365),
equipmentService.getUpcomingInspections(365),
]);
const header = 'Typ,Bezeichnung,Kurzname,Prüfungsart,Fällig am,Tage verbleibend';
const rows: string[] = [];
for (const a of vehicleAlerts) {
const pruefungsart = a.type === '57a' ? '§57a Überprüfung' : 'Nächste Wartung';
rows.push([
escape('Fahrzeug'),
escape(a.bezeichnung),
escape(a.kurzname),
escape(pruefungsart),
escape(formatDate(a.faelligAm)),
escape(a.tage),
].join(','));
}
for (const e of equipmentAlerts) {
rows.push([
escape('Ausrüstung'),
escape(e.bezeichnung),
escape(''),
escape('Prüfung'),
escape(formatDate(e.naechste_pruefung_am)),
escape(e.pruefung_tage_bis_faelligkeit),
].join(','));
}
const today = new Date();
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const csv = '\uFEFF' + header + '\n' + rows.join('\n');
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="pruefungen_${dateStr}.csv"`);
res.status(200).send(csv);
} catch (error) {
logger.error('exportAlerts error', { error });
res.status(500).json({ success: false, message: 'Prüfungsexport konnte nicht erstellt werden' });
}
}
async getVehicle(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 Fahrzeug-ID' });
return;
}
const vehicle = await vehicleService.getVehicleById(id);
if (!vehicle) {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
res.status(200).json({ success: true, data: vehicle });
} catch (error) {
logger.error('getVehicle error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht geladen werden' });
}
}
async createVehicle(req: Request, res: Response): Promise<void> {
try {
const parsed = CreateFahrzeugSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req));
res.status(201).json({ success: true, data: vehicle });
} catch (error) {
logger.error('createVehicle error', { error });
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht erstellt werden' });
}
}
async updateVehicle(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 Fahrzeug-ID' });
return;
}
const parsed = UpdateFahrzeugSchema.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 vehicle = await vehicleService.updateVehicle(id, parsed.data, getUserId(req));
res.status(200).json({ success: true, data: vehicle });
} catch (error: any) {
if (error?.message === 'Vehicle not found') {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
logger.error('updateVehicle error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht aktualisiert werden' });
}
}
async updateVehicleStatus(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 Fahrzeug-ID' });
return;
}
const parsed = UpdateStatusSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const io = req.app.get('io') ?? undefined;
const result = await vehicleService.updateVehicleStatus(
id,
parsed.data.status,
parsed.data.bemerkung,
getUserId(req),
io,
parsed.data.ausserDienstVon ? new Date(parsed.data.ausserDienstVon) : null,
parsed.data.ausserDienstBis ? new Date(parsed.data.ausserDienstBis) : null,
);
// Fire-and-forget: notify scheduled messages when vehicle goes out of service
if (parsed.data.status !== FahrzeugStatus.Einsatzbereit) {
scheduledMessagesService.sendVehicleEvent(id).catch(err => {
logger.error('Failed to send vehicle event notification', {
vehicleId: id,
error: err instanceof Error ? err.message : String(err),
});
});
}
res.status(200).json({ success: true, data: result });
} catch (error: any) {
if (error?.message === 'Vehicle not found') {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
logger.error('updateVehicleStatus error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' });
}
}
async addWartung(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 Fahrzeug-ID' });
return;
}
const parsed = CreateWartungslogSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: parsed.error.flatten().fieldErrors,
});
return;
}
const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req));
res.status(201).json({ success: true, data: entry });
} catch (error: any) {
if (error?.message === 'Vehicle not found') {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
logger.error('addWartung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
}
}
async deleteVehicle(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 Fahrzeug-ID' });
return;
}
await vehicleService.deleteVehicle(id, getUserId(req));
res.status(204).send();
} catch (error: any) {
if (error?.message === 'Vehicle not found') {
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
return;
}
logger.error('deleteVehicle error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht gelöscht werden' });
}
}
async getWartung(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 Fahrzeug-ID' });
return;
}
const entries = await vehicleService.getWartungslogForVehicle(id);
res.status(200).json({ success: true, data: entries });
} catch (error) {
logger.error('getWartung error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Wartungslog konnte nicht geladen werden' });
}
}
async getStatusHistory(req: Request, res: Response): Promise<void> {
try {
const { id } = req.params as Record<string, string>;
const history = await vehicleService.getStatusHistory(id);
res.status(200).json({ success: true, data: history });
} catch (error) {
logger.error('getStatusHistory error', { error, id: req.params.id });
res.status(500).json({ success: false, message: 'Status-Historie konnte nicht geladen werden' });
}
}
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);
if (isNaN(id)) {
res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' });
return;
}
const file = (req as any).file;
if (!file) {
res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' });
return;
}
try {
const result = await vehicleService.updateWartungslogFile(id, file.path);
res.status(200).json({ success: true, data: result });
} catch (error) {
logger.error('uploadWartungFile error', { error, wartungId });
res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' });
}
}
}
export default new VehicleController();

View File

@@ -0,0 +1,97 @@
import { Request, Response } from 'express';
import vikunjaService from '../services/vikunja.service';
import notificationService from '../services/notification.service';
import environment from '../config/environment';
import logger from '../utils/logger';
class VikunjaController {
async getMyTasks(_req: Request, res: Response): Promise<void> {
if (!environment.vikunja.url) {
logger.warn('Vikunja not configured VIKUNJA_URL is empty');
res.status(200).json({ success: true, data: [], configured: false });
return;
}
try {
const tasks = await vikunjaService.getMyTasks();
res.status(200).json({ success: true, data: tasks, configured: true, vikunjaUrl: environment.vikunja.url });
} catch (error) {
logger.error('VikunjaController.getMyTasks error', { error });
res.status(500).json({ success: false, message: 'Vikunja konnte nicht abgefragt werden' });
}
}
async getOverdueTasks(req: Request, res: Response): Promise<void> {
if (!environment.vikunja.url) {
logger.warn('Vikunja not configured VIKUNJA_URL is empty');
res.status(200).json({ success: true, data: [], configured: false });
return;
}
try {
const tasks = await vikunjaService.getOverdueTasks();
// Side-effect: create notifications for each overdue task (dedup via DB constraint)
if (req.user?.id && tasks.length > 0) {
const userId = req.user.id;
for (const task of tasks) {
await notificationService.createNotification({
user_id: userId,
typ: 'vikunja_task',
titel: 'Überfällige Aufgabe',
nachricht: task.title,
schwere: 'warnung',
link: environment.vikunja.url ? `${environment.vikunja.url}/tasks/${task.id}` : undefined,
quell_id: String(task.id),
quell_typ: 'vikunja_task',
});
}
}
res.status(200).json({ success: true, data: tasks, configured: true, vikunjaUrl: environment.vikunja.url });
} catch (error) {
logger.error('VikunjaController.getOverdueTasks error', { error });
res.status(500).json({ success: false, message: 'Vikunja konnte nicht abgefragt werden' });
}
}
async getProjects(_req: Request, res: Response): Promise<void> {
if (!environment.vikunja.url) {
logger.warn('Vikunja not configured VIKUNJA_URL is empty');
res.status(200).json({ success: true, data: [], configured: false });
return;
}
try {
const projects = await vikunjaService.getProjects();
res.status(200).json({ success: true, data: projects, configured: true });
} catch (error) {
logger.error('VikunjaController.getProjects error', { error });
res.status(500).json({ success: false, message: 'Vikunja-Projekte konnten nicht geladen werden' });
}
}
async createTask(req: Request, res: Response): Promise<void> {
if (!environment.vikunja.url) {
res.status(503).json({ success: false, message: 'Vikunja ist nicht eingerichtet' });
return;
}
const { projectId, title, dueDate } = req.body as {
projectId?: number;
title?: string;
dueDate?: string;
};
if (!projectId || !title || title.trim().length === 0) {
res.status(400).json({ success: false, message: 'Projekt und Titel sind erforderlich' });
return;
}
try {
const task = await vikunjaService.createTask(projectId, title.trim(), dueDate);
res.status(201).json({ success: true, data: task });
} catch (error) {
logger.error('VikunjaController.createTask error', { error });
res.status(500).json({ success: false, message: 'Aufgabe konnte nicht erstellt werden' });
}
}
}
export default new VikunjaController();

View File

@@ -0,0 +1,146 @@
-- =============================================================================
-- Migration 002: Audit Log Table
-- GDPR Art. 5(2) Accountability + Art. 30 Records of Processing Activities
--
-- Design decisions:
-- - UUID primary key (consistent with users table)
-- - user_id is nullable: pre-auth events (LOGIN failures) have no user yet
-- - old_value / new_value are JSONB: flexible, queryable, indexable
-- - ip_address stored as TEXT (supports IPv4 + IPv6); anonymised after 90 days
-- - Table is immutable: a PostgreSQL RULE blocks UPDATE and DELETE at the
-- SQL level, which is simpler than triggers and cannot be bypassed by the
-- application role
-- - Partial index on ip_address covers only recent rows (90-day window) to
-- support efficient anonymisation queries without a full-table scan
-- =============================================================================
-- -------------------------------------------------------
-- 1. Enums — define before the table
-- -------------------------------------------------------
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_action') THEN
CREATE TYPE audit_action AS ENUM (
'CREATE',
'UPDATE',
'DELETE',
'LOGIN',
'LOGOUT',
'EXPORT',
'PERMISSION_DENIED',
'PASSWORD_CHANGE',
'ROLE_CHANGE'
);
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_resource_type') THEN
CREATE TYPE audit_resource_type AS ENUM (
'MEMBER',
'INCIDENT',
'VEHICLE',
'EQUIPMENT',
'QUALIFICATION',
'USER',
'SYSTEM'
);
END IF;
END
$$;
-- -------------------------------------------------------
-- 2. Core audit_log table
-- -------------------------------------------------------
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
user_email VARCHAR(255), -- denormalised snapshot; users can be deleted
action audit_action NOT NULL,
resource_type audit_resource_type NOT NULL,
resource_id VARCHAR(255), -- may be UUID, numeric ID, or 'system'
old_value JSONB, -- state before the operation
new_value JSONB, -- state after the operation
ip_address TEXT, -- anonymised to '[anonymized]' after 90 days
user_agent TEXT,
metadata JSONB DEFAULT '{}', -- any extra context (e.g. export format, reason)
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Prevent modification of audit records at the database level.
-- Using RULE rather than a trigger because rules are evaluated before
-- the operation and cannot be disabled without superuser access.
CREATE OR REPLACE RULE audit_log_no_update AS
ON UPDATE TO audit_log DO INSTEAD NOTHING;
CREATE OR REPLACE RULE audit_log_no_delete AS
ON DELETE TO audit_log DO INSTEAD NOTHING;
-- -------------------------------------------------------
-- 3. Indexes
-- -------------------------------------------------------
-- Lookup by actor (admin "what did this user do?")
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id
ON audit_log (user_id)
WHERE user_id IS NOT NULL;
-- Lookup by subject resource (admin "what happened to member X?")
CREATE INDEX IF NOT EXISTS idx_audit_log_resource
ON audit_log (resource_type, resource_id);
-- Time-range queries and retention scans (most common filter)
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at
ON audit_log (created_at DESC);
-- Action-type filter
CREATE INDEX IF NOT EXISTS idx_audit_log_action
ON audit_log (action);
-- Partial index: only rows where IP address is not yet anonymised.
-- The anonymisation job does: WHERE created_at < NOW() - INTERVAL '90 days'
-- AND ip_address != '[anonymized]'
-- This index makes that query O(matched rows) instead of O(table size).
CREATE INDEX IF NOT EXISTS idx_audit_log_ip_retention
ON audit_log (created_at)
WHERE ip_address IS NOT NULL
AND ip_address != '[anonymized]';
-- -------------------------------------------------------
-- 4. OPTIONAL — Range partitioning by month (recommended
-- for high-volume deployments; skip in small setups)
--
-- To use partitioning, replace the CREATE TABLE above with
-- the following DDL *before* the first row is inserted.
-- Partitioning cannot be added to an existing unpartitioned
-- table without a full rewrite (pg_partman can automate this).
--
-- CREATE TABLE audit_log (
-- id UUID NOT NULL DEFAULT uuid_generate_v4(),
-- user_id UUID REFERENCES users(id) ON DELETE SET NULL,
-- user_email VARCHAR(255),
-- action audit_action NOT NULL,
-- resource_type audit_resource_type NOT NULL,
-- resource_id VARCHAR(255),
-- old_value JSONB,
-- new_value JSONB,
-- ip_address TEXT,
-- user_agent TEXT,
-- metadata JSONB DEFAULT '{}',
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- ) PARTITION BY RANGE (created_at);
--
-- -- Create monthly partitions with pg_partman or manually:
-- CREATE TABLE audit_log_2026_01 PARTITION OF audit_log
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
-- CREATE TABLE audit_log_2026_02 PARTITION OF audit_log
-- FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- -- ... and so on. pg_partman automates partition creation.
--
-- With partitioning, old partitions can be DROPped for
-- efficient bulk retention deletion without a full-table scan.
-- -------------------------------------------------------

View File

@@ -0,0 +1,138 @@
-- Migration: 003_create_mitglieder_profile
-- Creates the mitglieder_profile and dienstgrad_verlauf tables for fire department member data.
-- Rollback:
-- DROP TABLE IF EXISTS dienstgrad_verlauf;
-- DROP TABLE IF EXISTS mitglieder_profile;
-- ============================================================
-- mitglieder_profile
-- One-to-one extension of the users table.
-- A user can exist without a profile (profile is created later
-- by a Kommandant). The user_id is both FK and UNIQUE.
-- ============================================================
CREATE TABLE IF NOT EXISTS mitglieder_profile (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Internal member identifier assigned by the Kommandant
mitglieds_nr VARCHAR(32) UNIQUE,
-- Rank (Dienstgrad) with allowed values enforced by CHECK
dienstgrad VARCHAR(64)
CHECK (dienstgrad IS NULL OR dienstgrad IN (
'Feuerwehranwärter',
'Feuerwehrmann',
'Feuerwehrfrau',
'Oberfeuerwehrmann',
'Oberfeuerwehrfrau',
'Hauptfeuerwehrmann',
'Hauptfeuerwehrfrau',
'Löschmeister',
'Oberlöschmeister',
'Hauptlöschmeister',
'Brandmeister',
'Oberbrandmeister',
'Hauptbrandmeister',
'Brandinspektor',
'Oberbrandinspektor',
'Brandoberinspektor',
'Brandamtmann'
)),
dienstgrad_seit DATE,
-- Funktion(en) — a member can hold multiple roles simultaneously
-- Stored as a PostgreSQL TEXT array
funktion TEXT[] DEFAULT '{}',
-- Membership status
status VARCHAR(32) NOT NULL DEFAULT 'aktiv'
CHECK (status IN (
'aktiv',
'passiv',
'ehrenmitglied',
'jugendfeuerwehr',
'anwärter',
'ausgetreten'
)),
-- Important dates
eintrittsdatum DATE,
austrittsdatum DATE,
geburtsdatum DATE, -- sensitive: only shown to Kommandant/Admin
-- Contact information (stored raw, formatted on display)
telefon_mobil VARCHAR(32),
telefon_privat VARCHAR(32),
-- Emergency contact (sensitive: only own record visible to Mitglied)
notfallkontakt_name VARCHAR(255),
notfallkontakt_telefon VARCHAR(32),
-- Driving licenses (e.g. ['B', 'C', 'CE'])
fuehrerscheinklassen TEXT[] DEFAULT '{}',
-- Uniform sizing
tshirt_groesse VARCHAR(8)
CHECK (tshirt_groesse IS NULL OR tshirt_groesse IN (
'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'
)),
schuhgroesse VARCHAR(8),
-- Free-text notes (Kommandant only)
bemerkungen TEXT,
-- Profile photo URL (separate from the Authentik profile_picture_url)
bild_url TEXT,
-- Audit timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- Enforce one-to-one relationship with users
CONSTRAINT uq_mitglieder_profile_user_id UNIQUE (user_id)
);
-- ============================================================
-- Indexes for the most common query patterns
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_user_id
ON mitglieder_profile(user_id);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_status
ON mitglieder_profile(status);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_dienstgrad
ON mitglieder_profile(dienstgrad);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_mitglieds_nr
ON mitglieder_profile(mitglieds_nr);
-- ============================================================
-- Auto-update trigger for updated_at
-- Reuses the function already created by migration 001.
-- ============================================================
CREATE TRIGGER update_mitglieder_profile_updated_at
BEFORE UPDATE ON mitglieder_profile
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- dienstgrad_verlauf
-- Append-only audit log of every rank change.
-- Never deleted; soft history only.
-- ============================================================
CREATE TABLE IF NOT EXISTS dienstgrad_verlauf (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dienstgrad_neu VARCHAR(64) NOT NULL,
dienstgrad_alt VARCHAR(64), -- NULL on first assignment
datum DATE NOT NULL DEFAULT CURRENT_DATE,
durch_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
bemerkung TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_dienstgrad_verlauf_user_id
ON dienstgrad_verlauf(user_id);
CREATE INDEX IF NOT EXISTS idx_dienstgrad_verlauf_datum
ON dienstgrad_verlauf(datum DESC);

View File

@@ -0,0 +1,271 @@
-- Migration: 004_create_einsaetze.sql
-- Feature: Einsatzprotokoll (Incident Management)
-- Depends on: 001_create_users_table.sql
-- Rollback instructions at bottom of file
-- ---------------------------------------------------------------------------
-- ENUM-LIKE CHECK CONSTRAINTS (as VARCHAR + CHECK for easy extension)
-- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- SEQUENCE SUPPORT TABLE for year-based Einsatz-Nr (YYYY-NNN)
-- One row per year; nextval equivalent done via UPDATE...RETURNING
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsatz_nr_sequence (
year INTEGER PRIMARY KEY,
last_nr INTEGER NOT NULL DEFAULT 0
);
-- ---------------------------------------------------------------------------
-- FAHRZEUGE TABLE (vehicles — stub so junction table has a valid FK target)
-- A full Fahrzeuge feature is covered by 005_create_fahrzeuge.sql.
-- We do NOT create the table here; migration 005 owns it.
-- The FK in einsatz_fahrzeuge will resolve correctly when 005 runs first
-- in the standard sort order (004 runs before 005, so we use DEFERRABLE or
-- we simply add the FK as a separate ALTER TABLE at the end of this file,
-- after we know 005 may not have run yet).
--
-- Resolution strategy: einsatz_fahrzeuge.fahrzeug_id FK is defined as
-- DEFERRABLE INITIALLY DEFERRED so it is checked at transaction commit time
-- rather than at statement time, allowing this migration to run without
-- requiring fahrzeuge to exist yet.
-- ---------------------------------------------------------------------------
-- ---------------------------------------------------------------------------
-- MAIN EINSAETZE TABLE
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsaetze (
-- Identity
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
einsatz_nr VARCHAR(12) NOT NULL, -- Format: 2026-001
-- Core timestamps (all TIMESTAMPTZ — never null for alarm_time)
alarm_time TIMESTAMPTZ NOT NULL,
ausrueck_time TIMESTAMPTZ,
ankunft_time TIMESTAMPTZ,
einrueck_time TIMESTAMPTZ,
-- Classification
einsatz_art VARCHAR(30) NOT NULL
CONSTRAINT chk_einsatz_art CHECK (einsatz_art IN (
'Brand',
'THL',
'ABC',
'BMA',
'Hilfeleistung',
'Fehlalarm',
'Brandsicherheitswache'
)),
einsatz_stichwort VARCHAR(30), -- B1/B2/B3/B4, THL 1/2/3, etc.
-- Location
strasse VARCHAR(150),
hausnummer VARCHAR(20),
ort VARCHAR(100),
koordinaten POINT, -- (longitude, latitude)
-- Narrative
bericht_kurz VARCHAR(255), -- short description, visible to all
bericht_text TEXT, -- full narrative, restricted to Kommandant+
-- References
einsatzleiter_id UUID
CONSTRAINT fk_einsaetze_einsatzleiter REFERENCES users(id) ON DELETE SET NULL,
-- Operational metadata
alarmierung_art VARCHAR(30) NOT NULL DEFAULT 'ILS'
CONSTRAINT chk_alarmierung_art CHECK (alarmierung_art IN (
'ILS',
'DME',
'Telefon',
'Vor_Ort',
'Sonstiges'
)),
status VARCHAR(20) NOT NULL DEFAULT 'aktiv'
CONSTRAINT chk_einsatz_status CHECK (status IN (
'aktiv',
'abgeschlossen',
'archiviert'
)),
-- Audit
created_by UUID
CONSTRAINT fk_einsaetze_created_by REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Uniqueness: einsatz_nr is globally unique (year+seq guarantees it)
CONSTRAINT uq_einsatz_nr UNIQUE (einsatz_nr)
);
-- Performance indexes
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_time ON einsaetze(alarm_time DESC);
CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatz_art ON einsaetze(einsatz_art);
CREATE INDEX IF NOT EXISTS idx_einsaetze_status ON einsaetze(status);
CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatzleiter ON einsaetze(einsatzleiter_id);
-- Note: EXTRACT(YEAR FROM timestamptz) is STABLE (timezone-dependent), not IMMUTABLE,
-- so it cannot be used in expression indexes. Use alarm_time range scans for year filtering.
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_art ON einsaetze(einsatz_art, alarm_time DESC);
-- Auto-update updated_at
CREATE TRIGGER update_einsaetze_updated_at
BEFORE UPDATE ON einsaetze
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ---------------------------------------------------------------------------
-- JUNCTION TABLE: einsatz_fahrzeuge
-- NOTE: fahrzeug_id FK references fahrzeuge which is created in 005.
-- We define the FK as DEFERRABLE INITIALLY DEFERRED so the constraint is
-- validated at transaction commit, not statement time. This means the
-- migration can run before 005 as long as both run in the same session.
-- In production where 005 was already run, the FK resolves immediately.
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsatz_fahrzeuge (
einsatz_id UUID NOT NULL
CONSTRAINT fk_ef_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE,
fahrzeug_id UUID NOT NULL,
-- FK added separately below via ALTER TABLE to handle cross-migration dependency
-- Vehicle-level timestamps (may differ from main einsatz times)
ausrueck_time TIMESTAMPTZ,
einrueck_time TIMESTAMPTZ,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_einsatz_fahrzeuge PRIMARY KEY (einsatz_id, fahrzeug_id)
);
-- Add the FK to fahrzeuge only if the fahrzeuge table already exists
-- (handles the case where 004 runs after 005 in a fresh deployment).
-- If fahrzeuge does not exist yet, the FK will be added by migration 005
-- via an ALTER TABLE at the end of that migration.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'fahrzeuge'
) THEN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'einsatz_fahrzeuge'
AND constraint_name = 'fk_ef_fahrzeug'
) THEN
ALTER TABLE einsatz_fahrzeuge
ADD CONSTRAINT fk_ef_fahrzeug
FOREIGN KEY (fahrzeug_id) REFERENCES fahrzeuge(id) ON DELETE CASCADE;
END IF;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_ef_fahrzeug_id ON einsatz_fahrzeuge(fahrzeug_id);
-- ---------------------------------------------------------------------------
-- JUNCTION TABLE: einsatz_personal
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS einsatz_personal (
einsatz_id UUID NOT NULL
CONSTRAINT fk_ep_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE,
user_id UUID NOT NULL
CONSTRAINT fk_ep_user REFERENCES users(id) ON DELETE CASCADE,
funktion VARCHAR(50) NOT NULL DEFAULT 'Mannschaft'
CONSTRAINT chk_ep_funktion CHECK (funktion IN (
'Einsatzleiter',
'Gruppenführer',
'Maschinist',
'Atemschutz',
'Sicherheitstrupp',
'Melder',
'Wassertrupp',
'Angriffstrupp',
'Mannschaft',
'Sonstiges'
)),
-- Personal-level timestamps
alarm_time TIMESTAMPTZ,
ankunft_time TIMESTAMPTZ,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT pk_einsatz_personal PRIMARY KEY (einsatz_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_ep_user_id ON einsatz_personal(user_id);
CREATE INDEX IF NOT EXISTS idx_ep_funktion ON einsatz_personal(funktion);
-- ---------------------------------------------------------------------------
-- FUNCTION: generate_einsatz_nr(alarm_time)
-- Atomically generates the next Einsatz-Nr for a given year.
-- Uses an advisory lock to serialise concurrent inserts for the same year.
-- Returns format: '2026-001'
-- ---------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION generate_einsatz_nr(p_alarm_time TIMESTAMPTZ)
RETURNS VARCHAR AS $$
DECLARE
v_year INTEGER;
v_nr INTEGER;
BEGIN
v_year := EXTRACT(YEAR FROM p_alarm_time)::INTEGER;
-- Upsert the sequence row, then atomically increment and return
INSERT INTO einsatz_nr_sequence (year, last_nr)
VALUES (v_year, 1)
ON CONFLICT (year) DO UPDATE
SET last_nr = einsatz_nr_sequence.last_nr + 1
RETURNING last_nr INTO v_nr;
RETURN v_year::TEXT || '-' || LPAD(v_nr::TEXT, 3, '0');
END;
$$ LANGUAGE plpgsql;
-- ---------------------------------------------------------------------------
-- MATERIALIZED VIEW: einsatz_statistik
-- Pre-aggregated stats used by the dashboard and annual KBI reports.
-- Refresh manually after inserts/updates via: REFRESH MATERIALIZED VIEW einsatz_statistik;
-- ---------------------------------------------------------------------------
CREATE MATERIALIZED VIEW IF NOT EXISTS einsatz_statistik AS
SELECT
EXTRACT(YEAR FROM alarm_time)::INTEGER AS jahr,
EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat,
einsatz_art,
COUNT(*) AS anzahl,
-- Hilfsfrist: median minutes from alarm to arrival (only where ankunft_time is set)
ROUND(
AVG(
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
) FILTER (WHERE ankunft_time IS NOT NULL)
)::INTEGER AS avg_hilfsfrist_min,
-- Total duration (alarm to einrueck)
ROUND(
AVG(
EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0
) FILTER (WHERE einrueck_time IS NOT NULL)
)::INTEGER AS avg_dauer_min,
COUNT(*) FILTER (WHERE status = 'abgeschlossen') AS anzahl_abgeschlossen,
COUNT(*) FILTER (WHERE status = 'aktiv') AS anzahl_aktiv
FROM einsaetze
WHERE status != 'archiviert'
GROUP BY
EXTRACT(YEAR FROM alarm_time),
EXTRACT(MONTH FROM alarm_time),
einsatz_art
WITH DATA;
CREATE UNIQUE INDEX IF NOT EXISTS idx_einsatz_statistik_pk
ON einsatz_statistik(jahr, monat, einsatz_art);
CREATE INDEX IF NOT EXISTS idx_einsatz_statistik_jahr
ON einsatz_statistik(jahr);
-- ---------------------------------------------------------------------------
-- ROLLBACK INSTRUCTIONS (run in reverse order to undo):
--
-- DROP MATERIALIZED VIEW IF EXISTS einsatz_statistik;
-- DROP FUNCTION IF EXISTS generate_einsatz_nr(TIMESTAMPTZ);
-- DROP TABLE IF EXISTS einsatz_personal;
-- DROP TABLE IF EXISTS einsatz_fahrzeuge;
-- DROP TABLE IF EXISTS einsaetze;
-- DROP TABLE IF EXISTS fahrzeuge;
-- DROP TABLE IF EXISTS einsatz_nr_sequence;
-- ---------------------------------------------------------------------------

View File

@@ -0,0 +1,331 @@
-- Migration 005: Fahrzeugverwaltung (Vehicle Fleet Management)
-- Depends on: 001_create_users_table.sql (uuid-ossp extension, users table)
-- ============================================================
-- TABLE: fahrzeuge
-- ============================================================
CREATE TABLE IF NOT EXISTS fahrzeuge (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bezeichnung VARCHAR(100) NOT NULL, -- e.g. "LF 20/16", "HLF 10"
kurzname VARCHAR(20), -- e.g. "LF1", "HLF2"
amtliches_kennzeichen VARCHAR(20) UNIQUE, -- e.g. "WN-FW 1"
fahrgestellnummer VARCHAR(50), -- VIN
baujahr INTEGER CHECK (baujahr >= 1950 AND baujahr <= 2100),
hersteller VARCHAR(100), -- e.g. "MAN", "Mercedes-Benz", "Rosenbauer"
typ_schluessel VARCHAR(30), -- DIN 14502 code, e.g. "LF 20/16"
besatzung_soll VARCHAR(10), -- crew config e.g. "1/8", "1/5"
status VARCHAR(40) NOT NULL DEFAULT 'einsatzbereit'
CHECK (status IN (
'einsatzbereit',
'ausser_dienst_wartung',
'ausser_dienst_schaden',
'in_lehrgang'
)),
status_bemerkung TEXT,
standort VARCHAR(100) NOT NULL DEFAULT 'Feuerwehrhaus',
bild_url VARCHAR(500),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_status ON fahrzeuge(status);
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_kennzeichen ON fahrzeuge(amtliches_kennzeichen);
-- Auto-update updated_at (reuses function from migration 001)
CREATE TRIGGER update_fahrzeuge_updated_at
BEFORE UPDATE ON fahrzeuge
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- TABLE: fahrzeug_pruefungen
-- Stores both upcoming scheduled inspections AND completed ones.
-- A row with durchgefuehrt_am = NULL is an open/scheduled inspection.
-- ============================================================
CREATE TABLE IF NOT EXISTS fahrzeug_pruefungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
pruefung_art VARCHAR(30) NOT NULL
CHECK (pruefung_art IN (
'HU', -- Hauptuntersuchung (TÜV), 24-month interval
'AU', -- Abgasuntersuchung, 12-month interval
'UVV', -- Unfallverhütungsvorschrift BGV D29, 12-month
'Leiter', -- Leiternprüfung (DLK), 12-month
'Kran', -- Kranprüfung, 12-month
'Seilwinde', -- Seilwindenprüfung, 12-month
'Sonstiges'
)),
-- faellig_am: the deadline by which this inspection must be completed
faellig_am DATE NOT NULL,
-- durchgefuehrt_am: NULL = not yet done; set when inspection is completed
durchgefuehrt_am DATE,
ergebnis VARCHAR(30)
CHECK (ergebnis IS NULL OR ergebnis IN (
'bestanden',
'bestanden_mit_maengeln',
'nicht_bestanden',
'ausstehend'
)),
-- naechste_faelligkeit: auto-calculated next due date after completion
naechste_faelligkeit DATE,
pruefende_stelle VARCHAR(150), -- e.g. "TÜV Süd Stuttgart", "DEKRA"
kosten DECIMAL(8,2),
dokument_url VARCHAR(500),
bemerkung TEXT,
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pruefungen_fahrzeug_id ON fahrzeug_pruefungen(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_pruefungen_faellig_am ON fahrzeug_pruefungen(faellig_am);
CREATE INDEX IF NOT EXISTS idx_pruefungen_art ON fahrzeug_pruefungen(pruefung_art);
-- Composite index for the "latest per type" query pattern
CREATE INDEX IF NOT EXISTS idx_pruefungen_fahrzeug_art_faellig
ON fahrzeug_pruefungen(fahrzeug_id, pruefung_art, faellig_am DESC);
-- ============================================================
-- TABLE: fahrzeug_wartungslog
-- Service/maintenance log entries (fuel, repairs, tyres, etc.)
-- ============================================================
CREATE TABLE IF NOT EXISTS fahrzeug_wartungslog (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
datum DATE NOT NULL,
art VARCHAR(30)
CHECK (art IS NULL OR art IN (
'Inspektion',
'Reparatur',
'Kraftstoff',
'Reifenwechsel',
'Hauptuntersuchung',
'Reinigung',
'Sonstiges'
)),
beschreibung TEXT NOT NULL,
km_stand INTEGER CHECK (km_stand >= 0),
kraftstoff_liter DECIMAL(6,2) CHECK (kraftstoff_liter >= 0),
kosten DECIMAL(8,2) CHECK (kosten >= 0),
externe_werkstatt VARCHAR(150),
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wartungslog_fahrzeug_id ON fahrzeug_wartungslog(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_wartungslog_datum ON fahrzeug_wartungslog(datum DESC);
-- ============================================================
-- VIEW: fahrzeuge_mit_pruefstatus
-- For each vehicle, joins its LATEST scheduled pruefung per art
-- and computes tage_bis_faelligkeit (negative = overdue).
-- The dashboard alert panel and fleet overview query this view.
-- ============================================================
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
WITH latest_pruefungen AS (
-- For each (fahrzeug, pruefung_art) pair, pick the row with the
-- latest faellig_am that is NOT yet completed (durchgefuehrt_am IS NULL),
-- OR if all are completed, the one with the highest naechste_faelligkeit.
SELECT DISTINCT ON (fahrzeug_id, pruefung_art)
fahrzeug_id,
pruefung_art,
id AS pruefung_id,
faellig_am,
durchgefuehrt_am,
ergebnis,
naechste_faelligkeit,
pruefende_stelle,
CURRENT_DATE - faellig_am::date AS tage_ueberfaellig,
faellig_am::date - CURRENT_DATE AS tage_bis_faelligkeit
FROM fahrzeug_pruefungen
ORDER BY
fahrzeug_id,
pruefung_art,
-- Open inspections (nicht durchgeführt) first, then most recent
(durchgefuehrt_am IS NULL) DESC,
faellig_am DESC
)
SELECT
f.id,
f.bezeichnung,
f.kurzname,
f.amtliches_kennzeichen,
f.fahrgestellnummer,
f.baujahr,
f.hersteller,
f.typ_schluessel,
f.besatzung_soll,
f.status,
f.status_bemerkung,
f.standort,
f.bild_url,
f.created_at,
f.updated_at,
-- HU
hu.pruefung_id AS hu_pruefung_id,
hu.faellig_am AS hu_faellig_am,
hu.tage_bis_faelligkeit AS hu_tage_bis_faelligkeit,
hu.ergebnis AS hu_ergebnis,
-- AU
au.pruefung_id AS au_pruefung_id,
au.faellig_am AS au_faellig_am,
au.tage_bis_faelligkeit AS au_tage_bis_faelligkeit,
au.ergebnis AS au_ergebnis,
-- UVV
uvv.pruefung_id AS uvv_pruefung_id,
uvv.faellig_am AS uvv_faellig_am,
uvv.tage_bis_faelligkeit AS uvv_tage_bis_faelligkeit,
uvv.ergebnis AS uvv_ergebnis,
-- Leiter (DLK only)
leiter.pruefung_id AS leiter_pruefung_id,
leiter.faellig_am AS leiter_faellig_am,
leiter.tage_bis_faelligkeit AS leiter_tage_bis_faelligkeit,
leiter.ergebnis AS leiter_ergebnis,
-- Overall worst tage_bis_faelligkeit across all active inspections
LEAST(
hu.tage_bis_faelligkeit,
au.tage_bis_faelligkeit,
uvv.tage_bis_faelligkeit,
leiter.tage_bis_faelligkeit
) AS naechste_pruefung_tage
FROM
fahrzeuge f
LEFT JOIN latest_pruefungen hu ON hu.fahrzeug_id = f.id AND hu.pruefung_art = 'HU'
LEFT JOIN latest_pruefungen au ON au.fahrzeug_id = f.id AND au.pruefung_art = 'AU'
LEFT JOIN latest_pruefungen uvv ON uvv.fahrzeug_id = f.id AND uvv.pruefung_art = 'UVV'
LEFT JOIN latest_pruefungen leiter ON leiter.fahrzeug_id = f.id AND leiter.pruefung_art = 'Leiter';
-- ============================================================
-- SEED DATA: 3 typical German Feuerwehr vehicles
-- ============================================================
DO $$
DECLARE
v_lf10_id UUID := uuid_generate_v4();
v_hlf20_id UUID := uuid_generate_v4();
v_mtf_id UUID := uuid_generate_v4();
BEGIN
-- Only insert if no vehicles exist yet (idempotent seed)
IF (SELECT COUNT(*) FROM fahrzeuge) = 0 THEN
-- 1) LF 10 Standard Löschgruppenfahrzeug
INSERT INTO fahrzeuge (
id, bezeichnung, kurzname, amtliches_kennzeichen,
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
besatzung_soll, status, standort
) VALUES (
v_lf10_id,
'LF 10',
'LF 1',
'WN-FW 1',
'WDB9634031L123456',
2018,
'Mercedes-Benz Atego',
'LF 10',
'1/8',
'einsatzbereit',
'Feuerwehrhaus'
);
-- LF 10 inspections
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
(v_lf10_id, 'HU', '2026-03-15', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
(v_lf10_id, 'AU', '2026-04-01', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
(v_lf10_id, 'UVV', '2025-11-30', '2025-11-28', 'bestanden', '2026-11-28', 'DGUV Prüfer Rems');
-- LF 10 maintenance log
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
(v_lf10_id, '2025-11-28', 'Inspektion', 'Jahresinspektion nach Herstellervorgabe, Öl- und Filterwechsel, Bremsenprüfung', 48320, NULL, 420.00),
(v_lf10_id, '2025-10-15', 'Kraftstoff', 'Betankung nach Einsatz Feuerwehr Rems', 48150, 85.4, 145.18),
(v_lf10_id, '2025-09-01', 'Reifenwechsel','Sommerreifen auf Winterreifen gewechselt, alle 4 Reifen erneuert (Continental)', 47800, NULL, 980.00);
-- 2) HLF 20/16 Hilfeleistungslöschgruppenfahrzeug (flagship)
INSERT INTO fahrzeuge (
id, bezeichnung, kurzname, amtliches_kennzeichen,
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
besatzung_soll, status, standort
) VALUES (
v_hlf20_id,
'HLF 20/16',
'HLF 1',
'WN-FW 2',
'WMAN29ZZ3LM654321',
2020,
'MAN TGM / Rosenbauer',
'HLF 20/16',
'1/8',
'einsatzbereit',
'Feuerwehrhaus'
);
-- HLF 20 inspections — all current
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
(v_hlf20_id, 'HU', '2026-08-20', NULL, 'ausstehend', NULL, 'DEKRA Esslingen'),
(v_hlf20_id, 'AU', '2026-02-01', '2026-01-28', 'bestanden', '2027-01-28', 'DEKRA Esslingen'),
(v_hlf20_id, 'UVV', '2026-01-15', '2026-01-14', 'bestanden', '2027-01-14', 'DGUV Prüfer Rems');
-- HLF 20 maintenance log
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
(v_hlf20_id, '2026-01-28', 'Hauptuntersuchung', 'AU bestanden ohne Mängel bei DEKRA Esslingen', 22450, NULL, 185.00),
(v_hlf20_id, '2026-01-14', 'Inspektion', 'UVV-Prüfung bestanden, Licht und Bremsen geprüft', 22430, NULL, 0.00),
(v_hlf20_id, '2025-12-10', 'Kraftstoff', 'Betankung nach Übung', 22300, 120.0, 204.00),
(v_hlf20_id, '2025-10-05', 'Reparatur', 'Hydraulikpumpe für Rettungssatz getauscht', 21980, NULL, 2340.00);
-- 3) MTF Mannschaftstransportfahrzeug
INSERT INTO fahrzeuge (
id, bezeichnung, kurzname, amtliches_kennzeichen,
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
besatzung_soll, status, status_bemerkung, standort
) VALUES (
v_mtf_id,
'MTF',
'MTF 1',
'WN-FW 5',
'WDB9066371S789012',
2015,
'Mercedes-Benz Sprinter',
'MTF',
'1/8',
'ausser_dienst_wartung',
'Geplante Inspektion: Zahnriemenwechsel 60.000 km fällig',
'Feuerwehrhaus'
);
-- MTF inspections — HU overdue (safety-critical test data)
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
(v_mtf_id, 'HU', '2026-01-31', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
(v_mtf_id, 'AU', '2025-06-15', '2025-06-12', 'bestanden', '2026-06-12', 'TÜV Süd Stuttgart'),
(v_mtf_id, 'UVV', '2025-12-01', '2025-11-30', 'bestanden_mit_maengeln', '2026-11-30', 'DGUV Prüfer Rems');
-- MTF maintenance log
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
(v_mtf_id, '2025-11-30', 'Inspektion', 'UVV-Prüfung: kleiner Mangel Innenbeleuchtung, nachgebessert', 58920, NULL, 0.00),
(v_mtf_id, '2025-11-01', 'Kraftstoff', 'Betankung regulär', 58700, 65.0, 110.50),
(v_mtf_id, '2025-09-20', 'Reparatur', 'Heckleuchte links defekt, Glühbirne getauscht', 58400, NULL, 12.50);
END IF;
END
$$;
-- ---------------------------------------------------------------------------
-- Cross-migration FK: add fahrzeug_id FK to einsatz_fahrzeuge (created in 004)
-- This runs only if einsatz_fahrzeuge exists but the FK is not yet present.
-- Handles the case where 004 ran before 005 and deferred the FK creation.
-- ---------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'einsatz_fahrzeuge'
)
AND NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'einsatz_fahrzeuge'
AND constraint_name = 'fk_ef_fahrzeug'
) THEN
ALTER TABLE einsatz_fahrzeuge
ADD CONSTRAINT fk_ef_fahrzeug
FOREIGN KEY (fahrzeug_id) REFERENCES fahrzeuge(id) ON DELETE CASCADE;
RAISE NOTICE 'Added fk_ef_fahrzeug FK on einsatz_fahrzeuge';
END IF;
END $$;

View File

@@ -0,0 +1,201 @@
-- =============================================================================
-- Migration 006: Übungsplanung & Dienstkalender
-- Training Schedule & Service Calendar for Feuerwehr Rems Dashboard
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. Calendar token table for iCal subscribe URLs (no auth required per-request)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS calendar_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) UNIQUE NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ
);
CREATE INDEX idx_calendar_tokens_token ON calendar_tokens(token);
CREATE INDEX idx_calendar_tokens_user_id ON calendar_tokens(user_id);
-- -----------------------------------------------------------------------------
-- 2. Main events table
-- -----------------------------------------------------------------------------
CREATE TYPE uebung_typ AS ENUM (
'Übungsabend',
'Lehrgang',
'Sonderdienst',
'Versammlung',
'Gemeinschaftsübung',
'Sonstiges'
);
CREATE TABLE uebungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
titel VARCHAR(255) NOT NULL,
beschreibung TEXT,
typ uebung_typ NOT NULL,
datum_von TIMESTAMPTZ NOT NULL,
datum_bis TIMESTAMPTZ NOT NULL,
ort VARCHAR(255),
treffpunkt VARCHAR(255),
pflichtveranstaltung BOOLEAN NOT NULL DEFAULT FALSE,
mindest_teilnehmer INT CHECK (mindest_teilnehmer > 0),
max_teilnehmer INT CHECK (max_teilnehmer > 0),
angelegt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
abgesagt BOOLEAN NOT NULL DEFAULT FALSE,
absage_grund TEXT,
CONSTRAINT datum_reihenfolge CHECK (datum_bis >= datum_von),
CONSTRAINT max_groesser_min CHECK (
max_teilnehmer IS NULL OR
mindest_teilnehmer IS NULL OR
max_teilnehmer >= mindest_teilnehmer
)
);
CREATE INDEX idx_uebungen_datum_von ON uebungen(datum_von);
CREATE INDEX idx_uebungen_typ ON uebungen(typ);
CREATE INDEX idx_uebungen_pflichtveranstaltung ON uebungen(pflichtveranstaltung) WHERE pflichtveranstaltung = TRUE;
CREATE INDEX idx_uebungen_abgesagt ON uebungen(abgesagt) WHERE abgesagt = FALSE;
-- Compound index for the most common calendar-range query
CREATE INDEX idx_uebungen_datum_von_bis ON uebungen(datum_von, datum_bis);
-- Keep aktualisiert_am in sync via trigger (reuse function from migration 001)
CREATE TRIGGER update_uebungen_aktualisiert_am
BEFORE UPDATE ON uebungen
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- -----------------------------------------------------------------------------
-- 3. Attendance / RSVP table
-- -----------------------------------------------------------------------------
CREATE TYPE teilnahme_status AS ENUM (
'zugesagt',
'abgesagt',
'erschienen',
'entschuldigt',
'unbekannt'
);
CREATE TABLE uebung_teilnahmen (
uebung_id UUID NOT NULL REFERENCES uebungen(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status teilnahme_status NOT NULL DEFAULT 'unbekannt',
antwort_am TIMESTAMPTZ,
erschienen_erfasst_am TIMESTAMPTZ,
erschienen_erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
bemerkung VARCHAR(500),
PRIMARY KEY (uebung_id, user_id)
);
CREATE INDEX idx_teilnahmen_uebung_id ON uebung_teilnahmen(uebung_id);
CREATE INDEX idx_teilnahmen_user_id ON uebung_teilnahmen(user_id);
CREATE INDEX idx_teilnahmen_status ON uebung_teilnahmen(status);
-- -----------------------------------------------------------------------------
-- 4. Trigger: auto-create 'unbekannt' rows for all active members on new event
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION fn_create_teilnahmen_for_all_active_members()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO uebung_teilnahmen (uebung_id, user_id, status)
SELECT NEW.id, u.id, 'unbekannt'
FROM users u
WHERE u.is_active = TRUE
ON CONFLICT (uebung_id, user_id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_auto_teilnahmen_after_insert
AFTER INSERT ON uebungen
FOR EACH ROW
EXECUTE FUNCTION fn_create_teilnahmen_for_all_active_members();
-- -----------------------------------------------------------------------------
-- 5. Trigger: when a new member becomes active, add them to all future events
-- -----------------------------------------------------------------------------
CREATE OR REPLACE FUNCTION fn_add_member_to_future_events()
RETURNS TRIGGER AS $$
BEGIN
-- Only run when is_active transitions FALSE -> TRUE
IF (OLD.is_active = FALSE OR OLD.is_active IS NULL) AND NEW.is_active = TRUE THEN
INSERT INTO uebung_teilnahmen (uebung_id, user_id, status)
SELECT u.id, NEW.id, 'unbekannt'
FROM uebungen u
WHERE u.datum_von > NOW()
AND u.abgesagt = FALSE
ON CONFLICT (uebung_id, user_id) DO NOTHING;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_add_member_to_future_events
AFTER UPDATE OF is_active ON users
FOR EACH ROW
EXECUTE FUNCTION fn_add_member_to_future_events();
-- -----------------------------------------------------------------------------
-- 6. Convenience view: event overview with attendance counts
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW uebung_uebersicht AS
SELECT
u.id,
u.titel,
u.typ,
u.datum_von,
u.datum_bis,
u.ort,
u.treffpunkt,
u.pflichtveranstaltung,
u.mindest_teilnehmer,
u.max_teilnehmer,
u.abgesagt,
u.absage_grund,
u.angelegt_von,
u.erstellt_am,
u.aktualisiert_am,
-- Attendance aggregates
COUNT(t.user_id) AS gesamt_eingeladen,
COUNT(t.user_id) FILTER (WHERE t.status = 'zugesagt') AS anzahl_zugesagt,
COUNT(t.user_id) FILTER (WHERE t.status = 'abgesagt') AS anzahl_abgesagt,
COUNT(t.user_id) FILTER (WHERE t.status = 'erschienen') AS anzahl_erschienen,
COUNT(t.user_id) FILTER (WHERE t.status = 'entschuldigt') AS anzahl_entschuldigt,
COUNT(t.user_id) FILTER (WHERE t.status = 'unbekannt') AS anzahl_unbekannt
FROM uebungen u
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
GROUP BY u.id;
-- -----------------------------------------------------------------------------
-- 7. View: per-member participation statistics (feeds Tier 3 reporting)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW member_participation_stats AS
SELECT
usr.id AS user_id,
COALESCE(usr.name, usr.preferred_username, usr.email) AS name,
COUNT(t.uebung_id) AS total_eingeladen,
COUNT(t.uebung_id) FILTER (WHERE t.status = 'erschienen') AS total_erschienen,
COUNT(t.uebung_id) FILTER (
WHERE u.pflichtveranstaltung = TRUE AND t.status = 'erschienen'
) AS pflicht_erschienen,
COUNT(t.uebung_id) FILTER (WHERE u.pflichtveranstaltung = TRUE) AS pflicht_gesamt,
ROUND(
CASE
WHEN COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') = 0 THEN 0
ELSE
COUNT(t.uebung_id) FILTER (
WHERE u.typ = 'Übungsabend' AND t.status = 'erschienen'
)::NUMERIC /
COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') * 100
END, 1
) AS uebungsabend_quote_pct
FROM users usr
JOIN uebung_teilnahmen t ON t.user_id = usr.id
JOIN uebungen u ON u.id = t.uebung_id
WHERE usr.is_active = TRUE
AND u.abgesagt = FALSE
GROUP BY usr.id, usr.name, usr.preferred_username, usr.email;

View File

@@ -0,0 +1,110 @@
-- Migration 007: Authentik groups + vehicle inspection/service periods
-- Depends on: 001_create_users_table.sql, 005_create_fahrzeuge.sql
--
-- Changes:
-- 1. Add authentik_groups column to users (stores Authentik group memberships)
-- 2. Add paragraph57a_faellig_am + naechste_wartung_am to fahrzeuge
-- 3. Refresh the fahrzeuge_mit_pruefstatus view to expose the new columns
-- Rollback:
-- ALTER TABLE users DROP COLUMN IF EXISTS authentik_groups;
-- ALTER TABLE fahrzeuge DROP COLUMN IF EXISTS paragraph57a_faellig_am;
-- ALTER TABLE fahrzeuge DROP COLUMN IF EXISTS naechste_wartung_am;
-- ── 1. users: Authentik group memberships ─────────────────────────────────────
ALTER TABLE users
ADD COLUMN IF NOT EXISTS authentik_groups TEXT[] NOT NULL DEFAULT '{}';
COMMENT ON COLUMN users.authentik_groups IS
'Authentik group slugs synced on every login (e.g. dashboard_admin, fahrmeister)';
-- ── 2. fahrzeuge: §57a (Austrian periodic inspection) + service interval ──────
ALTER TABLE fahrzeuge
ADD COLUMN IF NOT EXISTS paragraph57a_faellig_am DATE;
COMMENT ON COLUMN fahrzeuge.paragraph57a_faellig_am IS
'§57a StVO periodic inspection due date (Austrian equivalent of HU/TÜV)';
ALTER TABLE fahrzeuge
ADD COLUMN IF NOT EXISTS naechste_wartung_am DATE;
COMMENT ON COLUMN fahrzeuge.naechste_wartung_am IS
'Next scheduled service / maintenance due date';
-- ── 3. Refresh view to expose new vehicle columns ─────────────────────────────
-- Drop and recreate since CREATE OR REPLACE on views requires identical column list.
DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus;
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
WITH latest_pruefungen AS (
SELECT DISTINCT ON (fahrzeug_id, pruefung_art)
fahrzeug_id,
pruefung_art,
id AS pruefung_id,
faellig_am,
durchgefuehrt_am,
ergebnis,
naechste_faelligkeit,
pruefende_stelle,
CURRENT_DATE - faellig_am::date AS tage_ueberfaellig,
faellig_am::date - CURRENT_DATE AS tage_bis_faelligkeit
FROM fahrzeug_pruefungen
ORDER BY
fahrzeug_id,
pruefung_art,
(durchgefuehrt_am IS NULL) DESC,
faellig_am DESC
)
SELECT
f.id,
f.bezeichnung,
f.kurzname,
f.amtliches_kennzeichen,
f.fahrgestellnummer,
f.baujahr,
f.hersteller,
f.typ_schluessel,
f.besatzung_soll,
f.status,
f.status_bemerkung,
f.standort,
f.bild_url,
f.created_at,
f.updated_at,
-- §57a Austrian periodic inspection
f.paragraph57a_faellig_am,
f.paragraph57a_faellig_am::date - CURRENT_DATE AS paragraph57a_tage_bis_faelligkeit,
-- Next service/maintenance
f.naechste_wartung_am,
f.naechste_wartung_am::date - CURRENT_DATE AS wartung_tage_bis_faelligkeit,
-- Legacy pruefungen (HU / AU / UVV / Leiter) kept for backwards compat
hu.pruefung_id AS hu_pruefung_id,
hu.faellig_am AS hu_faellig_am,
hu.tage_bis_faelligkeit AS hu_tage_bis_faelligkeit,
hu.ergebnis AS hu_ergebnis,
au.pruefung_id AS au_pruefung_id,
au.faellig_am AS au_faellig_am,
au.tage_bis_faelligkeit AS au_tage_bis_faelligkeit,
au.ergebnis AS au_ergebnis,
uvv.pruefung_id AS uvv_pruefung_id,
uvv.faellig_am AS uvv_faellig_am,
uvv.tage_bis_faelligkeit AS uvv_tage_bis_faelligkeit,
uvv.ergebnis AS uvv_ergebnis,
leiter.pruefung_id AS leiter_pruefung_id,
leiter.faellig_am AS leiter_faellig_am,
leiter.tage_bis_faelligkeit AS leiter_tage_bis_faelligkeit,
leiter.ergebnis AS leiter_ergebnis,
-- Overall worst urgency: §57a + Wartung take precedence, legacy pruefungen kept
LEAST(
f.paragraph57a_faellig_am::date - CURRENT_DATE,
f.naechste_wartung_am::date - CURRENT_DATE,
hu.tage_bis_faelligkeit,
au.tage_bis_faelligkeit,
uvv.tage_bis_faelligkeit,
leiter.tage_bis_faelligkeit
) AS naechste_pruefung_tage
FROM
fahrzeuge f
LEFT JOIN latest_pruefungen hu ON hu.fahrzeug_id = f.id AND hu.pruefung_art = 'HU'
LEFT JOIN latest_pruefungen au ON au.fahrzeug_id = f.id AND au.pruefung_art = 'AU'
LEFT JOIN latest_pruefungen uvv ON uvv.fahrzeug_id = f.id AND uvv.pruefung_art = 'UVV'
LEFT JOIN latest_pruefungen leiter ON leiter.fahrzeug_id = f.id AND leiter.pruefung_art = 'Leiter';

View File

@@ -0,0 +1,53 @@
-- Migration 008: Simplify inspection model
-- Remove fahrzeug_pruefungen table and related structures.
-- Only §57a (paragraph57a_faellig_am) and Wartung (naechste_wartung_am)
-- remain as the two tracked inspection deadlines, stored on fahrzeuge.
-- Drop the pruefungen table (cascades to indexes)
DROP TABLE IF EXISTS fahrzeug_pruefungen CASCADE;
-- Drop and recreate the fleet overview view (simplified — no CTE)
DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus;
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
SELECT
f.id,
f.bezeichnung,
f.kurzname,
f.amtliches_kennzeichen,
f.fahrgestellnummer,
f.baujahr,
f.hersteller,
f.typ_schluessel,
f.besatzung_soll,
f.status,
f.status_bemerkung,
f.standort,
f.bild_url,
f.created_at,
f.updated_at,
f.paragraph57a_faellig_am,
CASE
WHEN f.paragraph57a_faellig_am IS NOT NULL
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
ELSE NULL
END AS paragraph57a_tage_bis_faelligkeit,
f.naechste_wartung_am,
CASE
WHEN f.naechste_wartung_am IS NOT NULL
THEN f.naechste_wartung_am::date - CURRENT_DATE
ELSE NULL
END AS wartung_tage_bis_faelligkeit,
LEAST(
CASE WHEN f.paragraph57a_faellig_am IS NOT NULL
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
ELSE NULL END,
CASE WHEN f.naechste_wartung_am IS NOT NULL
THEN f.naechste_wartung_am::date - CURRENT_DATE
ELSE NULL END
) AS naechste_pruefung_tage
FROM fahrzeuge f;
-- Index support for alert queries
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_paragraph57a ON fahrzeuge(paragraph57a_faellig_am) WHERE paragraph57a_faellig_am IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_wartung ON fahrzeuge(naechste_wartung_am) WHERE naechste_wartung_am IS NOT NULL;

View File

@@ -0,0 +1,57 @@
-- Migration 009: Soft delete for vehicles
-- Adds deleted_at to fahrzeuge and refreshes the view to exclude soft-deleted rows.
-- Hard DELETE is replaced by UPDATE SET deleted_at = NOW() in the service layer.
ALTER TABLE fahrzeuge
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
COMMENT ON COLUMN fahrzeuge.deleted_at IS
'NULL = active vehicle. Set to timestamp when soft-deleted. Records are never physically removed.';
-- Partial index: only index active (non-deleted) vehicles for fast lookups
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_active
ON fahrzeuge(id)
WHERE deleted_at IS NULL;
-- Refresh the view to exclude soft-deleted vehicles
DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus;
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
SELECT
f.id,
f.bezeichnung,
f.kurzname,
f.amtliches_kennzeichen,
f.fahrgestellnummer,
f.baujahr,
f.hersteller,
f.typ_schluessel,
f.besatzung_soll,
f.status,
f.status_bemerkung,
f.standort,
f.bild_url,
f.created_at,
f.updated_at,
f.paragraph57a_faellig_am,
CASE
WHEN f.paragraph57a_faellig_am IS NOT NULL
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
ELSE NULL
END AS paragraph57a_tage_bis_faelligkeit,
f.naechste_wartung_am,
CASE
WHEN f.naechste_wartung_am IS NOT NULL
THEN f.naechste_wartung_am::date - CURRENT_DATE
ELSE NULL
END AS wartung_tage_bis_faelligkeit,
LEAST(
CASE WHEN f.paragraph57a_faellig_am IS NOT NULL
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
ELSE NULL END,
CASE WHEN f.naechste_wartung_am IS NOT NULL
THEN f.naechste_wartung_am::date - CURRENT_DATE
ELSE NULL END
) AS naechste_pruefung_tage
FROM fahrzeuge f
WHERE f.deleted_at IS NULL;

View File

@@ -0,0 +1,24 @@
-- Migration 010: Simplify WartungslogArt from 7 types to 3
-- Maps existing data to new categories and updates the CHECK constraint.
--
-- Old types: New type:
-- Inspektion -> Service
-- Reparatur -> Service
-- Reifenwechsel -> Service
-- Reinigung -> Service
-- Hauptuntersuchung -> §57a Prüfung
-- Kraftstoff -> Sonstiges
-- Sonstiges -> Sonstiges (unchanged)
-- Step 1: Drop the old CHECK constraint FIRST (must happen before data changes)
ALTER TABLE fahrzeug_wartungslog DROP CONSTRAINT IF EXISTS fahrzeug_wartungslog_art_check;
-- Step 2: Migrate existing data to new type values
UPDATE fahrzeug_wartungslog SET art = 'Service' WHERE art IN ('Inspektion', 'Reparatur', 'Reifenwechsel', 'Reinigung');
UPDATE fahrzeug_wartungslog SET art = '§57a Prüfung' WHERE art = 'Hauptuntersuchung';
UPDATE fahrzeug_wartungslog SET art = 'Sonstiges' WHERE art = 'Kraftstoff';
-- Step 3: Add the new CHECK constraint with simplified types
ALTER TABLE fahrzeug_wartungslog
ADD CONSTRAINT fahrzeug_wartungslog_art_check
CHECK (art IS NULL OR art IN ('§57a Prüfung', 'Service', 'Sonstiges'));

View File

@@ -0,0 +1,143 @@
-- Migration 011: Ausrüstungsverwaltung (Equipment Management)
-- Depends on: 001_create_users_table.sql (uuid-ossp extension, users table)
-- 005_create_fahrzeuge.sql (fahrzeuge table)
-- 009_vehicle_soft_delete.sql (soft-delete pattern)
-- ============================================================
-- TABLE: ausruestung_kategorien (Equipment Categories — lookup)
-- ============================================================
CREATE TABLE IF NOT EXISTS ausruestung_kategorien (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) NOT NULL UNIQUE, -- e.g. 'Atemschutzgeräte'
kurzname VARCHAR(30) NOT NULL UNIQUE, -- e.g. 'PA'
sortierung INTEGER NOT NULL DEFAULT 0, -- display order
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================
-- TABLE: ausruestung (Core Equipment Inventory)
-- ============================================================
CREATE TABLE IF NOT EXISTS ausruestung (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
bezeichnung VARCHAR(200) NOT NULL, -- e.g. 'Dräger PSS 5000'
kategorie_id UUID NOT NULL REFERENCES ausruestung_kategorien(id),
seriennummer VARCHAR(100), -- manufacturer serial
inventarnummer VARCHAR(50), -- internal inventory number
hersteller VARCHAR(150), -- manufacturer
baujahr INTEGER CHECK (baujahr >= 1950 AND baujahr <= 2100),
status VARCHAR(30) NOT NULL DEFAULT 'einsatzbereit'
CHECK (status IN (
'einsatzbereit',
'beschaedigt',
'in_wartung',
'ausser_dienst'
)),
status_bemerkung TEXT, -- free-text status note
ist_wichtig BOOLEAN NOT NULL DEFAULT FALSE, -- drives vehicle card warnings
fahrzeug_id UUID REFERENCES fahrzeuge(id) ON DELETE SET NULL, -- nullable
standort VARCHAR(150) NOT NULL DEFAULT 'Lager', -- used when no fahrzeug
pruef_intervall_monate INTEGER CHECK (pruef_intervall_monate > 0), -- nullable
letzte_pruefung_am DATE,
naechste_pruefung_am DATE,
bemerkung TEXT, -- general notes
deleted_at TIMESTAMPTZ, -- soft-delete
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ausruestung_status
ON ausruestung(status);
CREATE INDEX IF NOT EXISTS idx_ausruestung_kategorie
ON ausruestung(kategorie_id);
CREATE INDEX IF NOT EXISTS idx_ausruestung_fahrzeug
ON ausruestung(fahrzeug_id)
WHERE fahrzeug_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_ausruestung_active
ON ausruestung(id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_ausruestung_pruefung
ON ausruestung(naechste_pruefung_am)
WHERE naechste_pruefung_am IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_ausruestung_wichtig
ON ausruestung(fahrzeug_id, status)
WHERE ist_wichtig = TRUE AND deleted_at IS NULL;
-- Auto-update updated_at (reuses function from migration 001)
CREATE TRIGGER update_ausruestung_updated_at
BEFORE UPDATE ON ausruestung
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- TABLE: ausruestung_wartungslog (Service/Inspection History)
-- ============================================================
CREATE TABLE IF NOT EXISTS ausruestung_wartungslog (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE,
datum DATE NOT NULL,
art VARCHAR(30) NOT NULL
CHECK (art IN (
'Prüfung',
'Reparatur',
'Sonstiges'
)),
beschreibung TEXT NOT NULL,
ergebnis VARCHAR(30)
CHECK (ergebnis IS NULL OR ergebnis IN (
'bestanden',
'bestanden_mit_maengeln',
'nicht_bestanden'
)),
kosten DECIMAL(8,2) CHECK (kosten >= 0),
pruefende_stelle VARCHAR(150), -- e.g. 'Atemschutzwerkstatt Bezirk'
dokument_url VARCHAR(500), -- link to scan/PDF
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ausruestung_wartung_item
ON ausruestung_wartungslog(ausruestung_id);
CREATE INDEX IF NOT EXISTS idx_ausruestung_wartung_datum
ON ausruestung_wartungslog(datum DESC);
-- ============================================================
-- VIEW: ausruestung_mit_pruefstatus
-- For each active equipment item, joins category and vehicle
-- and computes pruefung_tage_bis_faelligkeit (negative = overdue).
-- The dashboard equipment panel and fleet overview query this view.
-- ============================================================
CREATE OR REPLACE VIEW ausruestung_mit_pruefstatus AS
SELECT
a.*,
k.name AS kategorie_name,
k.kurzname AS kategorie_kurzname,
f.bezeichnung AS fahrzeug_bezeichnung,
f.kurzname AS fahrzeug_kurzname,
CASE
WHEN a.naechste_pruefung_am IS NOT NULL
THEN a.naechste_pruefung_am::date - CURRENT_DATE
ELSE NULL
END AS pruefung_tage_bis_faelligkeit
FROM ausruestung a
JOIN ausruestung_kategorien k ON k.id = a.kategorie_id
LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id AND f.deleted_at IS NULL
WHERE a.deleted_at IS NULL;
-- ============================================================
-- SEED DATA: Equipment Categories
-- ============================================================
INSERT INTO ausruestung_kategorien (name, kurzname, sortierung) VALUES
('Atemschutzgeräte', 'PA', 1),
('Pumpen', 'Pumpe', 2),
('Schläuche', 'SL', 3),
('Leitern', 'Leiter', 4),
('Rettungsgeräte', 'RG', 5),
('Messgeräte', 'MG', 6),
('Persönliche Schutzausrüstung', 'PSA', 7),
('Kommunikation', 'Funk', 8),
('Beleuchtung', 'Licht', 9),
('Sonstige', 'Sonst.', 10)
ON CONFLICT (name) DO NOTHING;

View File

@@ -0,0 +1,30 @@
-- Add FDISK Standesbuch-Nr to mitglieder_profile for sync matching
ALTER TABLE mitglieder_profile
ADD COLUMN IF NOT EXISTS fdisk_standesbuch_nr VARCHAR(32) UNIQUE;
CREATE INDEX IF NOT EXISTS idx_mitglieder_fdisk_standesbuch_nr
ON mitglieder_profile(fdisk_standesbuch_nr);
-- Qualifications synced from FDISK
CREATE TABLE IF NOT EXISTS ausbildung (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
kursname VARCHAR(255) NOT NULL,
kurs_datum DATE,
ablaufdatum DATE,
ort VARCHAR(255),
status VARCHAR(32) NOT NULL DEFAULT 'abgeschlossen' CHECK (status IN (
'abgeschlossen', 'in_bearbeitung', 'abgelaufen'
)),
-- Composite key from FDISK to prevent duplicates on re-sync
fdisk_sync_key VARCHAR(255),
bemerkung TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
CONSTRAINT uq_ausbildung_user_fdisk_key UNIQUE (user_id, fdisk_sync_key)
);
CREATE INDEX IF NOT EXISTS idx_ausbildung_user_id ON ausbildung(user_id);
CREATE TRIGGER update_ausbildung_updated_at BEFORE UPDATE ON ausbildung
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,6 @@
-- Migration 013: Add Nextcloud credentials to users
-- Stores per-user Nextcloud login name and app password for Nextcloud API access.
ALTER TABLE users
ADD COLUMN IF NOT EXISTS nextcloud_login_name VARCHAR(255),
ADD COLUMN IF NOT EXISTS nextcloud_app_password TEXT;

View File

@@ -0,0 +1,129 @@
-- Migration: 014_create_atemschutz
-- Atemschutz-Traegerverwaltung (Breathing Apparatus Carrier Management)
-- Depends on: 001_create_users_table (users table, uuid-ossp, update_updated_at_column)
-- 003_create_mitglieder_profile (mitglieder_profile for the view)
-- Rollback:
-- DROP VIEW IF EXISTS atemschutz_uebersicht;
-- DROP TABLE IF EXISTS atemschutz_traeger;
-- ============================================================
-- TABLE: atemschutz_traeger (BA Carrier Registry)
-- Each row = one firefighter qualified/tracked for BA use.
-- One record per user (enforced by UNIQUE constraint).
-- ============================================================
CREATE TABLE IF NOT EXISTS atemschutz_traeger (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- Atemschutz-Lehrgang (BA course qualification)
atemschutz_lehrgang BOOLEAN NOT NULL DEFAULT FALSE,
lehrgang_datum DATE,
-- G26.3 Aerztliche Untersuchung (doctor's examination)
untersuchung_datum DATE,
untersuchung_gueltig_bis DATE, -- typically valid 3 years
untersuchung_ergebnis VARCHAR(30)
CHECK (untersuchung_ergebnis IS NULL OR untersuchung_ergebnis IN (
'tauglich', 'bedingt_tauglich', 'nicht_tauglich'
)),
-- Leistungstest / Finnentest (performance test)
leistungstest_datum DATE,
leistungstest_gueltig_bis DATE, -- typically valid 1 year
leistungstest_bestanden BOOLEAN,
-- Free-text notes (Kommandant / Atemschutzwart)
bemerkung TEXT,
-- Audit timestamps
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
-- One record per user
CONSTRAINT uq_atemschutz_user UNIQUE (user_id)
);
-- ============================================================
-- Indexes for the most common query patterns
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_atemschutz_user
ON atemschutz_traeger(user_id);
CREATE INDEX IF NOT EXISTS idx_atemschutz_untersuchung
ON atemschutz_traeger(untersuchung_gueltig_bis)
WHERE untersuchung_gueltig_bis IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_atemschutz_leistungstest
ON atemschutz_traeger(leistungstest_gueltig_bis)
WHERE leistungstest_gueltig_bis IS NOT NULL;
-- ============================================================
-- Auto-update trigger for updated_at
-- Reuses the function already created by migration 001.
-- ============================================================
CREATE TRIGGER update_atemschutz_updated_at
BEFORE UPDATE ON atemschutz_traeger
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- ============================================================
-- VIEW: atemschutz_uebersicht
-- Dashboard view with user info and computed validity status.
-- Joins user + mitglieder_profile for display name, rank, etc.
-- Computes expiry flags so the frontend can render traffic-light
-- indicators without any date math on the client side.
-- ============================================================
CREATE OR REPLACE VIEW atemschutz_uebersicht AS
SELECT
at.*,
u.name AS user_name,
u.given_name AS user_given_name,
u.family_name AS user_family_name,
u.email AS user_email,
mp.status AS mitglied_status,
mp.dienstgrad,
-- Computed: is the medical exam still valid?
CASE
WHEN at.untersuchung_gueltig_bis IS NOT NULL
THEN at.untersuchung_gueltig_bis >= CURRENT_DATE
ELSE FALSE
END AS untersuchung_gueltig,
-- Computed: days until medical exam expires (negative = expired)
CASE
WHEN at.untersuchung_gueltig_bis IS NOT NULL
THEN at.untersuchung_gueltig_bis::date - CURRENT_DATE
ELSE NULL
END AS untersuchung_tage_rest,
-- Computed: is the performance test still valid?
CASE
WHEN at.leistungstest_gueltig_bis IS NOT NULL
THEN at.leistungstest_gueltig_bis >= CURRENT_DATE
ELSE FALSE
END AS leistungstest_gueltig,
-- Computed: days until performance test expires (negative = expired)
CASE
WHEN at.leistungstest_gueltig_bis IS NOT NULL
THEN at.leistungstest_gueltig_bis::date - CURRENT_DATE
ELSE NULL
END AS leistungstest_tage_rest,
-- Computed: fully qualified for BA use (einsatzbereit)?
-- Requires: course done + valid medical (tauglich) + valid performance test (bestanden)
CASE
WHEN at.atemschutz_lehrgang = TRUE
AND at.untersuchung_gueltig_bis IS NOT NULL
AND at.untersuchung_gueltig_bis >= CURRENT_DATE
AND at.untersuchung_ergebnis = 'tauglich'
AND at.leistungstest_gueltig_bis IS NOT NULL
AND at.leistungstest_gueltig_bis >= CURRENT_DATE
AND at.leistungstest_bestanden = TRUE
THEN TRUE
ELSE FALSE
END AS einsatzbereit
FROM atemschutz_traeger at
JOIN users u ON u.id = at.user_id
LEFT JOIN mitglieder_profile mp ON mp.user_id = at.user_id;

View File

@@ -0,0 +1,120 @@
-- =============================================================================
-- Migration 015: Veranstaltungen (Events / General Calendar)
-- General event calendar for Feuerwehr Dashboard, separate from the training
-- calendar (uebungen). Supports categories, RSVPs, and iCal subscriptions.
-- Depends on: 001_create_users_table.sql (uuid-ossp, pgcrypto extensions,
-- users table, update_updated_at_column trigger function)
-- =============================================================================
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- -----------------------------------------------------------------------------
-- 1. Event categories table
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS veranstaltung_kategorien (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
beschreibung TEXT,
farbe VARCHAR(7) NOT NULL DEFAULT '#1976d2', -- hex colour for UI chips
icon VARCHAR(100), -- MUI icon name, e.g. 'Event', 'FireTruck'
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TRIGGER update_veranstaltung_kategorien_aktualisiert_am
BEFORE UPDATE ON veranstaltung_kategorien
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- -----------------------------------------------------------------------------
-- 2. Main events table
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS veranstaltungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
titel VARCHAR(500) NOT NULL,
beschreibung TEXT,
ort VARCHAR(500),
ort_url VARCHAR(1000), -- optional maps/navigation link
kategorie_id UUID REFERENCES veranstaltung_kategorien(id) ON DELETE SET NULL,
datum_von TIMESTAMPTZ NOT NULL,
datum_bis TIMESTAMPTZ NOT NULL,
ganztaegig BOOLEAN NOT NULL DEFAULT FALSE,
-- zielgruppen: array of Authentik group names, e.g. '{dashboard_mitglied,dashboard_jugend}'
zielgruppen TEXT[] NOT NULL DEFAULT '{}',
alle_gruppen BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE = visible to all members
max_teilnehmer INTEGER CHECK (max_teilnehmer > 0),
anmeldung_erforderlich BOOLEAN NOT NULL DEFAULT FALSE,
anmeldung_bis TIMESTAMPTZ,
erstellt_von UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
abgesagt BOOLEAN NOT NULL DEFAULT FALSE,
abgesagt_grund TEXT,
abgesagt_am TIMESTAMPTZ,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT veranstaltung_datum_reihenfolge CHECK (datum_bis >= datum_von)
);
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_datum_von
ON veranstaltungen(datum_von);
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_datum_bis
ON veranstaltungen(datum_bis);
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_kategorie_id
ON veranstaltungen(kategorie_id);
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_abgesagt
ON veranstaltungen(abgesagt) WHERE abgesagt = FALSE;
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_alle_gruppen
ON veranstaltungen(alle_gruppen);
-- Compound index for the most common calendar-range query
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_datum_von_bis
ON veranstaltungen(datum_von, datum_bis);
CREATE TRIGGER update_veranstaltungen_aktualisiert_am
BEFORE UPDATE ON veranstaltungen
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- -----------------------------------------------------------------------------
-- 3. RSVP / attendance table
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS veranstaltung_teilnahmen (
veranstaltung_id UUID NOT NULL REFERENCES veranstaltungen(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
-- status values: zugesagt, abgesagt, erschienen, unbekannt
status VARCHAR(20) NOT NULL DEFAULT 'unbekannt',
notiz VARCHAR(500),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (veranstaltung_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_veranstaltung_teilnahmen_veranstaltung_id
ON veranstaltung_teilnahmen(veranstaltung_id);
CREATE INDEX IF NOT EXISTS idx_veranstaltung_teilnahmen_user_id
ON veranstaltung_teilnahmen(user_id);
-- -----------------------------------------------------------------------------
-- 4. Per-user iCal subscription tokens
-- One token per user — covers the full events calendar feed for that user.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS veranstaltung_ical_tokens (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(128) UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
zuletzt_verwendet_am TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_veranstaltung_ical_tokens_token
ON veranstaltung_ical_tokens(token);
-- -----------------------------------------------------------------------------
-- 5. Seed default event categories
-- -----------------------------------------------------------------------------
INSERT INTO veranstaltung_kategorien (name, farbe, icon) VALUES
('Allgemein', '#1976d2', 'Event'),
('Ausbildung', '#2e7d32', 'School'),
('Gesellschaft', '#e65100', 'People'),
('Feuerwehrjugend', '#f57c00', 'ChildCare'),
('Kommando', '#6a1b9a', 'Shield')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,86 @@
-- =============================================================================
-- Migration 016: Fahrzeugbuchungen (Vehicle Booking System)
-- Allows members to book fire department vehicles for internal use, external
-- events, maintenance slots and reservations. Includes per-user iCal feeds.
-- Depends on: 001_create_users_table.sql (uuid-ossp, pgcrypto extensions,
-- users table, update_updated_at_column trigger function)
-- 005_create_fahrzeuge.sql (fahrzeuge table)
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. ENUM: booking type
-- Uses DO-block for idempotent creation (PostgreSQL has no CREATE TYPE IF NOT EXISTS)
-- -----------------------------------------------------------------------------
DO $$
BEGIN
CREATE TYPE fahrzeug_buchung_art AS ENUM (
'intern',
'extern',
'wartung',
'reservierung',
'sonstiges'
);
EXCEPTION
WHEN duplicate_object THEN NULL;
END
$$;
-- -----------------------------------------------------------------------------
-- 2. Vehicle bookings table
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS fahrzeug_buchungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
titel VARCHAR(500) NOT NULL,
beschreibung TEXT,
beginn TIMESTAMPTZ NOT NULL,
ende TIMESTAMPTZ NOT NULL,
buchungs_art fahrzeug_buchung_art NOT NULL DEFAULT 'intern',
gebucht_von UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
-- kontakt fields are relevant for external bookings
kontakt_person VARCHAR(255),
kontakt_telefon VARCHAR(50),
abgesagt BOOLEAN NOT NULL DEFAULT FALSE,
abgesagt_grund TEXT,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT buchung_ende_nach_beginn CHECK (ende > beginn)
);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_fahrzeug_id
ON fahrzeug_buchungen(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_beginn
ON fahrzeug_buchungen(beginn);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_ende
ON fahrzeug_buchungen(ende);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_gebucht_von
ON fahrzeug_buchungen(gebucht_von);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_abgesagt
ON fahrzeug_buchungen(abgesagt) WHERE abgesagt = FALSE;
-- Compound index for availability / overlap checks (fahrzeug + time range)
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_fahrzeug_beginn_ende
ON fahrzeug_buchungen(fahrzeug_id, beginn, ende);
CREATE TRIGGER update_fahrzeug_buchungen_aktualisiert_am
BEFORE UPDATE ON fahrzeug_buchungen
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- -----------------------------------------------------------------------------
-- 3. Per-user iCal subscription tokens for the vehicle booking calendar
-- One token per user — the feed returns all bookings the user has access to.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS fahrzeug_ical_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(128) UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
zuletzt_verwendet_am TIMESTAMPTZ,
-- one token per user for the full vehicle booking feed
UNIQUE (user_id)
);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_ical_tokens_token
ON fahrzeug_ical_tokens(token);

View File

@@ -0,0 +1,7 @@
-- Migration 017: Add zielgruppen (target groups) to event categories
-- Links categories to user groups for visibility filtering
ALTER TABLE veranstaltung_kategorien
ADD COLUMN IF NOT EXISTS zielgruppen TEXT[] NOT NULL DEFAULT '{}';
-- Comment for documentation
COMMENT ON COLUMN veranstaltung_kategorien.zielgruppen IS 'Array of Authentik group names this category is linked to';

View File

@@ -0,0 +1,26 @@
-- Migration 018: Fix BEFORE UPDATE triggers on event tables
-- Problem: update_updated_at_column() sets NEW.updated_at but both event tables
-- use aktualisiert_am instead. This causes every UPDATE to fail inside the trigger.
-- Create a new trigger function that references the correct column name
CREATE OR REPLACE FUNCTION update_aktualisiert_am_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.aktualisiert_am = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- Fix veranstaltungen table trigger
DROP TRIGGER IF EXISTS update_veranstaltungen_aktualisiert_am ON veranstaltungen;
CREATE TRIGGER update_veranstaltungen_aktualisiert_am
BEFORE UPDATE ON veranstaltungen
FOR EACH ROW
EXECUTE FUNCTION update_aktualisiert_am_column();
-- Fix veranstaltung_kategorien table trigger (if it was added)
DROP TRIGGER IF EXISTS update_veranstaltung_kategorien_aktualisiert_am ON veranstaltung_kategorien;
CREATE TRIGGER update_veranstaltung_kategorien_aktualisiert_am
BEFORE UPDATE ON veranstaltung_kategorien
FOR EACH ROW
EXECUTE FUNCTION update_aktualisiert_am_column();

View File

@@ -0,0 +1,12 @@
-- Migration 019: Add recurring event support
-- Adds wiederholung (recurrence) config and parent link for generated instances
ALTER TABLE veranstaltungen
ADD COLUMN IF NOT EXISTS wiederholung JSONB,
ADD COLUMN IF NOT EXISTS wiederholung_parent_id UUID REFERENCES veranstaltungen(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_parent_id
ON veranstaltungen(wiederholung_parent_id);
COMMENT ON COLUMN veranstaltungen.wiederholung IS 'JSON config for recurring events: {typ, intervall?, bis, wochentag?}';
COMMENT ON COLUMN veranstaltungen.wiederholung_parent_id IS 'Links generated recurrence instances back to the parent event';

View File

@@ -0,0 +1,32 @@
-- Migration 020: Create notifications table
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS notifications (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
typ VARCHAR(50) NOT NULL,
titel VARCHAR(500) NOT NULL,
nachricht TEXT NOT NULL,
schwere VARCHAR(20) NOT NULL DEFAULT 'info'
CHECK (schwere IN ('info', 'warnung', 'fehler')),
gelesen BOOLEAN NOT NULL DEFAULT FALSE,
gelesen_am TIMESTAMPTZ,
link VARCHAR(500),
quell_id VARCHAR(100),
quell_typ VARCHAR(50),
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Fast lookup for unread badge count
CREATE INDEX IF NOT EXISTS notifications_user_unread_idx
ON notifications (user_id, gelesen)
WHERE NOT gelesen;
-- Fast lookup for notification list ordered by date
CREATE INDEX IF NOT EXISTS notifications_user_date_idx
ON notifications (user_id, erstellt_am DESC);
-- Dedup index: one unread notification per (user, source type, source id)
CREATE UNIQUE INDEX IF NOT EXISTS notifications_dedup_idx
ON notifications (user_id, quell_typ, quell_id)
WHERE NOT gelesen AND quell_typ IS NOT NULL AND quell_id IS NOT NULL;

View File

@@ -0,0 +1,28 @@
-- Migration 021: Add motorisiert flag to ausruestung_kategorien
-- Motorized equipment (motorisiert = TRUE) → managed by fahrmeister
-- Non-motorized equipment (motorisiert = FALSE) → managed by zeugmeister
ALTER TABLE ausruestung_kategorien
ADD COLUMN IF NOT EXISTS motorisiert BOOLEAN NOT NULL DEFAULT FALSE;
-- DROP + CREATE is required because CREATE OR REPLACE VIEW cannot change the
-- column list of an existing view (PostgreSQL error 42P16).
DROP VIEW IF EXISTS ausruestung_mit_pruefstatus;
CREATE VIEW ausruestung_mit_pruefstatus AS
SELECT
a.*,
k.name AS kategorie_name,
k.kurzname AS kategorie_kurzname,
k.motorisiert AS kategorie_motorisiert,
f.bezeichnung AS fahrzeug_bezeichnung,
f.kurzname AS fahrzeug_kurzname,
CASE
WHEN a.naechste_pruefung_am IS NOT NULL
THEN a.naechste_pruefung_am::date - CURRENT_DATE
ELSE NULL
END AS pruefung_tage_bis_faelligkeit
FROM ausruestung a
JOIN ausruestung_kategorien k ON k.id = a.kategorie_id
LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id AND f.deleted_at IS NULL
WHERE a.deleted_at IS NULL;

View File

@@ -0,0 +1,6 @@
-- Migration 022: Add alle_gruppen flag to veranstaltung_kategorien
-- When alle_gruppen = TRUE, selecting this category auto-fills
-- the event's alle_gruppen = TRUE and clears individual zielgruppen.
ALTER TABLE veranstaltung_kategorien
ADD COLUMN IF NOT EXISTS alle_gruppen BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS monitored_services (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(200) NOT NULL,
url VARCHAR(500) NOT NULL,
type VARCHAR(20) NOT NULL DEFAULT 'custom' CHECK (type IN ('internal','custom')),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE OR REPLACE TRIGGER update_monitored_services_updated_at
BEFORE UPDATE ON monitored_services
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,12 @@
CREATE TABLE IF NOT EXISTS app_settings (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_by UUID REFERENCES users(id) ON DELETE SET NULL
);
-- Seed default external links (empty array)
INSERT INTO app_settings (key, value) VALUES
('external_links', '[]'::jsonb),
('refresh_intervals', '{"dashboard": 300000, "admin_services": 15000}'::jsonb)
ON CONFLICT (key) DO NOTHING;

View File

@@ -0,0 +1,14 @@
CREATE TYPE banner_level AS ENUM ('info', 'important', 'critical');
CREATE TABLE IF NOT EXISTS announcement_banners (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
message TEXT NOT NULL,
level banner_level NOT NULL DEFAULT 'info',
starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ends_at TIMESTAMPTZ,
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_banners_starts_at ON announcement_banners (starts_at);
CREATE INDEX idx_banners_ends_at ON announcement_banners (ends_at);

View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS service_ping_history (
id SERIAL PRIMARY KEY,
service_id VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL,
response_time_ms INTEGER,
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sph_service_checked ON service_ping_history(service_id, checked_at DESC);

View File

@@ -0,0 +1,3 @@
ALTER TABLE announcement_banners
ADD COLUMN show_as VARCHAR(20) NOT NULL DEFAULT 'banner'
CHECK (show_as IN ('banner', 'widget'));

View File

@@ -0,0 +1,2 @@
ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS fdisk_standesbuch_nr VARCHAR(32);
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_fdisk_standesbuch_nr ON mitglieder_profile(fdisk_standesbuch_nr);

View File

@@ -0,0 +1,127 @@
-- =============================================================================
-- Migration 029: Vehicle Out-of-Service Timeframe + Lehrgang Booking Type
--
-- 1. Add ausser_dienst_von / ausser_dienst_bis columns to fahrzeuge
-- 2. Add 'lehrgang' value to fahrzeug_buchung_art enum
-- 3. Remove 'in_lehrgang' from vehicle status CHECK constraint
-- 4. Refresh fahrzeuge_mit_pruefstatus view to include new columns
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. Add out-of-service timeframe columns
-- -----------------------------------------------------------------------------
ALTER TABLE fahrzeuge
ADD COLUMN IF NOT EXISTS ausser_dienst_von TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS ausser_dienst_bis TIMESTAMPTZ;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'fahrzeuge'
AND constraint_name = 'chk_ausser_dienst_zeitraum'
) THEN
ALTER TABLE fahrzeuge
ADD CONSTRAINT chk_ausser_dienst_zeitraum
CHECK (
ausser_dienst_von IS NULL
OR ausser_dienst_bis IS NULL
OR ausser_dienst_bis > ausser_dienst_von
);
END IF;
END
$$;
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_ausser_dienst_zeitraum
ON fahrzeuge(id, ausser_dienst_von, ausser_dienst_bis)
WHERE ausser_dienst_von IS NOT NULL;
-- -----------------------------------------------------------------------------
-- 2. Add 'lehrgang' to fahrzeug_buchung_art enum
-- -----------------------------------------------------------------------------
DO $$
BEGIN
ALTER TYPE fahrzeug_buchung_art ADD VALUE IF NOT EXISTS 'lehrgang';
EXCEPTION
WHEN others THEN
IF SQLERRM NOT LIKE '%already exists%' THEN
RAISE;
END IF;
END
$$;
-- -----------------------------------------------------------------------------
-- 3. Remove 'in_lehrgang' from vehicle status CHECK
-- -----------------------------------------------------------------------------
-- Migrate existing in_lehrgang rows first
UPDATE fahrzeuge SET status = 'einsatzbereit' WHERE status = 'in_lehrgang';
-- Drop old check constraint (created in migration 005 as fahrzeuge_status_check)
ALTER TABLE fahrzeuge DROP CONSTRAINT IF EXISTS fahrzeuge_status_check;
-- Re-add with only 3 valid values
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'fahrzeuge'
AND constraint_name = 'chk_fahrzeuge_status'
) THEN
ALTER TABLE fahrzeuge
ADD CONSTRAINT chk_fahrzeuge_status
CHECK (status IN (
'einsatzbereit',
'ausser_dienst_wartung',
'ausser_dienst_schaden'
));
END IF;
END
$$;
-- -----------------------------------------------------------------------------
-- 4. Refresh fahrzeuge_mit_pruefstatus view to include new columns
-- -----------------------------------------------------------------------------
DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus;
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
SELECT
f.id,
f.bezeichnung,
f.kurzname,
f.amtliches_kennzeichen,
f.fahrgestellnummer,
f.baujahr,
f.hersteller,
f.typ_schluessel,
f.besatzung_soll,
f.status,
f.status_bemerkung,
f.ausser_dienst_von,
f.ausser_dienst_bis,
f.standort,
f.bild_url,
f.created_at,
f.updated_at,
f.paragraph57a_faellig_am,
CASE
WHEN f.paragraph57a_faellig_am IS NOT NULL
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
ELSE NULL
END AS paragraph57a_tage_bis_faelligkeit,
f.naechste_wartung_am,
CASE
WHEN f.naechste_wartung_am IS NOT NULL
THEN f.naechste_wartung_am::date - CURRENT_DATE
ELSE NULL
END AS wartung_tage_bis_faelligkeit,
LEAST(
CASE WHEN f.paragraph57a_faellig_am IS NOT NULL
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
ELSE NULL END,
CASE WHEN f.naechste_wartung_am IS NOT NULL
THEN f.naechste_wartung_am::date - CURRENT_DATE
ELSE NULL END
) AS naechste_pruefung_tage
FROM fahrzeuge f
WHERE f.deleted_at IS NULL;

View File

@@ -0,0 +1,56 @@
-- Extends the dienstgrad CHECK constraint to include:
-- Jugendfeuerwehrmann, Probefeuerwehrmann, Verwaltungsmeister family, Verwalter,
-- and Ehren- prefixed variants of all Dienstgrade.
ALTER TABLE mitglieder_profile
DROP CONSTRAINT IF EXISTS mitglieder_profile_dienstgrad_check;
ALTER TABLE mitglieder_profile
ADD CONSTRAINT mitglieder_profile_dienstgrad_check
CHECK (dienstgrad IS NULL OR dienstgrad IN (
-- Standard Dienstgrade
'Feuerwehranwärter',
'Jugendfeuerwehrmann',
'Probefeuerwehrmann',
'Feuerwehrmann',
'Feuerwehrfrau',
'Oberfeuerwehrmann',
'Oberfeuerwehrfrau',
'Hauptfeuerwehrmann',
'Hauptfeuerwehrfrau',
'Löschmeister',
'Oberlöschmeister',
'Hauptlöschmeister',
'Brandmeister',
'Oberbrandmeister',
'Hauptbrandmeister',
'Brandinspektor',
'Oberbrandinspektor',
'Brandoberinspektor',
'Brandamtmann',
'Verwaltungsmeister',
'Oberverwaltungsmeister',
'Hauptverwaltungsmeister',
'Verwalter',
-- Ehrendienstgrade
'Ehren-Feuerwehrmann',
'Ehren-Feuerwehrfrau',
'Ehren-Oberfeuerwehrmann',
'Ehren-Oberfeuerwehrfrau',
'Ehren-Hauptfeuerwehrmann',
'Ehren-Hauptfeuerwehrfrau',
'Ehren-Löschmeister',
'Ehren-Oberlöschmeister',
'Ehren-Hauptlöschmeister',
'Ehren-Brandmeister',
'Ehren-Oberbrandmeister',
'Ehren-Hauptbrandmeister',
'Ehren-Brandinspektor',
'Ehren-Oberbrandinspektor',
'Ehren-Brandoberinspektor',
'Ehren-Brandamtmann',
'Ehren-Verwaltungsmeister',
'Ehren-Oberverwaltungsmeister',
'Ehren-Hauptverwaltungsmeister',
'Ehren-Verwalter'
));

View File

@@ -0,0 +1,2 @@
-- Remove mitglieds_nr column (replaced by fdisk_standesbuch_nr as the canonical member number)
ALTER TABLE mitglieder_profile DROP COLUMN IF EXISTS mitglieds_nr;

View File

@@ -0,0 +1,6 @@
-- Migration 032: Add FDISK-scraped profile fields to mitglieder_profile
ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS geburtsort VARCHAR(128);
ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS geschlecht VARCHAR(32);
ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS beruf VARCHAR(255);
ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS wohnort VARCHAR(128);
ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS plz VARCHAR(16);

View File

@@ -0,0 +1,13 @@
-- Migration 033: Create befoerderungen table (FDISK sync)
CREATE TABLE IF NOT EXISTS befoerderungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
datum DATE,
dienstgrad VARCHAR(64) NOT NULL,
fdisk_sync_key VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, fdisk_sync_key)
);
CREATE INDEX IF NOT EXISTS idx_befoerderungen_user_id ON befoerderungen(user_id);

View File

@@ -0,0 +1,16 @@
-- Migration 034: Create untersuchungen table (FDISK sync)
CREATE TABLE IF NOT EXISTS untersuchungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
datum DATE,
anmerkungen TEXT,
art VARCHAR(128) NOT NULL,
ergebnis VARCHAR(128),
fdisk_sync_key VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, fdisk_sync_key)
);
CREATE INDEX IF NOT EXISTS idx_untersuchungen_user_id ON untersuchungen(user_id);
CREATE INDEX IF NOT EXISTS idx_untersuchungen_art ON untersuchungen(user_id, art);

View File

@@ -0,0 +1,16 @@
-- Migration 035: Create fahrgenehmigungen table (FDISK sync)
CREATE TABLE IF NOT EXISTS fahrgenehmigungen (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
ausstellungsdatum DATE,
gueltig_bis DATE,
behoerde VARCHAR(128),
nummer VARCHAR(64),
klasse VARCHAR(128) NOT NULL,
fdisk_sync_key VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, fdisk_sync_key)
);
CREATE INDEX IF NOT EXISTS idx_fahrgenehmigungen_user_id ON fahrgenehmigungen(user_id);

View File

@@ -0,0 +1,2 @@
-- Migration 036: Widen geschlecht column (was VARCHAR(1), stores full text like 'männlich')
ALTER TABLE mitglieder_profile ALTER COLUMN geschlecht TYPE VARCHAR(32);

View File

@@ -0,0 +1,440 @@
-- Migration 037: DB-driven permission system
-- Replaces hardcoded RBAC with per-Authentik-group permission assignments
-- DROP + recreate is safe because this feature has not been deployed yet.
-- ═══════════════════════════════════════════════════════════════════════════
-- 0. Clean slate
-- ═══════════════════════════════════════════════════════════════════════════
DROP TABLE IF EXISTS group_permissions CASCADE;
DROP TABLE IF EXISTS permissions CASCADE;
DROP TABLE IF EXISTS feature_groups CASCADE;
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Feature Groups
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE feature_groups (
id VARCHAR(50) PRIMARY KEY,
label VARCHAR(100) NOT NULL,
sort_order INT NOT NULL DEFAULT 0,
maintenance BOOLEAN NOT NULL DEFAULT FALSE
);
INSERT INTO feature_groups (id, label, sort_order) VALUES
('kalender', 'Kalender', 1),
('fahrzeuge', 'Fahrzeuge', 2),
('einsaetze', 'Einsätze', 3),
('ausruestung', 'Ausrüstung', 4),
('mitglieder', 'Mitglieder', 5),
('atemschutz', 'Atemschutz', 6),
('wissen', 'Wissen', 7),
('vikunja', 'Vikunja', 8),
('dashboard', 'Dashboard', 9),
('admin', 'Admin', 10)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Permissions
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE permissions (
id VARCHAR(100) PRIMARY KEY,
feature_group_id VARCHAR(50) NOT NULL REFERENCES feature_groups(id) ON DELETE CASCADE,
label VARCHAR(150) NOT NULL,
description TEXT,
sort_order INT NOT NULL DEFAULT 0
);
-- Kalender permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('kalender:view', 'kalender', 'Ansehen', 'Kalender einsehen (Termine, Übungen, Buchungen)', 1),
('kalender:create', 'kalender', 'Erstellen', 'Termine und Übungen erstellen', 2),
('kalender:cancel', 'kalender', 'Absagen', 'Termine und Übungen absagen', 3),
('kalender:mark_attendance', 'kalender', 'Anwesenheit eintragen', 'Teilnahme bestätigen', 4),
('kalender:create_bookings', 'kalender', 'Buchungen erstellen', 'Neue Fahrzeugbuchungen anlegen', 5),
('kalender:edit_bookings', 'kalender', 'Buchungen bearbeiten', 'Bestehende Buchungen ändern', 6),
('kalender:cancel_own_bookings','kalender', 'Eigene Buchungen stornieren','Eigene Buchungen stornieren', 7),
('kalender:delete_bookings', 'kalender', 'Alle Buchungen stornieren/löschen', 'Alle Buchungen stornieren oder löschen', 8),
('kalender:manage_categories', 'kalender', 'Kategorien verwalten', 'Veranstaltungskategorien verwalten', 9),
('kalender:view_reports', 'kalender', 'Berichte ansehen', 'Übungsstatistiken und Berichte einsehen', 10),
('kalender:widget_events', 'kalender', 'Widget: Termine', 'Dashboard-Widget für Termine', 11),
('kalender:widget_bookings', 'kalender', 'Widget: Buchungen', 'Dashboard-Widget für Buchungen', 12),
('kalender:widget_quick_add', 'kalender', 'Widget: Schnell-Termin', 'Dashboard-Widget zum schnellen Erstellen', 13)
ON CONFLICT (id) DO NOTHING;
-- Fahrzeuge permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('fahrzeuge:view', 'fahrzeuge', 'Ansehen', 'Fahrzeugdetails einsehen', 1),
('fahrzeuge:create', 'fahrzeuge', 'Erstellen', 'Neue Fahrzeuge anlegen', 2),
('fahrzeuge:change_status', 'fahrzeuge', 'Status ändern', 'Fahrzeugstatus ändern', 3),
('fahrzeuge:manage_maintenance', 'fahrzeuge', 'Wartung verwalten', 'Wartungseinträge erstellen/bearbeiten', 4),
('fahrzeuge:delete', 'fahrzeuge', 'Löschen', 'Fahrzeuge löschen', 5),
('fahrzeuge:widget', 'fahrzeuge', 'Widget', 'Dashboard-Widget für Fahrzeuge', 6)
ON CONFLICT (id) DO NOTHING;
-- Einsätze permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('einsaetze:view', 'einsaetze', 'Ansehen', 'Einsätze einsehen', 1),
('einsaetze:view_reports', 'einsaetze', 'Berichte ansehen', 'Einsatzberichte/Berichtstext einsehen', 2),
('einsaetze:create', 'einsaetze', 'Erstellen', 'Neue Einsätze anlegen und bearbeiten', 3),
('einsaetze:delete', 'einsaetze', 'Löschen', 'Einsätze archivieren/löschen', 4),
('einsaetze:manage_personnel', 'einsaetze', 'Personal verwalten', 'Einsatzpersonal und Fahrzeuge zuweisen', 5)
ON CONFLICT (id) DO NOTHING;
-- Ausrüstung permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('ausruestung:view', 'ausruestung', 'Ansehen', 'Ausrüstung einsehen', 1),
('ausruestung:create', 'ausruestung', 'Erstellen', 'Neue Ausrüstung anlegen und bearbeiten', 2),
('ausruestung:manage_maintenance', 'ausruestung', 'Wartung verwalten', 'Wartungseinträge verwalten', 3),
('ausruestung:delete', 'ausruestung', 'Löschen', 'Ausrüstung löschen', 4),
('ausruestung:widget', 'ausruestung', 'Widget', 'Dashboard-Widget für Ausrüstung', 5)
ON CONFLICT (id) DO NOTHING;
-- Mitglieder permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('mitglieder:view_own', 'mitglieder', 'Eigenes Profil', 'Eigenes Profil einsehen', 1),
('mitglieder:view_all', 'mitglieder', 'Alle Profile', 'Alle Mitglieder-Profile einsehen', 2),
('mitglieder:edit', 'mitglieder', 'Bearbeiten', 'Mitglieder-Profile bearbeiten', 3),
('mitglieder:create_profile','mitglieder', 'Profil erstellen', 'Neue Mitglieder-Profile anlegen', 4)
ON CONFLICT (id) DO NOTHING;
-- Atemschutz permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('atemschutz:view', 'atemschutz', 'Ansehen', 'Atemschutz-Daten aller Träger sehen', 1),
('atemschutz:create', 'atemschutz', 'Erstellen', 'Atemschutz-Einträge anlegen/ändern', 2),
('atemschutz:delete', 'atemschutz', 'Löschen', 'Atemschutz-Einträge löschen', 3),
('atemschutz:widget', 'atemschutz', 'Widget', 'Dashboard-Widget für Atemschutz', 4)
ON CONFLICT (id) DO NOTHING;
-- Wissen permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('wissen:view', 'wissen', 'Ansehen', 'Wissen-Seite anzeigen', 1),
('wissen:widget_recent', 'wissen', 'Widget: Letzte', 'Dashboard-Widget letzte Seiten', 2),
('wissen:widget_search', 'wissen', 'Widget: Suche', 'Dashboard-Widget für BookStack-Suche', 3)
ON CONFLICT (id) DO NOTHING;
-- Vikunja permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('vikunja:create_tasks', 'vikunja', 'Aufgaben erstellen', 'Neue Vikunja-Aufgaben erstellen', 1),
('vikunja:widget_tasks', 'vikunja', 'Widget: Aufgaben', 'Dashboard-Widget für Vikunja-Aufgaben', 2),
('vikunja:widget_quick_add', 'vikunja', 'Widget: Schnell-Task', 'Dashboard-Widget zum schnellen Erstellen', 3)
ON CONFLICT (id) DO NOTHING;
-- Dashboard permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('dashboard:widget_links', 'dashboard', 'Widget: Links', 'Dashboard-Widget für externe Links', 1),
('dashboard:widget_banner', 'dashboard', 'Widget: Banner', 'Dashboard-Widget für Banner', 2)
ON CONFLICT (id) DO NOTHING;
-- Admin permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('admin:view', 'admin', 'Ansehen', 'Admin-Panel einsehen', 1),
('admin:write', 'admin', 'Bearbeiten', 'Admin-Einstellungen ändern', 2)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Group Permissions
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE group_permissions (
authentik_group VARCHAR(100) NOT NULL,
permission_id VARCHAR(100) NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
granted_by UUID REFERENCES users(id) ON DELETE SET NULL,
PRIMARY KEY (authentik_group, permission_id)
);
CREATE INDEX idx_group_permissions_group ON group_permissions(authentik_group);
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Seed data — replicate current RBAC behavior
-- ═══════════════════════════════════════════════════════════════════════════
-- NOTE: dashboard_admin is NOT seeded — it has hardwired full access in code.
-- ── dashboard_kommando — near-full access ──
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender (ALL)
('dashboard_kommando', 'kalender:view'),
('dashboard_kommando', 'kalender:create'),
('dashboard_kommando', 'kalender:cancel'),
('dashboard_kommando', 'kalender:mark_attendance'),
('dashboard_kommando', 'kalender:create_bookings'),
('dashboard_kommando', 'kalender:edit_bookings'),
('dashboard_kommando', 'kalender:cancel_own_bookings'),
('dashboard_kommando', 'kalender:delete_bookings'),
('dashboard_kommando', 'kalender:manage_categories'),
('dashboard_kommando', 'kalender:view_reports'),
('dashboard_kommando', 'kalender:widget_events'),
('dashboard_kommando', 'kalender:widget_bookings'),
('dashboard_kommando', 'kalender:widget_quick_add'),
-- Fahrzeuge (ALL)
('dashboard_kommando', 'fahrzeuge:view'),
('dashboard_kommando', 'fahrzeuge:create'),
('dashboard_kommando', 'fahrzeuge:change_status'),
('dashboard_kommando', 'fahrzeuge:manage_maintenance'),
('dashboard_kommando', 'fahrzeuge:delete'),
('dashboard_kommando', 'fahrzeuge:widget'),
-- Einsätze (ALL)
('dashboard_kommando', 'einsaetze:view'),
('dashboard_kommando', 'einsaetze:view_reports'),
('dashboard_kommando', 'einsaetze:create'),
('dashboard_kommando', 'einsaetze:delete'),
('dashboard_kommando', 'einsaetze:manage_personnel'),
-- Ausrüstung (ALL)
('dashboard_kommando', 'ausruestung:view'),
('dashboard_kommando', 'ausruestung:create'),
('dashboard_kommando', 'ausruestung:manage_maintenance'),
('dashboard_kommando', 'ausruestung:delete'),
('dashboard_kommando', 'ausruestung:widget'),
-- Mitglieder (ALL)
('dashboard_kommando', 'mitglieder:view_own'),
('dashboard_kommando', 'mitglieder:view_all'),
('dashboard_kommando', 'mitglieder:edit'),
('dashboard_kommando', 'mitglieder:create_profile'),
-- Atemschutz (ALL)
('dashboard_kommando', 'atemschutz:view'),
('dashboard_kommando', 'atemschutz:create'),
('dashboard_kommando', 'atemschutz:delete'),
('dashboard_kommando', 'atemschutz:widget'),
-- Wissen (ALL)
('dashboard_kommando', 'wissen:view'),
('dashboard_kommando', 'wissen:widget_recent'),
('dashboard_kommando', 'wissen:widget_search'),
-- Vikunja (ALL)
('dashboard_kommando', 'vikunja:create_tasks'),
('dashboard_kommando', 'vikunja:widget_tasks'),
('dashboard_kommando', 'vikunja:widget_quick_add'),
-- Dashboard (ALL)
('dashboard_kommando', 'dashboard:widget_links'),
('dashboard_kommando', 'dashboard:widget_banner'),
-- Admin (view only)
('dashboard_kommando', 'admin:view')
ON CONFLICT DO NOTHING;
-- ── dashboard_fahrmeister — vehicle specialist ──
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender
('dashboard_fahrmeister', 'kalender:view'),
('dashboard_fahrmeister', 'kalender:create_bookings'),
('dashboard_fahrmeister', 'kalender:edit_bookings'),
('dashboard_fahrmeister', 'kalender:cancel_own_bookings'),
('dashboard_fahrmeister', 'kalender:widget_events'),
('dashboard_fahrmeister', 'kalender:widget_bookings'),
-- Fahrzeuge
('dashboard_fahrmeister', 'fahrzeuge:view'),
('dashboard_fahrmeister', 'fahrzeuge:change_status'),
('dashboard_fahrmeister', 'fahrzeuge:manage_maintenance'),
('dashboard_fahrmeister', 'fahrzeuge:widget'),
-- Einsätze
('dashboard_fahrmeister', 'einsaetze:view'),
-- Ausrüstung
('dashboard_fahrmeister', 'ausruestung:view'),
('dashboard_fahrmeister', 'ausruestung:create'),
('dashboard_fahrmeister', 'ausruestung:manage_maintenance'),
('dashboard_fahrmeister', 'ausruestung:widget'),
-- Mitglieder
('dashboard_fahrmeister', 'mitglieder:view_own'),
('dashboard_fahrmeister', 'mitglieder:view_all'),
-- Atemschutz
('dashboard_fahrmeister', 'atemschutz:widget'),
-- Wissen
('dashboard_fahrmeister', 'wissen:view'),
('dashboard_fahrmeister', 'wissen:widget_recent'),
('dashboard_fahrmeister', 'wissen:widget_search'),
-- Vikunja
('dashboard_fahrmeister', 'vikunja:create_tasks'),
('dashboard_fahrmeister', 'vikunja:widget_tasks'),
('dashboard_fahrmeister', 'vikunja:widget_quick_add'),
-- Dashboard
('dashboard_fahrmeister', 'dashboard:widget_links'),
('dashboard_fahrmeister', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;
-- ── dashboard_zeugmeister — equipment specialist ──
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender
('dashboard_zeugmeister', 'kalender:view'),
('dashboard_zeugmeister', 'kalender:create_bookings'),
('dashboard_zeugmeister', 'kalender:cancel_own_bookings'),
('dashboard_zeugmeister', 'kalender:widget_events'),
('dashboard_zeugmeister', 'kalender:widget_bookings'),
-- Fahrzeuge
('dashboard_zeugmeister', 'fahrzeuge:view'),
('dashboard_zeugmeister', 'fahrzeuge:change_status'),
('dashboard_zeugmeister', 'fahrzeuge:manage_maintenance'),
('dashboard_zeugmeister', 'fahrzeuge:widget'),
-- Einsätze
('dashboard_zeugmeister', 'einsaetze:view'),
-- Ausrüstung
('dashboard_zeugmeister', 'ausruestung:view'),
('dashboard_zeugmeister', 'ausruestung:create'),
('dashboard_zeugmeister', 'ausruestung:manage_maintenance'),
('dashboard_zeugmeister', 'ausruestung:widget'),
-- Mitglieder
('dashboard_zeugmeister', 'mitglieder:view_own'),
('dashboard_zeugmeister', 'mitglieder:view_all'),
-- Atemschutz
('dashboard_zeugmeister', 'atemschutz:widget'),
-- Wissen
('dashboard_zeugmeister', 'wissen:view'),
('dashboard_zeugmeister', 'wissen:widget_recent'),
('dashboard_zeugmeister', 'wissen:widget_search'),
-- Vikunja
('dashboard_zeugmeister', 'vikunja:create_tasks'),
('dashboard_zeugmeister', 'vikunja:widget_tasks'),
('dashboard_zeugmeister', 'vikunja:widget_quick_add'),
-- Dashboard
('dashboard_zeugmeister', 'dashboard:widget_links'),
('dashboard_zeugmeister', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;
-- ── dashboard_chargen — mid level ──
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender
('dashboard_chargen', 'kalender:view'),
('dashboard_chargen', 'kalender:create_bookings'),
('dashboard_chargen', 'kalender:cancel_own_bookings'),
('dashboard_chargen', 'kalender:widget_events'),
('dashboard_chargen', 'kalender:widget_bookings'),
-- Fahrzeuge
('dashboard_chargen', 'fahrzeuge:view'),
('dashboard_chargen', 'fahrzeuge:change_status'),
('dashboard_chargen', 'fahrzeuge:manage_maintenance'),
('dashboard_chargen', 'fahrzeuge:widget'),
-- Einsätze
('dashboard_chargen', 'einsaetze:view'),
('dashboard_chargen', 'einsaetze:create'),
('dashboard_chargen', 'einsaetze:manage_personnel'),
-- Ausrüstung
('dashboard_chargen', 'ausruestung:view'),
('dashboard_chargen', 'ausruestung:create'),
('dashboard_chargen', 'ausruestung:manage_maintenance'),
('dashboard_chargen', 'ausruestung:widget'),
-- Mitglieder
('dashboard_chargen', 'mitglieder:view_own'),
('dashboard_chargen', 'mitglieder:view_all'),
-- Atemschutz
('dashboard_chargen', 'atemschutz:view'),
('dashboard_chargen', 'atemschutz:create'),
('dashboard_chargen', 'atemschutz:widget'),
-- Wissen
('dashboard_chargen', 'wissen:view'),
('dashboard_chargen', 'wissen:widget_recent'),
('dashboard_chargen', 'wissen:widget_search'),
-- Vikunja
('dashboard_chargen', 'vikunja:create_tasks'),
('dashboard_chargen', 'vikunja:widget_tasks'),
('dashboard_chargen', 'vikunja:widget_quick_add'),
-- Dashboard
('dashboard_chargen', 'dashboard:widget_links'),
('dashboard_chargen', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;
-- ── dashboard_moderator — event/calendar management ──
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender
('dashboard_moderator', 'kalender:view'),
('dashboard_moderator', 'kalender:create'),
('dashboard_moderator', 'kalender:cancel_own_bookings'),
('dashboard_moderator', 'kalender:create_bookings'),
('dashboard_moderator', 'kalender:edit_bookings'),
('dashboard_moderator', 'kalender:manage_categories'),
('dashboard_moderator', 'kalender:widget_events'),
('dashboard_moderator', 'kalender:widget_bookings'),
('dashboard_moderator', 'kalender:widget_quick_add'),
-- Fahrzeuge
('dashboard_moderator', 'fahrzeuge:view'),
('dashboard_moderator', 'fahrzeuge:widget'),
-- Einsätze
('dashboard_moderator', 'einsaetze:view'),
-- Ausrüstung
('dashboard_moderator', 'ausruestung:view'),
('dashboard_moderator', 'ausruestung:widget'),
-- Mitglieder
('dashboard_moderator', 'mitglieder:view_own'),
('dashboard_moderator', 'mitglieder:view_all'),
-- Atemschutz
('dashboard_moderator', 'atemschutz:view'),
('dashboard_moderator', 'atemschutz:widget'),
-- Wissen
('dashboard_moderator', 'wissen:view'),
('dashboard_moderator', 'wissen:widget_recent'),
('dashboard_moderator', 'wissen:widget_search'),
-- Vikunja
('dashboard_moderator', 'vikunja:create_tasks'),
('dashboard_moderator', 'vikunja:widget_tasks'),
('dashboard_moderator', 'vikunja:widget_quick_add'),
-- Dashboard
('dashboard_moderator', 'dashboard:widget_links'),
('dashboard_moderator', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;
-- ── dashboard_atemschutz — atemschutz specialist ──
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender
('dashboard_atemschutz', 'kalender:view'),
('dashboard_atemschutz', 'kalender:create_bookings'),
('dashboard_atemschutz', 'kalender:cancel_own_bookings'),
('dashboard_atemschutz', 'kalender:widget_events'),
('dashboard_atemschutz', 'kalender:widget_bookings'),
-- Fahrzeuge
('dashboard_atemschutz', 'fahrzeuge:view'),
('dashboard_atemschutz', 'fahrzeuge:widget'),
-- Einsätze
('dashboard_atemschutz', 'einsaetze:view'),
-- Ausrüstung
('dashboard_atemschutz', 'ausruestung:view'),
('dashboard_atemschutz', 'ausruestung:widget'),
-- Mitglieder
('dashboard_atemschutz', 'mitglieder:view_own'),
('dashboard_atemschutz', 'mitglieder:view_all'),
-- Atemschutz
('dashboard_atemschutz', 'atemschutz:view'),
('dashboard_atemschutz', 'atemschutz:create'),
('dashboard_atemschutz', 'atemschutz:widget'),
-- Wissen
('dashboard_atemschutz', 'wissen:view'),
('dashboard_atemschutz', 'wissen:widget_recent'),
('dashboard_atemschutz', 'wissen:widget_search'),
-- Vikunja
('dashboard_atemschutz', 'vikunja:create_tasks'),
('dashboard_atemschutz', 'vikunja:widget_tasks'),
('dashboard_atemschutz', 'vikunja:widget_quick_add'),
-- Dashboard
('dashboard_atemschutz', 'dashboard:widget_links'),
('dashboard_atemschutz', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;
-- ── dashboard_mitglied — basic member ──
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kalender
('dashboard_mitglied', 'kalender:view'),
('dashboard_mitglied', 'kalender:create_bookings'),
('dashboard_mitglied', 'kalender:cancel_own_bookings'),
('dashboard_mitglied', 'kalender:widget_events'),
('dashboard_mitglied', 'kalender:widget_bookings'),
-- Fahrzeuge
('dashboard_mitglied', 'fahrzeuge:view'),
('dashboard_mitglied', 'fahrzeuge:widget'),
-- Einsätze
('dashboard_mitglied', 'einsaetze:view'),
-- Ausrüstung
('dashboard_mitglied', 'ausruestung:view'),
('dashboard_mitglied', 'ausruestung:widget'),
-- Mitglieder
('dashboard_mitglied', 'mitglieder:view_own'),
-- Atemschutz
('dashboard_mitglied', 'atemschutz:widget'),
-- Wissen
('dashboard_mitglied', 'wissen:view'),
('dashboard_mitglied', 'wissen:widget_recent'),
('dashboard_mitglied', 'wissen:widget_search'),
-- Vikunja
('dashboard_mitglied', 'vikunja:create_tasks'),
('dashboard_mitglied', 'vikunja:widget_tasks'),
('dashboard_mitglied', 'vikunja:widget_quick_add'),
-- Dashboard
('dashboard_mitglied', 'dashboard:widget_links'),
('dashboard_mitglied', 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,145 @@
-- Migration 038: Bestellungen (Vendor Orders) system
-- Tables for vendors, orders, line items, file attachments, reminders, and audit trail.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Lieferanten (Vendors)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS lieferanten (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
kontakt_name TEXT,
email TEXT,
telefon TEXT,
adresse TEXT,
website TEXT,
notizen TEXT,
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_lieferanten_name ON lieferanten(name);
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Bestellungen (Orders)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS bestellungen (
id SERIAL PRIMARY KEY,
bezeichnung TEXT NOT NULL,
lieferant_id INT REFERENCES lieferanten(id) ON DELETE SET NULL,
besteller_id UUID REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'entwurf'
CHECK (status IN ('entwurf','erstellt','bestellt','teillieferung','vollstaendig','abgeschlossen')),
budget NUMERIC(10,2),
notizen TEXT,
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
bestellt_am TIMESTAMPTZ,
abgeschlossen_am TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_bestellungen_status ON bestellungen(status);
CREATE INDEX IF NOT EXISTS idx_bestellungen_lieferant ON bestellungen(lieferant_id);
CREATE INDEX IF NOT EXISTS idx_bestellungen_besteller ON bestellungen(besteller_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Bestellpositionen (Order Line Items)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS bestellpositionen (
id SERIAL PRIMARY KEY,
bestellung_id INT NOT NULL REFERENCES bestellungen(id) ON DELETE CASCADE,
bezeichnung TEXT NOT NULL,
artikelnummer TEXT,
menge NUMERIC NOT NULL DEFAULT 1,
einheit TEXT DEFAULT 'Stk',
einzelpreis NUMERIC(10,2),
erhalten_menge NUMERIC NOT NULL DEFAULT 0,
notizen TEXT,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bestellpositionen_bestellung ON bestellpositionen(bestellung_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Bestellung Dateien (Order File Attachments)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS bestellung_dateien (
id SERIAL PRIMARY KEY,
bestellung_id INT NOT NULL REFERENCES bestellungen(id) ON DELETE CASCADE,
dateiname TEXT NOT NULL,
dateipfad TEXT NOT NULL,
dateityp TEXT NOT NULL,
dateigroesse INT,
thumbnail_pfad TEXT,
hochgeladen_von UUID REFERENCES users(id) ON DELETE SET NULL,
hochgeladen_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bestellung_dateien_bestellung ON bestellung_dateien(bestellung_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. Bestellung Erinnerungen (Order Reminders)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS bestellung_erinnerungen (
id SERIAL PRIMARY KEY,
bestellung_id INT NOT NULL REFERENCES bestellungen(id) ON DELETE CASCADE,
faellig_am TIMESTAMPTZ NOT NULL,
nachricht TEXT,
erledigt BOOLEAN NOT NULL DEFAULT FALSE,
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bestellung_erinnerungen_faellig ON bestellung_erinnerungen(faellig_am) WHERE NOT erledigt;
-- ═══════════════════════════════════════════════════════════════════════════
-- 6. Bestellung Historie (Audit Trail)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS bestellung_historie (
id SERIAL PRIMARY KEY,
bestellung_id INT NOT NULL REFERENCES bestellungen(id) ON DELETE CASCADE,
aktion TEXT NOT NULL,
details JSONB,
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_bestellung_historie_bestellung ON bestellung_historie(bestellung_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 7. Auto-update aktualisiert_am triggers
-- ═══════════════════════════════════════════════════════════════════════════
CREATE OR REPLACE FUNCTION update_aktualisiert_am()
RETURNS TRIGGER AS $$
BEGIN
NEW.aktualisiert_am = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_lieferanten_aktualisiert') THEN
CREATE TRIGGER trg_lieferanten_aktualisiert BEFORE UPDATE ON lieferanten
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_bestellungen_aktualisiert') THEN
CREATE TRIGGER trg_bestellungen_aktualisiert BEFORE UPDATE ON bestellungen
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_bestellpositionen_aktualisiert') THEN
CREATE TRIGGER trg_bestellpositionen_aktualisiert BEFORE UPDATE ON bestellpositionen
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
END IF;
END $$;

View File

@@ -0,0 +1,84 @@
-- Migration 039: Internal Shop system
-- Tables for catalog items, member requests, request line items, and order linking.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Shop Artikel (Catalog Items)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS shop_artikel (
id SERIAL PRIMARY KEY,
bezeichnung TEXT NOT NULL,
beschreibung TEXT,
kategorie TEXT,
bild_pfad TEXT,
geschaetzter_preis NUMERIC(10,2),
aktiv BOOLEAN NOT NULL DEFAULT TRUE,
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_shop_artikel_kategorie ON shop_artikel(kategorie);
CREATE INDEX IF NOT EXISTS idx_shop_artikel_aktiv ON shop_artikel(aktiv);
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Shop Anfragen (Member Requests)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS shop_anfragen (
id SERIAL PRIMARY KEY,
anfrager_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'offen'
CHECK (status IN ('offen','genehmigt','abgelehnt','bestellt','erledigt')),
notizen TEXT,
admin_notizen TEXT,
bearbeitet_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_shop_anfragen_anfrager ON shop_anfragen(anfrager_id);
CREATE INDEX IF NOT EXISTS idx_shop_anfragen_status ON shop_anfragen(status);
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Shop Anfrage Positionen (Request Line Items)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS shop_anfrage_positionen (
id SERIAL PRIMARY KEY,
anfrage_id INT NOT NULL REFERENCES shop_anfragen(id) ON DELETE CASCADE,
artikel_id INT REFERENCES shop_artikel(id) ON DELETE SET NULL,
bezeichnung TEXT NOT NULL,
menge NUMERIC NOT NULL DEFAULT 1,
notizen TEXT,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_shop_anfrage_positionen_anfrage ON shop_anfrage_positionen(anfrage_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Shop Anfrage ↔ Bestellung Link
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS shop_anfrage_bestellung (
anfrage_id INT NOT NULL REFERENCES shop_anfragen(id) ON DELETE CASCADE,
bestellung_id INT NOT NULL REFERENCES bestellungen(id) ON DELETE CASCADE,
PRIMARY KEY (anfrage_id, bestellung_id)
);
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. Auto-update triggers
-- ═══════════════════════════════════════════════════════════════════════════
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_shop_artikel_aktualisiert') THEN
CREATE TRIGGER trg_shop_artikel_aktualisiert BEFORE UPDATE ON shop_artikel
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
END IF;
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_shop_anfragen_aktualisiert') THEN
CREATE TRIGGER trg_shop_anfragen_aktualisiert BEFORE UPDATE ON shop_anfragen
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
END IF;
END $$;

View File

@@ -0,0 +1,158 @@
-- Migration 040: Permission updates
-- 1. Add bestellungen + shop feature groups and their permissions
-- 2. Simplify calendar permissions from 13 → 4
-- 3. Migrate existing group_permissions to new calendar scheme
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. New feature groups
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO feature_groups (id, label, sort_order) VALUES
('bestellungen', 'Bestellungen', 11),
('shop', 'Shop', 12)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Bestellungen permissions
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('bestellungen:view', 'bestellungen', 'Ansehen', 'Bestellungen einsehen', 1),
('bestellungen:create', 'bestellungen', 'Erstellen/Bearbeiten', 'Bestellungen erstellen und bearbeiten', 2),
('bestellungen:delete', 'bestellungen', 'Löschen', 'Bestellungen löschen', 3),
('bestellungen:manage_vendors', 'bestellungen', 'Lieferanten verwalten','Lieferanten-Datenbank verwalten', 4),
('bestellungen:export', 'bestellungen', 'PDF Export', 'Bestellungen als PDF exportieren', 5),
('bestellungen:manage_reminders', 'bestellungen', 'Erinnerungen', 'Erinnerungen für Bestellungen verwalten', 6),
('bestellungen:widget', 'bestellungen', 'Widget', 'Dashboard-Widget für Bestellungen', 7)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Shop permissions
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('shop:view', 'shop', 'Katalog ansehen', 'Shop-Katalog einsehen', 1),
('shop:create_request', 'shop', 'Anfrage stellen', 'Bestellanfragen an Admin stellen', 2),
('shop:manage_catalog', 'shop', 'Katalog verwalten', 'Artikel im Shop-Katalog verwalten', 3),
('shop:approve_requests', 'shop', 'Anfragen genehmigen', 'Bestellanfragen genehmigen oder ablehnen', 4),
('shop:link_orders', 'shop', 'Mit Bestellung verknüpfen', 'Anfragen mit Lieferantenbestellungen verknüpfen', 5),
('shop:widget', 'shop', 'Widget', 'Dashboard-Widget für Shop-Anfragen', 6)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Calendar permission simplification (13 → 4)
-- ═══════════════════════════════════════════════════════════════════════════
-- New scheme:
-- kalender:view — see events + widgets
-- kalender:create — create/edit/cancel events, mark attendance, manage categories, view reports
-- kalender:view_bookings — see bookings + booking widgets
-- kalender:manage_bookings — create/edit/cancel/delete bookings
-- 4a. Collect which groups had which old permissions, then map to new ones.
-- We use a temp table so we don't lose data during the transition.
CREATE TEMP TABLE _cal_migration AS
SELECT DISTINCT authentik_group,
CASE
-- Any group that had kalender:create OR kalender:cancel OR kalender:manage_categories
-- OR kalender:view_reports OR kalender:mark_attendance → gets kalender:create
WHEN permission_id IN ('kalender:create','kalender:cancel','kalender:manage_categories',
'kalender:view_reports','kalender:mark_attendance')
THEN 'kalender:create'
-- Widget permissions → kalender:view (they already have it if they had widget perms)
WHEN permission_id IN ('kalender:widget_events','kalender:widget_quick_add')
THEN 'kalender:view'
-- Booking-related view widgets → kalender:view_bookings
WHEN permission_id IN ('kalender:widget_bookings')
THEN 'kalender:view_bookings'
-- All booking write ops → kalender:manage_bookings
WHEN permission_id IN ('kalender:create_bookings','kalender:edit_bookings',
'kalender:cancel_own_bookings','kalender:delete_bookings')
THEN 'kalender:manage_bookings'
ELSE permission_id
END AS new_perm
FROM group_permissions
WHERE permission_id LIKE 'kalender:%';
-- 4b. Delete old calendar permissions from group_permissions
DELETE FROM group_permissions WHERE permission_id LIKE 'kalender:%';
-- 4c. Delete old calendar permission definitions
DELETE FROM permissions WHERE id LIKE 'kalender:%';
-- 4d. Insert new calendar permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('kalender:view', 'kalender', 'Termine ansehen', 'Kalender-Termine und Widgets einsehen', 1),
('kalender:create', 'kalender', 'Termine verwalten', 'Termine erstellen/bearbeiten/absagen, Kategorien, Berichte', 2),
('kalender:view_bookings', 'kalender', 'Buchungen ansehen', 'Fahrzeugbuchungen und Buchungs-Widget einsehen', 3),
('kalender:manage_bookings', 'kalender', 'Buchungen verwalten', 'Buchungen erstellen/bearbeiten/stornieren/löschen', 4)
ON CONFLICT (id) DO NOTHING;
-- 4e. Re-insert migrated grants (only valid new permissions)
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT DISTINCT authentik_group, new_perm
FROM _cal_migration
WHERE new_perm IN ('kalender:view','kalender:create','kalender:view_bookings','kalender:manage_bookings')
ON CONFLICT DO NOTHING;
-- Also ensure everyone who had any calendar perm gets kalender:view
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT DISTINCT authentik_group, 'kalender:view'
FROM _cal_migration
ON CONFLICT DO NOTHING;
-- And everyone who had any booking perm gets kalender:view_bookings
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT DISTINCT authentik_group, 'kalender:view_bookings'
FROM _cal_migration
WHERE new_perm = 'kalender:manage_bookings'
ON CONFLICT DO NOTHING;
DROP TABLE _cal_migration;
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. Seed bestellungen + shop permissions for existing groups
-- ═══════════════════════════════════════════════════════════════════════════
-- kommando gets full access, other groups get view + shop request
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
-- Kommando: full bestellungen + shop
('dashboard_kommando', 'bestellungen:view'),
('dashboard_kommando', 'bestellungen:create'),
('dashboard_kommando', 'bestellungen:delete'),
('dashboard_kommando', 'bestellungen:manage_vendors'),
('dashboard_kommando', 'bestellungen:export'),
('dashboard_kommando', 'bestellungen:manage_reminders'),
('dashboard_kommando', 'bestellungen:widget'),
('dashboard_kommando', 'shop:view'),
('dashboard_kommando', 'shop:create_request'),
('dashboard_kommando', 'shop:manage_catalog'),
('dashboard_kommando', 'shop:approve_requests'),
('dashboard_kommando', 'shop:link_orders'),
('dashboard_kommando', 'shop:widget'),
-- Fahrmeister: view orders + shop request
('dashboard_fahrmeister', 'bestellungen:view'),
('dashboard_fahrmeister', 'bestellungen:widget'),
('dashboard_fahrmeister', 'shop:view'),
('dashboard_fahrmeister', 'shop:create_request'),
-- Zeugmeister: view orders + shop request
('dashboard_zeugmeister', 'bestellungen:view'),
('dashboard_zeugmeister', 'bestellungen:widget'),
('dashboard_zeugmeister', 'shop:view'),
('dashboard_zeugmeister', 'shop:create_request'),
-- Chargen: view orders + shop request
('dashboard_chargen', 'bestellungen:view'),
('dashboard_chargen', 'bestellungen:widget'),
('dashboard_chargen', 'shop:view'),
('dashboard_chargen', 'shop:create_request'),
-- Moderator: view orders + shop request
('dashboard_moderator', 'bestellungen:view'),
('dashboard_moderator', 'shop:view'),
('dashboard_moderator', 'shop:create_request'),
-- Atemschutz: shop request only
('dashboard_atemschutz', 'shop:view'),
('dashboard_atemschutz', 'shop:create_request'),
-- Mitglied: shop request only
('dashboard_mitglied', 'shop:view'),
('dashboard_mitglied', 'shop:create_request')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,42 @@
-- Migration 041: Status history tables, booking ganztaegig flag
-- Adds vehicle/equipment status change tracking and whole-day booking support.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Vehicle Status Change History
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS fahrzeug_status_historie (
id SERIAL PRIMARY KEY,
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
alter_status TEXT NOT NULL,
neuer_status TEXT NOT NULL,
bemerkung TEXT,
geaendert_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_status_historie_fahrzeug ON fahrzeug_status_historie(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_fahrzeug_status_historie_zeit ON fahrzeug_status_historie(erstellt_am DESC);
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Equipment Status Change History
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS ausruestung_status_historie (
id SERIAL PRIMARY KEY,
ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE,
alter_status TEXT NOT NULL,
neuer_status TEXT NOT NULL,
bemerkung TEXT,
geaendert_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ausruestung_status_historie_ausr ON ausruestung_status_historie(ausruestung_id);
CREATE INDEX IF NOT EXISTS idx_ausruestung_status_historie_zeit ON ausruestung_status_historie(erstellt_am DESC);
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Whole-day booking flag
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE fahrzeug_buchungen ADD COLUMN IF NOT EXISTS ganztaegig BOOLEAN DEFAULT FALSE;

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

@@ -0,0 +1,4 @@
-- Add manage_categories permission for equipment
INSERT INTO permissions (id, feature_group_id, label, description, sort_order)
VALUES ('ausruestung:manage_categories', 'ausruestung', 'Kategorien verwalten', 'Ausrüstungskategorien erstellen, bearbeiten und löschen', 99)
ON CONFLICT (id) DO NOTHING;

View File

@@ -0,0 +1,223 @@
-- Migration 045: Add new permissions + seed dependency config
-- 1. fahrzeuge:edit
-- 2. atemschutz:edit
-- 3. Per-tool admin permissions
-- 4. bestellungen:manage_orders
-- ═══════════════════════════════════════════════════════════════════════════
-- 0. Ensure all feature groups exist (some may be missing from production)
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO feature_groups (id, label, sort_order) VALUES
('kalender', 'Kalender', 1),
('fahrzeuge', 'Fahrzeuge', 2),
('einsaetze', 'Einsätze', 3),
('ausruestung', 'Ausrüstung', 4),
('mitglieder', 'Mitglieder', 5),
('atemschutz', 'Atemschutz', 6),
('wissen', 'Wissen', 7),
('vikunja', 'Vikunja', 8),
('dashboard', 'Dashboard', 9),
('admin', 'Admin', 10)
ON CONFLICT (id) DO NOTHING;
-- Re-seed base permissions that may have been cascade-deleted if feature groups were missing
-- (wissen, vikunja, dashboard, admin base permissions)
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('wissen:view', 'wissen', 'Ansehen', 'Wissen-Seite anzeigen', 1),
('wissen:widget_recent', 'wissen', 'Widget: Letzte', 'Dashboard-Widget letzte Seiten', 2),
('wissen:widget_search', 'wissen', 'Widget: Suche', 'Dashboard-Widget für BookStack-Suche', 3),
('vikunja:create_tasks', 'vikunja', 'Aufgaben erstellen', 'Neue Vikunja-Aufgaben erstellen', 1),
('vikunja:widget_tasks', 'vikunja', 'Widget: Aufgaben', 'Dashboard-Widget für Vikunja-Aufgaben', 2),
('vikunja:widget_quick_add', 'vikunja', 'Widget: Schnell-Task', 'Dashboard-Widget zum schnellen Erstellen', 3),
('dashboard:widget_links', 'dashboard', 'Widget: Links', 'Dashboard-Widget für externe Links', 1),
('dashboard:widget_banner', 'dashboard', 'Widget: Banner', 'Dashboard-Widget für Banner', 2),
('admin:view', 'admin', 'Ansehen', 'Admin-Panel einsehen', 1),
('admin:write', 'admin', 'Bearbeiten', 'Admin-Einstellungen ändern', 2)
ON CONFLICT (id) DO NOTHING;
-- Re-seed wissen + vikunja + dashboard grants for all dashboard groups
-- (these may have been cascade-deleted when feature groups were missing)
DO $$
DECLARE
grp TEXT;
BEGIN
FOR grp IN
SELECT DISTINCT authentik_group FROM group_permissions WHERE authentik_group LIKE 'dashboard_%'
LOOP
-- wissen permissions for everyone
INSERT INTO group_permissions (authentik_group, permission_id)
VALUES (grp, 'wissen:view'), (grp, 'wissen:widget_recent'), (grp, 'wissen:widget_search')
ON CONFLICT DO NOTHING;
-- dashboard widget permissions for everyone
INSERT INTO group_permissions (authentik_group, permission_id)
VALUES (grp, 'dashboard:widget_links'), (grp, 'dashboard:widget_banner')
ON CONFLICT DO NOTHING;
END LOOP;
END $$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. New permissions
-- ═══════════════════════════════════════════════════════════════════════════
-- fahrzeuge:edit (between create and change_status)
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('fahrzeuge:edit', 'fahrzeuge', 'Bearbeiten', 'Fahrzeugdaten bearbeiten', 2)
ON CONFLICT (id) DO NOTHING;
-- atemschutz:edit (between view and create)
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('atemschutz:edit', 'atemschutz', 'Bearbeiten', 'Atemschutz-Einträge bearbeiten', 2)
ON CONFLICT (id) DO NOTHING;
-- bestellungen:manage_orders (manage order status + received quantities)
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('bestellungen:manage_orders', 'bestellungen', 'Bestellungen verwalten', 'Bestellstatus ändern und Wareneingänge verbuchen', 3)
ON CONFLICT (id) DO NOTHING;
-- Per-tool admin permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('admin:view_services', 'admin', 'Services ansehen', 'Service-Monitoring einsehen', 3),
('admin:edit_services', 'admin', 'Services bearbeiten', 'Überwachte Services verwalten', 4),
('admin:view_system', 'admin', 'System ansehen', 'Systeminformationen einsehen', 5),
('admin:view_users', 'admin', 'Benutzer ansehen', 'Benutzerliste einsehen', 6),
('admin:edit_users', 'admin', 'Benutzer bearbeiten', 'Benutzereinstellungen verwalten', 7),
('admin:edit_broadcast', 'admin', 'Broadcast senden', 'Push-Benachrichtigungen senden', 8),
('admin:view_banner', 'admin', 'Banner ansehen', 'Ankündigungs-Banner einsehen', 9),
('admin:edit_banner', 'admin', 'Banner bearbeiten', 'Ankündigungs-Banner verwalten', 10),
('admin:view_maintenance', 'admin', 'Wartung ansehen', 'Wartungsmodus-Status einsehen', 11),
('admin:edit_maintenance', 'admin', 'Wartung bearbeiten', 'Wartungsmodus umschalten', 12),
('admin:view_fdisk', 'admin', 'FDISK ansehen', 'FDISK-Synchronisation einsehen', 13),
('admin:edit_fdisk', 'admin', 'FDISK bearbeiten', 'FDISK-Synchronisation starten/verwalten', 14),
('admin:view_permissions', 'admin', 'Berechtigungen ansehen', 'Berechtigungsmatrix einsehen', 15),
('admin:edit_permissions', 'admin', 'Berechtigungen bearbeiten','Berechtigungen und Abhängigkeiten ändern',16),
('admin:view_order_settings', 'admin', 'Bestell-Admin ansehen', 'Bestellungs-Admin-Einstellungen einsehen',17),
('admin:edit_order_settings', 'admin', 'Bestell-Admin bearbeiten','Bestellungs-Admin-Einstellungen ändern', 18),
('admin:view_data', 'admin', 'Datenverwaltung ansehen', 'Datenverwaltung einsehen', 19),
('admin:edit_data', 'admin', 'Datenverwaltung bearbeiten','Daten importieren/exportieren/bereinigen',20),
('admin:view_debug', 'admin', 'Debug ansehen', 'Debug-Informationen einsehen', 21)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Seed grants for existing groups
-- ═══════════════════════════════════════════════════════════════════════════
-- Give fahrzeuge:edit to groups that already have fahrzeuge:create
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT authentik_group, 'fahrzeuge:edit'
FROM group_permissions WHERE permission_id = 'fahrzeuge:create'
ON CONFLICT DO NOTHING;
-- Give atemschutz:edit to groups that already have atemschutz:create
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT authentik_group, 'atemschutz:edit'
FROM group_permissions WHERE permission_id = 'atemschutz:create'
ON CONFLICT DO NOTHING;
-- Give bestellungen:manage_orders to groups that already have bestellungen:create
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT authentik_group, 'bestellungen:manage_orders'
FROM group_permissions WHERE permission_id = 'bestellungen:create'
ON CONFLICT DO NOTHING;
-- Give all admin sub-permissions to groups that have admin:write
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT gp.authentik_group, p.id
FROM group_permissions gp
CROSS JOIN permissions p
WHERE gp.permission_id = 'admin:write'
AND p.feature_group_id = 'admin'
AND p.id NOT IN ('admin:view', 'admin:write')
ON CONFLICT DO NOTHING;
-- Give all admin view sub-permissions to groups that have admin:view but not admin:write
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT gp.authentik_group, p.id
FROM group_permissions gp
CROSS JOIN permissions p
WHERE gp.permission_id = 'admin:view'
AND p.feature_group_id = 'admin'
AND p.id LIKE 'admin:view_%'
ON CONFLICT DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Seed default permission dependency config
-- ═══════════════════════════════════════════════════════════════════════════
-- Only insert if no config exists yet (don't overwrite manual edits)
INSERT INTO app_settings (key, value)
SELECT 'permission_deps', '{
"kalender:create": ["kalender:view"],
"kalender:view_bookings": ["kalender:view"],
"kalender:manage_bookings": ["kalender:view_bookings", "kalender:view"],
"fahrzeuge:edit": ["fahrzeuge:view"],
"fahrzeuge:create": ["fahrzeuge:view"],
"fahrzeuge:change_status": ["fahrzeuge:view"],
"fahrzeuge:manage_maintenance": ["fahrzeuge:view"],
"fahrzeuge:delete": ["fahrzeuge:create", "fahrzeuge:view"],
"fahrzeuge:widget": ["fahrzeuge:view"],
"einsaetze:view_reports": ["einsaetze:view"],
"einsaetze:create": ["einsaetze:view"],
"einsaetze:delete": ["einsaetze:create", "einsaetze:view"],
"einsaetze:manage_personnel": ["einsaetze:view"],
"ausruestung:create": ["ausruestung:view"],
"ausruestung:manage_maintenance": ["ausruestung:view"],
"ausruestung:delete": ["ausruestung:create", "ausruestung:view"],
"ausruestung:manage_categories": ["ausruestung:view"],
"ausruestung:widget": ["ausruestung:view"],
"mitglieder:view_all": ["mitglieder:view_own"],
"mitglieder:edit": ["mitglieder:view_all", "mitglieder:view_own"],
"mitglieder:create_profile": ["mitglieder:edit", "mitglieder:view_all", "mitglieder:view_own"],
"atemschutz:edit": ["atemschutz:view"],
"atemschutz:create": ["atemschutz:view"],
"atemschutz:delete": ["atemschutz:create", "atemschutz:view"],
"atemschutz:widget": ["atemschutz:view"],
"bestellungen:create": ["bestellungen:view"],
"bestellungen:manage_orders": ["bestellungen:view"],
"bestellungen:delete": ["bestellungen:view"],
"bestellungen:manage_vendors": ["bestellungen:view"],
"bestellungen:export": ["bestellungen:view"],
"bestellungen:manage_reminders": ["bestellungen:view"],
"bestellungen:widget": ["bestellungen:view"],
"shop:create_request": ["shop:view"],
"shop:manage_catalog": ["shop:view"],
"shop:approve_requests": ["shop:view"],
"shop:link_orders": ["shop:approve_requests", "shop:view"],
"shop:view_overview": ["shop:view"],
"shop:order_for_user": ["shop:create_request", "shop:view"],
"shop:widget": ["shop:view"],
"issues:view_own": ["issues:create"],
"issues:view_all": ["issues:view_own"],
"issues:manage": ["issues:view_all", "issues:view_own"],
"admin:edit_services": ["admin:view_services", "admin:view"],
"admin:view_services": ["admin:view"],
"admin:view_system": ["admin:view"],
"admin:edit_users": ["admin:view_users", "admin:view"],
"admin:view_users": ["admin:view"],
"admin:edit_broadcast": ["admin:view"],
"admin:edit_banner": ["admin:view_banner", "admin:view"],
"admin:view_banner": ["admin:view"],
"admin:edit_maintenance": ["admin:view_maintenance", "admin:view"],
"admin:view_maintenance": ["admin:view"],
"admin:edit_fdisk": ["admin:view_fdisk", "admin:view"],
"admin:view_fdisk": ["admin:view"],
"admin:edit_permissions": ["admin:view_permissions", "admin:view"],
"admin:view_permissions": ["admin:view"],
"admin:edit_order_settings": ["admin:view_order_settings", "admin:view"],
"admin:view_order_settings": ["admin:view"],
"admin:edit_data": ["admin:view_data", "admin:view"],
"admin:view_data": ["admin:view"],
"admin:view_debug": ["admin:view"]
}'::jsonb
WHERE NOT EXISTS (SELECT 1 FROM app_settings WHERE key = 'permission_deps');

View File

@@ -0,0 +1,72 @@
-- Migration 046: Rename Shop → Ausrüstungsanfrage
-- Renames all shop_* tables and updates permission references.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Rename tables
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE IF EXISTS shop_artikel RENAME TO ausruestung_artikel;
ALTER TABLE IF EXISTS shop_anfragen RENAME TO ausruestung_anfragen;
ALTER TABLE IF EXISTS shop_anfrage_positionen RENAME TO ausruestung_anfrage_positionen;
ALTER TABLE IF EXISTS shop_anfrage_bestellung RENAME TO ausruestung_anfrage_bestellung;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Rename indexes
-- ═══════════════════════════════════════════════════════════════════════════
ALTER INDEX IF EXISTS idx_shop_artikel_kategorie RENAME TO idx_ausruestung_artikel_kategorie;
ALTER INDEX IF EXISTS idx_shop_artikel_aktiv RENAME TO idx_ausruestung_artikel_aktiv;
ALTER INDEX IF EXISTS idx_shop_anfragen_anfrager RENAME TO idx_ausruestung_anfragen_anfrager;
ALTER INDEX IF EXISTS idx_shop_anfragen_status RENAME TO idx_ausruestung_anfragen_status;
ALTER INDEX IF EXISTS idx_shop_anfrage_positionen_anfrage RENAME TO idx_ausruestung_anfrage_positionen_anfrage;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Rename triggers (skip if not exist — wrapped in DO block)
-- ═══════════════════════════════════════════════════════════════════════════
DO $$ BEGIN
ALTER TRIGGER trg_shop_artikel_aktualisiert ON ausruestung_artikel RENAME TO trg_ausruestung_artikel_aktualisiert;
EXCEPTION WHEN undefined_object THEN NULL;
END $$;
DO $$ BEGIN
ALTER TRIGGER trg_shop_anfragen_aktualisiert ON ausruestung_anfragen RENAME TO trg_ausruestung_anfragen_aktualisiert;
EXCEPTION WHEN undefined_object THEN NULL;
END $$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Update feature_groups + permissions (drop FK temporarily to avoid constraint violation)
-- ═══════════════════════════════════════════════════════════════════════════
-- Drop FK constraints that reference feature_groups.id
ALTER TABLE permissions DROP CONSTRAINT IF EXISTS permissions_feature_group_id_fkey;
ALTER TABLE group_permissions DROP CONSTRAINT IF EXISTS group_permissions_permission_id_fkey;
-- Now safe to update feature_groups
UPDATE feature_groups SET id = 'ausruestungsanfrage' WHERE id = 'shop';
-- Update permissions
UPDATE permissions SET
id = REPLACE(id, 'shop:', 'ausruestungsanfrage:'),
feature_group_id = 'ausruestungsanfrage'
WHERE feature_group_id = 'shop';
-- Update group_permissions
UPDATE group_permissions SET
permission_id = REPLACE(permission_id, 'shop:', 'ausruestungsanfrage:')
WHERE permission_id LIKE 'shop:%';
-- Re-add FK constraints
ALTER TABLE permissions ADD CONSTRAINT permissions_feature_group_id_fkey
FOREIGN KEY (feature_group_id) REFERENCES feature_groups(id) ON UPDATE CASCADE ON DELETE CASCADE;
ALTER TABLE group_permissions ADD CONSTRAINT group_permissions_permission_id_fkey
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON UPDATE CASCADE ON DELETE CASCADE;
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. Update notification quell_typ references
-- ═══════════════════════════════════════════════════════════════════════════
UPDATE notifications SET quell_typ = 'ausruestung_anfrage' WHERE quell_typ = 'shop_anfrage';
UPDATE notifications SET typ = 'ausruestung_anfrage' WHERE typ = 'shop_anfrage';
UPDATE notifications SET link = '/ausruestungsanfrage' WHERE link = '/shop';

View File

@@ -0,0 +1,26 @@
-- Migration 047: Update Ausrüstungsanfrage (Internal Orders) system
-- - Add bezeichnung column to ausruestung_anfragen
-- - Rename permissions: approve_requests → approve, view_overview → view_all
-- - Add new permission: ausruestungsanfrage:edit
-- 1. Add bezeichnung column to anfragen table
ALTER TABLE ausruestung_anfragen ADD COLUMN IF NOT EXISTS bezeichnung TEXT;
-- 2. Rename permissions
UPDATE permissions SET id = 'ausruestungsanfrage:approve'
WHERE id = 'ausruestungsanfrage:approve_requests';
UPDATE permissions SET id = 'ausruestungsanfrage:view_all'
WHERE id = 'ausruestungsanfrage:view_overview';
-- 3. Add new edit permission
INSERT INTO permissions (id, feature_group_id, label, description, sort_order)
VALUES ('ausruestungsanfrage:edit', 'ausruestungsanfrage', 'Alle Anfragen bearbeiten', 'Alle Anfragen bearbeiten (unabhängig von Status/Besitzer)', 10)
ON CONFLICT (id) DO NOTHING;
-- 4. Grant new edit permission to groups that had approve_requests (now approve)
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT gp.authentik_group, 'ausruestungsanfrage:edit'
FROM group_permissions gp
WHERE gp.permission_id = 'ausruestungsanfrage:approve'
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,68 @@
-- Migration 048: Catalog categories table + item characteristics
-- 1. Categories table (with parent_id for subcategories)
CREATE TABLE IF NOT EXISTS ausruestung_kategorien_katalog (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
parent_id INT REFERENCES ausruestung_kategorien_katalog(id) ON DELETE CASCADE,
erstellt_am TIMESTAMPTZ DEFAULT NOW()
);
-- Unique: top-level categories by name (where parent_id IS NULL)
CREATE UNIQUE INDEX IF NOT EXISTS ausruestung_kat_top_unique
ON ausruestung_kategorien_katalog (name) WHERE parent_id IS NULL;
-- Unique: subcategories by (parent_id, name)
CREATE UNIQUE INDEX IF NOT EXISTS ausruestung_kat_child_unique
ON ausruestung_kategorien_katalog (parent_id, name) WHERE parent_id IS NOT NULL;
-- Migrate existing categories from free-text
INSERT INTO ausruestung_kategorien_katalog (name)
SELECT DISTINCT kategorie FROM ausruestung_artikel WHERE kategorie IS NOT NULL AND kategorie != ''
ON CONFLICT DO NOTHING;
-- Add kategorie_id FK to artikel
ALTER TABLE ausruestung_artikel ADD COLUMN IF NOT EXISTS kategorie_id INT REFERENCES ausruestung_kategorien_katalog(id) ON DELETE SET NULL;
-- Populate kategorie_id from existing text values
UPDATE ausruestung_artikel a
SET kategorie_id = k.id
FROM ausruestung_kategorien_katalog k
WHERE k.name = a.kategorie AND a.kategorie_id IS NULL AND k.parent_id IS NULL;
-- 2. Characteristics definitions per catalog item
CREATE TABLE IF NOT EXISTS ausruestung_artikel_eigenschaften (
id SERIAL PRIMARY KEY,
artikel_id INT NOT NULL REFERENCES ausruestung_artikel(id) ON DELETE CASCADE,
name TEXT NOT NULL,
typ TEXT NOT NULL DEFAULT 'options',
optionen TEXT[],
pflicht BOOLEAN DEFAULT FALSE,
sort_order INT DEFAULT 0,
UNIQUE(artikel_id, name)
);
-- 3. Characteristic values filled per request position
CREATE TABLE IF NOT EXISTS ausruestung_position_eigenschaften (
id SERIAL PRIMARY KEY,
position_id INT NOT NULL REFERENCES ausruestung_anfrage_positionen(id) ON DELETE CASCADE,
eigenschaft_id INT NOT NULL REFERENCES ausruestung_artikel_eigenschaften(id) ON DELETE CASCADE,
wert TEXT NOT NULL,
UNIQUE(position_id, eigenschaft_id)
);
-- 4. Add manage_categories permission
INSERT INTO permissions (id, feature_group_id, label, description, sort_order)
VALUES ('ausruestungsanfrage:manage_categories', 'ausruestungsanfrage', 'Kategorien verwalten', 'Katalog-Kategorien erstellen und bearbeiten', 5)
ON CONFLICT (id) DO NOTHING;
-- Grant manage_categories to groups that have manage_catalog
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT gp.authentik_group, 'ausruestungsanfrage:manage_categories'
FROM group_permissions gp
WHERE gp.permission_id = 'ausruestungsanfrage:manage_catalog'
ON CONFLICT DO NOTHING;
-- 5. Remove view_all permission (approve covers the "Alle Anfragen" tab now)
DELETE FROM group_permissions WHERE permission_id = 'ausruestungsanfrage:view_all';
DELETE FROM permissions WHERE id = 'ausruestungsanfrage:view_all';

View File

@@ -0,0 +1,13 @@
-- Migration 049: Add parent_id to categories for subcategory support
-- (048 already created the table without parent_id)
ALTER TABLE ausruestung_kategorien_katalog
ADD COLUMN IF NOT EXISTS parent_id INT REFERENCES ausruestung_kategorien_katalog(id) ON DELETE CASCADE;
-- Unique: top-level categories by name
CREATE UNIQUE INDEX IF NOT EXISTS ausruestung_kat_top_unique
ON ausruestung_kategorien_katalog (name) WHERE parent_id IS NULL;
-- Unique: subcategories by (parent_id, name)
CREATE UNIQUE INDEX IF NOT EXISTS ausruestung_kat_child_unique
ON ausruestung_kategorien_katalog (parent_id, name) WHERE parent_id IS NOT NULL;

View File

@@ -0,0 +1,22 @@
-- Migration 050: Add missing columns to ausruestung_anfragen
-- These columns are referenced by the service code but were never created
ALTER TABLE ausruestung_anfragen ADD COLUMN IF NOT EXISTS bearbeitet_am TIMESTAMPTZ;
ALTER TABLE ausruestung_anfragen ADD COLUMN IF NOT EXISTS bestell_nummer INT;
ALTER TABLE ausruestung_anfragen ADD COLUMN IF NOT EXISTS bestell_jahr INT;
-- Backfill bestell_nummer for existing rows
DO $$
DECLARE
yr INT;
rec RECORD;
nr INT;
BEGIN
FOR yr IN SELECT DISTINCT EXTRACT(YEAR FROM erstellt_am)::int FROM ausruestung_anfragen LOOP
nr := 0;
FOR rec IN SELECT id FROM ausruestung_anfragen WHERE EXTRACT(YEAR FROM erstellt_am)::int = yr AND bestell_nummer IS NULL ORDER BY erstellt_am LOOP
nr := nr + 1;
UPDATE ausruestung_anfragen SET bestell_nummer = nr, bestell_jahr = yr WHERE id = rec.id;
END LOOP;
END LOOP;
END $$;

Some files were not shown because too many files have changed in this diff Show More