Compare commits
360 Commits
b3a2fd9ff9
...
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 | ||
|
|
a04577ac9e | ||
|
|
c15d4a50e0 | ||
|
|
023bd7acbb | ||
|
|
d780a284d3 | ||
|
|
f3ad989a9e | ||
|
|
3c72fe627f | ||
|
|
550a5a4883 | ||
|
|
215528a521 | ||
|
|
5f329bb5c1 | ||
|
|
8d03c13bee | ||
|
|
992ca8e104 | ||
|
|
cf6b3ad2d6 | ||
|
|
789f27c37e | ||
|
|
ef9d2ff4a2 | ||
|
|
e666ff434e | ||
|
|
3171fe1ce5 | ||
|
|
8941dc7e09 | ||
|
|
7245cd577e | ||
|
|
b3266afbf8 | ||
|
|
0d4e7b480d | ||
|
|
461d28fa0d | ||
|
|
b7b4fe2fc9 | ||
|
|
ab29c43735 | ||
|
|
f009694da7 | ||
|
|
8f454905b9 | ||
|
|
f5d1f7b061 | ||
|
|
1b1a53cd8f | ||
|
|
37c719e983 | ||
|
|
c174edbb0b | ||
|
|
e1aa8fa59b | ||
|
|
bc6d09200a | ||
|
|
02d9d808b2 | ||
|
|
602d9fd5b9 | ||
|
|
bb6438a0b9 | ||
|
|
03155dcf7a | ||
|
|
75c919c063 | ||
|
|
3dda069611 | ||
|
|
d31f139d9a | ||
|
|
ff72daa55e | ||
|
|
165acfbece | ||
|
|
20d2c9093a | ||
|
|
7833dca29c | ||
|
|
8d9388ca9a | ||
|
|
02a5359c87 | ||
|
|
1cdfde0128 | ||
|
|
4c7c8f72d3 | ||
|
|
3ecae37d72 | ||
|
|
7215e7f472 | ||
|
|
3361f1e28d | ||
|
|
e26d77ef35 | ||
|
|
e36de3199a | ||
|
|
1d5122a2cd | ||
|
|
86bb8a45c1 | ||
|
|
072713ca3d | ||
|
|
9d68b4fb28 | ||
|
|
cfb70e62c7 | ||
|
|
618f1d4996 | ||
|
|
3c9ab02b93 | ||
|
|
e49b4f63ae | ||
|
|
5f0e76155f | ||
|
|
42b9937da4 | ||
|
|
2ec587ac97 | ||
|
|
7427d04cf9 | ||
|
|
98c01d8a30 | ||
|
|
80e7730c1e | ||
|
|
76327832d1 | ||
|
|
11fb533ad6 | ||
|
|
501b697ca2 | ||
|
|
243da302c7 | ||
|
|
60488309ca | ||
|
|
f309096497 | ||
|
|
34f246af24 | ||
|
|
d1fed74f3b | ||
|
|
dc33d388a9 | ||
|
|
67b7d5ccd2 | ||
|
|
34ca007f9b | ||
|
|
68586b01dc | ||
|
|
5aa309b97a | ||
|
|
a5cd78f01f | ||
|
|
81174c2498 | ||
|
|
21b7be22db | ||
|
|
6c1cbb0ef3 | ||
|
|
36c222d32a | ||
|
|
cf490cc9ad | ||
|
|
cd68bd3795 | ||
|
|
71a04aee89 | ||
|
|
d5be68ca63 | ||
|
|
31f1414e06 | ||
|
|
7b14e3d5ba | ||
|
|
34f577bf06 | ||
|
|
3c9b7d3446 | ||
|
|
93a87a7ae9 | ||
|
|
e9463c1c66 | ||
|
|
fb5acd3d52 | ||
|
|
d27d2931a5 | ||
|
|
179bbabd58 | ||
|
|
029a721c43 | ||
|
|
11335748c2 | ||
|
|
32473f8329 | ||
|
|
926f79b576 | ||
|
|
d3561c1109 | ||
|
|
817329db70 | ||
|
|
5a6fc85a75 | ||
|
|
92b05726d4 | ||
|
|
c431c1af83 |
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.
|
||||
63
.env.example
63
.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
|
||||
@@ -159,6 +159,51 @@ AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback
|
||||
# Used by the backend for Nextcloud integration
|
||||
NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at
|
||||
|
||||
# ============================================================================
|
||||
# BOOKSTACK CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
# BookStack base URL
|
||||
# The URL of your BookStack instance (without trailing slash)
|
||||
BOOKSTACK_URL=https://docs.feuerwehr-rems.at
|
||||
|
||||
# BookStack API Token ID
|
||||
# Create via BookStack user profile → API Tokens
|
||||
BOOKSTACK_TOKEN_ID=your_bookstack_token_id
|
||||
|
||||
# BookStack API Token Secret
|
||||
# Create via BookStack user profile → API Tokens
|
||||
# WARNING: Keep this secret!
|
||||
BOOKSTACK_TOKEN_SECRET=your_bookstack_token_secret
|
||||
|
||||
# ============================================================================
|
||||
# VIKUNJA CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
# Vikunja base URL
|
||||
# The URL of your Vikunja instance (without trailing slash)
|
||||
VIKUNJA_URL=https://tasks.feuerwehr-rems.at
|
||||
|
||||
# Vikunja API Token
|
||||
# Create via Vikunja user settings → API Tokens
|
||||
# WARNING: Keep this secret!
|
||||
VIKUNJA_API_TOKEN=your_vikunja_api_token
|
||||
|
||||
# ============================================================================
|
||||
# FDISK SYNC CONFIGURATION
|
||||
# ============================================================================
|
||||
|
||||
# FDISK login credentials
|
||||
# Used by the fdisk-sync service to scrape member data from app.fdisk.at
|
||||
# REQUIRED for the sync service to work
|
||||
FDISK_USERNAME=your_fdisk_username
|
||||
FDISK_PASSWORD=your_fdisk_password
|
||||
|
||||
# Internal URL of the fdisk-sync control server
|
||||
# Used by the backend to proxy manual trigger and log requests
|
||||
# In Docker Compose this is fixed — only change if you remap the port
|
||||
FDISK_SYNC_URL=http://fdisk-sync:3001
|
||||
|
||||
# ============================================================================
|
||||
# LOGGING CONFIGURATION (Optional)
|
||||
# ============================================================================
|
||||
@@ -238,14 +283,14 @@ NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at
|
||||
# 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)
|
||||
@@ -1,2 +1 @@
|
||||
registry=https://registry.npmjs.org/
|
||||
omit-lockfile-registry-resolved=true
|
||||
|
||||
@@ -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
|
||||
|
||||
9
backend/package-lock.json
generated
9
backend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"jose": "^6.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pg": "^8.18.0",
|
||||
"winston": "^3.19.0",
|
||||
@@ -1069,6 +1070,14 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.1",
|
||||
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.3",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
|
||||
@@ -21,13 +21,16 @@
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"helmet": "^8.1.0",
|
||||
"jose": "^6.2.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pg": "^8.18.0",
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"winston": "^3.19.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.3.0",
|
||||
|
||||
@@ -2,9 +2,12 @@ import express, { Application, Request, Response } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import path from 'path';
|
||||
import environment from './config/environment';
|
||||
import logger from './utils/logger';
|
||||
import { errorHandler, notFoundHandler } from './middleware/error.middleware';
|
||||
import { requestTimeout } from './middleware/request-timeout.middleware';
|
||||
import { authenticate } from './middleware/auth.middleware';
|
||||
|
||||
const app: Application = express();
|
||||
|
||||
@@ -33,20 +36,29 @@ const authLimiter = rateLimit({
|
||||
});
|
||||
|
||||
app.use('/api/auth', authLimiter);
|
||||
// General rate limiter — skip auth routes (they have their own limiter above)
|
||||
// General rate limiter — skip auth routes (own limiter above) and authenticated
|
||||
// requests (Bearer token present). Auth middleware validates the token downstream;
|
||||
// rate-limiting authenticated dashboard polling would cause 429 floods.
|
||||
app.use('/api', rateLimit({
|
||||
windowMs: environment.rateLimit.windowMs,
|
||||
max: environment.rateLimit.max,
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skip: (req) => req.path.startsWith('/auth'),
|
||||
skip: (req) => {
|
||||
if (req.path.startsWith('/auth')) return true;
|
||||
const auth = req.headers.authorization;
|
||||
return typeof auth === 'string' && auth.startsWith('Bearer ');
|
||||
},
|
||||
}));
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Request timeout middleware
|
||||
app.use(requestTimeout);
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req: Request, _res: Response, next) => {
|
||||
logger.info('Incoming request', {
|
||||
@@ -80,6 +92,25 @@ import nextcloudRoutes from './routes/nextcloud.routes';
|
||||
import atemschutzRoutes from './routes/atemschutz.routes';
|
||||
import eventsRoutes from './routes/events.routes';
|
||||
import bookingRoutes from './routes/booking.routes';
|
||||
import notificationRoutes from './routes/notification.routes';
|
||||
import bookstackRoutes from './routes/bookstack.routes';
|
||||
import vikunjaRoutes from './routes/vikunja.routes';
|
||||
import bestellungRoutes from './routes/bestellung.routes';
|
||||
import configRoutes from './routes/config.routes';
|
||||
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
|
||||
import settingsRoutes from './routes/settings.routes';
|
||||
import bannerRoutes from './routes/banner.routes';
|
||||
import permissionRoutes from './routes/permission.routes';
|
||||
import ausruestungsanfrageRoutes from './routes/ausruestungsanfrage.routes';
|
||||
import issueRoutes from './routes/issue.routes';
|
||||
import buchungskategorieRoutes from './routes/buchungskategorie.routes';
|
||||
import checklistRoutes from './routes/checklist.routes';
|
||||
import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
|
||||
import ausruestungTypRoutes from './routes/ausruestungTyp.routes';
|
||||
import buchhaltungRoutes from './routes/buchhaltung.routes';
|
||||
import personalEquipmentRoutes from './routes/personalEquipment.routes';
|
||||
import toolConfigRoutes from './routes/toolConfig.routes';
|
||||
import scheduledMessagesRoutes from './routes/scheduledMessages.routes';
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/user', userRoutes);
|
||||
@@ -93,6 +124,30 @@ app.use('/api/atemschutz', atemschutzRoutes);
|
||||
app.use('/api/nextcloud/talk', nextcloudRoutes);
|
||||
app.use('/api/events', eventsRoutes);
|
||||
app.use('/api/bookings', bookingRoutes);
|
||||
app.use('/api/notifications', notificationRoutes);
|
||||
app.use('/api/bookstack', bookstackRoutes);
|
||||
app.use('/api/vikunja', vikunjaRoutes);
|
||||
app.use('/api/bestellungen', bestellungRoutes);
|
||||
app.use('/api/config', configRoutes);
|
||||
app.use('/api/admin', serviceMonitorRoutes);
|
||||
app.use('/api/admin/settings', settingsRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/banners', bannerRoutes);
|
||||
app.use('/api/permissions', permissionRoutes);
|
||||
app.use('/api/ausruestungsanfragen', ausruestungsanfrageRoutes);
|
||||
app.use('/api/issues', issueRoutes);
|
||||
app.use('/api/buchungskategorien', buchungskategorieRoutes);
|
||||
app.use('/api/checklisten', checklistRoutes);
|
||||
app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
|
||||
app.use('/api/ausruestung-typen', ausruestungTypRoutes);
|
||||
app.use('/api/buchhaltung', buchhaltungRoutes);
|
||||
app.use('/api/persoenliche-ausruestung', personalEquipmentRoutes);
|
||||
app.use('/api/admin/tools', toolConfigRoutes);
|
||||
app.use('/api/scheduled-messages', scheduledMessagesRoutes);
|
||||
|
||||
// Static file serving for uploads (authenticated)
|
||||
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
||||
app.use('/uploads', authenticate, express.static(uploadsDir));
|
||||
|
||||
// 404 handler
|
||||
app.use(notFoundHandler);
|
||||
|
||||
@@ -14,7 +14,7 @@ const poolConfig: PoolConfig = {
|
||||
database: environment.database.name,
|
||||
user: environment.database.user,
|
||||
password: environment.database.password,
|
||||
max: 20, // Maximum number of clients in the pool
|
||||
max: 30, // Maximum number of clients in the pool
|
||||
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
|
||||
connectionTimeoutMillis: 5000, // Return an error if connection takes longer than 5 seconds
|
||||
};
|
||||
@@ -26,6 +26,17 @@ pool.on('error', (err) => {
|
||||
logger.error('Unexpected error on idle database client', err);
|
||||
});
|
||||
|
||||
// Log pool exhaustion warnings every 60s (only when requests are waiting)
|
||||
setInterval(() => {
|
||||
if (pool.waitingCount > 0) {
|
||||
logger.warn('DB pool pressure detected', {
|
||||
total: pool.totalCount,
|
||||
idle: pool.idleCount,
|
||||
waiting: pool.waitingCount,
|
||||
});
|
||||
}
|
||||
}, 60_000).unref();
|
||||
|
||||
// Test database connection
|
||||
export const testConnection = async (): Promise<boolean> => {
|
||||
try {
|
||||
|
||||
@@ -33,6 +33,15 @@ interface EnvironmentConfig {
|
||||
redirectUri: string;
|
||||
};
|
||||
nextcloudUrl: string;
|
||||
bookstack: {
|
||||
url: string;
|
||||
tokenId: string;
|
||||
tokenSecret: string;
|
||||
};
|
||||
vikunja: {
|
||||
url: string;
|
||||
apiToken: string;
|
||||
};
|
||||
}
|
||||
|
||||
const environment: EnvironmentConfig = {
|
||||
@@ -46,7 +55,7 @@ const environment: EnvironmentConfig = {
|
||||
password: process.env.DB_PASSWORD || 'dev_password',
|
||||
},
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
||||
secret: process.env.JWT_SECRET || '',
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||
},
|
||||
cors: {
|
||||
@@ -63,6 +72,42 @@ const environment: EnvironmentConfig = {
|
||||
redirectUri: process.env.AUTHENTIK_REDIRECT_URI || 'http://localhost:5173/auth/callback',
|
||||
},
|
||||
nextcloudUrl: process.env.NEXTCLOUD_URL || '',
|
||||
bookstack: {
|
||||
url: process.env.BOOKSTACK_URL || '',
|
||||
tokenId: process.env.BOOKSTACK_TOKEN_ID || '',
|
||||
tokenSecret: process.env.BOOKSTACK_TOKEN_SECRET || '',
|
||||
},
|
||||
vikunja: {
|
||||
url: process.env.VIKUNJA_URL || '',
|
||||
apiToken: process.env.VIKUNJA_API_TOKEN || '',
|
||||
},
|
||||
};
|
||||
|
||||
function validateEnvironment(env: EnvironmentConfig): void {
|
||||
const secret = env.jwt.secret;
|
||||
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
'FATAL: JWT_SECRET is not set. ' +
|
||||
'Set a strong, random secret of at least 32 characters before starting the server.'
|
||||
);
|
||||
}
|
||||
|
||||
if (secret === 'your-secret-key-change-in-production') {
|
||||
throw new Error(
|
||||
'FATAL: JWT_SECRET is still set to the known weak default value. ' +
|
||||
'Replace it with a strong, random secret of at least 32 characters.'
|
||||
);
|
||||
}
|
||||
|
||||
if (secret.length < 32) {
|
||||
throw new Error(
|
||||
`FATAL: JWT_SECRET is too short (${secret.length} characters). ` +
|
||||
'A minimum of 32 characters is required.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
validateEnvironment(environment);
|
||||
|
||||
export default environment;
|
||||
|
||||
11
backend/src/config/httpClient.ts
Normal file
11
backend/src/config/httpClient.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import axios from 'axios';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
|
||||
const httpClient = axios.create({
|
||||
timeout: 10_000,
|
||||
httpAgent: new http.Agent({ keepAlive: true, maxSockets: 20 }),
|
||||
httpsAgent: new https.Agent({ keepAlive: true, maxSockets: 20 }),
|
||||
});
|
||||
|
||||
export default httpClient;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response } from 'express';
|
||||
import atemschutzService from '../services/atemschutz.service';
|
||||
import notificationService from '../services/notification.service';
|
||||
import { CreateAtemschutzSchema, UpdateAtemschutzSchema } from '../models/atemschutz.model';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
@@ -120,6 +121,29 @@ class AtemschutzController {
|
||||
}
|
||||
}
|
||||
|
||||
async getByUserId(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(userId)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Benutzer-ID' });
|
||||
return;
|
||||
}
|
||||
const callerId = getUserId(req);
|
||||
const callerGroups: string[] = (req.user as any)?.groups ?? [];
|
||||
const privileged = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
|
||||
const isPrivileged = callerGroups.some((g) => privileged.includes(g));
|
||||
if (userId !== callerId && !isPrivileged) {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
|
||||
return;
|
||||
}
|
||||
const record = await atemschutzService.getByUserId(userId);
|
||||
res.status(200).json({ success: true, data: record ?? null });
|
||||
} catch (error) {
|
||||
logger.error('Atemschutz getByUserId error', { error, userId: req.params.userId });
|
||||
res.status(500).json({ success: false, message: 'Atemschutzstatus konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getMyStatus(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = getUserId(req);
|
||||
@@ -136,6 +160,49 @@ class AtemschutzController {
|
||||
}
|
||||
}
|
||||
|
||||
async getExpiring(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const expiring = await atemschutzService.getExpiringCertifications(30);
|
||||
|
||||
// Side-effect: create notifications for expiring certifications (dedup via DB constraint)
|
||||
for (const item of expiring) {
|
||||
if (item.untersuchung_status !== 'ok') {
|
||||
await notificationService.createNotification({
|
||||
user_id: item.user_id,
|
||||
typ: 'atemschutz_expiry',
|
||||
titel: item.untersuchung_status === 'abgelaufen'
|
||||
? 'Atemschutztauglichkeitsuntersuchung abgelaufen'
|
||||
: 'Atemschutztauglichkeitsuntersuchung läuft bald ab',
|
||||
nachricht: `Ihre Atemschutztauglichkeitsuntersuchung ${item.untersuchung_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`,
|
||||
schwere: item.untersuchung_status === 'abgelaufen' ? 'fehler' : 'warnung',
|
||||
quell_typ: 'atemschutz_untersuchung',
|
||||
quell_id: item.id,
|
||||
link: '/atemschutz',
|
||||
});
|
||||
}
|
||||
if (item.leistungstest_status !== 'ok') {
|
||||
await notificationService.createNotification({
|
||||
user_id: item.user_id,
|
||||
typ: 'atemschutz_expiry',
|
||||
titel: item.leistungstest_status === 'abgelaufen'
|
||||
? 'Leistungstest abgelaufen'
|
||||
: 'Leistungstest läuft bald ab',
|
||||
nachricht: `Ihr Leistungstest ${item.leistungstest_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`,
|
||||
schwere: item.leistungstest_status === 'abgelaufen' ? 'fehler' : 'warnung',
|
||||
quell_typ: 'atemschutz_leistungstest',
|
||||
quell_id: item.id,
|
||||
link: '/atemschutz',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: expiring });
|
||||
} catch (error) {
|
||||
logger.error('Atemschutz getExpiring error', { error });
|
||||
res.status(500).json({ success: false, message: 'Ablaufende Zertifizierungen konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
|
||||
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();
|
||||
@@ -1,11 +1,14 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import authentikService from '../services/authentik.service';
|
||||
import tokenService from '../services/token.service';
|
||||
import userService from '../services/user.service';
|
||||
import memberService from '../services/member.service';
|
||||
import logger from '../utils/logger';
|
||||
import { AuthRequest } from '../types/auth.types';
|
||||
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
|
||||
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
|
||||
import { getUserRole } from '../middleware/rbac.middleware';
|
||||
import pool from '../config/database';
|
||||
|
||||
/**
|
||||
* Extract given_name and family_name from Authentik userinfo.
|
||||
@@ -60,10 +63,13 @@ class AuthController {
|
||||
const userAgent = extractUserAgent(req);
|
||||
|
||||
try {
|
||||
const { code } = req.body as AuthRequest;
|
||||
const callbackSchema = z.object({
|
||||
code: z.string().min(1),
|
||||
redirect_uri: z.string().url().optional(),
|
||||
});
|
||||
|
||||
// Validate code
|
||||
if (!code) {
|
||||
const parseResult = callbackSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Authorization code is required',
|
||||
@@ -71,6 +77,8 @@ class AuthController {
|
||||
return;
|
||||
}
|
||||
|
||||
const { code } = parseResult.data;
|
||||
|
||||
logger.info('Processing OAuth callback', { hasCode: !!code });
|
||||
|
||||
// Step 1: Exchange code for tokens
|
||||
@@ -79,18 +87,35 @@ class AuthController {
|
||||
// Step 2: Get user info from Authentik
|
||||
const userInfo = await authentikService.getUserInfo(tokens.access_token);
|
||||
const groups = userInfo.groups ?? [];
|
||||
const dashboardGroups = groups.filter((g: string) => g.startsWith('dashboard_'));
|
||||
|
||||
// Step 3: Verify ID token if present
|
||||
if (tokens.id_token) {
|
||||
try {
|
||||
authentikService.verifyIdToken(tokens.id_token);
|
||||
await authentikService.verifyIdToken(tokens.id_token);
|
||||
} catch (error) {
|
||||
logger.warn('ID token verification failed', { error });
|
||||
logger.error('ID token verification failed — continuing with userinfo (security event)', { error });
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Find or create user in database
|
||||
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
|
||||
@@ -111,7 +136,9 @@ class AuthController {
|
||||
profile_picture_url: userInfo.picture,
|
||||
});
|
||||
|
||||
await userService.updateGroups(user.id, groups);
|
||||
await userService.updateGroups(user.id, dashboardGroups);
|
||||
logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length });
|
||||
await memberService.ensureProfileExists(user.id);
|
||||
|
||||
// Audit: first-ever login (user record creation)
|
||||
auditService.logAudit({
|
||||
@@ -127,23 +154,50 @@ class AuthController {
|
||||
metadata: { new_account: true },
|
||||
});
|
||||
} else {
|
||||
// User exists, update last login
|
||||
// User exists — check active status BEFORE any mutations
|
||||
if (!user.is_active) {
|
||||
logger.warn('Inactive user attempted login', { userId: user.id });
|
||||
|
||||
auditService.logAudit({
|
||||
user_id: user.id,
|
||||
user_email: user.email,
|
||||
action: AuditAction.PERMISSION_DENIED,
|
||||
resource_type: AuditResourceType.USER,
|
||||
resource_id: user.id,
|
||||
old_value: null,
|
||||
new_value: null,
|
||||
ip_address: ip,
|
||||
user_agent: userAgent,
|
||||
metadata: { reason: 'account_inactive' },
|
||||
});
|
||||
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: 'User account is inactive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// User is active, proceed with login updates
|
||||
logger.info('Existing user logging in', {
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
});
|
||||
|
||||
await userService.updateLastLogin(user.id);
|
||||
await userService.updateGroups(user.id, groups);
|
||||
await userService.updateGroups(user.id, dashboardGroups);
|
||||
logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length });
|
||||
await memberService.ensureProfileExists(user.id);
|
||||
|
||||
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
|
||||
|
||||
// Refresh profile fields from Authentik on every login
|
||||
// Refresh profile fields from Authentik on every login (including profile picture)
|
||||
await userService.updateUser(user.id, {
|
||||
name: userInfo.name,
|
||||
given_name: updatedGivenName,
|
||||
family_name: updatedFamilyName,
|
||||
preferred_username: userInfo.preferred_username,
|
||||
name: userInfo.name,
|
||||
given_name: updatedGivenName,
|
||||
family_name: updatedFamilyName,
|
||||
preferred_username: userInfo.preferred_username,
|
||||
profile_picture_url: userInfo.picture || undefined,
|
||||
});
|
||||
|
||||
// Audit: returning user login
|
||||
@@ -164,37 +218,14 @@ class AuthController {
|
||||
// Extract normalised names once for use in the response
|
||||
const { given_name: resolvedGivenName, family_name: resolvedFamilyName } = extractNames(userInfo);
|
||||
|
||||
// Check if user is active
|
||||
if (!user.is_active) {
|
||||
logger.warn('Inactive user attempted login', { userId: user.id });
|
||||
|
||||
// Audit the denied login attempt
|
||||
auditService.logAudit({
|
||||
user_id: user.id,
|
||||
user_email: user.email,
|
||||
action: AuditAction.PERMISSION_DENIED,
|
||||
resource_type: AuditResourceType.USER,
|
||||
resource_id: user.id,
|
||||
old_value: null,
|
||||
new_value: null,
|
||||
ip_address: ip,
|
||||
user_agent: userAgent,
|
||||
metadata: { reason: 'account_inactive' },
|
||||
});
|
||||
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
message: 'User account is inactive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 5: Generate internal JWT token
|
||||
const role = await getUserRole(user.id);
|
||||
const accessToken = tokenService.generateToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
authentikSub: user.authentik_sub,
|
||||
groups,
|
||||
role,
|
||||
});
|
||||
|
||||
// Generate refresh token
|
||||
@@ -215,6 +246,7 @@ class AuthController {
|
||||
data: {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
isNewUser,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@@ -309,9 +341,12 @@ class AuthController {
|
||||
*/
|
||||
async handleRefresh(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
const refreshSchema = z.object({
|
||||
refreshToken: z.string().min(1),
|
||||
});
|
||||
|
||||
if (!refreshToken) {
|
||||
const parseResult = refreshSchema.safeParse(req.body);
|
||||
if (!parseResult.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Refresh token is required',
|
||||
@@ -319,6 +354,8 @@ class AuthController {
|
||||
return;
|
||||
}
|
||||
|
||||
const { refreshToken } = parseResult.data;
|
||||
|
||||
// Verify refresh token
|
||||
let decoded;
|
||||
try {
|
||||
@@ -358,10 +395,19 @@ class AuthController {
|
||||
}
|
||||
|
||||
// Generate new access token
|
||||
const role = await getUserRole(user.id);
|
||||
// Fetch groups from DB so refreshed tokens retain group info
|
||||
const groupsResult = await pool.query(
|
||||
'SELECT authentik_groups FROM users WHERE id = $1',
|
||||
[user.id]
|
||||
);
|
||||
const groups: string[] = groupsResult.rows[0]?.authentik_groups ?? [];
|
||||
const accessToken = tokenService.generateToken({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
authentikSub: user.authentik_sub,
|
||||
groups,
|
||||
role,
|
||||
});
|
||||
|
||||
logger.info('Token refreshed successfully', {
|
||||
|
||||
65
backend/src/controllers/banner.controller.ts
Normal file
65
backend/src/controllers/banner.controller.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import bannerService from '../services/banner.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const createSchema = z.object({
|
||||
message: z.string().min(1).max(2000),
|
||||
level: z.enum(['info', 'important', 'critical']).default('info'),
|
||||
show_as: z.enum(['banner', 'widget']).default('banner'),
|
||||
starts_at: z.string().datetime().optional(),
|
||||
ends_at: z.string().datetime().nullable().optional(),
|
||||
});
|
||||
|
||||
class BannerController {
|
||||
async getActive(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const banners = await bannerService.getActive();
|
||||
res.json({ success: true, data: banners });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get banners', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get banners' });
|
||||
}
|
||||
}
|
||||
|
||||
async getAll(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const banners = await bannerService.getAll();
|
||||
res.json({ success: true, data: banners });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get all banners', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get banners' });
|
||||
}
|
||||
}
|
||||
|
||||
async create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = createSchema.parse(req.body);
|
||||
const banner = await bannerService.create(data, req.user!.id);
|
||||
res.status(201).json({ success: true, data: banner });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to create banner', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to create banner' });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const deleted = await bannerService.delete(req.params.id as string);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ success: false, message: 'Banner not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete banner', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to delete banner' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new BannerController();
|
||||
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();
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
import bookingService from '../services/booking.service';
|
||||
import vehicleService from '../services/vehicle.service';
|
||||
import pool from '../config/database';
|
||||
import { permissionService } from '../services/permission.service';
|
||||
import {
|
||||
CreateBuchungSchema,
|
||||
UpdateBuchungSchema,
|
||||
@@ -25,8 +28,12 @@ function handleZodError(res: Response, err: ZodError): void {
|
||||
}
|
||||
|
||||
function handleConflictError(res: Response, err: Error): boolean {
|
||||
if (err.message?.includes('außer Dienst')) {
|
||||
res.status(409).json({ success: false, message: err.message, reason: 'out_of_service' });
|
||||
return true;
|
||||
}
|
||||
if (err.message?.includes('bereits gebucht')) {
|
||||
res.status(409).json({ success: false, message: err.message });
|
||||
res.status(409).json({ success: false, message: err.message, reason: 'booking_conflict' });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -37,6 +44,22 @@ function handleConflictError(res: Response, err: Error): boolean {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class BookingController {
|
||||
/**
|
||||
* GET /api/bookings/vehicles
|
||||
* Lightweight vehicle list for the booking form (no fahrzeuge:view needed).
|
||||
*/
|
||||
async getVehiclesForBooking(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT id, bezeichnung, amtliches_kennzeichen FROM fahrzeuge ORDER BY bezeichnung'
|
||||
);
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (err) {
|
||||
logger.error('Failed to fetch vehicles for booking', err);
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Laden der Fahrzeuge' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/bookings/calendar?from=&to=&fahrzeugId=
|
||||
* Returns all non-cancelled bookings overlapping the given date range.
|
||||
@@ -48,12 +71,13 @@ class BookingController {
|
||||
res.status(400).json({ success: false, message: 'from und to sind erforderlich' });
|
||||
return;
|
||||
}
|
||||
const bookings = await bookingService.getBookingsByRange(
|
||||
new Date(from as string),
|
||||
new Date(to as string),
|
||||
fahrzeugId as string | undefined
|
||||
);
|
||||
res.json({ success: true, data: bookings });
|
||||
const fromDate = new Date(from as string);
|
||||
const toDate = new Date(to as string);
|
||||
const [bookings, maintenanceWindows] = await Promise.all([
|
||||
bookingService.getBookingsByRange(fromDate, toDate, fahrzeugId as string | undefined),
|
||||
vehicleService.getMaintenanceWindows(fromDate, toDate),
|
||||
]);
|
||||
res.json({ success: true, data: { bookings, maintenanceWindows } });
|
||||
} catch (error) {
|
||||
logger.error('Booking getCalendarRange error', { error });
|
||||
res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' });
|
||||
@@ -81,17 +105,34 @@ class BookingController {
|
||||
*/
|
||||
async checkAvailability(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { fahrzeugId, from, to } = req.query;
|
||||
const { fahrzeugId, from, to, excludeId } = req.query;
|
||||
if (!fahrzeugId || !from || !to) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ success: false, message: 'fahrzeugId, from und to sind erforderlich' });
|
||||
return;
|
||||
}
|
||||
const beginn = new Date(from as string);
|
||||
const ende = new Date(to as string);
|
||||
|
||||
const outOfService = await bookingService.checkOutOfServiceConflict(
|
||||
fahrzeugId as string, beginn, ende
|
||||
);
|
||||
if (outOfService) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
available: false,
|
||||
reason: 'out_of_service',
|
||||
ausserDienstVon: outOfService.ausser_dienst_von.toISOString(),
|
||||
ausserDienstBis: outOfService.ausser_dienst_bis.toISOString(),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const hasConflict = await bookingService.checkConflict(
|
||||
fahrzeugId as string,
|
||||
new Date(from as string),
|
||||
new Date(to as string)
|
||||
fahrzeugId as string, beginn, ende, excludeId as string | undefined
|
||||
);
|
||||
res.json({ success: true, data: { available: !hasConflict } });
|
||||
} catch (error) {
|
||||
@@ -134,7 +175,7 @@ class BookingController {
|
||||
handleZodError(res, parsed.error);
|
||||
return;
|
||||
}
|
||||
const booking = await bookingService.create(parsed.data, req.user!.id);
|
||||
const booking = await bookingService.create(parsed.data, req.user!.id, req.body.ignoreOutOfService === true);
|
||||
res.status(201).json({ success: true, data: booking });
|
||||
} catch (error: any) {
|
||||
if (handleConflictError(res, error)) return;
|
||||
@@ -181,8 +222,9 @@ class BookingController {
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/bookings/:id
|
||||
* DELETE /api/bookings/:id or PATCH /api/bookings/:id/cancel
|
||||
* Soft-cancels a booking (sets abgesagt=TRUE).
|
||||
* Allowed for booking creator or users with bookings:write permission.
|
||||
*/
|
||||
async cancel(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
@@ -191,6 +233,24 @@ class BookingController {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check ownership: creator can cancel if they have cancel_own_bookings permission
|
||||
const booking = await bookingService.getById(id);
|
||||
if (!booking) {
|
||||
res.status(404).json({ success: false, message: 'Buchung nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
const isOwner = booking.gebucht_von === req.user!.id;
|
||||
const groups: string[] = req.user?.groups ?? [];
|
||||
const isAdmin = groups.includes('dashboard_admin');
|
||||
const canCancelOwn = isAdmin || permissionService.hasPermission(groups, 'fahrzeugbuchungen:manage');
|
||||
const canCancelAny = isAdmin || permissionService.hasPermission(groups, 'fahrzeugbuchungen:manage');
|
||||
|
||||
if (!(isOwner && canCancelOwn) && !canCancelAny) {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = CancelBuchungSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
handleZodError(res, parsed.error);
|
||||
|
||||
63
backend/src/controllers/bookstack.controller.ts
Normal file
63
backend/src/controllers/bookstack.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { Request, Response } from 'express';
|
||||
import bookstackService from '../services/bookstack.service';
|
||||
import environment from '../config/environment';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
class BookStackController {
|
||||
async getRecent(_req: Request, res: Response): Promise<void> {
|
||||
if (!environment.bookstack.url) {
|
||||
res.status(200).json({ success: true, data: [], configured: false });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const pages = await bookstackService.getRecentPages();
|
||||
res.status(200).json({ success: true, data: pages, configured: true });
|
||||
} catch (error) {
|
||||
logger.error('BookStackController.getRecent error', { error });
|
||||
res.status(500).json({ success: false, message: 'BookStack konnte nicht abgefragt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async search(req: Request, res: Response): Promise<void> {
|
||||
if (!environment.bookstack.url) {
|
||||
res.status(200).json({ success: true, data: [], configured: false });
|
||||
return;
|
||||
}
|
||||
const query = req.query.query as string | undefined;
|
||||
if (!query || query.trim().length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Suchbegriff fehlt' });
|
||||
return;
|
||||
}
|
||||
if (query.trim().length > 500) {
|
||||
res.status(400).json({ success: false, message: 'Suchanfrage zu lang' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const results = await bookstackService.searchPages(query.trim());
|
||||
res.status(200).json({ success: true, data: results, configured: true });
|
||||
} catch (error) {
|
||||
logger.error('BookStackController.search error', { error });
|
||||
res.status(500).json({ success: false, message: 'BookStack-Suche fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
async getPage(req: Request, res: Response): Promise<void> {
|
||||
if (!environment.bookstack.url) {
|
||||
res.status(200).json({ success: true, data: null, configured: false });
|
||||
return;
|
||||
}
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id) || id <= 0) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Seiten-ID' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const page = await bookstackService.getPageById(id);
|
||||
res.status(200).json({ success: true, data: page, configured: true });
|
||||
} catch (error) {
|
||||
logger.error('BookStackController.getPage error', { error });
|
||||
res.status(500).json({ success: false, message: 'BookStack-Seite konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new BookStackController();
|
||||
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();
|
||||
64
backend/src/controllers/config.controller.ts
Normal file
64
backend/src/controllers/config.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Request, Response } from 'express';
|
||||
import environment from '../config/environment';
|
||||
import settingsService from '../services/settings.service';
|
||||
|
||||
class ConfigController {
|
||||
async getServiceMode(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const setting = await settingsService.get('service_mode');
|
||||
const value = setting?.value ?? { active: false, message: '' };
|
||||
res.json({ success: true, data: value });
|
||||
} catch {
|
||||
res.json({ success: true, data: { active: false, message: '' } });
|
||||
}
|
||||
}
|
||||
|
||||
async getPdfSettings(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const [header, footer, logo, orgName, appLogo] = await Promise.all([
|
||||
settingsService.get('pdf_header'),
|
||||
settingsService.get('pdf_footer'),
|
||||
settingsService.get('pdf_logo'),
|
||||
settingsService.get('pdf_org_name'),
|
||||
settingsService.get('app_logo'),
|
||||
]);
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
pdf_header: header?.value ?? '',
|
||||
pdf_footer: footer?.value ?? '',
|
||||
pdf_logo: logo?.value ?? '',
|
||||
pdf_org_name: orgName?.value ?? '',
|
||||
app_logo: appLogo?.value ?? '',
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
res.json({ success: true, data: { pdf_header: '', pdf_footer: '', pdf_logo: '', pdf_org_name: '', app_logo: '' } });
|
||||
}
|
||||
}
|
||||
|
||||
async getExternalLinks(_req: Request, res: Response): Promise<void> {
|
||||
const envLinks: Record<string, string> = {};
|
||||
|
||||
const nextcloudUrl = await settingsService.getSettingOrEnv('integration_nextcloud_url', environment.nextcloudUrl);
|
||||
const bookstackUrl = await settingsService.getSettingOrEnv('integration_bookstack_url', environment.bookstack.url);
|
||||
const vikunjaUrl = await settingsService.getSettingOrEnv('integration_vikunja_url', environment.vikunja.url);
|
||||
|
||||
if (nextcloudUrl) envLinks.nextcloud = nextcloudUrl;
|
||||
if (bookstackUrl) envLinks.bookstack = bookstackUrl;
|
||||
if (vikunjaUrl) envLinks.vikunja = vikunjaUrl;
|
||||
|
||||
const linkCollections = await settingsService.getExternalLinks();
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
...envLinks,
|
||||
customLinks: linkCollections.flatMap(c => c.links),
|
||||
linkCollections,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new ConfigController();
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────
|
||||
@@ -89,6 +100,28 @@ function getUserId(req: Request): string {
|
||||
return req.user!.id;
|
||||
}
|
||||
|
||||
function getUserGroups(req: Request): string[] {
|
||||
return req.user?.groups ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the user is authorised to write to equipment in the given
|
||||
* category. Admin can write to any category. Fahrmeister can only write to
|
||||
* motorised categories. Zeugmeister can only write to non-motorised categories.
|
||||
*/
|
||||
async function checkCategoryPermission(kategorieId: string, groups: string[]): Promise<boolean> {
|
||||
if (groups.includes('dashboard_admin')) return true;
|
||||
|
||||
const result = await equipmentService.getCategoryById(kategorieId);
|
||||
if (!result) return false; // unknown category → deny
|
||||
|
||||
if (result.motorisiert) {
|
||||
return groups.includes('dashboard_fahrmeister');
|
||||
} else {
|
||||
return groups.includes('dashboard_zeugmeister');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Controller ────────────────────────────────────────────────────────────────
|
||||
|
||||
class EquipmentController {
|
||||
@@ -193,6 +226,12 @@ class EquipmentController {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const groups = getUserGroups(req);
|
||||
const allowed = await checkCategoryPermission(parsed.data.kategorie_id, groups);
|
||||
if (!allowed) {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
|
||||
return;
|
||||
}
|
||||
const equipment = await equipmentService.createEquipment(parsed.data, getUserId(req));
|
||||
res.status(201).json({ success: true, data: equipment });
|
||||
} catch (error) {
|
||||
@@ -221,6 +260,30 @@ class EquipmentController {
|
||||
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||
return;
|
||||
}
|
||||
// Determine which category to check permissions against
|
||||
const groups = getUserGroups(req);
|
||||
if (!groups.includes('dashboard_admin')) {
|
||||
// Always fetch existing equipment to check old category permission
|
||||
const existing = await equipmentService.getEquipmentById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
// Check permission against the OLD category (must be allowed to move FROM it)
|
||||
const allowedOld = await checkCategoryPermission(existing.kategorie_id, groups);
|
||||
if (!allowedOld) {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
|
||||
return;
|
||||
}
|
||||
// If kategorie_id is being changed, also check permission against the NEW category
|
||||
if (parsed.data.kategorie_id && parsed.data.kategorie_id !== existing.kategorie_id) {
|
||||
const allowedNew = await checkCategoryPermission(parsed.data.kategorie_id, groups);
|
||||
if (!allowedNew) {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung für die Ziel-Kategorie' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
const equipment = await equipmentService.updateEquipment(id, parsed.data, getUserId(req));
|
||||
if (!equipment) {
|
||||
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||
@@ -253,6 +316,19 @@ class EquipmentController {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const groups = getUserGroups(req);
|
||||
if (!groups.includes('dashboard_admin')) {
|
||||
const existing = await equipmentService.getEquipmentById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
const allowed = await checkCategoryPermission(existing.kategorie_id, groups);
|
||||
if (!allowed) {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
await equipmentService.updateStatus(
|
||||
id, parsed.data.status, parsed.data.bemerkung, getUserId(req)
|
||||
);
|
||||
@@ -302,6 +378,19 @@ class EquipmentController {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const groups = getUserGroups(req);
|
||||
if (!groups.includes('dashboard_admin')) {
|
||||
const existing = await equipmentService.getEquipmentById(id);
|
||||
if (!existing) {
|
||||
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
const allowed = await checkCategoryPermission(existing.kategorie_id, groups);
|
||||
if (!allowed) {
|
||||
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
const entry = await equipmentService.addWartungslog(id, parsed.data, getUserId(req));
|
||||
res.status(201).json({ success: true, data: entry });
|
||||
} catch (error: any) {
|
||||
@@ -313,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();
|
||||
|
||||
@@ -118,6 +118,39 @@ class EventsController {
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /api/events/conflicts?from=<ISO>&to=<ISO>&excludeId=<uuid>
|
||||
// -------------------------------------------------------------------------
|
||||
checkConflicts = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const fromStr = req.query.from as string | undefined;
|
||||
const toStr = req.query.to as string | undefined;
|
||||
const excludeId = req.query.excludeId as string | undefined;
|
||||
|
||||
if (!fromStr || !toStr) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Query-Parameter "from" und "to" sind erforderlich (ISO-8601)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const from = new Date(fromStr);
|
||||
const to = new Date(toStr);
|
||||
|
||||
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
|
||||
res.status(400).json({ success: false, message: 'Ungültiges Datumsformat' });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await eventsService.checkConflicts(from, to, excludeId);
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('checkConflicts error', { error });
|
||||
res.status(500).json({ success: false, message: 'Fehler bei der Konfliktprüfung' });
|
||||
}
|
||||
};
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /api/events/calendar?from=<ISO>&to=<ISO>
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -161,7 +194,7 @@ class EventsController {
|
||||
// -------------------------------------------------------------------------
|
||||
getUpcoming = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const limit = Math.min(Number(req.query.limit ?? 10), 50);
|
||||
const limit = Math.min(Number(req.query.limit) || 10, 50);
|
||||
const userGroups = getUserGroups(req);
|
||||
const data = await eventsService.getUpcomingEvents(limit, userGroups);
|
||||
res.json({ success: true, data });
|
||||
@@ -271,7 +304,12 @@ class EventsController {
|
||||
deleteEvent = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
const deleted = await eventsService.deleteEvent(id);
|
||||
const mode = (req.body?.mode as string) || 'all';
|
||||
if (!['all', 'single', 'future'].includes(mode)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültiger Löschmodus. Erlaubt: all, single, future' });
|
||||
return;
|
||||
}
|
||||
const deleted = await eventsService.deleteEvent(id, mode as 'all' | 'single' | 'future');
|
||||
if (!deleted) {
|
||||
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
|
||||
return;
|
||||
@@ -325,6 +363,43 @@ class EventsController {
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' });
|
||||
}
|
||||
};
|
||||
// -------------------------------------------------------------------------
|
||||
// POST /api/events/import
|
||||
// -------------------------------------------------------------------------
|
||||
importEvents = async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { events } = req.body as { events: unknown[] };
|
||||
if (!Array.isArray(events) || events.length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Keine Ereignisse zum Importieren' });
|
||||
return;
|
||||
}
|
||||
const userId = (req.user as any)?.id ?? 'unknown';
|
||||
const created: number[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
try {
|
||||
const parsed = CreateVeranstaltungSchema.safeParse(events[i]);
|
||||
if (!parsed.success) {
|
||||
errors.push(`Zeile ${i + 2}: ${parsed.error.issues.map((e) => e.message).join(', ')}`);
|
||||
continue;
|
||||
}
|
||||
await eventsService.createEvent(parsed.data, userId);
|
||||
created.push(i);
|
||||
} catch (e) {
|
||||
errors.push(`Zeile ${i + 2}: ${e instanceof Error ? e.message : 'Unbekannter Fehler'}`);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { created: created.length, errors },
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('importEvents error', { error });
|
||||
res.status(500).json({ success: false, message: 'Import fehlgeschlagen' });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default new EventsController();
|
||||
|
||||
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 } from '../middleware/rbac.middleware';
|
||||
import { AppRole } from '../middleware/rbac.middleware';
|
||||
import { permissionService } from '../services/permission.service';
|
||||
import {
|
||||
CreateEinsatzSchema,
|
||||
UpdateEinsatzSchema,
|
||||
@@ -75,16 +76,24 @@ class IncidentController {
|
||||
async getIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
|
||||
// UUID validation
|
||||
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Einsatz-ID' });
|
||||
return;
|
||||
}
|
||||
|
||||
const incident = await incidentService.getIncidentById(id);
|
||||
|
||||
if (!incident) {
|
||||
throw new AppError('Einsatz nicht gefunden', 404);
|
||||
}
|
||||
|
||||
// Role-based redaction: only Kommandant+ can see full bericht_text
|
||||
// Role-based redaction: check einsaetze:view_reports permission
|
||||
const groups: string[] = req.user?.groups ?? [];
|
||||
const canReadBerichtText =
|
||||
req.userRole !== undefined &&
|
||||
hasPermission(req.userRole, 'incidents:read_bericht_text');
|
||||
groups.includes('dashboard_admin') ||
|
||||
permissionService.hasPermission(groups, 'einsaetze:view_reports');
|
||||
|
||||
const responseData = {
|
||||
...incident,
|
||||
|
||||
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();
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
type AppRole = 'admin' | 'kommandant' | 'mitglied';
|
||||
|
||||
function getRole(req: Request): AppRole {
|
||||
return (req.user as any)?.role ?? 'mitglied';
|
||||
// req.userRole is set by requirePermission() for non-owner paths.
|
||||
// Fall back to req.user.role (JWT claim) and finally to 'mitglied'.
|
||||
return (req as any).userRole ?? (req.user as any)?.role ?? 'mitglied';
|
||||
}
|
||||
|
||||
function canWrite(req: Request): boolean {
|
||||
@@ -43,9 +45,11 @@ class MemberController {
|
||||
search,
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortDir,
|
||||
} = req.query as Record<string, string | undefined>;
|
||||
|
||||
// Arrays can be sent as ?status[]=aktiv&status[]=passiv or CSV
|
||||
// Arrays can be sent as ?status[]=aktiv&status[]=jugend or CSV
|
||||
const statusParam = req.query['status'] as string | string[] | undefined;
|
||||
const dienstgradParam = req.query['dienstgrad'] as string | string[] | undefined;
|
||||
|
||||
@@ -58,8 +62,10 @@ class MemberController {
|
||||
search,
|
||||
status: normalizeArray(statusParam) as any,
|
||||
dienstgrad: normalizeArray(dienstgradParam) as any,
|
||||
page: page ? parseInt(page, 10) : 1,
|
||||
pageSize: pageSize ? Math.min(parseInt(pageSize, 10), 100) : 25,
|
||||
page: page ? parseInt(page, 10) || 1 : 1,
|
||||
pageSize: pageSize ? (parseInt(pageSize, 10) === 0 ? 0 : Math.min(parseInt(pageSize, 10) || 25, 100)) : 25,
|
||||
sortBy,
|
||||
sortDir: sortDir === 'desc' ? 'desc' : sortDir === 'asc' ? 'asc' : undefined,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
@@ -209,14 +215,16 @@ class MemberController {
|
||||
return;
|
||||
}
|
||||
|
||||
const profile = await memberService.updateMemberProfile(
|
||||
await memberService.updateMemberProfile(
|
||||
userId,
|
||||
parseResult.data as any,
|
||||
updaterId
|
||||
);
|
||||
|
||||
// Return full MemberWithProfile so the frontend state stays consistent
|
||||
const fullMember = await memberService.getMemberById(userId);
|
||||
logger.info('updateMember', { userId, updatedBy: updaterId });
|
||||
res.status(200).json({ success: true, data: profile });
|
||||
res.status(200).json({ success: true, data: fullMember });
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'Mitgliedsprofil nicht gefunden.') {
|
||||
res.status(404).json({ success: false, message: error.message });
|
||||
@@ -226,6 +234,61 @@ class MemberController {
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Profils.' });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* GET /api/members/:userId/befoerderungen
|
||||
*/
|
||||
async getBefoerderungen(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId } = req.params as Record<string, string>;
|
||||
const data = await memberService.getBefoerderungen(userId);
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('getBefoerderungen error', { error, userId: req.params.userId });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Laden der Beförderungen.' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/members/:userId/untersuchungen
|
||||
*/
|
||||
async getUntersuchungen(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId } = req.params as Record<string, string>;
|
||||
const data = await memberService.getUntersuchungen(userId);
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('getUntersuchungen error', { error, userId: req.params.userId });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Laden der Untersuchungen.' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/members/:userId/fahrgenehmigungen
|
||||
*/
|
||||
async getFahrgenehmigungen(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId } = req.params as Record<string, string>;
|
||||
const data = await memberService.getFahrgenehmigungen(userId);
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('getFahrgenehmigungen error', { error, userId: req.params.userId });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Laden der Fahrgenehmigungen.' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/members/:userId/ausbildungen
|
||||
*/
|
||||
async getAusbildungen(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { userId } = req.params as Record<string, string>;
|
||||
const data = await memberService.getAusbildungen(userId);
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('getAusbildungen error', { error, userId: req.params.userId });
|
||||
res.status(500).json({ success: false, message: 'Fehler beim Laden der Ausbildungen.' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new MemberController();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
import nextcloudService from '../services/nextcloud.service';
|
||||
import userService from '../services/user.service';
|
||||
@@ -80,6 +81,348 @@ class NextcloudController {
|
||||
res.status(500).json({ success: false, message: 'Nextcloud-Trennung fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
|
||||
async getRooms(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(200).json({ success: true, data: { connected: false, rooms: [] } });
|
||||
return;
|
||||
}
|
||||
const rooms = await nextcloudService.getAllConversations(credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: { connected: true, rooms, loginName: credentials.loginName } });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||
await userService.clearNextcloudCredentials(req.user!.id);
|
||||
res.status(200).json({ success: true, data: { connected: false, rooms: [] } });
|
||||
return;
|
||||
}
|
||||
logger.error('getRooms error', { error });
|
||||
res.status(500).json({ success: false, message: 'Nextcloud-Räume konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getMessages(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
const token = req.params.token as string;
|
||||
if (!token) {
|
||||
res.status(400).json({ success: false, message: 'Room token fehlt' });
|
||||
return;
|
||||
}
|
||||
const lookIntoFuture = req.query.lookIntoFuture === '1';
|
||||
const lastKnownMessageId = req.query.lastKnownMessageId
|
||||
? parseInt(req.query.lastKnownMessageId as string, 10)
|
||||
: undefined;
|
||||
const timeout = req.query.timeout
|
||||
? Math.min(parseInt(req.query.timeout as string, 10), 25)
|
||||
: 25;
|
||||
const messages = await nextcloudService.getMessages(token, credentials.loginName, credentials.appPassword, {
|
||||
lookIntoFuture,
|
||||
lastKnownMessageId,
|
||||
timeout,
|
||||
});
|
||||
res.status(200).json({ success: true, data: messages });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||
await userService.clearNextcloudCredentials(req.user!.id);
|
||||
res.status(200).json({ success: true, data: { connected: false } });
|
||||
return;
|
||||
}
|
||||
logger.error('getMessages error', { error });
|
||||
res.status(500).json({ success: false, message: 'Nachrichten konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
const token = req.params.token as string;
|
||||
const { message, replyTo } = req.body;
|
||||
if (!token || !message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Token und Nachricht erforderlich' });
|
||||
return;
|
||||
}
|
||||
if (message.length > 32000) {
|
||||
res.status(400).json({ success: false, message: 'Nachricht zu lang' });
|
||||
return;
|
||||
}
|
||||
const replyToNum = (typeof replyTo === 'number' && replyTo > 0) ? replyTo : undefined;
|
||||
await nextcloudService.sendMessage(token, message.trim(), credentials.loginName, credentials.appPassword, replyToNum);
|
||||
res.status(200).json({ success: true, data: null });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||
await userService.clearNextcloudCredentials(req.user!.id);
|
||||
res.status(200).json({ success: true, data: { connected: false } });
|
||||
return;
|
||||
}
|
||||
logger.error('sendMessage error', { error });
|
||||
res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFile(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
const token = req.params.token as string;
|
||||
if (!token) {
|
||||
res.status(400).json({ success: false, message: 'Room token fehlt' });
|
||||
return;
|
||||
}
|
||||
if (!req.file) {
|
||||
res.status(400).json({ success: false, message: 'Keine Datei übermittelt' });
|
||||
return;
|
||||
}
|
||||
await nextcloudService.uploadFileToTalk(
|
||||
token,
|
||||
req.file.buffer,
|
||||
req.file.originalname,
|
||||
req.file.mimetype,
|
||||
credentials.loginName,
|
||||
credentials.appPassword,
|
||||
);
|
||||
res.status(200).json({ success: true, data: null });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||
await userService.clearNextcloudCredentials(req.user!.id);
|
||||
res.status(200).json({ success: true, data: { connected: false } });
|
||||
return;
|
||||
}
|
||||
logger.error('uploadFile error', { error });
|
||||
res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async downloadFile(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
const filePath = req.query.path as string;
|
||||
if (!filePath) {
|
||||
res.status(400).json({ success: false, message: 'Dateipfad fehlt' });
|
||||
return;
|
||||
}
|
||||
// Path traversal protection
|
||||
const normalized = path.normalize(filePath);
|
||||
if (normalized.includes('..') || !normalized.startsWith('/')) {
|
||||
res.status(400).json({ success: false, message: 'Ungültiger Dateipfad' });
|
||||
return;
|
||||
}
|
||||
const response = await nextcloudService.downloadFile(
|
||||
filePath,
|
||||
credentials.loginName,
|
||||
credentials.appPassword,
|
||||
);
|
||||
const contentType = response.headers['content-type'] ?? 'application/octet-stream';
|
||||
const contentDisposition = response.headers['content-disposition']
|
||||
?? `attachment; filename="${String(req.params.fileId).replace(/["\r\n\\]/g, '_')}"`;
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Content-Disposition', contentDisposition);
|
||||
if (response.headers['content-length']) {
|
||||
res.setHeader('Content-Length', response.headers['content-length']);
|
||||
}
|
||||
response.data.pipe(res);
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||
await userService.clearNextcloudCredentials(req.user!.id);
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
logger.error('downloadFile error', { error });
|
||||
res.status(500).json({ success: false, message: 'Datei konnte nicht heruntergeladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getFilePreview(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
const fileId = parseInt(req.params.fileId as string, 10);
|
||||
if (isNaN(fileId)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Datei-ID' });
|
||||
return;
|
||||
}
|
||||
const w = parseInt((req.query.w as string) ?? '400', 10) || 400;
|
||||
const h = parseInt((req.query.h as string) ?? '400', 10) || 400;
|
||||
const response = await nextcloudService.getFilePreview(
|
||||
fileId,
|
||||
Math.min(w, 1200),
|
||||
Math.min(h, 1200),
|
||||
credentials.loginName,
|
||||
credentials.appPassword,
|
||||
);
|
||||
const contentType = response.headers['content-type'] ?? 'image/jpeg';
|
||||
res.setHeader('Content-Type', contentType);
|
||||
if (response.headers['content-length']) {
|
||||
res.setHeader('Content-Length', response.headers['content-length']);
|
||||
}
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
response.data.pipe(res);
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||
await userService.clearNextcloudCredentials(req.user!.id);
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
logger.error('getFilePreview error', { error });
|
||||
res.status(500).json({ success: false, message: 'Vorschau konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async markRoomAsRead(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
const token = req.params.token as string;
|
||||
if (!token) {
|
||||
res.status(400).json({ success: false, message: 'Room token fehlt' });
|
||||
return;
|
||||
}
|
||||
await nextcloudService.markAsRead(token, credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: null });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||
await userService.clearNextcloudCredentials(req.user!.id);
|
||||
res.status(200).json({ success: true, data: { connected: false } });
|
||||
return;
|
||||
}
|
||||
logger.error('markRoomAsRead error', { error });
|
||||
res.status(500).json({ success: false, message: 'Raum konnte nicht als gelesen markiert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async searchUsers(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(200).json({ success: true, data: [] });
|
||||
return;
|
||||
}
|
||||
const query = (req.query.search as string) ?? '';
|
||||
const results = await nextcloudService.searchUsers(query, credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: results });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||
await userService.clearNextcloudCredentials(req.user!.id);
|
||||
res.status(200).json({ success: true, data: [] });
|
||||
return;
|
||||
}
|
||||
logger.error('searchUsers error', { error });
|
||||
res.status(500).json({ success: false, message: 'Benutzersuche fehlgeschlagen' });
|
||||
}
|
||||
}
|
||||
|
||||
async createRoom(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) {
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
const { roomType, invite, roomName } = req.body;
|
||||
if (typeof roomType !== 'number' || !invite || typeof invite !== 'string') {
|
||||
res.status(400).json({ success: false, message: 'roomType und invite erforderlich' });
|
||||
return;
|
||||
}
|
||||
const result = await nextcloudService.createRoom(roomType, invite, roomName, credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||
await userService.clearNextcloudCredentials(req.user!.id);
|
||||
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||
return;
|
||||
}
|
||||
logger.error('createRoom error', { error });
|
||||
res.status(500).json({ success: false, message: 'Raum konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async addReaction(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||
const token = req.params.token as string;
|
||||
const messageId = parseInt(req.params.messageId as string, 10);
|
||||
const { reaction } = req.body;
|
||||
if (!token || isNaN(messageId) || !reaction) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
|
||||
await nextcloudService.addReaction(token, messageId, reaction, credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: null });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||
logger.error('addReaction error', { error });
|
||||
res.status(500).json({ success: false, message: 'Reaktion konnte nicht hinzugefügt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async removeReaction(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||
const token = req.params.token as string;
|
||||
const messageId = parseInt(req.params.messageId as string, 10);
|
||||
const reaction = req.query.reaction as string;
|
||||
if (!token || isNaN(messageId) || !reaction) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
|
||||
await nextcloudService.removeReaction(token, messageId, reaction, credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data: null });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||
logger.error('removeReaction error', { error });
|
||||
res.status(500).json({ success: false, message: 'Reaktion konnte nicht entfernt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getReactions(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||
const token = req.params.token as string;
|
||||
const messageId = parseInt(req.params.messageId as string, 10);
|
||||
if (!token || isNaN(messageId)) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
|
||||
const data = await nextcloudService.getReactions(token, messageId, credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||
logger.error('getReactions error', { error });
|
||||
res.status(500).json({ success: false, message: 'Reaktionen konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getPoll(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||
const token = req.params.token as string;
|
||||
const pollId = parseInt(req.params.pollId as string, 10);
|
||||
if (!token || isNaN(pollId)) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
|
||||
const data = await nextcloudService.getPollDetails(token, pollId, credentials.loginName, credentials.appPassword);
|
||||
res.status(200).json({ success: true, data });
|
||||
} catch (error: any) {
|
||||
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||
logger.error('getPoll error', { error });
|
||||
res.status(500).json({ success: false, message: 'Abstimmung konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NextcloudController();
|
||||
|
||||
100
backend/src/controllers/notification.controller.ts
Normal file
100
backend/src/controllers/notification.controller.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// =============================================================================
|
||||
// Notification Controller
|
||||
// =============================================================================
|
||||
|
||||
import { Request, Response } from 'express';
|
||||
import notificationService from '../services/notification.service';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
function isValidUUID(s: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
||||
}
|
||||
|
||||
class NotificationController {
|
||||
/** GET /api/notifications — returns all notifications for the authenticated user. */
|
||||
async getNotifications(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const notifications = await notificationService.getByUser(userId);
|
||||
res.status(200).json({ success: true, data: notifications });
|
||||
} catch (error) {
|
||||
logger.error('NotificationController.getNotifications error', { error });
|
||||
res.status(500).json({ success: false, message: 'Notifications konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
/** GET /api/notifications/count — returns unread count for the authenticated user. */
|
||||
async getUnreadCount(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const count = await notificationService.getUnreadCount(userId);
|
||||
res.status(200).json({ success: true, data: { count } });
|
||||
} catch (error) {
|
||||
logger.error('NotificationController.getUnreadCount error', { error });
|
||||
res.status(500).json({ success: false, message: 'Anzahl konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
/** PATCH /api/notifications/:id/read — marks a single notification as read. */
|
||||
async markAsRead(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Notification-ID' });
|
||||
return;
|
||||
}
|
||||
const userId = req.user!.id;
|
||||
const updated = await notificationService.markAsRead(id, userId);
|
||||
if (!updated) {
|
||||
res.status(404).json({ success: false, message: 'Notification nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
res.status(200).json({ success: true, message: 'Als gelesen markiert' });
|
||||
} catch (error) {
|
||||
logger.error('NotificationController.markAsRead error', { error });
|
||||
res.status(500).json({ success: false, message: 'Notification konnte nicht aktualisiert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
/** POST /api/notifications/mark-all-read — marks all notifications as read. */
|
||||
async markAllRead(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
await notificationService.markAllRead(userId);
|
||||
res.status(200).json({ success: true, message: 'Alle als gelesen markiert' });
|
||||
} catch (error) {
|
||||
logger.error('NotificationController.markAllRead error', { error });
|
||||
res.status(500).json({ success: false, message: 'Notifications konnten nicht aktualisiert werden' });
|
||||
}
|
||||
}
|
||||
/** POST /api/notifications/dismiss-by-type — marks all unread notifications of a given type as read. */
|
||||
async dismissByType(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { quellTyp } = req.body;
|
||||
if (!quellTyp || typeof quellTyp !== 'string') {
|
||||
res.status(400).json({ success: false, message: 'quellTyp ist erforderlich' });
|
||||
return;
|
||||
}
|
||||
const userId = req.user!.id;
|
||||
await notificationService.dismissByType(userId, quellTyp);
|
||||
res.status(200).json({ success: true, message: 'Notifications als gelesen markiert' });
|
||||
} catch (error) {
|
||||
logger.error('NotificationController.dismissByType error', { error });
|
||||
res.status(500).json({ success: false, message: 'Notifications konnten nicht aktualisiert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE /api/notifications/read — deletes all read notifications for the authenticated user. */
|
||||
async deleteAllRead(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
await notificationService.deleteAllRead(userId);
|
||||
res.status(200).json({ success: true, message: 'Gelesene Notifications gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('NotificationController.deleteAllRead error', { error });
|
||||
res.status(500).json({ success: false, message: 'Gelesene Notifications konnten nicht gelöscht werden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new NotificationController();
|
||||
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();
|
||||
240
backend/src/controllers/serviceMonitor.controller.ts
Normal file
240
backend/src/controllers/serviceMonitor.controller.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import serviceMonitorService from '../services/serviceMonitor.service';
|
||||
import notificationService from '../services/notification.service';
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const createServiceSchema = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
url: z.string().url().max(500),
|
||||
});
|
||||
|
||||
const updateServiceSchema = z.object({
|
||||
name: z.string().min(1).max(200).optional(),
|
||||
url: z.string().url().max(500).optional(),
|
||||
is_active: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const broadcastSchema = z.object({
|
||||
titel: z.string().min(1).max(200),
|
||||
nachricht: z.string().min(1).max(2000),
|
||||
schwere: z.enum(['info', 'warnung', 'fehler']).default('info'),
|
||||
targetGroup: z.string().optional(),
|
||||
targetDienstgrad: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const broadcastFilterSchema = z.object({
|
||||
targetGroup: z.string().optional(),
|
||||
targetDienstgrad: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
function buildFilteredUserQuery(filters: { targetGroup?: string; targetDienstgrad?: string[] }): { text: string; values: unknown[] } {
|
||||
const conditions: string[] = ['u.is_active = TRUE'];
|
||||
const values: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (filters.targetGroup) {
|
||||
conditions.push(`$${paramIndex} = ANY(u.authentik_groups)`);
|
||||
values.push(filters.targetGroup);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (filters.targetDienstgrad && filters.targetDienstgrad.length > 0) {
|
||||
conditions.push(`mp.dienstgrad = ANY($${paramIndex})`);
|
||||
values.push(filters.targetDienstgrad);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const needsJoin = filters.targetDienstgrad && filters.targetDienstgrad.length > 0;
|
||||
const text = `SELECT DISTINCT u.id FROM users u${needsJoin ? ' LEFT JOIN member_profiles mp ON mp.user_id = u.id' : ''} WHERE ${conditions.join(' AND ')}`;
|
||||
|
||||
return { text, values };
|
||||
}
|
||||
|
||||
class ServiceMonitorController {
|
||||
async getAll(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const services = await serviceMonitorService.getAllServices();
|
||||
res.json({ success: true, data: services });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get services', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get services' });
|
||||
}
|
||||
}
|
||||
|
||||
async create(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { name, url } = createServiceSchema.parse(req.body);
|
||||
const service = await serviceMonitorService.createService(name, url);
|
||||
res.status(201).json({ success: true, data: service });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to create service', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to create service' });
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const data = updateServiceSchema.parse(req.body);
|
||||
const service = await serviceMonitorService.updateService(req.params.id as string, data);
|
||||
res.json({ success: true, data: service });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to update service', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to update service' });
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const deleted = await serviceMonitorService.deleteService(req.params.id as string);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ success: false, message: 'Service not found or is internal' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete service', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to delete service' });
|
||||
}
|
||||
}
|
||||
|
||||
async pingAll(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const results = await serviceMonitorService.pingAll();
|
||||
res.json({ success: true, data: results });
|
||||
} catch (error) {
|
||||
logger.error('Failed to ping services', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to ping services' });
|
||||
}
|
||||
}
|
||||
|
||||
async getStatusSummary(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const summary = await serviceMonitorService.getStatusSummary();
|
||||
res.json({ success: true, data: summary });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get status summary', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get status summary' });
|
||||
}
|
||||
}
|
||||
|
||||
async getSystemHealth(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
let dbStatus = false;
|
||||
let dbSize = '0';
|
||||
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
dbStatus = true;
|
||||
const sizeResult = await pool.query('SELECT pg_database_size(current_database()) as size');
|
||||
dbSize = sizeResult.rows[0].size;
|
||||
} catch {
|
||||
// DB is down
|
||||
}
|
||||
|
||||
const mem = process.memoryUsage();
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
nodeVersion: process.version,
|
||||
uptime: process.uptime(),
|
||||
memoryUsage: {
|
||||
heapUsed: mem.heapUsed,
|
||||
heapTotal: mem.heapTotal,
|
||||
rss: mem.rss,
|
||||
external: mem.external,
|
||||
},
|
||||
dbStatus,
|
||||
dbSize,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get system health', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get system health' });
|
||||
}
|
||||
}
|
||||
|
||||
async getUsers(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, email, name, authentik_groups as groups, is_active, last_login_at
|
||||
FROM users ORDER BY name`
|
||||
);
|
||||
res.json({ success: true, data: result.rows });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get users', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get users' });
|
||||
}
|
||||
}
|
||||
|
||||
async getPingHistory(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { serviceId } = req.params;
|
||||
const data = await serviceMonitorService.getPingHistory(serviceId as string);
|
||||
res.json({ success: true, data });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get ping history', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get ping history' });
|
||||
}
|
||||
}
|
||||
|
||||
async broadcastNotification(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { titel, nachricht, schwere, targetGroup, targetDienstgrad } = broadcastSchema.parse(req.body);
|
||||
|
||||
const query = buildFilteredUserQuery({ targetGroup, targetDienstgrad });
|
||||
const result = await pool.query(query.text, query.values);
|
||||
const users = result.rows;
|
||||
|
||||
let sent = 0;
|
||||
for (const user of users) {
|
||||
await notificationService.createNotification({
|
||||
user_id: user.id,
|
||||
typ: 'broadcast',
|
||||
titel,
|
||||
nachricht,
|
||||
schwere,
|
||||
});
|
||||
sent++;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { sent } });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to broadcast notification', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to broadcast notification' });
|
||||
}
|
||||
}
|
||||
|
||||
async broadcastPreview(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { targetGroup, targetDienstgrad } = broadcastFilterSchema.parse(req.body);
|
||||
|
||||
const query = buildFilteredUserQuery({ targetGroup, targetDienstgrad });
|
||||
const result = await pool.query(query.text, query.values);
|
||||
|
||||
res.json({ success: true, data: { count: result.rows.length } });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to preview broadcast', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to preview broadcast' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ServiceMonitorController();
|
||||
104
backend/src/controllers/settings.controller.ts
Normal file
104
backend/src/controllers/settings.controller.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import settingsService from '../services/settings.service';
|
||||
import pool from '../config/database';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const updateSchema = z.object({
|
||||
value: z.any(),
|
||||
});
|
||||
|
||||
const externalLinkSchema = z.array(z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(200),
|
||||
links: z.array(z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
url: z.string().url().max(500),
|
||||
})),
|
||||
}));
|
||||
|
||||
class SettingsController {
|
||||
async getAll(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const settings = await settingsService.getAll();
|
||||
res.json({ success: true, data: settings });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get settings', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get settings' });
|
||||
}
|
||||
}
|
||||
|
||||
async get(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const setting = await settingsService.get(req.params.key as string);
|
||||
if (!setting) {
|
||||
res.status(404).json({ success: false, message: 'Setting not found' });
|
||||
return;
|
||||
}
|
||||
res.json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get setting', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get setting' });
|
||||
}
|
||||
}
|
||||
|
||||
async update(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { value } = updateSchema.parse(req.body);
|
||||
|
||||
// Validate external_links specifically
|
||||
if ((req.params.key as string) === 'external_links') {
|
||||
externalLinkSchema.parse(value);
|
||||
}
|
||||
|
||||
const setting = await settingsService.set(req.params.key as string, value, req.user!.id);
|
||||
res.json({ success: true, data: setting });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
res.status(400).json({ success: false, message: 'Invalid input', errors: error.issues });
|
||||
return;
|
||||
}
|
||||
logger.error('Failed to update setting', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to update setting' });
|
||||
}
|
||||
}
|
||||
async getUserPreferences(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
const result = await pool.query('SELECT preferences FROM users WHERE id = $1', [userId]);
|
||||
const prefs = result.rows[0]?.preferences ?? {};
|
||||
res.json({ success: true, data: prefs });
|
||||
} catch (error) {
|
||||
logger.error('Failed to get user preferences', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to get user preferences' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserPreferences(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const userId = (req as any).user.id;
|
||||
const preferences = req.body;
|
||||
|
||||
// Basic validation — reject excessively large or non-object payloads
|
||||
if (typeof preferences !== 'object' || preferences === null || Array.isArray(preferences)) {
|
||||
res.status(400).json({ success: false, message: 'Preferences must be a JSON object' });
|
||||
return;
|
||||
}
|
||||
if (JSON.stringify(preferences).length > 10_000) {
|
||||
res.status(400).json({ success: false, message: 'Preferences payload too large' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'UPDATE users SET preferences = $1 WHERE id = $2',
|
||||
[JSON.stringify(preferences), userId]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Failed to update user preferences', { error });
|
||||
res.status(500).json({ success: false, message: 'Failed to update user preferences' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new SettingsController();
|
||||
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();
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import vehicleService from '../services/vehicle.service';
|
||||
import equipmentService from '../services/equipment.service';
|
||||
import scheduledMessagesService from '../services/scheduledMessages.service';
|
||||
import { FahrzeugStatus } from '../models/vehicle.model';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
@@ -16,7 +18,6 @@ const FahrzeugStatusEnum = z.enum([
|
||||
FahrzeugStatus.Einsatzbereit,
|
||||
FahrzeugStatus.AusserDienstWartung,
|
||||
FahrzeugStatus.AusserDienstSchaden,
|
||||
FahrzeugStatus.InLehrgang,
|
||||
]);
|
||||
|
||||
const isoDate = z.string().regex(
|
||||
@@ -64,10 +65,29 @@ const UpdateFahrzeugSchema = z.object({
|
||||
naechste_wartung_am: isoDate.nullable().optional(),
|
||||
});
|
||||
|
||||
const isoDatetime = z.string().datetime({ offset: true, message: 'Erwartet ISO-8601 Datum mit Zeitzone' });
|
||||
|
||||
const UpdateStatusSchema = z.object({
|
||||
status: FahrzeugStatusEnum,
|
||||
bemerkung: z.string().max(500).optional().default(''),
|
||||
});
|
||||
status: FahrzeugStatusEnum,
|
||||
bemerkung: z.string().max(500).optional().default(''),
|
||||
ausserDienstVon: isoDatetime.optional(),
|
||||
ausserDienstBis: isoDatetime.optional(),
|
||||
}).refine(
|
||||
(d) => {
|
||||
const isAusserDienst = d.status === FahrzeugStatus.AusserDienstWartung || d.status === FahrzeugStatus.AusserDienstSchaden;
|
||||
if (!isAusserDienst) return true;
|
||||
return !!d.ausserDienstVon && !!d.ausserDienstBis;
|
||||
},
|
||||
{ message: 'Außer-Dienst-Zeitraum (von + bis) ist bei diesem Status erforderlich', path: ['ausserDienstVon'] }
|
||||
).refine(
|
||||
(d) => {
|
||||
if (!d.ausserDienstVon || !d.ausserDienstBis) return true;
|
||||
return new Date(d.ausserDienstBis) > new Date(d.ausserDienstVon);
|
||||
},
|
||||
{ message: 'Enddatum muss nach Startdatum liegen', path: ['ausserDienstBis'] }
|
||||
);
|
||||
|
||||
const ErgebnisEnum = z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']);
|
||||
|
||||
const CreateWartungslogSchema = z.object({
|
||||
datum: isoDate,
|
||||
@@ -77,6 +97,18 @@ const CreateWartungslogSchema = z.object({
|
||||
kraftstoff_liter: z.number().min(0).optional(),
|
||||
kosten: z.number().min(0).optional(),
|
||||
externe_werkstatt: z.string().max(150).optional(),
|
||||
ergebnis: ErgebnisEnum.optional(),
|
||||
naechste_faelligkeit: isoDate.optional(),
|
||||
});
|
||||
|
||||
const UpdateWartungslogSchema = z.object({
|
||||
datum: isoDate,
|
||||
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
|
||||
beschreibung: z.string().min(1).max(2000),
|
||||
km_stand: z.number().int().min(0).optional(),
|
||||
externe_werkstatt: z.string().max(150).optional(),
|
||||
ergebnis: ErgebnisEnum.optional(),
|
||||
naechste_faelligkeit: isoDate.optional(),
|
||||
});
|
||||
|
||||
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||
@@ -124,6 +156,69 @@ class VehicleController {
|
||||
}
|
||||
}
|
||||
|
||||
async exportAlerts(_req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const escape = (v: unknown): string => {
|
||||
if (v === null || v === undefined) return '';
|
||||
const str = String(v);
|
||||
let safe = str.replace(/"/g, '""');
|
||||
if (/^[=+@\-]/.test(safe)) safe = "'" + safe;
|
||||
return `"${safe}"`;
|
||||
};
|
||||
|
||||
const formatDate = (d: Date | string | null): string => {
|
||||
if (!d) return '';
|
||||
const date = typeof d === 'string' ? new Date(d) : d;
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
return `${day}.${month}.${year}`;
|
||||
};
|
||||
|
||||
const [vehicleAlerts, equipmentAlerts] = await Promise.all([
|
||||
vehicleService.getUpcomingInspections(365),
|
||||
equipmentService.getUpcomingInspections(365),
|
||||
]);
|
||||
|
||||
const header = 'Typ,Bezeichnung,Kurzname,Prüfungsart,Fällig am,Tage verbleibend';
|
||||
const rows: string[] = [];
|
||||
|
||||
for (const a of vehicleAlerts) {
|
||||
const pruefungsart = a.type === '57a' ? '§57a Überprüfung' : 'Nächste Wartung';
|
||||
rows.push([
|
||||
escape('Fahrzeug'),
|
||||
escape(a.bezeichnung),
|
||||
escape(a.kurzname),
|
||||
escape(pruefungsart),
|
||||
escape(formatDate(a.faelligAm)),
|
||||
escape(a.tage),
|
||||
].join(','));
|
||||
}
|
||||
|
||||
for (const e of equipmentAlerts) {
|
||||
rows.push([
|
||||
escape('Ausrüstung'),
|
||||
escape(e.bezeichnung),
|
||||
escape(''),
|
||||
escape('Prüfung'),
|
||||
escape(formatDate(e.naechste_pruefung_am)),
|
||||
escape(e.pruefung_tage_bis_faelligkeit),
|
||||
].join(','));
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||
const csv = '\uFEFF' + header + '\n' + rows.join('\n');
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="pruefungen_${dateStr}.csv"`);
|
||||
res.status(200).send(csv);
|
||||
} catch (error) {
|
||||
logger.error('exportAlerts error', { error });
|
||||
res.status(500).json({ success: false, message: 'Prüfungsexport konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getVehicle(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
@@ -211,10 +306,27 @@ class VehicleController {
|
||||
return;
|
||||
}
|
||||
const io = req.app.get('io') ?? undefined;
|
||||
await vehicleService.updateVehicleStatus(
|
||||
id, parsed.data.status, parsed.data.bemerkung, getUserId(req), io
|
||||
const result = await vehicleService.updateVehicleStatus(
|
||||
id,
|
||||
parsed.data.status,
|
||||
parsed.data.bemerkung,
|
||||
getUserId(req),
|
||||
io,
|
||||
parsed.data.ausserDienstVon ? new Date(parsed.data.ausserDienstVon) : null,
|
||||
parsed.data.ausserDienstBis ? new Date(parsed.data.ausserDienstBis) : null,
|
||||
);
|
||||
res.status(200).json({ success: true, message: 'Status aktualisiert' });
|
||||
|
||||
// Fire-and-forget: notify scheduled messages when vehicle goes out of service
|
||||
if (parsed.data.status !== FahrzeugStatus.Einsatzbereit) {
|
||||
scheduledMessagesService.sendVehicleEvent(id).catch(err => {
|
||||
logger.error('Failed to send vehicle event notification', {
|
||||
vehicleId: id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: result });
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'Vehicle not found') {
|
||||
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||
@@ -286,6 +398,66 @@ class VehicleController {
|
||||
res.status(500).json({ success: false, message: 'Wartungslog konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getStatusHistory(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id } = req.params as Record<string, string>;
|
||||
const history = await vehicleService.getStatusHistory(id);
|
||||
res.status(200).json({ success: true, data: history });
|
||||
} catch (error) {
|
||||
logger.error('getStatusHistory error', { error, id: req.params.id });
|
||||
res.status(500).json({ success: false, message: 'Status-Historie konnte nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async updateWartung(req: Request, res: Response): Promise<void> {
|
||||
try {
|
||||
const { id, wartungId } = req.params as Record<string, string>;
|
||||
if (!isValidUUID(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||
return;
|
||||
}
|
||||
const parsed = UpdateWartungslogSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validierungsfehler',
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const entry = await vehicleService.updateWartungslog(wartungId, id, parsed.data, getUserId(req));
|
||||
res.status(200).json({ success: true, data: entry });
|
||||
} catch (error: any) {
|
||||
if (error?.message === 'Wartungseintrag nicht gefunden') {
|
||||
res.status(404).json({ success: false, message: 'Wartungseintrag nicht gefunden' });
|
||||
return;
|
||||
}
|
||||
logger.error('updateWartung error', { error, id: req.params.id, wartungId: req.params.wartungId });
|
||||
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht aktualisiert werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async uploadWartungFile(req: Request, res: Response): Promise<void> {
|
||||
const { wartungId } = req.params as Record<string, string>;
|
||||
const id = parseInt(wartungId, 10);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' });
|
||||
return;
|
||||
}
|
||||
const file = (req as any).file;
|
||||
if (!file) {
|
||||
res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await vehicleService.updateWartungslogFile(id, file.path);
|
||||
res.status(200).json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
logger.error('uploadWartungFile error', { error, wartungId });
|
||||
res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new VehicleController();
|
||||
|
||||
97
backend/src/controllers/vikunja.controller.ts
Normal file
97
backend/src/controllers/vikunja.controller.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Request, Response } from 'express';
|
||||
import vikunjaService from '../services/vikunja.service';
|
||||
import notificationService from '../services/notification.service';
|
||||
import environment from '../config/environment';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
class VikunjaController {
|
||||
async getMyTasks(_req: Request, res: Response): Promise<void> {
|
||||
if (!environment.vikunja.url) {
|
||||
logger.warn('Vikunja not configured – VIKUNJA_URL is empty');
|
||||
res.status(200).json({ success: true, data: [], configured: false });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tasks = await vikunjaService.getMyTasks();
|
||||
res.status(200).json({ success: true, data: tasks, configured: true, vikunjaUrl: environment.vikunja.url });
|
||||
} catch (error) {
|
||||
logger.error('VikunjaController.getMyTasks error', { error });
|
||||
res.status(500).json({ success: false, message: 'Vikunja konnte nicht abgefragt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getOverdueTasks(req: Request, res: Response): Promise<void> {
|
||||
if (!environment.vikunja.url) {
|
||||
logger.warn('Vikunja not configured – VIKUNJA_URL is empty');
|
||||
res.status(200).json({ success: true, data: [], configured: false });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const tasks = await vikunjaService.getOverdueTasks();
|
||||
|
||||
// Side-effect: create notifications for each overdue task (dedup via DB constraint)
|
||||
if (req.user?.id && tasks.length > 0) {
|
||||
const userId = req.user.id;
|
||||
for (const task of tasks) {
|
||||
await notificationService.createNotification({
|
||||
user_id: userId,
|
||||
typ: 'vikunja_task',
|
||||
titel: 'Überfällige Aufgabe',
|
||||
nachricht: task.title,
|
||||
schwere: 'warnung',
|
||||
link: environment.vikunja.url ? `${environment.vikunja.url}/tasks/${task.id}` : undefined,
|
||||
quell_id: String(task.id),
|
||||
quell_typ: 'vikunja_task',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({ success: true, data: tasks, configured: true, vikunjaUrl: environment.vikunja.url });
|
||||
} catch (error) {
|
||||
logger.error('VikunjaController.getOverdueTasks error', { error });
|
||||
res.status(500).json({ success: false, message: 'Vikunja konnte nicht abgefragt werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async getProjects(_req: Request, res: Response): Promise<void> {
|
||||
if (!environment.vikunja.url) {
|
||||
logger.warn('Vikunja not configured – VIKUNJA_URL is empty');
|
||||
res.status(200).json({ success: true, data: [], configured: false });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const projects = await vikunjaService.getProjects();
|
||||
res.status(200).json({ success: true, data: projects, configured: true });
|
||||
} catch (error) {
|
||||
logger.error('VikunjaController.getProjects error', { error });
|
||||
res.status(500).json({ success: false, message: 'Vikunja-Projekte konnten nicht geladen werden' });
|
||||
}
|
||||
}
|
||||
|
||||
async createTask(req: Request, res: Response): Promise<void> {
|
||||
if (!environment.vikunja.url) {
|
||||
res.status(503).json({ success: false, message: 'Vikunja ist nicht eingerichtet' });
|
||||
return;
|
||||
}
|
||||
const { projectId, title, dueDate } = req.body as {
|
||||
projectId?: number;
|
||||
title?: string;
|
||||
dueDate?: string;
|
||||
};
|
||||
|
||||
if (!projectId || !title || title.trim().length === 0) {
|
||||
res.status(400).json({ success: false, message: 'Projekt und Titel sind erforderlich' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const task = await vikunjaService.createTask(projectId, title.trim(), dueDate);
|
||||
res.status(201).json({ success: true, data: task });
|
||||
} catch (error) {
|
||||
logger.error('VikunjaController.createTask error', { error });
|
||||
res.status(500).json({ success: false, message: 'Aufgabe konnte nicht erstellt werden' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new VikunjaController();
|
||||
32
backend/src/database/migrations/020_create_notifications.sql
Normal file
32
backend/src/database/migrations/020_create_notifications.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- Migration 020: Create notifications table
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
typ VARCHAR(50) NOT NULL,
|
||||
titel VARCHAR(500) NOT NULL,
|
||||
nachricht TEXT NOT NULL,
|
||||
schwere VARCHAR(20) NOT NULL DEFAULT 'info'
|
||||
CHECK (schwere IN ('info', 'warnung', 'fehler')),
|
||||
gelesen BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
gelesen_am TIMESTAMPTZ,
|
||||
link VARCHAR(500),
|
||||
quell_id VARCHAR(100),
|
||||
quell_typ VARCHAR(50),
|
||||
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Fast lookup for unread badge count
|
||||
CREATE INDEX IF NOT EXISTS notifications_user_unread_idx
|
||||
ON notifications (user_id, gelesen)
|
||||
WHERE NOT gelesen;
|
||||
|
||||
-- Fast lookup for notification list ordered by date
|
||||
CREATE INDEX IF NOT EXISTS notifications_user_date_idx
|
||||
ON notifications (user_id, erstellt_am DESC);
|
||||
|
||||
-- Dedup index: one unread notification per (user, source type, source id)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS notifications_dedup_idx
|
||||
ON notifications (user_id, quell_typ, quell_id)
|
||||
WHERE NOT gelesen AND quell_typ IS NOT NULL AND quell_id IS NOT NULL;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Migration 021: Add motorisiert flag to ausruestung_kategorien
|
||||
-- Motorized equipment (motorisiert = TRUE) → managed by fahrmeister
|
||||
-- Non-motorized equipment (motorisiert = FALSE) → managed by zeugmeister
|
||||
|
||||
ALTER TABLE ausruestung_kategorien
|
||||
ADD COLUMN IF NOT EXISTS motorisiert BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- DROP + CREATE is required because CREATE OR REPLACE VIEW cannot change the
|
||||
-- column list of an existing view (PostgreSQL error 42P16).
|
||||
DROP VIEW IF EXISTS ausruestung_mit_pruefstatus;
|
||||
|
||||
CREATE VIEW ausruestung_mit_pruefstatus AS
|
||||
SELECT
|
||||
a.*,
|
||||
k.name AS kategorie_name,
|
||||
k.kurzname AS kategorie_kurzname,
|
||||
k.motorisiert AS kategorie_motorisiert,
|
||||
f.bezeichnung AS fahrzeug_bezeichnung,
|
||||
f.kurzname AS fahrzeug_kurzname,
|
||||
CASE
|
||||
WHEN a.naechste_pruefung_am IS NOT NULL
|
||||
THEN a.naechste_pruefung_am::date - CURRENT_DATE
|
||||
ELSE NULL
|
||||
END AS pruefung_tage_bis_faelligkeit
|
||||
FROM ausruestung a
|
||||
JOIN ausruestung_kategorien k ON k.id = a.kategorie_id
|
||||
LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id AND f.deleted_at IS NULL
|
||||
WHERE a.deleted_at IS NULL;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Migration 022: Add alle_gruppen flag to veranstaltung_kategorien
|
||||
-- When alle_gruppen = TRUE, selecting this category auto-fills
|
||||
-- the event's alle_gruppen = TRUE and clears individual zielgruppen.
|
||||
|
||||
ALTER TABLE veranstaltung_kategorien
|
||||
ADD COLUMN IF NOT EXISTS alle_gruppen BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,13 @@
|
||||
CREATE TABLE IF NOT EXISTS monitored_services (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
name VARCHAR(200) NOT NULL,
|
||||
url VARCHAR(500) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL DEFAULT 'custom' CHECK (type IN ('internal','custom')),
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE OR REPLACE TRIGGER update_monitored_services_updated_at
|
||||
BEFORE UPDATE ON monitored_services
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
12
backend/src/database/migrations/024_create_app_settings.sql
Normal file
12
backend/src/database/migrations/024_create_app_settings.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value JSONB NOT NULL DEFAULT '{}',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_by UUID REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Seed default external links (empty array)
|
||||
INSERT INTO app_settings (key, value) VALUES
|
||||
('external_links', '[]'::jsonb),
|
||||
('refresh_intervals', '{"dashboard": 300000, "admin_services": 15000}'::jsonb)
|
||||
ON CONFLICT (key) DO NOTHING;
|
||||
@@ -0,0 +1,14 @@
|
||||
CREATE TYPE banner_level AS ENUM ('info', 'important', 'critical');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS announcement_banners (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message TEXT NOT NULL,
|
||||
level banner_level NOT NULL DEFAULT 'info',
|
||||
starts_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
ends_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_banners_starts_at ON announcement_banners (starts_at);
|
||||
CREATE INDEX idx_banners_ends_at ON announcement_banners (ends_at);
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE IF NOT EXISTS service_ping_history (
|
||||
id SERIAL PRIMARY KEY,
|
||||
service_id VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL,
|
||||
response_time_ms INTEGER,
|
||||
checked_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sph_service_checked ON service_ping_history(service_id, checked_at DESC);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE announcement_banners
|
||||
ADD COLUMN show_as VARCHAR(20) NOT NULL DEFAULT 'banner'
|
||||
CHECK (show_as IN ('banner', 'widget'));
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS fdisk_standesbuch_nr VARCHAR(32);
|
||||
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_fdisk_standesbuch_nr ON mitglieder_profile(fdisk_standesbuch_nr);
|
||||
@@ -0,0 +1,127 @@
|
||||
-- =============================================================================
|
||||
-- Migration 029: Vehicle Out-of-Service Timeframe + Lehrgang Booking Type
|
||||
--
|
||||
-- 1. Add ausser_dienst_von / ausser_dienst_bis columns to fahrzeuge
|
||||
-- 2. Add 'lehrgang' value to fahrzeug_buchung_art enum
|
||||
-- 3. Remove 'in_lehrgang' from vehicle status CHECK constraint
|
||||
-- 4. Refresh fahrzeuge_mit_pruefstatus view to include new columns
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. Add out-of-service timeframe columns
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE fahrzeuge
|
||||
ADD COLUMN IF NOT EXISTS ausser_dienst_von TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS ausser_dienst_bis TIMESTAMPTZ;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_name = 'fahrzeuge'
|
||||
AND constraint_name = 'chk_ausser_dienst_zeitraum'
|
||||
) THEN
|
||||
ALTER TABLE fahrzeuge
|
||||
ADD CONSTRAINT chk_ausser_dienst_zeitraum
|
||||
CHECK (
|
||||
ausser_dienst_von IS NULL
|
||||
OR ausser_dienst_bis IS NULL
|
||||
OR ausser_dienst_bis > ausser_dienst_von
|
||||
);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_ausser_dienst_zeitraum
|
||||
ON fahrzeuge(id, ausser_dienst_von, ausser_dienst_bis)
|
||||
WHERE ausser_dienst_von IS NOT NULL;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. Add 'lehrgang' to fahrzeug_buchung_art enum
|
||||
-- -----------------------------------------------------------------------------
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TYPE fahrzeug_buchung_art ADD VALUE IF NOT EXISTS 'lehrgang';
|
||||
EXCEPTION
|
||||
WHEN others THEN
|
||||
IF SQLERRM NOT LIKE '%already exists%' THEN
|
||||
RAISE;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. Remove 'in_lehrgang' from vehicle status CHECK
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- Migrate existing in_lehrgang rows first
|
||||
UPDATE fahrzeuge SET status = 'einsatzbereit' WHERE status = 'in_lehrgang';
|
||||
|
||||
-- Drop old check constraint (created in migration 005 as fahrzeuge_status_check)
|
||||
ALTER TABLE fahrzeuge DROP CONSTRAINT IF EXISTS fahrzeuge_status_check;
|
||||
|
||||
-- Re-add with only 3 valid values
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_name = 'fahrzeuge'
|
||||
AND constraint_name = 'chk_fahrzeuge_status'
|
||||
) THEN
|
||||
ALTER TABLE fahrzeuge
|
||||
ADD CONSTRAINT chk_fahrzeuge_status
|
||||
CHECK (status IN (
|
||||
'einsatzbereit',
|
||||
'ausser_dienst_wartung',
|
||||
'ausser_dienst_schaden'
|
||||
));
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. Refresh fahrzeuge_mit_pruefstatus view to include new columns
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus;
|
||||
|
||||
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
|
||||
SELECT
|
||||
f.id,
|
||||
f.bezeichnung,
|
||||
f.kurzname,
|
||||
f.amtliches_kennzeichen,
|
||||
f.fahrgestellnummer,
|
||||
f.baujahr,
|
||||
f.hersteller,
|
||||
f.typ_schluessel,
|
||||
f.besatzung_soll,
|
||||
f.status,
|
||||
f.status_bemerkung,
|
||||
f.ausser_dienst_von,
|
||||
f.ausser_dienst_bis,
|
||||
f.standort,
|
||||
f.bild_url,
|
||||
f.created_at,
|
||||
f.updated_at,
|
||||
f.paragraph57a_faellig_am,
|
||||
CASE
|
||||
WHEN f.paragraph57a_faellig_am IS NOT NULL
|
||||
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
|
||||
ELSE NULL
|
||||
END AS paragraph57a_tage_bis_faelligkeit,
|
||||
f.naechste_wartung_am,
|
||||
CASE
|
||||
WHEN f.naechste_wartung_am IS NOT NULL
|
||||
THEN f.naechste_wartung_am::date - CURRENT_DATE
|
||||
ELSE NULL
|
||||
END AS wartung_tage_bis_faelligkeit,
|
||||
LEAST(
|
||||
CASE WHEN f.paragraph57a_faellig_am IS NOT NULL
|
||||
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
|
||||
ELSE NULL END,
|
||||
CASE WHEN f.naechste_wartung_am IS NOT NULL
|
||||
THEN f.naechste_wartung_am::date - CURRENT_DATE
|
||||
ELSE NULL END
|
||||
) AS naechste_pruefung_tage
|
||||
FROM fahrzeuge f
|
||||
WHERE f.deleted_at IS NULL;
|
||||
56
backend/src/database/migrations/030_extend_dienstgrad.sql
Normal file
56
backend/src/database/migrations/030_extend_dienstgrad.sql
Normal file
@@ -0,0 +1,56 @@
|
||||
-- Extends the dienstgrad CHECK constraint to include:
|
||||
-- Jugendfeuerwehrmann, Probefeuerwehrmann, Verwaltungsmeister family, Verwalter,
|
||||
-- and Ehren- prefixed variants of all Dienstgrade.
|
||||
|
||||
ALTER TABLE mitglieder_profile
|
||||
DROP CONSTRAINT IF EXISTS mitglieder_profile_dienstgrad_check;
|
||||
|
||||
ALTER TABLE mitglieder_profile
|
||||
ADD CONSTRAINT mitglieder_profile_dienstgrad_check
|
||||
CHECK (dienstgrad IS NULL OR dienstgrad IN (
|
||||
-- Standard Dienstgrade
|
||||
'Feuerwehranwärter',
|
||||
'Jugendfeuerwehrmann',
|
||||
'Probefeuerwehrmann',
|
||||
'Feuerwehrmann',
|
||||
'Feuerwehrfrau',
|
||||
'Oberfeuerwehrmann',
|
||||
'Oberfeuerwehrfrau',
|
||||
'Hauptfeuerwehrmann',
|
||||
'Hauptfeuerwehrfrau',
|
||||
'Löschmeister',
|
||||
'Oberlöschmeister',
|
||||
'Hauptlöschmeister',
|
||||
'Brandmeister',
|
||||
'Oberbrandmeister',
|
||||
'Hauptbrandmeister',
|
||||
'Brandinspektor',
|
||||
'Oberbrandinspektor',
|
||||
'Brandoberinspektor',
|
||||
'Brandamtmann',
|
||||
'Verwaltungsmeister',
|
||||
'Oberverwaltungsmeister',
|
||||
'Hauptverwaltungsmeister',
|
||||
'Verwalter',
|
||||
-- Ehrendienstgrade
|
||||
'Ehren-Feuerwehrmann',
|
||||
'Ehren-Feuerwehrfrau',
|
||||
'Ehren-Oberfeuerwehrmann',
|
||||
'Ehren-Oberfeuerwehrfrau',
|
||||
'Ehren-Hauptfeuerwehrmann',
|
||||
'Ehren-Hauptfeuerwehrfrau',
|
||||
'Ehren-Löschmeister',
|
||||
'Ehren-Oberlöschmeister',
|
||||
'Ehren-Hauptlöschmeister',
|
||||
'Ehren-Brandmeister',
|
||||
'Ehren-Oberbrandmeister',
|
||||
'Ehren-Hauptbrandmeister',
|
||||
'Ehren-Brandinspektor',
|
||||
'Ehren-Oberbrandinspektor',
|
||||
'Ehren-Brandoberinspektor',
|
||||
'Ehren-Brandamtmann',
|
||||
'Ehren-Verwaltungsmeister',
|
||||
'Ehren-Oberverwaltungsmeister',
|
||||
'Ehren-Hauptverwaltungsmeister',
|
||||
'Ehren-Verwalter'
|
||||
));
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Remove mitglieds_nr column (replaced by fdisk_standesbuch_nr as the canonical member number)
|
||||
ALTER TABLE mitglieder_profile DROP COLUMN IF EXISTS mitglieds_nr;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- Migration 032: Add FDISK-scraped profile fields to mitglieder_profile
|
||||
ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS geburtsort VARCHAR(128);
|
||||
ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS geschlecht VARCHAR(32);
|
||||
ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS beruf VARCHAR(255);
|
||||
ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS wohnort VARCHAR(128);
|
||||
ALTER TABLE mitglieder_profile ADD COLUMN IF NOT EXISTS plz VARCHAR(16);
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Migration 033: Create befoerderungen table (FDISK sync)
|
||||
CREATE TABLE IF NOT EXISTS befoerderungen (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
datum DATE,
|
||||
dienstgrad VARCHAR(64) NOT NULL,
|
||||
fdisk_sync_key VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, fdisk_sync_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_befoerderungen_user_id ON befoerderungen(user_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Migration 034: Create untersuchungen table (FDISK sync)
|
||||
CREATE TABLE IF NOT EXISTS untersuchungen (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
datum DATE,
|
||||
anmerkungen TEXT,
|
||||
art VARCHAR(128) NOT NULL,
|
||||
ergebnis VARCHAR(128),
|
||||
fdisk_sync_key VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, fdisk_sync_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_untersuchungen_user_id ON untersuchungen(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_untersuchungen_art ON untersuchungen(user_id, art);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Migration 035: Create fahrgenehmigungen table (FDISK sync)
|
||||
CREATE TABLE IF NOT EXISTS fahrgenehmigungen (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
ausstellungsdatum DATE,
|
||||
gueltig_bis DATE,
|
||||
behoerde VARCHAR(128),
|
||||
nummer VARCHAR(64),
|
||||
klasse VARCHAR(128) NOT NULL,
|
||||
fdisk_sync_key VARCHAR(255),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, fdisk_sync_key)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_fahrgenehmigungen_user_id ON fahrgenehmigungen(user_id);
|
||||
2
backend/src/database/migrations/036_widen_geschlecht.sql
Normal file
2
backend/src/database/migrations/036_widen_geschlecht.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Migration 036: Widen geschlecht column (was VARCHAR(1), stores full text like 'männlich')
|
||||
ALTER TABLE mitglieder_profile ALTER COLUMN geschlecht TYPE VARCHAR(32);
|
||||
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 $$;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user