Compare commits

...

345 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
434 changed files with 64519 additions and 5753 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
# IMPORTANT: Must match your frontend URL exactly!
# Development: http://localhost:5173 (Vite dev server)
# Production: https://start.feuerwehr-rems.at
# Production: https://portal.feuerwehr-rems.at
# Multiple origins: Use comma-separated values (if supported by your setup)
CORS_ORIGIN=https://start.feuerwehr-rems.at
CORS_ORIGIN=https://portal.feuerwehr-rems.at
# ============================================================================
# FRONTEND CONFIGURATION
@@ -103,9 +103,9 @@ FRONTEND_PORT=80
# API URL for frontend
# The URL where the frontend will send API requests
# Development: http://localhost:3000
# Production: https://start.feuerwehr-rems.at (proxied via nginx /api/)
# Production: https://portal.feuerwehr-rems.at (proxied via nginx /api/)
# IMPORTANT: Must be accessible from the user's browser!
VITE_API_URL=https://start.feuerwehr-rems.at
VITE_API_URL=https://portal.feuerwehr-rems.at
# Authentik URL for frontend
# The base URL of your Authentik instance (without application path)
@@ -143,8 +143,8 @@ AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerweh
# The URL where Authentik will redirect after successful authentication
# Must match EXACTLY what you configured in Authentik
# Development: http://localhost:5173/auth/callback
# Production: https://start.feuerwehr-rems.at/auth/callback
AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback
# Production: https://portal.feuerwehr-rems.at/auth/callback
AUTHENTIK_REDIRECT_URI=https://portal.feuerwehr-rems.at/auth/callback
# OAuth Scopes (optional, has defaults)
# Default: openid profile email
@@ -189,6 +189,21 @@ VIKUNJA_URL=https://tasks.feuerwehr-rems.at
# 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)
# ============================================================================
@@ -268,14 +283,14 @@ VIKUNJA_API_TOKEN=your_vikunja_api_token
# BACKEND_PORT=3000
# NODE_ENV=production
# JWT_SECRET=<generated-with-openssl-rand-base64-32>
# CORS_ORIGIN=https://start.feuerwehr-rems.at
# CORS_ORIGIN=https://portal.feuerwehr-rems.at
# FRONTEND_PORT=80
# VITE_API_URL=https://start.feuerwehr-rems.at
# VITE_API_URL=https://portal.feuerwehr-rems.at
# AUTHENTIK_CLIENT_ID=<from-authentik>
# AUTHENTIK_CLIENT_SECRET=<from-authentik>
# AUTHENTIK_URL=https://auth.firesuite.feuerwehr-rems.at
# AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/
# AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback
# AUTHENTIK_REDIRECT_URI=https://portal.feuerwehr-rems.at/auth/callback
# NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at
# LOG_LEVEL=info
#

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

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

View File

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

View File

