Compare commits
255 Commits
a04577ac9e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec719ad0a | ||
|
|
9410441ce2 | ||
|
|
c55ec55e1b | ||
|
|
65820805b0 | ||
|
|
99792d93dd | ||
|
|
752dfe474c | ||
|
|
d5291360c9 | ||
|
|
84254a0b71 | ||
|
|
3b4a14661c | ||
|
|
b401b75b9a | ||
|
|
d796fae978 | ||
|
|
ed3ee143dd | ||
|
|
8c25cb0d40 | ||
|
|
3f92156115 | ||
|
|
54a110d17b | ||
|
|
0a5402a9e5 | ||
|
|
0a6377a64f | ||
|
|
26df8b427e | ||
|
|
e1c7f44e56 | ||
|
|
8e6868eb55 | ||
|
|
bef5a685a8 | ||
|
|
fa9f50d982 | ||
|
|
219e5f1195 | ||
|
|
8ee2b9170d | ||
|
|
968b24156b | ||
|
|
d44f53a8a9 | ||
|
|
53c8be0f6d | ||
|
|
72b575478b | ||
|
|
e4c37ba219 | ||
|
|
68e4ed265f | ||
|
|
169d045e4c | ||
|
|
d8afcc1f63 | ||
|
|
fcca04cc39 | ||
|
|
7d2ea57c17 | ||
|
|
b91cf88812 | ||
|
|
5811ac201e | ||
|
|
510b44e48c | ||
|
|
7532a19326 | ||
|
|
8a0c4200ff | ||
|
|
6614fbaa68 | ||
|
|
6ead698294 | ||
|
|
1c071c7768 | ||
|
|
dfcdd44aa4 | ||
|
|
e56075f38a | ||
|
|
2fe0db6d9a | ||
|
|
a6aeab80d4 | ||
|
|
058ee721e8 | ||
|
|
dac0b79b3b | ||
|
|
3f8c4d151d | ||
|
|
260b71baf8 | ||
|
|
c1de8bd163 | ||
|
|
eb2342684e | ||
|
|
67fd0878ce | ||
|
|
9586822a32 | ||
|
|
55b2fc1cf4 | ||
|
|
719b7bfcdb | ||
|
|
dab4a45b79 | ||
|
|
c3fcbd1467 | ||
|
|
6ff531f79c | ||
|
|
50dbf6e9fd | ||
|
|
916aa488d2 | ||
|
|
4c01683c10 | ||
|
|
279cc03b6b | ||
|
|
633a75cb0b | ||
|
|
e6b6639fe9 | ||
|
|
a94d486a42 | ||
|
|
7392bfc29f | ||
|
|
967cad5922 | ||
|
|
f403c73334 | ||
|
|
3a8f166121 | ||
|
|
588d8e81db | ||
|
|
4fbea8af81 | ||
|
|
4c4fb01e68 | ||
|
|
25d418539e | ||
|
|
67adc4f5aa | ||
|
|
bb95a66ffe | ||
|
|
1ad328edd3 | ||
|
|
1215e9ea70 | ||
|
|
b477e5dbe0 | ||
|
|
a0b3c0ec5c | ||
|
|
b275d4baa5 | ||
|
|
dd5cd71fd1 | ||
|
|
f4690cf185 | ||
|
|
43ce1f930c | ||
|
|
5acfd7cc4f | ||
|
|
2eb59e9ff1 | ||
|
|
d833b3c224 | ||
|
|
bbbfc8eaaa | ||
|
|
f27e3134ca | ||
|
|
293848c710 | ||
|
|
fcfee85efd | ||
|
|
b21abce9e3 | ||
|
|
13aa4be599 | ||
|
|
86cb175aeb | ||
|
|
4e42d4077a | ||
|
|
e4f1d8864a | ||
|
|
5f25d644f4 | ||
|
|
333b94f64e | ||
|
|
75b07d6afc | ||
|
|
b7015ace84 | ||
|
|
2e736f7995 | ||
|
|
cdaaec2971 | ||
|
|
0c5432b50e | ||
|
|
bc39963746 | ||
|
|
c1fca5cc67 | ||
|
|
712afde30e | ||
|
|
3871efc026 | ||
|
|
58585327d8 | ||
|
|
18b1300de8 | ||
|
|
4349de9bc9 | ||
|
|
893fbe43a0 | ||
|
|
51be3b54f6 | ||
|
|
15337f768d | ||
|
|
4c1f188371 | ||
|
|
0d6d5e4f54 | ||
|
|
b62fd55246 | ||
|
|
a04de62634 | ||
|
|
6091d6c4dd | ||
|
|
a52bb2a57c | ||
|
|
bccb0745b8 | ||
|
|
534a24edbf | ||
|
|
6b46e97eb6 | ||
|
|
692093cc85 | ||
|
|
b171c3e921 | ||
|
|
443f3569bd | ||
|
|
0a912e60b5 | ||
|
|
0c2ea829aa | ||
|
|
a1cda5be51 | ||
|
|
992a184784 | ||
|
|
60e1329815 | ||
|
|
c1b4a92a12 | ||
|
|
1a66a66aab | ||
|
|
b36e05d192 | ||
|
|
82c386888f | ||
|
|
ae3f0c825b | ||
|
|
3f55990212 | ||
|
|
b5e8f11743 | ||
|
|
29d66e37a1 | ||
|
|
c704e2c173 | ||
|
|
6885cba3be | ||
|
|
35b3718e38 | ||
|
|
eb82fe29b7 | ||
|
|
90f691d607 | ||
|
|
75e533c1fc | ||
|
|
03f489d546 | ||
|
|
19dd528765 | ||
|
|
80cfb244cf | ||
|
|
0c101bea8b | ||
|
|
ecee41b3aa | ||
|
|
9c3dda337f | ||
|
|
66916ce6b5 | ||
|
|
671f0dedda | ||
|
|
7b7d799238 | ||
|
|
841b6e3775 | ||
|
|
3e5086441e | ||
|
|
c29b21f714 | ||
|
|
3c95b7506b | ||
|
|
507111e8e8 | ||
|
|
d351ea2647 | ||
|
|
d4adf9230d | ||
|
|
3d03345107 | ||
|
|
ca12a23a30 | ||
|
|
d5e5f2d44e | ||
|
|
884397b520 | ||
|
|
21bbe57d6f | ||
|
|
7dab359448 | ||
|
|
7cc4facc11 | ||
|
|
e78a23bc05 | ||
|
|
74d978171c | ||
|
|
e49639e2a6 | ||
|
|
51d8777d66 | ||
|
|
0bb2feaba2 | ||
|
|
5add6590e5 | ||
|
|
561334791b | ||
|
|
e02ada8b95 | ||
|
|
feb39d234f | ||
|
|
4ad260ce66 | ||
|
|
4ed76fe20d | ||
|
|
5db4cc21b5 | ||
|
|
6f39f22bf9 | ||
|
|
eb92dfcc96 | ||
|
|
43b7093996 | ||
|
|
5ceae7c364 | ||
|
|
59140939df | ||
|
|
5a64987236 | ||
|
|
86cd7b4ce0 | ||
|
|
f228dd67ba | ||
|
|
e6ddf67d95 | ||
|
|
f9f54b7e07 | ||
|
|
a0d99dce8d | ||
|
|
f1bd3e162f | ||
|
|
d8d2730547 | ||
|
|
65994286b2 | ||
|
|
0dd5033664 | ||
|
|
6c7531438e | ||
|
|
abb337c683 | ||
|
|
90944ca5f6 | ||
|
|
64663c0fe4 | ||
|
|
9a52e41372 | ||
|
|
50d963120a | ||
|
|
343cd7aee2 | ||
|
|
2b77ae5724 | ||
|
|
3ce8adfa07 | ||
|
|
0389c3d2aa | ||
|
|
209d5a676e | ||
|
|
39b8b30ca2 | ||
|
|
6ff5cc89ad | ||
|
|
3c0a8a6832 | ||
|
|
f982fbb2b6 | ||
|
|
99f02b8425 | ||
|
|
742c37b8de | ||
|
|
08249f1846 | ||
|
|
a63d78e9d9 | ||
|
|
135dbf6d71 | ||
|
|
c1313ce52e | ||
|
|
1b13e4f89e | ||
|
|
202a658b8d | ||
|
|
e720c52896 | ||
|
|
97c9af7f14 | ||
|
|
4c323748fd | ||
|
|
0ffa6feb4c | ||
|
|
f81d994e64 | ||
|
|
269b797f42 | ||
|
|
da08948ca8 | ||
|
|
55ded22a6f | ||
|
|
948b211f70 | ||
|
|
690f260b71 | ||
|
|
8c66492b27 | ||
|
|
e9a9478aac | ||
|
|
bfcf1556da | ||
|
|
34ee80b8c1 | ||
|
|
1d011ec2df | ||
|
|
3326156b15 | ||
|
|
d2dc64d54a | ||
|
|
9443c9457b | ||
|
|
5032e1593b | ||
|
|
83b84664ce | ||
|
|
725d4d1729 | ||
|
|
fa10467f21 | ||
|
|
a575b61d26 | ||
|
|
d173c8235e | ||
|
|
515f14956e | ||
|
|
2bb22850f4 | ||
|
|
f976f36cbc | ||
|
|
69c508a5d8 | ||
|
|
177dd1395b | ||
|
|
665eca24af | ||
|
|
75cf07f402 | ||
|
|
57ab1e8b25 | ||
|
|
d276e45248 | ||
|
|
9d1c796d1a | ||
|
|
43e5f907d5 | ||
|
|
aab63caf20 | ||
|
|
3fca17f853 | ||
|
|
41f45acd1c |
376
.claude/plans/2026-03-28-buchhaltung-design.md
Normal file
376
.claude/plans/2026-03-28-buchhaltung-design.md
Normal 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.
|
||||
101
.claude/plans/2026-03-30-buchhaltung-implementation.md
Normal file
101
.claude/plans/2026-03-30-buchhaltung-implementation.md
Normal 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`
|
||||
170
.claude/plans/feature-extensions.md
Normal file
170
.claude/plans/feature-extensions.md
Normal 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
|
||||
186
.claude/plans/tingly-cooking-locket.md
Normal file
186
.claude/plans/tingly-cooking-locket.md
Normal 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
|
||||
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
||||
{"sessionId":"7c1ca5d5-0a55-4bf7-b433-7d5e62543b8c","pid":94069,"acquiredAt":1776064212433}
|
||||
121
.claude/skills/orchestrate/SKILL.md
Normal file
121
.claude/skills/orchestrate/SKILL.md
Normal 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 (3–5 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.
|
||||
18
.env.example
18
.env.example
@@ -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
|
||||
@@ -283,14 +283,14 @@ FDISK_SYNC_URL=http://fdisk-sync:3001
|
||||
# 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
24
CLAUDE.md
Normal 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)
|
||||
@@ -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
|
||||
|
||||
@@ -2,10 +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();
|
||||
|
||||
@@ -93,10 +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);
|
||||
@@ -113,11 +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);
|
||||
|
||||
@@ -171,9 +171,9 @@ class AtemschutzController {
|
||||
user_id: item.user_id,
|
||||
typ: 'atemschutz_expiry',
|
||||
titel: item.untersuchung_status === 'abgelaufen'
|
||||
? 'G26 Untersuchung abgelaufen'
|
||||
: 'G26 Untersuchung läuft bald ab',
|
||||
nachricht: `Ihre G26 Untersuchung ${item.untersuchung_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`,
|
||||
? '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,
|
||||
|
||||
130
backend/src/controllers/ausruestungTyp.controller.ts
Normal file
130
backend/src/controllers/ausruestungTyp.controller.ts
Normal 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();
|
||||
657
backend/src/controllers/ausruestungsanfrage.controller.ts
Normal file
657
backend/src/controllers/ausruestungsanfrage.controller.ts
Normal 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();
|
||||
@@ -87,6 +87,7 @@ class AuthController {
|
||||
// Step 2: Get user info from Authentik
|
||||
const userInfo = await authentikService.getUserInfo(tokens.access_token);
|
||||
const groups = userInfo.groups ?? [];
|
||||
const dashboardGroups = groups.filter((g: string) => g.startsWith('dashboard_'));
|
||||
|
||||
// Step 3: Verify ID token if present
|
||||
if (tokens.id_token) {
|
||||
@@ -99,6 +100,22 @@ class AuthController {
|
||||
|
||||
// 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
|
||||
@@ -119,7 +136,8 @@ 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)
|
||||
@@ -167,7 +185,8 @@ class AuthController {
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -227,6 +246,7 @@ class AuthController {
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isNewUser,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
|
||||
612
backend/src/controllers/bestellung.controller.ts
Normal file
612
backend/src/controllers/bestellung.controller.ts
Normal 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();
|
||||
@@ -2,7 +2,8 @@ import { Request, Response } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
import bookingService from '../services/booking.service';
|
||||
import vehicleService from '../services/vehicle.service';
|
||||
import { hasPermission, resolveRequestRole } from '../middleware/rbac.middleware';
|
||||
import pool from '../config/database';
|
||||
import { permissionService } from '../services/permission.service';
|
||||
import {
|
||||
CreateBuchungSchema,
|
||||
UpdateBuchungSchema,
|
||||
@@ -43,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.
|
||||
@@ -158,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;
|
||||
@@ -217,15 +234,19 @@ class BookingController {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check ownership: creator can always cancel their own booking
|
||||
// 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 role = resolveRequestRole(req);
|
||||
if (!isOwner && !hasPermission(role, 'bookings:write')) {
|
||||
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;
|
||||
}
|
||||
|
||||
761
backend/src/controllers/buchhaltung.controller.ts
Normal file
761
backend/src/controllers/buchhaltung.controller.ts
Normal 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();
|
||||
101
backend/src/controllers/buchungskategorie.controller.ts
Normal file
101
backend/src/controllers/buchungskategorie.controller.ts
Normal 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();
|
||||
485
backend/src/controllers/checklist.controller.ts
Normal file
485
backend/src/controllers/checklist.controller.ts
Normal 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();
|
||||
@@ -15,11 +15,12 @@ class ConfigController {
|
||||
|
||||
async getPdfSettings(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const [header, footer, logo, orgName] = await Promise.all([
|
||||
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,
|
||||
@@ -28,18 +29,24 @@ class ConfigController {
|
||||
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: '' } });
|
||||
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> = {};
|
||||
if (environment.nextcloudUrl) envLinks.nextcloud = environment.nextcloudUrl;
|
||||
if (environment.bookstack.url) envLinks.bookstack = environment.bookstack.url;
|
||||
if (environment.vikunja.url) envLinks.vikunja = environment.vikunja.url;
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
@@ -391,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();
|
||||
|
||||
@@ -304,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;
|
||||
|
||||
126
backend/src/controllers/fahrzeugTyp.controller.ts
Normal file
126
backend/src/controllers/fahrzeugTyp.controller.ts
Normal 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();
|
||||
@@ -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, resolveRequestRole } from '../middleware/rbac.middleware';
|
||||
import { AppRole } from '../middleware/rbac.middleware';
|
||||
import { permissionService } from '../services/permission.service';
|
||||
import {
|
||||
CreateEinsatzSchema,
|
||||
UpdateEinsatzSchema,
|
||||
@@ -88,9 +89,11 @@ class IncidentController {
|
||||
throw new AppError('Einsatz nicht gefunden', 404);
|
||||
}
|
||||
|
||||
// Role-based redaction: self-contained role resolution (no middleware dependency)
|
||||
const role = resolveRequestRole(req);
|
||||
const canReadBerichtText = hasPermission(role, 'incidents:read_bericht_text');
|
||||
// Role-based redaction: check einsaetze:view_reports permission
|
||||
const groups: string[] = req.user?.groups ?? [];
|
||||
const canReadBerichtText =
|
||||
groups.includes('dashboard_admin') ||
|
||||
permissionService.hasPermission(groups, 'einsaetze:view_reports');
|
||||
|
||||
const responseData = {
|
||||
...incident,
|
||||
|
||||
581
backend/src/controllers/issue.controller.ts
Normal file
581
backend/src/controllers/issue.controller.ts
Normal 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();
|
||||
@@ -45,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;
|
||||
|
||||
@@ -61,7 +63,9 @@ class MemberController {
|
||||
status: normalizeArray(statusParam) as any,
|
||||
dienstgrad: normalizeArray(dienstgradParam) as any,
|
||||
page: page ? parseInt(page, 10) || 1 : 1,
|
||||
pageSize: pageSize ? Math.min(parseInt(pageSize, 10) || 25, 100) : 25,
|
||||
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({
|
||||
|
||||
316
backend/src/controllers/permission.controller.ts
Normal file
316
backend/src/controllers/permission.controller.ts
Normal 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();
|
||||
224
backend/src/controllers/personalEquipment.controller.ts
Normal file
224
backend/src/controllers/personalEquipment.controller.ts
Normal 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();
|
||||
200
backend/src/controllers/scheduledMessages.controller.ts
Normal file
200
backend/src/controllers/scheduledMessages.controller.ts
Normal 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();
|
||||
184
backend/src/controllers/toolConfig.controller.ts
Normal file
184
backend/src/controllers/toolConfig.controller.ts
Normal 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();
|
||||
@@ -2,6 +2,7 @@ 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';
|
||||
|
||||
@@ -86,6 +87,8 @@ const UpdateStatusSchema = z.object({
|
||||
{ message: 'Enddatum muss nach Startdatum liegen', path: ['ausserDienstBis'] }
|
||||
);
|
||||
|
||||
const ErgebnisEnum = z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']);
|
||||
|
||||
const CreateWartungslogSchema = z.object({
|
||||
datum: isoDate,
|
||||
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
|
||||
@@ -94,6 +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 ────────────────────────────────────────────────────────────────────
|
||||
@@ -300,6 +315,17 @@ class VehicleController {
|
||||
parsed.data.ausserDienstVon ? new Date(parsed.data.ausserDienstVon) : null,
|
||||
parsed.data.ausserDienstBis ? new Date(parsed.data.ausserDienstBis) : null,
|
||||
);
|
||||
|
||||
// Fire-and-forget: notify scheduled messages when vehicle goes out of service
|
||||
if (parsed.data.status !== FahrzeugStatus.Einsatzbereit) {
|
||||
scheduledMessagesService.sendVehicleEvent(id).catch(err => {
|
||||
logger.error('Failed to send vehicle event notification', {
|
||||
vehicleId: id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'Vehicle not found') {
|
||||
@@ -372,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();
|
||||
|
||||
440
backend/src/database/migrations/037_create_permission_system.sql
Normal file
440
backend/src/database/migrations/037_create_permission_system.sql
Normal 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;
|
||||
145
backend/src/database/migrations/038_create_bestellungen.sql
Normal file
145
backend/src/database/migrations/038_create_bestellungen.sql
Normal 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 $$;
|
||||
84
backend/src/database/migrations/039_create_shop.sql
Normal file
84
backend/src/database/migrations/039_create_shop.sql
Normal 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 $$;
|
||||
158
backend/src/database/migrations/040_update_permissions.sql
Normal file
158
backend/src/database/migrations/040_update_permissions.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Migration 042: Ensure ganztaegig column exists on fahrzeug_buchungen
|
||||
-- Fixes case where migration 041 was tracked before this column was added.
|
||||
|
||||
ALTER TABLE fahrzeug_buchungen ADD COLUMN IF NOT EXISTS ganztaegig BOOLEAN DEFAULT FALSE;
|
||||
130
backend/src/database/migrations/043_feature_batch.sql
Normal file
130
backend/src/database/migrations/043_feature_batch.sql
Normal file
@@ -0,0 +1,130 @@
|
||||
-- Migration 043: Feature batch
|
||||
-- 1. Vehicle wartungslog: ergebnis + naechste_faelligkeit
|
||||
-- 2. Shop anfragen: bestell_nummer + bestell_jahr
|
||||
-- 3. Issues + issue_kommentare tables
|
||||
-- 4. New feature group 'issues' + permissions
|
||||
-- 5. New shop permissions (view_overview, order_for_user)
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 1. Vehicle Wartungslog additions
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE fahrzeug_wartungslog
|
||||
ADD COLUMN IF NOT EXISTS ergebnis VARCHAR(30)
|
||||
CHECK (ergebnis IN ('bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden'));
|
||||
|
||||
ALTER TABLE fahrzeug_wartungslog
|
||||
ADD COLUMN IF NOT EXISTS naechste_faelligkeit DATE;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 2. Shop Anfragen: unique order ID per year
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ALTER TABLE shop_anfragen
|
||||
ADD COLUMN IF NOT EXISTS bestell_nummer INT;
|
||||
|
||||
ALTER TABLE shop_anfragen
|
||||
ADD COLUMN IF NOT EXISTS bestell_jahr INT DEFAULT EXTRACT(YEAR FROM CURRENT_DATE);
|
||||
|
||||
-- Make bezeichnung optional on shop_anfragen (if it exists)
|
||||
-- shop_anfragen doesn't have bezeichnung — it's on positionen. No change needed.
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 3. Issues tables
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issues (
|
||||
id SERIAL PRIMARY KEY,
|
||||
titel VARCHAR(500) NOT NULL,
|
||||
beschreibung TEXT,
|
||||
typ VARCHAR(50) NOT NULL DEFAULT 'bug'
|
||||
CHECK (typ IN ('bug', 'feature', 'sonstiges')),
|
||||
prioritaet VARCHAR(20) NOT NULL DEFAULT 'mittel'
|
||||
CHECK (prioritaet IN ('niedrig', 'mittel', 'hoch')),
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'offen'
|
||||
CHECK (status IN ('offen', 'in_bearbeitung', 'erledigt', 'abgelehnt')),
|
||||
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
zugewiesen_an UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_erstellt_von ON issues(erstellt_von);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_issues_typ ON issues(typ);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS issue_kommentare (
|
||||
id SERIAL PRIMARY KEY,
|
||||
issue_id INT NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
|
||||
autor_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
inhalt TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_issue_kommentare_issue ON issue_kommentare(issue_id);
|
||||
|
||||
-- Auto-update trigger for issues
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_issues_updated') THEN
|
||||
CREATE TRIGGER trg_issues_updated BEFORE UPDATE ON issues
|
||||
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am();
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 4. Issues feature group + permissions
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
INSERT INTO feature_groups (id, label, sort_order) VALUES
|
||||
('issues', 'Issues', 13)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('issues:create', 'issues', 'Erstellen', 'Issues erstellen', 1),
|
||||
('issues:view_own', 'issues', 'Eigene ansehen', 'Eigene Issues einsehen', 2),
|
||||
('issues:view_all', 'issues', 'Alle ansehen', 'Alle Issues einsehen', 3),
|
||||
('issues:manage', 'issues', 'Verwalten', 'Issues bearbeiten, Status ändern, zuweisen', 4)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Seed: all groups get create + view_own; kommando gets all
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
-- Kommando: full access
|
||||
('dashboard_kommando', 'issues:create'),
|
||||
('dashboard_kommando', 'issues:view_own'),
|
||||
('dashboard_kommando', 'issues:view_all'),
|
||||
('dashboard_kommando', 'issues:manage'),
|
||||
-- Fahrmeister
|
||||
('dashboard_fahrmeister', 'issues:create'),
|
||||
('dashboard_fahrmeister', 'issues:view_own'),
|
||||
-- Zeugmeister
|
||||
('dashboard_zeugmeister', 'issues:create'),
|
||||
('dashboard_zeugmeister', 'issues:view_own'),
|
||||
-- Chargen
|
||||
('dashboard_chargen', 'issues:create'),
|
||||
('dashboard_chargen', 'issues:view_own'),
|
||||
-- Moderator
|
||||
('dashboard_moderator', 'issues:create'),
|
||||
('dashboard_moderator', 'issues:view_own'),
|
||||
-- Atemschutz
|
||||
('dashboard_atemschutz', 'issues:create'),
|
||||
('dashboard_atemschutz', 'issues:view_own'),
|
||||
-- Mitglied
|
||||
('dashboard_mitglied', 'issues:create'),
|
||||
('dashboard_mitglied', 'issues:view_own')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
-- 5. New shop permissions
|
||||
-- ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('shop:view_overview', 'shop', 'Übersicht', 'Aggregierte Übersicht aller Anfragen', 7),
|
||||
('shop:order_for_user', 'shop', 'Für Benutzer bestellen', 'Anfragen im Namen anderer erstellen', 8)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Kommando gets the new permissions
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
('dashboard_kommando', 'shop:view_overview'),
|
||||
('dashboard_kommando', 'shop:order_for_user')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -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;
|
||||
223
backend/src/database/migrations/045_add_permissions_batch.sql
Normal file
223
backend/src/database/migrations/045_add_permissions_batch.sql
Normal 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');
|
||||
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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 $$;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
125
backend/src/database/migrations/053_issues_rework.sql
Normal file
125
backend/src/database/migrations/053_issues_rework.sql
Normal 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';
|
||||
@@ -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';
|
||||
@@ -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 $$;
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
11
backend/src/database/migrations/059_issue_historie.sql
Normal file
11
backend/src/database/migrations/059_issue_historie.sql
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
14
backend/src/database/migrations/061_add_laufende_nummer.sql
Normal file
14
backend/src/database/migrations/061_add_laufende_nummer.sql
Normal 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;
|
||||
22
backend/src/database/migrations/062_buchungs_kategorien.sql
Normal file
22
backend/src/database/migrations/062_buchungs_kategorien.sql
Normal 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;
|
||||
@@ -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'
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
46
backend/src/database/migrations/067_fahrzeug_typen.sql
Normal file
46
backend/src/database/migrations/067_fahrzeug_typen.sql
Normal 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;
|
||||
113
backend/src/database/migrations/068_checklisten.sql
Normal file
113
backend/src/database/migrations/068_checklisten.sql
Normal 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 $$;
|
||||
@@ -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;
|
||||
62
backend/src/database/migrations/070_ausruestung_typen.sql
Normal file
62
backend/src/database/migrations/070_ausruestung_typen.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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'));
|
||||
11
backend/src/database/migrations/073_checklist_subitems.sql
Normal file
11
backend/src/database/migrations/073_checklist_subitems.sql
Normal 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);
|
||||
70
backend/src/database/migrations/074_checklist_multi_type.sql
Normal file
70
backend/src/database/migrations/074_checklist_multi_type.sql
Normal 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 $$;
|
||||
231
backend/src/database/migrations/075_buchhaltung_schema.sql
Normal file
231
backend/src/database/migrations/075_buchhaltung_schema.sql
Normal 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();
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Convert kontonummer from TEXT to INTEGER
|
||||
ALTER TABLE buchhaltung_konten
|
||||
ALTER COLUMN kontonummer TYPE INTEGER USING kontonummer::INTEGER;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Categories for Buchhaltung Konten
|
||||
CREATE TABLE IF NOT EXISTS buchhaltung_kategorien (
|
||||
id SERIAL PRIMARY KEY,
|
||||
haushaltsjahr_id INT NOT NULL REFERENCES buchhaltung_haushaltsjahre(id),
|
||||
bezeichnung TEXT NOT NULL,
|
||||
sortierung INT DEFAULT 0,
|
||||
erstellt_am TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE buchhaltung_konten ADD COLUMN IF NOT EXISTS kategorie_id INT REFERENCES buchhaltung_kategorien(id);
|
||||
|
||||
-- Recurring transaction execution day configuration
|
||||
ALTER TABLE buchhaltung_wiederkehrend ADD COLUMN IF NOT EXISTS ausfuehrungstag TEXT DEFAULT 'erster' CHECK (ausfuehrungstag IN ('erster', 'mitte', 'letzter'));
|
||||
ALTER TABLE buchhaltung_wiederkehrend ADD COLUMN IF NOT EXISTS ausfuehrungs_monat INT CHECK (ausfuehrungs_monat BETWEEN 1 AND 12);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Add budget type support to buchhaltung_konten
|
||||
ALTER TABLE buchhaltung_konten
|
||||
ADD COLUMN IF NOT EXISTS budget_typ TEXT NOT NULL DEFAULT 'detailliert';
|
||||
|
||||
ALTER TABLE buchhaltung_konten
|
||||
ADD COLUMN IF NOT EXISTS budget_gesamt NUMERIC(12,2) NOT NULL DEFAULT 0;
|
||||
|
||||
-- Erstattung (reimbursement) linking table
|
||||
CREATE TABLE IF NOT EXISTS buchhaltung_erstattung_zuordnungen (
|
||||
erstattung_transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE,
|
||||
ausgabe_transaktion_id INT NOT NULL REFERENCES buchhaltung_transaktionen(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (erstattung_transaktion_id, ausgabe_transaktion_id)
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Migration 081: Add alert_threshold to buchhaltung_konten + default setting
|
||||
|
||||
ALTER TABLE buchhaltung_konten
|
||||
ADD COLUMN IF NOT EXISTS alert_threshold INT CHECK (alert_threshold BETWEEN 0 AND 100);
|
||||
|
||||
INSERT INTO buchhaltung_einstellungen (key, value)
|
||||
VALUES ('default_alert_threshold', '"80"')
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
18
backend/src/database/migrations/082_buchhaltung_transfer.sql
Normal file
18
backend/src/database/migrations/082_buchhaltung_transfer.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Migration 082: Add 'transfer' transaction type + target bank account column
|
||||
|
||||
-- 1. Drop and recreate the typ CHECK constraint to include 'transfer'
|
||||
ALTER TABLE buchhaltung_transaktionen
|
||||
DROP CONSTRAINT IF EXISTS buchhaltung_transaktionen_typ_check;
|
||||
|
||||
ALTER TABLE buchhaltung_transaktionen
|
||||
ADD CONSTRAINT buchhaltung_transaktionen_typ_check
|
||||
CHECK (typ IN ('einnahme', 'ausgabe', 'transfer'));
|
||||
|
||||
-- 2. Add target bank account column for transfers
|
||||
ALTER TABLE buchhaltung_transaktionen
|
||||
ADD COLUMN IF NOT EXISTS transfer_ziel_bankkonto_id INT
|
||||
REFERENCES buchhaltung_bankkonten(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_buch_trans_transfer_ziel
|
||||
ON buchhaltung_transaktionen(transfer_ziel_bankkonto_id)
|
||||
WHERE transfer_ziel_bankkonto_id IS NOT NULL;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Migration: 083_allow_null_profile_status
|
||||
-- Allow mitglieder_profile.status to be NULL (for FDISK data purge).
|
||||
-- Rollback:
|
||||
-- ALTER TABLE mitglieder_profile DROP CONSTRAINT IF EXISTS mitglieder_profile_status_check;
|
||||
-- ALTER TABLE mitglieder_profile ALTER COLUMN status SET NOT NULL;
|
||||
-- ALTER TABLE mitglieder_profile ALTER COLUMN status SET DEFAULT 'aktiv';
|
||||
-- ALTER TABLE mitglieder_profile ADD CONSTRAINT mitglieder_profile_status_check
|
||||
-- CHECK (status IN ('aktiv','passiv','ehrenmitglied','jugendfeuerwehr','anwärter','ausgetreten'));
|
||||
|
||||
-- 1. Drop existing CHECK constraint
|
||||
ALTER TABLE mitglieder_profile DROP CONSTRAINT IF EXISTS mitglieder_profile_status_check;
|
||||
|
||||
-- 2. Allow NULLs
|
||||
ALTER TABLE mitglieder_profile ALTER COLUMN status DROP NOT NULL;
|
||||
|
||||
-- 3. Remove default
|
||||
ALTER TABLE mitglieder_profile ALTER COLUMN status DROP DEFAULT;
|
||||
|
||||
-- 4. Re-add CHECK allowing NULL
|
||||
ALTER TABLE mitglieder_profile ADD CONSTRAINT mitglieder_profile_status_check
|
||||
CHECK (status IS NULL OR status IN (
|
||||
'aktiv',
|
||||
'passiv',
|
||||
'ehrenmitglied',
|
||||
'jugendfeuerwehr',
|
||||
'anwärter',
|
||||
'ausgetreten'
|
||||
));
|
||||
@@ -0,0 +1,85 @@
|
||||
-- Migration: 084_persoenliche_ausruestung
|
||||
-- Creates persoenliche_ausruestung table, adds assignment columns to
|
||||
-- ausruestung_anfrage_positionen, and seeds feature_group + permissions.
|
||||
|
||||
-- 1. Create persoenliche_ausruestung table
|
||||
CREATE TABLE IF NOT EXISTS persoenliche_ausruestung (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bezeichnung TEXT NOT NULL,
|
||||
kategorie TEXT,
|
||||
artikel_id INT REFERENCES ausruestung_artikel(id) ON DELETE SET NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
benutzer_name TEXT,
|
||||
groesse TEXT,
|
||||
seriennummer TEXT,
|
||||
inventarnummer TEXT,
|
||||
anschaffung_datum DATE,
|
||||
zustand TEXT DEFAULT 'gut' CHECK (zustand IN ('gut','beschaedigt','abgaengig','verloren')),
|
||||
notizen TEXT,
|
||||
anfrage_id INT REFERENCES ausruestung_anfragen(id) ON DELETE SET NULL,
|
||||
anfrage_position_id INT REFERENCES ausruestung_anfrage_positionen(id) ON DELETE SET NULL,
|
||||
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
erstellt_am TIMESTAMPTZ DEFAULT NOW(),
|
||||
aktualisiert_am TIMESTAMPTZ DEFAULT NOW(),
|
||||
geloescht_am TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persoenliche_ausruestung_user
|
||||
ON persoenliche_ausruestung(user_id) WHERE geloescht_am IS NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_persoenliche_ausruestung_artikel
|
||||
ON persoenliche_ausruestung(artikel_id);
|
||||
|
||||
-- Auto-update aktualisiert_am trigger (uses the aktualisiert_am variant from migration 018)
|
||||
-- DROP first to make this idempotent — previous failed migration runs may have committed the DDL
|
||||
-- without recording the migration (pool.query BEGIN/ROLLBACK does not guarantee same connection).
|
||||
DROP TRIGGER IF EXISTS trg_persoenliche_ausruestung_aktualisiert_am ON persoenliche_ausruestung;
|
||||
CREATE TRIGGER trg_persoenliche_ausruestung_aktualisiert_am
|
||||
BEFORE UPDATE ON persoenliche_ausruestung
|
||||
FOR EACH ROW EXECUTE FUNCTION update_aktualisiert_am_column();
|
||||
|
||||
-- 2. Add assignment columns to ausruestung_anfrage_positionen
|
||||
ALTER TABLE ausruestung_anfrage_positionen
|
||||
ADD COLUMN IF NOT EXISTS zuweisung_typ TEXT CHECK (zuweisung_typ IN ('ausruestung','persoenlich','keine'));
|
||||
|
||||
ALTER TABLE ausruestung_anfrage_positionen
|
||||
ADD COLUMN IF NOT EXISTS zuweisung_ausruestung_id UUID REFERENCES ausruestung(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ausruestung_anfrage_positionen
|
||||
ADD COLUMN IF NOT EXISTS zuweisung_persoenlich_id UUID REFERENCES persoenliche_ausruestung(id) ON DELETE SET NULL;
|
||||
|
||||
-- 3. Feature group + permissions
|
||||
INSERT INTO feature_groups (id, label, sort_order)
|
||||
VALUES ('persoenliche_ausruestung', 'Persönliche Ausrüstung', 99)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('persoenliche_ausruestung:view', 'persoenliche_ausruestung', 'Anzeigen (eigene)', 'Eigene persönliche Ausrüstung anzeigen', 1),
|
||||
('persoenliche_ausruestung:view_all', 'persoenliche_ausruestung', 'Anzeigen (alle)', 'Alle persönliche Ausrüstung anzeigen', 2),
|
||||
('persoenliche_ausruestung:create', 'persoenliche_ausruestung', 'Erstellen', 'Persönliche Ausrüstung erstellen', 3),
|
||||
('persoenliche_ausruestung:edit', 'persoenliche_ausruestung', 'Bearbeiten', 'Persönliche Ausrüstung bearbeiten', 4),
|
||||
('persoenliche_ausruestung:delete', 'persoenliche_ausruestung', 'Löschen', 'Persönliche Ausrüstung löschen', 5)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Seed permissions for groups: admin, kommandant, gruppenkommandant, zeugmeister get all; others get view only
|
||||
INSERT INTO group_permissions (authentik_group, permission_id)
|
||||
SELECT g.name, p.id
|
||||
FROM (VALUES
|
||||
('dashboard_admin'),
|
||||
('dashboard_kommandant'),
|
||||
('dashboard_gruppenkommandant'),
|
||||
('dashboard_zeugmeister')
|
||||
) AS g(name)
|
||||
CROSS JOIN permissions p
|
||||
WHERE p.feature_group_id = 'persoenliche_ausruestung'
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- All other groups get view only
|
||||
INSERT INTO group_permissions (authentik_group, permission_id)
|
||||
SELECT g.name, 'persoenliche_ausruestung:view'
|
||||
FROM (VALUES
|
||||
('dashboard_feuerwehrmitglied'),
|
||||
('dashboard_atemschutztraeger'),
|
||||
('dashboard_fahrmeister'),
|
||||
('dashboard_jugend')
|
||||
) AS g(name)
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- Migration: 085_personal_equipment_eigenschaften
|
||||
-- Adds eigenschaften (characteristics) storage for persoenliche_ausruestung
|
||||
-- and extends ausruestung_anfrage_positionen for status change requests.
|
||||
|
||||
-- 1. Characteristics table for personal equipment items
|
||||
CREATE TABLE IF NOT EXISTS persoenliche_ausruestung_eigenschaften (
|
||||
id SERIAL PRIMARY KEY,
|
||||
persoenlich_id UUID NOT NULL REFERENCES persoenliche_ausruestung(id) ON DELETE CASCADE,
|
||||
eigenschaft_id INT REFERENCES ausruestung_artikel_eigenschaften(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL,
|
||||
wert TEXT NOT NULL,
|
||||
UNIQUE(persoenlich_id, eigenschaft_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_persoenliche_ausruestung_eigenschaften_persoenlich
|
||||
ON persoenliche_ausruestung_eigenschaften(persoenlich_id);
|
||||
|
||||
-- 2. Add status-change request columns to anfrage positions
|
||||
ALTER TABLE ausruestung_anfrage_positionen
|
||||
ADD COLUMN IF NOT EXISTS persoenlich_id UUID REFERENCES persoenliche_ausruestung(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ausruestung_anfrage_positionen
|
||||
ADD COLUMN IF NOT EXISTS aktueller_zustand TEXT;
|
||||
|
||||
ALTER TABLE ausruestung_anfrage_positionen
|
||||
ADD COLUMN IF NOT EXISTS neuer_zustand TEXT CHECK (neuer_zustand IN ('gut','beschaedigt','abgaengig','verloren'));
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Add Kursnummer, Kurzbezeichnung, and Erfolgscode columns to ausbildung table
|
||||
-- These fields are scraped from FDISK's 5-column Ausbildungen detail page
|
||||
ALTER TABLE ausbildung
|
||||
ADD COLUMN IF NOT EXISTS kursnummer VARCHAR(32),
|
||||
ADD COLUMN IF NOT EXISTS kurs_kurzbezeichnung VARCHAR(32),
|
||||
ADD COLUMN IF NOT EXISTS erfolgscode VARCHAR(64);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS sync_source VARCHAR(16) DEFAULT NULL;
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Migration 088: Add 'Sachbearbeiter' to mitglieder_profile dienstgrad CHECK constraint.
|
||||
-- 'SB' is a valid FDISK Dienstgrad abbreviation that was missing from the allowed list,
|
||||
-- causing the FDISK sync to fail when a member's current rank is Sachbearbeiter.
|
||||
|
||||
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',
|
||||
'Sachbearbeiter',
|
||||
-- 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'
|
||||
));
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add optional mitglied_id (the member an order is for) to bestellungen
|
||||
ALTER TABLE bestellungen
|
||||
ADD COLUMN IF NOT EXISTS mitglied_id UUID REFERENCES users(id) ON DELETE SET NULL;
|
||||
@@ -0,0 +1,66 @@
|
||||
-- Migration 090: Add 'Abschnittssachbearbeiter' and remove non-existent ranks
|
||||
-- (Feuerwehranwärter, Feuerwehrfrau variants, Brandoberinspektor, Brandamtmann)
|
||||
-- from the mitglieder_profile dienstgrad CHECK constraint.
|
||||
|
||||
-- Null out any existing rows with ranks that are being removed
|
||||
UPDATE mitglieder_profile
|
||||
SET dienstgrad = NULL
|
||||
WHERE dienstgrad IN (
|
||||
'Feuerwehranwärter',
|
||||
'Feuerwehrfrau',
|
||||
'Oberfeuerwehrfrau',
|
||||
'Hauptfeuerwehrfrau',
|
||||
'Brandoberinspektor',
|
||||
'Brandamtmann',
|
||||
'Ehren-Feuerwehrfrau',
|
||||
'Ehren-Oberfeuerwehrfrau',
|
||||
'Ehren-Hauptfeuerwehrfrau',
|
||||
'Ehren-Brandoberinspektor',
|
||||
'Ehren-Brandamtmann'
|
||||
);
|
||||
|
||||
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
|
||||
'Jugendfeuerwehrmann',
|
||||
'Probefeuerwehrmann',
|
||||
'Feuerwehrmann',
|
||||
'Oberfeuerwehrmann',
|
||||
'Hauptfeuerwehrmann',
|
||||
'Löschmeister',
|
||||
'Oberlöschmeister',
|
||||
'Hauptlöschmeister',
|
||||
'Brandmeister',
|
||||
'Oberbrandmeister',
|
||||
'Hauptbrandmeister',
|
||||
'Brandinspektor',
|
||||
'Oberbrandinspektor',
|
||||
'Verwaltungsmeister',
|
||||
'Oberverwaltungsmeister',
|
||||
'Hauptverwaltungsmeister',
|
||||
'Verwalter',
|
||||
'Sachbearbeiter',
|
||||
'Abschnittssachbearbeiter',
|
||||
-- Ehrendienstgrade
|
||||
'Ehren-Feuerwehrmann',
|
||||
'Ehren-Oberfeuerwehrmann',
|
||||
'Ehren-Hauptfeuerwehrmann',
|
||||
'Ehren-Löschmeister',
|
||||
'Ehren-Oberlöschmeister',
|
||||
'Ehren-Hauptlöschmeister',
|
||||
'Ehren-Brandmeister',
|
||||
'Ehren-Oberbrandmeister',
|
||||
'Ehren-Hauptbrandmeister',
|
||||
'Ehren-Brandinspektor',
|
||||
'Ehren-Oberbrandinspektor',
|
||||
'Ehren-Verwaltungsmeister',
|
||||
'Ehren-Oberverwaltungsmeister',
|
||||
'Ehren-Hauptverwaltungsmeister',
|
||||
'Ehren-Verwalter',
|
||||
'Ehren-Sachbearbeiter',
|
||||
'Ehren-Abschnittssachbearbeiter'
|
||||
));
|
||||
16
backend/src/database/migrations/090_update_status_values.sql
Normal file
16
backend/src/database/migrations/090_update_status_values.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Migration: 090_update_status_values
|
||||
-- Replace old status values with FDISK-aligned values: aktiv, kind, jugend, reserve.
|
||||
-- Old values passiv, ehrenmitglied, jugendfeuerwehr, anwärter, ausgetreten are removed.
|
||||
-- Idempotent: safe to run multiple times.
|
||||
|
||||
-- 1. Drop existing CHECK constraint
|
||||
ALTER TABLE mitglieder_profile DROP CONSTRAINT IF EXISTS mitglieder_profile_status_check;
|
||||
|
||||
-- 2. Migrate existing data
|
||||
UPDATE mitglieder_profile SET status = 'jugend' WHERE status = 'jugendfeuerwehr';
|
||||
UPDATE mitglieder_profile SET status = NULL
|
||||
WHERE status IN ('passiv', 'ehrenmitglied', 'anwärter', 'ausgetreten');
|
||||
|
||||
-- 3. Re-add CHECK with new allowed values (NULL still allowed for profiles without FDISK sync)
|
||||
ALTER TABLE mitglieder_profile ADD CONSTRAINT mitglieder_profile_status_check
|
||||
CHECK (status IS NULL OR status IN ('aktiv', 'kind', 'jugend', 'reserve'));
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Migration: 091_personal_equipment_configurable_zustand
|
||||
-- Makes the zustand field on persoenliche_ausruestung admin-configurable
|
||||
-- by removing the hard-coded CHECK constraint and seeding default options
|
||||
-- into app_settings.
|
||||
|
||||
-- 1. Drop the hard-coded CHECK constraint
|
||||
ALTER TABLE persoenliche_ausruestung DROP CONSTRAINT IF EXISTS persoenliche_ausruestung_zustand_check;
|
||||
|
||||
-- 2. Seed default zustand options into app_settings
|
||||
INSERT INTO app_settings (key, value)
|
||||
VALUES (
|
||||
'personal_equipment_zustand_options',
|
||||
'[{"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"}]'
|
||||
)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -0,0 +1,29 @@
|
||||
-- =============================================================================
|
||||
-- Migration 092: Tool Configure Permissions + Nextcloud Feature Group
|
||||
-- =============================================================================
|
||||
|
||||
-- Feature group: nextcloud
|
||||
INSERT INTO feature_groups (id, label, sort_order)
|
||||
VALUES ('nextcloud', 'Nextcloud', 11)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Configure permissions for wissen, vikunja, nextcloud
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('wissen:configure', 'wissen', 'Konfigurieren', 'BookStack-Verbindung konfigurieren', 10),
|
||||
('vikunja:configure', 'vikunja', 'Konfigurieren', 'Vikunja-Verbindung konfigurieren', 10),
|
||||
('nextcloud:configure', 'nextcloud', 'Konfigurieren', 'Nextcloud-Verbindung konfigurieren', 1)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Grant all 3 configure permissions to dashboard_kommando
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
('dashboard_kommando', 'wissen:configure'),
|
||||
('dashboard_kommando', 'vikunja:configure'),
|
||||
('dashboard_kommando', 'nextcloud:configure')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Seed default app_settings for tool configs
|
||||
INSERT INTO app_settings (key, value) VALUES
|
||||
('tool_config_bookstack', '{}'::jsonb),
|
||||
('tool_config_vikunja', '{}'::jsonb),
|
||||
('tool_config_nextcloud', '{}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- =============================================================================
|
||||
-- Migration 093: Module Configure Permissions (kalender, fahrzeuge)
|
||||
-- =============================================================================
|
||||
|
||||
-- Configure permissions for kalender, fahrzeuge
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('kalender:configure', 'kalender', 'Konfigurieren', 'Kalender-Kategorien verwalten', 10),
|
||||
('fahrzeuge:configure', 'fahrzeuge', 'Konfigurieren', 'Fahrzeugtypen verwalten', 10)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Grant both configure permissions to dashboard_kommando
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
('dashboard_kommando', 'kalender:configure'),
|
||||
('dashboard_kommando', 'fahrzeuge:configure')
|
||||
ON CONFLICT DO NOTHING;
|
||||
42
backend/src/database/migrations/094_scheduled_messages.sql
Normal file
42
backend/src/database/migrations/094_scheduled_messages.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- Migration 094: Scheduled Messages tables
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduled_message_rules (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
message_type TEXT NOT NULL,
|
||||
-- message_type values: event_summary | birthday_list | dienstjubilaeen |
|
||||
-- fahrzeug_status | fahrzeug_event | bestellungen
|
||||
trigger_mode TEXT NOT NULL,
|
||||
-- trigger_mode values: day_of_week | days_before_month_start | event
|
||||
day_of_week INT, -- 0-6, used when trigger_mode = day_of_week
|
||||
send_time TIME, -- used when trigger_mode = day_of_week
|
||||
days_before_month_start INT, -- used when trigger_mode = days_before_month_start
|
||||
window_mode TEXT, -- rolling | calendar_month | NULL for event types
|
||||
window_days INT, -- used when window_mode = rolling
|
||||
target_room_token TEXT NOT NULL,
|
||||
target_room_name TEXT,
|
||||
template TEXT NOT NULL,
|
||||
extra_config JSONB, -- e.g. {"min_days_overdue": 14} for bestellungen
|
||||
subscribable BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
allowed_groups TEXT[], -- Authentik group names allowed to subscribe
|
||||
last_sent_at DATE,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduled_message_subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
rule_id UUID NOT NULL REFERENCES scheduled_message_rules(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
room_token TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (rule_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_smr_active_trigger
|
||||
ON scheduled_message_rules(active, trigger_mode);
|
||||
CREATE INDEX IF NOT EXISTS idx_sms_rule_id
|
||||
ON scheduled_message_subscriptions(rule_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sms_user_id
|
||||
ON scheduled_message_subscriptions(user_id);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Migration 095: Scheduled Messages permissions
|
||||
|
||||
INSERT INTO feature_groups (id, label, sort_order)
|
||||
VALUES ('scheduled_messages', 'Geplante Nachrichten', 12)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO permissions (id, feature_group_id, label, description, sort_order) VALUES
|
||||
('scheduled_messages:view', 'scheduled_messages', 'Ansehen', 'Geplante Nachrichten ansehen', 1),
|
||||
('scheduled_messages:edit', 'scheduled_messages', 'Verwalten', 'Automationen verwalten', 2),
|
||||
('scheduled_messages:subscribe', 'scheduled_messages', 'Abonnieren', 'Nachrichten abonnieren', 3)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- Only dashboard_admin gets defaults; other groups assigned manually on live system
|
||||
INSERT INTO group_permissions (authentik_group, permission_id) VALUES
|
||||
('dashboard_admin', 'scheduled_messages:view'),
|
||||
('dashboard_admin', 'scheduled_messages:edit'),
|
||||
('dashboard_admin', 'scheduled_messages:subscribe')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Migration 096: Add im_haus to internal request positions and FK back from external order positions
|
||||
--
|
||||
-- im_haus (ausruestung_anfrage_positionen): auto-set when external bestellposition is received
|
||||
-- anfrage_position_id (bestellpositionen): explicit link so receipt can sync back
|
||||
|
||||
ALTER TABLE ausruestung_anfrage_positionen
|
||||
ADD COLUMN IF NOT EXISTS im_haus BOOLEAN DEFAULT false;
|
||||
|
||||
ALTER TABLE bestellpositionen
|
||||
ADD COLUMN IF NOT EXISTS anfrage_position_id INT
|
||||
REFERENCES ausruestung_anfrage_positionen(id) ON DELETE SET NULL;
|
||||
16
backend/src/database/migrations/096_one_time_messages.sql
Normal file
16
backend/src/database/migrations/096_one_time_messages.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- Migration 096: One-time scheduled messages
|
||||
-- Lightweight table for single-delivery messages at a specific datetime.
|
||||
-- Rows are deleted automatically by the job after successful send.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS scheduled_one_time_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message TEXT NOT NULL,
|
||||
target_room_token TEXT NOT NULL,
|
||||
target_room_name TEXT,
|
||||
send_at TIMESTAMPTZ NOT NULL,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sotm_send_at
|
||||
ON scheduled_one_time_messages(send_at);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Migration: 097_add_lehrgang_theorie_only
|
||||
-- Adds a flag to distinguish theory-only AT20 completion from full lehrgang.
|
||||
-- AT20 "mit Erfolg Theorie" = theory passed but not equipment-eligible.
|
||||
|
||||
ALTER TABLE atemschutz_traeger
|
||||
ADD COLUMN IF NOT EXISTS lehrgang_theorie_only BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- The atemschutz_uebersicht view uses at.* so it picks up the new column automatically.
|
||||
19
backend/src/database/migrations/098_integration_settings.sql
Normal file
19
backend/src/database/migrations/098_integration_settings.sql
Normal file
@@ -0,0 +1,19 @@
|
||||
-- Migration: 098_integration_settings
|
||||
-- Seeds integration URL and credential keys into app_settings so admins
|
||||
-- can configure them via the GUI instead of requiring env var changes.
|
||||
-- Empty JSON string '""' means "use env var fallback".
|
||||
|
||||
INSERT INTO app_settings (key, value) VALUES ('integration_bookstack_url', '""') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO app_settings (key, value) VALUES ('integration_nextcloud_url', '""') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO app_settings (key, value) VALUES ('integration_vikunja_url', '""') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO app_settings (key, value) VALUES ('integration_ical_base_url', '""') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO app_settings (key, value) VALUES ('integration_bookstack_token_id', '""') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO app_settings (key, value) VALUES ('integration_bookstack_token_secret', '""') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO app_settings (key, value) VALUES ('integration_vikunja_api_token', '""') ON CONFLICT (key) DO NOTHING;
|
||||
|
||||
INSERT INTO app_settings (key, value) VALUES ('fdisk_base_url', '""') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO app_settings (key, value) VALUES ('fdisk_id_feuerwehren', '""') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO app_settings (key, value) VALUES ('fdisk_id_instanzen', '""') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO app_settings (key, value) VALUES ('fdisk_username', '""') ON CONFLICT (key) DO NOTHING;
|
||||
INSERT INTO app_settings (key, value) VALUES ('fdisk_password', '""') ON CONFLICT (key) DO NOTHING;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE checklist_faelligkeit ADD COLUMN IF NOT EXISTS verfuegbar_ab DATE;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE persoenliche_ausruestung ADD COLUMN IF NOT EXISTS menge INT NOT NULL DEFAULT 1;
|
||||
126
backend/src/jobs/buchhaltung-recurring.job.ts
Normal file
126
backend/src/jobs/buchhaltung-recurring.job.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import pool from '../config/database';
|
||||
import buchhaltungService from '../services/buchhaltung.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const INTERVAL_MS = 60 * 60 * 1000; // hourly
|
||||
let jobInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let isRunning = false;
|
||||
|
||||
/** Advance a date by N months, then apply ausfuehrungstag. */
|
||||
function advanceDate(base: Date, months: number, ausfuehrungstag: 'erster' | 'mitte' | 'letzter'): Date {
|
||||
const d = new Date(base);
|
||||
d.setMonth(d.getMonth() + months);
|
||||
if (ausfuehrungstag === 'erster') {
|
||||
d.setDate(1);
|
||||
} else if (ausfuehrungstag === 'mitte') {
|
||||
d.setDate(15);
|
||||
} else {
|
||||
// last day of the month
|
||||
d.setMonth(d.getMonth() + 1, 0);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
async function runRecurringCheck(): Promise<void> {
|
||||
if (isRunning) {
|
||||
logger.warn('BuchhaltungRecurringJob: previous run still in progress — skipping');
|
||||
return;
|
||||
}
|
||||
isRunning = true;
|
||||
try {
|
||||
const dueResult = await pool.query(
|
||||
`SELECT * FROM buchhaltung_wiederkehrend WHERE aktiv = TRUE AND naechste_ausfuehrung <= CURRENT_DATE`
|
||||
);
|
||||
|
||||
if (dueResult.rows.length === 0) {
|
||||
isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const haushaltsjahr = await buchhaltungService.getCurrentHaushaltsjahr();
|
||||
if (!haushaltsjahr) {
|
||||
logger.warn('BuchhaltungRecurringJob: no open fiscal year — skipping');
|
||||
isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let processed = 0;
|
||||
for (const template of dueResult.rows) {
|
||||
try {
|
||||
// Create transaction directly to set wiederkehrend_id
|
||||
const txResult = await pool.query(
|
||||
`INSERT INTO buchhaltung_transaktionen
|
||||
(haushaltsjahr_id, konto_id, bankkonto_id, typ, betrag, datum, beschreibung,
|
||||
empfaenger_auftraggeber, erstellt_von, wiederkehrend_id, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'entwurf')
|
||||
RETURNING *`,
|
||||
[
|
||||
haushaltsjahr.id,
|
||||
template.konto_id,
|
||||
template.bankkonto_id,
|
||||
template.typ,
|
||||
template.betrag,
|
||||
template.naechste_ausfuehrung,
|
||||
template.beschreibung,
|
||||
template.empfaenger_auftraggeber,
|
||||
template.erstellt_von,
|
||||
template.id,
|
||||
]
|
||||
);
|
||||
const tx = txResult.rows[0];
|
||||
if (tx) {
|
||||
await buchhaltungService.logAudit(tx.id, 'erstellt_wiederkehrend', { wiederkehrend_id: template.id }, template.erstellt_von);
|
||||
}
|
||||
|
||||
// Advance naechste_ausfuehrung
|
||||
const monthsMap: Record<string, number> = {
|
||||
monatlich: 1,
|
||||
quartalsweise: 3,
|
||||
halbjaehrlich: 6,
|
||||
jaehrlich: 12,
|
||||
};
|
||||
const months = monthsMap[template.intervall] ?? 1;
|
||||
const nextDate = advanceDate(new Date(template.naechste_ausfuehrung), months, template.ausfuehrungstag);
|
||||
const nextDateStr = nextDate.toISOString().split('T')[0];
|
||||
|
||||
await pool.query(
|
||||
`UPDATE buchhaltung_wiederkehrend SET naechste_ausfuehrung = $1 WHERE id = $2`,
|
||||
[nextDateStr, template.id]
|
||||
);
|
||||
processed++;
|
||||
} catch (templateErr) {
|
||||
logger.error('BuchhaltungRecurringJob: failed to process template', {
|
||||
id: template.id,
|
||||
error: templateErr instanceof Error ? templateErr.message : String(templateErr),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`BuchhaltungRecurringJob: processed ${processed} recurring transactions`);
|
||||
} catch (error) {
|
||||
logger.error('BuchhaltungRecurringJob: unexpected error', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startBuchhaltungRecurringJob(): void {
|
||||
if (jobInterval !== null) {
|
||||
logger.warn('BuchhaltungRecurringJob: already running — skipping duplicate start');
|
||||
return;
|
||||
}
|
||||
// Delay initial run to let migrations settle
|
||||
setTimeout(() => runRecurringCheck(), 60_000);
|
||||
jobInterval = setInterval(() => runRecurringCheck(), INTERVAL_MS);
|
||||
logger.info('Buchhaltung recurring job scheduled (hourly)');
|
||||
}
|
||||
|
||||
export function stopBuchhaltungRecurringJob(): void {
|
||||
if (jobInterval !== null) {
|
||||
clearInterval(jobInterval);
|
||||
jobInterval = null;
|
||||
}
|
||||
logger.info('Buchhaltung recurring job stopped');
|
||||
}
|
||||
94
backend/src/jobs/checklist-reminder.job.ts
Normal file
94
backend/src/jobs/checklist-reminder.job.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import pool from '../config/database';
|
||||
import notificationService from '../services/notification.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
|
||||
let jobInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let isRunning = false;
|
||||
|
||||
async function runChecklistReminderCheck(): Promise<void> {
|
||||
if (isRunning) {
|
||||
logger.warn('ChecklistReminderJob: previous run still in progress — skipping');
|
||||
return;
|
||||
}
|
||||
isRunning = true;
|
||||
try {
|
||||
// Find overdue checklists (vehicles + equipment)
|
||||
const result = await pool.query(`
|
||||
SELECT cf.fahrzeug_id, cf.ausruestung_id, cf.vorlage_id, cf.naechste_faellig_am,
|
||||
f.bezeichnung AS fahrzeug_name,
|
||||
ar.name AS ausruestung_name,
|
||||
v.name AS vorlage_name
|
||||
FROM checklist_faelligkeit cf
|
||||
LEFT JOIN fahrzeuge f ON f.id = cf.fahrzeug_id AND f.deleted_at IS NULL
|
||||
LEFT JOIN ausruestung ar ON ar.id = cf.ausruestung_id
|
||||
JOIN checklist_vorlagen v ON v.id = cf.vorlage_id AND v.aktiv = true
|
||||
WHERE cf.naechste_faellig_am <= CURRENT_DATE
|
||||
AND (cf.fahrzeug_id IS NOT NULL OR cf.ausruestung_id IS NOT NULL)
|
||||
AND (cf.fahrzeug_id IS NULL OR f.id IS NOT NULL)
|
||||
`);
|
||||
|
||||
if (result.rows.length === 0) return;
|
||||
|
||||
// Find users who can execute checklists (Zeugmeister, Fahrmeister, Kommandant groups)
|
||||
const usersResult = await pool.query(`
|
||||
SELECT id FROM users
|
||||
WHERE authentik_groups && ARRAY['dashboard_zeugmeister', 'dashboard_fahrmeister', 'dashboard_kommando', 'dashboard_admin']::text[]
|
||||
`);
|
||||
|
||||
const targetUserIds = usersResult.rows.map((r: any) => r.id);
|
||||
if (targetUserIds.length === 0) return;
|
||||
|
||||
for (const row of result.rows) {
|
||||
const faelligDatum = new Date(row.naechste_faellig_am).toLocaleDateString('de-AT', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||
});
|
||||
|
||||
const targetName = row.fahrzeug_name || row.ausruestung_name || 'Unbekannt';
|
||||
const quellId = row.fahrzeug_id
|
||||
? `${row.fahrzeug_id}_${row.vorlage_id}`
|
||||
: `eq_${row.ausruestung_id}_${row.vorlage_id}`;
|
||||
|
||||
// Notify responsible users (dedup handled by quell_id)
|
||||
for (const userId of targetUserIds) {
|
||||
await notificationService.createNotification({
|
||||
user_id: userId,
|
||||
typ: 'checklist_faellig',
|
||||
titel: `Checkliste überfällig: ${row.vorlage_name}`,
|
||||
nachricht: `Die Checkliste "${row.vorlage_name}" für ${targetName} war fällig am ${faelligDatum}`,
|
||||
schwere: 'warnung',
|
||||
link: `/checklisten`,
|
||||
quell_id: quellId,
|
||||
quell_typ: 'checklist_faellig',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`ChecklistReminderJob: processed ${result.rows.length} overdue checklists`);
|
||||
} catch (error) {
|
||||
logger.error('ChecklistReminderJob: unexpected error', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
} finally {
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function startChecklistReminderJob(): void {
|
||||
if (jobInterval !== null) {
|
||||
logger.warn('Checklist reminder job already running — skipping duplicate start');
|
||||
return;
|
||||
}
|
||||
// Run once after short delay, then repeat
|
||||
setTimeout(() => runChecklistReminderCheck(), 75 * 1000);
|
||||
jobInterval = setInterval(() => runChecklistReminderCheck(), INTERVAL_MS);
|
||||
logger.info('Checklist reminder job scheduled (every 15 minutes)');
|
||||
}
|
||||
|
||||
export function stopChecklistReminderJob(): void {
|
||||
if (jobInterval !== null) {
|
||||
clearInterval(jobInterval);
|
||||
jobInterval = null;
|
||||
}
|
||||
logger.info('Checklist reminder job stopped');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user