Compare commits
441 Commits
deabad167c
...
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 | ||
|
|
b3a2fd9ff9 | ||
|
|
5dfaf7db54 | ||
|
|
004b141cab | ||
|
|
02cf5138cf | ||
|
|
d91f757f34 | ||
|
|
3101f1a9c5 | ||
|
|
2306741c4d | ||
|
|
831927ae90 | ||
|
|
232d8aa872 | ||
|
|
64b23bae5c | ||
|
|
d9af34b744 | ||
|
|
146f79cf00 | ||
|
|
ad069fde10 | ||
|
|
9a6b9511c8 | ||
|
|
314b6c3bed | ||
|
|
355163820f | ||
|
|
6495ca94d1 | ||
|
|
2630224edd | ||
|
|
d9e6c0658f | ||
|
|
46d0895ebd | ||
|
|
5f0ed3c87e | ||
|
|
d074caa695 | ||
|
|
4328343f3e | ||
|
|
e0b687988b | ||
|
|
9b1f290a87 | ||
|
|
faa18b5688 | ||
|
|
4f7823ab16 | ||
|
|
a36e236175 | ||
|
|
064972e88a | ||
|
|
5b8f40ab9a | ||
|
|
e76946ed8a | ||
|
|
b7adf238ed | ||
|
|
b54e400c48 | ||
|
|
67ea0ba1f6 | ||
|
|
a880e56bb1 | ||
|
|
02797554aa | ||
|
|
acd1506df8 | ||
|
|
fad71d32fe | ||
|
|
2eeb206663 | ||
|
|
f559313eae | ||
|
|
c4d9be9027 | ||
|
|
9f5ef15590 | ||
|
|
dee27200ce | ||
|
|
e6c2a01b8a | ||
|
|
3b7e1d0ed9 | ||
|
|
2e08eef04e | ||
|
|
e5986b5a8b | ||
|
|
c5da8b07ae | ||
|
|
681acd8203 | ||
|
|
73ab6cea07 | ||
|
|
4476ca82de | ||
|
|
e2be29c712 | ||
|
|
0e81eabda6 | ||
|
|
f7b5261ad9 | ||
|
|
bb54af4630 | ||
|
|
b7b883649c | ||
|
|
06f94a6a48 | ||
|
|
1e478479be | ||
|
|
41fc41bee4 | ||
|
|
84cf505511 | ||
|
|
dbe4f52871 | ||
|
|
da4a56ba6b | ||
|
|
35d3fa0f16 | ||
|
|
d7a0d18899 | ||
|
|
8b3842a9fc | ||
|
|
46d3f5b351 | ||
|
|
e2713e25ba | ||
|
|
1c93399841 | ||
|
|
58fa420fea | ||
|
|
620bacc6b5 | ||
|
|
c5e8337a69 | ||
|
|
44e22a9fc6 | ||
|
|
36ffe7e88e | ||
|
|
2a70c274fb | ||
|
|
44c7958980 | ||
|
|
5e20cb9537 | ||
|
|
1c6c59c199 | ||
|
|
b86f3022f7 | ||
|
|
04d4f89834 | ||
|
|
abd7c041f4 | ||
|
|
e945cefbd3 |
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.
|
||||||
90
.env.example
90
.env.example
@@ -87,9 +87,9 @@ JWT_SECRET=your_jwt_secret_here
|
|||||||
# The frontend URL that is allowed to make requests to the backend
|
# The frontend URL that is allowed to make requests to the backend
|
||||||
# IMPORTANT: Must match your frontend URL exactly!
|
# IMPORTANT: Must match your frontend URL exactly!
|
||||||
# Development: http://localhost:5173 (Vite dev server)
|
# Development: http://localhost:5173 (Vite dev server)
|
||||||
# Production: https://dashboard.yourdomain.com
|
# Production: https://portal.feuerwehr-rems.at
|
||||||
# Multiple origins: Use comma-separated values (if supported by your setup)
|
# Multiple origins: Use comma-separated values (if supported by your setup)
|
||||||
CORS_ORIGIN=http://localhost:80
|
CORS_ORIGIN=https://portal.feuerwehr-rems.at
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# FRONTEND CONFIGURATION
|
# FRONTEND CONFIGURATION
|
||||||
@@ -103,16 +103,16 @@ FRONTEND_PORT=80
|
|||||||
# API URL for frontend
|
# API URL for frontend
|
||||||
# The URL where the frontend will send API requests
|
# The URL where the frontend will send API requests
|
||||||
# Development: http://localhost:3000
|
# Development: http://localhost:3000
|
||||||
# Production: https://api.yourdomain.com
|
# Production: https://portal.feuerwehr-rems.at (proxied via nginx /api/)
|
||||||
# IMPORTANT: Must be accessible from the user's browser!
|
# IMPORTANT: Must be accessible from the user's browser!
|
||||||
VITE_API_URL=http://localhost:3000
|
VITE_API_URL=https://portal.feuerwehr-rems.at
|
||||||
|
|
||||||
# Authentik URL for frontend
|
# Authentik URL for frontend
|
||||||
# The base URL of your Authentik instance (without application path)
|
# The base URL of your Authentik instance (without application path)
|
||||||
# Development: http://localhost:9000
|
# Development: http://localhost:9000
|
||||||
# Production: https://auth.yourdomain.com
|
# Production: https://auth.firesuite.feuerwehr-rems.at
|
||||||
# IMPORTANT: Used for OAuth redirect URL construction
|
# IMPORTANT: Used for OAuth redirect URL construction
|
||||||
VITE_AUTHENTIK_URL=https://auth.yourdomain.com
|
AUTHENTIK_URL=https://auth.firesuite.feuerwehr-rems.at
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# AUTHENTIK OAUTH CONFIGURATION
|
# AUTHENTIK OAUTH CONFIGURATION
|
||||||
@@ -122,7 +122,7 @@ VITE_AUTHENTIK_URL=https://auth.yourdomain.com
|
|||||||
|
|
||||||
# OAuth Client ID
|
# OAuth Client ID
|
||||||
# From Authentik: Applications → Providers → Your Provider
|
# From Authentik: Applications → Providers → Your Provider
|
||||||
# REQUIRED for authentication to work!
|
# Used by both backend and frontend. REQUIRED for authentication to work!
|
||||||
AUTHENTIK_CLIENT_ID=your_client_id_here
|
AUTHENTIK_CLIENT_ID=your_client_id_here
|
||||||
|
|
||||||
# OAuth Client Secret
|
# OAuth Client Secret
|
||||||
@@ -133,23 +133,77 @@ AUTHENTIK_CLIENT_SECRET=your_client_secret_here
|
|||||||
|
|
||||||
# OAuth Issuer URL
|
# OAuth Issuer URL
|
||||||
# From Authentik: Applications → Providers → Your Provider → OpenID Configuration
|
# From Authentik: Applications → Providers → Your Provider → OpenID Configuration
|
||||||
# Format: https://auth.yourdomain.com/application/o/your-app-slug/
|
# Format: https://auth.firesuite.feuerwehr-rems.at/application/o/your-app-slug/
|
||||||
# IMPORTANT: Must end with a trailing slash (/)
|
# IMPORTANT: Must end with a trailing slash (/)
|
||||||
# Development: http://localhost:9000/application/o/feuerwehr-dashboard/
|
# Development: http://localhost:9000/application/o/feuerwehr-dashboard/
|
||||||
# Production: https://auth.yourdomain.com/application/o/feuerwehr-dashboard/
|
# Production: https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/
|
||||||
AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr-dashboard/
|
AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/
|
||||||
|
|
||||||
# OAuth Redirect URI
|
# OAuth Redirect URI
|
||||||
# The URL where Authentik will redirect after successful authentication
|
# The URL where Authentik will redirect after successful authentication
|
||||||
# Must match EXACTLY what you configured in Authentik
|
# Must match EXACTLY what you configured in Authentik
|
||||||
# Development: http://localhost:5173/auth/callback
|
# Development: http://localhost:5173/auth/callback
|
||||||
# Production: https://dashboard.yourdomain.com/auth/callback
|
# Production: https://portal.feuerwehr-rems.at/auth/callback
|
||||||
AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback
|
AUTHENTIK_REDIRECT_URI=https://portal.feuerwehr-rems.at/auth/callback
|
||||||
|
|
||||||
# OAuth Scopes (optional, has defaults)
|
# OAuth Scopes (optional, has defaults)
|
||||||
# Default: openid profile email
|
# Default: openid profile email
|
||||||
# AUTHENTIK_SCOPES=openid profile email
|
# AUTHENTIK_SCOPES=openid profile email
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# NEXTCLOUD CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Nextcloud base URL
|
||||||
|
# The URL of your Nextcloud instance
|
||||||
|
# Used by the backend for Nextcloud integration
|
||||||
|
NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# BOOKSTACK CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# BookStack base URL
|
||||||
|
# The URL of your BookStack instance (without trailing slash)
|
||||||
|
BOOKSTACK_URL=https://docs.feuerwehr-rems.at
|
||||||
|
|
||||||
|
# BookStack API Token ID
|
||||||
|
# Create via BookStack user profile → API Tokens
|
||||||
|
BOOKSTACK_TOKEN_ID=your_bookstack_token_id
|
||||||
|
|
||||||
|
# BookStack API Token Secret
|
||||||
|
# Create via BookStack user profile → API Tokens
|
||||||
|
# WARNING: Keep this secret!
|
||||||
|
BOOKSTACK_TOKEN_SECRET=your_bookstack_token_secret
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# VIKUNJA CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Vikunja base URL
|
||||||
|
# The URL of your Vikunja instance (without trailing slash)
|
||||||
|
VIKUNJA_URL=https://tasks.feuerwehr-rems.at
|
||||||
|
|
||||||
|
# Vikunja API Token
|
||||||
|
# Create via Vikunja user settings → API Tokens
|
||||||
|
# WARNING: Keep this secret!
|
||||||
|
VIKUNJA_API_TOKEN=your_vikunja_api_token
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# FDISK SYNC CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# FDISK login credentials
|
||||||
|
# Used by the fdisk-sync service to scrape member data from app.fdisk.at
|
||||||
|
# REQUIRED for the sync service to work
|
||||||
|
FDISK_USERNAME=your_fdisk_username
|
||||||
|
FDISK_PASSWORD=your_fdisk_password
|
||||||
|
|
||||||
|
# Internal URL of the fdisk-sync control server
|
||||||
|
# Used by the backend to proxy manual trigger and log requests
|
||||||
|
# In Docker Compose this is fixed — only change if you remap the port
|
||||||
|
FDISK_SYNC_URL=http://fdisk-sync:3001
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# LOGGING CONFIGURATION (Optional)
|
# LOGGING CONFIGURATION (Optional)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -210,8 +264,10 @@ AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback
|
|||||||
# VITE_API_URL=http://localhost:3000
|
# VITE_API_URL=http://localhost:3000
|
||||||
# AUTHENTIK_CLIENT_ID=dev_client_id
|
# AUTHENTIK_CLIENT_ID=dev_client_id
|
||||||
# AUTHENTIK_CLIENT_SECRET=dev_client_secret
|
# AUTHENTIK_CLIENT_SECRET=dev_client_secret
|
||||||
|
# AUTHENTIK_URL=http://localhost:9000
|
||||||
# AUTHENTIK_ISSUER=http://localhost:9000/application/o/feuerwehr-dashboard/
|
# AUTHENTIK_ISSUER=http://localhost:9000/application/o/feuerwehr-dashboard/
|
||||||
# AUTHENTIK_REDIRECT_URI=http://localhost:5173/auth/callback
|
# AUTHENTIK_REDIRECT_URI=http://localhost:5173/auth/callback
|
||||||
|
# NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at
|
||||||
# LOG_LEVEL=debug
|
# LOG_LEVEL=debug
|
||||||
#
|
#
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -227,13 +283,15 @@ AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback
|
|||||||
# BACKEND_PORT=3000
|
# BACKEND_PORT=3000
|
||||||
# NODE_ENV=production
|
# NODE_ENV=production
|
||||||
# JWT_SECRET=<generated-with-openssl-rand-base64-32>
|
# JWT_SECRET=<generated-with-openssl-rand-base64-32>
|
||||||
# CORS_ORIGIN=https://dashboard.yourdomain.com
|
# CORS_ORIGIN=https://portal.feuerwehr-rems.at
|
||||||
# FRONTEND_PORT=80
|
# FRONTEND_PORT=80
|
||||||
# VITE_API_URL=https://api.yourdomain.com
|
# VITE_API_URL=https://portal.feuerwehr-rems.at
|
||||||
# AUTHENTIK_CLIENT_ID=<from-authentik>
|
# AUTHENTIK_CLIENT_ID=<from-authentik>
|
||||||
# AUTHENTIK_CLIENT_SECRET=<from-authentik>
|
# AUTHENTIK_CLIENT_SECRET=<from-authentik>
|
||||||
# AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr-dashboard/
|
# AUTHENTIK_URL=https://auth.firesuite.feuerwehr-rems.at
|
||||||
# AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback
|
# AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/
|
||||||
|
# AUTHENTIK_REDIRECT_URI=https://portal.feuerwehr-rems.at/auth/callback
|
||||||
|
# NEXTCLOUD_URL=https://cloud.feuerwehr-rems.at
|
||||||
# LOG_LEVEL=info
|
# LOG_LEVEL=info
|
||||||
#
|
#
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ http://localhost:3000
|
|||||||
|
|
||||||
### Production
|
### Production
|
||||||
```
|
```
|
||||||
https://api.yourdomain.com
|
https://start.feuerwehr-rems.at
|
||||||
```
|
```
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
@@ -155,7 +155,7 @@ Check if the API is running and healthy.
|
|||||||
**Request**:
|
**Request**:
|
||||||
```http
|
```http
|
||||||
GET /health HTTP/1.1
|
GET /health HTTP/1.1
|
||||||
Host: api.yourdomain.com
|
Host: start.feuerwehr-rems.at
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response**:
|
**Response**:
|
||||||
@@ -197,7 +197,7 @@ Handle OAuth callback and exchange authorization code for tokens.
|
|||||||
**Request Example**:
|
**Request Example**:
|
||||||
```http
|
```http
|
||||||
POST /api/auth/callback HTTP/1.1
|
POST /api/auth/callback HTTP/1.1
|
||||||
Host: api.yourdomain.com
|
Host: start.feuerwehr-rems.at
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -295,7 +295,7 @@ Refresh an expired access token using a refresh token.
|
|||||||
Host: start.feuerwehr-rems.at
|
Host: start.feuerwehr-rems.at
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Success Response**:
|
**Success Response**:
|
||||||
```http
|
```http
|
||||||
@@ -370,7 +370,7 @@ Authorization: Bearer <access-token>
|
|||||||
|
|
||||||
**Success Response**:
|
**Success Response**:
|
||||||
```http
|
```http
|
||||||
HTTP/1.1 200 OK
|
HTTP/1.1 200 OK
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -407,7 +407,7 @@ Authorization: Bearer <access-token>
|
|||||||
|
|
||||||
**Success Response**:
|
**Success Response**:
|
||||||
```http
|
```http
|
||||||
HTTP/1.1 200 OK
|
HTTP/1.1 200 OK
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -479,10 +479,10 @@ HTTP/1.1 404 Not Found
|
|||||||
redirect_uri: 'https://start.feuerwehr-rems.at/auth/callback',
|
redirect_uri: 'https://start.feuerwehr-rems.at/auth/callback',
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
scope: 'openid profile email'
|
scope: 'openid profile email'
|
||||||
});
|
});
|
||||||
|
|
||||||
window.location.href = `${authentikAuthUrl}?${params}`;
|
window.location.href = `${authentikAuthUrl}?${params}`;
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Step 2: Authentik Redirects Back
|
#### Step 2: Authentik Redirects Back
|
||||||
|
|
||||||
@@ -494,13 +494,13 @@ window.location.href = `${authentikAuthUrl}?${params}`;
|
|||||||
#### Step 3: Exchange Code for Tokens
|
#### Step 3: Exchange Code for Tokens
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://api.yourdomain.com/api/auth/callback \
|
curl -X POST https://start.feuerwehr-rems.at/api/auth/callback \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"code": "abc123def456"
|
"code": "abc123def456"
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -532,14 +532,14 @@ Response:
|
|||||||
|
|
||||||
#### Step 5: Refresh Token When Expired
|
#### Step 5: Refresh Token When Expired
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://start.feuerwehr-rems.at/api/auth/refresh \
|
curl -X POST https://start.feuerwehr-rems.at/api/auth/refresh \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{
|
-d '{
|
||||||
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### JavaScript/TypeScript Examples
|
### JavaScript/TypeScript Examples
|
||||||
|
|
||||||
#### Using Axios
|
#### Using Axios
|
||||||
@@ -553,7 +553,7 @@ curl -X POST https://api.yourdomain.com/api/auth/refresh \
|
|||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: API_URL,
|
baseURL: API_URL,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -612,32 +612,32 @@ export const logout = async () => {
|
|||||||
#### Login Callback
|
#### Login Callback
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://start.feuerwehr-rems.at/api/auth/callback \
|
curl -X POST https://start.feuerwehr-rems.at/api/auth/callback \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"code":"your_auth_code"}'
|
-d '{"code":"your_auth_code"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Get Current User
|
#### Get Current User
|
||||||
```bash
|
```bash
|
||||||
curl -X GET https://start.feuerwehr-rems.at/api/user/me \
|
curl -X GET https://start.feuerwehr-rems.at/api/user/me \
|
||||||
-H "Authorization: Bearer your_access_token"
|
-H "Authorization: Bearer your_access_token"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Refresh Token
|
#### Refresh Token
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://api.yourdomain.com/api/auth/refresh \
|
curl -X POST https://start.feuerwehr-rems.at/api/auth/refresh \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"refreshToken":"your_refresh_token"}'
|
-d '{"refreshToken":"your_refresh_token"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Logout
|
#### Logout
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://start.feuerwehr-rems.at/api/auth/logout \
|
curl -X POST https://start.feuerwehr-rems.at/api/auth/logout \
|
||||||
-H "Authorization: Bearer your_access_token"
|
-H "Authorization: Bearer your_access_token"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
### HTTPS Required in Production
|
### HTTPS Required in Production
|
||||||
|
|
||||||
Always use HTTPS for API requests in production to protect tokens and sensitive data.
|
Always use HTTPS for API requests in production to protect tokens and sensitive data.
|
||||||
|
|
||||||
@@ -660,7 +660,7 @@ The API is configured to only accept requests from allowed origins:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Ensure `CORS_ORIGIN` environment variable matches your frontend URL exactly.
|
Ensure `CORS_ORIGIN` environment variable matches your frontend URL exactly.
|
||||||
|
|
||||||
### Rate Limiting
|
### Rate Limiting
|
||||||
|
|
||||||
Respect rate limits to avoid being temporarily blocked. Implement exponential backoff for failed requests.
|
Respect rate limits to avoid being temporarily blocked. Implement exponential backoff for failed requests.
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ Before you begin, you need:
|
|||||||
|
|
||||||
- An Authentik instance (self-hosted or cloud)
|
- An Authentik instance (self-hosted or cloud)
|
||||||
- Admin access to Authentik
|
- Admin access to Authentik
|
||||||
- Your Feuerwehr Dashboard URL (e.g., `https://dashboard.yourdomain.com`)
|
- Your Feuerwehr Dashboard URL (e.g., `https://start.feuerwehr-rems.at`)
|
||||||
- Your backend API URL (e.g., `https://api.yourdomain.com`)
|
- Your backend API URL (e.g., `https://start.feuerwehr-rems.at`)
|
||||||
|
|
||||||
## Authentik Installation
|
## Authentik Installation
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ Protocol Settings:
|
|||||||
```
|
```
|
||||||
http://localhost:5173/auth/callback
|
http://localhost:5173/auth/callback
|
||||||
http://localhost/auth/callback
|
http://localhost/auth/callback
|
||||||
https://dashboard.yourdomain.com/auth/callback
|
https://start.feuerwehr-rems.at/auth/callback
|
||||||
```
|
```
|
||||||
|
|
||||||
Add one URI per line. Include all environments (development, staging, production).
|
Add one URI per line. Include all environments (development, staging, production).
|
||||||
@@ -173,7 +173,7 @@ Configure the application:
|
|||||||
Name: Feuerwehr Dashboard
|
Name: Feuerwehr Dashboard
|
||||||
Slug: feuerwehr-dashboard
|
Slug: feuerwehr-dashboard
|
||||||
Provider: Feuerwehr Dashboard Provider (select from dropdown)
|
Provider: Feuerwehr Dashboard Provider (select from dropdown)
|
||||||
Launch URL: https://dashboard.yourdomain.com
|
Launch URL: https://start.feuerwehr-rems.at
|
||||||
```
|
```
|
||||||
|
|
||||||
**UI Settings** (optional):
|
**UI Settings** (optional):
|
||||||
@@ -256,10 +256,10 @@ This is the Vite dev server URL.
|
|||||||
### Production Environment
|
### Production Environment
|
||||||
|
|
||||||
```
|
```
|
||||||
https://dashboard.yourdomain.com/auth/callback
|
https://start.feuerwehr-rems.at/auth/callback
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `yourdomain.com` with your actual domain.
|
Replace `feuerwehr-rems.at` with your actual domain.
|
||||||
|
|
||||||
### Docker Local Testing
|
### Docker Local Testing
|
||||||
|
|
||||||
@@ -317,11 +317,11 @@ const scopes = 'openid profile email';
|
|||||||
|
|
||||||
1. In the provider details, find **OpenID Configuration URL**:
|
1. In the provider details, find **OpenID Configuration URL**:
|
||||||
```
|
```
|
||||||
https://auth.yourdomain.com/application/o/feuerwehr-dashboard/.well-known/openid-configuration
|
https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/.well-known/openid-configuration
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Important URLs from this configuration:
|
2. Important URLs from this configuration:
|
||||||
- **Issuer**: `https://auth.yourdomain.com/application/o/feuerwehr-dashboard/`
|
- **Issuer**: `https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/`
|
||||||
- **Authorization Endpoint**: Auto-discovered
|
- **Authorization Endpoint**: Auto-discovered
|
||||||
- **Token Endpoint**: Auto-discovered
|
- **Token Endpoint**: Auto-discovered
|
||||||
- **Userinfo Endpoint**: Auto-discovered
|
- **Userinfo Endpoint**: Auto-discovered
|
||||||
@@ -334,8 +334,8 @@ Update your Feuerwehr Dashboard `.env` file:
|
|||||||
# Authentik OAuth Configuration
|
# Authentik OAuth Configuration
|
||||||
AUTHENTIK_CLIENT_ID=<your-client-id>
|
AUTHENTIK_CLIENT_ID=<your-client-id>
|
||||||
AUTHENTIK_CLIENT_SECRET=<your-client-secret>
|
AUTHENTIK_CLIENT_SECRET=<your-client-secret>
|
||||||
AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr-dashboard/
|
AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/
|
||||||
AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback
|
AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback
|
||||||
|
|
||||||
# For development, use:
|
# For development, use:
|
||||||
# AUTHENTIK_ISSUER=http://localhost:9000/application/o/feuerwehr-dashboard/
|
# AUTHENTIK_ISSUER=http://localhost:9000/application/o/feuerwehr-dashboard/
|
||||||
@@ -361,7 +361,7 @@ AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback
|
|||||||
2. **Open the dashboard** in your browser:
|
2. **Open the dashboard** in your browser:
|
||||||
```
|
```
|
||||||
Development: http://localhost:5173
|
Development: http://localhost:5173
|
||||||
Production: https://dashboard.yourdomain.com
|
Production: https://start.feuerwehr-rems.at
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Click "Login" button**
|
3. **Click "Login" button**
|
||||||
@@ -441,7 +441,7 @@ In the dashboard:
|
|||||||
**Solution**:
|
**Solution**:
|
||||||
1. Ensure `CORS_ORIGIN` in backend `.env` matches frontend URL
|
1. Ensure `CORS_ORIGIN` in backend `.env` matches frontend URL
|
||||||
2. For development: `CORS_ORIGIN=http://localhost:5173`
|
2. For development: `CORS_ORIGIN=http://localhost:5173`
|
||||||
3. For production: `CORS_ORIGIN=https://dashboard.yourdomain.com`
|
3. For production: `CORS_ORIGIN=https://start.feuerwehr-rems.at`
|
||||||
4. Restart backend after changing CORS settings
|
4. Restart backend after changing CORS settings
|
||||||
|
|
||||||
### Issue 4: Token Validation Failed
|
### Issue 4: Token Validation Failed
|
||||||
@@ -561,7 +561,7 @@ After configuration, verify:
|
|||||||
Client Type: Confidential
|
Client Type: Confidential
|
||||||
Client ID: <auto-generated>
|
Client ID: <auto-generated>
|
||||||
Client Secret: <auto-generated>
|
Client Secret: <auto-generated>
|
||||||
Redirect URIs: https://dashboard.yourdomain.com/auth/callback
|
Redirect URIs: https://start.feuerwehr-rems.at/auth/callback
|
||||||
Scopes: openid, profile, email
|
Scopes: openid, profile, email
|
||||||
Access Token Validity: 3600
|
Access Token Validity: 3600
|
||||||
Refresh Token Validity: 86400
|
Refresh Token Validity: 86400
|
||||||
@@ -571,8 +571,8 @@ Refresh Token Validity: 86400
|
|||||||
```bash
|
```bash
|
||||||
AUTHENTIK_CLIENT_ID=<from-authentik>
|
AUTHENTIK_CLIENT_ID=<from-authentik>
|
||||||
AUTHENTIK_CLIENT_SECRET=<from-authentik>
|
AUTHENTIK_CLIENT_SECRET=<from-authentik>
|
||||||
AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr-dashboard/
|
AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/
|
||||||
AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback
|
AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security Best Practices
|
## Security Best Practices
|
||||||
|
|||||||
24
CLAUDE.md
Normal file
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)
|
||||||
@@ -154,19 +154,19 @@ NODE_ENV=production
|
|||||||
JWT_SECRET=<generated-jwt-secret>
|
JWT_SECRET=<generated-jwt-secret>
|
||||||
|
|
||||||
# CORS - Set to your domain!
|
# CORS - Set to your domain!
|
||||||
CORS_ORIGIN=https://dashboard.yourdomain.com
|
CORS_ORIGIN=https://start.feuerwehr-rems.at
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
FRONTEND_PORT=80
|
FRONTEND_PORT=80
|
||||||
|
|
||||||
# API URL - Set to your backend URL
|
# API URL - Set to your backend URL
|
||||||
VITE_API_URL=https://api.yourdomain.com
|
VITE_API_URL=https://start.feuerwehr-rems.at
|
||||||
|
|
||||||
# Authentik OAuth (from Authentik setup)
|
# Authentik OAuth (from Authentik setup)
|
||||||
AUTHENTIK_CLIENT_ID=<your-client-id>
|
AUTHENTIK_CLIENT_ID=<your-client-id>
|
||||||
AUTHENTIK_CLIENT_SECRET=<your-client-secret>
|
AUTHENTIK_CLIENT_SECRET=<your-client-secret>
|
||||||
AUTHENTIK_ISSUER=https://auth.yourdomain.com/application/o/feuerwehr/
|
AUTHENTIK_ISSUER=https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr/
|
||||||
AUTHENTIK_REDIRECT_URI=https://dashboard.yourdomain.com/auth/callback
|
AUTHENTIK_REDIRECT_URI=https://start.feuerwehr-rems.at/auth/callback
|
||||||
```
|
```
|
||||||
|
|
||||||
Secure the .env file:
|
Secure the .env file:
|
||||||
@@ -253,7 +253,7 @@ Key points for production:
|
|||||||
Create `Caddyfile`:
|
Create `Caddyfile`:
|
||||||
|
|
||||||
```caddy
|
```caddy
|
||||||
dashboard.yourdomain.com {
|
start.feuerwehr-rems.at {
|
||||||
reverse_proxy localhost:80
|
reverse_proxy localhost:80
|
||||||
encode gzip
|
encode gzip
|
||||||
|
|
||||||
@@ -265,7 +265,7 @@ dashboard.yourdomain.com {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
api.yourdomain.com {
|
start.feuerwehr-rems.at {
|
||||||
reverse_proxy localhost:3000
|
reverse_proxy localhost:3000
|
||||||
encode gzip
|
encode gzip
|
||||||
}
|
}
|
||||||
@@ -299,7 +299,7 @@ Create Nginx configuration (`/etc/nginx/sites-available/feuerwehr`):
|
|||||||
```nginx
|
```nginx
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name dashboard.yourdomain.com;
|
server_name start.feuerwehr-rems.at;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://localhost:80;
|
proxy_pass http://localhost:80;
|
||||||
@@ -312,7 +312,7 @@ server {
|
|||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name api.yourdomain.com;
|
server_name start.feuerwehr-rems.at;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://localhost:3000;
|
proxy_pass http://localhost:3000;
|
||||||
@@ -330,7 +330,7 @@ Enable and obtain SSL:
|
|||||||
sudo ln -s /etc/nginx/sites-available/feuerwehr /etc/nginx/sites-enabled/
|
sudo ln -s /etc/nginx/sites-available/feuerwehr /etc/nginx/sites-enabled/
|
||||||
sudo nginx -t
|
sudo nginx -t
|
||||||
sudo systemctl reload nginx
|
sudo systemctl reload nginx
|
||||||
sudo certbot --nginx -d dashboard.yourdomain.com -d api.yourdomain.com
|
sudo certbot --nginx -d start.feuerwehr-rems.at -d start.feuerwehr-rems.at
|
||||||
```
|
```
|
||||||
|
|
||||||
### Option 3: Using Docker with Traefik
|
### Option 3: Using Docker with Traefik
|
||||||
|
|||||||
@@ -217,8 +217,8 @@ docker run -p 80:80 feuerwehr-frontend:latest
|
|||||||
# 1. Set production environment variables
|
# 1. Set production environment variables
|
||||||
export POSTGRES_PASSWORD="secure_production_password"
|
export POSTGRES_PASSWORD="secure_production_password"
|
||||||
export JWT_SECRET="secure_jwt_secret_min_32_chars"
|
export JWT_SECRET="secure_jwt_secret_min_32_chars"
|
||||||
export CORS_ORIGIN="https://yourdomain.com"
|
export CORS_ORIGIN="https://feuerwehr-rems.at"
|
||||||
export VITE_API_URL="https://api.yourdomain.com"
|
export VITE_API_URL="https://start.feuerwehr-rems.at"
|
||||||
|
|
||||||
# 2. Build and start
|
# 2. Build and start
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
|
|||||||
51
Makefile
51
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: help dev prod stop logs logs-dev logs-prod rebuild rebuild-dev clean install test
|
.PHONY: help dev prod stop logs logs-dev logs-prod rebuild rebuild-dev clean install test migrate
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
help:
|
help:
|
||||||
@@ -10,21 +10,22 @@ help:
|
|||||||
@echo " make rebuild-dev - Rebuild development services"
|
@echo " make rebuild-dev - Rebuild development services"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Production:"
|
@echo "Production:"
|
||||||
@echo " make prod - Deploy production environment"
|
@echo " make prod - Deploy production environment (includes migrations)"
|
||||||
|
@echo " make migrate - Run database migrations against production DB"
|
||||||
@echo " make logs-prod - Show production logs"
|
@echo " make logs-prod - Show production logs"
|
||||||
@echo " make rebuild - Rebuild production services"
|
@echo " make rebuild - Rebuild production services"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "General:"
|
@echo "General:"
|
||||||
@echo " make stop - Stop all services"
|
@echo " make stop - Stop all services"
|
||||||
@echo " make clean - Remove all containers and volumes"
|
@echo " make clean - Remove all containers and volumes"
|
||||||
@echo " make install - Install dependencies for backend and frontend"
|
@echo " make install - Install dependencies for backend, frontend and sync"
|
||||||
@echo " make test - Run tests"
|
@echo " make test - Run tests"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
|
||||||
# Development
|
# Development
|
||||||
dev:
|
dev:
|
||||||
@echo "Starting local development database..."
|
@echo "Starting local development database..."
|
||||||
docker-compose -f docker-compose.dev.yml up -d
|
docker compose -f docker-compose.dev.yml up -d
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Database is ready at localhost:5432"
|
@echo "Database is ready at localhost:5432"
|
||||||
@echo "Database: feuerwehr_dev"
|
@echo "Database: feuerwehr_dev"
|
||||||
@@ -32,10 +33,10 @@ dev:
|
|||||||
@echo "Password: dev_password"
|
@echo "Password: dev_password"
|
||||||
|
|
||||||
logs-dev:
|
logs-dev:
|
||||||
docker-compose -f docker-compose.dev.yml logs -f
|
docker compose -f docker-compose.dev.yml logs -f
|
||||||
|
|
||||||
rebuild-dev:
|
rebuild-dev:
|
||||||
docker-compose -f docker-compose.dev.yml up -d --build --force-recreate
|
docker compose -f docker-compose.dev.yml up -d --build --force-recreate
|
||||||
|
|
||||||
# Production
|
# Production
|
||||||
prod:
|
prod:
|
||||||
@@ -46,24 +47,43 @@ prod:
|
|||||||
exit 1; \
|
exit 1; \
|
||||||
fi
|
fi
|
||||||
@echo "Starting production deployment..."
|
@echo "Starting production deployment..."
|
||||||
docker-compose -f docker-compose.yml up -d --build
|
docker compose -f docker-compose.yml up -d --build
|
||||||
|
@echo ""
|
||||||
|
@echo "Running database migrations..."
|
||||||
|
@$(MAKE) migrate
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Production services are running!"
|
@echo "Production services are running!"
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
@if [ ! -f .env ]; then \
|
||||||
|
echo "Error: .env file not found! Run 'cp .env.example .env' first."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
@echo "Waiting for database to be ready..."
|
||||||
|
@until docker compose -f docker-compose.yml exec -T postgres \
|
||||||
|
pg_isready -U "$${POSTGRES_USER:-prod_user}" -d "$${POSTGRES_DB:-feuerwehr_prod}" \
|
||||||
|
> /dev/null 2>&1; do \
|
||||||
|
printf '.'; sleep 2; \
|
||||||
|
done
|
||||||
|
@echo ""
|
||||||
|
@echo "Running migrations..."
|
||||||
|
docker compose -f docker-compose.yml exec -T backend npm run migrate
|
||||||
|
@echo "Migrations complete!"
|
||||||
|
|
||||||
logs-prod:
|
logs-prod:
|
||||||
docker-compose -f docker-compose.yml logs -f
|
docker compose -f docker-compose.yml logs -f
|
||||||
|
|
||||||
logs:
|
logs:
|
||||||
@make logs-prod
|
@$(MAKE) logs-prod
|
||||||
|
|
||||||
rebuild:
|
rebuild:
|
||||||
docker-compose -f docker-compose.yml up -d --build --force-recreate
|
docker compose -f docker-compose.yml up -d --build --force-recreate
|
||||||
|
|
||||||
# General commands
|
# General commands
|
||||||
stop:
|
stop:
|
||||||
@echo "Stopping all services..."
|
@echo "Stopping all services..."
|
||||||
docker-compose -f docker-compose.yml down 2>/dev/null || true
|
docker compose -f docker-compose.yml down 2>/dev/null || true
|
||||||
docker-compose -f docker-compose.dev.yml down 2>/dev/null || true
|
docker compose -f docker-compose.dev.yml down 2>/dev/null || true
|
||||||
@echo "All services stopped"
|
@echo "All services stopped"
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
@@ -71,8 +91,8 @@ clean:
|
|||||||
@read -p "Are you sure? [y/N] " -n 1 -r; \
|
@read -p "Are you sure? [y/N] " -n 1 -r; \
|
||||||
echo; \
|
echo; \
|
||||||
if [ "$$REPLY" = "y" ] || [ "$$REPLY" = "Y" ]; then \
|
if [ "$$REPLY" = "y" ] || [ "$$REPLY" = "Y" ]; then \
|
||||||
docker-compose -f docker-compose.yml down -v 2>/dev/null || true; \
|
docker compose -f docker-compose.yml down -v 2>/dev/null || true; \
|
||||||
docker-compose -f docker-compose.dev.yml down -v 2>/dev/null || true; \
|
docker compose -f docker-compose.dev.yml down -v 2>/dev/null || true; \
|
||||||
docker images | grep feuerwehr | awk '{print $$3}' | xargs docker rmi -f 2>/dev/null || true; \
|
docker images | grep feuerwehr | awk '{print $$3}' | xargs docker rmi -f 2>/dev/null || true; \
|
||||||
echo "Cleanup complete!"; \
|
echo "Cleanup complete!"; \
|
||||||
else \
|
else \
|
||||||
@@ -87,6 +107,9 @@ install:
|
|||||||
@echo "Installing frontend dependencies..."
|
@echo "Installing frontend dependencies..."
|
||||||
cd frontend && npm install
|
cd frontend && npm install
|
||||||
@echo ""
|
@echo ""
|
||||||
|
@echo "Installing sync dependencies..."
|
||||||
|
cd sync && npm install
|
||||||
|
@echo ""
|
||||||
@echo "Dependencies installed!"
|
@echo "Dependencies installed!"
|
||||||
|
|
||||||
# Run tests
|
# Run tests
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
registry=https://registry.npmjs.org/
|
registry=https://registry.npmjs.org/
|
||||||
omit-lockfile-registry-resolved=true
|
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ COPY --from=builder /app/dist ./dist
|
|||||||
# Copy database migrations (needed for runtime)
|
# Copy database migrations (needed for runtime)
|
||||||
COPY --from=builder /app/src/database/migrations ./dist/database/migrations
|
COPY --from=builder /app/src/database/migrations ./dist/database/migrations
|
||||||
|
|
||||||
|
# Create logs and uploads directories
|
||||||
|
RUN mkdir -p /app/logs /app/uploads/bestellungen/thumbnails
|
||||||
|
|
||||||
# Change ownership to non-root user
|
# Change ownership to non-root user
|
||||||
RUN chown -R nodejs:nodejs /app
|
RUN chown -R nodejs:nodejs /app
|
||||||
|
|
||||||
|
|||||||
9
backend/package-lock.json
generated
9
backend/package-lock.json
generated
@@ -15,6 +15,7 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^8.2.1",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"jose": "^6.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
@@ -1069,6 +1070,14 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.2.1",
|
||||||
|
"integrity": "sha512-jUaKr1yrbfaImV7R2TN/b3IcZzsw38/chqMpo2XJ7i2F8AfM/lA4G1goC3JVEwg0H7UldTmSt3P68nt31W7/mw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsonwebtoken": {
|
"node_modules/jsonwebtoken": {
|
||||||
"version": "9.0.3",
|
"version": "9.0.3",
|
||||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "nodemon",
|
"dev": "nodemon",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
|
"migrate": "node -e \"require('./dist/config/database').runMigrations().then(() => { console.log('Done'); process.exit(0); }).catch((e) => { console.error(e.message); process.exit(1); })\"",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
@@ -20,13 +21,16 @@
|
|||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.2.1",
|
"express-rate-limit": "^8.2.1",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"jose": "^6.2.1",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
|
"multer": "^1.4.5-lts.2",
|
||||||
"winston": "^3.19.0",
|
"winston": "^3.19.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/multer": "^1.4.12",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^25.3.0",
|
"@types/node": "^25.3.0",
|
||||||
|
|||||||
@@ -2,12 +2,18 @@ import express, { Application, Request, Response } from 'express';
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import path from 'path';
|
||||||
import environment from './config/environment';
|
import environment from './config/environment';
|
||||||
import logger from './utils/logger';
|
import logger from './utils/logger';
|
||||||
import { errorHandler, notFoundHandler } from './middleware/error.middleware';
|
import { errorHandler, notFoundHandler } from './middleware/error.middleware';
|
||||||
|
import { requestTimeout } from './middleware/request-timeout.middleware';
|
||||||
|
import { authenticate } from './middleware/auth.middleware';
|
||||||
|
|
||||||
const app: Application = express();
|
const app: Application = express();
|
||||||
|
|
||||||
|
// Trust proxy (required for correct IP detection behind Traefik/Nginx)
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
|
||||||
// Security middleware
|
// Security middleware
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
|
|
||||||
@@ -17,21 +23,42 @@ app.use(cors({
|
|||||||
credentials: true,
|
credentials: true,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting - general API routes (applied below, after auth limiter)
|
||||||
const limiter = rateLimit({
|
|
||||||
|
// Rate limiting - auth routes (generous to avoid blocking logins during
|
||||||
|
// normal use; each OAuth flow = 1 callback + token exchange)
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 60, // 60 auth attempts per window (allows ~20 full login cycles)
|
||||||
|
message: 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.',
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/auth', authLimiter);
|
||||||
|
// General rate limiter — skip auth routes (own limiter above) and authenticated
|
||||||
|
// requests (Bearer token present). Auth middleware validates the token downstream;
|
||||||
|
// rate-limiting authenticated dashboard polling would cause 429 floods.
|
||||||
|
app.use('/api', rateLimit({
|
||||||
windowMs: environment.rateLimit.windowMs,
|
windowMs: environment.rateLimit.windowMs,
|
||||||
max: environment.rateLimit.max,
|
max: environment.rateLimit.max,
|
||||||
message: 'Too many requests from this IP, please try again later.',
|
message: 'Too many requests from this IP, please try again later.',
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
});
|
skip: (req) => {
|
||||||
|
if (req.path.startsWith('/auth')) return true;
|
||||||
app.use('/api', limiter);
|
const auth = req.headers.authorization;
|
||||||
|
return typeof auth === 'string' && auth.startsWith('Bearer ');
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Body parsing middleware
|
// Body parsing middleware
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
|
// Request timeout middleware
|
||||||
|
app.use(requestTimeout);
|
||||||
|
|
||||||
// Request logging middleware
|
// Request logging middleware
|
||||||
app.use((req: Request, _res: Response, next) => {
|
app.use((req: Request, _res: Response, next) => {
|
||||||
logger.info('Incoming request', {
|
logger.info('Incoming request', {
|
||||||
@@ -55,9 +82,72 @@ app.get('/health', (_req: Request, res: Response) => {
|
|||||||
// API routes
|
// API routes
|
||||||
import authRoutes from './routes/auth.routes';
|
import authRoutes from './routes/auth.routes';
|
||||||
import userRoutes from './routes/user.routes';
|
import userRoutes from './routes/user.routes';
|
||||||
|
import memberRoutes from './routes/member.routes';
|
||||||
|
import adminRoutes from './routes/admin.routes';
|
||||||
|
import trainingRoutes from './routes/training.routes';
|
||||||
|
import vehicleRoutes from './routes/vehicle.routes';
|
||||||
|
import incidentRoutes from './routes/incident.routes';
|
||||||
|
import equipmentRoutes from './routes/equipment.routes';
|
||||||
|
import nextcloudRoutes from './routes/nextcloud.routes';
|
||||||
|
import atemschutzRoutes from './routes/atemschutz.routes';
|
||||||
|
import eventsRoutes from './routes/events.routes';
|
||||||
|
import bookingRoutes from './routes/booking.routes';
|
||||||
|
import notificationRoutes from './routes/notification.routes';
|
||||||
|
import bookstackRoutes from './routes/bookstack.routes';
|
||||||
|
import vikunjaRoutes from './routes/vikunja.routes';
|
||||||
|
import bestellungRoutes from './routes/bestellung.routes';
|
||||||
|
import configRoutes from './routes/config.routes';
|
||||||
|
import serviceMonitorRoutes from './routes/serviceMonitor.routes';
|
||||||
|
import settingsRoutes from './routes/settings.routes';
|
||||||
|
import bannerRoutes from './routes/banner.routes';
|
||||||
|
import permissionRoutes from './routes/permission.routes';
|
||||||
|
import ausruestungsanfrageRoutes from './routes/ausruestungsanfrage.routes';
|
||||||
|
import issueRoutes from './routes/issue.routes';
|
||||||
|
import buchungskategorieRoutes from './routes/buchungskategorie.routes';
|
||||||
|
import checklistRoutes from './routes/checklist.routes';
|
||||||
|
import fahrzeugTypRoutes from './routes/fahrzeugTyp.routes';
|
||||||
|
import ausruestungTypRoutes from './routes/ausruestungTyp.routes';
|
||||||
|
import buchhaltungRoutes from './routes/buchhaltung.routes';
|
||||||
|
import personalEquipmentRoutes from './routes/personalEquipment.routes';
|
||||||
|
import toolConfigRoutes from './routes/toolConfig.routes';
|
||||||
|
import scheduledMessagesRoutes from './routes/scheduledMessages.routes';
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/api/user', userRoutes);
|
||||||
|
app.use('/api/members', memberRoutes);
|
||||||
|
app.use('/api/admin', adminRoutes);
|
||||||
|
app.use('/api/training', trainingRoutes);
|
||||||
|
app.use('/api/vehicles', vehicleRoutes);
|
||||||
|
app.use('/api/incidents', incidentRoutes);
|
||||||
|
app.use('/api/equipment', equipmentRoutes);
|
||||||
|
app.use('/api/atemschutz', atemschutzRoutes);
|
||||||
|
app.use('/api/nextcloud/talk', nextcloudRoutes);
|
||||||
|
app.use('/api/events', eventsRoutes);
|
||||||
|
app.use('/api/bookings', bookingRoutes);
|
||||||
|
app.use('/api/notifications', notificationRoutes);
|
||||||
|
app.use('/api/bookstack', bookstackRoutes);
|
||||||
|
app.use('/api/vikunja', vikunjaRoutes);
|
||||||
|
app.use('/api/bestellungen', bestellungRoutes);
|
||||||
|
app.use('/api/config', configRoutes);
|
||||||
|
app.use('/api/admin', serviceMonitorRoutes);
|
||||||
|
app.use('/api/admin/settings', settingsRoutes);
|
||||||
|
app.use('/api/settings', settingsRoutes);
|
||||||
|
app.use('/api/banners', bannerRoutes);
|
||||||
|
app.use('/api/permissions', permissionRoutes);
|
||||||
|
app.use('/api/ausruestungsanfragen', ausruestungsanfrageRoutes);
|
||||||
|
app.use('/api/issues', issueRoutes);
|
||||||
|
app.use('/api/buchungskategorien', buchungskategorieRoutes);
|
||||||
|
app.use('/api/checklisten', checklistRoutes);
|
||||||
|
app.use('/api/fahrzeug-typen', fahrzeugTypRoutes);
|
||||||
|
app.use('/api/ausruestung-typen', ausruestungTypRoutes);
|
||||||
|
app.use('/api/buchhaltung', buchhaltungRoutes);
|
||||||
|
app.use('/api/persoenliche-ausruestung', personalEquipmentRoutes);
|
||||||
|
app.use('/api/admin/tools', toolConfigRoutes);
|
||||||
|
app.use('/api/scheduled-messages', scheduledMessagesRoutes);
|
||||||
|
|
||||||
|
// Static file serving for uploads (authenticated)
|
||||||
|
const uploadsDir = process.env.NODE_ENV === 'production' ? '/app/uploads' : path.resolve(__dirname, '../../uploads');
|
||||||
|
app.use('/uploads', authenticate, express.static(uploadsDir));
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
@@ -11,15 +11,23 @@ interface AuthentikConfig {
|
|||||||
logoutEndpoint: string;
|
logoutEndpoint: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authentik's shared endpoints live at /application/o/, not at the per-app issuer path.
|
||||||
|
// Issuer example: https://auth.example.com/application/o/myapp/
|
||||||
|
// Token endpoint: https://auth.example.com/application/o/token/
|
||||||
|
const issuerUrl = new URL(environment.authentik.issuer);
|
||||||
|
const pathParts = issuerUrl.pathname.split('/').filter(Boolean);
|
||||||
|
const basePath = '/' + pathParts.slice(0, -1).join('/') + '/';
|
||||||
|
const baseEndpoint = `${issuerUrl.origin}${basePath}`;
|
||||||
|
|
||||||
const authentikConfig: AuthentikConfig = {
|
const authentikConfig: AuthentikConfig = {
|
||||||
issuer: environment.authentik.issuer,
|
issuer: environment.authentik.issuer,
|
||||||
clientId: environment.authentik.clientId,
|
clientId: environment.authentik.clientId,
|
||||||
clientSecret: environment.authentik.clientSecret,
|
clientSecret: environment.authentik.clientSecret,
|
||||||
redirectUri: environment.authentik.redirectUri,
|
redirectUri: environment.authentik.redirectUri,
|
||||||
tokenEndpoint: `${environment.authentik.issuer}token/`,
|
tokenEndpoint: `${baseEndpoint}token/`,
|
||||||
userInfoEndpoint: `${environment.authentik.issuer}userinfo/`,
|
userInfoEndpoint: `${baseEndpoint}userinfo/`,
|
||||||
authorizeEndpoint: `${environment.authentik.issuer}authorize/`,
|
authorizeEndpoint: `${baseEndpoint}authorize/`,
|
||||||
logoutEndpoint: `${environment.authentik.issuer}logout/`,
|
logoutEndpoint: `${baseEndpoint}end-session/`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default authentikConfig;
|
export default authentikConfig;
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
import { Pool, PoolConfig } from 'pg';
|
import { Pool, PoolConfig, types } from 'pg';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import environment from './environment';
|
import environment from './environment';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
// Override pg's default DATE parser: return plain 'YYYY-MM-DD' strings
|
||||||
|
// instead of JavaScript Date objects (which introduce timezone shifts).
|
||||||
|
types.setTypeParser(1082, (val: string) => val);
|
||||||
|
|
||||||
const poolConfig: PoolConfig = {
|
const poolConfig: PoolConfig = {
|
||||||
host: environment.database.host,
|
host: environment.database.host,
|
||||||
port: environment.database.port,
|
port: environment.database.port,
|
||||||
database: environment.database.name,
|
database: environment.database.name,
|
||||||
user: environment.database.user,
|
user: environment.database.user,
|
||||||
password: environment.database.password,
|
password: environment.database.password,
|
||||||
max: 20, // Maximum number of clients in the pool
|
max: 30, // Maximum number of clients in the pool
|
||||||
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
|
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
|
||||||
connectionTimeoutMillis: 2000, // Return an error if connection takes longer than 2 seconds
|
connectionTimeoutMillis: 5000, // Return an error if connection takes longer than 5 seconds
|
||||||
};
|
};
|
||||||
|
|
||||||
const pool = new Pool(poolConfig);
|
const pool = new Pool(poolConfig);
|
||||||
@@ -22,6 +26,17 @@ pool.on('error', (err) => {
|
|||||||
logger.error('Unexpected error on idle database client', err);
|
logger.error('Unexpected error on idle database client', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Log pool exhaustion warnings every 60s (only when requests are waiting)
|
||||||
|
setInterval(() => {
|
||||||
|
if (pool.waitingCount > 0) {
|
||||||
|
logger.warn('DB pool pressure detected', {
|
||||||
|
total: pool.totalCount,
|
||||||
|
idle: pool.idleCount,
|
||||||
|
waiting: pool.waitingCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 60_000).unref();
|
||||||
|
|
||||||
// Test database connection
|
// Test database connection
|
||||||
export const testConnection = async (): Promise<boolean> => {
|
export const testConnection = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
// Load environment-specific .env file
|
// Load from root .env (project-wide single source of truth).
|
||||||
const envFile = process.env.NODE_ENV === 'production'
|
// In Docker, env vars are already injected by docker-compose so this is a no-op.
|
||||||
? '.env.production'
|
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
|
||||||
: '.env.development';
|
|
||||||
|
|
||||||
dotenv.config({ path: path.resolve(__dirname, '../../', envFile) });
|
|
||||||
|
|
||||||
interface EnvironmentConfig {
|
interface EnvironmentConfig {
|
||||||
nodeEnv: string;
|
nodeEnv: string;
|
||||||
@@ -35,6 +32,16 @@ interface EnvironmentConfig {
|
|||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
redirectUri: string;
|
redirectUri: string;
|
||||||
};
|
};
|
||||||
|
nextcloudUrl: string;
|
||||||
|
bookstack: {
|
||||||
|
url: string;
|
||||||
|
tokenId: string;
|
||||||
|
tokenSecret: string;
|
||||||
|
};
|
||||||
|
vikunja: {
|
||||||
|
url: string;
|
||||||
|
apiToken: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const environment: EnvironmentConfig = {
|
const environment: EnvironmentConfig = {
|
||||||
@@ -48,7 +55,7 @@ const environment: EnvironmentConfig = {
|
|||||||
password: process.env.DB_PASSWORD || 'dev_password',
|
password: process.env.DB_PASSWORD || 'dev_password',
|
||||||
},
|
},
|
||||||
jwt: {
|
jwt: {
|
||||||
secret: process.env.JWT_SECRET || 'your-secret-key-change-in-production',
|
secret: process.env.JWT_SECRET || '',
|
||||||
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
expiresIn: process.env.JWT_EXPIRES_IN || '24h',
|
||||||
},
|
},
|
||||||
cors: {
|
cors: {
|
||||||
@@ -56,14 +63,51 @@ const environment: EnvironmentConfig = {
|
|||||||
},
|
},
|
||||||
rateLimit: {
|
rateLimit: {
|
||||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
|
||||||
max: parseInt(process.env.RATE_LIMIT_MAX || '100', 10),
|
max: parseInt(process.env.RATE_LIMIT_MAX || '300', 10),
|
||||||
},
|
},
|
||||||
authentik: {
|
authentik: {
|
||||||
issuer: process.env.AUTHENTIK_ISSUER || 'https://authentik.yourdomain.com/application/o/your-app/',
|
issuer: process.env.AUTHENTIK_ISSUER || 'https://auth.firesuite.feuerwehr-rems.at/application/o/feuerwehr-dashboard/',
|
||||||
clientId: process.env.AUTHENTIK_CLIENT_ID || 'your_client_id_here',
|
clientId: process.env.AUTHENTIK_CLIENT_ID || 'your_client_id_here',
|
||||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || 'your_client_secret_here',
|
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET || 'your_client_secret_here',
|
||||||
redirectUri: process.env.AUTHENTIK_REDIRECT_URI || 'http://localhost:5173/auth/callback',
|
redirectUri: process.env.AUTHENTIK_REDIRECT_URI || 'http://localhost:5173/auth/callback',
|
||||||
},
|
},
|
||||||
|
nextcloudUrl: process.env.NEXTCLOUD_URL || '',
|
||||||
|
bookstack: {
|
||||||
|
url: process.env.BOOKSTACK_URL || '',
|
||||||
|
tokenId: process.env.BOOKSTACK_TOKEN_ID || '',
|
||||||
|
tokenSecret: process.env.BOOKSTACK_TOKEN_SECRET || '',
|
||||||
|
},
|
||||||
|
vikunja: {
|
||||||
|
url: process.env.VIKUNJA_URL || '',
|
||||||
|
apiToken: process.env.VIKUNJA_API_TOKEN || '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function validateEnvironment(env: EnvironmentConfig): void {
|
||||||
|
const secret = env.jwt.secret;
|
||||||
|
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error(
|
||||||
|
'FATAL: JWT_SECRET is not set. ' +
|
||||||
|
'Set a strong, random secret of at least 32 characters before starting the server.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret === 'your-secret-key-change-in-production') {
|
||||||
|
throw new Error(
|
||||||
|
'FATAL: JWT_SECRET is still set to the known weak default value. ' +
|
||||||
|
'Replace it with a strong, random secret of at least 32 characters.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secret.length < 32) {
|
||||||
|
throw new Error(
|
||||||
|
`FATAL: JWT_SECRET is too short (${secret.length} characters). ` +
|
||||||
|
'A minimum of 32 characters is required.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateEnvironment(environment);
|
||||||
|
|
||||||
export default environment;
|
export default environment;
|
||||||
|
|||||||
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;
|
||||||
226
backend/src/controllers/atemschutz.controller.ts
Normal file
226
backend/src/controllers/atemschutz.controller.ts
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import atemschutzService from '../services/atemschutz.service';
|
||||||
|
import notificationService from '../services/notification.service';
|
||||||
|
import { CreateAtemschutzSchema, UpdateAtemschutzSchema } from '../models/atemschutz.model';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
// ── UUID validation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isValidUUID(s: string): boolean {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getUserId(req: Request): string {
|
||||||
|
return req.user!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controller ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AtemschutzController {
|
||||||
|
async list(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userGroups: string[] = (req.user as any)?.groups ?? [];
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const records = await atemschutzService.getAll(userGroups, userId);
|
||||||
|
res.status(200).json({ success: true, data: records });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Atemschutz list error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Atemschutzträger konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOne(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Atemschutz-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const record = await atemschutzService.getById(id);
|
||||||
|
if (!record) {
|
||||||
|
res.status(404).json({ success: false, message: 'Atemschutzträger nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: record });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Atemschutz getOne error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userGroups: string[] = (req.user as any)?.groups ?? [];
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const stats = await atemschutzService.getStats(userGroups, userId);
|
||||||
|
res.status(200).json({ success: true, data: stats });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Atemschutz getStats error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Atemschutz-Statistiken konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parsed = CreateAtemschutzSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const record = await atemschutzService.create(parsed.data, getUserId(req));
|
||||||
|
res.status(201).json({ success: true, data: record });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message?.includes('bereits ein Atemschutz-Eintrag')) {
|
||||||
|
res.status(409).json({ success: false, message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Atemschutz create error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Atemschutz-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = UpdateAtemschutzSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Object.keys(parsed.data).length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const record = await atemschutzService.update(id, parsed.data, getUserId(req));
|
||||||
|
if (!record) {
|
||||||
|
res.status(404).json({ success: false, message: 'Atemschutzträger nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: record });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'No fields to update') {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Atemschutz update error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByUserId(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(userId)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Benutzer-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const callerId = getUserId(req);
|
||||||
|
const callerGroups: string[] = (req.user as any)?.groups ?? [];
|
||||||
|
const privileged = ['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'];
|
||||||
|
const isPrivileged = callerGroups.some((g) => privileged.includes(g));
|
||||||
|
if (userId !== callerId && !isPrivileged) {
|
||||||
|
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const record = await atemschutzService.getByUserId(userId);
|
||||||
|
res.status(200).json({ success: true, data: record ?? null });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Atemschutz getByUserId error', { error, userId: req.params.userId });
|
||||||
|
res.status(500).json({ success: false, message: 'Atemschutzstatus konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMyStatus(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const userId = getUserId(req);
|
||||||
|
const record = await atemschutzService.getByUserId(userId);
|
||||||
|
if (!record) {
|
||||||
|
// User has no atemschutz entry — not an error, just no data
|
||||||
|
res.status(200).json({ success: true, data: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: record });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Atemschutz getMyStatus error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Persönlicher Atemschutz-Status konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getExpiring(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const expiring = await atemschutzService.getExpiringCertifications(30);
|
||||||
|
|
||||||
|
// Side-effect: create notifications for expiring certifications (dedup via DB constraint)
|
||||||
|
for (const item of expiring) {
|
||||||
|
if (item.untersuchung_status !== 'ok') {
|
||||||
|
await notificationService.createNotification({
|
||||||
|
user_id: item.user_id,
|
||||||
|
typ: 'atemschutz_expiry',
|
||||||
|
titel: item.untersuchung_status === 'abgelaufen'
|
||||||
|
? 'Atemschutztauglichkeitsuntersuchung abgelaufen'
|
||||||
|
: 'Atemschutztauglichkeitsuntersuchung läuft bald ab',
|
||||||
|
nachricht: `Ihre Atemschutztauglichkeitsuntersuchung ${item.untersuchung_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`,
|
||||||
|
schwere: item.untersuchung_status === 'abgelaufen' ? 'fehler' : 'warnung',
|
||||||
|
quell_typ: 'atemschutz_untersuchung',
|
||||||
|
quell_id: item.id,
|
||||||
|
link: '/atemschutz',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (item.leistungstest_status !== 'ok') {
|
||||||
|
await notificationService.createNotification({
|
||||||
|
user_id: item.user_id,
|
||||||
|
typ: 'atemschutz_expiry',
|
||||||
|
titel: item.leistungstest_status === 'abgelaufen'
|
||||||
|
? 'Leistungstest abgelaufen'
|
||||||
|
: 'Leistungstest läuft bald ab',
|
||||||
|
nachricht: `Ihr Leistungstest ${item.leistungstest_status === 'abgelaufen' ? 'ist abgelaufen' : 'läuft bald ab'}.`,
|
||||||
|
schwere: item.leistungstest_status === 'abgelaufen' ? 'fehler' : 'warnung',
|
||||||
|
quell_typ: 'atemschutz_leistungstest',
|
||||||
|
quell_id: item.id,
|
||||||
|
link: '/atemschutz',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: expiring });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Atemschutz getExpiring error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Ablaufende Zertifizierungen konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Atemschutz-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deleted = await atemschutzService.delete(id, getUserId(req));
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ success: false, message: 'Atemschutzträger nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, message: 'Atemschutzträger gelöscht' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Atemschutz delete error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Atemschutzträger konnte nicht gelöscht werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new AtemschutzController();
|
||||||
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,9 +1,57 @@
|
|||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
import authentikService from '../services/authentik.service';
|
import authentikService from '../services/authentik.service';
|
||||||
import tokenService from '../services/token.service';
|
import tokenService from '../services/token.service';
|
||||||
import userService from '../services/user.service';
|
import userService from '../services/user.service';
|
||||||
|
import memberService from '../services/member.service';
|
||||||
import logger from '../utils/logger';
|
import logger from '../utils/logger';
|
||||||
import { AuthRequest } from '../types/auth.types';
|
import auditService, { AuditAction, AuditResourceType } from '../services/audit.service';
|
||||||
|
import { extractIp, extractUserAgent } from '../middleware/audit.middleware';
|
||||||
|
import { getUserRole } from '../middleware/rbac.middleware';
|
||||||
|
import pool from '../config/database';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract given_name and family_name from Authentik userinfo.
|
||||||
|
* Falls back to splitting the `name` field if individual fields are missing or identical.
|
||||||
|
*/
|
||||||
|
function extractNames(userInfo: { name?: string; given_name?: string; family_name?: string }): {
|
||||||
|
given_name: string | undefined;
|
||||||
|
family_name: string | undefined;
|
||||||
|
} {
|
||||||
|
const givenName = userInfo.given_name?.trim();
|
||||||
|
const familyName = userInfo.family_name?.trim();
|
||||||
|
|
||||||
|
// If Authentik provides both and they differ, use them directly
|
||||||
|
// BUT: guard against the case where given_name is actually the full name
|
||||||
|
// (e.g. Authentik sends given_name="Matthias Hochmeister", family_name="Hochmeister")
|
||||||
|
if (givenName && familyName && givenName !== familyName) {
|
||||||
|
const looksLikeFullName =
|
||||||
|
givenName.includes(' ') &&
|
||||||
|
(givenName.endsWith(' ' + familyName) || givenName === familyName);
|
||||||
|
if (!looksLikeFullName) {
|
||||||
|
return { given_name: givenName, family_name: familyName };
|
||||||
|
}
|
||||||
|
// Fall through to split the name field
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to splitting the name field
|
||||||
|
if (userInfo.name) {
|
||||||
|
const parts = userInfo.name.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return {
|
||||||
|
given_name: parts[0],
|
||||||
|
family_name: parts.slice(1).join(' '),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Single word name — use as given_name only
|
||||||
|
return {
|
||||||
|
given_name: parts[0],
|
||||||
|
family_name: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { given_name: givenName, family_name: familyName };
|
||||||
|
}
|
||||||
|
|
||||||
class AuthController {
|
class AuthController {
|
||||||
/**
|
/**
|
||||||
@@ -11,11 +59,17 @@ class AuthController {
|
|||||||
* POST /api/auth/callback
|
* POST /api/auth/callback
|
||||||
*/
|
*/
|
||||||
async handleCallback(req: Request, res: Response): Promise<void> {
|
async handleCallback(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
const ip = extractIp(req);
|
||||||
const { code } = req.body as AuthRequest;
|
const userAgent = extractUserAgent(req);
|
||||||
|
|
||||||
// Validate code
|
try {
|
||||||
if (!code) {
|
const callbackSchema = z.object({
|
||||||
|
code: z.string().min(1),
|
||||||
|
redirect_uri: z.string().url().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const parseResult = callbackSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Authorization code is required',
|
message: 'Authorization code is required',
|
||||||
@@ -23,6 +77,8 @@ class AuthController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { code } = parseResult.data;
|
||||||
|
|
||||||
logger.info('Processing OAuth callback', { hasCode: !!code });
|
logger.info('Processing OAuth callback', { hasCode: !!code });
|
||||||
|
|
||||||
// Step 1: Exchange code for tokens
|
// Step 1: Exchange code for tokens
|
||||||
@@ -30,18 +86,36 @@ class AuthController {
|
|||||||
|
|
||||||
// Step 2: Get user info from Authentik
|
// Step 2: Get user info from Authentik
|
||||||
const userInfo = await authentikService.getUserInfo(tokens.access_token);
|
const userInfo = await authentikService.getUserInfo(tokens.access_token);
|
||||||
|
const groups = userInfo.groups ?? [];
|
||||||
|
const dashboardGroups = groups.filter((g: string) => g.startsWith('dashboard_'));
|
||||||
|
|
||||||
// Step 3: Verify ID token if present
|
// Step 3: Verify ID token if present
|
||||||
if (tokens.id_token) {
|
if (tokens.id_token) {
|
||||||
try {
|
try {
|
||||||
authentikService.verifyIdToken(tokens.id_token);
|
await authentikService.verifyIdToken(tokens.id_token);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('ID token verification failed', { error });
|
logger.error('ID token verification failed — continuing with userinfo (security event)', { error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Find or create user in database
|
// Step 4: Find or create user in database
|
||||||
let user = await userService.findByAuthentikSub(userInfo.sub);
|
let user = await userService.findByAuthentikSub(userInfo.sub);
|
||||||
|
let isNewUser = !user;
|
||||||
|
|
||||||
|
// Check for a FDISK-pre-created account to claim on first Authentik login
|
||||||
|
if (!user) {
|
||||||
|
const { given_name: fdiskGivenName, family_name: fdiskFamilyName } = extractNames(userInfo);
|
||||||
|
if (fdiskGivenName && fdiskFamilyName) {
|
||||||
|
const fdiskUser = await userService.findFdiskUserByName(fdiskGivenName, fdiskFamilyName);
|
||||||
|
if (fdiskUser) {
|
||||||
|
user = await userService.claimFdiskUser(fdiskUser.id, userInfo.sub, userInfo.email);
|
||||||
|
if (user) {
|
||||||
|
isNewUser = false;
|
||||||
|
logger.info('Claimed FDISK-pre-created user on first login', { userId: fdiskUser.id, sub: userInfo.sub });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
// User doesn't exist, create new user
|
// User doesn't exist, create new user
|
||||||
@@ -50,28 +124,53 @@ class AuthController {
|
|||||||
email: userInfo.email,
|
email: userInfo.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { given_name: newGivenName, family_name: newFamilyName } = extractNames(userInfo);
|
||||||
|
|
||||||
user = await userService.createUser({
|
user = await userService.createUser({
|
||||||
email: userInfo.email,
|
email: userInfo.email,
|
||||||
authentik_sub: userInfo.sub,
|
authentik_sub: userInfo.sub,
|
||||||
preferred_username: userInfo.preferred_username,
|
preferred_username: userInfo.preferred_username,
|
||||||
given_name: userInfo.given_name,
|
given_name: newGivenName,
|
||||||
family_name: userInfo.family_name,
|
family_name: newFamilyName,
|
||||||
name: userInfo.name,
|
name: userInfo.name,
|
||||||
profile_picture_url: userInfo.picture,
|
profile_picture_url: userInfo.picture,
|
||||||
});
|
});
|
||||||
} else {
|
|
||||||
// User exists, update last login
|
await userService.updateGroups(user.id, dashboardGroups);
|
||||||
logger.info('Existing user logging in', {
|
logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length });
|
||||||
userId: user.id,
|
await memberService.ensureProfileExists(user.id);
|
||||||
email: user.email,
|
|
||||||
|
// Audit: first-ever login (user record creation)
|
||||||
|
auditService.logAudit({
|
||||||
|
user_id: user.id,
|
||||||
|
user_email: user.email,
|
||||||
|
action: AuditAction.LOGIN,
|
||||||
|
resource_type: AuditResourceType.USER,
|
||||||
|
resource_id: user.id,
|
||||||
|
old_value: null,
|
||||||
|
new_value: { event: 'first_login', email: user.email },
|
||||||
|
ip_address: ip,
|
||||||
|
user_agent: userAgent,
|
||||||
|
metadata: { new_account: true },
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
await userService.updateLastLogin(user.id);
|
// User exists — check active status BEFORE any mutations
|
||||||
}
|
|
||||||
|
|
||||||
// Check if user is active
|
|
||||||
if (!user.is_active) {
|
if (!user.is_active) {
|
||||||
logger.warn('Inactive user attempted login', { userId: user.id });
|
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({
|
res.status(403).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'User account is inactive',
|
message: 'User account is inactive',
|
||||||
@@ -79,11 +178,54 @@ class AuthController {
|
|||||||
return;
|
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, dashboardGroups);
|
||||||
|
logger.info('Groups synced for user', { userId: user.id, groupCount: dashboardGroups.length });
|
||||||
|
await memberService.ensureProfileExists(user.id);
|
||||||
|
|
||||||
|
const { given_name: updatedGivenName, family_name: updatedFamilyName } = extractNames(userInfo);
|
||||||
|
|
||||||
|
// Refresh profile fields from Authentik on every login (including profile picture)
|
||||||
|
await userService.updateUser(user.id, {
|
||||||
|
name: userInfo.name,
|
||||||
|
given_name: updatedGivenName,
|
||||||
|
family_name: updatedFamilyName,
|
||||||
|
preferred_username: userInfo.preferred_username,
|
||||||
|
profile_picture_url: userInfo.picture || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Audit: returning user login
|
||||||
|
auditService.logAudit({
|
||||||
|
user_id: user.id,
|
||||||
|
user_email: user.email,
|
||||||
|
action: AuditAction.LOGIN,
|
||||||
|
resource_type: AuditResourceType.USER,
|
||||||
|
resource_id: user.id,
|
||||||
|
old_value: null,
|
||||||
|
new_value: null,
|
||||||
|
ip_address: ip,
|
||||||
|
user_agent: userAgent,
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract normalised names once for use in the response
|
||||||
|
const { given_name: resolvedGivenName, family_name: resolvedFamilyName } = extractNames(userInfo);
|
||||||
|
|
||||||
// Step 5: Generate internal JWT token
|
// Step 5: Generate internal JWT token
|
||||||
|
const role = await getUserRole(user.id);
|
||||||
const accessToken = tokenService.generateToken({
|
const accessToken = tokenService.generateToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
authentikSub: user.authentik_sub,
|
authentikSub: user.authentik_sub,
|
||||||
|
groups,
|
||||||
|
role,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Generate refresh token
|
// Generate refresh token
|
||||||
@@ -104,21 +246,40 @@ class AuthController {
|
|||||||
data: {
|
data: {
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
|
isNewUser,
|
||||||
user: {
|
user: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: userInfo.name || user.name,
|
||||||
preferredUsername: user.preferred_username,
|
preferredUsername: userInfo.preferred_username || user.preferred_username,
|
||||||
givenName: user.given_name,
|
givenName: resolvedGivenName || user.given_name,
|
||||||
familyName: user.family_name,
|
familyName: resolvedFamilyName || user.family_name,
|
||||||
profilePictureUrl: user.profile_picture_url,
|
profilePictureUrl: user.profile_picture_url,
|
||||||
isActive: user.is_active,
|
isActive: user.is_active,
|
||||||
|
groups,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('OAuth callback error', { error });
|
logger.error('OAuth callback error', { error });
|
||||||
|
|
||||||
|
// Audit the failed login attempt (user_id unknown at this point)
|
||||||
|
auditService.logAudit({
|
||||||
|
user_id: null,
|
||||||
|
user_email: null,
|
||||||
|
action: AuditAction.PERMISSION_DENIED,
|
||||||
|
resource_type: AuditResourceType.SYSTEM,
|
||||||
|
resource_id: null,
|
||||||
|
old_value: null,
|
||||||
|
new_value: null,
|
||||||
|
ip_address: ip,
|
||||||
|
user_agent: userAgent,
|
||||||
|
metadata: {
|
||||||
|
reason: 'oauth_callback_error',
|
||||||
|
error: error instanceof Error ? error.message : 'unknown',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : 'Authentication failed';
|
error instanceof Error ? error.message : 'Authentication failed';
|
||||||
|
|
||||||
@@ -134,15 +295,30 @@ class AuthController {
|
|||||||
* POST /api/auth/logout
|
* POST /api/auth/logout
|
||||||
*/
|
*/
|
||||||
async handleLogout(req: Request, res: Response): Promise<void> {
|
async handleLogout(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
const ip = extractIp(req);
|
||||||
// In a stateless JWT setup, logout is handled client-side by removing the token
|
const userAgent = extractUserAgent(req);
|
||||||
// However, we can log the event for audit purposes
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
// In a stateless JWT setup, logout is handled client-side by removing
|
||||||
|
// the token. We log the event for GDPR accountability.
|
||||||
if (req.user) {
|
if (req.user) {
|
||||||
logger.info('User logged out', {
|
logger.info('User logged out', {
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
email: req.user.email,
|
email: req.user.email,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
auditService.logAudit({
|
||||||
|
user_id: req.user.id,
|
||||||
|
user_email: req.user.email,
|
||||||
|
action: AuditAction.LOGOUT,
|
||||||
|
resource_type: AuditResourceType.USER,
|
||||||
|
resource_id: req.user.id,
|
||||||
|
old_value: null,
|
||||||
|
new_value: null,
|
||||||
|
ip_address: ip,
|
||||||
|
user_agent: userAgent,
|
||||||
|
metadata: {},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
@@ -165,9 +341,12 @@ class AuthController {
|
|||||||
*/
|
*/
|
||||||
async handleRefresh(req: Request, res: Response): Promise<void> {
|
async handleRefresh(req: Request, res: Response): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const { refreshToken } = req.body;
|
const refreshSchema = z.object({
|
||||||
|
refreshToken: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
if (!refreshToken) {
|
const parseResult = refreshSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Refresh token is required',
|
message: 'Refresh token is required',
|
||||||
@@ -175,6 +354,8 @@ class AuthController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { refreshToken } = parseResult.data;
|
||||||
|
|
||||||
// Verify refresh token
|
// Verify refresh token
|
||||||
let decoded;
|
let decoded;
|
||||||
try {
|
try {
|
||||||
@@ -214,10 +395,19 @@ class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate new access token
|
// Generate new access token
|
||||||
|
const role = await getUserRole(user.id);
|
||||||
|
// Fetch groups from DB so refreshed tokens retain group info
|
||||||
|
const groupsResult = await pool.query(
|
||||||
|
'SELECT authentik_groups FROM users WHERE id = $1',
|
||||||
|
[user.id]
|
||||||
|
);
|
||||||
|
const groups: string[] = groupsResult.rows[0]?.authentik_groups ?? [];
|
||||||
const accessToken = tokenService.generateToken({
|
const accessToken = tokenService.generateToken({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
authentikSub: user.authentik_sub,
|
authentikSub: user.authentik_sub,
|
||||||
|
groups,
|
||||||
|
role,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info('Token refreshed successfully', {
|
logger.info('Token refreshed successfully', {
|
||||||
|
|||||||
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();
|
||||||
330
backend/src/controllers/booking.controller.ts
Normal file
330
backend/src/controllers/booking.controller.ts
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { ZodError } from 'zod';
|
||||||
|
import bookingService from '../services/booking.service';
|
||||||
|
import vehicleService from '../services/vehicle.service';
|
||||||
|
import pool from '../config/database';
|
||||||
|
import { permissionService } from '../services/permission.service';
|
||||||
|
import {
|
||||||
|
CreateBuchungSchema,
|
||||||
|
UpdateBuchungSchema,
|
||||||
|
CancelBuchungSchema,
|
||||||
|
} from '../models/booking.model';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function isValidUUID(s: string): boolean {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZodError(res: Response, err: ZodError): void {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: err.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleConflictError(res: Response, err: Error): boolean {
|
||||||
|
if (err.message?.includes('außer Dienst')) {
|
||||||
|
res.status(409).json({ success: false, message: err.message, reason: 'out_of_service' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (err.message?.includes('bereits gebucht')) {
|
||||||
|
res.status(409).json({ success: false, message: err.message, reason: 'booking_conflict' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Controller
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class BookingController {
|
||||||
|
/**
|
||||||
|
* GET /api/bookings/vehicles
|
||||||
|
* Lightweight vehicle list for the booking form (no fahrzeuge:view needed).
|
||||||
|
*/
|
||||||
|
async getVehiclesForBooking(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT id, bezeichnung, amtliches_kennzeichen FROM fahrzeuge ORDER BY bezeichnung'
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: result.rows });
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch vehicles for booking', err);
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Fahrzeuge' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/bookings/calendar?from=&to=&fahrzeugId=
|
||||||
|
* Returns all non-cancelled bookings overlapping the given date range.
|
||||||
|
*/
|
||||||
|
async getCalendarRange(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { from, to, fahrzeugId } = req.query;
|
||||||
|
if (!from || !to) {
|
||||||
|
res.status(400).json({ success: false, message: 'from und to sind erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fromDate = new Date(from as string);
|
||||||
|
const toDate = new Date(to as string);
|
||||||
|
const [bookings, maintenanceWindows] = await Promise.all([
|
||||||
|
bookingService.getBookingsByRange(fromDate, toDate, fahrzeugId as string | undefined),
|
||||||
|
vehicleService.getMaintenanceWindows(fromDate, toDate),
|
||||||
|
]);
|
||||||
|
res.json({ success: true, data: { bookings, maintenanceWindows } });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking getCalendarRange error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/bookings/upcoming?limit=
|
||||||
|
* Returns the next upcoming non-cancelled bookings.
|
||||||
|
*/
|
||||||
|
async getUpcoming(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit as string) || 20;
|
||||||
|
const bookings = await bookingService.getUpcoming(limit);
|
||||||
|
res.json({ success: true, data: bookings });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking getUpcoming error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchungen konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/bookings/availability?fahrzeugId=&from=&to=
|
||||||
|
* Returns { available: true } when the vehicle has no conflicting booking.
|
||||||
|
*/
|
||||||
|
async checkAvailability(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { fahrzeugId, from, to, excludeId } = req.query;
|
||||||
|
if (!fahrzeugId || !from || !to) {
|
||||||
|
res
|
||||||
|
.status(400)
|
||||||
|
.json({ success: false, message: 'fahrzeugId, from und to sind erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const beginn = new Date(from as string);
|
||||||
|
const ende = new Date(to as string);
|
||||||
|
|
||||||
|
const outOfService = await bookingService.checkOutOfServiceConflict(
|
||||||
|
fahrzeugId as string, beginn, ende
|
||||||
|
);
|
||||||
|
if (outOfService) {
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
available: false,
|
||||||
|
reason: 'out_of_service',
|
||||||
|
ausserDienstVon: outOfService.ausser_dienst_von.toISOString(),
|
||||||
|
ausserDienstBis: outOfService.ausser_dienst_bis.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasConflict = await bookingService.checkConflict(
|
||||||
|
fahrzeugId as string, beginn, ende, excludeId as string | undefined
|
||||||
|
);
|
||||||
|
res.json({ success: true, data: { available: !hasConflict } });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking checkAvailability error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Verfügbarkeit konnte nicht geprüft werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/bookings/:id
|
||||||
|
* Returns a single booking with all joined fields.
|
||||||
|
*/
|
||||||
|
async getById(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const booking = await bookingService.getById(id);
|
||||||
|
if (!booking) {
|
||||||
|
res.status(404).json({ success: false, message: 'Buchung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: booking });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking getById error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchung konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/bookings
|
||||||
|
* Creates a new vehicle booking.
|
||||||
|
*/
|
||||||
|
async create(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parsed = CreateBuchungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
handleZodError(res, parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const booking = await bookingService.create(parsed.data, req.user!.id, req.body.ignoreOutOfService === true);
|
||||||
|
res.status(201).json({ success: true, data: booking });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (handleConflictError(res, error)) return;
|
||||||
|
logger.error('Booking create error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchung konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/bookings/:id
|
||||||
|
* Updates the provided fields of an existing booking.
|
||||||
|
*/
|
||||||
|
async update(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = UpdateBuchungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
handleZodError(res, parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Object.keys(parsed.data).length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const booking = await bookingService.update(id, parsed.data);
|
||||||
|
if (!booking) {
|
||||||
|
res.status(404).json({ success: false, message: 'Buchung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: booking });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'No fields to update') {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (handleConflictError(res, error)) return;
|
||||||
|
logger.error('Booking update error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchung konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/bookings/:id or PATCH /api/bookings/:id/cancel
|
||||||
|
* Soft-cancels a booking (sets abgesagt=TRUE).
|
||||||
|
* Allowed for booking creator or users with bookings:write permission.
|
||||||
|
*/
|
||||||
|
async cancel(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ownership: creator can cancel if they have cancel_own_bookings permission
|
||||||
|
const booking = await bookingService.getById(id);
|
||||||
|
if (!booking) {
|
||||||
|
res.status(404).json({ success: false, message: 'Buchung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isOwner = booking.gebucht_von === req.user!.id;
|
||||||
|
const groups: string[] = req.user?.groups ?? [];
|
||||||
|
const isAdmin = groups.includes('dashboard_admin');
|
||||||
|
const canCancelOwn = isAdmin || permissionService.hasPermission(groups, 'fahrzeugbuchungen:manage');
|
||||||
|
const canCancelAny = isAdmin || permissionService.hasPermission(groups, 'fahrzeugbuchungen:manage');
|
||||||
|
|
||||||
|
if (!(isOwner && canCancelOwn) && !canCancelAny) {
|
||||||
|
res.status(403).json({ success: false, message: 'Keine Berechtigung' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = CancelBuchungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
handleZodError(res, parsed.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await bookingService.cancel(id, parsed.data.abgesagt_grund);
|
||||||
|
res.json({ success: true, message: 'Buchung wurde storniert' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking cancel error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchung konnte nicht storniert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/bookings/:id/force
|
||||||
|
* Hard-deletes a booking record (admin only).
|
||||||
|
*/
|
||||||
|
async hardDelete(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Buchungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await bookingService.delete(id);
|
||||||
|
res.json({ success: true, message: 'Buchung gelöscht' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking hardDelete error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Buchung konnte nicht gelöscht werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/bookings/calendar-token
|
||||||
|
* Returns the user's iCal subscribe token and URL, creating it if needed.
|
||||||
|
*/
|
||||||
|
async getCalendarToken(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await bookingService.getOrCreateIcalToken(req.user!.id);
|
||||||
|
res.json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking getCalendarToken error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Kalender-Token konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/bookings/calendar.ics?token=&fahrzeugId=
|
||||||
|
* Returns an iCal file for the subscriber. No authentication required
|
||||||
|
* (token-based access).
|
||||||
|
*/
|
||||||
|
async getIcalExport(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { token, fahrzeugId } = req.query;
|
||||||
|
if (!token) {
|
||||||
|
res.status(400).send('Token required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ical = await bookingService.getIcalExport(
|
||||||
|
token as string,
|
||||||
|
fahrzeugId as string | undefined
|
||||||
|
);
|
||||||
|
if (!ical) {
|
||||||
|
res.status(404).send('Invalid token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="fahrzeugbuchungen.ics"');
|
||||||
|
res.send(ical);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Booking getIcalExport error', { error });
|
||||||
|
res.status(500).send('Internal server error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new BookingController();
|
||||||
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();
|
||||||
556
backend/src/controllers/equipment.controller.ts
Normal file
556
backend/src/controllers/equipment.controller.ts
Normal file
@@ -0,0 +1,556 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import equipmentService from '../services/equipment.service';
|
||||||
|
import { AusruestungStatus } from '../models/equipment.model';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
// ── UUID validation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isValidUUID(s: string): boolean {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zod Validation Schemas ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const AusruestungStatusEnum = z.enum([
|
||||||
|
AusruestungStatus.Einsatzbereit,
|
||||||
|
AusruestungStatus.Beschaedigt,
|
||||||
|
AusruestungStatus.InWartung,
|
||||||
|
AusruestungStatus.AusserDienst,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isoDate = z.string().regex(
|
||||||
|
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
|
||||||
|
'Erwartet ISO-Datum im Format YYYY-MM-DD'
|
||||||
|
);
|
||||||
|
|
||||||
|
const uuidString = z.string().regex(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||||
|
'Ungültige UUID'
|
||||||
|
);
|
||||||
|
|
||||||
|
const CreateAusruestungSchema = z.object({
|
||||||
|
bezeichnung: z.string().min(1).max(200),
|
||||||
|
kategorie_id: uuidString,
|
||||||
|
seriennummer: z.string().max(100).optional(),
|
||||||
|
inventarnummer: z.string().max(50).optional(),
|
||||||
|
hersteller: z.string().max(150).optional(),
|
||||||
|
baujahr: z.number().int().min(1950).max(2100).optional(),
|
||||||
|
status: AusruestungStatusEnum.optional(),
|
||||||
|
status_bemerkung: z.string().max(2000).optional(),
|
||||||
|
ist_wichtig: z.boolean().optional(),
|
||||||
|
fahrzeug_id: uuidString.optional(),
|
||||||
|
standort: z.string().min(1).max(150).optional(),
|
||||||
|
pruef_intervall_monate: z.number().int().min(1).optional(),
|
||||||
|
letzte_pruefung_am: isoDate.optional(),
|
||||||
|
naechste_pruefung_am: isoDate.optional(),
|
||||||
|
bemerkung: z.string().max(2000).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdateAusruestungSchema = z.object({
|
||||||
|
bezeichnung: z.string().min(1).max(200).optional(),
|
||||||
|
kategorie_id: uuidString.optional(),
|
||||||
|
seriennummer: z.string().max(100).nullable().optional(),
|
||||||
|
inventarnummer: z.string().max(50).nullable().optional(),
|
||||||
|
hersteller: z.string().max(150).nullable().optional(),
|
||||||
|
baujahr: z.number().int().min(1950).max(2100).nullable().optional(),
|
||||||
|
status: AusruestungStatusEnum.optional(),
|
||||||
|
status_bemerkung: z.string().max(2000).nullable().optional(),
|
||||||
|
ist_wichtig: z.boolean().optional(),
|
||||||
|
fahrzeug_id: uuidString.nullable().optional(),
|
||||||
|
standort: z.string().min(1).max(150).optional(),
|
||||||
|
pruef_intervall_monate: z.number().int().min(1).nullable().optional(),
|
||||||
|
letzte_pruefung_am: isoDate.nullable().optional(),
|
||||||
|
naechste_pruefung_am: isoDate.nullable().optional(),
|
||||||
|
bemerkung: z.string().max(2000).nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdateStatusSchema = z.object({
|
||||||
|
status: AusruestungStatusEnum,
|
||||||
|
bemerkung: z.string().max(2000).optional().default(''),
|
||||||
|
});
|
||||||
|
|
||||||
|
const CreateWartungslogSchema = z.object({
|
||||||
|
datum: isoDate,
|
||||||
|
art: z.enum(['Prüfung', 'Reparatur', 'Sonstiges']),
|
||||||
|
beschreibung: z.string().min(1).max(2000),
|
||||||
|
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']).optional(),
|
||||||
|
kosten: z.number().min(0).optional(),
|
||||||
|
pruefende_stelle: z.string().max(150).optional(),
|
||||||
|
dokument_url: z.string().url().max(500).refine(
|
||||||
|
(url) => /^https?:\/\//i.test(url),
|
||||||
|
'Nur http/https URLs erlaubt'
|
||||||
|
).optional(),
|
||||||
|
naechste_pruefung_am: isoDate.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdateWartungslogSchema = z.object({
|
||||||
|
datum: isoDate.optional(),
|
||||||
|
art: z.enum(['Prüfung', 'Reparatur', 'Sonstiges']).optional(),
|
||||||
|
beschreibung: z.string().min(1).max(2000).optional(),
|
||||||
|
ergebnis: z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']).nullable().optional(),
|
||||||
|
kosten: z.number().min(0).nullable().optional(),
|
||||||
|
pruefende_stelle: z.string().max(150).nullable().optional(),
|
||||||
|
naechste_pruefung_am: isoDate.nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getUserId(req: Request): string {
|
||||||
|
return req.user!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserGroups(req: Request): string[] {
|
||||||
|
return req.user?.groups ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the user is authorised to write to equipment in the given
|
||||||
|
* category. Admin can write to any category. Fahrmeister can only write to
|
||||||
|
* motorised categories. Zeugmeister can only write to non-motorised categories.
|
||||||
|
*/
|
||||||
|
async function checkCategoryPermission(kategorieId: string, groups: string[]): Promise<boolean> {
|
||||||
|
if (groups.includes('dashboard_admin')) return true;
|
||||||
|
|
||||||
|
const result = await equipmentService.getCategoryById(kategorieId);
|
||||||
|
if (!result) return false; // unknown category → deny
|
||||||
|
|
||||||
|
if (result.motorisiert) {
|
||||||
|
return groups.includes('dashboard_fahrmeister');
|
||||||
|
} else {
|
||||||
|
return groups.includes('dashboard_zeugmeister');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controller ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class EquipmentController {
|
||||||
|
async listEquipment(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const equipment = await equipmentService.getAllEquipment();
|
||||||
|
res.status(200).json({ success: true, data: equipment });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('listEquipment error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getEquipment(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const equipment = await equipmentService.getEquipmentById(id);
|
||||||
|
if (!equipment) {
|
||||||
|
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: equipment });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getEquipment error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByVehicle(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { fahrzeugId } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(fahrzeugId)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const equipment = await equipmentService.getEquipmentByVehicle(fahrzeugId);
|
||||||
|
res.status(200).json({ success: true, data: equipment });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getByVehicle error', { error, fahrzeugId: req.params.fahrzeugId });
|
||||||
|
res.status(500).json({ success: false, message: 'Ausrüstung für Fahrzeug konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCategories(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const categories = await equipmentService.getCategories();
|
||||||
|
res.status(200).json({ success: true, data: categories });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getCategories error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Kategorien konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stats = await equipmentService.getEquipmentStats();
|
||||||
|
res.status(200).json({ success: true, data: stats });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getStats error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Statistiken konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlerts(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const raw = parseInt((req.query.daysAhead as string) || '30', 10);
|
||||||
|
if (isNaN(raw) || raw < 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const daysAhead = Math.min(raw, 365);
|
||||||
|
const alerts = await equipmentService.getUpcomingInspections(daysAhead);
|
||||||
|
res.status(200).json({ success: true, data: alerts });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getAlerts error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Prüfungshinweise konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVehicleWarnings(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const warnings = await equipmentService.getVehicleWarnings();
|
||||||
|
res.status(200).json({ success: true, data: warnings });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getVehicleWarnings error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fahrzeug-Warnungen konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createEquipment(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parsed = CreateAusruestungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groups = getUserGroups(req);
|
||||||
|
const allowed = await checkCategoryPermission(parsed.data.kategorie_id, groups);
|
||||||
|
if (!allowed) {
|
||||||
|
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const equipment = await equipmentService.createEquipment(parsed.data, getUserId(req));
|
||||||
|
res.status(201).json({ success: true, data: equipment });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('createEquipment error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateEquipment(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = UpdateAusruestungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Object.keys(parsed.data).length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Determine which category to check permissions against
|
||||||
|
const groups = getUserGroups(req);
|
||||||
|
if (!groups.includes('dashboard_admin')) {
|
||||||
|
// Always fetch existing equipment to check old category permission
|
||||||
|
const existing = await equipmentService.getEquipmentById(id);
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Check permission against the OLD category (must be allowed to move FROM it)
|
||||||
|
const allowedOld = await checkCategoryPermission(existing.kategorie_id, groups);
|
||||||
|
if (!allowedOld) {
|
||||||
|
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If kategorie_id is being changed, also check permission against the NEW category
|
||||||
|
if (parsed.data.kategorie_id && parsed.data.kategorie_id !== existing.kategorie_id) {
|
||||||
|
const allowedNew = await checkCategoryPermission(parsed.data.kategorie_id, groups);
|
||||||
|
if (!allowedNew) {
|
||||||
|
res.status(403).json({ success: false, message: 'Keine Berechtigung für die Ziel-Kategorie' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const equipment = await equipmentService.updateEquipment(id, parsed.data, getUserId(req));
|
||||||
|
if (!equipment) {
|
||||||
|
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: equipment });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'No fields to update') {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('updateEquipment error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = UpdateStatusSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groups = getUserGroups(req);
|
||||||
|
if (!groups.includes('dashboard_admin')) {
|
||||||
|
const existing = await equipmentService.getEquipmentById(id);
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allowed = await checkCategoryPermission(existing.kategorie_id, groups);
|
||||||
|
if (!allowed) {
|
||||||
|
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await equipmentService.updateStatus(
|
||||||
|
id, parsed.data.status, parsed.data.bemerkung, getUserId(req)
|
||||||
|
);
|
||||||
|
res.status(200).json({ success: true, message: 'Status aktualisiert' });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'Equipment not found') {
|
||||||
|
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('updateStatus error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteEquipment(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deleted = await equipmentService.deleteEquipment(id, getUserId(req));
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, message: 'Ausrüstung gelöscht' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('deleteEquipment error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Ausrüstung konnte nicht gelöscht werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addWartung(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = CreateWartungslogSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groups = getUserGroups(req);
|
||||||
|
if (!groups.includes('dashboard_admin')) {
|
||||||
|
const existing = await equipmentService.getEquipmentById(id);
|
||||||
|
if (!existing) {
|
||||||
|
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const allowed = await checkCategoryPermission(existing.kategorie_id, groups);
|
||||||
|
if (!allowed) {
|
||||||
|
res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Kategorie' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const entry = await equipmentService.addWartungslog(id, parsed.data, getUserId(req));
|
||||||
|
res.status(201).json({ success: true, data: entry });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'Equipment not found') {
|
||||||
|
res.status(404).json({ success: false, message: 'Ausrüstung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('addWartung error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatusHistory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const history = await equipmentService.getStatusHistory(id);
|
||||||
|
res.status(200).json({ success: true, data: history });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getStatusHistory error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Status-Historie konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWartung(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id, wartungId } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Ausrüstungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const wId = parseInt(wartungId, 10);
|
||||||
|
if (isNaN(wId)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = UpdateWartungslogSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Object.keys(parsed.data).length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = await equipmentService.updateWartungslog(id, wId, parsed.data, getUserId(req));
|
||||||
|
res.status(200).json({ success: true, data: entry });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'Wartungseintrag nicht gefunden') {
|
||||||
|
res.status(404).json({ success: false, message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('updateWartung error', { error, id: req.params.id, wartungId: req.params.wartungId });
|
||||||
|
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createCategory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { name, kurzname, sortierung, motorisiert } = req.body;
|
||||||
|
if (!name || typeof name !== 'string' || !name.trim()) {
|
||||||
|
res.status(400).json({ success: false, message: 'Name ist erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!kurzname || typeof kurzname !== 'string' || !kurzname.trim()) {
|
||||||
|
res.status(400).json({ success: false, message: 'Kurzname ist erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const category = await equipmentService.createCategory({
|
||||||
|
name: name.trim(),
|
||||||
|
kurzname: kurzname.trim(),
|
||||||
|
sortierung: sortierung != null ? Number(sortierung) : undefined,
|
||||||
|
motorisiert: motorisiert != null ? Boolean(motorisiert) : undefined,
|
||||||
|
});
|
||||||
|
res.status(201).json({ success: true, data: category });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('createCategory error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Kategorie konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCategory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Kategorie-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { name, kurzname, sortierung, motorisiert } = req.body;
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (name !== undefined) data.name = String(name).trim();
|
||||||
|
if (kurzname !== undefined) data.kurzname = String(kurzname).trim();
|
||||||
|
if (sortierung !== undefined) data.sortierung = Number(sortierung);
|
||||||
|
if (motorisiert !== undefined) data.motorisiert = Boolean(motorisiert);
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const category = await equipmentService.updateCategory(id, data as any);
|
||||||
|
if (!category) {
|
||||||
|
res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: category });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'No fields to update') {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('updateCategory error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Kategorie konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCategory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Kategorie-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await equipmentService.deleteCategory(id);
|
||||||
|
if (!result.deleted) {
|
||||||
|
res.status(result.error === 'Kategorie nicht gefunden' ? 404 : 409).json({
|
||||||
|
success: false,
|
||||||
|
message: result.error,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, message: 'Kategorie gelöscht' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('deleteCategory error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Kategorie konnte nicht gelöscht werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadWartungFile(req: Request, res: Response): Promise<void> {
|
||||||
|
const { wartungId } = req.params as Record<string, string>;
|
||||||
|
const id = parseInt(wartungId, 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = (req as any).file;
|
||||||
|
if (!file) {
|
||||||
|
res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await equipmentService.updateWartungslogFile(id, file.path);
|
||||||
|
res.status(200).json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('uploadWartungFile error', { error, wartungId });
|
||||||
|
res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new EquipmentController();
|
||||||
405
backend/src/controllers/events.controller.ts
Normal file
405
backend/src/controllers/events.controller.ts
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import eventsService from '../services/events.service';
|
||||||
|
import {
|
||||||
|
CreateKategorieSchema,
|
||||||
|
UpdateKategorieSchema,
|
||||||
|
CreateVeranstaltungSchema,
|
||||||
|
UpdateVeranstaltungSchema,
|
||||||
|
CancelVeranstaltungSchema,
|
||||||
|
} from '../models/events.model';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper — extract userGroups from request
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function getUserGroups(req: Request): string[] {
|
||||||
|
return (req.user as any)?.groups ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Controller
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class EventsController {
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/kategorien
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
listKategorien = async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const data = await eventsService.getKategorien();
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('listKategorien error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Kategorien' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/events/kategorien
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
createKategorie = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const parsed = CreateKategorieSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await eventsService.createKategorie(parsed.data, req.user!.id);
|
||||||
|
res.status(201).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('createKategorie error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Erstellen der Kategorie' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PATCH /api/events/kategorien/:id
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
updateKategorie = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const parsed = UpdateKategorieSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await eventsService.updateKategorie(id, parsed.data);
|
||||||
|
if (!data) {
|
||||||
|
res.status(404).json({ success: false, message: 'Kategorie nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('updateKategorie error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Kategorie' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// DELETE /api/events/kategorien/:id
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
deleteKategorie = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
await eventsService.deleteKategorie(id);
|
||||||
|
res.json({ success: true, message: 'Kategorie wurde gelöscht' });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (
|
||||||
|
error.message === 'Kategorie nicht gefunden' ||
|
||||||
|
error.message?.includes('noch Veranstaltungen')
|
||||||
|
) {
|
||||||
|
res.status(409).json({ success: false, message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('deleteKategorie error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Löschen der Kategorie' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/groups
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getAvailableGroups = async (_req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const groups = await eventsService.getAvailableGroups();
|
||||||
|
res.json({ success: true, data: groups });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getAvailableGroups error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Gruppen' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/conflicts?from=<ISO>&to=<ISO>&excludeId=<uuid>
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
checkConflicts = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const fromStr = req.query.from as string | undefined;
|
||||||
|
const toStr = req.query.to as string | undefined;
|
||||||
|
const excludeId = req.query.excludeId as string | undefined;
|
||||||
|
|
||||||
|
if (!fromStr || !toStr) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Query-Parameter "from" und "to" sind erforderlich (ISO-8601)',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = new Date(fromStr);
|
||||||
|
const to = new Date(toStr);
|
||||||
|
|
||||||
|
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültiges Datumsformat' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await eventsService.checkConflicts(from, to, excludeId);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('checkConflicts error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler bei der Konfliktprüfung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/calendar?from=<ISO>&to=<ISO>
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getCalendarRange = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const fromStr = req.query.from as string | undefined;
|
||||||
|
const toStr = req.query.to as string | undefined;
|
||||||
|
|
||||||
|
if (!fromStr || !toStr) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Query-Parameter "from" und "to" sind erforderlich (ISO-8601)',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = new Date(fromStr);
|
||||||
|
const to = new Date(toStr);
|
||||||
|
|
||||||
|
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültiges Datumsformat' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to < from) {
|
||||||
|
res.status(400).json({ success: false, message: '"to" muss nach "from" liegen' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userGroups = getUserGroups(req);
|
||||||
|
const data = await eventsService.getEventsByDateRange(from, to, userGroups);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getCalendarRange error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalenders' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/upcoming?limit=10
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getUpcoming = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const limit = Math.min(Number(req.query.limit) || 10, 50);
|
||||||
|
const userGroups = getUserGroups(req);
|
||||||
|
const data = await eventsService.getUpcomingEvents(limit, userGroups);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getUpcoming error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltungen' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/:id
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getById = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const event = await eventsService.getById(id);
|
||||||
|
if (!event) {
|
||||||
|
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data: event });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getById error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/events
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
createEvent = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const parsed = CreateVeranstaltungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await eventsService.createEvent(parsed.data, req.user!.id);
|
||||||
|
res.status(201).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('createEvent error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Erstellen der Veranstaltung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PATCH /api/events/:id
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
updateEvent = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const parsed = UpdateVeranstaltungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await eventsService.updateEvent(id, parsed.data);
|
||||||
|
if (!data) {
|
||||||
|
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('updateEvent error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Veranstaltung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// DELETE /api/events/:id (soft cancel)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
cancelEvent = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const parsed = CancelVeranstaltungSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await eventsService.cancelEvent(id, parsed.data.abgesagt_grund, req.user!.id);
|
||||||
|
res.json({ success: true, message: 'Veranstaltung wurde abgesagt' });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Event not found') {
|
||||||
|
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('cancelEvent error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Absagen der Veranstaltung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/events/:id/delete (hard delete)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
deleteEvent = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const mode = (req.body?.mode as string) || 'all';
|
||||||
|
if (!['all', 'single', 'future'].includes(mode)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültiger Löschmodus. Erlaubt: all, single, future' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const deleted = await eventsService.deleteEvent(id, mode as 'all' | 'single' | 'future');
|
||||||
|
if (!deleted) {
|
||||||
|
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ success: true, message: 'Veranstaltung wurde gelöscht' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('deleteEvent error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Löschen der Veranstaltung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/calendar-token
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getCalendarToken = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await eventsService.getOrCreateIcalToken(req.user.id);
|
||||||
|
res.json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getCalendarToken error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalender-Tokens' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/events/calendar.ics?token=<token>
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getIcalExport = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const token = req.query.token as string | undefined;
|
||||||
|
if (!token) {
|
||||||
|
res.status(400).send('Token required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ical = await eventsService.getIcalExport(token);
|
||||||
|
if (!ical) {
|
||||||
|
res.status(404).send('Invalid token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="veranstaltungen.ics"');
|
||||||
|
// 30-minute cache — calendar clients typically re-fetch at this interval
|
||||||
|
res.setHeader('Cache-Control', 'max-age=1800, public');
|
||||||
|
res.send(ical);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getIcalExport error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/events/import
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
importEvents = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { events } = req.body as { events: unknown[] };
|
||||||
|
if (!Array.isArray(events) || events.length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Keine Ereignisse zum Importieren' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const userId = (req.user as any)?.id ?? 'unknown';
|
||||||
|
const created: number[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < events.length; i++) {
|
||||||
|
try {
|
||||||
|
const parsed = CreateVeranstaltungSchema.safeParse(events[i]);
|
||||||
|
if (!parsed.success) {
|
||||||
|
errors.push(`Zeile ${i + 2}: ${parsed.error.issues.map((e) => e.message).join(', ')}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await eventsService.createEvent(parsed.data, userId);
|
||||||
|
created.push(i);
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`Zeile ${i + 2}: ${e instanceof Error ? e.message : 'Unbekannter Fehler'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: { created: created.length, errors },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('importEvents error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Import fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new EventsController();
|
||||||
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();
|
||||||
311
backend/src/controllers/incident.controller.ts
Normal file
311
backend/src/controllers/incident.controller.ts
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import incidentService from '../services/incident.service';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
import { AppError } from '../middleware/error.middleware';
|
||||||
|
import { AppRole } from '../middleware/rbac.middleware';
|
||||||
|
import { permissionService } from '../services/permission.service';
|
||||||
|
import {
|
||||||
|
CreateEinsatzSchema,
|
||||||
|
UpdateEinsatzSchema,
|
||||||
|
AssignPersonnelSchema,
|
||||||
|
AssignVehicleSchema,
|
||||||
|
IncidentFiltersSchema,
|
||||||
|
} from '../models/incident.model';
|
||||||
|
|
||||||
|
// Extend Request type to carry the resolved role (set by requirePermission)
|
||||||
|
interface AuthenticatedRequest extends Request {
|
||||||
|
userRole?: AppRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
class IncidentController {
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/incidents
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async listIncidents(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parseResult = IncidentFiltersSchema.safeParse(req.query);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Ungültige Filter-Parameter',
|
||||||
|
errors: parseResult.error.issues,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { items, total } = await incidentService.getAllIncidents(parseResult.data);
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
limit: parseResult.data.limit,
|
||||||
|
offset: parseResult.data.offset,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('List incidents error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Einsätze' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/incidents/stats
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async getStats(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const year = req.query.year ? parseInt(req.query.year as string, 10) : undefined;
|
||||||
|
|
||||||
|
if (year !== undefined && (isNaN(year) || year < 2000 || year > 2100)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültiges Jahr' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await incidentService.getIncidentStats(year);
|
||||||
|
res.status(200).json({ success: true, data: stats });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Get incident stats error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistik' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/incidents/:id
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async getIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
|
||||||
|
// UUID validation
|
||||||
|
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Einsatz-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const incident = await incidentService.getIncidentById(id);
|
||||||
|
|
||||||
|
if (!incident) {
|
||||||
|
throw new AppError('Einsatz nicht gefunden', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Role-based redaction: check einsaetze:view_reports permission
|
||||||
|
const groups: string[] = req.user?.groups ?? [];
|
||||||
|
const canReadBerichtText =
|
||||||
|
groups.includes('dashboard_admin') ||
|
||||||
|
permissionService.hasPermission(groups, 'einsaetze:view_reports');
|
||||||
|
|
||||||
|
const responseData = {
|
||||||
|
...incident,
|
||||||
|
bericht_text: canReadBerichtText ? incident.bericht_text : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: responseData });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AppError) {
|
||||||
|
res.status(error.statusCode).json({ success: false, message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Get incident error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden des Einsatzes' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/incidents
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async createIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseResult = CreateEinsatzSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parseResult.error.issues,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const einsatz = await incidentService.createIncident(parseResult.data, req.user.id);
|
||||||
|
|
||||||
|
logger.info('Incident created via API', {
|
||||||
|
einsatzId: einsatz.id,
|
||||||
|
einsatz_nr: einsatz.einsatz_nr,
|
||||||
|
createdBy: req.user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: einsatz });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Create incident error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Einsatzes' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PATCH /api/incidents/:id
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async updateIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const parseResult = UpdateEinsatzSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parseResult.error.issues,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const einsatz = await incidentService.updateIncident(id, parseResult.data, req.user.id);
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: einsatz });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'Incident not found') {
|
||||||
|
res.status(404).json({ success: false, message: 'Einsatz nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Update incident error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Einsatzes' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// DELETE /api/incidents/:id (soft delete)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async deleteIncident(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
await incidentService.deleteIncident(id, req.user.id);
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, message: 'Einsatz archiviert' });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('not found')) {
|
||||||
|
res.status(404).json({ success: false, message: 'Einsatz nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Delete incident error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Archivieren des Einsatzes' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/incidents/:id/personnel
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async assignPersonnel(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
|
||||||
|
const parseResult = AssignPersonnelSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parseResult.error.issues,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await incidentService.assignPersonnel(id, parseResult.data);
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, message: 'Person zugewiesen' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Assign personnel error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Zuweisen der Person' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// DELETE /api/incidents/:id/personnel/:userId
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async removePersonnel(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id, userId } = req.params as Record<string, string>;
|
||||||
|
await incidentService.removePersonnel(id, userId);
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, message: 'Person entfernt' });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('not found')) {
|
||||||
|
res.status(404).json({ success: false, message: 'Zuweisung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Remove personnel error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Entfernen der Person' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/incidents/:id/vehicles
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async assignVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
|
||||||
|
const parseResult = AssignVehicleSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parseResult.error.issues,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await incidentService.assignVehicle(id, parseResult.data);
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, message: 'Fahrzeug zugewiesen' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Assign vehicle error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Zuweisen des Fahrzeugs' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// DELETE /api/incidents/:id/vehicles/:fahrzeugId
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async removeVehicle(req: AuthenticatedRequest, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id, fahrzeugId } = req.params as Record<string, string>;
|
||||||
|
await incidentService.removeVehicle(id, fahrzeugId);
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, message: 'Fahrzeug entfernt' });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes('not found')) {
|
||||||
|
res.status(404).json({ success: false, message: 'Zuweisung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('Remove vehicle error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Entfernen des Fahrzeugs' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/incidents/refresh-stats (admin utility)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
async refreshStats(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
await incidentService.refreshStatistikView();
|
||||||
|
res.status(200).json({ success: true, message: 'Statistik-View aktualisiert' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Refresh stats view error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Statistik' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new IncidentController();
|
||||||
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();
|
||||||
294
backend/src/controllers/member.controller.ts
Normal file
294
backend/src/controllers/member.controller.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import memberService from '../services/member.service';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
import {
|
||||||
|
CreateMemberProfileSchema,
|
||||||
|
UpdateMemberProfileSchema,
|
||||||
|
SelfUpdateMemberProfileSchema,
|
||||||
|
} from '../models/member.model';
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Role helpers
|
||||||
|
// These helpers inspect req.user.role which is populated by the
|
||||||
|
// requireRole / requirePermission middleware (see member.routes.ts).
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
type AppRole = 'admin' | 'kommandant' | 'mitglied';
|
||||||
|
|
||||||
|
function getRole(req: Request): AppRole {
|
||||||
|
// req.userRole is set by requirePermission() for non-owner paths.
|
||||||
|
// Fall back to req.user.role (JWT claim) and finally to 'mitglied'.
|
||||||
|
return (req as any).userRole ?? (req.user as any)?.role ?? 'mitglied';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canWrite(req: Request): boolean {
|
||||||
|
const role = getRole(req);
|
||||||
|
return role === 'admin' || role === 'kommandant';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOwnProfile(req: Request, userId: string): boolean {
|
||||||
|
return req.user?.id === userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Controller
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
class MemberController {
|
||||||
|
/**
|
||||||
|
* GET /api/members
|
||||||
|
* Returns a paginated list of all active members.
|
||||||
|
* Supports ?search=, ?status[]=, ?dienstgrad[]=, ?page=, ?pageSize=
|
||||||
|
*/
|
||||||
|
async getMembers(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
sortBy,
|
||||||
|
sortDir,
|
||||||
|
} = req.query as Record<string, string | undefined>;
|
||||||
|
|
||||||
|
// Arrays can be sent as ?status[]=aktiv&status[]=jugend or CSV
|
||||||
|
const statusParam = req.query['status'] as string | string[] | undefined;
|
||||||
|
const dienstgradParam = req.query['dienstgrad'] as string | string[] | undefined;
|
||||||
|
|
||||||
|
const normalizeArray = (v?: string | string[]): string[] | undefined => {
|
||||||
|
if (!v) return undefined;
|
||||||
|
return Array.isArray(v) ? v : v.split(',').map((s) => s.trim()).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
const { items, total } = await memberService.getAllMembers({
|
||||||
|
search,
|
||||||
|
status: normalizeArray(statusParam) as any,
|
||||||
|
dienstgrad: normalizeArray(dienstgradParam) as any,
|
||||||
|
page: page ? parseInt(page, 10) || 1 : 1,
|
||||||
|
pageSize: pageSize ? (parseInt(pageSize, 10) === 0 ? 0 : Math.min(parseInt(pageSize, 10) || 25, 100)) : 25,
|
||||||
|
sortBy,
|
||||||
|
sortDir: sortDir === 'desc' ? 'desc' : sortDir === 'asc' ? 'asc' : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: items,
|
||||||
|
meta: { total, page: page ? parseInt(page, 10) : 1 },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getMembers error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Mitglieder.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/members/stats
|
||||||
|
* Returns aggregate member counts for each status.
|
||||||
|
* Must be registered BEFORE /:userId to avoid route collision.
|
||||||
|
*/
|
||||||
|
async getMemberStats(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stats = await memberService.getMemberStats();
|
||||||
|
res.status(200).json({ success: true, data: stats });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getMemberStats error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistiken.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/members/:userId
|
||||||
|
* Returns full member detail including profile and rank history.
|
||||||
|
*
|
||||||
|
* Role rules:
|
||||||
|
* - Kommandant/Admin: all fields
|
||||||
|
* - Mitglied reading own profile: all fields
|
||||||
|
* - Mitglied reading another member: geburtsdatum and emergency contact redacted
|
||||||
|
*/
|
||||||
|
async getMemberById(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params as Record<string, string>;
|
||||||
|
const ownProfile = isOwnProfile(req, userId);
|
||||||
|
|
||||||
|
const member = await memberService.getMemberById(userId);
|
||||||
|
|
||||||
|
if (!member) {
|
||||||
|
res.status(404).json({ success: false, message: 'Mitglied nicht gefunden.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sensitive field masking for non-privileged reads of other members
|
||||||
|
const canReadSensitive = canWrite(req) || ownProfile;
|
||||||
|
|
||||||
|
if (!canReadSensitive && member.profile) {
|
||||||
|
// Replace geburtsdatum with only the age (year of birth omitted for DSGVO)
|
||||||
|
const ageMasked: any = { ...member.profile };
|
||||||
|
|
||||||
|
if (ageMasked.geburtsdatum) {
|
||||||
|
const birthYear = new Date(ageMasked.geburtsdatum).getFullYear();
|
||||||
|
const age = new Date().getFullYear() - birthYear;
|
||||||
|
ageMasked.geburtsdatum = null;
|
||||||
|
ageMasked._age = age; // synthesised non-DB field
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redact emergency contact entirely
|
||||||
|
ageMasked.notfallkontakt_name = null;
|
||||||
|
ageMasked.notfallkontakt_telefon = null;
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: { ...member, profile: ageMasked },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: member });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getMemberById error', { error, userId: req.params.userId });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden des Mitglieds.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/members/:userId/profile
|
||||||
|
* Creates the mitglieder_profile row for an existing auth user.
|
||||||
|
* Restricted to Kommandant/Admin.
|
||||||
|
*/
|
||||||
|
async createMemberProfile(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params as Record<string, string>;
|
||||||
|
|
||||||
|
const parseResult = CreateMemberProfileSchema.safeParse(req.body);
|
||||||
|
if (!parseResult.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Ungültige Eingabedaten.',
|
||||||
|
errors: parseResult.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await memberService.createMemberProfile(userId, parseResult.data);
|
||||||
|
logger.info('createMemberProfile', { userId, createdBy: req.user!.id });
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: profile });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message?.includes('existiert bereits')) {
|
||||||
|
res.status(409).json({ success: false, message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('createMemberProfile error', { error, userId: req.params.userId });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Profils.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/members/:userId
|
||||||
|
* Updates the mitglieder_profile row.
|
||||||
|
*
|
||||||
|
* Role rules:
|
||||||
|
* - Kommandant/Admin: full update (all fields)
|
||||||
|
* - Mitglied (own profile only): restricted to SelfUpdateMemberProfileSchema fields
|
||||||
|
*/
|
||||||
|
async updateMember(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params as Record<string, string>;
|
||||||
|
const updaterId = req.user!.id;
|
||||||
|
const ownProfile = isOwnProfile(req, userId);
|
||||||
|
|
||||||
|
if (!canWrite(req) && !ownProfile) {
|
||||||
|
res.status(403).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Keine Berechtigung, dieses Profil zu bearbeiten.',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Choose validation schema based on role
|
||||||
|
const schema = canWrite(req) ? UpdateMemberProfileSchema : SelfUpdateMemberProfileSchema;
|
||||||
|
const parseResult = schema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parseResult.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Ungültige Eingabedaten.',
|
||||||
|
errors: parseResult.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await memberService.updateMemberProfile(
|
||||||
|
userId,
|
||||||
|
parseResult.data as any,
|
||||||
|
updaterId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return full MemberWithProfile so the frontend state stays consistent
|
||||||
|
const fullMember = await memberService.getMemberById(userId);
|
||||||
|
logger.info('updateMember', { userId, updatedBy: updaterId });
|
||||||
|
res.status(200).json({ success: true, data: fullMember });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'Mitgliedsprofil nicht gefunden.') {
|
||||||
|
res.status(404).json({ success: false, message: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('updateMember error', { error, userId: req.params.userId });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren des Profils.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* GET /api/members/:userId/befoerderungen
|
||||||
|
*/
|
||||||
|
async getBefoerderungen(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params as Record<string, string>;
|
||||||
|
const data = await memberService.getBefoerderungen(userId);
|
||||||
|
res.status(200).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getBefoerderungen error', { error, userId: req.params.userId });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Beförderungen.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/members/:userId/untersuchungen
|
||||||
|
*/
|
||||||
|
async getUntersuchungen(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params as Record<string, string>;
|
||||||
|
const data = await memberService.getUntersuchungen(userId);
|
||||||
|
res.status(200).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getUntersuchungen error', { error, userId: req.params.userId });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Untersuchungen.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/members/:userId/fahrgenehmigungen
|
||||||
|
*/
|
||||||
|
async getFahrgenehmigungen(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params as Record<string, string>;
|
||||||
|
const data = await memberService.getFahrgenehmigungen(userId);
|
||||||
|
res.status(200).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getFahrgenehmigungen error', { error, userId: req.params.userId });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Fahrgenehmigungen.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/members/:userId/ausbildungen
|
||||||
|
*/
|
||||||
|
async getAusbildungen(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { userId } = req.params as Record<string, string>;
|
||||||
|
const data = await memberService.getAusbildungen(userId);
|
||||||
|
res.status(200).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getAusbildungen error', { error, userId: req.params.userId });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Ausbildungen.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new MemberController();
|
||||||
428
backend/src/controllers/nextcloud.controller.ts
Normal file
428
backend/src/controllers/nextcloud.controller.ts
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import path from 'path';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import nextcloudService from '../services/nextcloud.service';
|
||||||
|
import userService from '../services/user.service';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
const PollRequestSchema = z.object({
|
||||||
|
pollEndpoint: z.string().url(),
|
||||||
|
pollToken: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
class NextcloudController {
|
||||||
|
async initiateConnect(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await nextcloudService.initiateLoginFlow();
|
||||||
|
res.status(200).json({ success: true, data });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('initiateConnect error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Nextcloud-Verbindung konnte nicht gestartet werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async pollConnect(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parsed = PollRequestSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await nextcloudService.pollLoginFlow(parsed.data.pollEndpoint, parsed.data.pollToken);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
res.status(200).json({ success: true, data: { completed: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await userService.updateNextcloudCredentials(req.user!.id, result.loginName, result.appPassword);
|
||||||
|
res.status(200).json({ success: true, data: { completed: true } });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('pollConnect error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Nextcloud-Abfrage fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getConversations(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(200).json({ success: true, data: { connected: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { totalUnread, conversations } = await nextcloudService.getConversations(
|
||||||
|
credentials.loginName,
|
||||||
|
credentials.appPassword,
|
||||||
|
);
|
||||||
|
res.status(200).json({ success: true, data: { connected: true, totalUnread, conversations } });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(200).json({ success: true, data: { connected: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('getConversations error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Nextcloud-Gespräche konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(200).json({ success: true, data: null });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('disconnect error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Nextcloud-Trennung fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRooms(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(200).json({ success: true, data: { connected: false, rooms: [] } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rooms = await nextcloudService.getAllConversations(credentials.loginName, credentials.appPassword);
|
||||||
|
res.status(200).json({ success: true, data: { connected: true, rooms, loginName: credentials.loginName } });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(200).json({ success: true, data: { connected: false, rooms: [] } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('getRooms error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Nextcloud-Räume konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMessages(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = req.params.token as string;
|
||||||
|
if (!token) {
|
||||||
|
res.status(400).json({ success: false, message: 'Room token fehlt' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lookIntoFuture = req.query.lookIntoFuture === '1';
|
||||||
|
const lastKnownMessageId = req.query.lastKnownMessageId
|
||||||
|
? parseInt(req.query.lastKnownMessageId as string, 10)
|
||||||
|
: undefined;
|
||||||
|
const timeout = req.query.timeout
|
||||||
|
? Math.min(parseInt(req.query.timeout as string, 10), 25)
|
||||||
|
: 25;
|
||||||
|
const messages = await nextcloudService.getMessages(token, credentials.loginName, credentials.appPassword, {
|
||||||
|
lookIntoFuture,
|
||||||
|
lastKnownMessageId,
|
||||||
|
timeout,
|
||||||
|
});
|
||||||
|
res.status(200).json({ success: true, data: messages });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(200).json({ success: true, data: { connected: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('getMessages error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Nachrichten konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = req.params.token as string;
|
||||||
|
const { message, replyTo } = req.body;
|
||||||
|
if (!token || !message || typeof message !== 'string' || message.trim().length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Token und Nachricht erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.length > 32000) {
|
||||||
|
res.status(400).json({ success: false, message: 'Nachricht zu lang' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const replyToNum = (typeof replyTo === 'number' && replyTo > 0) ? replyTo : undefined;
|
||||||
|
await nextcloudService.sendMessage(token, message.trim(), credentials.loginName, credentials.appPassword, replyToNum);
|
||||||
|
res.status(200).json({ success: true, data: null });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(200).json({ success: true, data: { connected: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('sendMessage error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Nachricht konnte nicht gesendet werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = req.params.token as string;
|
||||||
|
if (!token) {
|
||||||
|
res.status(400).json({ success: false, message: 'Room token fehlt' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!req.file) {
|
||||||
|
res.status(400).json({ success: false, message: 'Keine Datei übermittelt' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await nextcloudService.uploadFileToTalk(
|
||||||
|
token,
|
||||||
|
req.file.buffer,
|
||||||
|
req.file.originalname,
|
||||||
|
req.file.mimetype,
|
||||||
|
credentials.loginName,
|
||||||
|
credentials.appPassword,
|
||||||
|
);
|
||||||
|
res.status(200).json({ success: true, data: null });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(200).json({ success: true, data: { connected: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('uploadFile error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadFile(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const filePath = req.query.path as string;
|
||||||
|
if (!filePath) {
|
||||||
|
res.status(400).json({ success: false, message: 'Dateipfad fehlt' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Path traversal protection
|
||||||
|
const normalized = path.normalize(filePath);
|
||||||
|
if (normalized.includes('..') || !normalized.startsWith('/')) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültiger Dateipfad' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await nextcloudService.downloadFile(
|
||||||
|
filePath,
|
||||||
|
credentials.loginName,
|
||||||
|
credentials.appPassword,
|
||||||
|
);
|
||||||
|
const contentType = response.headers['content-type'] ?? 'application/octet-stream';
|
||||||
|
const contentDisposition = response.headers['content-disposition']
|
||||||
|
?? `attachment; filename="${String(req.params.fileId).replace(/["\r\n\\]/g, '_')}"`;
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
res.setHeader('Content-Disposition', contentDisposition);
|
||||||
|
if (response.headers['content-length']) {
|
||||||
|
res.setHeader('Content-Length', response.headers['content-length']);
|
||||||
|
}
|
||||||
|
response.data.pipe(res);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('downloadFile error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Datei konnte nicht heruntergeladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFilePreview(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fileId = parseInt(req.params.fileId as string, 10);
|
||||||
|
if (isNaN(fileId)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Datei-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const w = parseInt((req.query.w as string) ?? '400', 10) || 400;
|
||||||
|
const h = parseInt((req.query.h as string) ?? '400', 10) || 400;
|
||||||
|
const response = await nextcloudService.getFilePreview(
|
||||||
|
fileId,
|
||||||
|
Math.min(w, 1200),
|
||||||
|
Math.min(h, 1200),
|
||||||
|
credentials.loginName,
|
||||||
|
credentials.appPassword,
|
||||||
|
);
|
||||||
|
const contentType = response.headers['content-type'] ?? 'image/jpeg';
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
if (response.headers['content-length']) {
|
||||||
|
res.setHeader('Content-Length', response.headers['content-length']);
|
||||||
|
}
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||||
|
response.data.pipe(res);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('getFilePreview error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Vorschau konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async markRoomAsRead(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = req.params.token as string;
|
||||||
|
if (!token) {
|
||||||
|
res.status(400).json({ success: false, message: 'Room token fehlt' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await nextcloudService.markAsRead(token, credentials.loginName, credentials.appPassword);
|
||||||
|
res.status(200).json({ success: true, data: null });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(200).json({ success: true, data: { connected: false } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('markRoomAsRead error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Raum konnte nicht als gelesen markiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async searchUsers(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(200).json({ success: true, data: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const query = (req.query.search as string) ?? '';
|
||||||
|
const results = await nextcloudService.searchUsers(query, credentials.loginName, credentials.appPassword);
|
||||||
|
res.status(200).json({ success: true, data: results });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(200).json({ success: true, data: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('searchUsers error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Benutzersuche fehlgeschlagen' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRoom(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { roomType, invite, roomName } = req.body;
|
||||||
|
if (typeof roomType !== 'number' || !invite || typeof invite !== 'string') {
|
||||||
|
res.status(400).json({ success: false, message: 'roomType und invite erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await nextcloudService.createRoom(roomType, invite, roomName, credentials.loginName, credentials.appPassword);
|
||||||
|
res.status(200).json({ success: true, data: result });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') {
|
||||||
|
await userService.clearNextcloudCredentials(req.user!.id);
|
||||||
|
res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('createRoom error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Raum konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addReaction(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||||
|
const token = req.params.token as string;
|
||||||
|
const messageId = parseInt(req.params.messageId as string, 10);
|
||||||
|
const { reaction } = req.body;
|
||||||
|
if (!token || isNaN(messageId) || !reaction) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
|
||||||
|
await nextcloudService.addReaction(token, messageId, reaction, credentials.loginName, credentials.appPassword);
|
||||||
|
res.status(200).json({ success: true, data: null });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||||
|
logger.error('addReaction error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Reaktion konnte nicht hinzugefügt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeReaction(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||||
|
const token = req.params.token as string;
|
||||||
|
const messageId = parseInt(req.params.messageId as string, 10);
|
||||||
|
const reaction = req.query.reaction as string;
|
||||||
|
if (!token || isNaN(messageId) || !reaction) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
|
||||||
|
await nextcloudService.removeReaction(token, messageId, reaction, credentials.loginName, credentials.appPassword);
|
||||||
|
res.status(200).json({ success: true, data: null });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||||
|
logger.error('removeReaction error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Reaktion konnte nicht entfernt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getReactions(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||||
|
const token = req.params.token as string;
|
||||||
|
const messageId = parseInt(req.params.messageId as string, 10);
|
||||||
|
if (!token || isNaN(messageId)) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
|
||||||
|
const data = await nextcloudService.getReactions(token, messageId, credentials.loginName, credentials.appPassword);
|
||||||
|
res.status(200).json({ success: true, data });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||||
|
logger.error('getReactions error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Reaktionen konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPoll(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const credentials = await userService.getNextcloudCredentials(req.user!.id);
|
||||||
|
if (!credentials) { res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||||
|
const token = req.params.token as string;
|
||||||
|
const pollId = parseInt(req.params.pollId as string, 10);
|
||||||
|
if (!token || isNaN(pollId)) { res.status(400).json({ success: false, message: 'Parameter fehlen' }); return; }
|
||||||
|
const data = await nextcloudService.getPollDetails(token, pollId, credentials.loginName, credentials.appPassword);
|
||||||
|
res.status(200).json({ success: true, data });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'NEXTCLOUD_AUTH_INVALID') { await userService.clearNextcloudCredentials(req.user!.id); res.status(401).json({ success: false, message: 'Nextcloud nicht verbunden' }); return; }
|
||||||
|
logger.error('getPoll error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Abstimmung konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new NextcloudController();
|
||||||
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();
|
||||||
346
backend/src/controllers/training.controller.ts
Normal file
346
backend/src/controllers/training.controller.ts
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import trainingService from '../services/training.service';
|
||||||
|
import {
|
||||||
|
CreateUebungSchema,
|
||||||
|
UpdateUebungSchema,
|
||||||
|
UpdateRsvpSchema,
|
||||||
|
MarkAttendanceSchema,
|
||||||
|
CancelEventSchema,
|
||||||
|
} from '../models/training.model';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
import environment from '../config/environment';
|
||||||
|
|
||||||
|
class TrainingController {
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/training
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getUpcoming = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const limit = Math.min(Number(req.query.limit ?? 10), 50);
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const events = await trainingService.getUpcomingEvents(limit, userId);
|
||||||
|
|
||||||
|
res.json({ success: true, data: events });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getUpcoming error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltungen' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/training/calendar?from=&to=
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getCalendarRange = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const fromStr = req.query.from as string | undefined;
|
||||||
|
const toStr = req.query.to as string | undefined;
|
||||||
|
|
||||||
|
if (!fromStr || !toStr) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Query-Parameter "from" und "to" sind erforderlich (ISO-8601)',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = new Date(fromStr);
|
||||||
|
const to = new Date(toStr);
|
||||||
|
|
||||||
|
if (isNaN(from.getTime()) || isNaN(to.getTime())) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültiges Datumsformat' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to < from) {
|
||||||
|
res.status(400).json({ success: false, message: '"to" muss nach "from" liegen' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const events = await trainingService.getEventsByDateRange(from, to, userId);
|
||||||
|
res.json({ success: true, data: events });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getCalendarRange error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalenders' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/training/calendar.ics?token=<calendarToken>
|
||||||
|
//
|
||||||
|
// Auth design: We use a per-user opaque token stored in `calendar_tokens`.
|
||||||
|
// This allows calendar clients (Apple Calendar, Google Calendar) to subscribe
|
||||||
|
// without sending a Bearer token on every refresh. The token is long-lived
|
||||||
|
// and can be rotated by calling POST /api/training/calendar-token/rotate.
|
||||||
|
//
|
||||||
|
// TRADEOFF DISCUSSION:
|
||||||
|
// Option A (used here) — Personal calendar token:
|
||||||
|
// + Works natively in all calendar apps (no auth headers needed)
|
||||||
|
// + Rotating the token invalidates stale subscriptions
|
||||||
|
// - Token embedded in URL is visible in server logs / browser history
|
||||||
|
// → Mitigation: tokens are 256-bit random, never contain PII
|
||||||
|
//
|
||||||
|
// Option B — Bearer auth only:
|
||||||
|
// + No token in URL
|
||||||
|
// - Most native calendar apps cannot send custom headers;
|
||||||
|
// requires a proxy or special client
|
||||||
|
// → Rejected for this use case (firefighter mobile phones)
|
||||||
|
//
|
||||||
|
// Option C — Publicly readable feed (no auth):
|
||||||
|
// + Simplest
|
||||||
|
// - Leaks member names and attendance status to anyone with the URL
|
||||||
|
// → Rejected on privacy grounds
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getIcalExport = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const token = req.query.token as string | undefined;
|
||||||
|
|
||||||
|
let userId: string | undefined;
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
const resolved = await trainingService.resolveCalendarToken(token);
|
||||||
|
if (!resolved) {
|
||||||
|
res.status(401).json({ success: false, message: 'Ungültiger Kalender-Token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
userId = resolved;
|
||||||
|
} else if (req.user) {
|
||||||
|
// Also accept a normal Bearer token for direct browser access
|
||||||
|
userId = req.user.id;
|
||||||
|
}
|
||||||
|
// userId may be undefined → returns all events without personal RSVP info
|
||||||
|
|
||||||
|
const ics = await trainingService.getCalendarExport(userId);
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/calendar; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="feuerwehr-rems.ics"');
|
||||||
|
// 30-minute cache — calendar clients typically re-fetch at this interval
|
||||||
|
res.setHeader('Cache-Control', 'max-age=1800, public');
|
||||||
|
res.send(ics);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getIcalExport error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Erstellen des Kalender-Exports' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/training/calendar-token (authenticated — get or create token)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getCalendarToken = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ success: false, message: 'Nicht authentifiziert' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await trainingService.getOrCreateCalendarToken(req.user.id);
|
||||||
|
const baseUrl = environment.cors.origin.replace(/\/$/, '');
|
||||||
|
const subscribeUrl = `${baseUrl.replace('localhost:3001', 'localhost:3000')}/api/training/calendar.ics?token=${token}`;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
subscribeUrl,
|
||||||
|
instructions: 'Kopiere diese URL und füge sie in "Kalender abonnieren" ein (Apple Kalender, Google Calendar, Thunderbird, etc.).',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getCalendarToken error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden des Kalender-Tokens' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/training/stats?year=2026
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getStats = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const year = Number(req.query.year ?? new Date().getFullYear());
|
||||||
|
|
||||||
|
if (isNaN(year) || year < 2000 || year > 2100) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültiges Jahr' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = await trainingService.getMemberParticipationStats(year);
|
||||||
|
res.json({ success: true, data: stats, meta: { year } });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getStats error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Statistiken' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// GET /api/training/:id
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
getById = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
// Determine if the requester may see the full attendee list
|
||||||
|
// In v1 we use a simple check: if the user has training:write permission
|
||||||
|
// (Kommandant / Gruppenführer), populate teilnahmen.
|
||||||
|
// The permission flag is set on req by requirePermission middleware,
|
||||||
|
// but we check it here by convention.
|
||||||
|
const canSeeTeilnahmen = (req as any).canSeeTeilnahmen === true;
|
||||||
|
|
||||||
|
const event = await trainingService.getEventById(id, userId, canSeeTeilnahmen);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: event });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getById error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Laden der Veranstaltung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/training
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
createEvent = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const parsed = CreateUebungSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await trainingService.createEvent(parsed.data, req.user!.id);
|
||||||
|
res.status(201).json({ success: true, data: event });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('createEvent error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Erstellen der Veranstaltung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PATCH /api/training/:id
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
updateEvent = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const parsed = UpdateUebungSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await trainingService.updateEvent(id, parsed.data, req.user!.id);
|
||||||
|
res.json({ success: true, data: event });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Event not found') {
|
||||||
|
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('updateEvent error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Aktualisieren der Veranstaltung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// DELETE /api/training/:id (soft cancel)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
cancelEvent = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const parsed = CancelEventSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await trainingService.cancelEvent(id, parsed.data.absage_grund, req.user!.id);
|
||||||
|
res.json({ success: true, message: 'Veranstaltung wurde abgesagt' });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message === 'Event not found') {
|
||||||
|
res.status(404).json({ success: false, message: 'Veranstaltung nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('cancelEvent error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Absagen der Veranstaltung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// PATCH /api/training/:id/attendance — own RSVP
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
updateRsvp = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id: uebungId } = req.params as Record<string, string>;
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const parsed = UpdateRsvpSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await trainingService.updateAttendanceRSVP(
|
||||||
|
uebungId,
|
||||||
|
userId,
|
||||||
|
parsed.data.status,
|
||||||
|
parsed.data.bemerkung
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, message: 'Rückmeldung gespeichert' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('updateRsvp error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Speichern der Rückmeldung' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// POST /api/training/:id/attendance/mark — bulk mark as erschienen
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
markAttendance = async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const { id: uebungId } = req.params as Record<string, string>;
|
||||||
|
const parsed = MarkAttendanceSchema.safeParse(req.body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await trainingService.markAttendance(uebungId, parsed.data.userIds, req.user!.id);
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `${parsed.data.userIds.length} Personen als erschienen markiert`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('markAttendance error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fehler beim Erfassen der Anwesenheit' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new TrainingController();
|
||||||
463
backend/src/controllers/vehicle.controller.ts
Normal file
463
backend/src/controllers/vehicle.controller.ts
Normal file
@@ -0,0 +1,463 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import vehicleService from '../services/vehicle.service';
|
||||||
|
import equipmentService from '../services/equipment.service';
|
||||||
|
import scheduledMessagesService from '../services/scheduledMessages.service';
|
||||||
|
import { FahrzeugStatus } from '../models/vehicle.model';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
// ── UUID validation ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function isValidUUID(s: string): boolean {
|
||||||
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zod Validation Schemas ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const FahrzeugStatusEnum = z.enum([
|
||||||
|
FahrzeugStatus.Einsatzbereit,
|
||||||
|
FahrzeugStatus.AusserDienstWartung,
|
||||||
|
FahrzeugStatus.AusserDienstSchaden,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isoDate = z.string().regex(
|
||||||
|
/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/,
|
||||||
|
'Erwartet ISO-Datum im Format YYYY-MM-DD'
|
||||||
|
);
|
||||||
|
|
||||||
|
const CreateFahrzeugSchema = z.object({
|
||||||
|
bezeichnung: z.string().min(1).max(100),
|
||||||
|
kurzname: z.string().max(20).optional(),
|
||||||
|
amtliches_kennzeichen: z.string().max(20).optional(),
|
||||||
|
fahrgestellnummer: z.string().max(50).optional(),
|
||||||
|
baujahr: z.number().int().min(1950).max(2100).optional(),
|
||||||
|
hersteller: z.string().max(100).optional(),
|
||||||
|
typ_schluessel: z.string().max(30).optional(),
|
||||||
|
besatzung_soll: z.string().max(10).optional(),
|
||||||
|
status: FahrzeugStatusEnum.optional(),
|
||||||
|
status_bemerkung: z.string().max(500).optional(),
|
||||||
|
standort: z.string().min(1).max(100).optional(),
|
||||||
|
bild_url: z.string().url().max(500).refine(
|
||||||
|
(url) => /^https?:\/\//i.test(url),
|
||||||
|
'Nur http/https URLs erlaubt'
|
||||||
|
).optional(),
|
||||||
|
paragraph57a_faellig_am: isoDate.optional(),
|
||||||
|
naechste_wartung_am: isoDate.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdateFahrzeugSchema = z.object({
|
||||||
|
bezeichnung: z.string().min(1).max(100).optional(),
|
||||||
|
kurzname: z.string().max(20).nullable().optional(),
|
||||||
|
amtliches_kennzeichen: z.string().max(20).nullable().optional(),
|
||||||
|
fahrgestellnummer: z.string().max(50).nullable().optional(),
|
||||||
|
baujahr: z.number().int().min(1950).max(2100).nullable().optional(),
|
||||||
|
hersteller: z.string().max(100).nullable().optional(),
|
||||||
|
typ_schluessel: z.string().max(30).nullable().optional(),
|
||||||
|
besatzung_soll: z.string().max(10).nullable().optional(),
|
||||||
|
status: FahrzeugStatusEnum.optional(),
|
||||||
|
status_bemerkung: z.string().max(500).nullable().optional(),
|
||||||
|
standort: z.string().min(1).max(100).optional(),
|
||||||
|
bild_url: z.string().url().max(500).refine(
|
||||||
|
(url) => /^https?:\/\//i.test(url),
|
||||||
|
'Nur http/https URLs erlaubt'
|
||||||
|
).nullable().optional(),
|
||||||
|
paragraph57a_faellig_am: isoDate.nullable().optional(),
|
||||||
|
naechste_wartung_am: isoDate.nullable().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isoDatetime = z.string().datetime({ offset: true, message: 'Erwartet ISO-8601 Datum mit Zeitzone' });
|
||||||
|
|
||||||
|
const UpdateStatusSchema = z.object({
|
||||||
|
status: FahrzeugStatusEnum,
|
||||||
|
bemerkung: z.string().max(500).optional().default(''),
|
||||||
|
ausserDienstVon: isoDatetime.optional(),
|
||||||
|
ausserDienstBis: isoDatetime.optional(),
|
||||||
|
}).refine(
|
||||||
|
(d) => {
|
||||||
|
const isAusserDienst = d.status === FahrzeugStatus.AusserDienstWartung || d.status === FahrzeugStatus.AusserDienstSchaden;
|
||||||
|
if (!isAusserDienst) return true;
|
||||||
|
return !!d.ausserDienstVon && !!d.ausserDienstBis;
|
||||||
|
},
|
||||||
|
{ message: 'Außer-Dienst-Zeitraum (von + bis) ist bei diesem Status erforderlich', path: ['ausserDienstVon'] }
|
||||||
|
).refine(
|
||||||
|
(d) => {
|
||||||
|
if (!d.ausserDienstVon || !d.ausserDienstBis) return true;
|
||||||
|
return new Date(d.ausserDienstBis) > new Date(d.ausserDienstVon);
|
||||||
|
},
|
||||||
|
{ message: 'Enddatum muss nach Startdatum liegen', path: ['ausserDienstBis'] }
|
||||||
|
);
|
||||||
|
|
||||||
|
const ErgebnisEnum = z.enum(['bestanden', 'bestanden_mit_maengeln', 'nicht_bestanden']);
|
||||||
|
|
||||||
|
const CreateWartungslogSchema = z.object({
|
||||||
|
datum: isoDate,
|
||||||
|
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
|
||||||
|
beschreibung: z.string().min(1).max(2000),
|
||||||
|
km_stand: z.number().int().min(0).optional(),
|
||||||
|
kraftstoff_liter: z.number().min(0).optional(),
|
||||||
|
kosten: z.number().min(0).optional(),
|
||||||
|
externe_werkstatt: z.string().max(150).optional(),
|
||||||
|
ergebnis: ErgebnisEnum.optional(),
|
||||||
|
naechste_faelligkeit: isoDate.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const UpdateWartungslogSchema = z.object({
|
||||||
|
datum: isoDate,
|
||||||
|
art: z.enum(['§57a Prüfung', 'Service', 'Sonstiges']).optional(),
|
||||||
|
beschreibung: z.string().min(1).max(2000),
|
||||||
|
km_stand: z.number().int().min(0).optional(),
|
||||||
|
externe_werkstatt: z.string().max(150).optional(),
|
||||||
|
ergebnis: ErgebnisEnum.optional(),
|
||||||
|
naechste_faelligkeit: isoDate.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helper ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getUserId(req: Request): string {
|
||||||
|
return req.user!.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controller ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class VehicleController {
|
||||||
|
async listVehicles(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const vehicles = await vehicleService.getAllVehicles();
|
||||||
|
res.status(200).json({ success: true, data: vehicles });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('listVehicles error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fahrzeuge konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStats(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const stats = await vehicleService.getVehicleStats();
|
||||||
|
res.status(200).json({ success: true, data: stats });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getStats error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Statistiken konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAlerts(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const raw = parseInt((req.query.daysAhead as string) || '30', 10);
|
||||||
|
if (isNaN(raw) || raw < 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültiger daysAhead-Wert' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const daysAhead = Math.min(raw, 365);
|
||||||
|
const alerts = await vehicleService.getUpcomingInspections(daysAhead);
|
||||||
|
res.status(200).json({ success: true, data: alerts });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getAlerts error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Prüfungshinweise konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportAlerts(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const escape = (v: unknown): string => {
|
||||||
|
if (v === null || v === undefined) return '';
|
||||||
|
const str = String(v);
|
||||||
|
let safe = str.replace(/"/g, '""');
|
||||||
|
if (/^[=+@\-]/.test(safe)) safe = "'" + safe;
|
||||||
|
return `"${safe}"`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (d: Date | string | null): string => {
|
||||||
|
if (!d) return '';
|
||||||
|
const date = typeof d === 'string' ? new Date(d) : d;
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const year = date.getFullYear();
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [vehicleAlerts, equipmentAlerts] = await Promise.all([
|
||||||
|
vehicleService.getUpcomingInspections(365),
|
||||||
|
equipmentService.getUpcomingInspections(365),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const header = 'Typ,Bezeichnung,Kurzname,Prüfungsart,Fällig am,Tage verbleibend';
|
||||||
|
const rows: string[] = [];
|
||||||
|
|
||||||
|
for (const a of vehicleAlerts) {
|
||||||
|
const pruefungsart = a.type === '57a' ? '§57a Überprüfung' : 'Nächste Wartung';
|
||||||
|
rows.push([
|
||||||
|
escape('Fahrzeug'),
|
||||||
|
escape(a.bezeichnung),
|
||||||
|
escape(a.kurzname),
|
||||||
|
escape(pruefungsart),
|
||||||
|
escape(formatDate(a.faelligAm)),
|
||||||
|
escape(a.tage),
|
||||||
|
].join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const e of equipmentAlerts) {
|
||||||
|
rows.push([
|
||||||
|
escape('Ausrüstung'),
|
||||||
|
escape(e.bezeichnung),
|
||||||
|
escape(''),
|
||||||
|
escape('Prüfung'),
|
||||||
|
escape(formatDate(e.naechste_pruefung_am)),
|
||||||
|
escape(e.pruefung_tage_bis_faelligkeit),
|
||||||
|
].join(','));
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
|
||||||
|
const csv = '\uFEFF' + header + '\n' + rows.join('\n');
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename="pruefungen_${dateStr}.csv"`);
|
||||||
|
res.status(200).send(csv);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('exportAlerts error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Prüfungsexport konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVehicle(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vehicle = await vehicleService.getVehicleById(id);
|
||||||
|
if (!vehicle) {
|
||||||
|
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(200).json({ success: true, data: vehicle });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getVehicle error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createVehicle(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const parsed = CreateFahrzeugSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vehicle = await vehicleService.createVehicle(parsed.data, getUserId(req));
|
||||||
|
res.status(201).json({ success: true, data: vehicle });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('createVehicle error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateVehicle(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = UpdateFahrzeugSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Object.keys(parsed.data).length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Kein Feld zum Aktualisieren angegeben' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vehicle = await vehicleService.updateVehicle(id, parsed.data, getUserId(req));
|
||||||
|
res.status(200).json({ success: true, data: vehicle });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'Vehicle not found') {
|
||||||
|
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('updateVehicle error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateVehicleStatus(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = UpdateStatusSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const io = req.app.get('io') ?? undefined;
|
||||||
|
const result = await vehicleService.updateVehicleStatus(
|
||||||
|
id,
|
||||||
|
parsed.data.status,
|
||||||
|
parsed.data.bemerkung,
|
||||||
|
getUserId(req),
|
||||||
|
io,
|
||||||
|
parsed.data.ausserDienstVon ? new Date(parsed.data.ausserDienstVon) : null,
|
||||||
|
parsed.data.ausserDienstBis ? new Date(parsed.data.ausserDienstBis) : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fire-and-forget: notify scheduled messages when vehicle goes out of service
|
||||||
|
if (parsed.data.status !== FahrzeugStatus.Einsatzbereit) {
|
||||||
|
scheduledMessagesService.sendVehicleEvent(id).catch(err => {
|
||||||
|
logger.error('Failed to send vehicle event notification', {
|
||||||
|
vehicleId: id,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: result });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'Vehicle not found') {
|
||||||
|
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('updateVehicleStatus error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Status konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addWartung(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = CreateWartungslogSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = await vehicleService.addWartungslog(id, parsed.data, getUserId(req));
|
||||||
|
res.status(201).json({ success: true, data: entry });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'Vehicle not found') {
|
||||||
|
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('addWartung error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht gespeichert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteVehicle(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await vehicleService.deleteVehicle(id, getUserId(req));
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'Vehicle not found') {
|
||||||
|
res.status(404).json({ success: false, message: 'Fahrzeug nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('deleteVehicle error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Fahrzeug konnte nicht gelöscht werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWartung(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entries = await vehicleService.getWartungslogForVehicle(id);
|
||||||
|
res.status(200).json({ success: true, data: entries });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getWartung error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Wartungslog konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatusHistory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id } = req.params as Record<string, string>;
|
||||||
|
const history = await vehicleService.getStatusHistory(id);
|
||||||
|
res.status(200).json({ success: true, data: history });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('getStatusHistory error', { error, id: req.params.id });
|
||||||
|
res.status(500).json({ success: false, message: 'Status-Historie konnte nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWartung(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { id, wartungId } = req.params as Record<string, string>;
|
||||||
|
if (!isValidUUID(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Fahrzeug-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = UpdateWartungslogSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validierungsfehler',
|
||||||
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = await vehicleService.updateWartungslog(wartungId, id, parsed.data, getUserId(req));
|
||||||
|
res.status(200).json({ success: true, data: entry });
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.message === 'Wartungseintrag nicht gefunden') {
|
||||||
|
res.status(404).json({ success: false, message: 'Wartungseintrag nicht gefunden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.error('updateWartung error', { error, id: req.params.id, wartungId: req.params.wartungId });
|
||||||
|
res.status(500).json({ success: false, message: 'Wartungseintrag konnte nicht aktualisiert werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadWartungFile(req: Request, res: Response): Promise<void> {
|
||||||
|
const { wartungId } = req.params as Record<string, string>;
|
||||||
|
const id = parseInt(wartungId, 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
res.status(400).json({ success: false, message: 'Ungültige Wartungs-ID' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = (req as any).file;
|
||||||
|
if (!file) {
|
||||||
|
res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await vehicleService.updateWartungslogFile(id, file.path);
|
||||||
|
res.status(200).json({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('uploadWartungFile error', { error, wartungId });
|
||||||
|
res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new VehicleController();
|
||||||
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();
|
||||||
146
backend/src/database/migrations/002_create_audit_log.sql
Normal file
146
backend/src/database/migrations/002_create_audit_log.sql
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Migration 002: Audit Log Table
|
||||||
|
-- GDPR Art. 5(2) Accountability + Art. 30 Records of Processing Activities
|
||||||
|
--
|
||||||
|
-- Design decisions:
|
||||||
|
-- - UUID primary key (consistent with users table)
|
||||||
|
-- - user_id is nullable: pre-auth events (LOGIN failures) have no user yet
|
||||||
|
-- - old_value / new_value are JSONB: flexible, queryable, indexable
|
||||||
|
-- - ip_address stored as TEXT (supports IPv4 + IPv6); anonymised after 90 days
|
||||||
|
-- - Table is immutable: a PostgreSQL RULE blocks UPDATE and DELETE at the
|
||||||
|
-- SQL level, which is simpler than triggers and cannot be bypassed by the
|
||||||
|
-- application role
|
||||||
|
-- - Partial index on ip_address covers only recent rows (90-day window) to
|
||||||
|
-- support efficient anonymisation queries without a full-table scan
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- 1. Enums — define before the table
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_action') THEN
|
||||||
|
CREATE TYPE audit_action AS ENUM (
|
||||||
|
'CREATE',
|
||||||
|
'UPDATE',
|
||||||
|
'DELETE',
|
||||||
|
'LOGIN',
|
||||||
|
'LOGOUT',
|
||||||
|
'EXPORT',
|
||||||
|
'PERMISSION_DENIED',
|
||||||
|
'PASSWORD_CHANGE',
|
||||||
|
'ROLE_CHANGE'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'audit_resource_type') THEN
|
||||||
|
CREATE TYPE audit_resource_type AS ENUM (
|
||||||
|
'MEMBER',
|
||||||
|
'INCIDENT',
|
||||||
|
'VEHICLE',
|
||||||
|
'EQUIPMENT',
|
||||||
|
'QUALIFICATION',
|
||||||
|
'USER',
|
||||||
|
'SYSTEM'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- 2. Core audit_log table
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
user_email VARCHAR(255), -- denormalised snapshot; users can be deleted
|
||||||
|
action audit_action NOT NULL,
|
||||||
|
resource_type audit_resource_type NOT NULL,
|
||||||
|
resource_id VARCHAR(255), -- may be UUID, numeric ID, or 'system'
|
||||||
|
old_value JSONB, -- state before the operation
|
||||||
|
new_value JSONB, -- state after the operation
|
||||||
|
ip_address TEXT, -- anonymised to '[anonymized]' after 90 days
|
||||||
|
user_agent TEXT,
|
||||||
|
metadata JSONB DEFAULT '{}', -- any extra context (e.g. export format, reason)
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Prevent modification of audit records at the database level.
|
||||||
|
-- Using RULE rather than a trigger because rules are evaluated before
|
||||||
|
-- the operation and cannot be disabled without superuser access.
|
||||||
|
CREATE OR REPLACE RULE audit_log_no_update AS
|
||||||
|
ON UPDATE TO audit_log DO INSTEAD NOTHING;
|
||||||
|
|
||||||
|
CREATE OR REPLACE RULE audit_log_no_delete AS
|
||||||
|
ON DELETE TO audit_log DO INSTEAD NOTHING;
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- 3. Indexes
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
|
||||||
|
-- Lookup by actor (admin "what did this user do?")
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_user_id
|
||||||
|
ON audit_log (user_id)
|
||||||
|
WHERE user_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Lookup by subject resource (admin "what happened to member X?")
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_resource
|
||||||
|
ON audit_log (resource_type, resource_id);
|
||||||
|
|
||||||
|
-- Time-range queries and retention scans (most common filter)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_created_at
|
||||||
|
ON audit_log (created_at DESC);
|
||||||
|
|
||||||
|
-- Action-type filter
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_action
|
||||||
|
ON audit_log (action);
|
||||||
|
|
||||||
|
-- Partial index: only rows where IP address is not yet anonymised.
|
||||||
|
-- The anonymisation job does: WHERE created_at < NOW() - INTERVAL '90 days'
|
||||||
|
-- AND ip_address != '[anonymized]'
|
||||||
|
-- This index makes that query O(matched rows) instead of O(table size).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_log_ip_retention
|
||||||
|
ON audit_log (created_at)
|
||||||
|
WHERE ip_address IS NOT NULL
|
||||||
|
AND ip_address != '[anonymized]';
|
||||||
|
|
||||||
|
-- -------------------------------------------------------
|
||||||
|
-- 4. OPTIONAL — Range partitioning by month (recommended
|
||||||
|
-- for high-volume deployments; skip in small setups)
|
||||||
|
--
|
||||||
|
-- To use partitioning, replace the CREATE TABLE above with
|
||||||
|
-- the following DDL *before* the first row is inserted.
|
||||||
|
-- Partitioning cannot be added to an existing unpartitioned
|
||||||
|
-- table without a full rewrite (pg_partman can automate this).
|
||||||
|
--
|
||||||
|
-- CREATE TABLE audit_log (
|
||||||
|
-- id UUID NOT NULL DEFAULT uuid_generate_v4(),
|
||||||
|
-- user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
-- user_email VARCHAR(255),
|
||||||
|
-- action audit_action NOT NULL,
|
||||||
|
-- resource_type audit_resource_type NOT NULL,
|
||||||
|
-- resource_id VARCHAR(255),
|
||||||
|
-- old_value JSONB,
|
||||||
|
-- new_value JSONB,
|
||||||
|
-- ip_address TEXT,
|
||||||
|
-- user_agent TEXT,
|
||||||
|
-- metadata JSONB DEFAULT '{}',
|
||||||
|
-- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
-- ) PARTITION BY RANGE (created_at);
|
||||||
|
--
|
||||||
|
-- -- Create monthly partitions with pg_partman or manually:
|
||||||
|
-- CREATE TABLE audit_log_2026_01 PARTITION OF audit_log
|
||||||
|
-- FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
|
||||||
|
-- CREATE TABLE audit_log_2026_02 PARTITION OF audit_log
|
||||||
|
-- FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
|
||||||
|
-- -- ... and so on. pg_partman automates partition creation.
|
||||||
|
--
|
||||||
|
-- With partitioning, old partitions can be DROPped for
|
||||||
|
-- efficient bulk retention deletion without a full-table scan.
|
||||||
|
-- -------------------------------------------------------
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
-- Migration: 003_create_mitglieder_profile
|
||||||
|
-- Creates the mitglieder_profile and dienstgrad_verlauf tables for fire department member data.
|
||||||
|
-- Rollback:
|
||||||
|
-- DROP TABLE IF EXISTS dienstgrad_verlauf;
|
||||||
|
-- DROP TABLE IF EXISTS mitglieder_profile;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- mitglieder_profile
|
||||||
|
-- One-to-one extension of the users table.
|
||||||
|
-- A user can exist without a profile (profile is created later
|
||||||
|
-- by a Kommandant). The user_id is both FK and UNIQUE.
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS mitglieder_profile (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Internal member identifier assigned by the Kommandant
|
||||||
|
mitglieds_nr VARCHAR(32) UNIQUE,
|
||||||
|
|
||||||
|
-- Rank (Dienstgrad) with allowed values enforced by CHECK
|
||||||
|
dienstgrad VARCHAR(64)
|
||||||
|
CHECK (dienstgrad IS NULL OR dienstgrad IN (
|
||||||
|
'Feuerwehranwärter',
|
||||||
|
'Feuerwehrmann',
|
||||||
|
'Feuerwehrfrau',
|
||||||
|
'Oberfeuerwehrmann',
|
||||||
|
'Oberfeuerwehrfrau',
|
||||||
|
'Hauptfeuerwehrmann',
|
||||||
|
'Hauptfeuerwehrfrau',
|
||||||
|
'Löschmeister',
|
||||||
|
'Oberlöschmeister',
|
||||||
|
'Hauptlöschmeister',
|
||||||
|
'Brandmeister',
|
||||||
|
'Oberbrandmeister',
|
||||||
|
'Hauptbrandmeister',
|
||||||
|
'Brandinspektor',
|
||||||
|
'Oberbrandinspektor',
|
||||||
|
'Brandoberinspektor',
|
||||||
|
'Brandamtmann'
|
||||||
|
)),
|
||||||
|
dienstgrad_seit DATE,
|
||||||
|
|
||||||
|
-- Funktion(en) — a member can hold multiple roles simultaneously
|
||||||
|
-- Stored as a PostgreSQL TEXT array
|
||||||
|
funktion TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Membership status
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'aktiv'
|
||||||
|
CHECK (status IN (
|
||||||
|
'aktiv',
|
||||||
|
'passiv',
|
||||||
|
'ehrenmitglied',
|
||||||
|
'jugendfeuerwehr',
|
||||||
|
'anwärter',
|
||||||
|
'ausgetreten'
|
||||||
|
)),
|
||||||
|
|
||||||
|
-- Important dates
|
||||||
|
eintrittsdatum DATE,
|
||||||
|
austrittsdatum DATE,
|
||||||
|
geburtsdatum DATE, -- sensitive: only shown to Kommandant/Admin
|
||||||
|
|
||||||
|
-- Contact information (stored raw, formatted on display)
|
||||||
|
telefon_mobil VARCHAR(32),
|
||||||
|
telefon_privat VARCHAR(32),
|
||||||
|
|
||||||
|
-- Emergency contact (sensitive: only own record visible to Mitglied)
|
||||||
|
notfallkontakt_name VARCHAR(255),
|
||||||
|
notfallkontakt_telefon VARCHAR(32),
|
||||||
|
|
||||||
|
-- Driving licenses (e.g. ['B', 'C', 'CE'])
|
||||||
|
fuehrerscheinklassen TEXT[] DEFAULT '{}',
|
||||||
|
|
||||||
|
-- Uniform sizing
|
||||||
|
tshirt_groesse VARCHAR(8)
|
||||||
|
CHECK (tshirt_groesse IS NULL OR tshirt_groesse IN (
|
||||||
|
'XS', 'S', 'M', 'L', 'XL', 'XXL', 'XXXL'
|
||||||
|
)),
|
||||||
|
schuhgroesse VARCHAR(8),
|
||||||
|
|
||||||
|
-- Free-text notes (Kommandant only)
|
||||||
|
bemerkungen TEXT,
|
||||||
|
|
||||||
|
-- Profile photo URL (separate from the Authentik profile_picture_url)
|
||||||
|
bild_url TEXT,
|
||||||
|
|
||||||
|
-- Audit timestamps
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Enforce one-to-one relationship with users
|
||||||
|
CONSTRAINT uq_mitglieder_profile_user_id UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Indexes for the most common query patterns
|
||||||
|
-- ============================================================
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_user_id
|
||||||
|
ON mitglieder_profile(user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_status
|
||||||
|
ON mitglieder_profile(status);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_dienstgrad
|
||||||
|
ON mitglieder_profile(dienstgrad);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mitglieder_profile_mitglieds_nr
|
||||||
|
ON mitglieder_profile(mitglieds_nr);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Auto-update trigger for updated_at
|
||||||
|
-- Reuses the function already created by migration 001.
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TRIGGER update_mitglieder_profile_updated_at
|
||||||
|
BEFORE UPDATE ON mitglieder_profile
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- dienstgrad_verlauf
|
||||||
|
-- Append-only audit log of every rank change.
|
||||||
|
-- Never deleted; soft history only.
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS dienstgrad_verlauf (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
dienstgrad_neu VARCHAR(64) NOT NULL,
|
||||||
|
dienstgrad_alt VARCHAR(64), -- NULL on first assignment
|
||||||
|
datum DATE NOT NULL DEFAULT CURRENT_DATE,
|
||||||
|
durch_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
bemerkung TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dienstgrad_verlauf_user_id
|
||||||
|
ON dienstgrad_verlauf(user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dienstgrad_verlauf_datum
|
||||||
|
ON dienstgrad_verlauf(datum DESC);
|
||||||
271
backend/src/database/migrations/004_create_einsaetze.sql
Normal file
271
backend/src/database/migrations/004_create_einsaetze.sql
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
-- Migration: 004_create_einsaetze.sql
|
||||||
|
-- Feature: Einsatzprotokoll (Incident Management)
|
||||||
|
-- Depends on: 001_create_users_table.sql
|
||||||
|
-- Rollback instructions at bottom of file
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- ENUM-LIKE CHECK CONSTRAINTS (as VARCHAR + CHECK for easy extension)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- SEQUENCE SUPPORT TABLE for year-based Einsatz-Nr (YYYY-NNN)
|
||||||
|
-- One row per year; nextval equivalent done via UPDATE...RETURNING
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS einsatz_nr_sequence (
|
||||||
|
year INTEGER PRIMARY KEY,
|
||||||
|
last_nr INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- FAHRZEUGE TABLE (vehicles — stub so junction table has a valid FK target)
|
||||||
|
-- A full Fahrzeuge feature is covered by 005_create_fahrzeuge.sql.
|
||||||
|
-- We do NOT create the table here; migration 005 owns it.
|
||||||
|
-- The FK in einsatz_fahrzeuge will resolve correctly when 005 runs first
|
||||||
|
-- in the standard sort order (004 runs before 005, so we use DEFERRABLE or
|
||||||
|
-- we simply add the FK as a separate ALTER TABLE at the end of this file,
|
||||||
|
-- after we know 005 may not have run yet).
|
||||||
|
--
|
||||||
|
-- Resolution strategy: einsatz_fahrzeuge.fahrzeug_id FK is defined as
|
||||||
|
-- DEFERRABLE INITIALLY DEFERRED so it is checked at transaction commit time
|
||||||
|
-- rather than at statement time, allowing this migration to run without
|
||||||
|
-- requiring fahrzeuge to exist yet.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- MAIN EINSAETZE TABLE
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS einsaetze (
|
||||||
|
-- Identity
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
einsatz_nr VARCHAR(12) NOT NULL, -- Format: 2026-001
|
||||||
|
|
||||||
|
-- Core timestamps (all TIMESTAMPTZ — never null for alarm_time)
|
||||||
|
alarm_time TIMESTAMPTZ NOT NULL,
|
||||||
|
ausrueck_time TIMESTAMPTZ,
|
||||||
|
ankunft_time TIMESTAMPTZ,
|
||||||
|
einrueck_time TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- Classification
|
||||||
|
einsatz_art VARCHAR(30) NOT NULL
|
||||||
|
CONSTRAINT chk_einsatz_art CHECK (einsatz_art IN (
|
||||||
|
'Brand',
|
||||||
|
'THL',
|
||||||
|
'ABC',
|
||||||
|
'BMA',
|
||||||
|
'Hilfeleistung',
|
||||||
|
'Fehlalarm',
|
||||||
|
'Brandsicherheitswache'
|
||||||
|
)),
|
||||||
|
einsatz_stichwort VARCHAR(30), -- B1/B2/B3/B4, THL 1/2/3, etc.
|
||||||
|
|
||||||
|
-- Location
|
||||||
|
strasse VARCHAR(150),
|
||||||
|
hausnummer VARCHAR(20),
|
||||||
|
ort VARCHAR(100),
|
||||||
|
koordinaten POINT, -- (longitude, latitude)
|
||||||
|
|
||||||
|
-- Narrative
|
||||||
|
bericht_kurz VARCHAR(255), -- short description, visible to all
|
||||||
|
bericht_text TEXT, -- full narrative, restricted to Kommandant+
|
||||||
|
|
||||||
|
-- References
|
||||||
|
einsatzleiter_id UUID
|
||||||
|
CONSTRAINT fk_einsaetze_einsatzleiter REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Operational metadata
|
||||||
|
alarmierung_art VARCHAR(30) NOT NULL DEFAULT 'ILS'
|
||||||
|
CONSTRAINT chk_alarmierung_art CHECK (alarmierung_art IN (
|
||||||
|
'ILS',
|
||||||
|
'DME',
|
||||||
|
'Telefon',
|
||||||
|
'Vor_Ort',
|
||||||
|
'Sonstiges'
|
||||||
|
)),
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'aktiv'
|
||||||
|
CONSTRAINT chk_einsatz_status CHECK (status IN (
|
||||||
|
'aktiv',
|
||||||
|
'abgeschlossen',
|
||||||
|
'archiviert'
|
||||||
|
)),
|
||||||
|
|
||||||
|
-- Audit
|
||||||
|
created_by UUID
|
||||||
|
CONSTRAINT fk_einsaetze_created_by REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- Uniqueness: einsatz_nr is globally unique (year+seq guarantees it)
|
||||||
|
CONSTRAINT uq_einsatz_nr UNIQUE (einsatz_nr)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Performance indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_time ON einsaetze(alarm_time DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatz_art ON einsaetze(einsatz_art);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_einsaetze_status ON einsaetze(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_einsaetze_einsatzleiter ON einsaetze(einsatzleiter_id);
|
||||||
|
-- Note: EXTRACT(YEAR FROM timestamptz) is STABLE (timezone-dependent), not IMMUTABLE,
|
||||||
|
-- so it cannot be used in expression indexes. Use alarm_time range scans for year filtering.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_einsaetze_alarm_art ON einsaetze(einsatz_art, alarm_time DESC);
|
||||||
|
|
||||||
|
-- Auto-update updated_at
|
||||||
|
CREATE TRIGGER update_einsaetze_updated_at
|
||||||
|
BEFORE UPDATE ON einsaetze
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- JUNCTION TABLE: einsatz_fahrzeuge
|
||||||
|
-- NOTE: fahrzeug_id FK references fahrzeuge which is created in 005.
|
||||||
|
-- We define the FK as DEFERRABLE INITIALLY DEFERRED so the constraint is
|
||||||
|
-- validated at transaction commit, not statement time. This means the
|
||||||
|
-- migration can run before 005 as long as both run in the same session.
|
||||||
|
-- In production where 005 was already run, the FK resolves immediately.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS einsatz_fahrzeuge (
|
||||||
|
einsatz_id UUID NOT NULL
|
||||||
|
CONSTRAINT fk_ef_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE,
|
||||||
|
fahrzeug_id UUID NOT NULL,
|
||||||
|
-- FK added separately below via ALTER TABLE to handle cross-migration dependency
|
||||||
|
|
||||||
|
-- Vehicle-level timestamps (may differ from main einsatz times)
|
||||||
|
ausrueck_time TIMESTAMPTZ,
|
||||||
|
einrueck_time TIMESTAMPTZ,
|
||||||
|
|
||||||
|
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT pk_einsatz_fahrzeuge PRIMARY KEY (einsatz_id, fahrzeug_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Add the FK to fahrzeuge only if the fahrzeuge table already exists
|
||||||
|
-- (handles the case where 004 runs after 005 in a fresh deployment).
|
||||||
|
-- If fahrzeuge does not exist yet, the FK will be added by migration 005
|
||||||
|
-- via an ALTER TABLE at the end of that migration.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'fahrzeuge'
|
||||||
|
) THEN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE table_name = 'einsatz_fahrzeuge'
|
||||||
|
AND constraint_name = 'fk_ef_fahrzeug'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE einsatz_fahrzeuge
|
||||||
|
ADD CONSTRAINT fk_ef_fahrzeug
|
||||||
|
FOREIGN KEY (fahrzeug_id) REFERENCES fahrzeuge(id) ON DELETE CASCADE;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ef_fahrzeug_id ON einsatz_fahrzeuge(fahrzeug_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- JUNCTION TABLE: einsatz_personal
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS einsatz_personal (
|
||||||
|
einsatz_id UUID NOT NULL
|
||||||
|
CONSTRAINT fk_ep_einsatz REFERENCES einsaetze(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL
|
||||||
|
CONSTRAINT fk_ep_user REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
funktion VARCHAR(50) NOT NULL DEFAULT 'Mannschaft'
|
||||||
|
CONSTRAINT chk_ep_funktion CHECK (funktion IN (
|
||||||
|
'Einsatzleiter',
|
||||||
|
'Gruppenführer',
|
||||||
|
'Maschinist',
|
||||||
|
'Atemschutz',
|
||||||
|
'Sicherheitstrupp',
|
||||||
|
'Melder',
|
||||||
|
'Wassertrupp',
|
||||||
|
'Angriffstrupp',
|
||||||
|
'Mannschaft',
|
||||||
|
'Sonstiges'
|
||||||
|
)),
|
||||||
|
|
||||||
|
-- Personal-level timestamps
|
||||||
|
alarm_time TIMESTAMPTZ,
|
||||||
|
ankunft_time TIMESTAMPTZ,
|
||||||
|
|
||||||
|
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT pk_einsatz_personal PRIMARY KEY (einsatz_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ep_user_id ON einsatz_personal(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ep_funktion ON einsatz_personal(funktion);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- FUNCTION: generate_einsatz_nr(alarm_time)
|
||||||
|
-- Atomically generates the next Einsatz-Nr for a given year.
|
||||||
|
-- Uses an advisory lock to serialise concurrent inserts for the same year.
|
||||||
|
-- Returns format: '2026-001'
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION generate_einsatz_nr(p_alarm_time TIMESTAMPTZ)
|
||||||
|
RETURNS VARCHAR AS $$
|
||||||
|
DECLARE
|
||||||
|
v_year INTEGER;
|
||||||
|
v_nr INTEGER;
|
||||||
|
BEGIN
|
||||||
|
v_year := EXTRACT(YEAR FROM p_alarm_time)::INTEGER;
|
||||||
|
|
||||||
|
-- Upsert the sequence row, then atomically increment and return
|
||||||
|
INSERT INTO einsatz_nr_sequence (year, last_nr)
|
||||||
|
VALUES (v_year, 1)
|
||||||
|
ON CONFLICT (year) DO UPDATE
|
||||||
|
SET last_nr = einsatz_nr_sequence.last_nr + 1
|
||||||
|
RETURNING last_nr INTO v_nr;
|
||||||
|
|
||||||
|
RETURN v_year::TEXT || '-' || LPAD(v_nr::TEXT, 3, '0');
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- MATERIALIZED VIEW: einsatz_statistik
|
||||||
|
-- Pre-aggregated stats used by the dashboard and annual KBI reports.
|
||||||
|
-- Refresh manually after inserts/updates via: REFRESH MATERIALIZED VIEW einsatz_statistik;
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE MATERIALIZED VIEW IF NOT EXISTS einsatz_statistik AS
|
||||||
|
SELECT
|
||||||
|
EXTRACT(YEAR FROM alarm_time)::INTEGER AS jahr,
|
||||||
|
EXTRACT(MONTH FROM alarm_time)::INTEGER AS monat,
|
||||||
|
einsatz_art,
|
||||||
|
COUNT(*) AS anzahl,
|
||||||
|
-- Hilfsfrist: median minutes from alarm to arrival (only where ankunft_time is set)
|
||||||
|
ROUND(
|
||||||
|
AVG(
|
||||||
|
EXTRACT(EPOCH FROM (ankunft_time - alarm_time)) / 60.0
|
||||||
|
) FILTER (WHERE ankunft_time IS NOT NULL)
|
||||||
|
)::INTEGER AS avg_hilfsfrist_min,
|
||||||
|
-- Total duration (alarm to einrueck)
|
||||||
|
ROUND(
|
||||||
|
AVG(
|
||||||
|
EXTRACT(EPOCH FROM (einrueck_time - alarm_time)) / 60.0
|
||||||
|
) FILTER (WHERE einrueck_time IS NOT NULL)
|
||||||
|
)::INTEGER AS avg_dauer_min,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'abgeschlossen') AS anzahl_abgeschlossen,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'aktiv') AS anzahl_aktiv
|
||||||
|
FROM einsaetze
|
||||||
|
WHERE status != 'archiviert'
|
||||||
|
GROUP BY
|
||||||
|
EXTRACT(YEAR FROM alarm_time),
|
||||||
|
EXTRACT(MONTH FROM alarm_time),
|
||||||
|
einsatz_art
|
||||||
|
WITH DATA;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_einsatz_statistik_pk
|
||||||
|
ON einsatz_statistik(jahr, monat, einsatz_art);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_einsatz_statistik_jahr
|
||||||
|
ON einsatz_statistik(jahr);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- ROLLBACK INSTRUCTIONS (run in reverse order to undo):
|
||||||
|
--
|
||||||
|
-- DROP MATERIALIZED VIEW IF EXISTS einsatz_statistik;
|
||||||
|
-- DROP FUNCTION IF EXISTS generate_einsatz_nr(TIMESTAMPTZ);
|
||||||
|
-- DROP TABLE IF EXISTS einsatz_personal;
|
||||||
|
-- DROP TABLE IF EXISTS einsatz_fahrzeuge;
|
||||||
|
-- DROP TABLE IF EXISTS einsaetze;
|
||||||
|
-- DROP TABLE IF EXISTS fahrzeuge;
|
||||||
|
-- DROP TABLE IF EXISTS einsatz_nr_sequence;
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
331
backend/src/database/migrations/005_create_fahrzeuge.sql
Normal file
331
backend/src/database/migrations/005_create_fahrzeuge.sql
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
-- Migration 005: Fahrzeugverwaltung (Vehicle Fleet Management)
|
||||||
|
-- Depends on: 001_create_users_table.sql (uuid-ossp extension, users table)
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE: fahrzeuge
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS fahrzeuge (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
bezeichnung VARCHAR(100) NOT NULL, -- e.g. "LF 20/16", "HLF 10"
|
||||||
|
kurzname VARCHAR(20), -- e.g. "LF1", "HLF2"
|
||||||
|
amtliches_kennzeichen VARCHAR(20) UNIQUE, -- e.g. "WN-FW 1"
|
||||||
|
fahrgestellnummer VARCHAR(50), -- VIN
|
||||||
|
baujahr INTEGER CHECK (baujahr >= 1950 AND baujahr <= 2100),
|
||||||
|
hersteller VARCHAR(100), -- e.g. "MAN", "Mercedes-Benz", "Rosenbauer"
|
||||||
|
typ_schluessel VARCHAR(30), -- DIN 14502 code, e.g. "LF 20/16"
|
||||||
|
besatzung_soll VARCHAR(10), -- crew config e.g. "1/8", "1/5"
|
||||||
|
status VARCHAR(40) NOT NULL DEFAULT 'einsatzbereit'
|
||||||
|
CHECK (status IN (
|
||||||
|
'einsatzbereit',
|
||||||
|
'ausser_dienst_wartung',
|
||||||
|
'ausser_dienst_schaden',
|
||||||
|
'in_lehrgang'
|
||||||
|
)),
|
||||||
|
status_bemerkung TEXT,
|
||||||
|
standort VARCHAR(100) NOT NULL DEFAULT 'Feuerwehrhaus',
|
||||||
|
bild_url VARCHAR(500),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_status ON fahrzeuge(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_kennzeichen ON fahrzeuge(amtliches_kennzeichen);
|
||||||
|
|
||||||
|
-- Auto-update updated_at (reuses function from migration 001)
|
||||||
|
CREATE TRIGGER update_fahrzeuge_updated_at
|
||||||
|
BEFORE UPDATE ON fahrzeuge
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE: fahrzeug_pruefungen
|
||||||
|
-- Stores both upcoming scheduled inspections AND completed ones.
|
||||||
|
-- A row with durchgefuehrt_am = NULL is an open/scheduled inspection.
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS fahrzeug_pruefungen (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
|
||||||
|
pruefung_art VARCHAR(30) NOT NULL
|
||||||
|
CHECK (pruefung_art IN (
|
||||||
|
'HU', -- Hauptuntersuchung (TÜV), 24-month interval
|
||||||
|
'AU', -- Abgasuntersuchung, 12-month interval
|
||||||
|
'UVV', -- Unfallverhütungsvorschrift BGV D29, 12-month
|
||||||
|
'Leiter', -- Leiternprüfung (DLK), 12-month
|
||||||
|
'Kran', -- Kranprüfung, 12-month
|
||||||
|
'Seilwinde', -- Seilwindenprüfung, 12-month
|
||||||
|
'Sonstiges'
|
||||||
|
)),
|
||||||
|
-- faellig_am: the deadline by which this inspection must be completed
|
||||||
|
faellig_am DATE NOT NULL,
|
||||||
|
-- durchgefuehrt_am: NULL = not yet done; set when inspection is completed
|
||||||
|
durchgefuehrt_am DATE,
|
||||||
|
ergebnis VARCHAR(30)
|
||||||
|
CHECK (ergebnis IS NULL OR ergebnis IN (
|
||||||
|
'bestanden',
|
||||||
|
'bestanden_mit_maengeln',
|
||||||
|
'nicht_bestanden',
|
||||||
|
'ausstehend'
|
||||||
|
)),
|
||||||
|
-- naechste_faelligkeit: auto-calculated next due date after completion
|
||||||
|
naechste_faelligkeit DATE,
|
||||||
|
pruefende_stelle VARCHAR(150), -- e.g. "TÜV Süd Stuttgart", "DEKRA"
|
||||||
|
kosten DECIMAL(8,2),
|
||||||
|
dokument_url VARCHAR(500),
|
||||||
|
bemerkung TEXT,
|
||||||
|
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pruefungen_fahrzeug_id ON fahrzeug_pruefungen(fahrzeug_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pruefungen_faellig_am ON fahrzeug_pruefungen(faellig_am);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pruefungen_art ON fahrzeug_pruefungen(pruefung_art);
|
||||||
|
-- Composite index for the "latest per type" query pattern
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pruefungen_fahrzeug_art_faellig
|
||||||
|
ON fahrzeug_pruefungen(fahrzeug_id, pruefung_art, faellig_am DESC);
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE: fahrzeug_wartungslog
|
||||||
|
-- Service/maintenance log entries (fuel, repairs, tyres, etc.)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS fahrzeug_wartungslog (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
|
||||||
|
datum DATE NOT NULL,
|
||||||
|
art VARCHAR(30)
|
||||||
|
CHECK (art IS NULL OR art IN (
|
||||||
|
'Inspektion',
|
||||||
|
'Reparatur',
|
||||||
|
'Kraftstoff',
|
||||||
|
'Reifenwechsel',
|
||||||
|
'Hauptuntersuchung',
|
||||||
|
'Reinigung',
|
||||||
|
'Sonstiges'
|
||||||
|
)),
|
||||||
|
beschreibung TEXT NOT NULL,
|
||||||
|
km_stand INTEGER CHECK (km_stand >= 0),
|
||||||
|
kraftstoff_liter DECIMAL(6,2) CHECK (kraftstoff_liter >= 0),
|
||||||
|
kosten DECIMAL(8,2) CHECK (kosten >= 0),
|
||||||
|
externe_werkstatt VARCHAR(150),
|
||||||
|
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wartungslog_fahrzeug_id ON fahrzeug_wartungslog(fahrzeug_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_wartungslog_datum ON fahrzeug_wartungslog(datum DESC);
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- VIEW: fahrzeuge_mit_pruefstatus
|
||||||
|
-- For each vehicle, joins its LATEST scheduled pruefung per art
|
||||||
|
-- and computes tage_bis_faelligkeit (negative = overdue).
|
||||||
|
-- The dashboard alert panel and fleet overview query this view.
|
||||||
|
-- ============================================================
|
||||||
|
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
|
||||||
|
WITH latest_pruefungen AS (
|
||||||
|
-- For each (fahrzeug, pruefung_art) pair, pick the row with the
|
||||||
|
-- latest faellig_am that is NOT yet completed (durchgefuehrt_am IS NULL),
|
||||||
|
-- OR if all are completed, the one with the highest naechste_faelligkeit.
|
||||||
|
SELECT DISTINCT ON (fahrzeug_id, pruefung_art)
|
||||||
|
fahrzeug_id,
|
||||||
|
pruefung_art,
|
||||||
|
id AS pruefung_id,
|
||||||
|
faellig_am,
|
||||||
|
durchgefuehrt_am,
|
||||||
|
ergebnis,
|
||||||
|
naechste_faelligkeit,
|
||||||
|
pruefende_stelle,
|
||||||
|
CURRENT_DATE - faellig_am::date AS tage_ueberfaellig,
|
||||||
|
faellig_am::date - CURRENT_DATE AS tage_bis_faelligkeit
|
||||||
|
FROM fahrzeug_pruefungen
|
||||||
|
ORDER BY
|
||||||
|
fahrzeug_id,
|
||||||
|
pruefung_art,
|
||||||
|
-- Open inspections (nicht durchgeführt) first, then most recent
|
||||||
|
(durchgefuehrt_am IS NULL) DESC,
|
||||||
|
faellig_am DESC
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
f.id,
|
||||||
|
f.bezeichnung,
|
||||||
|
f.kurzname,
|
||||||
|
f.amtliches_kennzeichen,
|
||||||
|
f.fahrgestellnummer,
|
||||||
|
f.baujahr,
|
||||||
|
f.hersteller,
|
||||||
|
f.typ_schluessel,
|
||||||
|
f.besatzung_soll,
|
||||||
|
f.status,
|
||||||
|
f.status_bemerkung,
|
||||||
|
f.standort,
|
||||||
|
f.bild_url,
|
||||||
|
f.created_at,
|
||||||
|
f.updated_at,
|
||||||
|
-- HU
|
||||||
|
hu.pruefung_id AS hu_pruefung_id,
|
||||||
|
hu.faellig_am AS hu_faellig_am,
|
||||||
|
hu.tage_bis_faelligkeit AS hu_tage_bis_faelligkeit,
|
||||||
|
hu.ergebnis AS hu_ergebnis,
|
||||||
|
-- AU
|
||||||
|
au.pruefung_id AS au_pruefung_id,
|
||||||
|
au.faellig_am AS au_faellig_am,
|
||||||
|
au.tage_bis_faelligkeit AS au_tage_bis_faelligkeit,
|
||||||
|
au.ergebnis AS au_ergebnis,
|
||||||
|
-- UVV
|
||||||
|
uvv.pruefung_id AS uvv_pruefung_id,
|
||||||
|
uvv.faellig_am AS uvv_faellig_am,
|
||||||
|
uvv.tage_bis_faelligkeit AS uvv_tage_bis_faelligkeit,
|
||||||
|
uvv.ergebnis AS uvv_ergebnis,
|
||||||
|
-- Leiter (DLK only)
|
||||||
|
leiter.pruefung_id AS leiter_pruefung_id,
|
||||||
|
leiter.faellig_am AS leiter_faellig_am,
|
||||||
|
leiter.tage_bis_faelligkeit AS leiter_tage_bis_faelligkeit,
|
||||||
|
leiter.ergebnis AS leiter_ergebnis,
|
||||||
|
-- Overall worst tage_bis_faelligkeit across all active inspections
|
||||||
|
LEAST(
|
||||||
|
hu.tage_bis_faelligkeit,
|
||||||
|
au.tage_bis_faelligkeit,
|
||||||
|
uvv.tage_bis_faelligkeit,
|
||||||
|
leiter.tage_bis_faelligkeit
|
||||||
|
) AS naechste_pruefung_tage
|
||||||
|
FROM
|
||||||
|
fahrzeuge f
|
||||||
|
LEFT JOIN latest_pruefungen hu ON hu.fahrzeug_id = f.id AND hu.pruefung_art = 'HU'
|
||||||
|
LEFT JOIN latest_pruefungen au ON au.fahrzeug_id = f.id AND au.pruefung_art = 'AU'
|
||||||
|
LEFT JOIN latest_pruefungen uvv ON uvv.fahrzeug_id = f.id AND uvv.pruefung_art = 'UVV'
|
||||||
|
LEFT JOIN latest_pruefungen leiter ON leiter.fahrzeug_id = f.id AND leiter.pruefung_art = 'Leiter';
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- SEED DATA: 3 typical German Feuerwehr vehicles
|
||||||
|
-- ============================================================
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
v_lf10_id UUID := uuid_generate_v4();
|
||||||
|
v_hlf20_id UUID := uuid_generate_v4();
|
||||||
|
v_mtf_id UUID := uuid_generate_v4();
|
||||||
|
BEGIN
|
||||||
|
-- Only insert if no vehicles exist yet (idempotent seed)
|
||||||
|
IF (SELECT COUNT(*) FROM fahrzeuge) = 0 THEN
|
||||||
|
|
||||||
|
-- 1) LF 10 – Standard Löschgruppenfahrzeug
|
||||||
|
INSERT INTO fahrzeuge (
|
||||||
|
id, bezeichnung, kurzname, amtliches_kennzeichen,
|
||||||
|
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
|
||||||
|
besatzung_soll, status, standort
|
||||||
|
) VALUES (
|
||||||
|
v_lf10_id,
|
||||||
|
'LF 10',
|
||||||
|
'LF 1',
|
||||||
|
'WN-FW 1',
|
||||||
|
'WDB9634031L123456',
|
||||||
|
2018,
|
||||||
|
'Mercedes-Benz Atego',
|
||||||
|
'LF 10',
|
||||||
|
'1/8',
|
||||||
|
'einsatzbereit',
|
||||||
|
'Feuerwehrhaus'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- LF 10 inspections
|
||||||
|
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
|
||||||
|
(v_lf10_id, 'HU', '2026-03-15', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
|
||||||
|
(v_lf10_id, 'AU', '2026-04-01', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
|
||||||
|
(v_lf10_id, 'UVV', '2025-11-30', '2025-11-28', 'bestanden', '2026-11-28', 'DGUV Prüfer Rems');
|
||||||
|
|
||||||
|
-- LF 10 maintenance log
|
||||||
|
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
|
||||||
|
(v_lf10_id, '2025-11-28', 'Inspektion', 'Jahresinspektion nach Herstellervorgabe, Öl- und Filterwechsel, Bremsenprüfung', 48320, NULL, 420.00),
|
||||||
|
(v_lf10_id, '2025-10-15', 'Kraftstoff', 'Betankung nach Einsatz Feuerwehr Rems', 48150, 85.4, 145.18),
|
||||||
|
(v_lf10_id, '2025-09-01', 'Reifenwechsel','Sommerreifen auf Winterreifen gewechselt, alle 4 Reifen erneuert (Continental)', 47800, NULL, 980.00);
|
||||||
|
|
||||||
|
-- 2) HLF 20/16 – Hilfeleistungslöschgruppenfahrzeug (flagship)
|
||||||
|
INSERT INTO fahrzeuge (
|
||||||
|
id, bezeichnung, kurzname, amtliches_kennzeichen,
|
||||||
|
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
|
||||||
|
besatzung_soll, status, standort
|
||||||
|
) VALUES (
|
||||||
|
v_hlf20_id,
|
||||||
|
'HLF 20/16',
|
||||||
|
'HLF 1',
|
||||||
|
'WN-FW 2',
|
||||||
|
'WMAN29ZZ3LM654321',
|
||||||
|
2020,
|
||||||
|
'MAN TGM / Rosenbauer',
|
||||||
|
'HLF 20/16',
|
||||||
|
'1/8',
|
||||||
|
'einsatzbereit',
|
||||||
|
'Feuerwehrhaus'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- HLF 20 inspections — all current
|
||||||
|
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
|
||||||
|
(v_hlf20_id, 'HU', '2026-08-20', NULL, 'ausstehend', NULL, 'DEKRA Esslingen'),
|
||||||
|
(v_hlf20_id, 'AU', '2026-02-01', '2026-01-28', 'bestanden', '2027-01-28', 'DEKRA Esslingen'),
|
||||||
|
(v_hlf20_id, 'UVV', '2026-01-15', '2026-01-14', 'bestanden', '2027-01-14', 'DGUV Prüfer Rems');
|
||||||
|
|
||||||
|
-- HLF 20 maintenance log
|
||||||
|
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
|
||||||
|
(v_hlf20_id, '2026-01-28', 'Hauptuntersuchung', 'AU bestanden ohne Mängel bei DEKRA Esslingen', 22450, NULL, 185.00),
|
||||||
|
(v_hlf20_id, '2026-01-14', 'Inspektion', 'UVV-Prüfung bestanden, Licht und Bremsen geprüft', 22430, NULL, 0.00),
|
||||||
|
(v_hlf20_id, '2025-12-10', 'Kraftstoff', 'Betankung nach Übung', 22300, 120.0, 204.00),
|
||||||
|
(v_hlf20_id, '2025-10-05', 'Reparatur', 'Hydraulikpumpe für Rettungssatz getauscht', 21980, NULL, 2340.00);
|
||||||
|
|
||||||
|
-- 3) MTF – Mannschaftstransportfahrzeug
|
||||||
|
INSERT INTO fahrzeuge (
|
||||||
|
id, bezeichnung, kurzname, amtliches_kennzeichen,
|
||||||
|
fahrgestellnummer, baujahr, hersteller, typ_schluessel,
|
||||||
|
besatzung_soll, status, status_bemerkung, standort
|
||||||
|
) VALUES (
|
||||||
|
v_mtf_id,
|
||||||
|
'MTF',
|
||||||
|
'MTF 1',
|
||||||
|
'WN-FW 5',
|
||||||
|
'WDB9066371S789012',
|
||||||
|
2015,
|
||||||
|
'Mercedes-Benz Sprinter',
|
||||||
|
'MTF',
|
||||||
|
'1/8',
|
||||||
|
'ausser_dienst_wartung',
|
||||||
|
'Geplante Inspektion: Zahnriemenwechsel 60.000 km fällig',
|
||||||
|
'Feuerwehrhaus'
|
||||||
|
);
|
||||||
|
|
||||||
|
-- MTF inspections — HU overdue (safety-critical test data)
|
||||||
|
INSERT INTO fahrzeug_pruefungen (fahrzeug_id, pruefung_art, faellig_am, durchgefuehrt_am, ergebnis, naechste_faelligkeit, pruefende_stelle) VALUES
|
||||||
|
(v_mtf_id, 'HU', '2026-01-31', NULL, 'ausstehend', NULL, 'TÜV Süd Stuttgart'),
|
||||||
|
(v_mtf_id, 'AU', '2025-06-15', '2025-06-12', 'bestanden', '2026-06-12', 'TÜV Süd Stuttgart'),
|
||||||
|
(v_mtf_id, 'UVV', '2025-12-01', '2025-11-30', 'bestanden_mit_maengeln', '2026-11-30', 'DGUV Prüfer Rems');
|
||||||
|
|
||||||
|
-- MTF maintenance log
|
||||||
|
INSERT INTO fahrzeug_wartungslog (fahrzeug_id, datum, art, beschreibung, km_stand, kraftstoff_liter, kosten) VALUES
|
||||||
|
(v_mtf_id, '2025-11-30', 'Inspektion', 'UVV-Prüfung: kleiner Mangel Innenbeleuchtung, nachgebessert', 58920, NULL, 0.00),
|
||||||
|
(v_mtf_id, '2025-11-01', 'Kraftstoff', 'Betankung regulär', 58700, 65.0, 110.50),
|
||||||
|
(v_mtf_id, '2025-09-20', 'Reparatur', 'Heckleuchte links defekt, Glühbirne getauscht', 58400, NULL, 12.50);
|
||||||
|
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Cross-migration FK: add fahrzeug_id FK to einsatz_fahrzeuge (created in 004)
|
||||||
|
-- This runs only if einsatz_fahrzeuge exists but the FK is not yet present.
|
||||||
|
-- Handles the case where 004 ran before 005 and deferred the FK creation.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'einsatz_fahrzeuge'
|
||||||
|
)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.table_constraints
|
||||||
|
WHERE table_name = 'einsatz_fahrzeuge'
|
||||||
|
AND constraint_name = 'fk_ef_fahrzeug'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE einsatz_fahrzeuge
|
||||||
|
ADD CONSTRAINT fk_ef_fahrzeug
|
||||||
|
FOREIGN KEY (fahrzeug_id) REFERENCES fahrzeuge(id) ON DELETE CASCADE;
|
||||||
|
RAISE NOTICE 'Added fk_ef_fahrzeug FK on einsatz_fahrzeuge';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
201
backend/src/database/migrations/006_create_uebungen.sql
Normal file
201
backend/src/database/migrations/006_create_uebungen.sql
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Migration 006: Übungsplanung & Dienstkalender
|
||||||
|
-- Training Schedule & Service Calendar for Feuerwehr Rems Dashboard
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. Calendar token table for iCal subscribe URLs (no auth required per-request)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(64) UNIQUE NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_used_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_calendar_tokens_token ON calendar_tokens(token);
|
||||||
|
CREATE INDEX idx_calendar_tokens_user_id ON calendar_tokens(user_id);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. Main events table
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TYPE uebung_typ AS ENUM (
|
||||||
|
'Übungsabend',
|
||||||
|
'Lehrgang',
|
||||||
|
'Sonderdienst',
|
||||||
|
'Versammlung',
|
||||||
|
'Gemeinschaftsübung',
|
||||||
|
'Sonstiges'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE uebungen (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
titel VARCHAR(255) NOT NULL,
|
||||||
|
beschreibung TEXT,
|
||||||
|
typ uebung_typ NOT NULL,
|
||||||
|
datum_von TIMESTAMPTZ NOT NULL,
|
||||||
|
datum_bis TIMESTAMPTZ NOT NULL,
|
||||||
|
ort VARCHAR(255),
|
||||||
|
treffpunkt VARCHAR(255),
|
||||||
|
pflichtveranstaltung BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
mindest_teilnehmer INT CHECK (mindest_teilnehmer > 0),
|
||||||
|
max_teilnehmer INT CHECK (max_teilnehmer > 0),
|
||||||
|
angelegt_von UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
abgesagt BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
absage_grund TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT datum_reihenfolge CHECK (datum_bis >= datum_von),
|
||||||
|
CONSTRAINT max_groesser_min CHECK (
|
||||||
|
max_teilnehmer IS NULL OR
|
||||||
|
mindest_teilnehmer IS NULL OR
|
||||||
|
max_teilnehmer >= mindest_teilnehmer
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_uebungen_datum_von ON uebungen(datum_von);
|
||||||
|
CREATE INDEX idx_uebungen_typ ON uebungen(typ);
|
||||||
|
CREATE INDEX idx_uebungen_pflichtveranstaltung ON uebungen(pflichtveranstaltung) WHERE pflichtveranstaltung = TRUE;
|
||||||
|
CREATE INDEX idx_uebungen_abgesagt ON uebungen(abgesagt) WHERE abgesagt = FALSE;
|
||||||
|
-- Compound index for the most common calendar-range query
|
||||||
|
CREATE INDEX idx_uebungen_datum_von_bis ON uebungen(datum_von, datum_bis);
|
||||||
|
|
||||||
|
-- Keep aktualisiert_am in sync via trigger (reuse function from migration 001)
|
||||||
|
CREATE TRIGGER update_uebungen_aktualisiert_am
|
||||||
|
BEFORE UPDATE ON uebungen
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 3. Attendance / RSVP table
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TYPE teilnahme_status AS ENUM (
|
||||||
|
'zugesagt',
|
||||||
|
'abgesagt',
|
||||||
|
'erschienen',
|
||||||
|
'entschuldigt',
|
||||||
|
'unbekannt'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE uebung_teilnahmen (
|
||||||
|
uebung_id UUID NOT NULL REFERENCES uebungen(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
status teilnahme_status NOT NULL DEFAULT 'unbekannt',
|
||||||
|
antwort_am TIMESTAMPTZ,
|
||||||
|
erschienen_erfasst_am TIMESTAMPTZ,
|
||||||
|
erschienen_erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
bemerkung VARCHAR(500),
|
||||||
|
|
||||||
|
PRIMARY KEY (uebung_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_teilnahmen_uebung_id ON uebung_teilnahmen(uebung_id);
|
||||||
|
CREATE INDEX idx_teilnahmen_user_id ON uebung_teilnahmen(user_id);
|
||||||
|
CREATE INDEX idx_teilnahmen_status ON uebung_teilnahmen(status);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 4. Trigger: auto-create 'unbekannt' rows for all active members on new event
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION fn_create_teilnahmen_for_all_active_members()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO uebung_teilnahmen (uebung_id, user_id, status)
|
||||||
|
SELECT NEW.id, u.id, 'unbekannt'
|
||||||
|
FROM users u
|
||||||
|
WHERE u.is_active = TRUE
|
||||||
|
ON CONFLICT (uebung_id, user_id) DO NOTHING;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_auto_teilnahmen_after_insert
|
||||||
|
AFTER INSERT ON uebungen
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION fn_create_teilnahmen_for_all_active_members();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 5. Trigger: when a new member becomes active, add them to all future events
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE FUNCTION fn_add_member_to_future_events()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Only run when is_active transitions FALSE -> TRUE
|
||||||
|
IF (OLD.is_active = FALSE OR OLD.is_active IS NULL) AND NEW.is_active = TRUE THEN
|
||||||
|
INSERT INTO uebung_teilnahmen (uebung_id, user_id, status)
|
||||||
|
SELECT u.id, NEW.id, 'unbekannt'
|
||||||
|
FROM uebungen u
|
||||||
|
WHERE u.datum_von > NOW()
|
||||||
|
AND u.abgesagt = FALSE
|
||||||
|
ON CONFLICT (uebung_id, user_id) DO NOTHING;
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER trg_add_member_to_future_events
|
||||||
|
AFTER UPDATE OF is_active ON users
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION fn_add_member_to_future_events();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 6. Convenience view: event overview with attendance counts
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW uebung_uebersicht AS
|
||||||
|
SELECT
|
||||||
|
u.id,
|
||||||
|
u.titel,
|
||||||
|
u.typ,
|
||||||
|
u.datum_von,
|
||||||
|
u.datum_bis,
|
||||||
|
u.ort,
|
||||||
|
u.treffpunkt,
|
||||||
|
u.pflichtveranstaltung,
|
||||||
|
u.mindest_teilnehmer,
|
||||||
|
u.max_teilnehmer,
|
||||||
|
u.abgesagt,
|
||||||
|
u.absage_grund,
|
||||||
|
u.angelegt_von,
|
||||||
|
u.erstellt_am,
|
||||||
|
u.aktualisiert_am,
|
||||||
|
-- Attendance aggregates
|
||||||
|
COUNT(t.user_id) AS gesamt_eingeladen,
|
||||||
|
COUNT(t.user_id) FILTER (WHERE t.status = 'zugesagt') AS anzahl_zugesagt,
|
||||||
|
COUNT(t.user_id) FILTER (WHERE t.status = 'abgesagt') AS anzahl_abgesagt,
|
||||||
|
COUNT(t.user_id) FILTER (WHERE t.status = 'erschienen') AS anzahl_erschienen,
|
||||||
|
COUNT(t.user_id) FILTER (WHERE t.status = 'entschuldigt') AS anzahl_entschuldigt,
|
||||||
|
COUNT(t.user_id) FILTER (WHERE t.status = 'unbekannt') AS anzahl_unbekannt
|
||||||
|
FROM uebungen u
|
||||||
|
LEFT JOIN uebung_teilnahmen t ON t.uebung_id = u.id
|
||||||
|
GROUP BY u.id;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 7. View: per-member participation statistics (feeds Tier 3 reporting)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW member_participation_stats AS
|
||||||
|
SELECT
|
||||||
|
usr.id AS user_id,
|
||||||
|
COALESCE(usr.name, usr.preferred_username, usr.email) AS name,
|
||||||
|
COUNT(t.uebung_id) AS total_eingeladen,
|
||||||
|
COUNT(t.uebung_id) FILTER (WHERE t.status = 'erschienen') AS total_erschienen,
|
||||||
|
COUNT(t.uebung_id) FILTER (
|
||||||
|
WHERE u.pflichtveranstaltung = TRUE AND t.status = 'erschienen'
|
||||||
|
) AS pflicht_erschienen,
|
||||||
|
COUNT(t.uebung_id) FILTER (WHERE u.pflichtveranstaltung = TRUE) AS pflicht_gesamt,
|
||||||
|
ROUND(
|
||||||
|
CASE
|
||||||
|
WHEN COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') = 0 THEN 0
|
||||||
|
ELSE
|
||||||
|
COUNT(t.uebung_id) FILTER (
|
||||||
|
WHERE u.typ = 'Übungsabend' AND t.status = 'erschienen'
|
||||||
|
)::NUMERIC /
|
||||||
|
COUNT(t.uebung_id) FILTER (WHERE u.typ = 'Übungsabend') * 100
|
||||||
|
END, 1
|
||||||
|
) AS uebungsabend_quote_pct
|
||||||
|
FROM users usr
|
||||||
|
JOIN uebung_teilnahmen t ON t.user_id = usr.id
|
||||||
|
JOIN uebungen u ON u.id = t.uebung_id
|
||||||
|
WHERE usr.is_active = TRUE
|
||||||
|
AND u.abgesagt = FALSE
|
||||||
|
GROUP BY usr.id, usr.name, usr.preferred_username, usr.email;
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
-- Migration 007: Authentik groups + vehicle inspection/service periods
|
||||||
|
-- Depends on: 001_create_users_table.sql, 005_create_fahrzeuge.sql
|
||||||
|
--
|
||||||
|
-- Changes:
|
||||||
|
-- 1. Add authentik_groups column to users (stores Authentik group memberships)
|
||||||
|
-- 2. Add paragraph57a_faellig_am + naechste_wartung_am to fahrzeuge
|
||||||
|
-- 3. Refresh the fahrzeuge_mit_pruefstatus view to expose the new columns
|
||||||
|
-- Rollback:
|
||||||
|
-- ALTER TABLE users DROP COLUMN IF EXISTS authentik_groups;
|
||||||
|
-- ALTER TABLE fahrzeuge DROP COLUMN IF EXISTS paragraph57a_faellig_am;
|
||||||
|
-- ALTER TABLE fahrzeuge DROP COLUMN IF EXISTS naechste_wartung_am;
|
||||||
|
|
||||||
|
-- ── 1. users: Authentik group memberships ─────────────────────────────────────
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS authentik_groups TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
COMMENT ON COLUMN users.authentik_groups IS
|
||||||
|
'Authentik group slugs synced on every login (e.g. dashboard_admin, fahrmeister)';
|
||||||
|
|
||||||
|
-- ── 2. fahrzeuge: §57a (Austrian periodic inspection) + service interval ──────
|
||||||
|
ALTER TABLE fahrzeuge
|
||||||
|
ADD COLUMN IF NOT EXISTS paragraph57a_faellig_am DATE;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN fahrzeuge.paragraph57a_faellig_am IS
|
||||||
|
'§57a StVO periodic inspection due date (Austrian equivalent of HU/TÜV)';
|
||||||
|
|
||||||
|
ALTER TABLE fahrzeuge
|
||||||
|
ADD COLUMN IF NOT EXISTS naechste_wartung_am DATE;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN fahrzeuge.naechste_wartung_am IS
|
||||||
|
'Next scheduled service / maintenance due date';
|
||||||
|
|
||||||
|
-- ── 3. Refresh view to expose new vehicle columns ─────────────────────────────
|
||||||
|
-- Drop and recreate since CREATE OR REPLACE on views requires identical column list.
|
||||||
|
DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
|
||||||
|
WITH latest_pruefungen AS (
|
||||||
|
SELECT DISTINCT ON (fahrzeug_id, pruefung_art)
|
||||||
|
fahrzeug_id,
|
||||||
|
pruefung_art,
|
||||||
|
id AS pruefung_id,
|
||||||
|
faellig_am,
|
||||||
|
durchgefuehrt_am,
|
||||||
|
ergebnis,
|
||||||
|
naechste_faelligkeit,
|
||||||
|
pruefende_stelle,
|
||||||
|
CURRENT_DATE - faellig_am::date AS tage_ueberfaellig,
|
||||||
|
faellig_am::date - CURRENT_DATE AS tage_bis_faelligkeit
|
||||||
|
FROM fahrzeug_pruefungen
|
||||||
|
ORDER BY
|
||||||
|
fahrzeug_id,
|
||||||
|
pruefung_art,
|
||||||
|
(durchgefuehrt_am IS NULL) DESC,
|
||||||
|
faellig_am DESC
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
f.id,
|
||||||
|
f.bezeichnung,
|
||||||
|
f.kurzname,
|
||||||
|
f.amtliches_kennzeichen,
|
||||||
|
f.fahrgestellnummer,
|
||||||
|
f.baujahr,
|
||||||
|
f.hersteller,
|
||||||
|
f.typ_schluessel,
|
||||||
|
f.besatzung_soll,
|
||||||
|
f.status,
|
||||||
|
f.status_bemerkung,
|
||||||
|
f.standort,
|
||||||
|
f.bild_url,
|
||||||
|
f.created_at,
|
||||||
|
f.updated_at,
|
||||||
|
-- §57a Austrian periodic inspection
|
||||||
|
f.paragraph57a_faellig_am,
|
||||||
|
f.paragraph57a_faellig_am::date - CURRENT_DATE AS paragraph57a_tage_bis_faelligkeit,
|
||||||
|
-- Next service/maintenance
|
||||||
|
f.naechste_wartung_am,
|
||||||
|
f.naechste_wartung_am::date - CURRENT_DATE AS wartung_tage_bis_faelligkeit,
|
||||||
|
-- Legacy pruefungen (HU / AU / UVV / Leiter) kept for backwards compat
|
||||||
|
hu.pruefung_id AS hu_pruefung_id,
|
||||||
|
hu.faellig_am AS hu_faellig_am,
|
||||||
|
hu.tage_bis_faelligkeit AS hu_tage_bis_faelligkeit,
|
||||||
|
hu.ergebnis AS hu_ergebnis,
|
||||||
|
au.pruefung_id AS au_pruefung_id,
|
||||||
|
au.faellig_am AS au_faellig_am,
|
||||||
|
au.tage_bis_faelligkeit AS au_tage_bis_faelligkeit,
|
||||||
|
au.ergebnis AS au_ergebnis,
|
||||||
|
uvv.pruefung_id AS uvv_pruefung_id,
|
||||||
|
uvv.faellig_am AS uvv_faellig_am,
|
||||||
|
uvv.tage_bis_faelligkeit AS uvv_tage_bis_faelligkeit,
|
||||||
|
uvv.ergebnis AS uvv_ergebnis,
|
||||||
|
leiter.pruefung_id AS leiter_pruefung_id,
|
||||||
|
leiter.faellig_am AS leiter_faellig_am,
|
||||||
|
leiter.tage_bis_faelligkeit AS leiter_tage_bis_faelligkeit,
|
||||||
|
leiter.ergebnis AS leiter_ergebnis,
|
||||||
|
-- Overall worst urgency: §57a + Wartung take precedence, legacy pruefungen kept
|
||||||
|
LEAST(
|
||||||
|
f.paragraph57a_faellig_am::date - CURRENT_DATE,
|
||||||
|
f.naechste_wartung_am::date - CURRENT_DATE,
|
||||||
|
hu.tage_bis_faelligkeit,
|
||||||
|
au.tage_bis_faelligkeit,
|
||||||
|
uvv.tage_bis_faelligkeit,
|
||||||
|
leiter.tage_bis_faelligkeit
|
||||||
|
) AS naechste_pruefung_tage
|
||||||
|
FROM
|
||||||
|
fahrzeuge f
|
||||||
|
LEFT JOIN latest_pruefungen hu ON hu.fahrzeug_id = f.id AND hu.pruefung_art = 'HU'
|
||||||
|
LEFT JOIN latest_pruefungen au ON au.fahrzeug_id = f.id AND au.pruefung_art = 'AU'
|
||||||
|
LEFT JOIN latest_pruefungen uvv ON uvv.fahrzeug_id = f.id AND uvv.pruefung_art = 'UVV'
|
||||||
|
LEFT JOIN latest_pruefungen leiter ON leiter.fahrzeug_id = f.id AND leiter.pruefung_art = 'Leiter';
|
||||||
53
backend/src/database/migrations/008_simplify_inspections.sql
Normal file
53
backend/src/database/migrations/008_simplify_inspections.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
-- Migration 008: Simplify inspection model
|
||||||
|
-- Remove fahrzeug_pruefungen table and related structures.
|
||||||
|
-- Only §57a (paragraph57a_faellig_am) and Wartung (naechste_wartung_am)
|
||||||
|
-- remain as the two tracked inspection deadlines, stored on fahrzeuge.
|
||||||
|
|
||||||
|
-- Drop the pruefungen table (cascades to indexes)
|
||||||
|
DROP TABLE IF EXISTS fahrzeug_pruefungen CASCADE;
|
||||||
|
|
||||||
|
-- Drop and recreate the fleet overview view (simplified — no CTE)
|
||||||
|
DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
|
||||||
|
SELECT
|
||||||
|
f.id,
|
||||||
|
f.bezeichnung,
|
||||||
|
f.kurzname,
|
||||||
|
f.amtliches_kennzeichen,
|
||||||
|
f.fahrgestellnummer,
|
||||||
|
f.baujahr,
|
||||||
|
f.hersteller,
|
||||||
|
f.typ_schluessel,
|
||||||
|
f.besatzung_soll,
|
||||||
|
f.status,
|
||||||
|
f.status_bemerkung,
|
||||||
|
f.standort,
|
||||||
|
f.bild_url,
|
||||||
|
f.created_at,
|
||||||
|
f.updated_at,
|
||||||
|
f.paragraph57a_faellig_am,
|
||||||
|
CASE
|
||||||
|
WHEN f.paragraph57a_faellig_am IS NOT NULL
|
||||||
|
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
|
||||||
|
ELSE NULL
|
||||||
|
END AS paragraph57a_tage_bis_faelligkeit,
|
||||||
|
f.naechste_wartung_am,
|
||||||
|
CASE
|
||||||
|
WHEN f.naechste_wartung_am IS NOT NULL
|
||||||
|
THEN f.naechste_wartung_am::date - CURRENT_DATE
|
||||||
|
ELSE NULL
|
||||||
|
END AS wartung_tage_bis_faelligkeit,
|
||||||
|
LEAST(
|
||||||
|
CASE WHEN f.paragraph57a_faellig_am IS NOT NULL
|
||||||
|
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
|
||||||
|
ELSE NULL END,
|
||||||
|
CASE WHEN f.naechste_wartung_am IS NOT NULL
|
||||||
|
THEN f.naechste_wartung_am::date - CURRENT_DATE
|
||||||
|
ELSE NULL END
|
||||||
|
) AS naechste_pruefung_tage
|
||||||
|
FROM fahrzeuge f;
|
||||||
|
|
||||||
|
-- Index support for alert queries
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_paragraph57a ON fahrzeuge(paragraph57a_faellig_am) WHERE paragraph57a_faellig_am IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_wartung ON fahrzeuge(naechste_wartung_am) WHERE naechste_wartung_am IS NOT NULL;
|
||||||
57
backend/src/database/migrations/009_vehicle_soft_delete.sql
Normal file
57
backend/src/database/migrations/009_vehicle_soft_delete.sql
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
-- Migration 009: Soft delete for vehicles
|
||||||
|
-- Adds deleted_at to fahrzeuge and refreshes the view to exclude soft-deleted rows.
|
||||||
|
-- Hard DELETE is replaced by UPDATE SET deleted_at = NOW() in the service layer.
|
||||||
|
|
||||||
|
ALTER TABLE fahrzeuge
|
||||||
|
ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN fahrzeuge.deleted_at IS
|
||||||
|
'NULL = active vehicle. Set to timestamp when soft-deleted. Records are never physically removed.';
|
||||||
|
|
||||||
|
-- Partial index: only index active (non-deleted) vehicles for fast lookups
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeuge_active
|
||||||
|
ON fahrzeuge(id)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- Refresh the view to exclude soft-deleted vehicles
|
||||||
|
DROP VIEW IF EXISTS fahrzeuge_mit_pruefstatus;
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW fahrzeuge_mit_pruefstatus AS
|
||||||
|
SELECT
|
||||||
|
f.id,
|
||||||
|
f.bezeichnung,
|
||||||
|
f.kurzname,
|
||||||
|
f.amtliches_kennzeichen,
|
||||||
|
f.fahrgestellnummer,
|
||||||
|
f.baujahr,
|
||||||
|
f.hersteller,
|
||||||
|
f.typ_schluessel,
|
||||||
|
f.besatzung_soll,
|
||||||
|
f.status,
|
||||||
|
f.status_bemerkung,
|
||||||
|
f.standort,
|
||||||
|
f.bild_url,
|
||||||
|
f.created_at,
|
||||||
|
f.updated_at,
|
||||||
|
f.paragraph57a_faellig_am,
|
||||||
|
CASE
|
||||||
|
WHEN f.paragraph57a_faellig_am IS NOT NULL
|
||||||
|
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
|
||||||
|
ELSE NULL
|
||||||
|
END AS paragraph57a_tage_bis_faelligkeit,
|
||||||
|
f.naechste_wartung_am,
|
||||||
|
CASE
|
||||||
|
WHEN f.naechste_wartung_am IS NOT NULL
|
||||||
|
THEN f.naechste_wartung_am::date - CURRENT_DATE
|
||||||
|
ELSE NULL
|
||||||
|
END AS wartung_tage_bis_faelligkeit,
|
||||||
|
LEAST(
|
||||||
|
CASE WHEN f.paragraph57a_faellig_am IS NOT NULL
|
||||||
|
THEN f.paragraph57a_faellig_am::date - CURRENT_DATE
|
||||||
|
ELSE NULL END,
|
||||||
|
CASE WHEN f.naechste_wartung_am IS NOT NULL
|
||||||
|
THEN f.naechste_wartung_am::date - CURRENT_DATE
|
||||||
|
ELSE NULL END
|
||||||
|
) AS naechste_pruefung_tage
|
||||||
|
FROM fahrzeuge f
|
||||||
|
WHERE f.deleted_at IS NULL;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Migration 010: Simplify WartungslogArt from 7 types to 3
|
||||||
|
-- Maps existing data to new categories and updates the CHECK constraint.
|
||||||
|
--
|
||||||
|
-- Old types: New type:
|
||||||
|
-- Inspektion -> Service
|
||||||
|
-- Reparatur -> Service
|
||||||
|
-- Reifenwechsel -> Service
|
||||||
|
-- Reinigung -> Service
|
||||||
|
-- Hauptuntersuchung -> §57a Prüfung
|
||||||
|
-- Kraftstoff -> Sonstiges
|
||||||
|
-- Sonstiges -> Sonstiges (unchanged)
|
||||||
|
|
||||||
|
-- Step 1: Drop the old CHECK constraint FIRST (must happen before data changes)
|
||||||
|
ALTER TABLE fahrzeug_wartungslog DROP CONSTRAINT IF EXISTS fahrzeug_wartungslog_art_check;
|
||||||
|
|
||||||
|
-- Step 2: Migrate existing data to new type values
|
||||||
|
UPDATE fahrzeug_wartungslog SET art = 'Service' WHERE art IN ('Inspektion', 'Reparatur', 'Reifenwechsel', 'Reinigung');
|
||||||
|
UPDATE fahrzeug_wartungslog SET art = '§57a Prüfung' WHERE art = 'Hauptuntersuchung';
|
||||||
|
UPDATE fahrzeug_wartungslog SET art = 'Sonstiges' WHERE art = 'Kraftstoff';
|
||||||
|
|
||||||
|
-- Step 3: Add the new CHECK constraint with simplified types
|
||||||
|
ALTER TABLE fahrzeug_wartungslog
|
||||||
|
ADD CONSTRAINT fahrzeug_wartungslog_art_check
|
||||||
|
CHECK (art IS NULL OR art IN ('§57a Prüfung', 'Service', 'Sonstiges'));
|
||||||
143
backend/src/database/migrations/011_create_ausruestung.sql
Normal file
143
backend/src/database/migrations/011_create_ausruestung.sql
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
-- Migration 011: Ausrüstungsverwaltung (Equipment Management)
|
||||||
|
-- Depends on: 001_create_users_table.sql (uuid-ossp extension, users table)
|
||||||
|
-- 005_create_fahrzeuge.sql (fahrzeuge table)
|
||||||
|
-- 009_vehicle_soft_delete.sql (soft-delete pattern)
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE: ausruestung_kategorien (Equipment Categories — lookup)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS ausruestung_kategorien (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(100) NOT NULL UNIQUE, -- e.g. 'Atemschutzgeräte'
|
||||||
|
kurzname VARCHAR(30) NOT NULL UNIQUE, -- e.g. 'PA'
|
||||||
|
sortierung INTEGER NOT NULL DEFAULT 0, -- display order
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE: ausruestung (Core Equipment Inventory)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS ausruestung (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
bezeichnung VARCHAR(200) NOT NULL, -- e.g. 'Dräger PSS 5000'
|
||||||
|
kategorie_id UUID NOT NULL REFERENCES ausruestung_kategorien(id),
|
||||||
|
seriennummer VARCHAR(100), -- manufacturer serial
|
||||||
|
inventarnummer VARCHAR(50), -- internal inventory number
|
||||||
|
hersteller VARCHAR(150), -- manufacturer
|
||||||
|
baujahr INTEGER CHECK (baujahr >= 1950 AND baujahr <= 2100),
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'einsatzbereit'
|
||||||
|
CHECK (status IN (
|
||||||
|
'einsatzbereit',
|
||||||
|
'beschaedigt',
|
||||||
|
'in_wartung',
|
||||||
|
'ausser_dienst'
|
||||||
|
)),
|
||||||
|
status_bemerkung TEXT, -- free-text status note
|
||||||
|
ist_wichtig BOOLEAN NOT NULL DEFAULT FALSE, -- drives vehicle card warnings
|
||||||
|
fahrzeug_id UUID REFERENCES fahrzeuge(id) ON DELETE SET NULL, -- nullable
|
||||||
|
standort VARCHAR(150) NOT NULL DEFAULT 'Lager', -- used when no fahrzeug
|
||||||
|
pruef_intervall_monate INTEGER CHECK (pruef_intervall_monate > 0), -- nullable
|
||||||
|
letzte_pruefung_am DATE,
|
||||||
|
naechste_pruefung_am DATE,
|
||||||
|
bemerkung TEXT, -- general notes
|
||||||
|
deleted_at TIMESTAMPTZ, -- soft-delete
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ausruestung_status
|
||||||
|
ON ausruestung(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ausruestung_kategorie
|
||||||
|
ON ausruestung(kategorie_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ausruestung_fahrzeug
|
||||||
|
ON ausruestung(fahrzeug_id)
|
||||||
|
WHERE fahrzeug_id IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ausruestung_active
|
||||||
|
ON ausruestung(id)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ausruestung_pruefung
|
||||||
|
ON ausruestung(naechste_pruefung_am)
|
||||||
|
WHERE naechste_pruefung_am IS NOT NULL AND deleted_at IS NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ausruestung_wichtig
|
||||||
|
ON ausruestung(fahrzeug_id, status)
|
||||||
|
WHERE ist_wichtig = TRUE AND deleted_at IS NULL;
|
||||||
|
|
||||||
|
-- Auto-update updated_at (reuses function from migration 001)
|
||||||
|
CREATE TRIGGER update_ausruestung_updated_at
|
||||||
|
BEFORE UPDATE ON ausruestung
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE: ausruestung_wartungslog (Service/Inspection History)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS ausruestung_wartungslog (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
ausruestung_id UUID NOT NULL REFERENCES ausruestung(id) ON DELETE CASCADE,
|
||||||
|
datum DATE NOT NULL,
|
||||||
|
art VARCHAR(30) NOT NULL
|
||||||
|
CHECK (art IN (
|
||||||
|
'Prüfung',
|
||||||
|
'Reparatur',
|
||||||
|
'Sonstiges'
|
||||||
|
)),
|
||||||
|
beschreibung TEXT NOT NULL,
|
||||||
|
ergebnis VARCHAR(30)
|
||||||
|
CHECK (ergebnis IS NULL OR ergebnis IN (
|
||||||
|
'bestanden',
|
||||||
|
'bestanden_mit_maengeln',
|
||||||
|
'nicht_bestanden'
|
||||||
|
)),
|
||||||
|
kosten DECIMAL(8,2) CHECK (kosten >= 0),
|
||||||
|
pruefende_stelle VARCHAR(150), -- e.g. 'Atemschutzwerkstatt Bezirk'
|
||||||
|
dokument_url VARCHAR(500), -- link to scan/PDF
|
||||||
|
erfasst_von UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ausruestung_wartung_item
|
||||||
|
ON ausruestung_wartungslog(ausruestung_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ausruestung_wartung_datum
|
||||||
|
ON ausruestung_wartungslog(datum DESC);
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- VIEW: ausruestung_mit_pruefstatus
|
||||||
|
-- For each active equipment item, joins category and vehicle
|
||||||
|
-- and computes pruefung_tage_bis_faelligkeit (negative = overdue).
|
||||||
|
-- The dashboard equipment panel and fleet overview query this view.
|
||||||
|
-- ============================================================
|
||||||
|
CREATE OR REPLACE VIEW ausruestung_mit_pruefstatus AS
|
||||||
|
SELECT
|
||||||
|
a.*,
|
||||||
|
k.name AS kategorie_name,
|
||||||
|
k.kurzname AS kategorie_kurzname,
|
||||||
|
f.bezeichnung AS fahrzeug_bezeichnung,
|
||||||
|
f.kurzname AS fahrzeug_kurzname,
|
||||||
|
CASE
|
||||||
|
WHEN a.naechste_pruefung_am IS NOT NULL
|
||||||
|
THEN a.naechste_pruefung_am::date - CURRENT_DATE
|
||||||
|
ELSE NULL
|
||||||
|
END AS pruefung_tage_bis_faelligkeit
|
||||||
|
FROM ausruestung a
|
||||||
|
JOIN ausruestung_kategorien k ON k.id = a.kategorie_id
|
||||||
|
LEFT JOIN fahrzeuge f ON f.id = a.fahrzeug_id AND f.deleted_at IS NULL
|
||||||
|
WHERE a.deleted_at IS NULL;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- SEED DATA: Equipment Categories
|
||||||
|
-- ============================================================
|
||||||
|
INSERT INTO ausruestung_kategorien (name, kurzname, sortierung) VALUES
|
||||||
|
('Atemschutzgeräte', 'PA', 1),
|
||||||
|
('Pumpen', 'Pumpe', 2),
|
||||||
|
('Schläuche', 'SL', 3),
|
||||||
|
('Leitern', 'Leiter', 4),
|
||||||
|
('Rettungsgeräte', 'RG', 5),
|
||||||
|
('Messgeräte', 'MG', 6),
|
||||||
|
('Persönliche Schutzausrüstung', 'PSA', 7),
|
||||||
|
('Kommunikation', 'Funk', 8),
|
||||||
|
('Beleuchtung', 'Licht', 9),
|
||||||
|
('Sonstige', 'Sonst.', 10)
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
30
backend/src/database/migrations/012_create_ausbildung.sql
Normal file
30
backend/src/database/migrations/012_create_ausbildung.sql
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
-- Add FDISK Standesbuch-Nr to mitglieder_profile for sync matching
|
||||||
|
ALTER TABLE mitglieder_profile
|
||||||
|
ADD COLUMN IF NOT EXISTS fdisk_standesbuch_nr VARCHAR(32) UNIQUE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_mitglieder_fdisk_standesbuch_nr
|
||||||
|
ON mitglieder_profile(fdisk_standesbuch_nr);
|
||||||
|
|
||||||
|
-- Qualifications synced from FDISK
|
||||||
|
CREATE TABLE IF NOT EXISTS ausbildung (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
kursname VARCHAR(255) NOT NULL,
|
||||||
|
kurs_datum DATE,
|
||||||
|
ablaufdatum DATE,
|
||||||
|
ort VARCHAR(255),
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'abgeschlossen' CHECK (status IN (
|
||||||
|
'abgeschlossen', 'in_bearbeitung', 'abgelaufen'
|
||||||
|
)),
|
||||||
|
-- Composite key from FDISK to prevent duplicates on re-sync
|
||||||
|
fdisk_sync_key VARCHAR(255),
|
||||||
|
bemerkung TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_ausbildung_user_fdisk_key UNIQUE (user_id, fdisk_sync_key)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ausbildung_user_id ON ausbildung(user_id);
|
||||||
|
|
||||||
|
CREATE TRIGGER update_ausbildung_updated_at BEFORE UPDATE ON ausbildung
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Migration 013: Add Nextcloud credentials to users
|
||||||
|
-- Stores per-user Nextcloud login name and app password for Nextcloud API access.
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS nextcloud_login_name VARCHAR(255),
|
||||||
|
ADD COLUMN IF NOT EXISTS nextcloud_app_password TEXT;
|
||||||
129
backend/src/database/migrations/014_create_atemschutz.sql
Normal file
129
backend/src/database/migrations/014_create_atemschutz.sql
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
-- Migration: 014_create_atemschutz
|
||||||
|
-- Atemschutz-Traegerverwaltung (Breathing Apparatus Carrier Management)
|
||||||
|
-- Depends on: 001_create_users_table (users table, uuid-ossp, update_updated_at_column)
|
||||||
|
-- 003_create_mitglieder_profile (mitglieder_profile for the view)
|
||||||
|
-- Rollback:
|
||||||
|
-- DROP VIEW IF EXISTS atemschutz_uebersicht;
|
||||||
|
-- DROP TABLE IF EXISTS atemschutz_traeger;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TABLE: atemschutz_traeger (BA Carrier Registry)
|
||||||
|
-- Each row = one firefighter qualified/tracked for BA use.
|
||||||
|
-- One record per user (enforced by UNIQUE constraint).
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE IF NOT EXISTS atemschutz_traeger (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|
||||||
|
-- Atemschutz-Lehrgang (BA course qualification)
|
||||||
|
atemschutz_lehrgang BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
lehrgang_datum DATE,
|
||||||
|
|
||||||
|
-- G26.3 Aerztliche Untersuchung (doctor's examination)
|
||||||
|
untersuchung_datum DATE,
|
||||||
|
untersuchung_gueltig_bis DATE, -- typically valid 3 years
|
||||||
|
untersuchung_ergebnis VARCHAR(30)
|
||||||
|
CHECK (untersuchung_ergebnis IS NULL OR untersuchung_ergebnis IN (
|
||||||
|
'tauglich', 'bedingt_tauglich', 'nicht_tauglich'
|
||||||
|
)),
|
||||||
|
|
||||||
|
-- Leistungstest / Finnentest (performance test)
|
||||||
|
leistungstest_datum DATE,
|
||||||
|
leistungstest_gueltig_bis DATE, -- typically valid 1 year
|
||||||
|
leistungstest_bestanden BOOLEAN,
|
||||||
|
|
||||||
|
-- Free-text notes (Kommandant / Atemschutzwart)
|
||||||
|
bemerkung TEXT,
|
||||||
|
|
||||||
|
-- Audit timestamps
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||||
|
|
||||||
|
-- One record per user
|
||||||
|
CONSTRAINT uq_atemschutz_user UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Indexes for the most common query patterns
|
||||||
|
-- ============================================================
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_atemschutz_user
|
||||||
|
ON atemschutz_traeger(user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_atemschutz_untersuchung
|
||||||
|
ON atemschutz_traeger(untersuchung_gueltig_bis)
|
||||||
|
WHERE untersuchung_gueltig_bis IS NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_atemschutz_leistungstest
|
||||||
|
ON atemschutz_traeger(leistungstest_gueltig_bis)
|
||||||
|
WHERE leistungstest_gueltig_bis IS NOT NULL;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Auto-update trigger for updated_at
|
||||||
|
-- Reuses the function already created by migration 001.
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TRIGGER update_atemschutz_updated_at
|
||||||
|
BEFORE UPDATE ON atemschutz_traeger
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- VIEW: atemschutz_uebersicht
|
||||||
|
-- Dashboard view with user info and computed validity status.
|
||||||
|
-- Joins user + mitglieder_profile for display name, rank, etc.
|
||||||
|
-- Computes expiry flags so the frontend can render traffic-light
|
||||||
|
-- indicators without any date math on the client side.
|
||||||
|
-- ============================================================
|
||||||
|
CREATE OR REPLACE VIEW atemschutz_uebersicht AS
|
||||||
|
SELECT
|
||||||
|
at.*,
|
||||||
|
u.name AS user_name,
|
||||||
|
u.given_name AS user_given_name,
|
||||||
|
u.family_name AS user_family_name,
|
||||||
|
u.email AS user_email,
|
||||||
|
mp.status AS mitglied_status,
|
||||||
|
mp.dienstgrad,
|
||||||
|
|
||||||
|
-- Computed: is the medical exam still valid?
|
||||||
|
CASE
|
||||||
|
WHEN at.untersuchung_gueltig_bis IS NOT NULL
|
||||||
|
THEN at.untersuchung_gueltig_bis >= CURRENT_DATE
|
||||||
|
ELSE FALSE
|
||||||
|
END AS untersuchung_gueltig,
|
||||||
|
|
||||||
|
-- Computed: days until medical exam expires (negative = expired)
|
||||||
|
CASE
|
||||||
|
WHEN at.untersuchung_gueltig_bis IS NOT NULL
|
||||||
|
THEN at.untersuchung_gueltig_bis::date - CURRENT_DATE
|
||||||
|
ELSE NULL
|
||||||
|
END AS untersuchung_tage_rest,
|
||||||
|
|
||||||
|
-- Computed: is the performance test still valid?
|
||||||
|
CASE
|
||||||
|
WHEN at.leistungstest_gueltig_bis IS NOT NULL
|
||||||
|
THEN at.leistungstest_gueltig_bis >= CURRENT_DATE
|
||||||
|
ELSE FALSE
|
||||||
|
END AS leistungstest_gueltig,
|
||||||
|
|
||||||
|
-- Computed: days until performance test expires (negative = expired)
|
||||||
|
CASE
|
||||||
|
WHEN at.leistungstest_gueltig_bis IS NOT NULL
|
||||||
|
THEN at.leistungstest_gueltig_bis::date - CURRENT_DATE
|
||||||
|
ELSE NULL
|
||||||
|
END AS leistungstest_tage_rest,
|
||||||
|
|
||||||
|
-- Computed: fully qualified for BA use (einsatzbereit)?
|
||||||
|
-- Requires: course done + valid medical (tauglich) + valid performance test (bestanden)
|
||||||
|
CASE
|
||||||
|
WHEN at.atemschutz_lehrgang = TRUE
|
||||||
|
AND at.untersuchung_gueltig_bis IS NOT NULL
|
||||||
|
AND at.untersuchung_gueltig_bis >= CURRENT_DATE
|
||||||
|
AND at.untersuchung_ergebnis = 'tauglich'
|
||||||
|
AND at.leistungstest_gueltig_bis IS NOT NULL
|
||||||
|
AND at.leistungstest_gueltig_bis >= CURRENT_DATE
|
||||||
|
AND at.leistungstest_bestanden = TRUE
|
||||||
|
THEN TRUE
|
||||||
|
ELSE FALSE
|
||||||
|
END AS einsatzbereit
|
||||||
|
|
||||||
|
FROM atemschutz_traeger at
|
||||||
|
JOIN users u ON u.id = at.user_id
|
||||||
|
LEFT JOIN mitglieder_profile mp ON mp.user_id = at.user_id;
|
||||||
120
backend/src/database/migrations/015_create_veranstaltungen.sql
Normal file
120
backend/src/database/migrations/015_create_veranstaltungen.sql
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Migration 015: Veranstaltungen (Events / General Calendar)
|
||||||
|
-- General event calendar for Feuerwehr Dashboard, separate from the training
|
||||||
|
-- calendar (uebungen). Supports categories, RSVPs, and iCal subscriptions.
|
||||||
|
-- Depends on: 001_create_users_table.sql (uuid-ossp, pgcrypto extensions,
|
||||||
|
-- users table, update_updated_at_column trigger function)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. Event categories table
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS veranstaltung_kategorien (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
beschreibung TEXT,
|
||||||
|
farbe VARCHAR(7) NOT NULL DEFAULT '#1976d2', -- hex colour for UI chips
|
||||||
|
icon VARCHAR(100), -- MUI icon name, e.g. 'Event', 'FireTruck'
|
||||||
|
erstellt_von UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TRIGGER update_veranstaltung_kategorien_aktualisiert_am
|
||||||
|
BEFORE UPDATE ON veranstaltung_kategorien
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. Main events table
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS veranstaltungen (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
titel VARCHAR(500) NOT NULL,
|
||||||
|
beschreibung TEXT,
|
||||||
|
ort VARCHAR(500),
|
||||||
|
ort_url VARCHAR(1000), -- optional maps/navigation link
|
||||||
|
kategorie_id UUID REFERENCES veranstaltung_kategorien(id) ON DELETE SET NULL,
|
||||||
|
datum_von TIMESTAMPTZ NOT NULL,
|
||||||
|
datum_bis TIMESTAMPTZ NOT NULL,
|
||||||
|
ganztaegig BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
-- zielgruppen: array of Authentik group names, e.g. '{dashboard_mitglied,dashboard_jugend}'
|
||||||
|
zielgruppen TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
alle_gruppen BOOLEAN NOT NULL DEFAULT FALSE, -- TRUE = visible to all members
|
||||||
|
max_teilnehmer INTEGER CHECK (max_teilnehmer > 0),
|
||||||
|
anmeldung_erforderlich BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
anmeldung_bis TIMESTAMPTZ,
|
||||||
|
erstellt_von UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
abgesagt BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
abgesagt_grund TEXT,
|
||||||
|
abgesagt_am TIMESTAMPTZ,
|
||||||
|
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT veranstaltung_datum_reihenfolge CHECK (datum_bis >= datum_von)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_datum_von
|
||||||
|
ON veranstaltungen(datum_von);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_datum_bis
|
||||||
|
ON veranstaltungen(datum_bis);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_kategorie_id
|
||||||
|
ON veranstaltungen(kategorie_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_abgesagt
|
||||||
|
ON veranstaltungen(abgesagt) WHERE abgesagt = FALSE;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_alle_gruppen
|
||||||
|
ON veranstaltungen(alle_gruppen);
|
||||||
|
-- Compound index for the most common calendar-range query
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_datum_von_bis
|
||||||
|
ON veranstaltungen(datum_von, datum_bis);
|
||||||
|
|
||||||
|
CREATE TRIGGER update_veranstaltungen_aktualisiert_am
|
||||||
|
BEFORE UPDATE ON veranstaltungen
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 3. RSVP / attendance table
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS veranstaltung_teilnahmen (
|
||||||
|
veranstaltung_id UUID NOT NULL REFERENCES veranstaltungen(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
-- status values: zugesagt, abgesagt, erschienen, unbekannt
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'unbekannt',
|
||||||
|
notiz VARCHAR(500),
|
||||||
|
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
PRIMARY KEY (veranstaltung_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltung_teilnahmen_veranstaltung_id
|
||||||
|
ON veranstaltung_teilnahmen(veranstaltung_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltung_teilnahmen_user_id
|
||||||
|
ON veranstaltung_teilnahmen(user_id);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 4. Per-user iCal subscription tokens
|
||||||
|
-- One token per user — covers the full events calendar feed for that user.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS veranstaltung_ical_tokens (
|
||||||
|
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(128) UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
|
||||||
|
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
zuletzt_verwendet_am TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltung_ical_tokens_token
|
||||||
|
ON veranstaltung_ical_tokens(token);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 5. Seed default event categories
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
INSERT INTO veranstaltung_kategorien (name, farbe, icon) VALUES
|
||||||
|
('Allgemein', '#1976d2', 'Event'),
|
||||||
|
('Ausbildung', '#2e7d32', 'School'),
|
||||||
|
('Gesellschaft', '#e65100', 'People'),
|
||||||
|
('Feuerwehrjugend', '#f57c00', 'ChildCare'),
|
||||||
|
('Kommando', '#6a1b9a', 'Shield')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Migration 016: Fahrzeugbuchungen (Vehicle Booking System)
|
||||||
|
-- Allows members to book fire department vehicles for internal use, external
|
||||||
|
-- events, maintenance slots and reservations. Includes per-user iCal feeds.
|
||||||
|
-- Depends on: 001_create_users_table.sql (uuid-ossp, pgcrypto extensions,
|
||||||
|
-- users table, update_updated_at_column trigger function)
|
||||||
|
-- 005_create_fahrzeuge.sql (fahrzeuge table)
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 1. ENUM: booking type
|
||||||
|
-- Uses DO-block for idempotent creation (PostgreSQL has no CREATE TYPE IF NOT EXISTS)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
CREATE TYPE fahrzeug_buchung_art AS ENUM (
|
||||||
|
'intern',
|
||||||
|
'extern',
|
||||||
|
'wartung',
|
||||||
|
'reservierung',
|
||||||
|
'sonstiges'
|
||||||
|
);
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 2. Vehicle bookings table
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS fahrzeug_buchungen (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
fahrzeug_id UUID NOT NULL REFERENCES fahrzeuge(id) ON DELETE CASCADE,
|
||||||
|
titel VARCHAR(500) NOT NULL,
|
||||||
|
beschreibung TEXT,
|
||||||
|
beginn TIMESTAMPTZ NOT NULL,
|
||||||
|
ende TIMESTAMPTZ NOT NULL,
|
||||||
|
buchungs_art fahrzeug_buchung_art NOT NULL DEFAULT 'intern',
|
||||||
|
gebucht_von UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
-- kontakt fields are relevant for external bookings
|
||||||
|
kontakt_person VARCHAR(255),
|
||||||
|
kontakt_telefon VARCHAR(50),
|
||||||
|
abgesagt BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
abgesagt_grund TEXT,
|
||||||
|
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
aktualisiert_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT buchung_ende_nach_beginn CHECK (ende > beginn)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_fahrzeug_id
|
||||||
|
ON fahrzeug_buchungen(fahrzeug_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_beginn
|
||||||
|
ON fahrzeug_buchungen(beginn);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_ende
|
||||||
|
ON fahrzeug_buchungen(ende);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_gebucht_von
|
||||||
|
ON fahrzeug_buchungen(gebucht_von);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_abgesagt
|
||||||
|
ON fahrzeug_buchungen(abgesagt) WHERE abgesagt = FALSE;
|
||||||
|
-- Compound index for availability / overlap checks (fahrzeug + time range)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_buchungen_fahrzeug_beginn_ende
|
||||||
|
ON fahrzeug_buchungen(fahrzeug_id, beginn, ende);
|
||||||
|
|
||||||
|
CREATE TRIGGER update_fahrzeug_buchungen_aktualisiert_am
|
||||||
|
BEFORE UPDATE ON fahrzeug_buchungen
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- 3. Per-user iCal subscription tokens for the vehicle booking calendar
|
||||||
|
-- One token per user — the feed returns all bookings the user has access to.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS fahrzeug_ical_tokens (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token VARCHAR(128) UNIQUE NOT NULL DEFAULT encode(gen_random_bytes(32), 'hex'),
|
||||||
|
erstellt_am TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
zuletzt_verwendet_am TIMESTAMPTZ,
|
||||||
|
|
||||||
|
-- one token per user for the full vehicle booking feed
|
||||||
|
UNIQUE (user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_fahrzeug_ical_tokens_token
|
||||||
|
ON fahrzeug_ical_tokens(token);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Migration 017: Add zielgruppen (target groups) to event categories
|
||||||
|
-- Links categories to user groups for visibility filtering
|
||||||
|
ALTER TABLE veranstaltung_kategorien
|
||||||
|
ADD COLUMN IF NOT EXISTS zielgruppen TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
-- Comment for documentation
|
||||||
|
COMMENT ON COLUMN veranstaltung_kategorien.zielgruppen IS 'Array of Authentik group names this category is linked to';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
-- Migration 018: Fix BEFORE UPDATE triggers on event tables
|
||||||
|
-- Problem: update_updated_at_column() sets NEW.updated_at but both event tables
|
||||||
|
-- use aktualisiert_am instead. This causes every UPDATE to fail inside the trigger.
|
||||||
|
|
||||||
|
-- Create a new trigger function that references the correct column name
|
||||||
|
CREATE OR REPLACE FUNCTION update_aktualisiert_am_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.aktualisiert_am = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Fix veranstaltungen table trigger
|
||||||
|
DROP TRIGGER IF EXISTS update_veranstaltungen_aktualisiert_am ON veranstaltungen;
|
||||||
|
CREATE TRIGGER update_veranstaltungen_aktualisiert_am
|
||||||
|
BEFORE UPDATE ON veranstaltungen
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_aktualisiert_am_column();
|
||||||
|
|
||||||
|
-- Fix veranstaltung_kategorien table trigger (if it was added)
|
||||||
|
DROP TRIGGER IF EXISTS update_veranstaltung_kategorien_aktualisiert_am ON veranstaltung_kategorien;
|
||||||
|
CREATE TRIGGER update_veranstaltung_kategorien_aktualisiert_am
|
||||||
|
BEFORE UPDATE ON veranstaltung_kategorien
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_aktualisiert_am_column();
|
||||||
12
backend/src/database/migrations/019_add_wiederholung.sql
Normal file
12
backend/src/database/migrations/019_add_wiederholung.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- Migration 019: Add recurring event support
|
||||||
|
-- Adds wiederholung (recurrence) config and parent link for generated instances
|
||||||
|
|
||||||
|
ALTER TABLE veranstaltungen
|
||||||
|
ADD COLUMN IF NOT EXISTS wiederholung JSONB,
|
||||||
|
ADD COLUMN IF NOT EXISTS wiederholung_parent_id UUID REFERENCES veranstaltungen(id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_veranstaltungen_parent_id
|
||||||
|
ON veranstaltungen(wiederholung_parent_id);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN veranstaltungen.wiederholung IS 'JSON config for recurring events: {typ, intervall?, bis, wochentag?}';
|
||||||
|
COMMENT ON COLUMN veranstaltungen.wiederholung_parent_id IS 'Links generated recurrence instances back to the parent event';
|
||||||
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 $$;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user