@@ -15,6 +15,7 @@
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"jose": "^6.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.18.0",
"winston": "^3.19.0",
@@ -1069,6 +1070,14 @@
"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": {
"version": "9.0.3",
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",

View File

@@ -21,14 +21,16 @@
"express": "^5.2.1",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"jose": "^6.0.11",
"jose": "^6.2.1",
"jsonwebtoken": "^9.0.3",
"pg": "^8.18.0",
"multer": "^1.4.5-lts.2",
"winston": "^3.19.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/multer": "^1.4.12",
"@types/express": "^5.0.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.3.0",

View File

@@ -2,9 +2,12 @@ import express, { Application, Request, Response } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import path from 'path';
import environment from './config/environment';
import logger from './utils/logger';
import { errorHandler, notFoundHandler } from './middleware/error.middleware';
import { requestTimeout } from './middleware/request-timeout.middleware';
import { authenticate } from './middleware/auth.middleware';
const app: Application = express();
@@ -33,20 +36,29 @@ const authLimiter = rateLimit({
});
app.use('/api/auth', authLimiter);
// General rate limiter — skip auth routes (they have their own limiter above)
// 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,
max: environment.rateLimit.max,
message: 'Too many requests from this IP, please try again later.',
standardHeaders: true,
legacyHeaders: false,
skip: (req) => req.path.startsWith('/auth'),
skip: (req) => {
if (req.path.startsWith('/auth')) return true;
const auth = req.headers.authorization;
return typeof auth === 'string' && auth.startsWith('Bearer ');
},
}));
// Body parsing middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Request timeout middleware
app.use(requestTimeout);
// Request logging middleware
app.use((req: Request, _res: Response, next) => {
logger.info('Incoming request', {
@@ -83,6 +95,22 @@ 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/user', userRoutes);
@@ -99,6 +127,27 @@ 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
app.use(notFoundHandler);

View File

@@ -14,7 +14,7 @@ const poolConfig: PoolConfig = {
database: environment.database.name,
user: environment.database.user,
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
connectionTimeoutMillis: 5000, // Return an error if connection takes longer than 5 seconds
};
@@ -26,6 +26,17 @@ pool.on('error', (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
export const testConnection = async (): Promise<boolean> => {
try {

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

@@ -1,5 +1,6 @@
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';
@@ -120,6 +121,29 @@ class AtemschutzController {
}
}
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);
@@ -136,6 +160,49 @@ class AtemschutzController {
}
}
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>;

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

@@ -3,10 +3,12 @@ import { z } from 'zod';
import authentikService from '../services/authentik.service';
import tokenService from '../services/token.service';
import userService from '../services/user.service';
import memberService from '../services/member.service';
import logger from '../utils/logger';
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.
@@ -85,18 +87,35 @@ class AuthController {
// Step 2: Get user info from Authentik
const userInfo = await authentikService.getUserInfo(tokens.access_token);
const groups = userInfo.groups ?? [];
const dashboardGroups = groups.filter((g: string) => g.startsWith('dashboard_'));
// Step 3: Verify ID token if present
if (tokens.id_token) {
try {
await authentikService.verifyIdToken(tokens.id_token);
} catch (error) {
logger.warn('ID token verification failed — continuing with userinfo', { error });
logger.error('ID token verification failed — continuing with userinfo (security event)', { error });
}
}
// Step 4: Find or create user in database
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) {
// User doesn't exist, create new user
@@ -117,7 +136,9 @@ class AuthController {
profile_picture_url: userInfo.picture,
});
await userService.updateGroups(user.id, groups);
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({
@@ -133,23 +154,50 @@ class AuthController {
metadata: { new_account: true },
});
} 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', {
userId: user.id,
email: user.email,
});
await userService.updateLastLogin(user.id);
await userService.updateGroups(user.id, groups);
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
// 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,
name: userInfo.name,
given_name: updatedGivenName,
family_name: updatedFamilyName,
preferred_username: userInfo.preferred_username,
profile_picture_url: userInfo.picture || undefined,
});
// Audit: returning user login
@@ -170,31 +218,6 @@ class AuthController {
// Extract normalised names once for use in the response
const { given_name: resolvedGivenName, family_name: resolvedFamilyName } = extractNames(userInfo);
// Check if user is active
if (!user.is_active) {
logger.warn('Inactive user attempted login', { userId: user.id });
// Audit the denied login attempt
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;
}
// Step 5: Generate internal JWT token
const role = await getUserRole(user.id);
const accessToken = tokenService.generateToken({
@@ -223,6 +246,7 @@ class AuthController {
data: {
accessToken,
refreshToken,
isNewUser,
user: {
id: user.id,
email: user.email,
@@ -372,10 +396,17 @@ class AuthController {
// 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({
userId: user.id,
email: user.email,
authentikSub: user.authentik_sub,
groups,
role,
});

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

@@ -1,6 +1,9 @@
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,
@@ -25,8 +28,12 @@ function handleZodError(res: Response, err: ZodError): void {
}
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 });
res.status(409).json({ success: false, message: err.message, reason: 'booking_conflict' });
return true;
}
return false;
@@ -37,6 +44,22 @@ function handleConflictError(res: Response, err: Error): boolean {
// ---------------------------------------------------------------------------
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.
@@ -48,12 +71,13 @@ class BookingController {
res.status(400).json({ success: false, message: 'from und to sind erforderlich' });
return;
}
const bookings = await bookingService.getBookingsByRange(
new Date(from as string),
new Date(to as string),
fahrzeugId as string | undefined
);
res.json({ success: true, data: bookings });
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' });
@@ -81,17 +105,34 @@ class BookingController {
*/
async checkAvailability(req: Request, res: Response): Promise<void> {
try {
const { fahrzeugId, from, to } = req.query;
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,
new Date(from as string),
new Date(to as string)
fahrzeugId as string, beginn, ende, excludeId as string | undefined
);
res.json({ success: true, data: { available: !hasConflict } });
} catch (error) {
@@ -134,7 +175,7 @@ class BookingController {
handleZodError(res, parsed.error);
return;
}
const booking = await bookingService.create(parsed.data, req.user!.id);
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;
@@ -181,8 +222,9 @@ class BookingController {
}
/**
* DELETE /api/bookings/:id
* 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 {
@@ -191,6 +233,24 @@ class BookingController {
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);

View File

@@ -40,6 +40,24 @@ class BookStackController {
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

@@ -81,6 +81,17 @@ const CreateWartungslogSchema = z.object({
(url) => /^https?:\/\//i.test(url),
'Nur http/https URLs erlaubt'
).optional(),
naechste_pruefung_am: isoDate.optional(),
});
const UpdateWartungslogSchema = z.object({
datum: isoDate.optional(),
art: z.enum(['Prüfung', 'Reparatur', 'Sonstiges']).optional(),
beschreibung: z.string().min(1).max(2000).optional(),
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']).nullable().optional(),
kosten: z.number().min(0).nullable().optional(),
pruefende_stelle: z.string().max(150).nullable().optional(),
naechste_pruefung_am: isoDate.nullable().optional(),
});
// ── Helper ────────────────────────────────────────────────────────────────────
@@ -252,21 +263,26 @@ class EquipmentController {
// Determine which category to check permissions against
const groups = getUserGroups(req);
if (!groups.includes('dashboard_admin')) {
// If kategorie_id is being changed, check against the new category; otherwise fetch existing
let kategorieId = parsed.data.kategorie_id;
if (!kategorieId) {
const existing = await equipmentService.getEquipmentById(id);
if (!existing) {
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
return;
}
kategorieId = existing.kategorie_id;
// 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;
}
const allowed = await checkCategoryPermission(kategorieId, groups);
if (!allowed) {
// 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) {
@@ -386,6 +402,155 @@ class EquipmentController {
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

@@ -118,6 +118,39 @@ class EventsController {
}
};
// -------------------------------------------------------------------------
// 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>
// -------------------------------------------------------------------------
@@ -161,7 +194,7 @@ class EventsController {
// -------------------------------------------------------------------------
getUpcoming = async (req: Request, res: Response): Promise<void> => {
try {
const limit = Math.min(Number(req.query.limit ?? 10), 50);
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 });
@@ -271,7 +304,12 @@ class EventsController {
deleteEvent = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params as Record<string, string>;
const deleted = await eventsService.deleteEvent(id);
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;

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

@@ -2,7 +2,8 @@ import { Request, Response } from 'express';
import incidentService from '../services/incident.service';
import logger from '../utils/logger';
import { AppError } from '../middleware/error.middleware';
import { AppRole, hasPermission } from '../middleware/rbac.middleware';
import { AppRole } from '../middleware/rbac.middleware';
import { permissionService } from '../services/permission.service';
import {
CreateEinsatzSchema,
UpdateEinsatzSchema,
@@ -75,16 +76,24 @@ class IncidentController {
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: only Kommandant+ can see full bericht_text
// Role-based redaction: check einsaetze:view_reports permission
const groups: string[] = req.user?.groups ?? [];
const canReadBerichtText =
req.userRole !== undefined &&
hasPermission(req.userRole, 'incidents:read_bericht_text');
groups.includes('dashboard_admin') ||
permissionService.hasPermission(groups, 'einsaetze:view_reports');
const responseData = {
...incident,

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

@@ -16,7 +16,9 @@ import {
type AppRole = 'admin' | 'kommandant' | 'mitglied';
function getRole(req: Request): AppRole {
return (req.user as any)?.role ?? 'mitglied';
// 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 {
@@ -43,9 +45,11 @@ class MemberController {
search,
page,
pageSize,
sortBy,
sortDir,
} = req.query as Record<string, string | undefined>;
// Arrays can be sent as ?status[]=aktiv&status[]=passiv or CSV
// 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;
@@ -58,8 +62,10 @@ class MemberController {
search,
status: normalizeArray(statusParam) as any,
dienstgrad: normalizeArray(dienstgradParam) as any,
page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? Math.min(parseInt(pageSize, 10), 100) : 25,
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({
@@ -209,14 +215,16 @@ class MemberController {
return;
}
const profile = await memberService.updateMemberProfile(
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: profile });
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 });
@@ -226,6 +234,61 @@ class MemberController {
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

@@ -1,4 +1,5 @@
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';
@@ -80,6 +81,348 @@ class NextcloudController {
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

@@ -67,6 +67,34 @@ class NotificationController {
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

@@ -1,6 +1,8 @@
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';
@@ -16,7 +18,6 @@ const FahrzeugStatusEnum = z.enum([
FahrzeugStatus.Einsatzbereit,
FahrzeugStatus.AusserDienstWartung,
FahrzeugStatus.AusserDienstSchaden,
FahrzeugStatus.InLehrgang,
]);
const isoDate = z.string().regex(
@@ -64,10 +65,29 @@ const UpdateFahrzeugSchema = z.object({
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(''),
});
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,
@@ -77,6 +97,18 @@ const CreateWartungslogSchema = z.object({
kraftstoff_liter: z.number().min(0).optional(),
kosten: z.number().min(0).optional(),
externe_werkstatt: z.string().max(150).optional(),
ergebnis: ErgebnisEnum.optional(),
naechste_faelligkeit: isoDate.optional(),
});
const UpdateWartungslogSchema = z.object({
datum: isoDate,
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
beschreibung: z.string().min(1).max(2000),
km_stand: z.number().int().min(0).optional(),
externe_werkstatt: z.string().max(150).optional(),
ergebnis: ErgebnisEnum.optional(),
naechste_faelligkeit: isoDate.optional(),
});
// ── Helper ────────────────────────────────────────────────────────────────────
@@ -124,6 +156,69 @@ class VehicleController {
}
}
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>;
@@ -211,10 +306,27 @@ class VehicleController {
return;
}
const io = req.app.get('io') ?? undefined;
await vehicleService.updateVehicleStatus(
id, parsed.data.status, parsed.data.bemerkung, getUserId(req), io
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,
);
res.status(200).json({ success: true, message: 'Status aktualisiert' });
// 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' });
@@ -286,6 +398,66 @@ class VehicleController {
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

@@ -7,12 +7,13 @@ 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 });
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' });
@@ -21,6 +22,7 @@ class VikunjaController {
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;
}
@@ -44,7 +46,7 @@ class VikunjaController {
}
}
res.status(200).json({ success: true, data: tasks, configured: true });
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' });
@@ -53,6 +55,7 @@ class VikunjaController {
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;
}

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 $$;

View File

@@ -0,0 +1,3 @@
-- Add per-item delivery tracking to request positions
ALTER TABLE ausruestung_anfrage_positionen
ADD COLUMN IF NOT EXISTS geliefert BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,2 @@
-- Add fuer_benutzer_name column for custom names (users not in system)
ALTER TABLE ausruestung_anfragen ADD COLUMN IF NOT EXISTS fuer_benutzer_name TEXT;

View File

@@ -0,0 +1,125 @@
-- Migration 053: Issues rework
-- 1. Fix update trigger bug (uses wrong column name)
-- 2. Dynamic issue types table (issue_typen)
-- 3. Migrate issues.typ → issues.typ_id
-- 4. Permission rework: replace issues:manage with granular permissions
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Fix update trigger
-- The old trigger calls update_aktualisiert_am() which sets NEW.aktualisiert_am,
-- but issues table uses updated_at → crashes every UPDATE.
-- ═══════════════════════════════════════════════════════════════════════════
DROP TRIGGER IF EXISTS trg_issues_updated ON issues;
CREATE OR REPLACE FUNCTION update_issues_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_issues_updated BEFORE UPDATE ON issues
FOR EACH ROW EXECUTE FUNCTION update_issues_updated_at();
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Dynamic types table
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS issue_typen (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
parent_id INT REFERENCES issue_typen(id) ON DELETE CASCADE,
icon VARCHAR(50) DEFAULT NULL,
farbe VARCHAR(20) DEFAULT NULL,
erlaubt_abgelehnt BOOLEAN NOT NULL DEFAULT true,
sort_order INT NOT NULL DEFAULT 0,
aktiv BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_issue_typen_parent ON issue_typen(parent_id);
-- Seed default types
INSERT INTO issue_typen (id, name, parent_id, icon, farbe, erlaubt_abgelehnt, sort_order) VALUES
(1, 'Bug', NULL, 'BugReport', 'error', false, 1),
(2, 'Funktionsanfrage', NULL, 'FiberNew', 'info', true, 2),
(3, 'Sonstiges', NULL, 'HelpOutline', 'action', true, 3)
ON CONFLICT (id) DO NOTHING;
-- Ensure sequence is past seeded IDs
SELECT setval('issue_typen_id_seq', GREATEST((SELECT MAX(id) FROM issue_typen), 3));
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Migrate issues.typ column → typ_id FK
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE issues ADD COLUMN IF NOT EXISTS typ_id INT REFERENCES issue_typen(id) ON DELETE SET NULL;
-- Migrate existing data
UPDATE issues SET typ_id = 1 WHERE typ = 'bug' AND typ_id IS NULL;
UPDATE issues SET typ_id = 2 WHERE typ = 'feature' AND typ_id IS NULL;
UPDATE issues SET typ_id = 3 WHERE typ = 'sonstiges' AND typ_id IS NULL;
-- Fallback: anything unmapped → Sonstiges
UPDATE issues SET typ_id = 3 WHERE typ_id IS NULL;
-- Drop old constraint and column
ALTER TABLE issues DROP CONSTRAINT IF EXISTS issues_typ_check;
ALTER TABLE issues DROP COLUMN IF EXISTS typ;
CREATE INDEX IF NOT EXISTS idx_issues_typ_id ON issues(typ_id);
CREATE INDEX IF NOT EXISTS idx_issues_zugewiesen_an ON issues(zugewiesen_an);
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Permission rework
-- Replace issues:manage with granular permissions
-- ═══════════════════════════════════════════════════════════════════════════
-- 4a. Find all groups that had issues:manage and give them the new permissions
-- We use a temp table to store the groups that had manage
CREATE TEMP TABLE IF NOT EXISTS _issues_manage_groups AS
SELECT authentik_group FROM group_permissions WHERE permission_id = 'issues:manage';
-- 4b. Insert new permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('issues:change_status', 'issues', 'Status ändern', 'Status ändern und kommentieren', 4),
('issues:edit', 'issues', 'Bearbeiten', 'Issues bearbeiten (Titel, Beschreibung, Typ, Priorität, Zuweisung)', 5),
('issues:edit_settings', 'issues', 'Einstellungen', 'Issue-Kategorien verwalten', 6),
('issues:delete', 'issues', 'Löschen', 'Issues löschen', 7)
ON CONFLICT (id) DO NOTHING;
-- 4c. Grant all new permissions to groups that had manage
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT g.authentik_group, p.id
FROM _issues_manage_groups g
CROSS JOIN (VALUES
('issues:view_all'),
('issues:change_status'),
('issues:edit'),
('issues:edit_settings'),
('issues:delete')
) AS p(id)
ON CONFLICT DO NOTHING;
-- 4d. Remove old manage permission
DELETE FROM group_permissions WHERE permission_id = 'issues:manage';
DELETE FROM permissions WHERE id = 'issues:manage';
DROP TABLE IF EXISTS _issues_manage_groups;
-- 4e. Update permission_deps in app_settings JSON
-- Remove old issues entries and add new ones
UPDATE app_settings
SET value = jsonb_strip_nulls(
(value - 'issues:view_own' - 'issues:view_all' - 'issues:manage')
|| '{
"issues:create": ["issues:view_own"],
"issues:view_all": ["issues:view_own"],
"issues:change_status": ["issues:view_all"],
"issues:edit": ["issues:view_all"],
"issues:delete": ["issues:view_all"],
"issues:edit_settings": ["issues:view_all"]
}'::jsonb
)
WHERE key = 'permission_deps';

View File

@@ -0,0 +1,7 @@
-- Update feature_groups label
UPDATE feature_groups SET label = 'Interne Bestellungen' WHERE id = 'ausruestungsanfrage';
-- Update permission descriptions to remove "Shop-" references
UPDATE permissions SET description = 'Katalog einsehen' WHERE id = 'ausruestungsanfrage:view';
UPDATE permissions SET description = 'Artikel im Katalog verwalten' WHERE id = 'ausruestungsanfrage:manage_catalog';
UPDATE permissions SET description = 'Dashboard-Widget für Anfragen' WHERE id = 'ausruestungsanfrage:widget';

View File

@@ -0,0 +1,185 @@
-- Migration 055: Comprehensive permissions re-seed
-- Ensures all feature_groups and permissions exist using their final names
-- (after all renames in 046/047/053). Safe to re-run — all ON CONFLICT DO NOTHING.
-- Also seeds wissen:view and bestellungen:view for all dashboard_ groups.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Ensure all feature groups exist
-- ═══════════════════════════════════════════════════════════════════════════
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),
('bestellungen', 'Bestellungen', 11),
('ausruestungsanfrage','Interne Bestellungen', 12),
('issues', 'Issues', 13)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Ensure all permissions exist (final names after all migrations)
-- ═══════════════════════════════════════════════════════════════════════════
-- Kalender (simplified 4-permission scheme from migration 040)
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;
-- Fahrzeuge
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:edit', 'fahrzeuge', 'Bearbeiten', 'Fahrzeugdaten bearbeiten', 3),
('fahrzeuge:change_status', 'fahrzeuge', 'Status ändern', 'Fahrzeugstatus ändern', 4),
('fahrzeuge:manage_maintenance', 'fahrzeuge', 'Wartung verwalten', 'Wartungseinträge erstellen/bearbeiten', 5),
('fahrzeuge:delete', 'fahrzeuge', 'Löschen', 'Fahrzeuge löschen', 6),
('fahrzeuge:widget', 'fahrzeuge', 'Widget', 'Dashboard-Widget für Fahrzeuge', 7)
ON CONFLICT (id) DO NOTHING;
-- Einsätze
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
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),
('ausruestung:manage_categories', 'ausruestung', 'Kategorien verwalten', 'Ausrüstungskategorien verwalten', 6)
ON CONFLICT (id) DO NOTHING;
-- Mitglieder
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
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:edit', 'atemschutz', 'Bearbeiten', 'Atemschutz-Einträge bearbeiten', 3),
('atemschutz:delete', 'atemschutz', 'Löschen', 'Atemschutz-Einträge löschen', 4),
('atemschutz:widget', 'atemschutz', 'Widget', 'Dashboard-Widget für Atemschutz', 5)
ON CONFLICT (id) DO NOTHING;
-- Wissen
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
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
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
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),
('admin:view_services', 'admin', 'Dienste ansehen', 'Dienste-Übersicht einsehen', 3),
('admin:edit_services', 'admin', 'Dienste bearbeiten', 'Dienste-Einstellungen ändern', 4),
('admin:view_system', 'admin', 'System ansehen', 'Systeminfo einsehen', 5),
('admin:view_users', 'admin', 'Benutzer ansehen', 'Benutzerliste einsehen', 6),
('admin:edit_users', 'admin', 'Benutzer bearbeiten', 'Benutzer verwalten', 7),
('admin:edit_broadcast', 'admin', 'Broadcast bearbeiten', 'Broadcast-Nachrichten senden', 8),
('admin:view_banner', 'admin', 'Banner ansehen', 'Banner einsehen', 9),
('admin:edit_banner', 'admin', 'Banner bearbeiten', 'Banner verwalten', 10),
('admin:view_maintenance', 'admin', 'Wartungsmodus ansehen', 'Wartungsmodus-Status einsehen', 11),
('admin:edit_maintenance', 'admin', 'Wartungsmodus bearbeiten', 'Wartungsmodus ein-/ausschalten', 12),
('admin:view_fdisk', 'admin', 'FDISK ansehen', 'FDISK-Einstellungen einsehen', 13),
('admin:edit_fdisk', 'admin', 'FDISK bearbeiten', 'FDISK-Einstellungen ändern', 14),
('admin:view_permissions', 'admin', 'Berechtigungen ansehen', 'Berechtigungsmatrix einsehen', 15),
('admin:edit_permissions', 'admin', 'Berechtigungen bearbeiten', 'Berechtigungen verwalten', 16),
('admin:view_order_settings', 'admin', 'Bestelleinstellungen ansehen','Bestelleinstellungen einsehen', 17),
('admin:edit_order_settings', 'admin', 'Bestelleinstellungen bearb.','Bestelleinstellungen ändern', 18),
('admin:view_data', 'admin', 'Daten ansehen', 'Datenverwaltung einsehen', 19),
('admin:edit_data', 'admin', 'Daten bearbeiten', 'Daten verwalten', 20),
('admin:view_debug', 'admin', 'Debug', 'Debug-Informationen einsehen', 21)
ON CONFLICT (id) DO NOTHING;
-- Bestellungen
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),
('bestellungen:manage_orders', 'bestellungen', 'Aufträge verwalten', 'Bestellaufträge verwalten', 8)
ON CONFLICT (id) DO NOTHING;
-- Ausrüstungsanfrage (formerly shop — final names after migration 046 + 047)
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('ausruestungsanfrage:view', 'ausruestungsanfrage', 'Katalog ansehen', 'Katalog einsehen', 1),
('ausruestungsanfrage:create_request', 'ausruestungsanfrage', 'Anfrage stellen', 'Bestellanfragen stellen', 2),
('ausruestungsanfrage:manage_catalog', 'ausruestungsanfrage', 'Katalog verwalten', 'Artikel im Katalog verwalten', 3),
('ausruestungsanfrage:approve', 'ausruestungsanfrage', 'Anfragen genehmigen', 'Bestellanfragen genehmigen oder ablehnen', 4),
('ausruestungsanfrage:link_orders', 'ausruestungsanfrage', 'Mit Bestellung verknüpfen', 'Anfragen mit Lieferantenbestellungen verknüpfen', 5),
('ausruestungsanfrage:widget', 'ausruestungsanfrage', 'Widget', 'Dashboard-Widget für Anfragen', 6),
('ausruestungsanfrage:view_all', 'ausruestungsanfrage', 'Alle Anfragen ansehen', 'Aggregierte Übersicht aller Anfragen', 7),
('ausruestungsanfrage:order_for_user', 'ausruestungsanfrage', 'Für Benutzer bestellen', 'Anfragen im Namen anderer erstellen', 8),
('ausruestungsanfrage:edit', 'ausruestungsanfrage', 'Alle Anfragen bearbeiten','Alle Anfragen bearbeiten (unabhängig von Status/Besitzer)', 9)
ON CONFLICT (id) DO NOTHING;
-- Issues (final permissions after migration 053 rework — issues:manage removed)
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:change_status', 'issues', 'Status ändern', 'Status ändern und kommentieren', 4),
('issues:edit', 'issues', 'Bearbeiten', 'Issues bearbeiten (Titel, Beschreibung, Typ, Priorität, Zuweisung)', 5),
('issues:edit_settings', 'issues', 'Einstellungen', 'Issue-Kategorien verwalten', 6),
('issues:delete', 'issues', 'Löschen', 'Issues löschen', 7)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Seed wissen and bestellungen:view for all dashboard_ groups
-- ═══════════════════════════════════════════════════════════════════════════
DO $$
DECLARE
grp TEXT;
BEGIN
FOR grp IN
SELECT DISTINCT authentik_group FROM group_permissions WHERE authentik_group LIKE 'dashboard_%'
LOOP
INSERT INTO group_permissions (authentik_group, permission_id)
VALUES (grp, 'wissen:view'), (grp, 'wissen:widget_recent'), (grp, 'wissen:widget_search')
ON CONFLICT DO NOTHING;
INSERT INTO group_permissions (authentik_group, permission_id)
VALUES (grp, 'bestellungen:view')
ON CONFLICT DO NOTHING;
END LOOP;
END $$;

View File

@@ -0,0 +1,22 @@
-- Migration 056: Add issues:widget permission
--
-- Adds the widget permission for the Issue Quick Add dashboard widget.
-- dashboard_admin gets it automatically via the permission cache (loadCache
-- populates it with every permission in the system).
-- 1. Insert the new widget permission
INSERT INTO permissions (id, feature_group_id, label, description, sort_order)
VALUES ('issues:widget', 'issues', 'Widget', 'Issue-Schnelleingabe auf dem Dashboard', 8)
ON CONFLICT (id) DO NOTHING;
-- 2. Grant issues:widget to every group that already has issues:create
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT authentik_group, 'issues:widget'
FROM group_permissions
WHERE permission_id = 'issues:create'
ON CONFLICT DO NOTHING;
-- 3. Add dependency: issues:widget requires issues:create
UPDATE app_settings
SET value = value || '{"issues:widget": ["issues:create"]}'::jsonb
WHERE key = 'permission_deps';

View File

@@ -0,0 +1,24 @@
-- Migration 057: issue_statusmeldungen table
CREATE TABLE IF NOT EXISTS issue_statusmeldungen (
id SERIAL PRIMARY KEY,
titel VARCHAR(255) NOT NULL,
inhalt TEXT,
schwere VARCHAR(20) NOT NULL DEFAULT 'info',
aktiv BOOLEAN NOT NULL DEFAULT true,
erstellt_von UUID REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE OR REPLACE FUNCTION update_issue_statusmeldungen_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_issue_statusmeldungen_updated_at
BEFORE UPDATE ON issue_statusmeldungen
FOR EACH ROW EXECUTE FUNCTION update_issue_statusmeldungen_updated_at();

View File

@@ -0,0 +1,37 @@
-- Migration 058: dynamic issue statuses and priorities
CREATE TABLE IF NOT EXISTS issue_statuses (
id SERIAL PRIMARY KEY,
schluessel VARCHAR(50) UNIQUE NOT NULL,
bezeichnung VARCHAR(100) NOT NULL,
farbe VARCHAR(50) NOT NULL DEFAULT 'default',
ist_abschluss BOOLEAN NOT NULL DEFAULT false,
ist_initial BOOLEAN NOT NULL DEFAULT false,
benoetigt_typ_freigabe BOOLEAN NOT NULL DEFAULT false,
sort_order INT NOT NULL DEFAULT 0,
aktiv BOOLEAN NOT NULL DEFAULT true
);
INSERT INTO issue_statuses (schluessel, bezeichnung, farbe, ist_abschluss, ist_initial, benoetigt_typ_freigabe, sort_order)
VALUES
('offen', 'Offen', 'info', false, true, false, 0),
('in_bearbeitung', 'In Bearbeitung', 'warning', false, false, false, 1),
('erledigt', 'Erledigt', 'success', true, false, false, 2),
('abgelehnt', 'Abgelehnt', 'error', true, false, true, 3)
ON CONFLICT (schluessel) DO NOTHING;
CREATE TABLE IF NOT EXISTS issue_prioritaeten (
id SERIAL PRIMARY KEY,
schluessel VARCHAR(50) UNIQUE NOT NULL,
bezeichnung VARCHAR(100) NOT NULL,
farbe VARCHAR(50) NOT NULL DEFAULT '#9e9e9e',
sort_order INT NOT NULL DEFAULT 0,
aktiv BOOLEAN NOT NULL DEFAULT true
);
INSERT INTO issue_prioritaeten (schluessel, bezeichnung, farbe, sort_order)
VALUES
('hoch', 'Hoch', '#d32f2f', 0),
('mittel', 'Mittel', '#ed6c02', 1),
('niedrig', 'Niedrig', '#9e9e9e', 2)
ON CONFLICT (schluessel) DO NOTHING;

View File

@@ -0,0 +1,11 @@
-- Issue change history
CREATE TABLE IF NOT EXISTS issue_historie (
id SERIAL PRIMARY KEY,
issue_id INT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
aktion VARCHAR(100) NOT NULL,
details JSONB,
erstellt_von UUID REFERENCES users(id),
erstellt_am TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_issue_historie_issue_id ON issue_historie(issue_id);

View File

@@ -0,0 +1,3 @@
-- Add preferred vendor to catalog items
ALTER TABLE ausruestung_artikel
ADD COLUMN IF NOT EXISTS bevorzugter_lieferant_id INT REFERENCES lieferanten(id) ON DELETE SET NULL;

View File

@@ -0,0 +1,3 @@
-- Add tax rate column to bestellungen
ALTER TABLE bestellungen
ADD COLUMN IF NOT EXISTS steuersatz NUMERIC(5,2) NOT NULL DEFAULT 20.00;

View File

@@ -0,0 +1,14 @@
-- Add laufende_nummer (sequential number per year) to bestellungen
ALTER TABLE bestellungen ADD COLUMN laufende_nummer INTEGER;
-- Backfill existing rows with sequential numbers per year
WITH numbered AS (
SELECT id, ROW_NUMBER() OVER (
PARTITION BY EXTRACT(YEAR FROM erstellt_am) ORDER BY erstellt_am, id
) AS nr
FROM bestellungen
)
UPDATE bestellungen b SET laufende_nummer = n.nr FROM numbered n WHERE b.id = n.id;
-- Make NOT NULL after backfill
ALTER TABLE bestellungen ALTER COLUMN laufende_nummer SET NOT NULL;

View File

@@ -0,0 +1,22 @@
-- =============================================================================
-- Migration 062: Buchungskategorien (Booking Categories)
-- Replaces the fahrzeug_buchung_art ENUM with a configurable categories table.
-- =============================================================================
CREATE TABLE IF NOT EXISTS buchungs_kategorien (
id SERIAL PRIMARY KEY,
bezeichnung VARCHAR(100) NOT NULL UNIQUE,
farbe VARCHAR(7) DEFAULT '#607D8B',
aktiv BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
erstellt_am TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
INSERT INTO buchungs_kategorien (bezeichnung, farbe, sort_order) VALUES
('intern', '#1976D2', 1),
('extern', '#388E3C', 2),
('wartung', '#F57C00', 3),
('reservierung', '#7B1FA2', 4),
('lehrgang', '#D32F2F', 5),
('sonstiges', '#607D8B', 6)
ON CONFLICT (bezeichnung) DO NOTHING;

View File

@@ -0,0 +1,63 @@
-- Migration 063: Split fahrzeugbuchungen into its own feature group
-- Moves kalender:view_bookings, kalender:manage_bookings, kalender:widget_bookings
-- into a new 'fahrzeugbuchungen' feature group with cleaner permission names.
-- 1. Add new feature group
INSERT INTO feature_groups (id, label, sort_order)
VALUES ('fahrzeugbuchungen', 'Fahrzeugbuchungen', 2)
ON CONFLICT (id) DO NOTHING;
-- Shift existing groups down to make room (kalender stays 1, fahrzeugbuchungen is 2)
UPDATE feature_groups SET sort_order = sort_order + 1
WHERE id NOT IN ('kalender', 'fahrzeugbuchungen') AND sort_order >= 2;
-- 2. Add new permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('fahrzeugbuchungen:view', 'fahrzeugbuchungen', 'Ansehen', 'Buchungsliste anzeigen', 1),
('fahrzeugbuchungen:create', 'fahrzeugbuchungen', 'Erstellen', 'Neue Fahrzeugbuchungen anlegen', 2),
('fahrzeugbuchungen:manage', 'fahrzeugbuchungen', 'Verwalten', 'Buchungen bearbeiten, stornieren, löschen; Kategorien verwalten', 3),
('fahrzeugbuchungen:widget', 'fahrzeugbuchungen', 'Widget', 'Dashboard-Widget für Fahrzeugbuchungen', 4)
ON CONFLICT (id) DO NOTHING;
-- 3. Migrate existing group_permissions
-- kalender:view_bookings → fahrzeugbuchungen:view
-- kalender:manage_bookings → fahrzeugbuchungen:manage + fahrzeugbuchungen:create + fahrzeugbuchungen:view
-- kalender:widget_bookings → fahrzeugbuchungen:widget
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT authentik_group, 'fahrzeugbuchungen:view'
FROM group_permissions
WHERE permission_id = 'kalender:view_bookings'
ON CONFLICT DO NOTHING;
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT authentik_group, 'fahrzeugbuchungen:create'
FROM group_permissions
WHERE permission_id = 'kalender:manage_bookings'
ON CONFLICT DO NOTHING;
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT authentik_group, 'fahrzeugbuchungen:manage'
FROM group_permissions
WHERE permission_id = 'kalender:manage_bookings'
ON CONFLICT DO NOTHING;
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT authentik_group, 'fahrzeugbuchungen:view'
FROM group_permissions
WHERE permission_id = 'kalender:manage_bookings'
ON CONFLICT DO NOTHING;
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT authentik_group, 'fahrzeugbuchungen:widget'
FROM group_permissions
WHERE permission_id = 'kalender:widget_bookings'
ON CONFLICT DO NOTHING;
-- 4. Remove old kalender booking permissions
DELETE FROM group_permissions WHERE permission_id IN (
'kalender:view_bookings', 'kalender:manage_bookings', 'kalender:widget_bookings'
);
DELETE FROM permissions WHERE id IN (
'kalender:view_bookings', 'kalender:manage_bookings', 'kalender:widget_bookings'
);

View File

@@ -0,0 +1,63 @@
-- Migration 064: Add spezifikationen to line items, approval columns, and new status workflow
-- 1. spezifikationen JSONB on bestellpositionen
-- 2. genehmigt_von / genehmigt_am on bestellungen
-- 3. Drop old status CHECK constraint
-- 4. Data migration: old statuses → new statuses
-- 5. Add new status CHECK constraint with approval workflow statuses
-- 6. bestellungen:approve permission + seed for dashboard_kommando
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Spezifikationen on line items
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE bestellpositionen
ADD COLUMN IF NOT EXISTS spezifikationen JSONB DEFAULT '[]';
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Approval columns on bestellungen
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE bestellungen
ADD COLUMN IF NOT EXISTS genehmigt_von UUID REFERENCES users(id),
ADD COLUMN IF NOT EXISTS genehmigt_am TIMESTAMPTZ;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Drop old status CHECK constraint
-- ═══════════════════════════════════════════════════════════════════════════
DO $$ BEGIN
ALTER TABLE bestellungen DROP CONSTRAINT IF EXISTS bestellungen_status_check;
EXCEPTION WHEN undefined_object THEN
NULL;
END $$;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Data migration: map old statuses to new ones (before new constraint)
-- ═══════════════════════════════════════════════════════════════════════════
UPDATE bestellungen SET status = 'bereit_zur_bestellung' WHERE status = 'erstellt';
UPDATE bestellungen SET status = 'lieferung_pruefen' WHERE status = 'vollstaendig';
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. Add new status CHECK constraint
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE bestellungen
ADD CONSTRAINT bestellungen_status_check
CHECK (status IN ('entwurf','wartet_auf_genehmigung','bereit_zur_bestellung','bestellt','teillieferung','lieferung_pruefen','abgeschlossen'));
-- ═══════════════════════════════════════════════════════════════════════════
-- 6. Add bestellungen:approve permission
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('bestellungen:approve', 'bestellungen', 'Genehmigen', 'Bestellungen genehmigen', 25)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 7. Seed grant for dashboard_kommando
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_kommando', 'bestellungen:approve')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,9 @@
-- Migration 065: Shared catalog + request type fields
-- Link bestellpositionen to ausruestung_artikel (shared catalog)
ALTER TABLE bestellpositionen
ADD COLUMN IF NOT EXISTS artikel_id INT REFERENCES ausruestung_artikel(id) ON DELETE SET NULL;
-- Add replacement/return fields to ausruestung_anfrage_positionen
ALTER TABLE ausruestung_anfrage_positionen
ADD COLUMN IF NOT EXISTS ist_ersatz BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS altes_geraet_zurueckgegeben BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,27 @@
-- Migration 066: Issue due dates + file attachments
-- Adds faellig_am column to issues and creates issue_dateien table.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Add due date column to issues
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE issues ADD COLUMN IF NOT EXISTS faellig_am TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS idx_issues_faellig_am ON issues(faellig_am) WHERE faellig_am IS NOT NULL;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Issue file attachments
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS issue_dateien (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
issue_id INT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
dateiname VARCHAR(500) NOT NULL,
dateipfad VARCHAR(1000) NOT NULL,
dateityp VARCHAR(100),
dateigroesse BIGINT,
hochgeladen_von UUID REFERENCES users(id) ON DELETE SET NULL,
hochgeladen_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_issue_dateien_issue_id ON issue_dateien(issue_id);

View File

@@ -0,0 +1,46 @@
-- Migration 067: Fahrzeug-Typen (Vehicle Types)
-- Dynamic vehicle type table with many-to-many junction to fahrzeuge.
-- Seeds initial types from existing fahrzeuge.typ_schluessel values.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Fahrzeug-Typen
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS fahrzeug_typen (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
beschreibung TEXT,
icon VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Junction table (many-to-many)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS fahrzeug_fahrzeug_typen (
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
fahrzeug_typ_id INT NOT NULL REFERENCES fahrzeug_typen(id) ON DELETE CASCADE,
PRIMARY KEY (fahrzeug_id, fahrzeug_typ_id)
);
CREATE INDEX IF NOT EXISTS idx_fft_fahrzeug_id ON fahrzeug_fahrzeug_typen(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_fft_typ_id ON fahrzeug_fahrzeug_typen(fahrzeug_typ_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Seed types from existing typ_schluessel values
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO fahrzeug_typen (name)
SELECT DISTINCT typ_schluessel
FROM fahrzeuge
WHERE typ_schluessel IS NOT NULL AND typ_schluessel != ''
ON CONFLICT (name) DO NOTHING;
-- Populate junction table from existing assignments
INSERT INTO fahrzeug_fahrzeug_typen (fahrzeug_id, fahrzeug_typ_id)
SELECT f.id, ft.id
FROM fahrzeuge f
JOIN fahrzeug_typen ft ON ft.name = f.typ_schluessel
WHERE f.typ_schluessel IS NOT NULL AND f.typ_schluessel != ''
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,113 @@
-- Migration 068: Checklisten (Checklist system)
-- Templates, vehicle-specific items, execution records, and due date tracking.
-- Depends on: 067_fahrzeug_typen.sql (fahrzeug_typen table)
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Checklist-Vorlagen (Templates)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS checklist_vorlagen (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
fahrzeug_typ_id INT REFERENCES fahrzeug_typen(id) ON DELETE SET NULL,
intervall VARCHAR(20) CHECK (intervall IN ('weekly','monthly','yearly','custom')),
intervall_tage INT,
beschreibung TEXT,
aktiv BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Vorlage Items (Template line items)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS checklist_vorlage_items (
id SERIAL PRIMARY KEY,
vorlage_id INT NOT NULL REFERENCES checklist_vorlagen(id) ON DELETE CASCADE,
bezeichnung VARCHAR(500) NOT NULL,
beschreibung TEXT,
pflicht BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_cvi_vorlage_id ON checklist_vorlage_items(vorlage_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Fahrzeug-spezifische Checklist Items
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS fahrzeug_checklist_items (
id SERIAL PRIMARY KEY,
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
bezeichnung VARCHAR(500) NOT NULL,
beschreibung TEXT,
pflicht BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
aktiv BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE INDEX IF NOT EXISTS idx_fci_fahrzeug_id ON fahrzeug_checklist_items(fahrzeug_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Checklist-Ausführungen (Execution records)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS checklist_ausfuehrungen (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
vorlage_id INT REFERENCES checklist_vorlagen(id) ON DELETE SET NULL,
status VARCHAR(30) NOT NULL DEFAULT 'offen'
CHECK (status IN ('offen','abgeschlossen','unvollstaendig','freigegeben')),
ausgefuehrt_von UUID REFERENCES users(id) ON DELETE SET NULL,
ausgefuehrt_am TIMESTAMPTZ,
freigegeben_von UUID REFERENCES users(id) ON DELETE SET NULL,
freigegeben_am TIMESTAMPTZ,
notizen TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_ca_fahrzeug_id ON checklist_ausfuehrungen(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_ca_vorlage_id ON checklist_ausfuehrungen(vorlage_id);
CREATE INDEX IF NOT EXISTS idx_ca_status ON checklist_ausfuehrungen(status);
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. Ausführung Items (Execution line items / answers)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS checklist_ausfuehrung_items (
id SERIAL PRIMARY KEY,
ausfuehrung_id UUID NOT NULL REFERENCES checklist_ausfuehrungen(id) ON DELETE CASCADE,
vorlage_item_id INT REFERENCES checklist_vorlage_items(id) ON DELETE SET NULL,
fahrzeug_item_id INT REFERENCES fahrzeug_checklist_items(id) ON DELETE SET NULL,
bezeichnung VARCHAR(500),
ergebnis VARCHAR(20) CHECK (ergebnis IN ('ok','nok','na')),
kommentar TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_cai_ausfuehrung_id ON checklist_ausfuehrung_items(ausfuehrung_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 6. Fälligkeiten (Due date tracking per vehicle+template)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS checklist_faelligkeit (
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
vorlage_id INT NOT NULL REFERENCES checklist_vorlagen(id) ON DELETE CASCADE,
naechste_faellig_am DATE NOT NULL,
letzte_ausfuehrung_id UUID REFERENCES checklist_ausfuehrungen(id) ON DELETE SET NULL,
PRIMARY KEY (fahrzeug_id, vorlage_id)
);
-- ═══════════════════════════════════════════════════════════════════════════
-- 7. Auto-update updated_at trigger for checklist_vorlagen
-- ═══════════════════════════════════════════════════════════════════════════
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_checklist_vorlagen_updated') THEN
CREATE TRIGGER trg_checklist_vorlagen_updated BEFORE UPDATE ON checklist_vorlagen
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
END IF;
END $$;

View File

@@ -0,0 +1,78 @@
-- Migration 069: Checklisten permissions
-- Adds checklisten feature group and permissions, seeds group_permissions.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Feature group
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO feature_groups (id, label, sort_order) VALUES
('checklisten', 'Checklisten', 15)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Permissions
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('checklisten:view', 'checklisten', 'Ansehen', 'Checklisten und Ausführungen einsehen', 1),
('checklisten:execute', 'checklisten', 'Ausfüllen', 'Checklisten ausfüllen und abschließen', 2),
('checklisten:approve', 'checklisten', 'Freigeben', 'Checklisten nach Prüfung freigeben', 3),
('checklisten:manage_templates', 'checklisten', 'Vorlagen verwalten', 'Vorlagen und Fahrzeugtypen erstellen und bearbeiten', 4),
('checklisten:widget', 'checklisten', 'Widget', 'Checklisten-Widget im Dashboard anzeigen', 5)
ON CONFLICT (id) DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Seed group permissions
-- ═══════════════════════════════════════════════════════════════════════════
-- dashboard_admin has hardwired full access (not seeded).
-- Kommando: all permissions
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_kommando', 'checklisten:view'),
('dashboard_kommando', 'checklisten:execute'),
('dashboard_kommando', 'checklisten:approve'),
('dashboard_kommando', 'checklisten:manage_templates'),
('dashboard_kommando', 'checklisten:widget')
ON CONFLICT DO NOTHING;
-- Fahrmeister: view, execute, approve, widget (vehicle specialist)
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_fahrmeister', 'checklisten:view'),
('dashboard_fahrmeister', 'checklisten:execute'),
('dashboard_fahrmeister', 'checklisten:approve'),
('dashboard_fahrmeister', 'checklisten:widget')
ON CONFLICT DO NOTHING;
-- Zeugmeister: view, execute, approve, widget (equipment specialist)
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_zeugmeister', 'checklisten:view'),
('dashboard_zeugmeister', 'checklisten:execute'),
('dashboard_zeugmeister', 'checklisten:approve'),
('dashboard_zeugmeister', 'checklisten:widget')
ON CONFLICT DO NOTHING;
-- Chargen: view, execute, widget
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_chargen', 'checklisten:view'),
('dashboard_chargen', 'checklisten:execute'),
('dashboard_chargen', 'checklisten:widget')
ON CONFLICT DO NOTHING;
-- Moderator: view, widget
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_moderator', 'checklisten:view'),
('dashboard_moderator', 'checklisten:widget')
ON CONFLICT DO NOTHING;
-- Atemschutz: view, execute, widget
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_atemschutz', 'checklisten:view'),
('dashboard_atemschutz', 'checklisten:execute'),
('dashboard_atemschutz', 'checklisten:widget')
ON CONFLICT DO NOTHING;
-- Mitglied: view, widget
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_mitglied', 'checklisten:view'),
('dashboard_mitglied', 'checklisten:widget')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,62 @@
-- Migration 070: Ausruestung-Typen (Equipment Types)
-- Dynamic equipment type table with many-to-many junction to ausruestung.
-- Mirrors the fahrzeug_typen pattern from migration 067.
-- Seeds initial types from existing ausruestung_kategorien values.
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Ausruestung-Typen
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS ausruestung_typen (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
beschreibung TEXT,
icon VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Junction table (many-to-many)
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS ausruestung_ausruestung_typen (
ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE,
ausruestung_typ_id INT NOT NULL REFERENCES ausruestung_typen(id) ON DELETE CASCADE,
PRIMARY KEY (ausruestung_id, ausruestung_typ_id)
);
CREATE INDEX IF NOT EXISTS idx_aat_ausruestung_id ON ausruestung_ausruestung_typen(ausruestung_id);
CREATE INDEX IF NOT EXISTS idx_aat_typ_id ON ausruestung_ausruestung_typen(ausruestung_typ_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Seed types from existing ausruestung_kategorien
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO ausruestung_typen (name, beschreibung)
SELECT name, kurzname
FROM ausruestung_kategorien
ON CONFLICT (name) DO NOTHING;
-- Populate junction table from existing kategorie_id assignments
INSERT INTO ausruestung_ausruestung_typen (ausruestung_id, ausruestung_typ_id)
SELECT a.id, at2.id
FROM ausruestung a
JOIN ausruestung_kategorien ak ON a.kategorie_id = ak.id
JOIN ausruestung_typen at2 ON at2.name = ak.name
WHERE a.kategorie_id IS NOT NULL
AND a.deleted_at IS NULL
ON CONFLICT DO NOTHING;
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Permission for managing equipment types
-- ═══════════════════════════════════════════════════════════════════════════
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('ausruestung:manage_types', 'ausruestung', 'Typen verwalten', 'Ausruestung-Typen erstellen und bearbeiten', 10)
ON CONFLICT (id) DO NOTHING;
-- Grant to kommando and zeugmeister
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_kommando', 'ausruestung:manage_types'),
('dashboard_zeugmeister', 'ausruestung:manage_types')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,82 @@
-- Migration 071: Checklisten Equipment Extensions
-- Extends the checklist system to support equipment (Ausruestung) alongside vehicles.
-- Depends on: 068_checklisten.sql, 070_ausruestung_typen.sql
-- ═══════════════════════════════════════════════════════════════════════════
-- 1. Extend checklist_vorlagen with direct assignment columns
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE checklist_vorlagen
ADD COLUMN IF NOT EXISTS fahrzeug_id UUID REFERENCES fahrzeuge(id) ON DELETE SET NULL;
ALTER TABLE checklist_vorlagen
ADD COLUMN IF NOT EXISTS ausruestung_id UUID REFERENCES ausruestung(id) ON DELETE SET NULL;
ALTER TABLE checklist_vorlagen
ADD COLUMN IF NOT EXISTS ausruestung_typ_id INT REFERENCES ausruestung_typen(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_cv_fahrzeug_id ON checklist_vorlagen(fahrzeug_id);
CREATE INDEX IF NOT EXISTS idx_cv_ausruestung_id ON checklist_vorlagen(ausruestung_id);
CREATE INDEX IF NOT EXISTS idx_cv_ausruestung_typ_id ON checklist_vorlagen(ausruestung_typ_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 2. Extend checklist_ausfuehrungen with ausruestung support
-- ═══════════════════════════════════════════════════════════════════════════
-- Make fahrzeug_id nullable (was NOT NULL; now either fahrzeug or ausruestung)
ALTER TABLE checklist_ausfuehrungen
ALTER COLUMN fahrzeug_id DROP NOT NULL;
ALTER TABLE checklist_ausfuehrungen
ADD COLUMN IF NOT EXISTS ausruestung_id UUID REFERENCES ausruestung(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_ca_ausruestung_id ON checklist_ausfuehrungen(ausruestung_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 3. Extend checklist_faelligkeit with ausruestung support
-- ═══════════════════════════════════════════════════════════════════════════
-- Make fahrzeug_id nullable (was part of composite PK)
ALTER TABLE checklist_faelligkeit
DROP CONSTRAINT IF EXISTS checklist_faelligkeit_pkey;
ALTER TABLE checklist_faelligkeit
ALTER COLUMN fahrzeug_id DROP NOT NULL;
ALTER TABLE checklist_faelligkeit
ADD COLUMN IF NOT EXISTS ausruestung_id UUID REFERENCES ausruestung(id) ON DELETE CASCADE;
-- Use partial unique indexes instead of UNIQUE NULLS NOT DISTINCT (compatible with PG < 15)
CREATE UNIQUE INDEX IF NOT EXISTS idx_cf_fahrzeug_vorlage
ON checklist_faelligkeit (vorlage_id, fahrzeug_id)
WHERE fahrzeug_id IS NOT NULL AND ausruestung_id IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_cf_ausruestung_vorlage
ON checklist_faelligkeit (vorlage_id, ausruestung_id)
WHERE ausruestung_id IS NOT NULL AND fahrzeug_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_cf_ausruestung_id ON checklist_faelligkeit(ausruestung_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 4. Ausruestung-spezifische Checklist Items
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS ausruestung_checklist_items (
id SERIAL PRIMARY KEY,
ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE,
bezeichnung VARCHAR(500) NOT NULL,
beschreibung TEXT,
pflicht BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INT NOT NULL DEFAULT 0,
aktiv BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_aci_ausruestung_id ON ausruestung_checklist_items(ausruestung_id);
-- ═══════════════════════════════════════════════════════════════════════════
-- 5. Extend checklist_ausfuehrung_items to reference ausruestung items
-- ═══════════════════════════════════════════════════════════════════════════
ALTER TABLE checklist_ausfuehrung_items
ADD COLUMN IF NOT EXISTS ausruestung_item_id INT REFERENCES ausruestung_checklist_items(id) ON DELETE SET NULL;

View File

@@ -0,0 +1,10 @@
-- Migration 072: Fix checklist_vorlagen intervall CHECK constraint
-- The original constraint in 068 was missing 'quarterly' and 'halfyearly'.
-- These values are used in the frontend and service but were rejected by the DB.
ALTER TABLE checklist_vorlagen
DROP CONSTRAINT IF EXISTS checklist_vorlagen_intervall_check;
ALTER TABLE checklist_vorlagen
ADD CONSTRAINT checklist_vorlagen_intervall_check
CHECK (intervall IN ('weekly', 'monthly', 'quarterly', 'halfyearly', 'yearly', 'custom'));

View File

@@ -0,0 +1,11 @@
-- Add parent_item_id to vorlage items (self-referential)
ALTER TABLE checklist_vorlage_items
ADD COLUMN parent_item_id INT REFERENCES checklist_vorlage_items(id) ON DELETE CASCADE;
CREATE INDEX idx_vorlage_items_parent ON checklist_vorlage_items(parent_item_id);
-- Add parent_ausfuehrung_item_id to execution items (self-referential)
ALTER TABLE checklist_ausfuehrung_items
ADD COLUMN parent_ausfuehrung_item_id INT REFERENCES checklist_ausfuehrung_items(id) ON DELETE CASCADE;
CREATE INDEX idx_ausfuehrung_items_parent ON checklist_ausfuehrung_items(parent_ausfuehrung_item_id);

View File

@@ -0,0 +1,70 @@
-- Migration 074: Checklist multi-type assignment (junction tables)
-- Replaces single FK columns with M:N junction tables
-- 1. Create junction tables
CREATE TABLE IF NOT EXISTS checklist_vorlage_fahrzeug_typen (
vorlage_id INTEGER NOT NULL REFERENCES checklist_vorlagen(id) ON DELETE CASCADE,
fahrzeug_typ_id INTEGER NOT NULL REFERENCES fahrzeug_typen(id) ON DELETE CASCADE,
PRIMARY KEY (vorlage_id, fahrzeug_typ_id)
);
CREATE TABLE IF NOT EXISTS checklist_vorlage_fahrzeuge (
vorlage_id INTEGER NOT NULL REFERENCES checklist_vorlagen(id) ON DELETE CASCADE,
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
PRIMARY KEY (vorlage_id, fahrzeug_id)
);
CREATE TABLE IF NOT EXISTS checklist_vorlage_ausruestung_typen (
vorlage_id INTEGER NOT NULL REFERENCES checklist_vorlagen(id) ON DELETE CASCADE,
ausruestung_typ_id INTEGER NOT NULL REFERENCES ausruestung_typen(id) ON DELETE CASCADE,
PRIMARY KEY (vorlage_id, ausruestung_typ_id)
);
CREATE TABLE IF NOT EXISTS checklist_vorlage_ausruestung (
vorlage_id INTEGER NOT NULL REFERENCES checklist_vorlagen(id) ON DELETE CASCADE,
ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE,
PRIMARY KEY (vorlage_id, ausruestung_id)
);
-- 2. Migrate existing single-FK data into junction tables
-- (only if the old columns exist — safe with DO $$ blocks)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='fahrzeug_typ_id') THEN
INSERT INTO checklist_vorlage_fahrzeug_typen (vorlage_id, fahrzeug_typ_id)
SELECT id, fahrzeug_typ_id FROM checklist_vorlagen WHERE fahrzeug_typ_id IS NOT NULL
ON CONFLICT DO NOTHING;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='fahrzeug_id') THEN
INSERT INTO checklist_vorlage_fahrzeuge (vorlage_id, fahrzeug_id)
SELECT id, fahrzeug_id FROM checklist_vorlagen WHERE fahrzeug_id IS NOT NULL
ON CONFLICT DO NOTHING;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='ausruestung_typ_id') THEN
INSERT INTO checklist_vorlage_ausruestung_typen (vorlage_id, ausruestung_typ_id)
SELECT id, ausruestung_typ_id FROM checklist_vorlagen WHERE ausruestung_typ_id IS NOT NULL
ON CONFLICT DO NOTHING;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='ausruestung_id') THEN
INSERT INTO checklist_vorlage_ausruestung (vorlage_id, ausruestung_id)
SELECT id, ausruestung_id FROM checklist_vorlagen WHERE ausruestung_id IS NOT NULL
ON CONFLICT DO NOTHING;
END IF;
END $$;
-- 3. Drop old FK columns (use DO $$ for safety)
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='fahrzeug_typ_id') THEN
ALTER TABLE checklist_vorlagen DROP COLUMN fahrzeug_typ_id;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='fahrzeug_id') THEN
ALTER TABLE checklist_vorlagen DROP COLUMN fahrzeug_id;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='ausruestung_typ_id') THEN
ALTER TABLE checklist_vorlagen DROP COLUMN ausruestung_typ_id;
END IF;
IF EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='checklist_vorlagen' AND column_name='ausruestung_id') THEN
ALTER TABLE checklist_vorlagen DROP COLUMN ausruestung_id;
END IF;
END $$;

View File

@@ -0,0 +1,231 @@
-- =============================================================================
-- Migration 075: Buchhaltung (Accounting) Schema
-- =============================================================================
-- 1. Account types (lookup table)
CREATE TABLE IF NOT EXISTS buchhaltung_konto_typen (
id SERIAL PRIMARY KEY,
bezeichnung TEXT NOT NULL UNIQUE,
art TEXT NOT NULL CHECK (art IN ('einnahme', 'ausgabe', 'vermoegen', 'verbindlichkeit')),
sort_order INT NOT NULL DEFAULT 0
);
INSERT INTO buchhaltung_konto_typen (bezeichnung, art, sort_order) VALUES
('Einnahmen', 'einnahme', 1),
('Ausgaben', 'ausgabe', 2),
('Vermögen', 'vermoegen', 3),
('Verbindlichkeiten','verbindlichkeit', 4)
ON CONFLICT (bezeichnung) DO NOTHING;
-- 2. Bank accounts
CREATE TABLE IF NOT EXISTS buchhaltung_bankkonten (
id SERIAL PRIMARY KEY,
bezeichnung TEXT NOT NULL,
iban TEXT,
bic TEXT,
institut TEXT,
ist_standard BOOLEAN NOT NULL DEFAULT FALSE,
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()
);
-- 3. Fiscal years
CREATE TABLE IF NOT EXISTS buchhaltung_haushaltsjahre (
id SERIAL PRIMARY KEY,
jahr INT NOT NULL UNIQUE,
bezeichnung TEXT NOT NULL,
beginn DATE NOT NULL,
ende DATE NOT NULL,
abgeschlossen BOOLEAN NOT NULL DEFAULT FALSE,
erstellt_von UUID,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 4. Budget accounts
CREATE TABLE IF NOT EXISTS buchhaltung_konten (
id SERIAL PRIMARY KEY,
haushaltsjahr_id INT NOT NULL REFERENCES buchhaltung_haushaltsjahre(id) ON DELETE CASCADE,
konto_typ_id INT REFERENCES buchhaltung_konto_typen(id) ON DELETE SET NULL,
kontonummer TEXT NOT NULL,
bezeichnung TEXT NOT NULL,
budget_betrag NUMERIC(12,2) NOT NULL DEFAULT 0,
notizen TEXT,
aktiv BOOLEAN NOT NULL DEFAULT TRUE,
erstellt_von UUID,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (haushaltsjahr_id, kontonummer)
);
-- 5. Transactions
CREATE TABLE IF NOT EXISTS buchhaltung_transaktionen (
id SERIAL PRIMARY KEY,
haushaltsjahr_id INT NOT NULL REFERENCES buchhaltung_haushaltsjahre(id) ON DELETE RESTRICT,
konto_id INT REFERENCES buchhaltung_konten(id) ON DELETE SET NULL,
bankkonto_id INT REFERENCES buchhaltung_bankkonten(id) ON DELETE SET NULL,
laufende_nummer INT,
typ TEXT NOT NULL CHECK (typ IN ('einnahme', 'ausgabe')),
betrag NUMERIC(12,2) NOT NULL,
datum DATE NOT NULL,
buchungsdatum DATE,
beschreibung TEXT,
empfaenger_auftraggeber TEXT,
verwendungszweck TEXT,
beleg_nr TEXT,
status TEXT NOT NULL CHECK (status IN ('entwurf', 'gebucht', 'freigegeben', 'storniert')) DEFAULT 'entwurf',
bestellung_id INT REFERENCES bestellungen(id) ON DELETE SET NULL,
erstellt_von UUID,
gebucht_von UUID,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 6. Receipts / attachments
CREATE TABLE IF NOT EXISTS buchhaltung_belege (
id SERIAL PRIMARY KEY,
transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE,
dateiname TEXT NOT NULL,
original_name TEXT NOT NULL,
dateityp TEXT NOT NULL,
dateigroesse INT NOT NULL,
erstellt_von UUID,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 7. Approvals
CREATE TABLE IF NOT EXISTS buchhaltung_freigaben (
id SERIAL PRIMARY KEY,
transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE,
status TEXT NOT NULL CHECK (status IN ('ausstehend', 'genehmigt', 'abgelehnt')) DEFAULT 'ausstehend',
kommentar TEXT,
freigegeben_von UUID REFERENCES users(id) ON DELETE SET NULL,
freigegeben_am TIMESTAMPTZ,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 8. Recurring bookings
CREATE TABLE IF NOT EXISTS buchhaltung_wiederkehrend (
id SERIAL PRIMARY KEY,
bezeichnung TEXT NOT NULL,
konto_id INT REFERENCES buchhaltung_konten(id) ON DELETE SET NULL,
bankkonto_id INT REFERENCES buchhaltung_bankkonten(id) ON DELETE SET NULL,
typ TEXT NOT NULL CHECK (typ IN ('einnahme', 'ausgabe')),
betrag NUMERIC(12,2) NOT NULL,
beschreibung TEXT,
empfaenger_auftraggeber TEXT,
intervall TEXT NOT NULL CHECK (intervall IN ('monatlich', 'quartalsweise', 'halbjaehrlich', 'jaehrlich')),
naechste_ausfuehrung DATE NOT NULL,
aktiv BOOLEAN NOT NULL DEFAULT TRUE,
erstellt_von UUID,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 9. Audit log
CREATE TABLE IF NOT EXISTS buchhaltung_audit (
id SERIAL PRIMARY KEY,
transaktion_id INT REFERENCES buchhaltung_transaktionen(id) ON DELETE SET NULL,
aktion TEXT NOT NULL,
details JSONB,
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 10. Settings
CREATE TABLE IF NOT EXISTS buchhaltung_einstellungen (
key TEXT PRIMARY KEY,
value JSONB NOT NULL,
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 11. Budget planning (Phase 7 prep)
CREATE TABLE IF NOT EXISTS buchhaltung_planung (
id SERIAL PRIMARY KEY,
haushaltsjahr_id INT NOT NULL REFERENCES buchhaltung_haushaltsjahre(id) ON DELETE CASCADE,
bezeichnung TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('entwurf', 'aktiv', 'abgeschlossen')) DEFAULT 'entwurf',
erstellt_von UUID,
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 12. Planning line items
CREATE TABLE IF NOT EXISTS buchhaltung_planpositionen (
id SERIAL PRIMARY KEY,
planung_id INT NOT NULL REFERENCES buchhaltung_planung(id) ON DELETE CASCADE,
konto_id INT REFERENCES buchhaltung_konten(id) ON DELETE SET NULL,
bezeichnung TEXT NOT NULL,
plan_betrag NUMERIC(12,2) NOT NULL DEFAULT 0,
notizen TEXT,
sort_order INT NOT NULL DEFAULT 0
);
-- =============================================================================
-- Indexes
-- =============================================================================
CREATE INDEX IF NOT EXISTS idx_buch_trans_haushaltsjahr ON buchhaltung_transaktionen(haushaltsjahr_id);
CREATE INDEX IF NOT EXISTS idx_buch_trans_konto ON buchhaltung_transaktionen(konto_id);
CREATE INDEX IF NOT EXISTS idx_buch_trans_bankkonto ON buchhaltung_transaktionen(bankkonto_id);
CREATE INDEX IF NOT EXISTS idx_buch_trans_status ON buchhaltung_transaktionen(status);
CREATE INDEX IF NOT EXISTS idx_buch_trans_datum ON buchhaltung_transaktionen(datum);
CREATE INDEX IF NOT EXISTS idx_buch_trans_bestellung ON buchhaltung_transaktionen(bestellung_id);
CREATE INDEX IF NOT EXISTS idx_buch_konten_haushaltsjahr ON buchhaltung_konten(haushaltsjahr_id);
-- =============================================================================
-- Triggers — aktualisiert_am (reuse existing update_aktualisiert_am function)
-- =============================================================================
CREATE TRIGGER trg_buch_bankkonten_updated
BEFORE UPDATE ON buchhaltung_bankkonten
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
CREATE TRIGGER trg_buch_haushaltsjahre_updated
BEFORE UPDATE ON buchhaltung_haushaltsjahre
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
CREATE TRIGGER trg_buch_konten_updated
BEFORE UPDATE ON buchhaltung_konten
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
CREATE TRIGGER trg_buch_transaktionen_updated
BEFORE UPDATE ON buchhaltung_transaktionen
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
CREATE TRIGGER trg_buch_wiederkehrend_updated
BEFORE UPDATE ON buchhaltung_wiederkehrend
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
CREATE TRIGGER trg_buch_planung_updated
BEFORE UPDATE ON buchhaltung_planung
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
-- =============================================================================
-- Trigger — sequential laufende_nummer per fiscal year
-- =============================================================================
CREATE OR REPLACE FUNCTION buchhaltung_assign_laufende_nummer()
RETURNS TRIGGER AS $$
DECLARE
next_num INT;
BEGIN
-- Assign laufende_nummer when status becomes 'gebucht' and not yet assigned
IF NEW.status = 'gebucht' AND NEW.laufende_nummer IS NULL THEN
IF TG_OP = 'INSERT' OR (TG_OP = 'UPDATE' AND (OLD.status IS NULL OR OLD.status != 'gebucht')) THEN
SELECT COALESCE(MAX(laufende_nummer), 0) + 1
INTO next_num
FROM buchhaltung_transaktionen
WHERE haushaltsjahr_id = NEW.haushaltsjahr_id;
NEW.laufende_nummer := next_num;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_buch_transaktionen_laufende_nummer
BEFORE INSERT OR UPDATE ON buchhaltung_transaktionen
FOR EACH ROW EXECUTE FUNCTION buchhaltung_assign_laufende_nummer();

View File

@@ -0,0 +1,33 @@
-- =============================================================================
-- Migration 076: Buchhaltung Permissions
-- =============================================================================
-- Feature group
INSERT INTO feature_groups (id, label, sort_order)
VALUES ('buchhaltung', 'Buchhaltung', 13)
ON CONFLICT (id) DO NOTHING;
-- 8 permissions
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
('buchhaltung:view', 'buchhaltung', 'Ansehen', 'Buchhaltungsdaten einsehen', 1),
('buchhaltung:create', 'buchhaltung', 'Erstellen', 'Transaktionen anlegen', 2),
('buchhaltung:edit', 'buchhaltung', 'Bearbeiten', 'Transaktionen bearbeiten', 3),
('buchhaltung:delete', 'buchhaltung', 'Löschen', 'Transaktionen löschen', 4),
('buchhaltung:manage_accounts', 'buchhaltung', 'Konten verwalten', 'Konten und Bankkonten verwalten', 5),
('buchhaltung:manage_settings', 'buchhaltung', 'Einstellungen', 'Buchhaltungs-Einstellungen verwalten', 6),
('buchhaltung:export', 'buchhaltung', 'Exportieren', 'Daten exportieren (CSV/PDF)', 7),
('buchhaltung:widget', 'buchhaltung', 'Widget', 'Dashboard-Widget anzeigen', 8)
ON CONFLICT (id) DO NOTHING;
-- Grant all permissions to dashboard_kommando
INSERT INTO group_permissions (authentik_group, permission_id)
SELECT 'dashboard_kommando', id FROM permissions WHERE feature_group_id = 'buchhaltung'
ON CONFLICT DO NOTHING;
-- Grant view, create, edit, widget to dashboard_chargen
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
('dashboard_chargen', 'buchhaltung:view'),
('dashboard_chargen', 'buchhaltung:create'),
('dashboard_chargen', 'buchhaltung:edit'),
('dashboard_chargen', 'buchhaltung:widget')
ON CONFLICT DO NOTHING;

View File

@@ -0,0 +1,25 @@
-- 1. Add parent_id for account hierarchy
ALTER TABLE buchhaltung_konten ADD COLUMN parent_id INT REFERENCES buchhaltung_konten(id) ON DELETE SET NULL;
CREATE INDEX idx_buch_konten_parent ON buchhaltung_konten(parent_id);
-- 2. Replace budget_betrag with three type-specific budget columns
ALTER TABLE buchhaltung_konten ADD COLUMN budget_gwg NUMERIC(12,2) NOT NULL DEFAULT 0;
ALTER TABLE buchhaltung_konten ADD COLUMN budget_anlagen NUMERIC(12,2) NOT NULL DEFAULT 0;
ALTER TABLE buchhaltung_konten ADD COLUMN budget_instandhaltung NUMERIC(12,2) NOT NULL DEFAULT 0;
-- Migrate existing budget to GWG as default
UPDATE buchhaltung_konten SET budget_gwg = COALESCE(budget_betrag, 0);
ALTER TABLE buchhaltung_konten DROP COLUMN budget_betrag;
-- 3. Add ausgaben_typ to transactions (nullable: einnahmen have no type)
ALTER TABLE buchhaltung_transaktionen ADD COLUMN ausgaben_typ TEXT CHECK (ausgaben_typ IN ('gwg', 'anlagen', 'instandhaltung'));
-- 4. Add wiederkehrend_id to track auto-generated transactions
ALTER TABLE buchhaltung_transaktionen ADD COLUMN wiederkehrend_id INT REFERENCES buchhaltung_wiederkehrend(id) ON DELETE SET NULL;
CREATE INDEX idx_buch_trans_wiederkehrend ON buchhaltung_transaktionen(wiederkehrend_id);
-- 5. Update planpositionen to have type-specific budgets
ALTER TABLE buchhaltung_planpositionen ADD COLUMN budget_gwg NUMERIC(12,2) NOT NULL DEFAULT 0;
ALTER TABLE buchhaltung_planpositionen ADD COLUMN budget_anlagen NUMERIC(12,2) NOT NULL DEFAULT 0;
ALTER TABLE buchhaltung_planpositionen ADD COLUMN budget_instandhaltung NUMERIC(12,2) NOT NULL DEFAULT 0;
UPDATE buchhaltung_planpositionen SET budget_gwg = COALESCE(plan_betrag, 0);
ALTER TABLE buchhaltung_planpositionen DROP COLUMN plan_betrag;

View File

@@ -0,0 +1,3 @@
-- Convert kontonummer from TEXT to INTEGER
ALTER TABLE buchhaltung_konten
ALTER COLUMN kontonummer TYPE INTEGER USING kontonummer::INTEGER;

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