Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a95c68c5e | ||
|
|
7c1a6d9537 | ||
|
|
f018c21308 | ||
|
|
a9738c76e7 | ||
|
|
71558a19f9 | ||
|
|
f83870e025 | ||
|
|
480ce4fd70 | ||
|
|
585a8bab60 | ||
|
|
b378a1efc5 | ||
|
|
24126c5f71 | ||
|
|
4fd5b1db56 | ||
|
|
bff55aa262 | ||
|
|
47d3336e1f | ||
|
|
bbb8aa97fc | ||
|
|
6f81dab3e7 | ||
|
|
8e6a2e1119 | ||
|
|
5f12a14f37 | ||
|
|
c6b662a945 | ||
|
|
c5110a9670 | ||
|
|
726b2f9a31 | ||
|
|
e001beae56 | ||
|
|
772f7e90c3 | ||
|
|
904ad673fb | ||
|
|
e5324372af | ||
|
|
8ee6d11c44 | ||
|
|
a974be66ca | ||
|
|
bb4c11019e | ||
|
|
14e3083729 | ||
|
|
f9dacda4e9 | ||
|
|
bcbc449590 | ||
|
|
4ff3d4d9b6 | ||
|
|
cd6e03ce1d | ||
|
|
171be7e321 | ||
|
|
7e7b8859d7 | ||
|
|
2cccebd947 | ||
|
|
d106ca0a1e | ||
|
|
90cf811ba0 | ||
|
|
a929fbe2be | ||
|
|
02764b0088 | ||
|
|
1a79469da9 | ||
|
|
824ae8b303 | ||
|
|
1d57a58f16 | ||
|
|
166592697c | ||
|
|
20db5e0cbd | ||
|
|
4047e2a5c3 | ||
|
|
79826ca69e | ||
|
|
7bdbeee6c4 | ||
|
|
2c04cc0aa6 | ||
|
|
88f25d82ad | ||
|
|
e12f80b65a | ||
|
|
e6c0ba5af2 | ||
|
|
08d543cd12 | ||
|
|
77c6b1af9e | ||
|
|
19e7868254 | ||
|
|
e083638e97 | ||
|
|
e6d6bff5b3 | ||
|
|
acb40edb09 | ||
|
|
02616d0081 | ||
|
|
dbfb52ce9d | ||
|
|
10b69f9c8c | ||
|
|
a7644de272 | ||
|
|
ad3e8c9bf0 | ||
|
|
6334111b40 | ||
|
|
e005388abc | ||
|
|
5eb3e23d8c | ||
|
|
0960e1a59e | ||
|
|
24de50940e | ||
|
|
d31f33516b | ||
|
|
545190f4e3 | ||
|
|
1a78d84452 | ||
|
|
bd4f68e81e | ||
|
|
6ebff18d52 | ||
|
|
4ed45505df | ||
|
|
7b95af41dc | ||
|
|
4d45ff8a6b | ||
|
|
b1a5efc7dd | ||
|
|
ce9a0a05ab | ||
|
|
fc387c41e4 | ||
|
|
82a1bd79ce | ||
|
|
d049f53409 | ||
|
|
b0d3ad012a | ||
|
|
494c8e6604 | ||
|
|
5c5e01208a | ||
|
|
1a97e172fb | ||
|
|
5ed3bb6280 | ||
|
|
d64d036a7e | ||
|
|
8970f692a1 | ||
|
|
0a2df40fc5 | ||
|
|
dbe4a82d8d | ||
|
|
20cb46537a | ||
|
|
8f401fba62 | ||
|
|
0ae0b6c0e9 | ||
|
|
66c042dcb3 | ||
|
|
234fc004cb | ||
|
|
cefad76767 | ||
|
|
ebec1fb6af | ||
|
|
6b377badb4 | ||
|
|
2258341cf3 | ||
|
|
434793c7f8 | ||
|
|
bf68c34eb8 | ||
|
|
342169c48a | ||
|
|
702ada398b | ||
|
|
a8fd665ca0 | ||
|
|
d22c023ec7 | ||
|
|
6ffb05f4ca | ||
|
|
6155584b15 | ||
|
|
ea5e5ec519 | ||
|
|
0a09b2d34e | ||
|
|
f8b982e736 | ||
|
|
8ddf5ec278 | ||
|
|
f8d64cc1ae | ||
|
|
eef71d1d76 | ||
|
|
072520b01b | ||
|
|
264e0d6440 | ||
|
|
e90c183a83 | ||
|
|
ec0ec5440f | ||
|
|
4c3f876663 | ||
|
|
8f62cdba45 | ||
|
|
9642b9690a | ||
|
|
fa04ebefb7 | ||
|
|
fe3c8fa7c5 | ||
|
|
4ad8fe8349 | ||
|
|
5e81baa076 | ||
|
|
3a77b15e32 | ||
|
|
009079553b | ||
|
|
6369bacbc7 | ||
|
|
0d285ae37e | ||
|
|
33705e71a1 | ||
|
|
3461f5391b | ||
|
|
818a753921 | ||
|
|
096873bf99 | ||
|
|
737165bb8f | ||
|
|
371847fb98 | ||
|
|
8056c37b7e | ||
|
|
27e3836c2e | ||
|
|
be95ee2046 | ||
|
|
b6aaa6ec1f | ||
|
|
f9ce1fb36a | ||
|
|
4e38fa8b4f | ||
|
|
088b237669 | ||
|
|
61099abb8b | ||
|
|
494134a8ea | ||
|
|
82287f3778 | ||
|
|
e5bc238c58 | ||
|
|
b6bb4245f8 | ||
|
|
2810202c97 | ||
|
|
79d70af395 | ||
|
|
a5811300dc | ||
|
|
f904378396 | ||
|
|
5f5f206880 | ||
|
|
df50937f36 | ||
|
|
ca6a786c8e | ||
|
|
e688ef82c9 | ||
|
|
1ebdcbb12b | ||
|
|
b9645ddf3e | ||
|
|
3e6be3817d | ||
|
|
fb9ff17af5 | ||
|
|
65cf2623d7 | ||
|
|
e7cbd5ef5c | ||
|
|
7698400a26 | ||
|
|
2dda10a88d | ||
|
|
14fe13220e | ||
|
|
9f762311e7 | ||
|
|
78182c0fae | ||
|
|
76c472693a | ||
|
|
5a0a4a2d3a | ||
|
|
8305614541 | ||
|
|
5ac4fa63d4 | ||
|
|
840f88cdbe | ||
|
|
f9a49240de | ||
|
|
b8cd9484f4 | ||
|
|
95693b4b19 | ||
|
|
5134b0641e | ||
|
|
a492235a57 | ||
|
|
0d5636c2f2 | ||
|
|
5c11239567 | ||
|
|
0d06854f17 | ||
|
|
f8341c8c39 | ||
|
|
98bab6de35 | ||
|
|
0f81803257 | ||
|
|
8b54fc3e4b | ||
|
|
30a2fd4ba1 | ||
|
|
445e078280 | ||
|
|
0e800925a3 | ||
|
|
e3cf6336c1 | ||
|
|
56606bab71 | ||
|
|
65692f87c4 | ||
|
|
951a108c70 | ||
|
|
56c9dc12ad | ||
|
|
5871e9bdd3 | ||
|
|
6e9340d902 | ||
|
|
fa739ec156 | ||
|
|
2a1e8bc530 | ||
|
|
66feb71887 | ||
|
|
3f62821329 | ||
|
|
1066989c69 | ||
|
|
75f6e7621c | ||
|
|
bae289fd25 | ||
|
|
442071624a | ||
|
|
0c4c09d3cc | ||
|
|
a4160abafa | ||
|
|
c0ebdb6e95 | ||
|
|
a8fd91dc04 | ||
|
|
e34c42b8b9 | ||
|
|
b1a48e9461 | ||
|
|
2dffe704db | ||
|
|
ed0f00beaa | ||
|
|
e0f393adae | ||
|
|
41508bff9a | ||
|
|
125c9e3b22 | ||
|
|
b7eac0c3a2 | ||
|
|
bd1b2a952c | ||
|
|
1c6c0f6765 | ||
|
|
b021ab6df3 | ||
|
|
8c2cb344fa | ||
|
|
95eede76f5 | ||
|
|
fcfc1802e0 | ||
|
|
aa76dc60cd | ||
|
|
6aa78b4490 | ||
|
|
4b27705cf0 | ||
|
|
dcd954b5bf | ||
|
|
ccc9c20342 | ||
|
|
7b47beef5b | ||
|
|
a36e734153 | ||
|
|
a55c2ff0c6 | ||
|
|
9c0bf716f6 | ||
|
|
9e348ba9c4 | ||
|
|
690d7cd626 | ||
|
|
da93eb4c09 | ||
|
|
65b4be9181 | ||
|
|
527d22d148 | ||
|
|
fc4fe874f7 | ||
|
|
ba426e9cbd | ||
|
|
36fddc2289 | ||
|
|
aabfaaa965 | ||
|
|
a4e83126b6 | ||
|
|
e0371381ec | ||
|
|
4d96260fcf | ||
|
|
c8b706487f | ||
|
|
4131696cff | ||
|
|
1490401017 | ||
|
|
c98f358a7d | ||
|
|
94d872dea3 | ||
|
|
94a0936483 | ||
|
|
a502c48b77 | ||
|
|
5946ea5372 | ||
|
|
d1c38200c4 | ||
|
|
2bad52cf43 | ||
|
|
e175802f93 | ||
|
|
5347501ced | ||
|
|
659c41714b | ||
|
|
19b47793f6 | ||
|
|
d7f8ea2388 | ||
|
|
e2d080fae9 | ||
|
|
c5910eddfe | ||
|
|
353b7797f8 | ||
|
|
4c573c46a1 | ||
|
|
406f3a211e | ||
|
|
84448ad45e | ||
|
|
95faa40e98 | ||
|
|
fba54f356c | ||
|
|
11ee64da8a | ||
|
|
871bfbd8a2 | ||
|
|
2e783e018b | ||
|
|
dee5c631b9 | ||
|
|
c0b4092498 | ||
|
|
752b42245a | ||
|
|
ded90af175 | ||
|
|
a7577a234d | ||
|
|
2277cf5493 | ||
|
|
32f5e780d6 | ||
|
|
10578475f0 | ||
|
|
e90c1a4563 | ||
|
|
0cd88ac2f9 | ||
|
|
152c5d1401 | ||
|
|
f6ad05f5e5 | ||
|
|
cc035349e5 | ||
|
|
09da839779 | ||
|
|
7c664f0a6b | ||
|
|
cdec2d346b | ||
|
|
fb61ebc2c3 | ||
|
|
ce1e6ef028 | ||
|
|
fe1e7713fa | ||
|
|
1092cfce0d | ||
|
|
29fdc7afce | ||
|
|
9770ed381f | ||
|
|
14ebc63be5 | ||
|
|
5abc50c6d7 | ||
|
|
9e2e4c95f5 | ||
|
|
29caa7f2e2 | ||
|
|
aa8c6c0d19 | ||
|
|
6187cec8bf | ||
|
|
63beec4877 | ||
|
|
79e293e9e0 | ||
|
|
497acf5557 | ||
|
|
14ae2fd936 | ||
|
|
af87466328 | ||
|
|
14fb5ec0fe | ||
|
|
122876dded | ||
|
|
8a75966077 | ||
|
|
d3bf15fbce | ||
|
|
490f60dd37 | ||
|
|
da84410644 | ||
|
|
5435d2c2ef | ||
|
|
531ec38860 | ||
|
|
02294d3161 | ||
|
|
db81add5ac | ||
|
|
3391a3a69f | ||
|
|
4c0f87b6c6 | ||
|
|
14794b146c | ||
|
|
df69e259eb | ||
|
|
f107976fda | ||
|
|
0abf939ef5 | ||
|
|
fa5e25ae57 | ||
|
|
3831cf67e9 | ||
|
|
07bd3e8b24 | ||
|
|
b2204b2ea5 | ||
|
|
86bc8228d3 | ||
|
|
e2e8c1482f | ||
|
|
4554cbbf0d | ||
|
|
bff7b65b6e | ||
|
|
e96f783c24 | ||
|
|
fe5907b255 | ||
|
|
3d0e0717a6 | ||
|
|
348ab56c90 | ||
|
|
6622761f82 | ||
|
|
20496b16d2 | ||
|
|
54ff35884d | ||
|
|
72eddf433d | ||
|
|
1cb8f5c3b4 | ||
|
|
c18238f29d | ||
|
|
b6ee3160db | ||
|
|
d61e9b0166 | ||
|
|
cfcb4c5e10 | ||
|
|
a8c7d38c74 | ||
|
|
3387bac00a | ||
|
|
5ac7c2e1b1 | ||
|
|
4520d435e0 | ||
|
|
8bf6bb92ba | ||
|
|
79c6424c77 | ||
|
|
3a408cd8dc | ||
|
|
fad8536292 | ||
|
|
d1aae0f2ce | ||
|
|
58a8fab9f5 | ||
|
|
65d42108db | ||
|
|
b207fa4944 | ||
|
|
27b3e4dbaf | ||
|
|
c55cc54ba2 | ||
|
|
747ecbc618 | ||
|
|
8b64172140 | ||
|
|
44d3b7308d | ||
|
|
bb2963b55d | ||
|
|
950331de90 | ||
|
|
ba7fc2dd1d | ||
|
|
c3b9948fa2 | ||
|
|
53f8bf0577 | ||
|
|
e1c6f8dfca | ||
|
|
257fbc1e55 | ||
|
|
25beaea671 | ||
|
|
323d49e13e | ||
|
|
4319ceec47 | ||
|
|
c1a170563d | ||
|
|
aeae758b4d | ||
|
|
9b1b7c0d6e | ||
|
|
0945d61744 | ||
|
|
187b7b5c9e | ||
|
|
ba32f72275 | ||
|
|
c57918c86f | ||
|
|
515db6b5e2 | ||
|
|
b322e13aef | ||
|
|
4e1e6d85f8 | ||
|
|
2ee9be11c0 | ||
|
|
fa7cb047a9 | ||
|
|
91ae4659b5 | ||
|
|
aed0db39ba | ||
|
|
07295c8b77 | ||
|
|
2837074176 | ||
|
|
e18614672f | ||
|
|
c7f79db919 | ||
|
|
a165f1e20f | ||
|
|
a86036c657 | ||
|
|
f3ba8c20b1 | ||
|
|
6343e06db5 | ||
|
|
38646692bf | ||
|
|
4045ab2b95 | ||
|
|
f04a6c9806 | ||
|
|
98f0f6a1d0 | ||
|
|
22f9d57240 | ||
|
|
f6faac2193 | ||
|
|
0e4da219f3 | ||
|
|
4a7a2e665e | ||
|
|
a322212ef5 | ||
|
|
1d5cb064a4 | ||
|
|
9553f62399 | ||
|
|
b268bf7fae | ||
|
|
fe7b797f03 | ||
|
|
960dc81511 | ||
|
|
cf023d1427 | ||
|
|
02e79b6f73 | ||
|
|
d902255b51 | ||
|
|
aa3f1325fc | ||
|
|
ce15ed76d7 | ||
|
|
a555fee640 | ||
|
|
b565683729 | ||
|
|
cf2d0aab30 | ||
|
|
dbd062d37f | ||
|
|
17d28b31b2 | ||
|
|
a043389b89 | ||
|
|
7e1e29b73e | ||
|
|
08eab8c0cf | ||
|
|
80da72d86e | ||
|
|
b6be4b9bf7 | ||
|
|
8dfcbbe9f3 | ||
|
|
d75073645a | ||
|
|
6714833377 | ||
|
|
16798f664f | ||
|
|
231684a369 | ||
|
|
b04a9666da | ||
|
|
38e6ffa45a | ||
|
|
e22caa9a75 | ||
|
|
98870587f9 | ||
|
|
0d13814c74 | ||
|
|
35ecfbd4dc | ||
|
|
bc2d087943 | ||
|
|
2ce5bab309 | ||
|
|
0294fa50ce | ||
|
|
b344da0825 | ||
|
|
b2b16a93f0 | ||
|
|
973e696b4d | ||
|
|
1b5374fd28 | ||
|
|
202a8ab1ba | ||
|
|
77799a9fbd | ||
|
|
96507acfc2 | ||
|
|
2b5bc2d21c | ||
|
|
cd141db39e | ||
|
|
7bfb66d31d | ||
|
|
7bf581672a | ||
|
|
8bc4560e7a | ||
|
|
9e89c4af07 | ||
|
|
d6e4eb327a | ||
|
|
f01877150c | ||
|
|
47eb477f40 | ||
|
|
f795bd66ea | ||
|
|
506773c1be | ||
|
|
aa823f9130 | ||
|
|
7162449ca1 | ||
|
|
a8e146b297 | ||
|
|
00cbd3f38e | ||
|
|
2b158200a7 | ||
|
|
a63da1cf57 | ||
|
|
43d61851fa | ||
|
|
4c32dd691a | ||
|
|
92d5d9ba42 | ||
|
|
c27991619c | ||
|
|
2a99060740 | ||
|
|
078ab743bc | ||
|
|
59c236ebcd | ||
|
|
6d97fb4f3e | ||
|
|
c494839ffc | ||
|
|
5d1cd45f55 | ||
|
|
a8d6374eb8 | ||
|
|
d7a48a0a7c | ||
|
|
9c78a9fe95 | ||
|
|
35f7ddde34 | ||
|
|
6c89ddddb9 | ||
|
|
def896d930 | ||
|
|
922cbddabe | ||
|
|
5dc3502ab0 | ||
|
|
c78c3a1e71 | ||
|
|
905dbe411a | ||
|
|
c1c598318e | ||
|
|
7a367e7098 | ||
|
|
e3bc3763d4 | ||
|
|
d0b25ccc78 | ||
|
|
15579292a5 | ||
|
|
a6d5af2c79 | ||
|
|
cd47f2eb6c | ||
|
|
37311cfbc6 | ||
|
|
96355ce691 | ||
|
|
5a9d525f7f | ||
|
|
79c73e20c5 | ||
|
|
86c76fc526 | ||
|
|
40cdfdc6ad | ||
|
|
19fbac7d27 | ||
|
|
ed8468a1bf | ||
|
|
33517dd296 | ||
|
|
4a2c0a1227 | ||
|
|
5ee22eb72c | ||
|
|
9681ffec34 | ||
|
|
d846b979e4 | ||
|
|
613b5a10cb | ||
|
|
a6c66ab5a1 | ||
|
|
99fba6b10d | ||
|
|
bd0b87ac68 | ||
|
|
162bd68a86 | ||
|
|
6c47f8d2e2 | ||
|
|
cd689d1b88 | ||
|
|
832e97a413 | ||
|
|
64e4d524c1 | ||
|
|
cde1559f63 | ||
|
|
4d1d870c56 | ||
|
|
f2ce2f16f9 | ||
|
|
becf3f292b | ||
|
|
64f552f81a | ||
|
|
d190825ef7 | ||
|
|
f7b161fee8 | ||
|
|
d1df52e1d2 | ||
|
|
1914c0656b | ||
|
|
271ca38245 | ||
|
|
3644fb4fef | ||
|
|
fab35bcd85 | ||
|
|
e1f4a70519 | ||
|
|
1877562632 | ||
|
|
3d09e447b0 | ||
|
|
4511da92cb | ||
|
|
95a1bdf458 | ||
|
|
d502e76aaa | ||
|
|
a4543205ed | ||
|
|
dc34a7b505 | ||
|
|
b1ce163bb7 | ||
|
|
fcaae83001 | ||
|
|
13d82ad39f | ||
|
|
32bdda2301 | ||
|
|
9a90206df5 | ||
|
|
2ecec2bef6 | ||
|
|
6b5fd653e4 | ||
|
|
3f46fcda4f | ||
|
|
d67f61fe9c | ||
|
|
955f8c3e46 | ||
|
|
918f2bbb24 | ||
|
|
fc070f4abc | ||
|
|
fc2c7ef5ea | ||
|
|
54d0c48bd4 | ||
|
|
728d428c78 | ||
|
|
b0b0d00dbe | ||
|
|
1a32c6f44f | ||
|
|
ccf6951d2c | ||
|
|
3c13667b34 | ||
|
|
09e8db6f9b | ||
|
|
3cec3d9d13 | ||
|
|
d358695cdb | ||
|
|
5b09226ae1 | ||
|
|
242bd070da | ||
|
|
2cbee9af5a | ||
|
|
7dac91fa35 | ||
|
|
5ffdb4fc1b | ||
|
|
72925ad504 | ||
|
|
d3ce16a51d | ||
|
|
b6f4370f9c | ||
|
|
2452849c74 | ||
|
|
12699f0918 | ||
|
|
d6df75ad64 | ||
|
|
2bc706cb09 | ||
|
|
a7aeec1b26 | ||
|
|
ecabba060c | ||
|
|
0df18c6747 | ||
|
|
ce27492b24 | ||
|
|
9acec37e18 | ||
|
|
b976f394f5 | ||
|
|
e3c3b7df1c |
@@ -13,7 +13,7 @@ RewriteRule ^ - [F,L]
|
||||
# RewriteRule ^ - [F,L]
|
||||
|
||||
# Clickjacking-Schutz
|
||||
Header always set X-Frame-Options "SAMEORIGIN"
|
||||
Header always set X-Frame-Options "DENY"
|
||||
Header always set X-Content-Type-Options "nosniff"
|
||||
|
||||
# Schützt interne Verzeichnisse vor direktem Web-Zugriff.
|
||||
@@ -44,6 +44,10 @@ RewriteCond %{REQUEST_URI} \.(sqlite3?|db|sql|log|bak|old|orig|swp|md|sh|bat|ps1
|
||||
RewriteCond %{REQUEST_URI} ^/(offen\.txt|install\.sh|install_windows\.bat|composer\.(json|lock)|package(-lock)?\.json)$ [NC]
|
||||
RewriteRule ^ - [F,L]
|
||||
|
||||
# Übrig gebliebene alte Ordner eines Updates (…alt-JJJJMMTT-HHMMSS) nicht ausliefern
|
||||
RewriteCond %{REQUEST_URI} \.alt-[0-9]{8}-[0-9]{6}(/|$) [NC]
|
||||
RewriteRule ^ - [F,L]
|
||||
|
||||
# version.php nur intern zugänglich
|
||||
RewriteCond %{REQUEST_URI} ^/version\.php$ [NC]
|
||||
RewriteRule ^ - [F,L]
|
||||
|
||||
@@ -5,7 +5,12 @@ declare(strict_types=1);
|
||||
*
|
||||
* POST /api/public/v1/tickets.php Ticket anlegen (Recht "Tickets anlegen")
|
||||
* JSON: { "name", "email", "subject", "message", "helpTopicId"?, "departmentId"?, "priorityId"? }
|
||||
* POST /api/public/v1/tickets.php Ticket lösen (Recht "Tickets anlegen")
|
||||
* JSON: { "action": "resolve", "number", "email", "message"? } nur für Tickets, die über die API angelegt wurden, mit passender E-Mail-Adresse
|
||||
* GET /api/public/v1/tickets.php?number=…&email=… Status + öffentlicher Verlauf (Recht "Tickets lesen")
|
||||
* GET /api/public/v1/tickets.php?topics=1 Öffentliche Hilfethemen (Recht "Tickets anlegen")
|
||||
* GET /api/public/v1/tickets.php?form=1&helpTopicId=… Kundenformular samt eigener Felder (Recht "Tickets anlegen")
|
||||
* Beim Anlegen nimmt "fields": { "<Feld-ID>": "Wert", … } die Angaben dieses Formulars entgegen (Pflichtfelder werden geprüft).
|
||||
*
|
||||
* Authentifizierung: Header "X-API-Key: dh_…" oder "Authorization: Bearer dh_…".
|
||||
* Schlüssel verwaltet ein Administrator unter Verwalten → API-Schlüssel.
|
||||
@@ -32,19 +37,50 @@ $key = trim((string) ($_SERVER['HTTP_X_API_KEY'] ?? ''));
|
||||
if ($key === '' && preg_match('/^Bearer\s+(\S+)$/i', (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? ''), $m)) $key = $m[1];
|
||||
$pdo = get_db();
|
||||
$apiKey = $key !== '' ? api_key_verify($pdo, $key, client_ip()) : null;
|
||||
if (!$apiKey) public_api_fail(401, 'UNAUTHENTICATED', 'Ein gültiger API-Schlüssel ist erforderlich (Header X-API-Key).');
|
||||
if (!$apiKey) {
|
||||
if ($key !== '' && api_key_ip_blocked($pdo, $key, client_ip())) {
|
||||
public_api_fail(401, 'IP_NOT_ALLOWED', 'Der Schlüssel ist gültig, aber für die IP-Adresse ' . client_ip() . ' nicht freigegeben. Diese Adresse bei „Erlaubte IPs“ des Schlüssels eintragen (oder das Feld leer lassen).');
|
||||
}
|
||||
public_api_fail(401, 'UNAUTHENTICATED', 'Ein gültiger API-Schlüssel ist erforderlich (Header X-API-Key).');
|
||||
}
|
||||
|
||||
if ($method === 'POST') {
|
||||
if (!$apiKey['can_create_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets anlegen.');
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
// Normale Anfragen höchstens 200 KB; mit Dateianhängen (Base64) mehr: bis zu 10 MB Dateien, Base64 macht rund ein Drittel mehr daraus
|
||||
if (strlen($raw) > 200000 && (strlen($raw) > 200000 + (int) (PUBLIC_ATTACHMENT_TOTAL_BYTES * 1.4) || !str_contains($raw, '"attachments"'))) {
|
||||
public_api_fail(413, 'TOO_LARGE', 'Die Anfrage ist zu groß.');
|
||||
}
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) public_api_fail(400, 'INVALID_JSON', 'Der Anfragekörper muss gültiges JSON sein.');
|
||||
if (($body['action'] ?? '') === 'resolve') {
|
||||
// Ein Fremdsystem meldet Entwarnung (zum Beispiel Minecraft-Server wieder erreichbar): Ticket auf „Gelöst“ setzen.
|
||||
// Nur Tickets, die über die API angelegt wurden, und nur mit der E-Mail-Adresse des Anfragenden.
|
||||
$number = trim((string) ($body['number'] ?? ''));
|
||||
$email = strtolower(trim((string) ($body['email'] ?? '')));
|
||||
if ($number === '' || $email === '') public_api_fail(422, 'MISSING_PARAMS', 'Die Felder number und email sind erforderlich.');
|
||||
$stmt = $pdo->prepare("SELECT * FROM tickets WHERE ticket_number = ? AND LOWER(requester_email) = ? AND source = 'api'");
|
||||
$stmt->execute([$number, $email]);
|
||||
$ticket = $stmt->fetch();
|
||||
if (!$ticket) public_api_fail(404, 'NOT_FOUND', 'Ticket nicht gefunden.');
|
||||
$note = trim(strip_tags((string) ($body['message'] ?? '')));
|
||||
if ($note === '') $note = 'Das Fremdsystem meldet Entwarnung.';
|
||||
$note = mb_substr($note, 0, 2000);
|
||||
if (!in_array($ticket['status'], ['resolved', 'closed'], true)) {
|
||||
$now = db_now();
|
||||
$pdo->prepare("INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?, 'system', ?, 1, ?)")->execute([$ticket['id'], $note, $now]);
|
||||
$pdo->prepare("UPDATE tickets SET status = 'resolved', resolved_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticket['id']]);
|
||||
log_activity($pdo, (int) $ticket['id'], null, 'Über die API gelöst: ' . $note);
|
||||
try { webhook_dispatch($pdo, 'ticket.status_changed', (int) $ticket['id'], ['previousStatus' => (string) $ticket['status']]); } catch (Throwable $e) { /* nachrangig */ }
|
||||
}
|
||||
echo json_encode(['number' => $number, 'status' => 'resolved'], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
// Schutz vor Überflutung: höchstens 60 über die API angelegte Tickets pro Minute
|
||||
$recent = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE source = 'api' AND created_at >= ?");
|
||||
$recent->execute([date('Y-m-d H:i:s', time() - 60)]);
|
||||
if ((int) $recent->fetchColumn() >= 60) public_api_fail(429, 'RATE_LIMITED', 'Zu viele Anfragen. Bitte in einer Minute erneut versuchen.');
|
||||
|
||||
$raw = file_get_contents('php://input') ?: '';
|
||||
if (strlen($raw) > 200000) public_api_fail(413, 'TOO_LARGE', 'Die Anfrage ist zu groß.');
|
||||
$body = json_decode($raw, true);
|
||||
if (!is_array($body)) public_api_fail(400, 'INVALID_JSON', 'Der Anfragekörper muss gültiges JSON sein.');
|
||||
try {
|
||||
$created = public_ticket_create($pdo, $body, client_ip());
|
||||
} catch (AdminModelException $e) {
|
||||
@@ -55,6 +91,28 @@ if ($method === 'POST') {
|
||||
exit;
|
||||
}
|
||||
|
||||
// GET ?topics=1 (Hilfethemen) und GET ?form=1&helpTopicId=…|departmentId=… (Kundenformular mit eigenen Feldern) für Website-Formulare.
|
||||
// Das ist dasselbe, was Kunden im Kundenportal sehen; es braucht das Recht "Tickets anlegen".
|
||||
if (isset($_GET['topics']) || isset($_GET['form'])) {
|
||||
if (!$apiKey['can_create_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets anlegen.');
|
||||
if (isset($_GET['topics'])) {
|
||||
echo json_encode(['topics' => array_map(fn(array $t) => [
|
||||
'id' => (int) $t['id'], 'name' => (string) $t['name'], 'path' => (string) $t['path'], 'depth' => (int) $t['depth'],
|
||||
'parentId' => !empty($t['parent_id']) ? (int) $t['parent_id'] : null, 'departmentId' => (int) $t['department_id'],
|
||||
], help_topic_options($pdo, true))], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
try {
|
||||
$topicId = am_int_or_null($_GET, 'helpTopicId');
|
||||
$deptId = am_int_or_null($_GET, 'departmentId');
|
||||
$def = public_form_definition($pdo, $topicId, $topicId === null ? ($deptId ?: (int) get_setting('default_department_id', '1')) : $deptId);
|
||||
} catch (AdminModelException $e) {
|
||||
public_api_fail($e->httpStatus(), $e->errorCode(), $e->getMessage());
|
||||
}
|
||||
echo json_encode(['form' => public_form_payload($def)], JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
// GET: Status eines Tickets (nur mit passender E-Mail-Adresse des Anfragenden)
|
||||
if (!$apiKey['can_read_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets lesen.');
|
||||
$number = trim((string) ($_GET['number'] ?? ''));
|
||||
|
||||
@@ -6,11 +6,18 @@ api_require_method('GET');
|
||||
$staff = api_require_staff();
|
||||
$pdo = get_db();
|
||||
|
||||
/** Echter Stand für den Zähler: Ticket-IDs, die jetzt Aufmerksamkeit brauchen (nicht zugewiesen / mir gehörend), plus Anzahlen. */
|
||||
function api_attention(PDO $pdo, array $staff): array
|
||||
{
|
||||
$a = staff_attention_tickets($pdo, $staff);
|
||||
return ['unassigned' => $a['unassigned'], 'mine' => $a['mine'], 'unassignedCount' => count($a['unassigned']), 'mineCount' => count($a['mine'])];
|
||||
}
|
||||
|
||||
$since = trim($_GET['since'] ?? '');
|
||||
if ($since === '') {
|
||||
// Erster Aufruf nach Login: nur die Serverzeit als Referenzpunkt liefern, noch nichts zählen
|
||||
// (verhindert Uhrzeit-Abweichungen zwischen Client und Server bei der Baseline).
|
||||
api_json(['newReplies' => 0, 'newUnassigned' => 0, 'serverTime' => db_now(), 'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : []]);
|
||||
api_json(['newReplies' => 0, 'newUnassigned' => 0, 'attention' => api_attention($pdo, $staff), 'serverTime' => db_now(), 'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : []]);
|
||||
}
|
||||
if (strtotime($since) === false) {
|
||||
api_error(422, 'INVALID_SINCE', 'Der since-Zeitstempel ist ungültig.');
|
||||
@@ -36,6 +43,7 @@ $newUnassigned = (int) $stmt->fetchColumn();
|
||||
api_json([
|
||||
'newReplies' => $newReplies,
|
||||
'newUnassigned' => $newUnassigned,
|
||||
'attention' => api_attention($pdo, $staff),
|
||||
'serverTime' => db_now(),
|
||||
'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : [],
|
||||
]);
|
||||
|
||||
@@ -3,14 +3,14 @@ declare(strict_types=1);
|
||||
require_once __DIR__ . '/common.php';
|
||||
|
||||
/**
|
||||
* Update-Prüfung der Apps: GET ?platform=android|windows liefert die aktuell veröffentlichte Version,
|
||||
* Update-Prüfung der Apps und der Chrome-Erweiterung: GET ?platform=android|windows|chrome liefert die aktuell veröffentlichte Version,
|
||||
* mit &download=1 die Installationsdatei. Die Quelle ist die Plugin-Webseite (Zugangsschlüssel bleibt auf dem Server).
|
||||
*/
|
||||
api_require_method('GET');
|
||||
api_require_staff();
|
||||
|
||||
$platform = (string) ($_GET['platform'] ?? '');
|
||||
if (!in_array($platform, ['android', 'windows'], true)) api_error(400, 'bad_platform', 'Unbekannte Plattform.');
|
||||
if (!in_array($platform, ['android', 'windows', 'chrome'], true)) api_error(400, 'bad_platform', 'Unbekannte Plattform.');
|
||||
|
||||
$info = app_publisher_info($platform);
|
||||
if (!$info) api_json(['available' => false, 'platform' => $platform]);
|
||||
|
||||
@@ -63,8 +63,8 @@ if (!in_array($newStatus, AVAILABILITY_STATUSES, true)) {
|
||||
// Ein Admin darf den Status eines anderen Mitarbeiters setzen (staffId), alle anderen nur den eigenen
|
||||
$targetId = (int) $staff['id'];
|
||||
if (isset($body['staffId']) && (int) $body['staffId'] !== $targetId) {
|
||||
if (($staff['role'] ?? '') !== 'admin') {
|
||||
api_error(403, 'FORBIDDEN', 'Nur Administratoren dürfen den Status anderer Mitarbeiter ändern.');
|
||||
if (!has_permission($staff, 'manage_availability')) {
|
||||
api_error(403, 'FORBIDDEN', 'Für den Status anderer Mitarbeiter fehlt die erforderliche Berechtigung.');
|
||||
}
|
||||
$targetId = (int) $body['staffId'];
|
||||
$exists = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1');
|
||||
|
||||
@@ -4,7 +4,7 @@ require_once __DIR__ . '/common.php';
|
||||
|
||||
api_require_method('GET', 'POST');
|
||||
$staff = api_require_staff();
|
||||
api_require_admin($staff);
|
||||
api_require_admin_or_permission($staff, 'manage_banlist');
|
||||
$pdo = get_db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
@@ -31,8 +31,12 @@ $action = $body['action'] ?? '';
|
||||
function api_close_banned_tickets(PDO $pdo, string $matchType, string $value): void
|
||||
{
|
||||
$col = $matchType === 'email' ? 'requester_email' : sql_email_domain('requester_email');
|
||||
$pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')")
|
||||
$before = $pdo->prepare("SELECT id,status FROM tickets WHERE $col=? AND status NOT IN ('closed')");
|
||||
$before->execute([$value]);
|
||||
$rows = $before->fetchAll();
|
||||
$pdo->prepare("UPDATE tickets SET status='closed',resolution_code='spam',resolution_note='Im Rahmen einer Sperre geschlossen',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')")
|
||||
->execute([db_now(), db_now(), $value]);
|
||||
ticket_sla_transition_rows($pdo, $rows, 'closed', (int) ($GLOBALS['__api_staff']['id'] ?? 0) ?: null);
|
||||
}
|
||||
|
||||
if ($action === 'create') {
|
||||
|
||||
@@ -9,7 +9,24 @@ header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
header('Pragma: no-cache');
|
||||
|
||||
const MOBILE_API_STATUSES = ['open', 'pending', 'answered', 'on_hold', 'resolved', 'closed'];
|
||||
// Browser-Erweiterungen (Chrome/Edge/Brave: chrome-extension://…, Firefox: moz-extension://…) dürfen die API aufrufen. Ohne diese Antwort hängt der
|
||||
// Zugriff davon ab, ob der Browser die Server-Berechtigung der Erweiterung schon angewendet hat (nach der ersten Erlaubnis oft erst nach einem Neustart).
|
||||
// Es werden keine Cookies gesendet; wer die API nutzt, braucht weiterhin das Token bzw. Passwort.
|
||||
$__origin = (string) ($_SERVER['HTTP_ORIGIN'] ?? '');
|
||||
if ($__origin !== '' && preg_match('#^(chrome|moz)-extension://[a-z0-9-]{8,64}$#', $__origin)) {
|
||||
header('Access-Control-Allow-Origin: ' . $__origin);
|
||||
header('Vary: Origin');
|
||||
header('Access-Control-Allow-Headers: Authorization, X-DeskHub-Authorization, Content-Type, Cache-Control, Accept');
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
header('Access-Control-Max-Age: 600');
|
||||
if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
exit;
|
||||
}
|
||||
}
|
||||
unset($__origin);
|
||||
|
||||
const MOBILE_API_STATUSES =['open', 'pending', 'answered', 'on_hold', 'resolved', 'closed'];
|
||||
|
||||
set_exception_handler(function (Throwable $error): void {
|
||||
error_log('Mobile Staff API: ' . $error->getMessage());
|
||||
@@ -153,10 +170,7 @@ function api_require_staff(): array
|
||||
|
||||
function api_staff_payload(array $staff): array
|
||||
{
|
||||
$permissionNames = array_merge([
|
||||
'reply_ticket', 'close_ticket', 'post_note', 'assign_ticket', 'transfer_ticket',
|
||||
'create_ticket', 'view_reports',
|
||||
], array_keys(admin_section_permissions()));
|
||||
$permissionNames = array_keys(permission_labels()); // alle Rechte des Katalogs (includes/permissions.php)
|
||||
$permissions = [];
|
||||
foreach ($permissionNames as $permission) {
|
||||
$permissions[$permission] = has_permission($staff, $permission);
|
||||
@@ -248,6 +262,8 @@ function api_ticket_summary(array $ticket): array
|
||||
'requesterName' => $ticket['requester_name'],
|
||||
'requesterEmail' => $ticket['requester_email'],
|
||||
'status' => $ticket['status'],
|
||||
'resolutionCode' => $ticket['resolution_code'] ?? null,
|
||||
'resolutionNote' => $ticket['resolution_note'] ?? null,
|
||||
'priority' => [
|
||||
'id' => (int) $ticket['priority_id'],
|
||||
'name' => $ticket['priority_name'],
|
||||
@@ -261,14 +277,16 @@ function api_ticket_summary(array $ticket): array
|
||||
'id' => (int) $ticket['assigned_staff_id'],
|
||||
'name' => $ticket['staff_name'],
|
||||
],
|
||||
'isOverdue' => !empty($ticket['resolution_due_at'])
|
||||
'slaPaused' => !empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']),
|
||||
'slaPausedAt' => $ticket['sla_paused_at'] ?? null,
|
||||
'isOverdue' => !in_array($ticket['status'], ['resolved', 'closed'], true) && (!empty($ticket['is_overdue']) || (empty($ticket['sla_paused_at']) && !empty($ticket['resolution_due_at'])
|
||||
&& strtotime($ticket['resolution_due_at']) < time()
|
||||
&& !in_array($ticket['status'], ['resolved', 'closed'], true),
|
||||
)),
|
||||
// Fertig formatierter Fälligkeitstext ("in 3 Std 12 Min" / "vor 2 Std 5 Min
|
||||
// (überfällig)") – bewusst serverseitig über dieselbe time_until()-Funktion wie im
|
||||
// Web berechnet, statt das Datumsformat in Kotlin nachzubauen (siehe die Lehren aus
|
||||
// den PHP-Datums-Bugs dieser Session: eine einzige Quelle der Wahrheit fürs Format).
|
||||
'dueLabel' => !empty($ticket['resolution_due_at']) ? time_until($ticket['resolution_due_at']) : null,
|
||||
'dueLabel' => in_array($ticket['status'], ['resolved', 'closed'], true) ? null : (!empty($ticket['sla_paused_at']) && ticket_sla_is_paused_status((string) $ticket['status']) ? 'Pausiert' : (!empty($ticket['resolution_due_at']) ? time_until($ticket['resolution_due_at']) : null)),
|
||||
'isSpam' => !empty($ticket['is_spam']),
|
||||
'createdAt' => $ticket['created_at'],
|
||||
'updatedAt' => $ticket['updated_at'],
|
||||
@@ -304,7 +322,7 @@ function api_ticket_collaborators(PDO $pdo, int $ticketId): array
|
||||
return array_map(fn($r) => ['id' => (int) $r['id'], 'email' => (string) $r['email'], 'name' => (string) ($r['name'] ?? '')], $s->fetchAll());
|
||||
}
|
||||
|
||||
function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null): int
|
||||
function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus, ?string $signatureChoice = null, ?string $resolutionCode = null, string $resolutionNote = ''): int
|
||||
{
|
||||
$permission = $type === 'note' ? 'post_note' : 'reply_ticket';
|
||||
api_require_permission($staff, $permission, (int) $ticket['department_id']);
|
||||
@@ -352,6 +370,9 @@ function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $t
|
||||
}
|
||||
if (in_array($newStatus, ['resolved', 'closed'], true)) {
|
||||
api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']);
|
||||
if (!isset(ticket_resolution_reasons()[$resolutionCode ?? ''])) {
|
||||
api_error(422, 'RESOLUTION_CODE_REQUIRED', 'Bitte wählen Sie einen Abschlussgrund.');
|
||||
}
|
||||
}
|
||||
}
|
||||
$now = db_now();
|
||||
@@ -370,15 +391,23 @@ function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $t
|
||||
} else {
|
||||
$firstResponse = empty($ticket['first_responded_at']);
|
||||
$autoAssign = empty($ticket['assigned_staff_id']);
|
||||
$terminal = in_array($newStatus, ['resolved', 'closed'], true);
|
||||
$sql = 'UPDATE tickets SET status = ?, updated_at = ?'
|
||||
. ($terminal ? ', resolution_code = ?, resolution_note = ?' : '')
|
||||
. ($newStatus === 'resolved' && empty($ticket['resolved_at']) ? ', resolved_at = ?' : '')
|
||||
. ($newStatus === 'closed' && empty($ticket['closed_at']) ? ', closed_at = ?' : '')
|
||||
. ($firstResponse ? ', first_responded_at = ?' : '')
|
||||
. ($autoAssign ? ', assigned_staff_id = ?' : '')
|
||||
. ' WHERE id = ?';
|
||||
$args = [$newStatus, $now];
|
||||
if ($terminal) { $args[] = $resolutionCode; $args[] = trim($resolutionNote); }
|
||||
if ($newStatus === 'resolved' && empty($ticket['resolved_at'])) $args[] = $now;
|
||||
if ($newStatus === 'closed' && empty($ticket['closed_at'])) $args[] = $now;
|
||||
if ($firstResponse) $args[] = $now;
|
||||
if ($autoAssign) $args[] = $staff['id'];
|
||||
$args[] = $ticket['id'];
|
||||
$pdo->prepare($sql)->execute($args);
|
||||
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus, (int) $staff['id']);
|
||||
save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
|
||||
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Öffentliche Antwort gesendet (Mobile App)');
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ $myOpen = (int) $stmt->fetchColumn();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT COUNT(*) FROM tickets
|
||||
WHERE resolution_due_at IS NOT NULL AND resolution_due_at < ?
|
||||
WHERE status NOT IN ('resolved','closed') AND (is_overdue = 1 OR (sla_paused_at IS NULL AND resolution_due_at IS NOT NULL AND resolution_due_at < ?))
|
||||
AND status NOT IN ('resolved','closed'){$deptFilter}"
|
||||
);
|
||||
$stmt->execute([db_now(), ...$deptParams]);
|
||||
@@ -86,7 +86,7 @@ if ($departmentIds === null) {
|
||||
"SELECT d.id, d.name,
|
||||
COUNT(CASE WHEN t.status NOT IN ('resolved','closed') THEN 1 END) AS open_count,
|
||||
COUNT(CASE WHEN t.status NOT IN ('resolved','closed')
|
||||
AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ?
|
||||
AND (t.is_overdue = 1 OR (t.sla_paused_at IS NULL AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ?))
|
||||
THEN 1 END) AS overdue_count
|
||||
FROM departments d
|
||||
LEFT JOIN tickets t ON t.department_id = d.id
|
||||
|
||||
@@ -118,7 +118,7 @@ if (!$ticket) api_error(404, 'TICKET_NOT_FOUND', 'Chat nicht gefunden oder kein
|
||||
|
||||
if ($action === 'typing') {
|
||||
if ($ticket['status'] !== 'closed' && !live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) {
|
||||
$pdo->prepare('UPDATE tickets SET typing_staff_until = ? WHERE id = ?')
|
||||
$pdo->prepare('UPDATE tickets SET typing_staff_until = ?, updated_at = updated_at WHERE id = ?')
|
||||
->execute([date('Y-m-d H:i:s', time() + 6), $ticket['id']]);
|
||||
}
|
||||
api_json(['ok' => true]);
|
||||
@@ -151,6 +151,7 @@ if ($action === 'send') {
|
||||
if ($autoAssign) $args[] = $staff['id'];
|
||||
$args[] = $ticket['id'];
|
||||
$pdo->prepare($sql)->execute($args);
|
||||
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'answered', (int) $staff['id']);
|
||||
if ($autoAssign) log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Automatisch zugewiesen (Live-Chat, Mobile App)');
|
||||
|
||||
save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
|
||||
@@ -167,8 +168,9 @@ if ($action === 'end') {
|
||||
}
|
||||
$now = db_now();
|
||||
$pdo->prepare(
|
||||
"UPDATE tickets SET status='closed', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
|
||||
"UPDATE tickets SET status='closed', resolution_code='other', resolution_note='Live-Chat beendet', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
|
||||
)->execute([$now, $now, $ticket['id']]);
|
||||
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'closed', (int) $staff['id']);
|
||||
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Live-Chat beendet (Mobile App)');
|
||||
finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']);
|
||||
|
||||
|
||||
@@ -12,8 +12,10 @@ $totpCode = trim((string) ($body['totpCode'] ?? ''));
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
|
||||
api_error(422, 'CREDENTIALS_REQUIRED', 'E-Mail-Adresse und Passwort sind erforderlich.');
|
||||
}
|
||||
if (!rate_limit_check('mobile_staff_login_ip', client_ip(), 10, 15)
|
||||
|| !rate_limit_check('mobile_staff_login_email', strtolower($email), 5, 15)) {
|
||||
$ipOk = rate_limit_check('mobile_staff_login_ip', client_ip(), 10, 15);
|
||||
$accountOk = !$ipOk || rate_limit_check('mobile_staff_login_email', strtolower($email), login_guard_max_attempts(), login_guard_lock_minutes());
|
||||
if (!$ipOk || !$accountOk) {
|
||||
if (!$accountOk) login_guard_locked($email, 'app'); // das IP-Limit allein sperrt kein Konto und löst keine Mail an den Kontoinhaber aus
|
||||
api_error(429, 'RATE_LIMITED', 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.');
|
||||
}
|
||||
|
||||
@@ -22,6 +24,7 @@ $stmt = $pdo->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_
|
||||
$stmt->execute([$email]);
|
||||
$staff = $stmt->fetch();
|
||||
if (!$staff || !password_verify($password, $staff['password_hash'])) {
|
||||
login_guard_failed($staff ?: null, $email, 'app', $staff ? 'Falsches Passwort' : 'Unbekannte E-Mail-Adresse');
|
||||
api_error(401, 'INVALID_CREDENTIALS', 'E-Mail-Adresse oder Passwort ist falsch.');
|
||||
}
|
||||
if (!empty($staff['totp_enabled'])) {
|
||||
@@ -29,6 +32,7 @@ if (!empty($staff['totp_enabled'])) {
|
||||
api_json(['requiresTotp' => true], 202);
|
||||
}
|
||||
if (!preg_match('/^\d{6}$/', $totpCode) || !totp_verify($staff['totp_secret'], $totpCode)) {
|
||||
login_guard_failed($staff, $email, 'app', 'Falscher 2FA-Code');
|
||||
api_error(401, 'INVALID_TOTP', 'Der Authenticator-Code ist ungültig.');
|
||||
}
|
||||
}
|
||||
@@ -37,6 +41,7 @@ rate_limit_reset('mobile_staff_login_email', strtolower($email));
|
||||
rate_limit_reset('mobile_staff_login_ip', client_ip());
|
||||
$issued = api_issue_token($pdo, (int) $staff['id'], isset($body['deviceName']) ? (string) $body['deviceName'] : null);
|
||||
audit_log((int) $staff['id'], 'Mobile App: Anmeldung');
|
||||
login_guard_success($staff, 'app', isset($body['deviceName']) ? (string) $body['deviceName'] : null);
|
||||
api_json([
|
||||
'token' => $issued['token'],
|
||||
'expiresAt' => $issued['expires_at'],
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/common.php';
|
||||
require_once __DIR__ . '/../../../includes/mail_fetch_manual.php';
|
||||
|
||||
/**
|
||||
* E-Mail-Abruf von Hand starten (wie der Knopf in der Ticketliste der Weboberfläche). POST ohne Inhalt.
|
||||
* Antwort: ok (ohne Fehler), level (success|info|error), message (fertiger Text), processed, newTickets, replies, errors.
|
||||
*/
|
||||
api_require_method('POST');
|
||||
$staff = api_require_staff();
|
||||
api_require_permission($staff, 'fetch_mail');
|
||||
$pdo = get_db();
|
||||
$r = mail_fetch_manual($pdo);
|
||||
audit_log((int) $staff['id'], 'E-Mail-Abruf von Hand gestartet über die App (' . $r['processed'] . ' E-Mail(s))');
|
||||
api_json([
|
||||
'ok' => $r['ok'],
|
||||
'level' => $r['level'],
|
||||
'message' => $r['message'],
|
||||
'processed' => $r['processed'],
|
||||
'newTickets' => $r['new_tickets'],
|
||||
'replies' => $r['replies'],
|
||||
'errors' => $r['errors'],
|
||||
'available' => mail_fetch_available($pdo),
|
||||
]);
|
||||
@@ -22,7 +22,7 @@ $action = $body['action'] ?? '';
|
||||
|
||||
try {
|
||||
if ($action === 'save') {
|
||||
$id = queue_create($pdo, (int) $staff['id'], (string) ($body['name'] ?? ''), (array) ($body['filters'] ?? []), !empty($body['isShared']));
|
||||
$id = queue_create($pdo, (int) $staff['id'], (string) ($body['name'] ?? ''), (array) ($body['filters'] ?? []), !empty($body['isShared']) && has_permission($staff, 'share_views'));
|
||||
api_json(['id' => $id]);
|
||||
}
|
||||
if ($action === 'delete') {
|
||||
|
||||
@@ -38,7 +38,7 @@ if ($action === 'save') {
|
||||
$cfg = report_clean_config($pdo, (array) ($body['config'] ?? []));
|
||||
$now = db_now();
|
||||
$pdo->prepare('INSERT INTO saved_reports (staff_id, name, config, is_shared, created_at, updated_at) VALUES (?,?,?,?,?,?)')
|
||||
->execute([$staff['id'], mb_substr($name, 0, 120), json_encode($cfg, JSON_UNESCAPED_UNICODE), !empty($body['isShared']) ? 1 : 0, $now, $now]);
|
||||
->execute([$staff['id'], mb_substr($name, 0, 120), json_encode($cfg, JSON_UNESCAPED_UNICODE), (!empty($body['isShared']) && has_permission($staff, 'share_views')) ? 1 : 0, $now, $now]);
|
||||
api_json(['id' => (int) $pdo->lastInsertId()]);
|
||||
}
|
||||
if ($action === 'delete') {
|
||||
|
||||
@@ -9,10 +9,7 @@ $pdo = get_db();
|
||||
|
||||
// Ticket-Rechte + alle Adminportal-Bereichsrechte (admin_section_permissions(), includes/functions.php) –
|
||||
// zusammen ergeben sie das vollständige Set, das über eine benutzerdefinierte Rolle vergeben werden kann.
|
||||
$rolePermissionKeys = array_merge([
|
||||
'reply_ticket', 'close_ticket', 'delete_ticket', 'transfer_ticket', 'post_note',
|
||||
'assign_ticket', 'manage_canned', 'view_reports', 'manage_kb', 'create_ticket',
|
||||
], array_keys(admin_section_permissions()));
|
||||
$rolePermissionKeys = array_keys(permission_labels());
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
$roles = $pdo->query(
|
||||
@@ -21,11 +18,14 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') {
|
||||
|
||||
api_json([
|
||||
'permissionKeys' => $rolePermissionKeys,
|
||||
'permissionLabels' => permission_labels(),
|
||||
'permissionGroups' => array_values(array_map(fn($g) => ['title' => $g['title'], 'keys' => array_keys($g['items'])], permission_catalog())),
|
||||
'defaultAgentPermissions' => default_permissions('agent'),
|
||||
'items' => array_map(fn(array $r) => [
|
||||
'id' => (int) $r['id'],
|
||||
'name' => $r['name'],
|
||||
'permissions' => json_decode($r['permissions'] ?? '{}', true) ?? [],
|
||||
'effectivePermissions' => permission_effective(json_decode($r['permissions'] ?? '{}', true) ?? []),
|
||||
'staffCount' => (int) $r['staff_count'],
|
||||
'isActive' => (bool) ($r['is_active'] ?? 1),
|
||||
'notes' => $r['notes'] ?? null,
|
||||
@@ -44,9 +44,19 @@ if ($action === 'save') {
|
||||
if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
|
||||
|
||||
$incoming = (array) ($body['permissions'] ?? []);
|
||||
// Rechte, die eine ältere App-Version nicht kennt (und deshalb nicht mitschickt), bleiben unverändert bzw. bekommen den Standardwert
|
||||
$existingPerms = default_permissions('agent');
|
||||
if ($id) {
|
||||
$ep = $pdo->prepare('SELECT permissions FROM roles WHERE id = ?');
|
||||
$ep->execute([$id]);
|
||||
$existingPerms = array_merge($existingPerms, json_decode((string) $ep->fetchColumn(), true) ?: []);
|
||||
}
|
||||
$perms = [];
|
||||
foreach ($rolePermissionKeys as $p) {
|
||||
$perms[$p] = !empty($incoming[$p]);
|
||||
$want = array_key_exists($p, $incoming) ? !empty($incoming[$p]) : !empty($existingPerms[$p]);
|
||||
// Wer kein echter Administrator ist, kann keine Rechte vergeben, die er selbst nicht hat
|
||||
if ($staff['role'] !== 'admin' && $want && !has_permission($staff, $p)) $want = !empty($existingPerms[$p]) && $id > 0;
|
||||
$perms[$p] = $want;
|
||||
}
|
||||
$json = json_encode($perms);
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM roles WHERE LOWER(name) = LOWER(?) AND id <> ?');
|
||||
|
||||
@@ -17,7 +17,7 @@ $staff = api_require_staff();
|
||||
$pdo = get_db();
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
api_require_admin($staff);
|
||||
api_require_admin_or_permission($staff, 'manage_status_page');
|
||||
$b = api_request_body();
|
||||
$action = (string) ($b['action'] ?? '');
|
||||
if ($action === 'set_component') {
|
||||
@@ -39,4 +39,4 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
api_error(422, 'UNKNOWN_ACTION', 'Unbekannte Aktion.');
|
||||
}
|
||||
}
|
||||
api_json(['status' => status_summary($pdo)] + ['canManage' => $staff['role'] === 'admin']);
|
||||
api_json(['status' => status_summary($pdo)] + ['canManage' => has_permission($staff, 'manage_status_page')]);
|
||||
|
||||
@@ -40,8 +40,9 @@ $threadStmt = $pdo->prepare(
|
||||
ORDER BY tt.created_at ASC, tt.id ASC'
|
||||
);
|
||||
$threadStmt->execute([$ticket['id']]);
|
||||
// Eingebettete Bilder einer Mail (Logos, Fotos im Text) gehören zur Darstellung der Nachricht und sind keine Anhänge
|
||||
$attachmentStmt = $pdo->prepare(
|
||||
'SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id'
|
||||
'SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ?' . (ticket_mail_html_ready($pdo) ? ' AND COALESCE(is_inline, 0) = 0' : '') . ' ORDER BY id'
|
||||
);
|
||||
$commentStmt = $pdo->prepare(
|
||||
'SELECT tc.id, tc.message, tc.created_at, s.id AS staff_id, s.name AS staff_name
|
||||
@@ -88,6 +89,8 @@ if ($ticket['source'] === 'chat' && $ticket['status'] !== 'closed') {
|
||||
}
|
||||
}
|
||||
|
||||
ticket_ack($pdo, (int) $ticket['id'], (int) $staff['id']); // gesehen: verschwindet aus dem Zähler der Apps
|
||||
|
||||
$summary = api_ticket_summary($ticket);
|
||||
$summary['source'] = $ticket['source'];
|
||||
$summary['firstResponseDueAt'] = $ticket['first_response_due_at'];
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/common.php';
|
||||
require_once __DIR__ . '/../../../includes/mail_reload.php';
|
||||
|
||||
/**
|
||||
* Lädt Anhänge, eingebettete Bilder und die Formatierung der Original-E-Mail eines Tickets nach (wie der Knopf im Ticket der
|
||||
* Weboberfläche). POST {"number": "260101-0001"}.
|
||||
*/
|
||||
api_require_method('POST');
|
||||
$staff = api_require_staff();
|
||||
$body = api_request_body();
|
||||
$number = trim((string) ($body['number'] ?? ''));
|
||||
if ($number === '') api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.');
|
||||
$pdo = get_db();
|
||||
$ticket = api_find_ticket($pdo, $staff, $number);
|
||||
api_require_permission($staff, 'reply_ticket', (int) $ticket['department_id']);
|
||||
if ($ticket['source'] !== 'email') api_error(422, 'EMAIL_TICKET_REQUIRED', 'Nur E-Mail-Tickets haben eine Original-E-Mail.');
|
||||
$r = reload_ticket_mail_attachments($pdo, $ticket, (int) $staff['id']);
|
||||
if ($r['error']) api_error(422, 'MAIL_RELOAD_FAILED', $r['error']);
|
||||
api_json([
|
||||
'added' => $r['added'],
|
||||
'inlineImages' => $r['inline'],
|
||||
'formatting' => $r['html'] > 0,
|
||||
'skipped' => $r['skipped'],
|
||||
'alreadyThere' => $r['existing'],
|
||||
'failed' => $r['failed'],
|
||||
]);
|
||||
@@ -16,5 +16,7 @@ if ($number === '') {
|
||||
}
|
||||
$pdo = get_db();
|
||||
$ticket = api_find_ticket($pdo, $staff, $number);
|
||||
$threadId = api_add_ticket_message($pdo, $staff, $ticket, $type, $message, $setStatus, $signatureChoice);
|
||||
$resolutionCode = isset($body['resolutionCode']) ? trim((string) $body['resolutionCode']) : null;
|
||||
$resolutionNote = trim((string) ($body['resolutionNote'] ?? ''));
|
||||
$threadId = api_add_ticket_message($pdo, $staff, $ticket, $type, $message, $setStatus, $signatureChoice, $resolutionCode, $resolutionNote);
|
||||
api_json(['threadId' => $threadId], 201);
|
||||
|
||||
@@ -12,8 +12,8 @@ require_once __DIR__ . '/common.php';
|
||||
|
||||
api_require_method('GET', 'POST');
|
||||
$staff = api_require_staff();
|
||||
if ($staff['role'] !== 'admin') {
|
||||
api_error(403, 'ADMIN_REQUIRED', 'Nur Administratoren können Ticket-Regeln verwalten.');
|
||||
if (!has_permission($staff, 'manage_automation')) {
|
||||
api_error(403, 'FORBIDDEN', 'Für Ticket-Regeln fehlt die erforderliche Berechtigung.');
|
||||
}
|
||||
$pdo = get_db();
|
||||
|
||||
|
||||
@@ -27,6 +27,6 @@ if ($holderId !== (int) $staff['id']) {
|
||||
$n->execute([$holderId]);
|
||||
$name = (string) ($n->fetchColumn() ?: 'einem anderen Mitarbeiter');
|
||||
}
|
||||
$pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL WHERE id = ?')->execute([$ticket['id']]);
|
||||
$pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL, updated_at = updated_at WHERE id = ?')->execute([$ticket['id']]);
|
||||
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], $name !== '' ? "Sperre von \"{$name}\" durch Admin aufgehoben" : 'Sperre manuell aufgehoben');
|
||||
api_json(['ok' => true, 'unlocked' => true]);
|
||||
|
||||
@@ -29,6 +29,12 @@ if (array_key_exists('status', $body) && $body['status'] !== $ticket['status'])
|
||||
api_error(422, 'INVALID_STATUS', 'Der Status ist ungültig.');
|
||||
}
|
||||
api_require_permission($staff, in_array($status, ['resolved', 'closed'], true) ? 'close_ticket' : 'reply_ticket', (int) $ticket['department_id']);
|
||||
if (in_array($status, ['resolved', 'closed'], true)) {
|
||||
$resolutionCode = trim((string) ($body['resolutionCode'] ?? ''));
|
||||
if (!isset(ticket_resolution_reasons()[$resolutionCode])) api_error(422, 'RESOLUTION_CODE_REQUIRED', 'Bitte wählen Sie einen Abschlussgrund.');
|
||||
$updates[] = 'resolution_code = ?'; $params[] = $resolutionCode;
|
||||
$updates[] = 'resolution_note = ?'; $params[] = trim((string) ($body['resolutionNote'] ?? ''));
|
||||
}
|
||||
$updates[] = 'status = ?';
|
||||
$params[] = $status;
|
||||
if ($status === 'resolved' && empty($ticket['resolved_at'])) {
|
||||
@@ -90,6 +96,7 @@ $params[] = $now;
|
||||
$params[] = $ticket['id'];
|
||||
$pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($params);
|
||||
if (array_key_exists('status', $body) && $body['status'] !== $ticket['status']) {
|
||||
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], (string) $body['status'], (int) $staff['id']);
|
||||
[$label] = status_label((string) $body['status']);
|
||||
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Status geändert: ' . $label . ' (Mobile App)');
|
||||
}
|
||||
|
||||
@@ -70,13 +70,13 @@ if ($sla !== '') {
|
||||
$now = db_now();
|
||||
$riskThreshold = date('Y-m-d H:i:s', time() + 4 * 3600);
|
||||
if ($sla === 'overdue') {
|
||||
$where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')";
|
||||
$where[] = "t.resolution_due_at IS NOT NULL AND (t.is_overdue = 1 OR (t.sla_paused_at IS NULL AND t.resolution_due_at < ?)) AND t.status NOT IN ('resolved','closed')";
|
||||
$params[] = $now;
|
||||
} elseif ($sla === 'at_risk') {
|
||||
$where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at >= ? AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')";
|
||||
$where[] = "t.sla_paused_at IS NULL AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at >= ? AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')";
|
||||
array_push($params, $now, $riskThreshold);
|
||||
} elseif ($sla === 'on_time') {
|
||||
$where[] = "(t.resolution_due_at IS NULL OR t.resolution_due_at >= ?) AND t.status NOT IN ('resolved','closed')";
|
||||
$where[] = "t.sla_paused_at IS NULL AND (t.resolution_due_at IS NULL OR t.resolution_due_at >= ?) AND t.status NOT IN ('resolved','closed')";
|
||||
$params[] = $riskThreshold;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1134,3 +1134,25 @@ body.staff-login-page {
|
||||
@media (min-width: 821px) {
|
||||
html.sidebar-closed .sidebar .lang-row { display: none; }
|
||||
}
|
||||
|
||||
/* ── Seitenleiste unten: Benutzerkarte, Symbolleiste, Sprache ───────────── */
|
||||
.sidebar-footer .sf-user { display:flex; align-items:center; gap:10px; margin-bottom:12px; }
|
||||
.sidebar-footer .sf-avatar { width:38px; height:38px; border-radius:50%; object-fit:cover; flex-shrink:0; border:2px solid #2a3744; }
|
||||
.sidebar-footer .sf-avatar-letter { display:flex; align-items:center; justify-content:center; background:rgba(15,111,106,.3); color:#8bc4c2; font-weight:700; font-size:1rem; }
|
||||
.sidebar-footer .sf-who { min-width:0; display:flex; flex-direction:column; gap:1px; align-items:flex-start; }
|
||||
.sidebar-footer .sf-name { font-size:.86rem; font-weight:700; color:#fff; max-width:100%; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.sidebar-footer .sf-role { font-size:.72rem; color:#7c8da0; }
|
||||
.sidebar-footer .sf-vac { margin-top:3px; font-size:.68rem; font-weight:600; color:#fbbf24; background:rgba(251,191,36,.14); border-radius:10px; padding:1px 8px; }
|
||||
.sidebar-footer .sf-actions { display:grid; grid-template-columns:repeat(4, 1fr); gap:6px; margin-bottom:10px; }
|
||||
.sidebar-footer .sf-btn { position:relative; display:flex; align-items:center; justify-content:center; height:34px; border-radius:9px; background:rgba(255,255,255,.06); color:#a9b6c3; text-decoration:none; transition:background .15s, color .15s; }
|
||||
.sidebar-footer .sf-btn:hover { background:rgba(255,255,255,.14); color:#fff; }
|
||||
.sidebar-footer .sf-btn-out:hover { background:rgba(220,38,38,.28); color:#fecaca; }
|
||||
.sidebar-footer .sf-btn:focus-visible { outline:2px solid #8bc4c2; outline-offset:1px; }
|
||||
.sidebar-footer .sf-badge { position:absolute; top:-5px; right:-4px; min-width:16px; height:16px; padding:0 4px; border-radius:8px; background:#dc2626; color:#fff; font-size:.62rem; font-weight:700; display:flex; align-items:center; justify-content:center; line-height:1; box-shadow:0 0 0 2px #1b2733; }
|
||||
.sidebar-footer .lang-row { display:flex; justify-content:center; }
|
||||
.sidebar-footer .lang-row .lang-switch { gap:4px !important; padding:3px; background:rgba(255,255,255,.05); border-radius:9px; }
|
||||
.sidebar-footer .lang-row .lang-switch a { padding:2px !important; border-radius:6px !important; outline-offset:0 !important; }
|
||||
.sidebar-footer .lang-row .lang-switch img { width:22px !important; height:15px !important; }
|
||||
html.sidebar-closed .sidebar .sidebar-footer .sf-actions { grid-template-columns:1fr; }
|
||||
html.sidebar-closed .sidebar .sidebar-footer .sf-btn { width:34px; margin:0 auto; }
|
||||
html.sidebar-closed .sidebar .sidebar-footer .sf-user { justify-content:center; }
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/* Passkeys im Browser: Anmeldung (staff/login.php) und Registrierung (Profil → Sicherheit). Spricht mit /staff/passkey.php. */
|
||||
(function () {
|
||||
'use strict';
|
||||
function b64uToBuf(s) {
|
||||
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (s.length % 4) s += '=';
|
||||
var bin = atob(s), buf = new Uint8Array(bin.length);
|
||||
for (var i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
||||
return buf.buffer;
|
||||
}
|
||||
function bufToB64u(buf) {
|
||||
var b = new Uint8Array(buf), s = '';
|
||||
for (var i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
|
||||
return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
function post(csrf, fields) {
|
||||
var fd = new FormData();
|
||||
fd.append('csrf_token', csrf);
|
||||
Object.keys(fields).forEach(function (k) { fd.append(k, fields[k]); });
|
||||
return fetch('/staff/passkey.php', { method: 'POST', body: fd, credentials: 'same-origin' }).then(function (r) {
|
||||
return r.json().catch(function () { return { ok: false, error: 'Unerwartete Antwort des Servers.' }; });
|
||||
});
|
||||
}
|
||||
function supported() {
|
||||
return !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext);
|
||||
}
|
||||
function friendly(e) {
|
||||
if (e && (e.name === 'NotAllowedError' || e.name === 'AbortError')) return 'Abgebrochen oder abgelaufen. Bitte noch einmal versuchen.';
|
||||
if (e && e.name === 'InvalidStateError') return 'Dieses Gerät ist schon als Passkey registriert.';
|
||||
return (e && e.message) ? e.message : 'Der Passkey konnte nicht verwendet werden.';
|
||||
}
|
||||
|
||||
window.DeskHubPasskey = {
|
||||
supported: supported,
|
||||
login: function (csrf) {
|
||||
return post(csrf, { action: 'login_options' }).then(function (r) {
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
var o = r.options;
|
||||
return navigator.credentials.get({ publicKey: { challenge: b64uToBuf(o.challenge), rpId: o.rpId, timeout: o.timeout, userVerification: o.userVerification, allowCredentials: [] } });
|
||||
}).then(function (c) {
|
||||
var rs = c.response;
|
||||
return post(csrf, { action: 'login_verify', credential: JSON.stringify({ id: c.id, response: {
|
||||
clientDataJSON: bufToB64u(rs.clientDataJSON), authenticatorData: bufToB64u(rs.authenticatorData), signature: bufToB64u(rs.signature),
|
||||
userHandle: rs.userHandle ? bufToB64u(rs.userHandle) : ''
|
||||
} }) });
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r;
|
||||
}).catch(function (e) { throw new Error(friendly(e)); });
|
||||
},
|
||||
register: function (csrf, name, password) {
|
||||
return post(csrf, { action: 'register_options', password: password }).then(function (r) {
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
var o = r.options;
|
||||
o.challenge = b64uToBuf(o.challenge);
|
||||
o.user.id = b64uToBuf(o.user.id);
|
||||
o.excludeCredentials = (o.excludeCredentials || []).map(function (c) { return { type: c.type, id: b64uToBuf(c.id) }; });
|
||||
return navigator.credentials.create({ publicKey: o });
|
||||
}).then(function (c) {
|
||||
var rs = c.response;
|
||||
return post(csrf, { action: 'register_verify', name: name, credential: JSON.stringify({ id: c.id, response: {
|
||||
clientDataJSON: bufToB64u(rs.clientDataJSON), attestationObject: bufToB64u(rs.attestationObject)
|
||||
} }) });
|
||||
}).then(function (r) {
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r;
|
||||
}).catch(function (e) { throw new Error(friendly(e)); });
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -198,6 +198,33 @@
|
||||
imgBtn.addEventListener('click', () => fileInput.click());
|
||||
toolbar.appendChild(imgBtn);
|
||||
toolbar.appendChild(fileInput);
|
||||
|
||||
// Bild aus der Zwischenablage (Strg + V, zum Beispiel ein Screenshot) oder per Ziehen in das Feld: derselbe Weg wie der Knopf.
|
||||
// Enthält die Zwischenablage auch Text (Kopie aus Word, Webseite), bleibt das normale Einfügen unverändert.
|
||||
const imageFrom = (list) => Array.from(list || []).find((f) => /^image\/(png|jpe?g|gif)$/i.test(f.type));
|
||||
const uploadPicked = (file) => {
|
||||
const ext = file.type.toLowerCase() === 'image/jpeg' ? 'jpg' : file.type.split('/')[1].toLowerCase();
|
||||
const named = /\.(png|jpe?g|gif)$/i.test(file.name || '') ? file : new File([file], 'screenshot-' + Date.now() + '.' + ext, { type: file.type });
|
||||
const dt = new DataTransfer();
|
||||
dt.items.add(named);
|
||||
fileInput.files = dt.files;
|
||||
fileInput.dispatchEvent(new Event('change'));
|
||||
};
|
||||
editable.addEventListener('paste', (e) => {
|
||||
const cd = e.clipboardData;
|
||||
if (!cd || Array.from(cd.types || []).some((t) => t === 'text/plain' || t === 'text/html')) return;
|
||||
const img = imageFrom(cd.files);
|
||||
if (!img) return;
|
||||
e.preventDefault();
|
||||
uploadPicked(img);
|
||||
});
|
||||
editable.addEventListener('dragover', (e) => { if (Array.from((e.dataTransfer && e.dataTransfer.types) || []).includes('Files')) e.preventDefault(); });
|
||||
editable.addEventListener('drop', (e) => {
|
||||
const img = imageFrom(e.dataTransfer && e.dataTransfer.files);
|
||||
if (!img) return;
|
||||
e.preventDefault();
|
||||
uploadPicked(img);
|
||||
});
|
||||
}
|
||||
|
||||
editable.addEventListener('input', () => { textarea.value = editable.innerHTML; });
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Tastenkürzel im Mitarbeiterbereich.
|
||||
*
|
||||
* Ticketliste: j / k = Ticket wählen, Enter oder o = öffnen, r = öffnen und gleich antworten, x = Auswahlfeld, / = Suche, n = neues Ticket
|
||||
* Ticket: j / k = nächste / vorherige Nachricht, r = Antwort schreiben, u = zurück zur Liste, / = Suche in der Liste
|
||||
* Überall: ? = Übersicht, Esc = schließen. Strg + Enter im Antwortfeld sendet die Antwort.
|
||||
*
|
||||
* Die Kürzel gelten nur, wenn kein Eingabefeld aktiv ist und keine Strg-, Alt- oder Meta-Taste gedrückt wird. Wer sie nicht möchte,
|
||||
* schaltet sie in der Übersicht (?) ab; die Wahl bleibt im Browser (localStorage).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
if (window.__deskhubShortcuts) return;
|
||||
window.__deskhubShortcuts = true;
|
||||
|
||||
var EN = /^en/i.test(document.documentElement.lang || '');
|
||||
// Die Seite wird serverseitig übersetzt, Texte aus diesem Skript nicht: deshalb hier die englische Fassung
|
||||
var TXT = EN ? {
|
||||
title: 'Keyboard shortcuts', nextMsg: 'Next / previous message', nextTicket: 'Next / previous ticket', open: 'Open the selected ticket (list)',
|
||||
reply: 'Reply (in the list: open and select the reply field)', tick: 'Tick the selected ticket (list)', search: 'Search', create: 'New ticket',
|
||||
back: 'Back to the ticket list (in a ticket)', send: 'Send the reply (in the reply field)', help: 'This overview', use: 'Use keyboard shortcuts (this browser only)',
|
||||
enter: 'Enter or o', ctrl: 'Ctrl + Enter'
|
||||
} : {
|
||||
title: 'Tastenkürzel', nextMsg: 'Nächste / vorherige Nachricht', nextTicket: 'Nächstes / vorheriges Ticket', open: 'Gewähltes Ticket öffnen (Liste)',
|
||||
reply: 'Antworten (in der Liste: öffnen und Antwortfeld wählen)', tick: 'Gewähltes Ticket ankreuzen (Liste)', search: 'Suche', create: 'Neues Ticket',
|
||||
back: 'Zurück zur Ticketliste (im Ticket)', send: 'Antwort senden (im Antwortfeld)', help: 'Diese Übersicht', use: 'Tastenkürzel verwenden (nur in diesem Browser)',
|
||||
enter: 'Enter oder o', ctrl: 'Strg + Enter'
|
||||
};
|
||||
var KEY = 'deskhub_shortcuts_off';
|
||||
var path = location.pathname;
|
||||
var onList = /\/staff\/tickets\.php$/.test(path);
|
||||
var onTicket = /\/staff\/ticket_view\.php$/.test(path);
|
||||
var cursor = -1;
|
||||
var help = null;
|
||||
|
||||
function disabled() { try { return localStorage.getItem(KEY) === '1'; } catch (e) { return false; } }
|
||||
function setDisabled(v) { try { if (v) localStorage.setItem(KEY, '1'); else localStorage.removeItem(KEY); } catch (e) { /* ohne Speicher */ } }
|
||||
|
||||
function typing(el) {
|
||||
if (!el) return false;
|
||||
var tag = (el.tagName || '').toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable === true;
|
||||
}
|
||||
|
||||
function rows() { return Array.prototype.slice.call(document.querySelectorAll('table tbody tr')).filter(function (tr) { return tr.querySelector('a.ticket-no'); }); }
|
||||
function messages() { return Array.prototype.slice.call(document.querySelectorAll('.thread-msg')); }
|
||||
|
||||
function mark(list, i) {
|
||||
list.forEach(function (el) { el.classList.remove('kb-active'); });
|
||||
if (i < 0 || i >= list.length) return;
|
||||
var el = list[i];
|
||||
el.classList.add('kb-active');
|
||||
el.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function move(list, delta) {
|
||||
if (!list.length) return;
|
||||
var current = list.findIndex(function (el) { return el.classList.contains('kb-active'); });
|
||||
cursor = current < 0 ? (delta > 0 ? 0 : list.length - 1) : Math.max(0, Math.min(list.length - 1, current + delta));
|
||||
mark(list, cursor);
|
||||
}
|
||||
|
||||
function activeRow() { return rows().filter(function (tr) { return tr.classList.contains('kb-active'); })[0] || null; }
|
||||
|
||||
function focusReply() {
|
||||
var field = document.getElementById('message-field');
|
||||
if (!field) return false;
|
||||
var wrap = field.parentNode;
|
||||
var editable = wrap && wrap.querySelector('[contenteditable]');
|
||||
var target = editable || field;
|
||||
target.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
||||
target.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeHelp() { if (help) { help.remove(); help = null; } }
|
||||
|
||||
function showHelp() {
|
||||
if (help) return closeHelp();
|
||||
var items = [
|
||||
['j / k', onTicket ? TXT.nextMsg : TXT.nextTicket],
|
||||
[TXT.enter, TXT.open],
|
||||
['r', TXT.reply],
|
||||
['x', TXT.tick],
|
||||
['/', TXT.search],
|
||||
['n', TXT.create],
|
||||
['u', TXT.back],
|
||||
[TXT.ctrl, TXT.send],
|
||||
['?', TXT.help],
|
||||
];
|
||||
help = document.createElement('div');
|
||||
help.setAttribute('role', 'dialog');
|
||||
help.setAttribute('aria-label', TXT.title);
|
||||
help.style.cssText = 'position:fixed;inset:0;z-index:2147483600;background:rgba(15,23,42,.55);display:flex;align-items:center;justify-content:center;padding:16px';
|
||||
var box = document.createElement('div');
|
||||
box.style.cssText = 'background:var(--surface,#fff);color:var(--ink,#0f172a);border-radius:12px;padding:20px 24px;max-width:460px;width:100%;box-shadow:0 20px 50px rgba(0,0,0,.35);font:14px/1.5 system-ui,sans-serif';
|
||||
var h = document.createElement('div');
|
||||
h.style.cssText = 'font-weight:700;font-size:1.05rem;margin-bottom:10px';
|
||||
h.textContent = TXT.title;
|
||||
box.appendChild(h);
|
||||
items.forEach(function (it) {
|
||||
var row = document.createElement('div');
|
||||
row.style.cssText = 'display:flex;gap:12px;padding:4px 0;border-top:1px solid rgba(148,163,184,.25)';
|
||||
var k = document.createElement('kbd');
|
||||
k.style.cssText = 'min-width:104px;font:600 12px ui-monospace,monospace;background:rgba(148,163,184,.2);border-radius:5px;padding:2px 7px;align-self:flex-start';
|
||||
k.textContent = it[0];
|
||||
var d = document.createElement('span');
|
||||
d.textContent = it[1];
|
||||
row.appendChild(k);
|
||||
row.appendChild(d);
|
||||
box.appendChild(row);
|
||||
});
|
||||
var label = document.createElement('label');
|
||||
label.style.cssText = 'display:flex;align-items:center;gap:8px;margin-top:14px;font-weight:400';
|
||||
var cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.style.width = 'auto';
|
||||
cb.checked = !disabled();
|
||||
cb.addEventListener('change', function () { setDisabled(!cb.checked); });
|
||||
label.appendChild(cb);
|
||||
label.appendChild(document.createTextNode(TXT.use));
|
||||
box.appendChild(label);
|
||||
help.appendChild(box);
|
||||
help.addEventListener('click', function (e) { if (e.target === help) closeHelp(); });
|
||||
document.body.appendChild(help);
|
||||
}
|
||||
|
||||
// Auffälliger Rahmen für die gewählte Zeile bzw. Nachricht
|
||||
var style = document.createElement('style');
|
||||
style.textContent = '.kb-active{outline:2px solid var(--primary,#0f6f6a);outline-offset:-2px;background:rgba(15,111,106,.07)}';
|
||||
document.head.appendChild(style);
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
// Strg + Enter im Antwortfeld sendet die öffentliche Antwort (unabhängig von den Einzeltasten)
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey) && onTicket && !disabled()) {
|
||||
var editable = e.target && e.target.closest ? e.target.closest('.richtext-editable') : null;
|
||||
if (editable) {
|
||||
var form = editable.closest('form');
|
||||
var action = form && form.querySelector('#form-action');
|
||||
if (form && action && action.value === 'reply') {
|
||||
e.preventDefault();
|
||||
var ta = form.querySelector('textarea[data-richtext]');
|
||||
if (ta) ta.value = editable.innerHTML;
|
||||
if (form.requestSubmit) form.requestSubmit(); else form.submit();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') { if (help) { closeHelp(); e.preventDefault(); } else if (typing(e.target)) e.target.blur(); return; }
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
if (typing(e.target)) return;
|
||||
if (e.key === '?') { e.preventDefault(); showHelp(); return; }
|
||||
if (disabled() || help) return;
|
||||
|
||||
switch (e.key) {
|
||||
case 'j': case 'k':
|
||||
if (onList) { e.preventDefault(); move(rows(), e.key === 'j' ? 1 : -1); }
|
||||
else if (onTicket) { e.preventDefault(); move(messages(), e.key === 'j' ? 1 : -1); }
|
||||
break;
|
||||
case 'Enter': case 'o': {
|
||||
var row = onList ? activeRow() : null;
|
||||
var link = row && row.querySelector('a.ticket-no');
|
||||
if (link) { e.preventDefault(); location.href = link.href; }
|
||||
break;
|
||||
}
|
||||
case 'x': {
|
||||
var r = onList ? activeRow() : null;
|
||||
var box = r && r.querySelector('input.ticket-check');
|
||||
if (box) { e.preventDefault(); box.checked = !box.checked; box.dispatchEvent(new Event('change', { bubbles: true })); }
|
||||
break;
|
||||
}
|
||||
case 'r':
|
||||
if (onTicket) { if (focusReply()) e.preventDefault(); }
|
||||
else if (onList) {
|
||||
var rr = activeRow();
|
||||
var a = rr && rr.querySelector('a.ticket-no');
|
||||
if (a) { e.preventDefault(); location.href = a.href + '#reply'; }
|
||||
}
|
||||
break;
|
||||
case '/': {
|
||||
var search = document.querySelector('input[name="q"]');
|
||||
if (search) { e.preventDefault(); search.focus(); search.select(); }
|
||||
else { e.preventDefault(); location.href = '/staff/tickets.php#search'; }
|
||||
break;
|
||||
}
|
||||
case 'n':
|
||||
e.preventDefault();
|
||||
location.href = '/staff/ticket_create.php';
|
||||
break;
|
||||
case 'u':
|
||||
if (onTicket) { e.preventDefault(); location.href = '/staff/tickets.php'; }
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (disabled()) return;
|
||||
if (location.hash === '#reply' && onTicket) setTimeout(focusReply, 150);
|
||||
if (location.hash === '#search' && onList) {
|
||||
var s = document.querySelector('input[name="q"]');
|
||||
if (s) { s.focus(); s.select(); }
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Bildvorschau für Anhänge im Ticket: Ein Klick auf ein Vorschaubild zeigt das Bild groß über der Seite, ohne es herunterzuladen.
|
||||
* Pfeil links/rechts (oder die Knöpfe) blättern durch alle Bilder des Tickets, Esc oder ein Klick daneben schließt. Ohne JavaScript
|
||||
* öffnet der Link das Bild wie bisher in einem neuen Tab.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var links = Array.prototype.slice.call(document.querySelectorAll('a[data-lightbox]'));
|
||||
if (!links.length) return;
|
||||
|
||||
var EN = /^en/i.test(document.documentElement.lang || '');
|
||||
var TXT = EN ? { dialog: 'Image preview', prev: 'Previous image (←)', next: 'Next image (→)', download: '⬇ Download', close: '✕ Close', closeTitle: 'Close (Esc)' }
|
||||
: { dialog: 'Bildvorschau', prev: 'Vorheriges Bild (←)', next: 'Nächstes Bild (→)', download: '⬇ Herunterladen', close: '✕ Schließen', closeTitle: 'Schließen (Esc)' };
|
||||
var overlay = null, img = null, caption = null, counter = null, index = 0;
|
||||
|
||||
function build() {
|
||||
overlay = document.createElement('div');
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', TXT.dialog);
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:2147483500;background:rgba(2,6,23,.88);display:none;flex-direction:column;align-items:center;justify-content:center;padding:16px;gap:10px';
|
||||
img = document.createElement('img');
|
||||
img.alt = '';
|
||||
img.style.cssText = 'max-width:96vw;max-height:80vh;object-fit:contain;border-radius:6px;background:#fff;box-shadow:0 10px 40px rgba(0,0,0,.5)';
|
||||
caption = document.createElement('div');
|
||||
caption.style.cssText = 'color:#e2e8f0;font:14px system-ui,sans-serif;text-align:center;max-width:90vw;word-break:break-all';
|
||||
counter = document.createElement('span');
|
||||
counter.style.cssText = 'color:#94a3b8;margin-left:8px';
|
||||
var bar = document.createElement('div');
|
||||
bar.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;justify-content:center';
|
||||
function btn(label, title, fn) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = label;
|
||||
b.title = title;
|
||||
b.style.cssText = 'background:#1e293b;color:#fff;border:1px solid #475569;border-radius:8px;padding:7px 14px;cursor:pointer;font:600 14px system-ui,sans-serif';
|
||||
b.addEventListener('click', function (e) { e.stopPropagation(); fn(); });
|
||||
return b;
|
||||
}
|
||||
var download = document.createElement('a');
|
||||
download.textContent = TXT.download;
|
||||
download.style.cssText = 'background:#1e293b;color:#fff;border:1px solid #475569;border-radius:8px;padding:7px 14px;text-decoration:none;font:600 14px system-ui,sans-serif';
|
||||
download.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
overlay._download = download;
|
||||
bar.appendChild(btn('‹', TXT.prev, function () { show(index - 1); }));
|
||||
bar.appendChild(btn('›', TXT.next, function () { show(index + 1); }));
|
||||
bar.appendChild(download);
|
||||
bar.appendChild(btn(TXT.close, TXT.closeTitle, close));
|
||||
caption.appendChild(document.createTextNode(''));
|
||||
overlay.appendChild(img);
|
||||
overlay.appendChild(caption);
|
||||
overlay.appendChild(bar);
|
||||
overlay.addEventListener('click', close);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function show(i) {
|
||||
if (!links.length) return;
|
||||
index = (i + links.length) % links.length;
|
||||
var a = links[index];
|
||||
img.src = a.getAttribute('href');
|
||||
img.alt = a.getAttribute('title') || '';
|
||||
caption.textContent = a.getAttribute('title') || '';
|
||||
counter.textContent = links.length > 1 ? '(' + (index + 1) + ' / ' + links.length + ')' : '';
|
||||
caption.appendChild(counter);
|
||||
overlay._download.href = a.getAttribute('data-download') || a.getAttribute('href');
|
||||
overlay._download.setAttribute('download', a.getAttribute('title') || '');
|
||||
}
|
||||
|
||||
function open(i) {
|
||||
if (!overlay) build();
|
||||
overlay.style.display = 'flex';
|
||||
document.body.style.overflow = 'hidden';
|
||||
show(i);
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!overlay) return;
|
||||
overlay.style.display = 'none';
|
||||
img.removeAttribute('src');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
links.forEach(function (a, i) {
|
||||
a.addEventListener('click', function (e) {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button === 1) return; // neuer Tab bleibt möglich
|
||||
e.preventDefault();
|
||||
open(i);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!overlay || overlay.style.display === 'none') return;
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); }
|
||||
else if (e.key === 'ArrowLeft') { e.preventDefault(); show(index - 1); }
|
||||
else if (e.key === 'ArrowRight') { e.preventDefault(); show(index + 1); }
|
||||
});
|
||||
})();
|
||||
@@ -52,6 +52,7 @@ function run_auto_close_tickets(PDO $pdo, bool $verbose = false): array
|
||||
foreach ($tickets as $t) {
|
||||
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?")
|
||||
->execute([$now, $now, $t['id']]);
|
||||
ticket_sla_status_transition($pdo, (int) $t['id'], (string) $t['status'], 'closed');
|
||||
log_activity($pdo, (int) $t['id'], null, "Automatisch geschlossen (keine Kundenreaktion seit {$days} Tagen)");
|
||||
$closed++;
|
||||
if ($verbose) {
|
||||
|
||||
@@ -147,7 +147,7 @@ function run_check_escalations(PDO $pdo, bool $verbose = false): array
|
||||
}
|
||||
|
||||
$sql = 'SELECT t.*, d.name AS department_name, d.email AS department_email,
|
||||
p.name AS priority_name, s.name AS staff_name, s.email AS staff_email
|
||||
p.name AS priority_name, s.name AS staff_name, s.email AS staff_email, s.is_on_vacation AS staff_on_vacation
|
||||
FROM tickets t
|
||||
JOIN departments d ON d.id = t.department_id
|
||||
JOIN priorities p ON p.id = t.priority_id
|
||||
@@ -182,6 +182,7 @@ function run_check_escalations(PDO $pdo, bool $verbose = false): array
|
||||
if ($rule['action_set_status']) {
|
||||
$pdo->prepare('UPDATE tickets SET status = ?, updated_at = ? WHERE id = ?')
|
||||
->execute([$rule['action_set_status'], $now, $ticket['id']]);
|
||||
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], (string) $rule['action_set_status']);
|
||||
$changes[] = 'Status gesetzt auf: ' . $rule['action_set_status'];
|
||||
}
|
||||
|
||||
@@ -201,6 +202,11 @@ function run_check_escalations(PDO $pdo, bool $verbose = false): array
|
||||
}
|
||||
|
||||
// Zugewiesenen Mitarbeiter benachrichtigen
|
||||
if ($rule['action_notify_staff'] && !empty($ticket['staff_email'])) {
|
||||
// Bearbeiter im Urlaub: die Vertretung bekommt die Mail
|
||||
$escTo = vacation_expand_ids($pdo, [(int) $ticket['assigned_staff_id']]);
|
||||
$ticket['staff_email'] = $escTo ? (staff_emails_by_ids($pdo, [$escTo[0]])[0] ?? '') : '';
|
||||
}
|
||||
if ($rule['action_notify_staff'] && !empty($ticket['staff_email'])) {
|
||||
$subj = "Eskalation: Ticket #{$ticket['ticket_number']} – {$ticket['subject']}";
|
||||
$inner = email_p('Die Eskalationsregel <strong>' . e($rule['name']) . '</strong> wurde für folgendes Ticket ausgelöst:')
|
||||
|
||||
@@ -32,6 +32,7 @@ function run_check_sla(PDO $pdo, bool $verbose = false): array
|
||||
"SELECT t.*, d.email AS department_email, d.name AS department_name FROM tickets t
|
||||
JOIN departments d ON d.id = t.department_id
|
||||
WHERE t.status NOT IN ('resolved','closed')
|
||||
AND t.sla_paused_at IS NULL
|
||||
AND t.resolution_due_at IS NOT NULL
|
||||
AND t.resolution_due_at < ?"
|
||||
);
|
||||
@@ -43,18 +44,12 @@ function run_check_sla(PDO $pdo, bool $verbose = false): array
|
||||
|
||||
foreach ($overdue as $t) {
|
||||
if (!$t['is_overdue']) {
|
||||
$pdo->prepare('UPDATE tickets SET is_overdue = 1 WHERE id = ?')->execute([$t['id']]);
|
||||
$pdo->prepare('UPDATE tickets SET is_overdue = 1, updated_at = updated_at WHERE id = ?')->execute([$t['id']]);
|
||||
ticket_sla_log_overdue($pdo, (int) $t['id'], 'resolution', (string) $t['resolution_due_at']);
|
||||
log_activity($pdo, $t['id'], null, 'SLA überschritten – Ticket als überfällig markiert');
|
||||
|
||||
if ($t['department_email']) {
|
||||
$subject = "SLA überschritten: Ticket #{$t['ticket_number']}";
|
||||
$inner = email_p('Das folgende Ticket hat die SLA-Lösungsfrist überschritten und benötigt Aufmerksamkeit:')
|
||||
. email_badge('Ticket', '#' . $t['ticket_number'], '#b91c1c', '#fee2e2')
|
||||
. email_p('<strong>Betreff:</strong> ' . e($t['subject']), '24px')
|
||||
. email_button('Ticket ansehen', e(base_url() . '/staff/ticket_view.php?n=' . urlencode($t['ticket_number'])), '#dc2626');
|
||||
email_send_and_log($t['department_email'], $subject, email_wrap($inner), null, 'SLA-überschritten-Mail (Abteilung)', $t['ticket_number']);
|
||||
}
|
||||
notify_admins_overdue_ticket($t);
|
||||
// Nur die Betroffenen bekommen eine Mail (Bearbeiter bzw. Abteilung), nicht die Postfach-Adresse der Abteilung und nicht alle Admins
|
||||
notify_overdue_ticket_staff($t);
|
||||
fcm_notify_overdue($t); // Push auf die Android-App
|
||||
$newlyOverdue++;
|
||||
if ($verbose) {
|
||||
@@ -63,14 +58,24 @@ function run_check_sla(PDO $pdo, bool $verbose = false): array
|
||||
}
|
||||
}
|
||||
|
||||
$responseStmt = $pdo->prepare("SELECT id, first_response_due_at FROM tickets
|
||||
WHERE first_responded_at IS NULL AND first_response_due_at IS NOT NULL AND first_response_due_at < ?
|
||||
AND sla_paused_at IS NULL AND status NOT IN ('resolved','closed')");
|
||||
$responseStmt->execute([$now]);
|
||||
foreach ($responseStmt->fetchAll() as $ticket) ticket_sla_log_overdue($pdo, (int) $ticket['id'], 'first_response', (string) $ticket['first_response_due_at']);
|
||||
|
||||
// Tickets, die nicht mehr überfällig sind (z.B. weil sie gelöst wurden), zurücksetzen
|
||||
$pdo->exec("UPDATE tickets SET is_overdue = 0 WHERE status IN ('resolved','closed') AND is_overdue = 1");
|
||||
$pdo->exec("UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE status IN ('resolved','closed') AND is_overdue = 1");
|
||||
|
||||
// Erinnerung an Bearbeiter: Ticket wartet seit mehreren Arbeitsstunden auf Antwort, bevor die Frist reißt
|
||||
$waitReminders = wait_reminders_run($pdo, $now);
|
||||
foreach ($waitReminders['log'] as $line) $log[] = $line;
|
||||
|
||||
set_setting('cron_check_sla_last_run', $now);
|
||||
|
||||
$log[] = "[" . date('Y-m-d H:i:s') . "] SLA-Prüfung abgeschlossen. " . count($overdue) . " überfällige Tickets insgesamt, davon {$newlyOverdue} neu erkannt.";
|
||||
|
||||
return ['overdueTotal' => count($overdue), 'newlyOverdue' => $newlyOverdue, 'log' => $log];
|
||||
return ['overdueTotal' => count($overdue), 'newlyOverdue' => $newlyOverdue, 'waitReminders' => $waitReminders['sent'], 'log' => $log];
|
||||
}
|
||||
|
||||
if (php_sapi_name() === 'cli') {
|
||||
|
||||
@@ -28,6 +28,7 @@ if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_respons
|
||||
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/../includes/mail_text.php';
|
||||
require_once __DIR__ . '/../includes/mail_threading.php';
|
||||
|
||||
/** Verarbeitet eine Mail nach dem Einlesen gemäß dem gewählten Modus */
|
||||
function handle_after_fetch(mixed $mbox, int $emailNum, string $mode, string $folder): void
|
||||
@@ -175,6 +176,14 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
|
||||
// Bei "delete" bleibt die Zuordnung zwar erhalten, die Originalmail ist danach jedoch nicht mehr verschiebbar.
|
||||
$sourceFolderAfterFetch = $afterFetch === 'archive' ? $archiveFolder : $account['folder'];
|
||||
|
||||
// Mails, die DeskHub selbst verschickt hat (zum Beispiel SLA-Meldungen an eine Postfach-Adresse), dürfen keine Tickets erzeugen
|
||||
if (mail_is_own_system_mail($pdo, (string) $fromEmail, (string) @imap_fetchheader($mbox, $emailNum))) {
|
||||
$log[] = "[$label] Mail von {$fromEmail} ignoriert (eigene Systemmail, würde sonst ein Ticket erzeugen): {$subject}";
|
||||
handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder);
|
||||
$processed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Nachrichtentext: Text-Teil bevorzugt, bei reinen HTML-Mails (Newsletter) nur der sichtbare Text ohne CSS und Skripte
|
||||
$structure = @imap_fetchstructure($mbox, $emailNum);
|
||||
$body = $structure ? mail_best_text($structure, fn(string $no) => (string) imap_fetchbody($mbox, $emailNum, $no)) : '';
|
||||
@@ -186,20 +195,27 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
|
||||
if (trim($body) === '') $body = '(kein Inhalt)';
|
||||
|
||||
// ── Antwort auf bestehendes Ticket? ──────────────────────────────────
|
||||
if (preg_match('/\[#([0-9]{6}-[0-9]{4})\]/', $subject, $m)) {
|
||||
$ticketNumber = $m[1];
|
||||
$stmt = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ?');
|
||||
$stmt->execute([$ticketNumber]);
|
||||
$ticket = $stmt->fetch();
|
||||
// Zuordnung über die Nummer im Betreff, eine andere Nummer (früheres Ticketsystem) oder die Kopfzeilen In-Reply-To/References
|
||||
$replyMatch = mail_find_ticket_for_reply($pdo, $subject, (string) @imap_fetchheader($mbox, $emailNum), (string) $fromEmail);
|
||||
if ($replyMatch) {
|
||||
$ticket = $replyMatch['ticket'];
|
||||
$ticketNumber = (string) $ticket['ticket_number'];
|
||||
$replyVia = ['subject' => '', 'legacy' => ' – zugeordnet über die Nummer im Betreff', 'header' => ' – zugeordnet über die Mail-Kopfzeilen'][$replyMatch['via']] ?? '';
|
||||
|
||||
if ($ticket) {
|
||||
$now = db_now();
|
||||
$pdo->prepare(
|
||||
'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)'
|
||||
)->execute([$ticket['id'], 'customer', $body, $now]);
|
||||
$replyThreadId = (int) $pdo->lastInsertId();
|
||||
$fetchPart = fn(string $no) => (string) imap_fetchbody($mbox, $emailNum, $no);
|
||||
$htmlBody = ticket_mail_html_ready($pdo) && $structure ? mail_html_for_display($structure, $fetchPart) : null;
|
||||
if ($htmlBody !== null) $pdo->prepare('UPDATE ticket_threads SET message_html = ? WHERE id = ?')->execute([$htmlBody, $replyThreadId]);
|
||||
mail_store_attachments($pdo, $structure ?: null, $fetchPart, $replyThreadId, (int) $ticket['id'], $label, $log, false, $htmlBody !== null);
|
||||
|
||||
$newStatus = in_array($ticket['status'], ['answered', 'resolved']) ? 'open' : $ticket['status'];
|
||||
$newStatus = in_array($ticket['status'], ['answered', 'resolved', 'pending']) ? 'open' : $ticket['status'];
|
||||
$pdo->prepare('UPDATE tickets SET status=?, updated_at=? WHERE id=?')->execute([$newStatus, $now, $ticket['id']]);
|
||||
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus);
|
||||
log_activity($pdo, $ticket['id'], null, 'Antwort per E-Mail empfangen');
|
||||
notify_new_reply_channels($ticket, $body);
|
||||
webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'customer']);
|
||||
@@ -207,7 +223,7 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
|
||||
'INSERT INTO ticket_email_sources (ticket_id, imap_account_id, message_uid, message_id, current_folder, created_at) VALUES (?,?,?,?,?,?)'
|
||||
)->execute([$ticket['id'], $account['id'], $messageUid ?: null, $messageId ?: null, $sourceFolderAfterFetch, $now]);
|
||||
|
||||
$log[] = "[$label] Antwort zu Ticket #{$ticketNumber} hinzugefügt (von {$fromEmail}).";
|
||||
$log[] = "[$label] Antwort zu Ticket #{$ticketNumber} hinzugefügt (von {$fromEmail}){$replyVia}.";
|
||||
handle_after_fetch($mbox, $emailNum, $afterFetch, $archiveFolder);
|
||||
$processed++;
|
||||
continue;
|
||||
@@ -294,6 +310,11 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
|
||||
$pdo->prepare(
|
||||
'INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)'
|
||||
)->execute([$ticketId, 'customer', $body, $now]);
|
||||
$firstThreadId = (int) $pdo->lastInsertId();
|
||||
$fetchPart = fn(string $no) => (string) imap_fetchbody($mbox, $emailNum, $no);
|
||||
$htmlBody = ticket_mail_html_ready($pdo) && $structure ? mail_html_for_display($structure, $fetchPart) : null;
|
||||
if ($htmlBody !== null) $pdo->prepare('UPDATE ticket_threads SET message_html = ? WHERE id = ?')->execute([$htmlBody, $firstThreadId]);
|
||||
mail_store_attachments($pdo, $structure ?: null, $fetchPart, $firstThreadId, $ticketId, $label, $log, false, $htmlBody !== null);
|
||||
|
||||
log_activity($pdo, $ticketId, null, 'Ticket per E-Mail erstellt');
|
||||
$log[] = "[$label] Neues Ticket #{$ticketNumber} erstellt (von {$fromEmail}).";
|
||||
@@ -356,7 +377,8 @@ function fetch_emails_for_account(PDO $pdo, array $account, bool $verbose): arra
|
||||
return ['processed' => $processed, 'log' => $log];
|
||||
}
|
||||
|
||||
if (php_sapi_name() === 'cli') {
|
||||
// Nur starten, wenn diese Datei selbst aufgerufen wird (Cronjob), nicht wenn sie von einer anderen Datei eingebunden wird (Abruf von Hand, Tests)
|
||||
if (php_sapi_name() === 'cli' && isset($_SERVER['argv'][0]) && realpath((string) $_SERVER['argv'][0]) === realpath(__FILE__)) {
|
||||
$result = run_fetch_emails(get_db(), true);
|
||||
foreach ($result['log'] as $line) {
|
||||
echo $line . "\n";
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
// Direkter Web-Zugriff wird geblockt (nur CLI oder Einbindung aus dem Backend/Pseudo-Cron).
|
||||
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
|
||||
/**
|
||||
* cron/process_mail_queue.php
|
||||
*
|
||||
* Versucht Mails erneut zu senden, die wegen einer Störung (SMTP-Server nicht erreichbar, falscher Port …) nicht rausgingen.
|
||||
* Läuft alle paar Minuten, z. B. per Cronjob:
|
||||
* * /5 * * * * /usr/bin/php /pfad/zu/deskhub/cron/process_mail_queue.php
|
||||
*/
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
|
||||
/** @return array{sent:int, failed:int, retry:int, gave_up:int, log:string[]} */
|
||||
function run_process_mail_queue(PDO $pdo, bool $verbose = false): array
|
||||
{
|
||||
$r = mail_queue_run($pdo);
|
||||
set_setting('cron_process_mail_queue_last_run', db_now());
|
||||
$r['log'] = ['[' . date('Y-m-d H:i:s') . "] Mail-Warteschlange: {$r['sent']} gesendet, {$r['retry']} später erneut, {$r['gave_up']} aufgegeben."];
|
||||
return $r;
|
||||
}
|
||||
|
||||
if (php_sapi_name() === 'cli') {
|
||||
foreach (run_process_mail_queue(get_db(), true)['log'] as $line) echo $line . "\n";
|
||||
}
|
||||
@@ -29,6 +29,9 @@ function run_plugins_cron(PDO $pdo, bool $verbose = false): array
|
||||
}
|
||||
|
||||
if (php_sapi_name() === 'cli') {
|
||||
// Der echte Cronjob lebt: immer vermerken, auch wenn der Lauf unten übersprungen wird (bei viel Besuch hat meist schon der eingebaute
|
||||
// Hintergrund-Betrieb die Minute belegt; sonst stünde in der Plugin-Anzeige dauerhaft „bisher kein Lauf“)
|
||||
set_setting('cron_run_plugins_last_cli', db_now());
|
||||
// Läuft zeitgleich der eingebaute Hintergrund-Betrieb, nicht doppelt ausführen (gleiche Sperre wie dort)
|
||||
if (!function_exists('pseudo_cron_try_claim') || pseudo_cron_try_claim(get_db(), 'cron_run_plugins_last_run', 50)) {
|
||||
foreach (run_plugins_cron(get_db(), true)['log'] as $line) echo $line . "\n";
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
// Direkter Web-Zugriff wird geblockt (nur CLI oder Einbindung aus dem Backend/Pseudo-Cron).
|
||||
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
|
||||
/**
|
||||
* cron/send_report_schedules.php
|
||||
*
|
||||
* Versendet gespeicherte Berichte nach Zeitplan per E-Mail (Bericht-Baukasten → „Versand per E-Mail“). Läuft alle paar Minuten, z. B.:
|
||||
* * /5 * * * * /usr/bin/php /pfad/zu/deskhub/cron/send_report_schedules.php
|
||||
*/
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
|
||||
/** @return array{sent:int, failed:int, log:string[]} */
|
||||
function run_send_report_schedules(PDO $pdo, bool $verbose = false): array
|
||||
{
|
||||
$r = report_schedules_run_due($pdo);
|
||||
set_setting('cron_send_report_schedules_last_run', db_now());
|
||||
return $r + ['log' => ['[' . date('Y-m-d H:i:s') . "] Berichte per E-Mail: {$r['sent']} gesendet, {$r['failed']} fehlgeschlagen."]];
|
||||
}
|
||||
|
||||
if (php_sapi_name() === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
|
||||
foreach (run_send_report_schedules(get_db(), true)['log'] as $line) echo $line . "\n";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
// Direkter Web-Zugriff wird geblockt (nur CLI oder Einbindung aus dem Backend/Pseudo-Cron).
|
||||
if (php_sapi_name() !== 'cli' && !defined('DESKHUB_CRON_RUNNER')) { http_response_code(403); exit('Kein Web-Zugriff.'); }
|
||||
/**
|
||||
* cron/send_scheduled_replies.php
|
||||
*
|
||||
* Sendet zeitversetzt geplante Antworten, sobald ihr Zeitpunkt erreicht ist. Läuft jede Minute, z. B. per Cronjob:
|
||||
* * * * * * /usr/bin/php /pfad/zu/deskhub/cron/send_scheduled_replies.php
|
||||
*/
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
|
||||
/** @return array{sent:int, failed:int, log:string[]} */
|
||||
function run_send_scheduled_replies(PDO $pdo, bool $verbose = false): array
|
||||
{
|
||||
$r = scheduled_replies_run_due($pdo);
|
||||
set_setting('cron_send_scheduled_replies_last_run', db_now());
|
||||
return $r + ['log' => ['[' . date('Y-m-d H:i:s') . "] Geplante Antworten: {$r['sent']} gesendet, {$r['failed']} fehlgeschlagen."]];
|
||||
}
|
||||
|
||||
if (php_sapi_name() === 'cli' && realpath($_SERVER['SCRIPT_FILENAME'] ?? '') === __FILE__) {
|
||||
foreach (run_send_scheduled_replies(get_db(), true)['log'] as $line) echo $line . "\n";
|
||||
}
|
||||
@@ -1542,6 +1542,255 @@ function run_migrations(PDO $pdo, bool $verbose = false): array
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0055: Status-Komponenten lassen sich für Anzeige und Prüfungen aktivieren/deaktivieren.
|
||||
if (!isset($done['php_0055_status_component_enabled'])) {
|
||||
try {
|
||||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
|
||||
$hasEnabled = false;
|
||||
foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'is_enabled') $hasEnabled = true;
|
||||
if (!$hasEnabled) $pdo->exec('ALTER TABLE status_components ADD COLUMN is_enabled TINYINT NOT NULL DEFAULT 1');
|
||||
$categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
|
||||
$categoryStmt->execute(['Apps']);
|
||||
$gameCategoryId = $categoryStmt->fetchColumn();
|
||||
if (!$gameCategoryId) {
|
||||
$categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
|
||||
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Apps', $categoryOrder]);
|
||||
$gameCategoryId = (int) $pdo->lastInsertId();
|
||||
}
|
||||
$minecraftStmt = $pdo->query("SELECT id, category_id, category FROM status_components WHERE LOWER(name) LIKE '%minecraft%' ORDER BY id LIMIT 1");
|
||||
$minecraft = $minecraftStmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$minecraft) {
|
||||
$sortOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn() + 10;
|
||||
$insert = $pdo->prepare('INSERT INTO status_components (name, description, status, sort_order, created_at, category_id, monitor_type, is_enabled) VALUES (?,?,?,?,?,?,?,0)');
|
||||
$insert->execute(['Minecraft-Server', 'Minecraft Java – IP-Adresse oder Domain und Port eintragen.', 'operational', $sortOrder, db_now(), (int) $gameCategoryId, 'tcp']);
|
||||
} elseif (empty($minecraft['category_id']) && trim((string) ($minecraft['category'] ?? '')) === '') {
|
||||
$pdo->prepare('UPDATE status_components SET category_id = ? WHERE id = ?')->execute([(int) $gameCategoryId, (int) $minecraft['id']]);
|
||||
}
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0055_status_component_enabled']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0055_status_component_enabled';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0055_status_component_enabled: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0055_status_component_enabled - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0056: Minecraft-Statusdienst der Kategorie „Apps“ zuordnen.
|
||||
if (!isset($done['php_0056_minecraft_status_category'])) {
|
||||
try {
|
||||
$categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
|
||||
$categoryStmt->execute(['Apps']);
|
||||
$gameCategoryId = $categoryStmt->fetchColumn();
|
||||
if (!$gameCategoryId) {
|
||||
$categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
|
||||
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Apps', $categoryOrder]);
|
||||
$gameCategoryId = (int) $pdo->lastInsertId();
|
||||
}
|
||||
$pdo->prepare("UPDATE status_components SET category_id = ? WHERE LOWER(name) LIKE '%minecraft%' AND category_id IS NULL AND (category IS NULL OR TRIM(category) = '')")
|
||||
->execute([(int) $gameCategoryId]);
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0056_minecraft_status_category']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0056_minecraft_status_category';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0056_minecraft_status_category: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0056_minecraft_status_category - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0057: Minecraft-Dienst unter „Apps“ einsortieren.
|
||||
if (!isset($done['php_0057_minecraft_status_apps_category'])) {
|
||||
try {
|
||||
$categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
|
||||
$categoryStmt->execute(['Apps']);
|
||||
$appCategoryId = $categoryStmt->fetchColumn();
|
||||
if (!$appCategoryId) {
|
||||
$categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
|
||||
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Apps', $categoryOrder]);
|
||||
$appCategoryId = (int) $pdo->lastInsertId();
|
||||
}
|
||||
$pdo->prepare("UPDATE status_components SET category_id = ?, category = NULL WHERE LOWER(name) LIKE '%minecraft%'")
|
||||
->execute([(int) $appCategoryId]);
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0057_minecraft_status_apps_category']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0057_minecraft_status_apps_category';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0057_minecraft_status_apps_category: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0057_minecraft_status_apps_category - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0059: Optionalen Backup-Server für die Statusseite anlegen.
|
||||
if (!isset($done['php_0059_backup_server_status_component'])) {
|
||||
try {
|
||||
$categoryStmt = $pdo->prepare('SELECT id FROM status_categories WHERE LOWER(name) = LOWER(?) LIMIT 1');
|
||||
$categoryStmt->execute(['Server & Datenbank']);
|
||||
$categoryId = $categoryStmt->fetchColumn();
|
||||
if (!$categoryId) {
|
||||
$categoryOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_categories')->fetchColumn() + 10;
|
||||
$pdo->prepare('INSERT INTO status_categories (name, sort_order) VALUES (?,?)')->execute(['Server & Datenbank', $categoryOrder]);
|
||||
$categoryId = (int) $pdo->lastInsertId();
|
||||
}
|
||||
$existing = $pdo->query("SELECT id FROM status_components WHERE LOWER(name) IN ('backup-server', 'backup server', 'ftp-backup-server') LIMIT 1")->fetchColumn();
|
||||
if (!$existing) {
|
||||
$sortOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn() + 10;
|
||||
$insert = $pdo->prepare('INSERT INTO status_components (name, description, status, sort_order, created_at, category_id, monitor_type, is_enabled) VALUES (?,?,?,?,?,?,?,0)');
|
||||
$insert->execute(['Backup-Server', 'Optionaler externer Backup-Server; Prüfung mit Host und Port.', 'operational', $sortOrder, db_now(), (int) $categoryId, 'tcp']);
|
||||
}
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0059_backup_server_status_component']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0059_backup_server_status_component';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0059_backup_server_status_component: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0059_backup_server_status_component - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0060: Geplante Wartungsfenster der Statusseite.
|
||||
if (!isset($done['php_0060_status_maintenance_windows'])) {
|
||||
try {
|
||||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS status_maintenance_windows (
|
||||
id $ai,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
body TEXT NULL,
|
||||
component_id INT NULL,
|
||||
starts_at DATETIME NOT NULL,
|
||||
ends_at DATETIME NOT NULL,
|
||||
created_by INT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
cancelled_at DATETIME NULL
|
||||
)$eng");
|
||||
$pdo->exec('CREATE INDEX idx_status_maintenance_window_time ON status_maintenance_windows (starts_at, ends_at)');
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0060_status_maintenance_windows']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0060_status_maintenance_windows';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0060_status_maintenance_windows: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0060_status_maintenance_windows - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0061: Prüfintervall je Statusdienst konfigurierbar machen.
|
||||
if (!isset($done['php_0061_status_check_intervals'])) {
|
||||
try {
|
||||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
|
||||
$hasInterval = false;
|
||||
foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'check_interval_minutes') $hasInterval = true;
|
||||
if (!$hasInterval) $pdo->exec('ALTER TABLE status_components ADD COLUMN check_interval_minutes INT NOT NULL DEFAULT 1');
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0061_status_check_intervals']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0061_status_check_intervals';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0061_status_check_intervals: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0061_status_check_intervals - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0063: SLA-Wartepausen und Ereignisverlauf.
|
||||
if (!isset($done['php_0063_ticket_sla_pause_history'])) {
|
||||
try {
|
||||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$cols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(tickets)' : 'SHOW COLUMNS FROM tickets')->fetchAll();
|
||||
$hasPauseColumn = false;
|
||||
foreach ($cols as $col) if ((string) ($col['name'] ?? $col['Field'] ?? '') === 'sla_paused_at') $hasPauseColumn = true;
|
||||
if (!$hasPauseColumn) $pdo->exec('ALTER TABLE tickets ADD COLUMN sla_paused_at DATETIME NULL');
|
||||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_sla_history (
|
||||
id $ai, ticket_id INT NOT NULL, event_type VARCHAR(24) NOT NULL,
|
||||
deadline_type VARCHAR(24) NULL, details TEXT NULL, staff_id INT NULL, created_at DATETIME NOT NULL
|
||||
)$eng");
|
||||
$indexes = $pdo->query($drv === 'sqlite' ? 'PRAGMA index_list(ticket_sla_history)' : 'SHOW INDEX FROM ticket_sla_history')->fetchAll();
|
||||
$hasIndex = false;
|
||||
foreach ($indexes as $index) if ((string) ($index['name'] ?? $index['Key_name'] ?? '') === 'idx_ticket_sla_history_ticket_time') $hasIndex = true;
|
||||
if (!$hasIndex) $pdo->exec('CREATE INDEX idx_ticket_sla_history_ticket_time ON ticket_sla_history (ticket_id, created_at)');
|
||||
foreach ([['first_response_due_at', 'first_response'], ['resolution_due_at', 'resolution']] as [$dueColumn, $type]) {
|
||||
$sql = "INSERT INTO ticket_sla_history (ticket_id, event_type, deadline_type, details, created_at)
|
||||
SELECT t.id, 'started', ?, ?, t.created_at FROM tickets t
|
||||
WHERE t.$dueColumn IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM ticket_sla_history h WHERE h.ticket_id = t.id AND h.event_type = 'started' AND h.deadline_type = ?
|
||||
)";
|
||||
$pdo->prepare($sql)->execute([$type, 'SLA gestartet; Fristfeld: ' . $dueColumn, $type]);
|
||||
}
|
||||
$now = db_now();
|
||||
$pdo->prepare("UPDATE tickets SET sla_paused_at = ? WHERE sla_paused_at IS NULL AND status IN ('pending','on_hold')")->execute([$now]);
|
||||
$pdo->prepare("INSERT INTO ticket_sla_history (ticket_id, event_type, details, created_at)
|
||||
SELECT id, 'paused', 'SLA-Pause bei Aktivierung uebernommen', ? FROM tickets
|
||||
WHERE status IN ('pending','on_hold') AND sla_paused_at = ?")->execute([$now, $now]);
|
||||
$pdo->exec("INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,created_at)
|
||||
SELECT t.id,'overdue','resolution',NULL,t.resolution_due_at FROM tickets t
|
||||
WHERE t.is_overdue = 1 AND t.resolution_due_at IS NOT NULL AND NOT EXISTS (
|
||||
SELECT 1 FROM ticket_sla_history h WHERE h.ticket_id=t.id AND h.event_type='overdue' AND h.deadline_type='resolution'
|
||||
)");
|
||||
$backfillResponse = $pdo->prepare("INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,created_at)
|
||||
SELECT t.id,'overdue','first_response',NULL,t.first_response_due_at FROM tickets t
|
||||
WHERE t.first_responded_at IS NULL AND t.first_response_due_at IS NOT NULL AND t.first_response_due_at < ?
|
||||
AND t.status NOT IN ('pending','on_hold','resolved','closed') AND NOT EXISTS (
|
||||
SELECT 1 FROM ticket_sla_history h WHERE h.ticket_id=t.id AND h.event_type='overdue' AND h.deadline_type='first_response'
|
||||
)");
|
||||
$backfillResponse->execute([$now]);
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0063_ticket_sla_pause_history']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0063_ticket_sla_pause_history';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0063_ticket_sla_pause_history: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0063_ticket_sla_pause_history - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0062: Problemmanagement und dokumentierte Ticket-Abschlussgründe.
|
||||
if (!isset($done['php_0062_problems_and_resolution_codes'])) {
|
||||
try {
|
||||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||||
$ticketCols = $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(tickets)' : 'SHOW COLUMNS FROM tickets')->fetchAll();
|
||||
$has = [];
|
||||
foreach ($ticketCols as $col) $has[] = (string) ($col['name'] ?? $col['Field'] ?? '');
|
||||
if (!in_array('resolution_code', $has, true)) $pdo->exec('ALTER TABLE tickets ADD COLUMN resolution_code VARCHAR(40) NULL');
|
||||
if (!in_array('resolution_note', $has, true)) $pdo->exec('ALTER TABLE tickets ADD COLUMN resolution_note TEXT NULL');
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS problems (
|
||||
id $ai,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
description TEXT NULL,
|
||||
root_cause TEXT NULL,
|
||||
workaround TEXT NULL,
|
||||
resolution TEXT NULL,
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'investigating',
|
||||
created_by INT NULL,
|
||||
updated_by INT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
resolved_at DATETIME NULL
|
||||
)$eng");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS problem_tickets (
|
||||
problem_id INT NOT NULL,
|
||||
ticket_id INT NOT NULL,
|
||||
linked_by INT NULL,
|
||||
linked_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (problem_id, ticket_id)
|
||||
)$eng");
|
||||
$indexes = $pdo->query($drv === 'sqlite' ? 'PRAGMA index_list(problem_tickets)' : 'SHOW INDEX FROM problem_tickets')->fetchAll();
|
||||
$ticketIndexExists = false;
|
||||
foreach ($indexes as $index) if ((string) ($index['name'] ?? $index['Key_name'] ?? '') === 'idx_problem_tickets_ticket') $ticketIndexExists = true;
|
||||
if (!$ticketIndexExists) $pdo->exec('CREATE INDEX idx_problem_tickets_ticket ON problem_tickets (ticket_id)');
|
||||
$indexes = $pdo->query($drv === 'sqlite' ? 'PRAGMA index_list(problems)' : 'SHOW INDEX FROM problems')->fetchAll();
|
||||
$problemIndexExists = false;
|
||||
foreach ($indexes as $index) if ((string) ($index['name'] ?? $index['Key_name'] ?? '') === 'idx_problems_status_updated') $problemIndexExists = true;
|
||||
if (!$problemIndexExists) $pdo->exec('CREATE INDEX idx_problems_status_updated ON problems (status, updated_at)');
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0062_problems_and_resolution_codes']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0062_problems_and_resolution_codes';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0062_problems_and_resolution_codes: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0062_problems_and_resolution_codes - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0049: gesammelte, noch nicht übersetzte Texte (Sammelmodus der Mehrsprachigkeit).
|
||||
if (!isset($done['php_0049_i18n_missing'])) {
|
||||
try {
|
||||
@@ -1571,6 +1820,336 @@ function run_migrations(PDO $pdo, bool $verbose = false): array
|
||||
}
|
||||
}
|
||||
|
||||
// Warteschlange für ausgehende Mails: Fehlgeschlagene Mails werden später erneut versucht
|
||||
if (!isset($done['php_0064_mail_queue'])) {
|
||||
try {
|
||||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||||
$long = $drv === 'sqlite' ? 'TEXT' : 'MEDIUMTEXT';
|
||||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS mail_queue (
|
||||
id $ai,
|
||||
to_email VARCHAR(255) NOT NULL,
|
||||
from_header VARCHAR(255) NULL,
|
||||
subject VARCHAR(500) NOT NULL,
|
||||
body $long NOT NULL,
|
||||
status VARCHAR(10) NOT NULL DEFAULT 'pending',
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
next_attempt_at DATETIME NOT NULL,
|
||||
last_error VARCHAR(400) NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
sent_at DATETIME NULL
|
||||
)$eng");
|
||||
$pdo->exec('CREATE INDEX idx_mail_queue_due ON mail_queue (status, next_attempt_at)');
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0064_mail_queue']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0064_mail_queue';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0064_mail_queue: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0064_mail_queue - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Anmelde-Wächter: Protokoll der Anmeldungen sowie gemerkte Geräte und Orte je Mitarbeiter
|
||||
if (!isset($done['php_0065_login_guard'])) {
|
||||
try {
|
||||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS login_events (
|
||||
id $ai,
|
||||
staff_id INT NULL,
|
||||
email VARCHAR(255) NOT NULL DEFAULT '',
|
||||
event VARCHAR(20) NOT NULL,
|
||||
channel VARCHAR(10) NOT NULL DEFAULT 'web',
|
||||
ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||
place VARCHAR(100) NOT NULL DEFAULT '',
|
||||
device VARCHAR(160) NOT NULL DEFAULT '',
|
||||
detail VARCHAR(255) NOT NULL DEFAULT '',
|
||||
token_hash VARCHAR(64) NULL,
|
||||
token_used TINYINT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL
|
||||
)$eng");
|
||||
$pdo->exec('CREATE INDEX idx_login_events_created ON login_events (created_at)');
|
||||
$pdo->exec('CREATE INDEX idx_login_events_staff ON login_events (staff_id, created_at)');
|
||||
$pdo->exec('CREATE INDEX idx_login_events_token ON login_events (token_hash)');
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS login_devices (
|
||||
id $ai,
|
||||
staff_id INT NOT NULL,
|
||||
device_hash VARCHAR(64) NOT NULL,
|
||||
label VARCHAR(160) NOT NULL DEFAULT '',
|
||||
channel VARCHAR(10) NOT NULL DEFAULT 'web',
|
||||
first_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||
last_ip VARCHAR(64) NOT NULL DEFAULT '',
|
||||
place VARCHAR(100) NOT NULL DEFAULT '',
|
||||
first_seen DATETIME NOT NULL,
|
||||
last_seen DATETIME NOT NULL,
|
||||
UNIQUE (staff_id, device_hash)
|
||||
)$eng");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS login_places (
|
||||
id $ai,
|
||||
staff_id INT NOT NULL,
|
||||
place_key VARCHAR(80) NOT NULL,
|
||||
label VARCHAR(100) NOT NULL DEFAULT '',
|
||||
first_seen DATETIME NOT NULL,
|
||||
last_seen DATETIME NOT NULL,
|
||||
UNIQUE (staff_id, place_key)
|
||||
)$eng");
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0065_login_guard']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0065_login_guard';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0065_login_guard: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0065_login_guard - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Abwesenheit im Profil: Welche Tickets an die Vertretung weitergegeben wurden (für die Rückgabe nach dem Urlaub)
|
||||
if (!isset($done['php_0066_vacation_moves'])) {
|
||||
try {
|
||||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS vacation_moves (
|
||||
id $ai,
|
||||
ticket_id INT NOT NULL,
|
||||
from_staff_id INT NOT NULL,
|
||||
to_staff_id INT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
returned_at DATETIME NULL
|
||||
)$eng");
|
||||
$pdo->exec('CREATE INDEX idx_vacation_moves_from ON vacation_moves (from_staff_id, returned_at)');
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0066_vacation_moves']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0066_vacation_moves';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0066_vacation_moves: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0066_vacation_moves - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Reparatur: Entwarnungs-Notizen der öffentlichen API waren doppelt maskiert (" statt ")
|
||||
if (!isset($done['php_0067_fix_api_resolve_notes'])) {
|
||||
try {
|
||||
$rows = $pdo->query("SELECT t.id, t.message FROM ticket_threads t JOIN tickets k ON k.id = t.ticket_id
|
||||
WHERE k.source = 'api' AND t.author_type = 'system' AND t.is_internal_note = 1
|
||||
AND (t.message LIKE '%"%' OR t.message LIKE '%&%' OR t.message LIKE '%'%')")->fetchAll();
|
||||
$upd = $pdo->prepare('UPDATE ticket_threads SET message = ? WHERE id = ?');
|
||||
foreach ($rows as $r) $upd->execute([html_entity_decode((string) $r['message'], ENT_QUOTES | ENT_HTML5, 'UTF-8'), $r['id']]);
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0067_fix_api_resolve_notes']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0067_fix_api_resolve_notes (' . count($rows) . ' Notizen repariert)';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0067_fix_api_resolve_notes: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0067_fix_api_resolve_notes - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Indizes für die Übersicht (letzte Aktivität) und die Ticketliste (Sortierung nach letzter Bearbeitung)
|
||||
if (!isset($done['php_0068_overview_indexes'])) {
|
||||
try {
|
||||
$hasIndex = function (string $table, string $name) use ($pdo): bool {
|
||||
$rows = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? "PRAGMA index_list($table)" : "SHOW INDEX FROM $table")->fetchAll();
|
||||
foreach ($rows as $r) if ((string) ($r['name'] ?? $r['Key_name'] ?? '') === $name) return true;
|
||||
return false;
|
||||
};
|
||||
if (!$hasIndex('activity_log', 'idx_activity_log_created')) $pdo->exec('CREATE INDEX idx_activity_log_created ON activity_log (created_at)');
|
||||
if (!$hasIndex('tickets', 'idx_tickets_updated')) $pdo->exec('CREATE INDEX idx_tickets_updated ON tickets (updated_at)');
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0068_overview_indexes']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0068_overview_indexes';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0068_overview_indexes: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0068_overview_indexes - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Statusseite: Dienste lassen sich als „intern“ markieren (werden geprüft, erscheinen aber nicht auf der öffentlichen Statusseite)
|
||||
if (!isset($done['php_0069_status_component_internal'])) {
|
||||
try {
|
||||
$cols = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
|
||||
$has = false;
|
||||
foreach ($cols as $col) if (($col['name'] ?? $col['Field'] ?? '') === 'is_internal') $has = true;
|
||||
if (!$has) $pdo->exec('ALTER TABLE status_components ADD COLUMN is_internal TINYINT NOT NULL DEFAULT 0');
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0069_status_component_internal']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0069_status_component_internal';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0069_status_component_internal: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0069_status_component_internal - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// E-Mails mit Formatierung und eingebetteten Bildern: HTML-Fassung der Nachricht, Content-ID und Kennzeichnung eingebetteter Bilder
|
||||
if (!isset($done['php_0070_mail_html'])) {
|
||||
try {
|
||||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$columns = function (string $table) use ($pdo, $drv): array {
|
||||
$rows = $pdo->query($drv === 'sqlite' ? "PRAGMA table_info($table)" : "SHOW COLUMNS FROM $table")->fetchAll();
|
||||
return array_map(fn($r) => (string) ($r['name'] ?? $r['Field'] ?? ''), $rows);
|
||||
};
|
||||
if (!in_array('message_html', $columns('ticket_threads'), true)) $pdo->exec('ALTER TABLE ticket_threads ADD COLUMN message_html MEDIUMTEXT NULL');
|
||||
$att = $columns('ticket_attachments');
|
||||
if (!in_array('content_id', $att, true)) $pdo->exec('ALTER TABLE ticket_attachments ADD COLUMN content_id VARCHAR(255) NULL');
|
||||
if (!in_array('is_inline', $att, true)) $pdo->exec('ALTER TABLE ticket_attachments ADD COLUMN is_inline TINYINT NOT NULL DEFAULT 0');
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0070_mail_html']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0070_mail_html';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0070_mail_html: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0070_mail_html - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0071: verknüpfte Tickets, zeitversetzt gesendete Antworten und Berichte per E-Mail nach Zeitplan.
|
||||
if (!isset($done['php_0071_links_schedules'])) {
|
||||
try {
|
||||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$ai = $drv === 'sqlite' ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||||
$eng = $drv === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4';
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_links (
|
||||
id $ai,
|
||||
ticket_id INT NOT NULL,
|
||||
linked_ticket_id INT NOT NULL,
|
||||
link_type VARCHAR(20) NOT NULL DEFAULT 'related',
|
||||
created_by INT NULL,
|
||||
created_at DATETIME NULL,
|
||||
UNIQUE (ticket_id, linked_ticket_id)
|
||||
)$eng");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS scheduled_replies (
|
||||
id $ai,
|
||||
ticket_id INT NOT NULL,
|
||||
staff_id INT NOT NULL,
|
||||
message MEDIUMTEXT NOT NULL,
|
||||
set_status VARCHAR(20) NOT NULL DEFAULT 'answered',
|
||||
resolution_code VARCHAR(40) NULL,
|
||||
resolution_note TEXT NULL,
|
||||
signature_choice VARCHAR(40) NULL,
|
||||
send_at DATETIME NOT NULL,
|
||||
state VARCHAR(12) NOT NULL DEFAULT 'pending',
|
||||
error TEXT NULL,
|
||||
created_at DATETIME NULL,
|
||||
sent_at DATETIME NULL
|
||||
)$eng");
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS report_schedules (
|
||||
id $ai,
|
||||
report_id INT NOT NULL,
|
||||
staff_id INT NOT NULL,
|
||||
recipients TEXT NOT NULL,
|
||||
frequency VARCHAR(10) NOT NULL DEFAULT 'weekly',
|
||||
weekday TINYINT NOT NULL DEFAULT 1,
|
||||
monthday TINYINT NOT NULL DEFAULT 1,
|
||||
send_time VARCHAR(5) NOT NULL DEFAULT '08:00',
|
||||
period VARCHAR(12) NOT NULL DEFAULT 'last_7',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1,
|
||||
next_run_at DATETIME NULL,
|
||||
last_sent_at DATETIME NULL,
|
||||
last_error TEXT NULL,
|
||||
created_at DATETIME NULL
|
||||
)$eng");
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0071_links_schedules']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0071_links_schedules';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0071_links_schedules: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0071_links_schedules - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0072: Merker „Mitarbeiter hat das Ticket gesehen“ für den Zähler in Chrome-Erweiterung und Windows-Programm.
|
||||
if (!isset($done['php_0072_ticket_acks'])) {
|
||||
try {
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS ticket_acks (
|
||||
staff_id INT NOT NULL,
|
||||
ticket_id INT NOT NULL,
|
||||
acked_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (staff_id, ticket_id)
|
||||
)" . ($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'));
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0072_ticket_acks']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0072_ticket_acks';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0072_ticket_acks: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0072_ticket_acks - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0073: weniger Berichts-Mails: nur bei Daten, nur bei Änderung, bei „täglich“ nur an Werktagen.
|
||||
if (!isset($done['php_0073_report_schedule_quiet'])) {
|
||||
try {
|
||||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$cols = array_map(fn($r) => (string) ($r['name'] ?? $r['Field'] ?? ''), $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(report_schedules)' : 'SHOW COLUMNS FROM report_schedules')->fetchAll());
|
||||
foreach (['only_if_data' => 'TINYINT NOT NULL DEFAULT 1', 'skip_unchanged' => 'TINYINT NOT NULL DEFAULT 1', 'weekdays_only' => 'TINYINT NOT NULL DEFAULT 0', 'last_hash' => 'VARCHAR(64) NULL', 'last_note' => 'VARCHAR(190) NULL'] as $col => $def) {
|
||||
if (!in_array($col, $cols, true)) $pdo->exec("ALTER TABLE report_schedules ADD COLUMN {$col} {$def}");
|
||||
}
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0073_report_schedule_quiet']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0073_report_schedule_quiet';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0073_report_schedule_quiet: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0073_report_schedule_quiet - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0074: Merker für die Erinnerung „Ticket wartet auf Antwort“ (eine Erinnerung je Kundennachricht).
|
||||
if (!isset($done['php_0074_wait_reminder'])) {
|
||||
try {
|
||||
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
|
||||
$cols = array_map(fn($r) => (string) ($r['name'] ?? $r['Field'] ?? ''), $pdo->query($drv === 'sqlite' ? 'PRAGMA table_info(tickets)' : 'SHOW COLUMNS FROM tickets')->fetchAll());
|
||||
if (!in_array('wait_reminded_at', $cols, true)) $pdo->exec('ALTER TABLE tickets ADD COLUMN wait_reminded_at DATETIME NULL');
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0074_wait_reminder']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0074_wait_reminder';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0074_wait_reminder: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0074_wait_reminder - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0075: Passkeys (WebAuthn) für die Anmeldung der Mitarbeiter.
|
||||
if (!isset($done['php_0075_staff_passkeys'])) {
|
||||
try {
|
||||
$sqlite = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite';
|
||||
$ai = $sqlite ? 'INTEGER PRIMARY KEY AUTOINCREMENT' : 'INT AUTO_INCREMENT PRIMARY KEY';
|
||||
$pdo->exec("CREATE TABLE IF NOT EXISTS staff_passkeys (
|
||||
id {$ai},
|
||||
staff_id INT NOT NULL,
|
||||
credential_id VARCHAR(512) NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
alg INT NOT NULL DEFAULT -7,
|
||||
sign_count BIGINT NOT NULL DEFAULT 0,
|
||||
name VARCHAR(80) NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
last_used_at DATETIME NULL
|
||||
)" . ($sqlite ? '' : ' ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'));
|
||||
$pdo->exec($sqlite ? 'CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_passkeys_cred ON staff_passkeys (credential_id)' : 'CREATE UNIQUE INDEX idx_staff_passkeys_cred ON staff_passkeys (credential_id(190))');
|
||||
$pdo->exec('CREATE INDEX idx_staff_passkeys_staff ON staff_passkeys (staff_id)');
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0075_staff_passkeys']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0075_staff_passkeys';
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0075_staff_passkeys: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0075_staff_passkeys - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// Migration 0076: vordefinierte Rollen (Co-Administrator, Support-Manager, IT, Teamleitung, Support 1st/2nd Level, …). Vorhandene Rollen bleiben unberührt.
|
||||
if (!isset($done['php_0076_default_roles'])) {
|
||||
try {
|
||||
require_once __DIR__ . '/../includes/permissions.php';
|
||||
$cols = array_map(fn($r) => (string) ($r['name'] ?? $r['Field'] ?? ''), $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(roles)' : 'SHOW COLUMNS FROM roles')->fetchAll());
|
||||
if (in_array('is_active', $cols, true) && in_array('notes', $cols, true) && in_array('updated_at', $cols, true)) {
|
||||
permission_seed_default_roles($pdo);
|
||||
$pdo->prepare('INSERT INTO schema_migrations (migration) VALUES (?)')->execute(['php_0076_default_roles']);
|
||||
$result['applied']++;
|
||||
if ($verbose) $result['log'][] = ' [OK] php_0076_default_roles';
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$result['errors'][] = 'php_0076_default_roles: ' . $e->getMessage();
|
||||
if ($verbose) $result['log'][] = ' [FAIL] php_0076_default_roles - ' . $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
@@ -16,6 +16,8 @@ function app_platforms(): array
|
||||
'windows' => ['label' => 'Windows', 'ext' => 'exe', 'stored' => 'DeskHub-Setup.exe', 'download' => 'DeskHub-Setup.exe', 'magic' => 'MZ', 'mime' => 'application/vnd.microsoft.portable-executable'],
|
||||
'android' => ['label' => 'Android', 'ext' => 'apk', 'stored' => 'DeskHub.apk', 'download' => 'DeskHub.apk', 'magic' => 'PK', 'mime' => 'application/vnd.android.package-archive'],
|
||||
'minecraft' => ['label' => 'Minecraft', 'ext' => 'jar', 'stored' => 'DeskHub-Minecraft.jar', 'download' => 'DeskHub-Minecraft.jar', 'magic' => 'PK', 'mime' => 'application/java-archive'],
|
||||
'wordpress' => ['label' => 'WordPress', 'ext' => 'zip', 'stored' => 'deskhub-tickets-wordpress.zip', 'download' => 'deskhub-tickets-wordpress.zip', 'magic' => 'PK', 'mime' => 'application/zip'],
|
||||
'chrome' => ['label' => 'Chrome', 'ext' => 'zip', 'stored' => 'deskhub-chrome-erweiterung.zip', 'download' => 'deskhub-chrome-erweiterung.zip', 'magic' => 'PK', 'mime' => 'application/zip'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -27,7 +29,7 @@ function app_downloads_dir(): string
|
||||
/** Official publisher release endpoint; the access key stays on the server. */
|
||||
function app_publisher_endpoint(string $platform, bool $download = false): string
|
||||
{
|
||||
if (!in_array($platform, ['android', 'windows', 'minecraft'], true) || !function_exists('catalog_defaults')) return '';
|
||||
if (!isset(app_platforms()[$platform]) || !function_exists('catalog_defaults')) return '';
|
||||
$base = (string) (catalog_defaults()['plugins_url'] ?? '');
|
||||
$parts = parse_url($base);
|
||||
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return '';
|
||||
|
||||
@@ -52,7 +52,9 @@ function staff_login(array $staff): void
|
||||
session_regenerate_id(true);
|
||||
$_SESSION['staff_id'] = $staff['id'];
|
||||
$_SESSION['staff_last_activity'] = time();
|
||||
$_SESSION['staff_login_at'] = time();
|
||||
staff_touch_activity(get_db(), (int) $staff['id']);
|
||||
login_guard_success($staff, 'web');
|
||||
}
|
||||
|
||||
function staff_logout(): void
|
||||
@@ -77,6 +79,11 @@ function current_staff(): ?array
|
||||
$stmt = get_db()->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
|
||||
$stmt->execute([$_SESSION['staff_id']]);
|
||||
$staff = $stmt->fetch();
|
||||
// „Das war ich nicht“ (Anmelde-Wächter): ältere Browser-Sitzungen dieses Kontos sind ungültig
|
||||
if ($staff && login_guard_session_revoked((int) $staff['id'])) {
|
||||
unset($_SESSION['staff_id']);
|
||||
return null;
|
||||
}
|
||||
return $cache = ($staff ?: null);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ require_once __DIR__ . '/time_tracking.php';
|
||||
require_once __DIR__ . '/pseudo_cron.php';
|
||||
require_once __DIR__ . '/auth.php';
|
||||
require_once __DIR__ . '/smtp_mailer.php';
|
||||
require_once __DIR__ . '/mail_queue.php';
|
||||
require_once __DIR__ . '/login_guard.php';
|
||||
require_once __DIR__ . '/vacation.php';
|
||||
require_once __DIR__ . '/mailer.php';
|
||||
require_once __DIR__ . '/mail_threading.php';
|
||||
require_once __DIR__ . '/email_addresses.php';
|
||||
require_once __DIR__ . '/health.php';
|
||||
require_once __DIR__ . '/admin_models.php';
|
||||
@@ -27,8 +31,18 @@ require_once __DIR__ . '/i18n.php';
|
||||
require_once __DIR__ . '/csat.php';
|
||||
require_once __DIR__ . '/status_page.php';
|
||||
require_once __DIR__ . '/ticket_templates.php';
|
||||
require_once __DIR__ . '/problems.php';
|
||||
require_once __DIR__ . '/ticket_links.php';
|
||||
require_once __DIR__ . '/ticket_attention.php';
|
||||
require_once __DIR__ . '/wait_reminders.php';
|
||||
require_once __DIR__ . '/my_week.php';
|
||||
require_once __DIR__ . '/signout.php';
|
||||
require_once __DIR__ . '/scheduled_replies.php';
|
||||
require_once __DIR__ . '/report_schedules.php';
|
||||
require_once __DIR__ . '/gdpr.php';
|
||||
require_once __DIR__ . '/plugins.php';
|
||||
require_once __DIR__ . '/app_downloads.php';
|
||||
require_once __DIR__ . '/permissions.php';
|
||||
require_once __DIR__ . '/theme_catalog.php';
|
||||
require_once __DIR__ . '/security_check.php';
|
||||
require_once __DIR__ . '/update_security.php';
|
||||
|
||||
@@ -76,7 +76,7 @@ function csat_mail_html(array $ticket, string $token): string
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$stars .= '<a href="' . e(csat_url($token, $i)) . '" style="display:inline-block;text-decoration:none;font-size:34px;line-height:1;color:#f5b301;padding:0 3px" title="' . $i . ' von 5">★</a>';
|
||||
}
|
||||
return '<h2 style="margin:0 0 12px;font-size:20px;color:#16202a">Wie zufrieden waren Sie?</h2>'
|
||||
return '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Wie zufrieden waren Sie?</h2>'
|
||||
. '<p style="margin:0 0 16px;color:#475569;font-size:15px;line-height:1.55">' . ($name !== '' ? 'Hallo ' . e($name) . ', ' : '')
|
||||
. 'Ihr Ticket <strong>[#' . e((string) $ticket['ticket_number']) . '] ' . e((string) $ticket['subject']) . '</strong> ist abgeschlossen. '
|
||||
. 'Eine kurze Bewertung hilft uns, besser zu werden. Es dauert nur einen Klick.</p>'
|
||||
|
||||
@@ -15,6 +15,10 @@ function config(): array
|
||||
if (getenv('DESKHUB_TESTING') === '1' && ($extra = json_decode((string) getenv('DESKHUB_CONFIG_JSON'), true)) && is_array($extra)) {
|
||||
$config = array_replace_recursive($config, $extra);
|
||||
}
|
||||
// Nur für Tests: Datenbankangaben (zum Beispiel eine MySQL-Testdatenbank) überschreiben alles andere, auch wenn der Test selbst DESKHUB_CONFIG_JSON setzt
|
||||
if (getenv('DESKHUB_TESTING') === '1' && ($testDbJson = json_decode((string) getenv('DESKHUB_TEST_DB_JSON'), true)) && is_array($testDbJson)) {
|
||||
$config['db'] = array_replace($config['db'] ?? [], $testDbJson);
|
||||
}
|
||||
// Nur für die Testläufe (tests/run_all.php setzt DESKHUB_TESTS_FORCE_SQLITE): nie die in config/config.php eingetragene Datenbank
|
||||
// (zum Beispiel die Produktiv-MySQL) benutzen, sondern SQLite. In Test-Webservern nur mit einer vom Test gesetzten Datenbankdatei.
|
||||
if (getenv('DESKHUB_TESTS_FORCE_SQLITE') === '1') {
|
||||
|
||||
@@ -188,7 +188,7 @@ function email_address_send_test(PDO $pdo, int $id, string $recipient): string
|
||||
$from = $addr['label'] ? "{$addr['label']} <{$addr['email']}>" : $addr['email'];
|
||||
$via = $addr['smtp_enabled'] ? 'eigenen SMTP-Server' : 'globalen SMTP-Server';
|
||||
$html = '<p>Dies ist eine Test-Mail von DeskHub.</p><p>Absender: ' . e($addr['email']) . '<br>Versand über: ' . e($via) . '</p>';
|
||||
if (!send_mail($recipient, 'DeskHub – Test-Mail von ' . $addr['email'], $html, $from)) {
|
||||
if (!send_mail($recipient, 'DeskHub – Test-Mail von ' . $addr['email'], $html, $from, false)) {
|
||||
throw new EmailAddressException('Versand fehlgeschlagen. Bitte SMTP-Daten prüfen. ' . smtp_last_error(), 'MAIL_FAILED', 502);
|
||||
}
|
||||
return "Test-Mail von {$addr['email']} an {$recipient} gesendet (über {$via}).";
|
||||
|
||||
@@ -311,13 +311,16 @@ function fcm_notify_staff(array $staffIds, array $data, ?int $departmentId = nul
|
||||
if (!$staffIds || !fcm_configured()) return;
|
||||
fcm_after_response(function () use ($staffIds, $data, $departmentId, $extraFilter) {
|
||||
$pdo = get_db();
|
||||
// Mitarbeiter im Urlaub: stattdessen bekommt ihre Vertretung die Meldung
|
||||
$staffIds = vacation_expand_ids($pdo, $staffIds);
|
||||
if (!$staffIds) return;
|
||||
// Rechte: Nur wer Zugriff auf die Abteilung des Tickets hat (und für diese Art von Push berechtigt ist), bekommt etwas
|
||||
if ($departmentId !== null) {
|
||||
$staffIds = fcm_staff_allowed($pdo, $staffIds, $departmentId, fcm_event_permission((string) ($data['type'] ?? '')), $extraFilter);
|
||||
if (!$staffIds) return;
|
||||
}
|
||||
$in = implode(',', array_fill(0, count($staffIds), '?'));
|
||||
$stmt = $pdo->prepare("SELECT d.id, d.token, d.staff_id FROM push_devices d JOIN staff s ON s.id = d.staff_id WHERE s.is_active = 1 AND d.staff_id IN ($in)");
|
||||
$stmt = $pdo->prepare("SELECT d.id, d.token, d.staff_id FROM push_devices d JOIN staff s ON s.id = d.staff_id WHERE s.is_active = 1 AND s.is_on_vacation = 0 AND d.staff_id IN ($in)");
|
||||
$stmt->execute($staffIds);
|
||||
// Einstellungen je Mitarbeiter: abgewählte Ereignisse und stille Zeiten überspringen
|
||||
$devices = array_values(array_filter($stmt->fetchAll(), fn($d) => fcm_wants(fcm_prefs($pdo, (int) $d['staff_id']), (string) ($data['type'] ?? ''))));
|
||||
@@ -371,7 +374,7 @@ function fcm_recipients_for_department(PDO $pdo, ?int $deptId): array
|
||||
/** Text der Benachrichtigung: standardmäßig nur mit Ticketnummer (privat), mit "Details" auch Betreff und Kunde. */
|
||||
function fcm_message(string $type, array $ticket): array
|
||||
{
|
||||
$titles = ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Antwort', 'ticket_assigned' => 'Ticket zugewiesen', 'chat_new' => 'Neuer Live-Chat', 'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Erwähnung', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket'];
|
||||
$titles = ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Antwort', 'ticket_assigned' => 'Ticket zugewiesen', 'chat_new' => 'Neuer Live-Chat', 'ticket_sla' => 'SLA überschritten', 'ticket_wait' => 'Ticket wartet auf Antwort', 'ticket_mention' => 'Erwähnung', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket'];
|
||||
$title = $titles[$type] ?? 'DeskHub';
|
||||
$number = (string) ($ticket['ticket_number'] ?? '');
|
||||
if (get_setting('fcm_show_details', '0') === '1') {
|
||||
@@ -431,7 +434,7 @@ function fcm_notify_assignment(array $ticket, int $staffId, ?int $actorId = null
|
||||
function fcm_event_types(): array
|
||||
{
|
||||
return ['ticket_new' => 'Neues Ticket', 'ticket_reply' => 'Neue Kundenantwort', 'ticket_assigned' => 'Ticket mir zugewiesen', 'chat_new' => 'Neuer Live-Chat',
|
||||
'ticket_sla' => 'SLA überschritten', 'ticket_mention' => 'Ich wurde erwähnt', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket aktualisiert'];
|
||||
'ticket_sla' => 'SLA überschritten', 'ticket_wait' => 'Ticket wartet auf meine Antwort', 'ticket_mention' => 'Ich wurde erwähnt', 'ticket_snooze' => 'Wiedervorlage fällig', 'ticket_follow' => 'Beobachtetes Ticket aktualisiert'];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -443,7 +446,7 @@ function fcm_event_types(): array
|
||||
function fcm_event_types_for(array $staff): array
|
||||
{
|
||||
$types = fcm_event_types();
|
||||
if (!has_permission($staff, 'reply_ticket')) unset($types['ticket_new'], $types['ticket_reply'], $types['chat_new']);
|
||||
if (!has_permission($staff, 'reply_ticket')) unset($types['ticket_new'], $types['ticket_reply'], $types['ticket_wait'], $types['chat_new']);
|
||||
if (get_setting('live_chat_enabled', '0') !== '1') unset($types['chat_new']);
|
||||
return $types;
|
||||
}
|
||||
@@ -499,6 +502,7 @@ function fcm_wants(array $prefs, string $type): bool
|
||||
function fcm_notify_overdue(array $ticket): void
|
||||
{
|
||||
if (!fcm_configured() || empty($ticket['ticket_number'])) return;
|
||||
if (function_exists('sla_notify_mode') && sla_notify_mode() === 'admins') return; // nur Administratoren und nur per E-Mail
|
||||
$dept = (int) ($ticket['department_id'] ?? 0);
|
||||
$assigned = (int) ($ticket['assigned_staff_id'] ?? 0);
|
||||
$to = fcm_recipients_for_department(get_db(), $dept ?: null);
|
||||
|
||||
@@ -222,6 +222,229 @@ function calc_sla_dates(array $priority, ?string $from = null, ?int $departmentI
|
||||
];
|
||||
}
|
||||
|
||||
function ticket_sla_is_paused_status(string $status): bool
|
||||
{
|
||||
return in_array($status, ['pending', 'on_hold'], true);
|
||||
}
|
||||
|
||||
function ticket_sla_history_add(PDO $pdo, int $ticketId, string $event, ?string $deadlineType, string $details, ?int $staffId = null, ?string $at = null): void
|
||||
{
|
||||
try {
|
||||
$pdo->prepare('INSERT INTO ticket_sla_history (ticket_id,event_type,deadline_type,details,staff_id,created_at) VALUES (?,?,?,?,?,?)')
|
||||
->execute([$ticketId, $event, $deadlineType, $details, $staffId, $at ?? db_now()]);
|
||||
} catch (Throwable $e) {
|
||||
error_log('DeskHub SLA-Verlauf konnte nicht gespeichert werden: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Seconds that count against an SLA during the interval, honoring business hours and holidays. */
|
||||
function ticket_sla_working_seconds_between(string $from, string $to): int
|
||||
{
|
||||
$start = strtotime($from); $end = strtotime($to);
|
||||
if ($start === false || $end === false || $end <= $start) return 0;
|
||||
if (get_setting('business_hours_enabled', '0') !== '1') return $end - $start;
|
||||
$pdo = get_db(); $hours = [];
|
||||
try {
|
||||
foreach ($pdo->query('SELECT weekday,is_open,open_time,close_time FROM business_hours')->fetchAll() as $row) $hours[(int) $row['weekday']] = $row;
|
||||
$holidays = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
|
||||
} catch (Throwable $e) { error_log('DeskHub SLA-Geschäftszeiten nicht lesbar; verwende Kalenderzeit: ' . $e->getMessage()); return $end - $start; }
|
||||
$zone = new DateTimeZone(date_default_timezone_get());
|
||||
$day = (new DateTimeImmutable('@' . $start))->setTimezone($zone)->setTime(0, 0);
|
||||
$lastDay = (new DateTimeImmutable('@' . $end))->setTimezone($zone)->setTime(0, 0);
|
||||
$seconds = 0; $iterations = 0;
|
||||
while ($day <= $lastDay && $iterations++ < 36600) {
|
||||
$w = (int) $day->format('w'); $cfg = $hours[$w] ?? null; $date = $day->format('Y-m-d');
|
||||
if ($cfg && !empty($cfg['is_open']) && !isset($holidays[$date])) {
|
||||
$open = (new DateTimeImmutable($date . ' ' . $cfg['open_time'], $zone))->getTimestamp();
|
||||
$close = (new DateTimeImmutable($date . ' ' . $cfg['close_time'], $zone))->getTimestamp();
|
||||
$seconds += max(0, min($end, $close) - max($start, $open));
|
||||
}
|
||||
$day = $day->modify('+1 day');
|
||||
}
|
||||
return $seconds;
|
||||
}
|
||||
|
||||
function ticket_sla_add_business_seconds(string $from, int $seconds): string
|
||||
{
|
||||
if ($seconds <= 0) return $from;
|
||||
$pdo = get_db(); $hours = [];
|
||||
try {
|
||||
foreach ($pdo->query('SELECT weekday,is_open,open_time,close_time FROM business_hours')->fetchAll() as $row) $hours[(int) $row['weekday']] = $row;
|
||||
$holidays = array_flip($pdo->query('SELECT holiday_date FROM business_holidays')->fetchAll(PDO::FETCH_COLUMN));
|
||||
} catch (Throwable $e) {
|
||||
error_log('DeskHub SLA-Geschäftszeiten nicht lesbar; verwende Kalenderzeit: ' . $e->getMessage());
|
||||
return (new DateTimeImmutable($from))->modify('+' . $seconds . ' seconds')->format('Y-m-d H:i:s');
|
||||
}
|
||||
$zone = new DateTimeZone(date_default_timezone_get());
|
||||
$cursor = new DateTimeImmutable($from, $zone); $iterations = 0;
|
||||
while ($seconds > 0 && $iterations++ < 36600) {
|
||||
$date = $cursor->format('Y-m-d'); $cfg = $hours[(int) $cursor->format('w')] ?? null;
|
||||
if (!$cfg || empty($cfg['is_open']) || isset($holidays[$date])) {
|
||||
$cursor = $cursor->modify('+1 day')->setTime(0, 0); continue;
|
||||
}
|
||||
$open = new DateTimeImmutable($date . ' ' . $cfg['open_time'], $zone);
|
||||
$close = new DateTimeImmutable($date . ' ' . $cfg['close_time'], $zone);
|
||||
if ($cursor < $open) $cursor = $open;
|
||||
if ($cursor >= $close) { $cursor = $cursor->modify('+1 day')->setTime(0, 0); continue; }
|
||||
$available = $close->getTimestamp() - $cursor->getTimestamp();
|
||||
if ($seconds <= $available) { $cursor = $cursor->modify('+' . $seconds . ' seconds'); $seconds = 0; }
|
||||
else { $seconds -= $available; $cursor = $cursor->modify('+1 day')->setTime(0, 0); }
|
||||
}
|
||||
return $cursor->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
function ticket_sla_shift_deadline(string $deadline, int $pauseSeconds): string
|
||||
{
|
||||
if ($pauseSeconds <= 0) return $deadline;
|
||||
if (get_setting('business_hours_enabled', '0') === '1') {
|
||||
return ticket_sla_add_business_seconds($deadline, $pauseSeconds);
|
||||
}
|
||||
return (new DateTimeImmutable($deadline))->modify('+' . $pauseSeconds . ' seconds')->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
function ticket_sla_completed_pause_seconds(PDO $pdo, int $ticketId): int
|
||||
{
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT event_type,created_at FROM ticket_sla_history WHERE ticket_id = ? AND event_type IN ('paused','resumed') ORDER BY created_at,id");
|
||||
$stmt->execute([$ticketId]);
|
||||
$started = null; $total = 0;
|
||||
foreach ($stmt->fetchAll() as $event) {
|
||||
if ($event['event_type'] === 'paused') $started = (string) $event['created_at'];
|
||||
elseif ($started !== null) { $total += ticket_sla_working_seconds_between($started, (string) $event['created_at']); $started = null; }
|
||||
}
|
||||
return $total;
|
||||
} catch (Throwable $e) { error_log('DeskHub SLA-Pausenzeiten nicht lesbar: ' . $e->getMessage()); return 0; }
|
||||
}
|
||||
|
||||
/** Startet die SLA eines wieder geöffneten Tickets neu: neue Lösungsfrist ab jetzt, Überfällig-Markierung weg. */
|
||||
function ticket_sla_restart(PDO $pdo, int $ticketId, ?int $staffId = null): void
|
||||
{
|
||||
$q = $pdo->prepare('SELECT t.priority_id, t.department_id, t.help_topic_id FROM tickets t WHERE t.id = ?');
|
||||
$q->execute([$ticketId]);
|
||||
$t = $q->fetch();
|
||||
if (!$t) return;
|
||||
$p = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
|
||||
$p->execute([(int) $t['priority_id']]);
|
||||
$priority = $p->fetch();
|
||||
if (!$priority) { $pdo->prepare('UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); return; }
|
||||
$now = db_now();
|
||||
$dates = calc_sla_dates($priority, $now, (int) $t['department_id'] ?: null, (int) $t['help_topic_id'] ?: null);
|
||||
$pdo->prepare('UPDATE tickets SET resolution_due_at = ?, is_overdue = 0, sla_paused_at = NULL, updated_at = updated_at WHERE id = ?')->execute([$dates['resolution_due_at'], $ticketId]);
|
||||
ticket_sla_history_add($pdo, $ticketId, 'restarted', 'resolution', 'SLA neu gestartet: Ticket wurde wieder geöffnet', $staffId, $now);
|
||||
}
|
||||
|
||||
/** Call after the ticket status has been persisted. pending/on_hold pause both SLA clocks. */
|
||||
function ticket_sla_status_transition_impl(PDO $pdo, int $ticketId, string $oldStatus, string $newStatus, ?int $staffId = null): void
|
||||
{
|
||||
if ($oldStatus === $newStatus) return;
|
||||
// Wieder geöffnet (zum Beispiel weil der Kunde auf ein gelöstes Ticket antwortet): Die alte Lösungsfrist ist längst abgelaufen und würde
|
||||
// sofort „SLA überschritten“ auslösen. Die SLA beginnt deshalb neu, mit den Zeiten der Priorität bzw. des SLA-Plans ab jetzt.
|
||||
$terminal = ['resolved', 'closed'];
|
||||
if (in_array($oldStatus, $terminal, true) && !in_array($newStatus, $terminal, true)) {
|
||||
ticket_sla_restart($pdo, $ticketId, $staffId);
|
||||
}
|
||||
// Erledigt (gelöst oder geschlossen) ist dasselbe: Die SLA zählt nicht weiter, „überfällig“ und eine Wiedervorlage entfallen
|
||||
if (!in_array($oldStatus, $terminal, true) && in_array($newStatus, $terminal, true)) {
|
||||
try { $pdo->prepare('UPDATE tickets SET is_overdue = 0, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); } catch (Throwable $e) { error_log('SLA erledigt: ' . $e->getMessage()); }
|
||||
try { $pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL, updated_at = updated_at WHERE id = ?')->execute([$ticketId]); } catch (Throwable $e) { /* Spalte fehlt vor dem Datenbank-Update */ }
|
||||
}
|
||||
$wasPaused = ticket_sla_is_paused_status($oldStatus); $willPause = ticket_sla_is_paused_status($newStatus);
|
||||
if (!$wasPaused && $willPause) {
|
||||
$now = db_now();
|
||||
$pdo->prepare('UPDATE tickets SET sla_paused_at = ? WHERE id = ?')->execute([$now, $ticketId]);
|
||||
ticket_sla_history_add($pdo, $ticketId, 'paused', null, 'Pausiert: ' . status_label($newStatus)[0], $staffId, $now);
|
||||
$q = $pdo->prepare('SELECT first_response_due_at,resolution_due_at,first_responded_at,resolved_at FROM tickets WHERE id = ?');
|
||||
$q->execute([$ticketId]); $deadlines = $q->fetch() ?: [];
|
||||
if (empty($deadlines['first_responded_at']) && !empty($deadlines['first_response_due_at']) && $deadlines['first_response_due_at'] < $now) {
|
||||
ticket_sla_log_overdue($pdo, $ticketId, 'first_response', (string) $deadlines['first_response_due_at']);
|
||||
}
|
||||
if (empty($deadlines['resolved_at']) && !empty($deadlines['resolution_due_at']) && $deadlines['resolution_due_at'] < $now) {
|
||||
ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $deadlines['resolution_due_at']);
|
||||
$pdo->prepare('UPDATE tickets SET is_overdue = 1, updated_at = updated_at WHERE id = ?')->execute([$ticketId]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ($wasPaused && !$willPause) {
|
||||
$stmt = $pdo->prepare('SELECT sla_paused_at,first_response_due_at,resolution_due_at,first_responded_at FROM tickets WHERE id = ?');
|
||||
$stmt->execute([$ticketId]); $row = $stmt->fetch();
|
||||
if (!$row) return;
|
||||
$now = db_now(); $started = (string) ($row['sla_paused_at'] ?? '');
|
||||
$seconds = $started !== '' ? ticket_sla_working_seconds_between($started, $now) : 0;
|
||||
$respondedBeforePause = !empty($row['first_responded_at']) && ($started === '' || (string) $row['first_responded_at'] < $started);
|
||||
$updates = []; $args = [];
|
||||
if ($seconds > 0) {
|
||||
foreach (['first_response_due_at' => 'first_response', 'resolution_due_at' => 'resolution'] as $field => $deadlineType) {
|
||||
$breach = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id=? AND event_type='overdue' AND deadline_type=?");
|
||||
$breach->execute([$ticketId, $deadlineType]);
|
||||
if (!empty($row[$field]) && !($deadlineType === 'first_response' && $respondedBeforePause) && !(int) $breach->fetchColumn()) {
|
||||
$updates[] = "$field = ?"; $args[] = ticket_sla_shift_deadline((string) $row[$field], $seconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
$updates[] = 'sla_paused_at = NULL'; $args[] = $ticketId;
|
||||
$pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($args);
|
||||
$minutes = (int) floor($seconds / 60);
|
||||
ticket_sla_history_add($pdo, $ticketId, 'resumed', null, 'Fortgesetzt nach ' . $minutes . ' Minuten Wartezeit', $staffId, $now);
|
||||
}
|
||||
}
|
||||
|
||||
function ticket_sla_status_transition(PDO $pdo, int $ticketId, string $oldStatus, string $newStatus, ?int $staffId = null): void
|
||||
{
|
||||
try {
|
||||
ticket_sla_status_transition_impl($pdo, $ticketId, $oldStatus, $newStatus, $staffId);
|
||||
} catch (Throwable $e) {
|
||||
error_log('DeskHub SLA-Pause konnte für Ticket #' . $ticketId . ' nicht aktualisiert werden: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
function ticket_sla_transition_rows(PDO $pdo, array $rows, string $newStatus, ?int $staffId = null): void
|
||||
{
|
||||
foreach ($rows as $row) ticket_sla_status_transition($pdo, (int) $row['id'], (string) $row['status'], $newStatus, $staffId);
|
||||
}
|
||||
|
||||
function ticket_sla_history_for_ticket(PDO $pdo, array $ticket): array
|
||||
{
|
||||
try {
|
||||
$ticketId = (int) $ticket['id'];
|
||||
$currentlyPaused = !empty($ticket['sla_paused_at']) || ticket_sla_is_paused_status((string) ($ticket['status'] ?? ''));
|
||||
if (!$currentlyPaused && !in_array((string) ($ticket['status'] ?? ''), ['resolved', 'closed'], true)) {
|
||||
$now = db_now();
|
||||
if (empty($ticket['first_responded_at']) && !empty($ticket['first_response_due_at']) && $ticket['first_response_due_at'] < $now) {
|
||||
ticket_sla_log_overdue($pdo, $ticketId, 'first_response', (string) $ticket['first_response_due_at']);
|
||||
}
|
||||
if (empty($ticket['resolved_at']) && !empty($ticket['resolution_due_at']) && $ticket['resolution_due_at'] < $now) {
|
||||
ticket_sla_log_overdue($pdo, $ticketId, 'resolution', (string) $ticket['resolution_due_at']);
|
||||
}
|
||||
}
|
||||
foreach ([['first_response_due_at', 'first_response', 'Erstantwort'], ['resolution_due_at', 'resolution', 'Lösung']] as [$field, $type, $label]) {
|
||||
if (empty($ticket[$field])) continue;
|
||||
$check = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id = ? AND event_type = 'started' AND deadline_type = ?");
|
||||
$check->execute([$ticketId, $type]);
|
||||
if (!(int) $check->fetchColumn()) {
|
||||
ticket_sla_history_add($pdo, $ticketId, 'started', $type, $label . '-Frist gestartet; fällig am ' . $ticket[$field], null, (string) $ticket['created_at']);
|
||||
} else {
|
||||
$pdo->prepare("UPDATE ticket_sla_history SET details = ? WHERE ticket_id = ? AND event_type = 'started' AND deadline_type = ? AND details LIKE 'SLA gestartet; Fristfeld:%'")
|
||||
->execute([$label . '-Frist gestartet; fällig am ' . $ticket[$field], $ticketId, $type]);
|
||||
}
|
||||
}
|
||||
$stmt = $pdo->prepare('SELECT h.*, s.name AS staff_name FROM ticket_sla_history h LEFT JOIN staff s ON s.id = h.staff_id WHERE h.ticket_id = ? ORDER BY h.created_at DESC, h.id DESC LIMIT 100');
|
||||
$stmt->execute([$ticketId]);
|
||||
return $stmt->fetchAll();
|
||||
} catch (Throwable $e) {
|
||||
error_log('DeskHub SLA-Verlauf konnte nicht geladen werden: ' . $e->getMessage());
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function ticket_sla_log_overdue(PDO $pdo, int $ticketId, string $deadlineType, string $dueAt): void
|
||||
{
|
||||
try {
|
||||
$check = $pdo->prepare("SELECT COUNT(*) FROM ticket_sla_history WHERE ticket_id = ? AND event_type = 'overdue' AND deadline_type = ?");
|
||||
$check->execute([$ticketId, $deadlineType]);
|
||||
if (!(int) $check->fetchColumn()) ticket_sla_history_add($pdo, $ticketId, 'overdue', $deadlineType, 'SLA-Frist überschritten', null, $dueAt);
|
||||
} catch (Throwable $e) { error_log('DeskHub SLA-Überschreitung konnte nicht protokolliert werden: ' . $e->getMessage()); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktiver SLA-Plan für ein Ticket: der Plan des Hilfethemas hat Vorrang vor dem der Abteilung.
|
||||
* null = kein Plan zugeordnet -> es gelten die Zeiten der Priorität.
|
||||
@@ -413,6 +636,18 @@ function staff_can_access_department(array $staff, int $departmentId): bool
|
||||
return $ids === null || in_array($departmentId, $ids, true);
|
||||
}
|
||||
|
||||
/** Ist der Mitarbeiter gerade abwesend (Urlaubsmodus)? Im Urlaub gibt es keine Benachrichtigungen (Mail, Push, Android). */
|
||||
function staff_is_on_vacation(int $staffId): bool
|
||||
{
|
||||
try {
|
||||
$q = get_db()->prepare('SELECT is_on_vacation FROM staff WHERE id = ?');
|
||||
$q->execute([$staffId]);
|
||||
return (int) $q->fetchColumn() === 1;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function staff_department_ids(array $staff): ?array
|
||||
{
|
||||
if ($staff['role'] === 'admin') {
|
||||
@@ -427,9 +662,56 @@ function staff_department_ids(array $staff): ?array
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$ids[] = (int) $row['department_id'];
|
||||
}
|
||||
foreach (staff_substitution_department_ids($staff) as $d) $ids[] = $d;
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/**
|
||||
* Abteilungen, auf die ein Mitarbeiter nur als Vertretung Zugriff hat: Ist ein Kollege im Urlaub (Profil → Abwesenheit oder Plugin
|
||||
* „Urlaub und Vertretung“) und hat diesen Mitarbeiter als Vertretung eingetragen, gelten dessen Abteilungen (Haupt- und zusätzliche) mit,
|
||||
* solange die Abwesenheit dauert. Ketten (A vertritt B, B ist selbst im Urlaub und von C vertreten) folgen bis zu drei Stufen.
|
||||
* Ist der Abwesende Administrator (sieht alle Abteilungen), kommen nur die Abteilungen der Tickets dazu, die der Vertretung gerade
|
||||
* zugewiesen sind. Ohne die Spalten (Datenbank noch nicht aktualisiert) ergibt sich eine leere Liste.
|
||||
* @return list<int>
|
||||
*/
|
||||
function staff_substitution_department_ids(array $staff): array
|
||||
{
|
||||
if (($staff['role'] ?? '') === 'admin' || empty($staff['id'])) return [];
|
||||
$pdo = get_db();
|
||||
$out = [];
|
||||
try {
|
||||
$frontier = [(int) $staff['id']];
|
||||
$seen = $frontier;
|
||||
for ($level = 0; $level < 3 && $frontier; $level++) {
|
||||
$in = implode(',', array_fill(0, count($frontier), '?'));
|
||||
$q = $pdo->prepare("SELECT id, role, department_id FROM staff WHERE is_active = 1 AND is_on_vacation = 1 AND vacation_substitute_id IN ($in)");
|
||||
$q->execute($frontier);
|
||||
$next = [];
|
||||
$adminAbsent = false;
|
||||
foreach ($q->fetchAll() as $abs) {
|
||||
$aid = (int) $abs['id'];
|
||||
if (in_array($aid, $seen, true)) continue;
|
||||
$seen[] = $aid;
|
||||
$next[] = $aid;
|
||||
if ($abs['role'] === 'admin') { $adminAbsent = true; continue; }
|
||||
if (!empty($abs['department_id'])) $out[] = (int) $abs['department_id'];
|
||||
$d = $pdo->prepare('SELECT department_id FROM staff_department WHERE staff_id = ?');
|
||||
$d->execute([$aid]);
|
||||
foreach ($d->fetchAll(PDO::FETCH_COLUMN) as $did) $out[] = (int) $did;
|
||||
}
|
||||
if ($adminAbsent) {
|
||||
$t = $pdo->prepare("SELECT DISTINCT department_id FROM tickets WHERE assigned_staff_id = ? AND status NOT IN ('resolved','closed')");
|
||||
$t->execute([(int) $staff['id']]);
|
||||
foreach ($t->fetchAll(PDO::FETCH_COLUMN) as $did) $out[] = (int) $did;
|
||||
}
|
||||
$frontier = $next;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
return array_values(array_unique(array_filter($out)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Beim Öffnen eines noch niemandem zugewiesenen Tickets wird es dem öffnenden Mitarbeiter zugewiesen (Einstellung "Beim Öffnen
|
||||
* automatisch zuweisen", Standard an). Nicht bei geschlossenen, gelösten, zusammengeführten oder als Spam markierten Tickets und nicht,
|
||||
@@ -530,6 +812,7 @@ function delete_ticket(PDO $pdo, array $ticket, ?int $deletedByStaffId): void
|
||||
$deletedByStaffId,
|
||||
db_now(),
|
||||
]);
|
||||
if (function_exists('ticket_links_purge')) ticket_links_purge($pdo, (int) $ticket['id']);
|
||||
$pdo->prepare('DELETE FROM tickets WHERE id = ?')->execute([$ticket['id']]);
|
||||
}
|
||||
|
||||
@@ -558,9 +841,39 @@ function ip_in_list(string $ip, array $list): bool
|
||||
}
|
||||
|
||||
/** Kommt die Anfrage direkt von einem als vertrauenswürdig eingetragenen Reverse-Proxy (config site → trusted_proxies)? */
|
||||
function cloudflare_proxy_ranges(): array
|
||||
{
|
||||
static $ranges = null;
|
||||
if ($ranges !== null) return $ranges;
|
||||
$ranges = [];
|
||||
try {
|
||||
$active = get_db()->prepare('SELECT is_active FROM plugins WHERE slug = ?');
|
||||
$active->execute(['cloudflare-protection']);
|
||||
if ((int) $active->fetchColumn() === 1) {
|
||||
$dynamic = json_decode((string) get_setting('plugin.cloudflare-protection.ranges', ''), true);
|
||||
if (is_array($dynamic)) $ranges = array_values(array_filter($dynamic, 'is_string'));
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// No dynamic Cloudflare networks are trusted if the plugin is not installed.
|
||||
}
|
||||
return $ranges;
|
||||
}
|
||||
|
||||
function trusted_proxy_ranges(): array
|
||||
{
|
||||
static $ranges = null;
|
||||
if ($ranges === null) {
|
||||
$ranges = array_values(array_unique(array_merge(
|
||||
array_map('strval', (array) (config()['site']['trusted_proxies'] ?? [])),
|
||||
cloudflare_proxy_ranges()
|
||||
)));
|
||||
}
|
||||
return $ranges;
|
||||
}
|
||||
|
||||
function request_from_trusted_proxy(): bool
|
||||
{
|
||||
$list = (array) (config()['site']['trusted_proxies'] ?? []);
|
||||
$list = trusted_proxy_ranges();
|
||||
return $list !== [] && ip_in_list((string) ($_SERVER['REMOTE_ADDR'] ?? ''), $list);
|
||||
}
|
||||
|
||||
@@ -580,7 +893,12 @@ function client_ip(): string
|
||||
{
|
||||
$remote = (string) ($_SERVER['REMOTE_ADDR'] ?? 'unknown');
|
||||
if (!request_from_trusted_proxy()) return $remote;
|
||||
$trusted = (array) (config()['site']['trusted_proxies'] ?? []);
|
||||
$cloudflareRanges = cloudflare_proxy_ranges();
|
||||
$cloudflareIp = trim((string) ($_SERVER['HTTP_CF_CONNECTING_IP'] ?? ''));
|
||||
if ($cloudflareRanges !== [] && ip_in_list($remote, $cloudflareRanges) && filter_var($cloudflareIp, FILTER_VALIDATE_IP)) {
|
||||
return $cloudflareIp;
|
||||
}
|
||||
$trusted = trusted_proxy_ranges();
|
||||
$chain = array_reverse(array_map('trim', explode(',', (string) ($_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''))));
|
||||
foreach ($chain as $ip) {
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP) && !ip_in_list($ip, $trusted)) return $ip;
|
||||
@@ -839,7 +1157,7 @@ function live_chat_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?stri
|
||||
* frisch von jemand anderem gehalten – siehe live_chat_locked_by_other()). */
|
||||
function live_chat_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void
|
||||
{
|
||||
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?')
|
||||
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ?, updated_at = updated_at WHERE id = ?')
|
||||
->execute([$staffId, db_now(), $ticketId]);
|
||||
}
|
||||
|
||||
@@ -877,7 +1195,7 @@ function ticket_locked_by_other(array $ticket, int $staffId, PDO $pdo): ?string
|
||||
* frisch von jemand anderem gehalten – siehe ticket_locked_by_other()). */
|
||||
function ticket_acquire_lock(int $ticketId, int $staffId, PDO $pdo): void
|
||||
{
|
||||
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ? WHERE id = ?')
|
||||
$pdo->prepare('UPDATE tickets SET locked_by = ?, locked_at = ?, updated_at = updated_at WHERE id = ?')
|
||||
->execute([$staffId, db_now(), $ticketId]);
|
||||
}
|
||||
|
||||
@@ -1043,6 +1361,16 @@ function get_allowed_extensions(): array
|
||||
return config()['uploads']['allowed_extensions'];
|
||||
}
|
||||
|
||||
/** Zusätzliche Dateiendungen, die nur für Anhänge eingehender E-Mails erlaubt sind (Einstellungen → Uploads); Standard: gängige Büro- und Bildformate. */
|
||||
const MAIL_ATTACHMENT_EXTRA_DEFAULT = 'doc, xls, ppt, pptx, odt, ods, odp, csv, rtf, msg, heic, webp';
|
||||
|
||||
/** @return string[] */
|
||||
function mail_attachment_extra_extensions(): array
|
||||
{
|
||||
$list = array_filter(array_map('trim', explode(',', strtolower(get_setting('mail_attachment_extra_extensions', MAIL_ATTACHMENT_EXTRA_DEFAULT)))));
|
||||
return array_values(array_unique(array_map(fn($e) => ltrim($e, '.'), $list)));
|
||||
}
|
||||
|
||||
/** Gibt die maximale Upload-Größe in Bytes zurück (aus DB-Setting oder config-Fallback). */
|
||||
function get_max_upload_size(): int
|
||||
{
|
||||
@@ -1053,10 +1381,16 @@ function get_max_upload_size(): int
|
||||
return config()['uploads']['max_size_bytes'];
|
||||
}
|
||||
|
||||
function handle_ticket_upload(array $file): array
|
||||
/**
|
||||
* @param bool $dryRun nur prüfen (Name, Endung, Größe, Inhalt), nichts speichern
|
||||
* @param array $extraAllowed zusätzlich erlaubte Endungen (nur für Dateien, die DeskHub selbst aus einer Mail übernimmt: Bilder, .eml)
|
||||
* @param bool $httpUpload true bei einem Formular-Upload (move_uploaded_file); false für eine Datei, die DeskHub selbst angelegt hat
|
||||
* (zum Beispiel ein Anhang aus einer abgerufenen E-Mail). Die Prüfungen sind in beiden Fällen dieselben.
|
||||
*/
|
||||
function handle_ticket_upload(array $file, bool $httpUpload = true, array $extraAllowed = [], bool $dryRun = false): array
|
||||
{
|
||||
$cfg = config()['uploads'];
|
||||
$allowed = get_allowed_extensions();
|
||||
$allowed = array_values(array_unique(array_merge(get_allowed_extensions(), $extraAllowed)));
|
||||
$maxSize = get_max_upload_size();
|
||||
|
||||
if ($file['error'] !== UPLOAD_ERR_OK) {
|
||||
@@ -1103,6 +1437,7 @@ function handle_ticket_upload(array $file): array
|
||||
return ['error' => 'Der Inhalt der Datei „' . $file['name'] . '“ sieht nach einem Skript aus und ist aus Sicherheitsgründen nicht erlaubt.'];
|
||||
}
|
||||
}
|
||||
if ($dryRun) return ['original_name' => $file['name'], 'stored_name' => '', 'filesize' => $file['size']];
|
||||
// Sicherer, zufälliger Dateiname ohne ursprüngliche Extension-Inferenz
|
||||
$storedName = bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
$uploadsDir = realpath(rtrim($cfg['path'], '/'));
|
||||
@@ -1110,12 +1445,55 @@ function handle_ticket_upload(array $file): array
|
||||
if (!$uploadsDir || strpos($dest, $uploadsDir) !== 0) {
|
||||
return ['error' => 'Ungültiger Upload-Pfad.'];
|
||||
}
|
||||
if (!move_uploaded_file($file['tmp_name'], $dest)) {
|
||||
$moved = $httpUpload ? move_uploaded_file($file['tmp_name'], $dest) : @copy($file['tmp_name'], $dest);
|
||||
if (!$moved) {
|
||||
return ['error' => 'Die Datei konnte nicht gespeichert werden.'];
|
||||
}
|
||||
return ['original_name' => $file['name'], 'stored_name' => $storedName, 'filesize' => $file['size']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Speichert einen Anhang aus einer E-Mail (Rohdaten) als Anhang einer Nachricht. Es gelten dieselben Regeln wie beim Upload
|
||||
* (erlaubte Endungen, Größe, gesperrte Typen).
|
||||
*
|
||||
* @return array{ok:bool,error?:string,name:string}
|
||||
*/
|
||||
function store_ticket_attachment_data(PDO $pdo, int $threadId, string $name, string $data, array $opts = []): array
|
||||
{
|
||||
$name = trim((string) preg_replace('/[\x00-\x1F\x7F]+/u', '', basename(str_replace(chr(92), '/', $name))));
|
||||
if ($name === '') $name = 'Anhang';
|
||||
$name = mb_substr($name, 0, 255);
|
||||
if ($data === '') return ['ok' => false, 'error' => 'Der Anhang ist leer.', 'name' => $name];
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'dhmail');
|
||||
if ($tmp === false || file_put_contents($tmp, $data) === false) return ['ok' => false, 'error' => 'Der Anhang konnte nicht zwischengespeichert werden.', 'name' => $name];
|
||||
$res = handle_ticket_upload(['name' => $name, 'type' => '', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => strlen($data)], false, (array) ($opts['extra_allowed'] ?? []));
|
||||
@unlink($tmp);
|
||||
if (isset($res['error'])) return ['ok' => false, 'error' => (string) $res['error'], 'name' => $name];
|
||||
if (!empty($opts['content_id']) && ticket_mail_html_ready($pdo)) {
|
||||
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at, content_id, is_inline) VALUES (?,?,?,?,?,?,?)')
|
||||
->execute([$threadId, $res['original_name'], $res['stored_name'], $res['filesize'], db_now(), mb_substr((string) $opts['content_id'], 0, 255), empty($opts['inline']) ? 0 : 1]);
|
||||
} else {
|
||||
$pdo->prepare('INSERT INTO ticket_attachments (thread_id, original_name, stored_name, filesize, created_at) VALUES (?,?,?,?,?)')
|
||||
->execute([$threadId, $res['original_name'], $res['stored_name'], $res['filesize'], db_now()]);
|
||||
}
|
||||
return ['ok' => true, 'name' => $name];
|
||||
}
|
||||
|
||||
/** Gibt es die Spalten für die HTML-Fassung von Mails (Datenbank-Update php_0070 eingespielt)? Bis dahin verhält sich alles wie bisher. */
|
||||
function ticket_mail_html_ready(PDO $pdo): bool
|
||||
{
|
||||
static $cache = [];
|
||||
$key = spl_object_id($pdo);
|
||||
if (isset($cache[$key])) return $cache[$key];
|
||||
try {
|
||||
$rows = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(ticket_threads)' : 'SHOW COLUMNS FROM ticket_threads')->fetchAll();
|
||||
foreach ($rows as $r) if (($r['name'] ?? $r['Field'] ?? '') === 'message_html') return $cache[$key] = true;
|
||||
} catch (Throwable $e) {
|
||||
// Tabelle fehlt: nichts zu tun
|
||||
}
|
||||
return $cache[$key] = false;
|
||||
}
|
||||
|
||||
/** Verarbeitet mehrere Datei-Uploads auf einmal (multi-file input).
|
||||
* Gibt ein Array von Ergebnissen zurück – jedes entweder mit 'error' oder mit den Upload-Daten. */
|
||||
function handle_ticket_uploads_multi(array $filesInput): array
|
||||
@@ -1229,8 +1607,26 @@ function build_report_table(PDO $pdo, string $dimColumn, string $from, string $t
|
||||
* der Seite zu einem Tag verbindet. Attributwerte werden vor der Prüfung entschlüsselt (Entities),
|
||||
* weil der Browser sie ebenfalls entschlüsselt, und beim Ausgeben neu maskiert.
|
||||
*/
|
||||
function sanitize_staff_html(string $html): string
|
||||
/**
|
||||
* Ob eine Nachricht HTML enthält (echte HTML-Tags wie <p>, <div>, <br>, <b> …) und deshalb als HTML dargestellt werden muss.
|
||||
* Eine Adresse in spitzen Klammern ("Olaf <olaf@gmx.de>") ist kein HTML: Wird sie dafür gehalten, verschwinden die Zeilenumbrüche
|
||||
* des Textes (HTML kennt keine).
|
||||
*/
|
||||
function message_is_html(string $text): bool
|
||||
{
|
||||
return (bool) preg_match('/<\/?(?:a|abbr|b|big|blockquote|body|br|caption|center|code|col|colgroup|div|em|figcaption|figure|font|h[1-6]|head|hr|html|i|img|li|meta|ol|p|pre|s|small|span|strike|strong|style|sub|sup|table|tbody|td|tfoot|th|thead|title|tr|u|ul|o:p)(?=[\s\/>])[^>]*>/i', $text);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{remote_images?:string,cid_map?:array<string,string>} $opts
|
||||
* remote_images: 'placeholder' = Bilder von fremden Adressen (https) werden nicht geladen, sondern durch einen Platzhalter ersetzt
|
||||
* (Datenschutz: ein Bild von außen verrät dem Absender, wann und von wo die Mail geöffnet wurde).
|
||||
* cid_map: Content-ID (klein, ohne <>) => Adresse des Bildes, das aus der Mail übernommen wurde (cid:-Bilder im Text)
|
||||
*/
|
||||
function sanitize_staff_html(string $html, array $opts = []): string
|
||||
{
|
||||
$placeholderRemote = ($opts['remote_images'] ?? '') === 'placeholder';
|
||||
$cidMap = (array) ($opts['cid_map'] ?? []);
|
||||
if (trim($html) === '') return '';
|
||||
|
||||
// Gefährliche Elemente komplett inkl. Inhalt entfernen
|
||||
@@ -1314,7 +1710,15 @@ function sanitize_staff_html(string $html): string
|
||||
$src = $attr($raw, 'src');
|
||||
if ($src !== null) {
|
||||
// Nur https, data:image (Rasterbilder) und cid: erlauben, alles andere durch ein leeres Bild ersetzen
|
||||
if (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);|cid:)#i', $src)) {
|
||||
$blank = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=';
|
||||
if (preg_match('#^cid:#i', $src)) {
|
||||
$cid = strtolower(trim(substr($src, 4), " \t\r\n<>"));
|
||||
if (isset($cidMap[$cid])) $out .= ' src="' . $e((string) $cidMap[$cid]) . '"';
|
||||
elseif ($placeholderRemote) $out .= ' src="' . $blank . '" title="Bild aus der E-Mail nicht verfügbar"';
|
||||
else $out .= ' src="' . $e($src) . '"';
|
||||
} elseif ($placeholderRemote && preg_match('#^https://#i', $src)) {
|
||||
$out .= ' src="' . $blank . '" title="Externes Bild aus Datenschutzgründen nicht geladen" data-blocked-remote="1"';
|
||||
} elseif (preg_match('#^(https://|data:image/(?:png|jpe?g|gif|webp);)#i', $src)) {
|
||||
$out .= ' src="' . $e($src) . '"';
|
||||
} else {
|
||||
$out .= ' src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEAAAAALAAAAAABAAEAAAI=" title="Bild aus Datenschutzgründen blockiert"';
|
||||
@@ -1513,7 +1917,11 @@ function apply_rule_close(PDO $pdo, int $ticketId, array $ruleResult): bool
|
||||
{
|
||||
if (empty($ruleResult['close'])) return false;
|
||||
$now = db_now();
|
||||
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
|
||||
$statusStmt = $pdo->prepare('SELECT status FROM tickets WHERE id = ?');
|
||||
$statusStmt->execute([$ticketId]);
|
||||
$oldStatus = (string) ($statusStmt->fetchColumn() ?: 'open');
|
||||
$pdo->prepare("UPDATE tickets SET status = 'closed', closed_at = ?, resolution_code = 'automated', resolution_note = 'Automatisch durch Ticket-Regel geschlossen', updated_at = ? WHERE id = ?")->execute([$now, $now, $ticketId]);
|
||||
ticket_sla_status_transition($pdo, $ticketId, $oldStatus, 'closed');
|
||||
log_activity($pdo, $ticketId, null, 'Ticket automatisch geschlossen (Ticket-Regel)');
|
||||
return true;
|
||||
}
|
||||
@@ -1806,60 +2214,7 @@ function add_business_hours(string $from, int $hours): string
|
||||
// Rollen & Berechtigungen
|
||||
// ====================================================================
|
||||
|
||||
/**
|
||||
* Standard-Berechtigungen je Admin/Agent-Rolle.
|
||||
* Kann durch eine benutzerdefinierte Rolle (roles.permissions) überschrieben werden.
|
||||
*/
|
||||
function default_permissions(string $role): array
|
||||
{
|
||||
if ($role === 'admin') {
|
||||
return array_fill_keys(array_merge([
|
||||
'reply_ticket','close_ticket','delete_ticket','transfer_ticket',
|
||||
'post_note','assign_ticket','manage_canned','view_reports',
|
||||
'manage_kb','create_ticket',
|
||||
], array_keys(admin_section_permissions())), true);
|
||||
}
|
||||
// Standard-Agent
|
||||
return array_merge([
|
||||
'reply_ticket' => true,
|
||||
'close_ticket' => true,
|
||||
'delete_ticket' => false,
|
||||
'transfer_ticket' => true,
|
||||
'post_note' => true,
|
||||
'assign_ticket' => true,
|
||||
'manage_canned' => false,
|
||||
'view_reports' => true,
|
||||
'manage_kb' => false,
|
||||
'create_ticket' => true,
|
||||
], array_fill_keys(array_keys(admin_section_permissions()), false));
|
||||
}
|
||||
|
||||
/**
|
||||
* Feinkörnige Rechte für einzelne Adminportal-Bereiche – damit ein Admin einem Agenten
|
||||
* (per benutzerdefinierter Rolle, siehe staff/roles.php) gezielt einzelne Admin-Funktionen
|
||||
* freigeben kann, ohne ihm die volle Administrator-Rolle zu geben ("Co-Admin").
|
||||
* Admins haben über has_permission() ohnehin immer automatisch alle Rechte.
|
||||
*/
|
||||
function admin_section_permissions(): array
|
||||
{
|
||||
return [
|
||||
'manage_departments' => 'Abteilungen verwalten',
|
||||
'manage_priorities' => 'Prioritäten / SLA verwalten',
|
||||
'manage_help_topics' => 'Hilfe-Themen verwalten',
|
||||
'manage_kb' => 'Wissensdatenbank verwalten',
|
||||
'manage_email_addresses' => 'E-Mail-Adressen verwalten',
|
||||
'manage_email_templates' => 'E-Mail-Vorlagen verwalten',
|
||||
'manage_organizations' => 'Organisationen verwalten',
|
||||
'manage_teams' => 'Teams verwalten',
|
||||
'manage_roles' => 'Rollen verwalten',
|
||||
'manage_staff' => 'Mitarbeiter verwalten',
|
||||
'view_audit_log' => 'Admin-Protokoll einsehen',
|
||||
'manage_escalations' => 'Eskalationsregeln verwalten',
|
||||
'view_time_entries' => 'Zeiterfassung einsehen',
|
||||
'manage_business_hours' => 'Geschäftszeiten verwalten',
|
||||
'manage_assistant' => 'Digitalen Assistenten verwalten',
|
||||
];
|
||||
}
|
||||
// Rechte-Katalog, Standardrechte und Vorlagen: siehe includes/permissions.php (permission_catalog(), default_permissions(), admin_section_permissions())
|
||||
|
||||
/**
|
||||
* Prüft ob ein Mitarbeiter eine bestimmte Berechtigung hat.
|
||||
@@ -1894,7 +2249,8 @@ function has_permission(array $staff, string $permission, ?int $departmentId = n
|
||||
$roleCache[$rid] = ($row && ($row['is_active'] ?? 1)) ? (json_decode((string) $row['permissions'], true) ?? []) : null;
|
||||
}
|
||||
$perms = $roleCache[$rid];
|
||||
if ($perms !== null) return (bool) ($perms[$permission] ?? false);
|
||||
// Fehlt ein Recht in der gespeicherten Rolle: ein altes Recht gilt wie bisher als „nein“, ein neueres mit dem Standardwert des Katalogs
|
||||
if ($perms !== null) return array_key_exists($permission, $perms) ? (bool) $perms[$permission] : (!in_array($permission, permission_legacy_keys(), true) && (bool) (default_permissions('agent')[$permission] ?? false));
|
||||
}
|
||||
|
||||
$defaults = default_permissions('agent');
|
||||
@@ -1928,8 +2284,10 @@ function save_mentions(PDO $pdo, int $ticketId, int $threadId, int $byStaffId, s
|
||||
->execute([$ticketId, $threadId, $mentionedId, $byStaffId, db_now()]);
|
||||
fcm_notify_mention($ticket, $mentionedId, $byStaffId); // Push auf die Android-App
|
||||
// E-Mail an erwähnten Mitarbeiter
|
||||
// Im Urlaub geht die Mail an die Vertretung
|
||||
$mailTo = vacation_expand_ids($pdo, [$mentionedId]);
|
||||
$stmt = $pdo->prepare('SELECT email, name FROM staff WHERE id=?');
|
||||
$stmt->execute([$mentionedId]);
|
||||
$stmt->execute([$mailTo ? $mailTo[0] : 0]);
|
||||
$mentioned = $stmt->fetch();
|
||||
if ($mentioned) {
|
||||
$by = $pdo->prepare('SELECT name FROM staff WHERE id=?');
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
/**
|
||||
* Datenschutz-Werkzeuge für Auskunft und Löschung auf Anfrage eines Kunden (DSGVO Art. 15 und 17).
|
||||
* Nur für Administratoren und nur, wenn unter Einstellungen → Datenschutz eingeschaltet (gdpr_tools_enabled).
|
||||
*
|
||||
* - gdpr_customer_export(): alle zum Kunden gespeicherten Daten als Struktur (wird als JSON-Datei geliefert)
|
||||
* - gdpr_customer_erase(): anonymisiert Tickets, entfernt Nachrichten und Anhänge des Kunden, Notizen und das Kundenkonto
|
||||
*
|
||||
* Ein Kunde ist entweder ein Konto (user_id) oder ein Gast, der nur über seine E-Mail-Adresse in Tickets vorkommt.
|
||||
*/
|
||||
|
||||
function gdpr_tools_enabled(): bool
|
||||
{
|
||||
return get_setting('gdpr_tools_enabled', '0') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{user_id?:int,email?:string} $ref
|
||||
* @return array{email:string,user:?array,tickets:list<array>}|null
|
||||
*/
|
||||
function gdpr_resolve_customer(PDO $pdo, array $ref): ?array
|
||||
{
|
||||
$user = null;
|
||||
$email = trim((string) ($ref['email'] ?? ''));
|
||||
if (!empty($ref['user_id'])) {
|
||||
$stmt = $pdo->prepare('SELECT u.*, o.name AS org_name FROM users u LEFT JOIN organizations o ON o.id = u.organization_id WHERE u.id = ?');
|
||||
$stmt->execute([(int) $ref['user_id']]);
|
||||
$user = $stmt->fetch() ?: null;
|
||||
if (!$user) return null;
|
||||
$email = (string) $user['email'];
|
||||
}
|
||||
if ($email === '') return null;
|
||||
$stmt = $pdo->prepare('SELECT * FROM tickets WHERE requester_email = ? OR (user_id IS NOT NULL AND user_id = ?) ORDER BY created_at');
|
||||
$stmt->execute([$email, $user ? (int) $user['id'] : 0]);
|
||||
$tickets = $stmt->fetchAll();
|
||||
if (!$user && !$tickets) return null;
|
||||
return ['email' => $email, 'user' => $user, 'tickets' => $tickets];
|
||||
}
|
||||
|
||||
function gdpr_plain(string $html): string
|
||||
{
|
||||
$t = preg_replace('~<(br|/p|/div|/li|/tr)\b[^>]*>~i', "\n", $html) ?? $html;
|
||||
return trim(html_entity_decode(strip_tags($t), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
}
|
||||
|
||||
/** Alle gespeicherten Daten zu einem Kunden. @return array<string,mixed>|null */
|
||||
function gdpr_customer_export(PDO $pdo, array $ref): ?array
|
||||
{
|
||||
$c = gdpr_resolve_customer($pdo, $ref);
|
||||
if ($c === null) return null;
|
||||
$user = $c['user'];
|
||||
$out = [
|
||||
'erstellt_am' => date('c'),
|
||||
'hinweis' => 'Auskunft nach Art. 15 DSGVO. Interne Notizen der Mitarbeiter sind als intern gekennzeichnet und vor der Weitergabe zu prüfen. Dateiinhalte der Anhänge sind nicht enthalten, nur deren Namen.',
|
||||
'kunde' => [
|
||||
'name' => $user['name'] ?? ($c['tickets'][0]['requester_name'] ?? ''),
|
||||
'email' => $c['email'],
|
||||
'telefon' => $user['phone'] ?? null,
|
||||
'organisation' => $user['org_name'] ?? null,
|
||||
'konto_angelegt_am' => $user['created_at'] ?? null,
|
||||
'hat_konto' => $user !== null,
|
||||
],
|
||||
'tickets' => [],
|
||||
'notizen_zum_kunden' => [],
|
||||
];
|
||||
$dept = $pdo->prepare('SELECT name FROM departments WHERE id = ?');
|
||||
$threads = $pdo->prepare('SELECT t.*, s.name AS staff_name FROM ticket_threads t LEFT JOIN staff s ON s.id = t.staff_id WHERE t.ticket_id = ? ORDER BY t.created_at, t.id');
|
||||
$atts = $pdo->prepare('SELECT a.original_name, a.filesize, a.created_at FROM ticket_attachments a JOIN ticket_threads t ON t.id = a.thread_id WHERE t.ticket_id = ? ORDER BY a.id');
|
||||
$collab = $pdo->prepare('SELECT email, name FROM ticket_collaborators WHERE ticket_id = ?');
|
||||
$rating = $pdo->prepare('SELECT rating, comment, created_at FROM ticket_ratings WHERE ticket_id = ?');
|
||||
$csat = $pdo->prepare('SELECT rating, comment, answered_at FROM ticket_csat WHERE ticket_id = ? AND answered_at IS NOT NULL');
|
||||
foreach ($c['tickets'] as $t) {
|
||||
$dept->execute([(int) $t['department_id']]);
|
||||
$threads->execute([(int) $t['id']]);
|
||||
$atts->execute([(int) $t['id']]);
|
||||
$collab->execute([(int) $t['id']]);
|
||||
$rating->execute([(int) $t['id']]);
|
||||
$csat->execute([(int) $t['id']]);
|
||||
$messages = [];
|
||||
foreach ($threads->fetchAll() as $m) {
|
||||
$messages[] = [
|
||||
'zeitpunkt' => $m['created_at'],
|
||||
'von' => $m['author_type'] === 'customer' ? 'Kunde' : ($m['author_type'] === 'staff' ? 'Mitarbeiter' : 'System'),
|
||||
'mitarbeiter' => $m['author_type'] === 'staff' ? ($m['staff_name'] ?? null) : null,
|
||||
'intern' => (bool) $m['is_internal_note'],
|
||||
'text' => gdpr_plain((string) $m['message']),
|
||||
];
|
||||
}
|
||||
$out['tickets'][] = [
|
||||
'nummer' => $t['ticket_number'], 'betreff' => $t['subject'], 'status' => $t['status'], 'quelle' => $t['source'],
|
||||
'abteilung' => (string) ($dept->fetchColumn() ?: ''), 'angelegt_am' => $t['created_at'], 'geschlossen_am' => $t['closed_at'] ?: null,
|
||||
'name_im_ticket' => $t['requester_name'], 'email_im_ticket' => $t['requester_email'], 'ip_adresse' => $t['requester_ip'] ?: null,
|
||||
'nachrichten' => $messages,
|
||||
'anhaenge' => array_map(fn($a) => ['name' => $a['original_name'], 'groesse_bytes' => (int) $a['filesize'], 'hinzugefuegt_am' => $a['created_at']], $atts->fetchAll()),
|
||||
'mitleser' => $collab->fetchAll(PDO::FETCH_ASSOC),
|
||||
'bewertung' => $rating->fetch(PDO::FETCH_ASSOC) ?: ($csat->fetch(PDO::FETCH_ASSOC) ?: null),
|
||||
];
|
||||
}
|
||||
if ($user) {
|
||||
$n = $pdo->prepare('SELECT note, created_at FROM customer_notes WHERE user_id = ? ORDER BY created_at');
|
||||
$n->execute([(int) $user['id']]);
|
||||
$out['notizen_zum_kunden'] = array_map(fn($r) => ['zeitpunkt' => $r['created_at'], 'text' => $r['note']], $n->fetchAll());
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht die personenbezogenen Daten eines Kunden. Ticketnummer, Betreff, Status und Zeiten bleiben für die Statistik erhalten.
|
||||
* Offene Tickets verhindern die Löschung, solange $force nicht gesetzt ist.
|
||||
* @return array{error:?string,tickets:int,messages:int,files:int}
|
||||
*/
|
||||
function gdpr_customer_erase(PDO $pdo, array $ref, bool $force = false): array
|
||||
{
|
||||
$res = ['error' => null, 'tickets' => 0, 'messages' => 0, 'files' => 0];
|
||||
$c = gdpr_resolve_customer($pdo, $ref);
|
||||
if ($c === null) { $res['error'] = 'Kunde nicht gefunden.'; return $res; }
|
||||
$open = array_filter($c['tickets'], fn($t) => !in_array($t['status'], ['resolved', 'closed'], true));
|
||||
if ($open && !$force) {
|
||||
$res['error'] = count($open) . ' Ticket(s) sind noch offen. Bitte zuerst schließen oder die Löschung ausdrücklich erzwingen.';
|
||||
return $res;
|
||||
}
|
||||
$user = $c['user'];
|
||||
$email = $c['email'];
|
||||
$anon = 'anon_' . substr(hash('sha256', $email . 'deskhub-anon'), 0, 16) . '@deleted.local';
|
||||
$uploads = realpath((string) (config()['uploads']['path'] ?? '')) ?: '';
|
||||
$files = [];
|
||||
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
foreach ($c['tickets'] as $t) {
|
||||
$tid = (int) $t['id'];
|
||||
// Dateien der Kunden-Nachrichten merken und Zeilen entfernen
|
||||
$stmt = $pdo->prepare("SELECT a.id, a.stored_name FROM ticket_attachments a JOIN ticket_threads th ON th.id = a.thread_id WHERE th.ticket_id = ? AND th.author_type = 'customer'");
|
||||
$stmt->execute([$tid]);
|
||||
foreach ($stmt->fetchAll() as $a) {
|
||||
$files[] = (string) $a['stored_name'];
|
||||
$pdo->prepare('DELETE FROM ticket_attachments WHERE id = ?')->execute([(int) $a['id']]);
|
||||
}
|
||||
$hasHtml = function_exists('ticket_mail_html_ready') && ticket_mail_html_ready($pdo);
|
||||
$upd = $pdo->prepare("UPDATE ticket_threads SET message = ?, " . ($hasHtml ? 'message_html = NULL, ' : '') . "user_id = NULL WHERE ticket_id = ? AND author_type = 'customer'");
|
||||
$upd->execute(['[Auf Anfrage des Betroffenen gelöscht]', $tid]);
|
||||
$res['messages'] += $upd->rowCount();
|
||||
$pdo->prepare('DELETE FROM ticket_collaborators WHERE ticket_id = ? AND LOWER(email) = LOWER(?)')->execute([$tid, $email]);
|
||||
$pdo->prepare('UPDATE ticket_ratings SET comment = NULL, user_id = NULL WHERE ticket_id = ?')->execute([$tid]);
|
||||
$pdo->prepare('UPDATE ticket_csat SET comment = NULL WHERE ticket_id = ?')->execute([$tid]);
|
||||
$pdo->prepare("UPDATE tickets SET requester_name = 'Gelöscht', requester_email = ?, requester_ip = '0.0.0.0', user_id = NULL WHERE id = ?")->execute([$anon, $tid]);
|
||||
$res['tickets']++;
|
||||
}
|
||||
if ($user) {
|
||||
$uid = (int) $user['id'];
|
||||
$pdo->prepare('DELETE FROM customer_notes WHERE user_id = ?')->execute([$uid]);
|
||||
$pdo->prepare('UPDATE tickets SET user_id = NULL WHERE user_id = ?')->execute([$uid]);
|
||||
$pdo->prepare('UPDATE ticket_threads SET user_id = NULL WHERE user_id = ?')->execute([$uid]);
|
||||
$pdo->prepare('DELETE FROM users WHERE id = ?')->execute([$uid]);
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
$res['error'] = 'Löschen fehlgeschlagen: ' . $e->getMessage();
|
||||
return $res;
|
||||
}
|
||||
// Dateien erst nach erfolgreichem Abschluss löschen (nur innerhalb des Upload-Ordners)
|
||||
foreach (array_unique($files) as $name) {
|
||||
if ($name === '' || $uploads === '' || $name !== basename($name)) continue;
|
||||
$path = $uploads . DIRECTORY_SEPARATOR . $name;
|
||||
if (is_file($path) && @unlink($path)) $res['files']++;
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
@@ -109,6 +109,12 @@ function system_health(PDO $pdo): array
|
||||
$issues[] = ['level' => 'warning', 'code' => 'send_failed', 'message' => 'Der letzte E-Mail-Versand ist fehlgeschlagen (' . date('d.m.Y H:i', strtotime($at)) . '): ' . $msg];
|
||||
}
|
||||
}
|
||||
// Mails, die nicht rausgingen: warten in der Warteschlange oder wurden aufgegeben
|
||||
if (function_exists('mail_queue_counts')) {
|
||||
$q = mail_queue_counts($pdo);
|
||||
if ($q['failed'] > 0) $issues[] = ['level' => 'error', 'code' => 'mail_queue_failed', 'message' => $q['failed'] . ' Mail(s) konnten auch nach mehreren Versuchen nicht gesendet werden. Unter Verwalten → Mail-Warteschlange nachsenden oder löschen.'];
|
||||
elseif ($q['pending'] > 0) $issues[] = ['level' => 'warning', 'code' => 'mail_queue_pending', 'message' => $q['pending'] . ' Mail(s) warten in der Warteschlange und werden erneut versucht (Verwalten → Mail-Warteschlange).'];
|
||||
}
|
||||
return $issues;
|
||||
}
|
||||
|
||||
|
||||
@@ -95,15 +95,87 @@ function i18n_current(): string
|
||||
* Wörterbuch einer Sprache: exakte Texte und Muster mit "{}".
|
||||
* @return array{exact:array<string,string>,patterns:list<array{0:string,1:string}>,count:int}
|
||||
*/
|
||||
function i18n_dictionary(string $code): array
|
||||
function i18n_dictionary(string $code, bool $reload = false): array
|
||||
{
|
||||
static $cache = [];
|
||||
if ($reload) unset($cache[$code]);
|
||||
if (isset($cache[$code])) return $cache[$code];
|
||||
$dict = ['exact' => [], 'patterns' => [], 'count' => 0];
|
||||
$file = i18n_languages()[$code]['strings'] ?? '';
|
||||
if ($file === '' || !is_file($file) || filesize($file) > I18N_MAX_STRINGS_BYTES) return $cache[$code] = $dict;
|
||||
$files = [];
|
||||
// Zuerst die eigenen Übersetzungen der aktiven Plugins (lang/<code>.json), danach das Sprachpaket: Es gewinnt bei gleichem Text,
|
||||
// damit sich ein Plugin-Text im Sprachpaket verbessern lässt.
|
||||
foreach (i18n_plugin_string_files($code) as $f) $files[] = $f;
|
||||
$packFile = i18n_languages()[$code]['strings'] ?? '';
|
||||
if ($packFile !== '') $files[] = $packFile;
|
||||
foreach ($files as $file) i18n_load_strings_file($file, $dict);
|
||||
// Genauere (längere) Muster zuerst: Sonst würde ein allgemeines wie „Ticket #{}“ ein spezielleres wie „Ticket #{} wurde nicht gefunden.“ überdecken
|
||||
if (count($dict['patterns']) > 1) {
|
||||
$order = array_keys($dict['patterns']);
|
||||
usort($order, fn($x, $y) => strlen((string) ($dict['pkeys'][$y] ?? '')) <=> strlen((string) ($dict['pkeys'][$x] ?? '')));
|
||||
$dict['patterns'] = array_map(fn($i) => $dict['patterns'][$i], $order);
|
||||
$dict['pkeys'] = array_map(fn($i) => $dict['pkeys'][$i] ?? '', $order);
|
||||
}
|
||||
return $cache[$code] = $dict;
|
||||
}
|
||||
|
||||
/**
|
||||
* Übersetzungsdateien der aktiven Plugins für eine Sprache: <Plugin>/lang/<code>.json (gleiches Format wie strings.json eines Sprachpakets).
|
||||
* So bringt jedes Plugin seine Texte selbst mit; eine neue Sprache braucht dann nur die Datei lang/<code>.json im Plugin.
|
||||
* @return array<string,string> Plugin-Kennung => Dateipfad
|
||||
*/
|
||||
function i18n_plugin_string_files(string $code): array
|
||||
{
|
||||
if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code) || $code === 'de') return [];
|
||||
$slugs = [];
|
||||
try {
|
||||
if (function_exists('plugins_globally_disabled') && plugins_globally_disabled()) return [];
|
||||
foreach (get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN) as $slug) {
|
||||
if (plugin_valid_slug((string) $slug)) $slugs[] = (string) $slug;
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
$out = [];
|
||||
foreach ($slugs as $slug) {
|
||||
$file = plugins_dir() . '/' . $slug . '/lang/' . $code . '.json';
|
||||
if (is_file($file)) $out[$slug] = $file;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Welche Plugins bringen für welche Sprachen eigene Übersetzungen mit? (Anzeige unter Sprachen)
|
||||
* @return array<string,array<string,int>> Plugin-Kennung => [Sprachcode => Anzahl Texte]
|
||||
*/
|
||||
function i18n_plugin_language_overview(): array
|
||||
{
|
||||
$out = [];
|
||||
try {
|
||||
$slugs = get_db()->query('SELECT slug FROM plugins WHERE is_active = 1 ORDER BY slug')->fetchAll(PDO::FETCH_COLUMN);
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
foreach ($slugs as $slug) {
|
||||
$slug = (string) $slug;
|
||||
if (!plugin_valid_slug($slug)) continue;
|
||||
foreach (glob(plugins_dir() . '/' . $slug . '/lang/*.json') ?: [] as $f) {
|
||||
$code = strtolower(basename($f, '.json'));
|
||||
if (!preg_match('/^[a-z]{2,3}(-[a-z]{2,4})?$/', $code) || filesize($f) > I18N_MAX_STRINGS_BYTES) continue;
|
||||
$data = json_decode((string) file_get_contents($f), true);
|
||||
$n = 0;
|
||||
if (is_array($data)) foreach ($data as $k => $v) if (is_string($v) && $v !== '' && (string) $k !== '' && ((string) $k)[0] !== '_') $n++;
|
||||
$out[$slug][$code] = $n;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Liest eine strings.json (Schlüssel = deutscher Text) in ein Wörterbuch ein; spätere Dateien überschreiben frühere. */
|
||||
function i18n_load_strings_file(string $file, array &$dict): void
|
||||
{
|
||||
if ($file === '' || !is_file($file) || filesize($file) > I18N_MAX_STRINGS_BYTES) return;
|
||||
$data = json_decode((string) file_get_contents($file), true);
|
||||
if (!is_array($data)) return $cache[$code] = $dict;
|
||||
if (!is_array($data)) return;
|
||||
foreach ($data as $key => $value) {
|
||||
$key = (string) $key;
|
||||
if (!is_string($value) || $value === '' || $key === '' || $key[0] === '_') continue;
|
||||
@@ -114,13 +186,18 @@ function i18n_dictionary(string $code): array
|
||||
$regex = '/^';
|
||||
foreach ($pieces as $k => $piece) $regex .= $k % 2 === 0 ? preg_quote($piece, '/') : ($piece === '{#}' ? '(\d+)' : '(.+?)');
|
||||
$regex .= '$/su';
|
||||
// gleicher Schlüssel in einer späteren Datei ersetzt das frühere Muster
|
||||
foreach ($dict['patterns'] as $i => $p) if (($dict['pkeys'][$i] ?? null) === $norm) { unset($dict['patterns'][$i], $dict['pkeys'][$i]); $dict['count']--; }
|
||||
$dict['patterns'] = array_values($dict['patterns']);
|
||||
$dict['pkeys'] = array_values($dict['pkeys'] ?? []);
|
||||
$dict['patterns'][] = [$regex, $value];
|
||||
$dict['pkeys'][] = $norm;
|
||||
$dict['count']++;
|
||||
} else {
|
||||
if (!isset($dict['exact'][$norm])) $dict['count']++;
|
||||
$dict['exact'][$norm] = $value;
|
||||
}
|
||||
$dict['count']++;
|
||||
}
|
||||
return $cache[$code] = $dict;
|
||||
}
|
||||
|
||||
/** Entities auflösen, Leerraum zusammenfassen: so werden Schlüssel und Seitentexte verglichen. */
|
||||
|
||||
@@ -151,8 +151,9 @@ function set_ticket_spam(PDO $pdo, array $ticket, int $staffId, bool $restore =
|
||||
log_activity($pdo, $ticket['id'], $staffId, 'Spam-Markierung aufgehoben');
|
||||
} else {
|
||||
$pdo->prepare(
|
||||
"UPDATE tickets SET is_spam=1, status='closed', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
|
||||
"UPDATE tickets SET is_spam=1, status='closed', resolution_code='spam', resolution_note='Als Spam markiert', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
|
||||
)->execute([$now, $now, $ticket['id']]);
|
||||
ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], 'closed', $staffId);
|
||||
finalize_work_session($pdo, (int) $ticket['id'], $staffId);
|
||||
apply_tag_to_ticket($pdo, (int) $ticket['id'], 'Spam');
|
||||
log_activity($pdo, $ticket['id'], $staffId, 'Ticket als Spam markiert');
|
||||
|
||||
@@ -49,8 +49,192 @@ function api_key_verify(PDO $pdo, string $key, string $ip): ?array
|
||||
return $row;
|
||||
}
|
||||
|
||||
// ── Ticket über die öffentliche API anlegen ─────────────────────────────────
|
||||
// ── Kundenformular für Fremdsysteme (eigene Felder, Formular der Abteilung) ──────────────────
|
||||
|
||||
/**
|
||||
* Das Kundenformular für ein Hilfethema oder eine Abteilung, wie es ticket_new.php zeigt: Hat die Abteilung ein eigenes Formular,
|
||||
* gelten dessen Texte und Felder; sonst die allgemeinen Felder und die des Hilfethemas.
|
||||
*
|
||||
* @return array{topicId:?int,departmentId:?int,own:?array,fields:array}
|
||||
* @throws AdminModelException wenn das Hilfethema nicht existiert
|
||||
*/
|
||||
function public_form_definition(PDO $pdo, ?int $topicId, ?int $deptId): array
|
||||
{
|
||||
if ($topicId !== null) {
|
||||
$t = $pdo->prepare('SELECT * FROM help_topics WHERE id = ? AND is_active = 1');
|
||||
$t->execute([$topicId]);
|
||||
$topic = $t->fetch();
|
||||
if (!$topic) throw new AdminModelException('Das Hilfethema existiert nicht oder ist deaktiviert.', 'INVALID_TOPIC');
|
||||
$deptId = $deptId ?? (int) $topic['department_id'];
|
||||
}
|
||||
$own = $deptId ? department_form($pdo, $deptId) : null;
|
||||
if ($own) {
|
||||
$fields = $own['fields'];
|
||||
} else {
|
||||
$all = $pdo->query('SELECT * FROM custom_fields WHERE is_active = 1 AND department_id IS NULL ORDER BY sort_order, label')->fetchAll();
|
||||
$fields = array_values(array_filter($all, fn(array $f) => $f['help_topic_id'] === null || ($topicId !== null && (int) $f['help_topic_id'] === $topicId)));
|
||||
}
|
||||
return ['topicId' => $topicId, 'departmentId' => $deptId, 'own' => $own, 'fields' => $fields];
|
||||
}
|
||||
|
||||
/** Die Felder eines Formulars für die API-Antwort. Hinweistexte kommen auch als sicheres HTML (Links mit absoluter Adresse). */
|
||||
function public_form_fields_payload(array $fields): array
|
||||
{
|
||||
$base = base_url();
|
||||
$out = [];
|
||||
foreach ($fields as $f) {
|
||||
$type = in_array($f['field_type'], ['text', 'textarea', 'select', 'radio', 'checkbox', 'number', 'date'], true) ? $f['field_type'] : 'text';
|
||||
$options = [];
|
||||
if (in_array($type, ['select', 'radio'], true)) {
|
||||
foreach (preg_split('/\r?\n/', trim((string) ($f['options'] ?? ''))) ?: [] as $o) {
|
||||
$o = trim($o);
|
||||
if ($o !== '') $options[] = $o;
|
||||
}
|
||||
}
|
||||
$help = (string) ($f['help_text'] ?? '');
|
||||
$html = $help !== '' ? str_replace('href="/', 'href="' . $base . '/', form_help_html($help, (string) $f['label'])) : '';
|
||||
$out[] = [
|
||||
'id' => (int) $f['id'],
|
||||
'label' => (string) $f['label'],
|
||||
'type' => $type,
|
||||
'required' => !empty($f['is_required']),
|
||||
'options' => $options,
|
||||
'help' => $help,
|
||||
'helpHtml' => $html,
|
||||
'width' => in_array($f['form_width'] ?? '', ['half', 'third', 'twothirds'], true) ? $f['form_width'] : 'full',
|
||||
'section' => (string) ($f['_section'] ?? 'extra'),
|
||||
'sectionTitle' => (string) ($f['_section_title'] ?? ''),
|
||||
'radioInline' => !empty($f['radio_inline']),
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Das ganze Formular für die API-Antwort (Texte und Felder). */
|
||||
/** Grenzen für Dateianhänge über die öffentliche Schnittstelle (die Datei kommt Base64-kodiert im JSON). */
|
||||
const PUBLIC_ATTACHMENT_MAX_FILES = 5;
|
||||
const PUBLIC_ATTACHMENT_FILE_BYTES = 5 * 1048576;
|
||||
const PUBLIC_ATTACHMENT_TOTAL_BYTES = 10 * 1048576;
|
||||
|
||||
/** Dürfen Besucher bei diesem Formular Dateien anhängen, und mit welchen Grenzen? (Ein eigenes Formular kann Anhänge abschalten.) */
|
||||
function public_attachment_policy(?array $own): array
|
||||
{
|
||||
$enabled = $own === null || !array_key_exists('attachment_show', $own) || !empty($own['attachment_show']);
|
||||
return [
|
||||
'enabled' => $enabled,
|
||||
'maxFiles' => PUBLIC_ATTACHMENT_MAX_FILES,
|
||||
'maxFileBytes' => min(PUBLIC_ATTACHMENT_FILE_BYTES, get_max_upload_size()),
|
||||
'maxTotalBytes' => PUBLIC_ATTACHMENT_TOTAL_BYTES,
|
||||
'allowedExtensions' => get_allowed_extensions(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Dateianhänge einer Anfrage und liefert sie dekodiert: [['name' => …, 'data' => Rohdaten], …]. Geprüft wird alles vor dem
|
||||
* Anlegen des Tickets (Anzahl, Größe, Endung, Inhalt wie beim Upload im Formular).
|
||||
*
|
||||
* @throws AdminModelException mit Code INVALID_ATTACHMENT (die Meldung ist für den Besucher gedacht)
|
||||
*/
|
||||
function public_attachments_prepare($raw, ?array $own): array
|
||||
{
|
||||
if ($raw === null || $raw === [] || $raw === '') return [];
|
||||
$policy = public_attachment_policy($own);
|
||||
if (!is_array($raw) || (count($raw) > 0 && array_keys($raw) !== range(0, count($raw) - 1))) throw new AdminModelException('Die Dateianhänge haben ein ungültiges Format.', 'INVALID_ATTACHMENT');
|
||||
if (!$policy['enabled']) throw new AdminModelException('Bei diesem Formular können keine Dateien angehängt werden.', 'INVALID_ATTACHMENT');
|
||||
if (count($raw) > $policy['maxFiles']) throw new AdminModelException('Es können höchstens ' . $policy['maxFiles'] . ' Dateien angehängt werden.', 'INVALID_ATTACHMENT');
|
||||
$out = [];
|
||||
$total = 0;
|
||||
foreach ($raw as $a) {
|
||||
$name = is_array($a) ? trim((string) ($a['name'] ?? '')) : '';
|
||||
$b64 = is_array($a) ? (string) ($a['data'] ?? '') : '';
|
||||
if ($name === '' || mb_strlen($name) > 200 || $b64 === '') throw new AdminModelException('Jeder Anhang braucht einen Namen und Inhalt (Base64).', 'INVALID_ATTACHMENT');
|
||||
$data = base64_decode(preg_replace('/\s+/', '', $b64) ?? '', true);
|
||||
if ($data === false || $data === '') throw new AdminModelException('Der Anhang „' . mb_substr($name, 0, 80) . '“ ist nicht lesbar (Base64).', 'INVALID_ATTACHMENT');
|
||||
if (strlen($data) > $policy['maxFileBytes']) throw new AdminModelException('Der Anhang „' . mb_substr($name, 0, 80) . '“ ist zu groß (höchstens ' . round($policy['maxFileBytes'] / 1048576, 1) . ' MB).', 'INVALID_ATTACHMENT');
|
||||
$total += strlen($data);
|
||||
if ($total > $policy['maxTotalBytes']) throw new AdminModelException('Die Anhänge sind zusammen zu groß (höchstens ' . round($policy['maxTotalBytes'] / 1048576) . ' MB).', 'INVALID_ATTACHMENT');
|
||||
// Name, Endung und Inhalt wie beim Upload im Formular prüfen, ohne etwas zu speichern
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'dhpub');
|
||||
if ($tmp === false || file_put_contents($tmp, $data) === false) throw new AdminModelException('Der Anhang konnte nicht verarbeitet werden.', 'INVALID_ATTACHMENT');
|
||||
$check = handle_ticket_upload(['name' => basename(str_replace(chr(92), '/', $name)), 'type' => '', 'tmp_name' => $tmp, 'error' => UPLOAD_ERR_OK, 'size' => strlen($data)], false, [], true);
|
||||
@unlink($tmp);
|
||||
if (isset($check['error'])) throw new AdminModelException(strip_tags((string) $check['error']), 'INVALID_ATTACHMENT');
|
||||
$out[] = ['name' => $name, 'data' => $data];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function public_form_payload(array $def): array
|
||||
{
|
||||
$o = $def['own'];
|
||||
return [
|
||||
'helpTopicId' => $def['topicId'],
|
||||
'departmentId' => $def['departmentId'],
|
||||
'ownForm' => $o !== null,
|
||||
'title' => (string) ($o['title'] ?? ''),
|
||||
'intro' => (string) ($o['intro'] ?? ''),
|
||||
'subject' => ['show' => $o ? (bool) $o['subject_show'] : true, 'label' => (string) ($o['subject_label'] ?? 'Betreff')],
|
||||
'message' => ['show' => $o ? (bool) ($o['message_show'] ?? true) : true, 'label' => (string) ($o['message_label'] ?? 'Beschreibung'),
|
||||
'hint' => (string) ($o['message_hint'] ?? 'Beschreiben Sie Ihr Anliegen so detailliert wie möglich…')],
|
||||
'submitLabel' => (string) ($o['submit_label'] ?? 'Ticket erstellen'),
|
||||
'fields' => public_form_fields_payload($def['fields']),
|
||||
'attachments' => public_attachment_policy($o),
|
||||
];
|
||||
}
|
||||
|
||||
/** Wert eines Feldes aus der Anfrage, in der Form, wie ticket_new.php ihn speichert (Checkbox: "1" oder "0"). */
|
||||
function public_form_field_value(array $f, array $input): string
|
||||
{
|
||||
$raw = $input[(int) $f['id']] ?? $input[(string) $f['id']] ?? null;
|
||||
if ($f['field_type'] === 'checkbox') {
|
||||
$norm = is_string($raw) ? strtolower(trim($raw)) : $raw;
|
||||
return in_array($norm, [true, 1, '1', 'on', 'yes', 'ja', 'true'], true) ? '1' : '0';
|
||||
}
|
||||
return is_scalar($raw) ? trim((string) $raw) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die gelieferten Feldwerte gegen das Formular (Pflichtfelder, Auswahlwerte, Zahl, Datum) wie ticket_new.php.
|
||||
*
|
||||
* @return array<int,string> Feld-ID => Wert
|
||||
* @throws AdminModelException mit Code INVALID_FIELD (die Meldung ist für den Besucher gedacht)
|
||||
*/
|
||||
function public_form_validate_fields(array $fields, array $input): array
|
||||
{
|
||||
$values = [];
|
||||
foreach ($fields as $f) {
|
||||
$val = public_form_field_value($f, $input);
|
||||
$label = (string) $f['label'];
|
||||
$type = $f['field_type'];
|
||||
if (mb_strlen($val) > 10000) throw new AdminModelException('Die Angabe bei „' . $label . '“ ist zu lang.', 'INVALID_FIELD');
|
||||
if (!empty($f['is_required']) && $type !== 'checkbox' && $val === '') throw new AdminModelException('Bitte füllen Sie das Feld „' . $label . '“ aus.', 'INVALID_FIELD');
|
||||
if (!empty($f['is_required']) && $type === 'checkbox' && $val !== '1') throw new AdminModelException('Bitte bestätigen Sie „' . $label . '“.', 'INVALID_FIELD');
|
||||
if (in_array($type, ['select', 'radio'], true) && $val !== '' && !in_array($val, array_map('trim', preg_split('/\r?\n/', (string) $f['options']) ?: []), true)) {
|
||||
throw new AdminModelException('Bitte wählen Sie bei „' . $label . '“ eine der angebotenen Möglichkeiten.', 'INVALID_FIELD');
|
||||
}
|
||||
if ($type === 'number' && $val !== '' && !is_numeric($val)) throw new AdminModelException('Bitte geben Sie bei „' . $label . '“ eine Zahl an.', 'INVALID_FIELD');
|
||||
if ($type === 'date' && $val !== '' && !(preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $val, $d) && checkdate((int) $d[2], (int) $d[3], (int) $d[1]))) {
|
||||
throw new AdminModelException('Bitte geben Sie bei „' . $label . '“ ein gültiges Datum an (JJJJ-MM-TT).', 'INVALID_FIELD');
|
||||
}
|
||||
$values[(int) $f['id']] = $val;
|
||||
}
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist der Schlüssel gültig, wird aber nur wegen der IP-Beschränkung abgelehnt? So kann die API dem Aufrufer sagen, welche Adresse
|
||||
* DeskHub sieht (hinter einem Proxy oder Cloudflare ist das oft nicht die erwartete). Verrät nichts, was der Aufrufer mit dem Schlüssel nicht ohnehin weiß.
|
||||
*/
|
||||
function api_key_ip_blocked(PDO $pdo, string $key, string $ip): bool
|
||||
{
|
||||
if (!preg_match('/^dh_[a-f0-9]{48}$/', $key)) return false;
|
||||
$stmt = $pdo->prepare('SELECT ip_whitelist FROM api_keys WHERE key_hash = ? AND is_active = 1');
|
||||
$stmt->execute([hash('sha256', $key)]);
|
||||
$row = $stmt->fetch();
|
||||
return $row && !empty($row['ip_whitelist']) && !in_array($ip, explode(',', $row['ip_whitelist']), true);
|
||||
}
|
||||
|
||||
// ── Ticket über die öffentliche API anlegen ─────────────────────────────────
|
||||
/**
|
||||
* Legt ein Ticket für einen Fremdsystem-Aufruf an (Quelle "api"), wendet Ticket-Regeln und SLA-Pläne an,
|
||||
* benachrichtigt Mitarbeiter und Kunde und löst den Webhook "ticket.created" aus.
|
||||
@@ -64,11 +248,35 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp, array $opts
|
||||
$email = trim((string) ($in['email'] ?? ''));
|
||||
$subject = trim((string) ($in['subject'] ?? ''));
|
||||
$message = trim((string) ($in['message'] ?? ''));
|
||||
// Mit "fields" liefert der Aufrufer die Angaben eines Kundenformulars (eigene Felder, Formular der Abteilung). Ohne "fields" bleibt alles wie bisher.
|
||||
$formDef = null;
|
||||
$fieldInput = [];
|
||||
if (isset($in['fields']) && is_array($in['fields'])) {
|
||||
$fieldInput = $in['fields'];
|
||||
$preTopic = am_int_or_null($in, 'helpTopicId');
|
||||
$preDept = am_int_or_null($in, 'departmentId');
|
||||
$formDef = public_form_definition($pdo, $preTopic, $preTopic === null ? ($preDept ?: (int) get_setting('default_department_id', '1')) : $preDept);
|
||||
$own = $formDef['own'];
|
||||
if ($own && !$own['subject_show'] && $subject === '') $subject = 'Anfrage ' . $own['department_name'];
|
||||
if ($own && empty($own['message_show']) && $message === '') {
|
||||
// Ohne Beschreibungsfeld besteht der Ticket-Text aus den Angaben der eigenen Felder
|
||||
$lines = [];
|
||||
foreach ($formDef['fields'] as $ff) {
|
||||
$v = public_form_field_value($ff, $fieldInput);
|
||||
if ($ff['field_type'] === 'checkbox') $v = $v === '1' ? 'Ja' : 'Nein';
|
||||
if ($v !== '') $lines[] = $ff['label'] . ': ' . $v;
|
||||
}
|
||||
$message = $lines ? implode("\n", $lines) : 'Anfrage ohne weitere Angaben';
|
||||
}
|
||||
}
|
||||
if ($name === '' || mb_strlen($name) > 120) throw new AdminModelException('Bitte einen Namen angeben (höchstens 120 Zeichen).', 'INVALID_NAME');
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) throw new AdminModelException('Bitte eine gültige E-Mail-Adresse angeben.', 'INVALID_EMAIL');
|
||||
if ($subject === '' || mb_strlen($subject) > 255) throw new AdminModelException('Bitte einen Betreff angeben (höchstens 255 Zeichen).', 'INVALID_SUBJECT');
|
||||
if ($message === '' || mb_strlen($message) > 50000) throw new AdminModelException('Bitte eine Nachricht angeben (höchstens 50.000 Zeichen).', 'INVALID_MESSAGE');
|
||||
if (is_banned($email)) throw new AdminModelException('Diese Absenderadresse ist gesperrt.', 'BANNED', 403);
|
||||
$fieldValues = $formDef ? public_form_validate_fields($formDef['fields'], $fieldInput) : [];
|
||||
// Dateianhänge (Base64 im JSON): ohne "fields" gilt das Standardformular, das Anhänge erlaubt
|
||||
$attachmentList = public_attachments_prepare($in['attachments'] ?? null, $formDef['own'] ?? null);
|
||||
|
||||
$topicId = am_int_or_null($in, 'helpTopicId');
|
||||
$deptId = am_int_or_null($in, 'departmentId');
|
||||
@@ -110,6 +318,17 @@ function public_ticket_create(PDO $pdo, array $in, string $clientIp, array $opts
|
||||
$ticketId = (int) $pdo->lastInsertId();
|
||||
$pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, message, is_internal_note, created_at) VALUES (?,?,?,0,?)')
|
||||
->execute([$ticketId, 'customer', $message, $now]);
|
||||
$threadId = (int) $pdo->lastInsertId();
|
||||
foreach ($attachmentList as $att) {
|
||||
$saved = store_ticket_attachment_data($pdo, $threadId, $att['name'], $att['data']);
|
||||
if (!$saved['ok']) log_activity($pdo, $ticketId, null, 'Anhang nicht übernommen: ' . mb_substr($att['name'], 0, 80) . ' (' . mb_substr((string) ($saved['error'] ?? ''), 0, 100) . ')');
|
||||
}
|
||||
if ($fieldValues) {
|
||||
$cfStmt = $pdo->prepare('INSERT INTO ticket_field_values (ticket_id, custom_field_id, value) VALUES (?,?,?)');
|
||||
foreach ($fieldValues as $fieldId => $val) {
|
||||
if ($val !== '') $cfStmt->execute([$ticketId, $fieldId, $val]);
|
||||
}
|
||||
}
|
||||
log_activity($pdo, $ticketId, null, (string) ($opts['activity'] ?? 'Ticket über die API erstellt'));
|
||||
if (!empty($opts['tag'])) apply_tag_to_ticket($pdo, $ticketId, (string) $opts['tag']);
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
<?php
|
||||
/**
|
||||
* Anmelde-Wächter: merkt sich die Geräte und Orte, von denen sich Mitarbeiter anmelden, und warnt per Mail, wenn ein neues Gerät
|
||||
* oder ein neuer Ort auftaucht. Fehlversuche und Sperren stehen im Protokoll (staff/login_log.php). Wer eine Warnung bekommt und die
|
||||
* Anmeldung nicht selbst war, sichert das Konto über den Link „Das war ich nicht“ in der Mail (staff/login_alert.php).
|
||||
*
|
||||
* Ein Gerät ist ein Browser (Kennung in einem Cookie) bzw. eine App (Gerätename). Ein Ort ist das Land (nur wenn Cloudflare es
|
||||
* mitschickt) oder sonst das Netz der IP-Adresse; echte Standortdaten gibt es ohne Fremddienst nicht.
|
||||
* Fehler im Wächter dürfen eine Anmeldung nie verhindern: alles ist in try/catch gefasst.
|
||||
*/
|
||||
|
||||
const LOGIN_GUARD_COOKIE = 'dh_device';
|
||||
|
||||
function login_guard_enabled(): bool
|
||||
{
|
||||
return get_setting('login_guard', '1') === '1';
|
||||
}
|
||||
|
||||
function login_guard_max_attempts(): int
|
||||
{
|
||||
return max(3, min(20, (int) get_setting('login_guard_max_attempts', '5')));
|
||||
}
|
||||
|
||||
function login_guard_lock_minutes(): int
|
||||
{
|
||||
return max(5, min(240, (int) get_setting('login_guard_lock_minutes', '15')));
|
||||
}
|
||||
|
||||
function login_guard_retention_days(): int
|
||||
{
|
||||
return max(7, min(1095, (int) get_setting('login_guard_retention_days', '180')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist dieser Browser ein bekanntes Gerät dieses Mitarbeiters (Cookie des Anmelde-Wächters, bei einer früheren Anmeldung gemerkt)?
|
||||
* Liest nur, setzt kein Cookie. Nur für Browser: Die Kennung von Apps stammt aus frei wählbaren Angaben und taugt dafür nicht.
|
||||
*/
|
||||
function login_guard_known_device(PDO $pdo, int $staffId): bool
|
||||
{
|
||||
$id = (string) ($_COOKIE[LOGIN_GUARD_COOKIE] ?? '');
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', $id)) return false;
|
||||
$st = $pdo->prepare('SELECT 1 FROM login_devices WHERE staff_id = ? AND device_hash = ?');
|
||||
$st->execute([$staffId, hash('sha256', $staffId . '|' . $id)]);
|
||||
return (bool) $st->fetchColumn();
|
||||
}
|
||||
|
||||
/** Wie login_guard_known_device(), aber für eine E-Mail-Adresse (unbekannte Adresse = nein). */
|
||||
function login_guard_known_device_for_email(string $email): bool
|
||||
{
|
||||
if (!login_guard_enabled() || trim($email) === '') return false;
|
||||
try {
|
||||
$pdo = get_db();
|
||||
$st = $pdo->prepare('SELECT id FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
|
||||
$st->execute([$email]);
|
||||
$id = (int) $st->fetchColumn();
|
||||
return $id > 0 && login_guard_known_device($pdo, $id);
|
||||
} catch (Throwable $e) {
|
||||
error_log('login_guard_known_device: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ist die Anmeldung dieses Kontos gerade wegen zu vieler Fehlversuche gesperrt (Web oder App)? */
|
||||
function login_guard_is_locked(PDO $pdo, string $email): bool
|
||||
{
|
||||
$email = strtolower(trim($email));
|
||||
if ($email === '') return false;
|
||||
$since = date('Y-m-d H:i:s', time() - login_guard_lock_minutes() * 60);
|
||||
$st = $pdo->prepare("SELECT COUNT(*) FROM rate_limit_log WHERE bucket IN ('staff_login_email','mobile_staff_login_email') AND identifier = ? AND created_at >= ?");
|
||||
$st->execute([$email, $since]);
|
||||
return (int) $st->fetchColumn() >= login_guard_max_attempts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hebt die Sperre eines Kontos sofort auf: die Fehlversuch-Zähler für Web, App und 2FA-Code werden gelöscht.
|
||||
* Die Sperre je IP-Adresse (viele Versuche von einer Adresse) bleibt bewusst bestehen.
|
||||
* @return int Anzahl der gelöschten Zähler-Einträge
|
||||
*/
|
||||
function login_guard_unlock(PDO $pdo, int $staffId, ?int $byStaffId = null, string $reason = ''): int
|
||||
{
|
||||
$st = $pdo->prepare('SELECT email FROM staff WHERE id = ?');
|
||||
$st->execute([$staffId]);
|
||||
$email = strtolower(trim((string) $st->fetchColumn()));
|
||||
if ($email === '') return 0;
|
||||
$n = 0;
|
||||
$del = $pdo->prepare("DELETE FROM rate_limit_log WHERE bucket IN ('staff_login_email','mobile_staff_login_email') AND identifier = ?");
|
||||
$del->execute([$email]);
|
||||
$n += $del->rowCount();
|
||||
$del = $pdo->prepare("DELETE FROM rate_limit_log WHERE bucket = 'staff_totp_attempt' AND identifier = ?");
|
||||
$del->execute([(string) $staffId]);
|
||||
$n += $del->rowCount();
|
||||
$detail = $reason !== '' ? $reason : ($byStaffId !== null && $byStaffId !== $staffId ? 'Sperre aufgehoben durch Administrator #' . $byStaffId : 'Sperre aufgehoben');
|
||||
try { login_guard_record($pdo, 'unlocked', $staffId, $email, 'web', $detail); } catch (Throwable $e) { error_log('login_guard_unlock log: ' . $e->getMessage()); }
|
||||
audit_log($byStaffId ?? $staffId, "Anmeldesperre aufgehoben für Mitarbeiter #{$staffId}" . ($reason !== '' ? ' (' . $reason . ')' : ''));
|
||||
return $n;
|
||||
}
|
||||
|
||||
/** Anzeigename des Geräts aus dem User-Agent, z. B. „Chrome auf Windows“. */
|
||||
function login_guard_describe_device(string $ua): string
|
||||
{
|
||||
$ua = trim($ua);
|
||||
if ($ua === '') return 'Unbekanntes Gerät';
|
||||
if (stripos($ua, 'okhttp') !== false || stripos($ua, 'DeskHub') !== false) return 'DeskHub-App';
|
||||
$browser = 'Browser';
|
||||
foreach (['Edg' => 'Edge', 'OPR' => 'Opera', 'Firefox' => 'Firefox', 'Chrome' => 'Chrome', 'Safari' => 'Safari'] as $needle => $name) {
|
||||
if (stripos($ua, $needle) !== false) { $browser = $name; break; }
|
||||
}
|
||||
$os = 'unbekanntem System';
|
||||
foreach (['Windows' => 'Windows', 'Android' => 'Android', 'iPhone' => 'iOS', 'iPad' => 'iOS', 'Macintosh' => 'macOS', 'Mac OS' => 'macOS', 'Linux' => 'Linux'] as $needle => $name) {
|
||||
if (stripos($ua, $needle) !== false) { $os = $name; break; }
|
||||
}
|
||||
return $browser . ' auf ' . $os;
|
||||
}
|
||||
|
||||
/** Ort der Anmeldung: [Schlüssel, Anzeigetext]. Land nur, wenn Cloudflare es über einen vertrauten Proxy mitschickt. */
|
||||
function login_guard_place(string $ip): array
|
||||
{
|
||||
if (request_from_trusted_proxy()) {
|
||||
$country = strtoupper(trim((string) ($_SERVER['HTTP_CF_IPCOUNTRY'] ?? '')));
|
||||
if (preg_match('/^[A-Z]{2}$/', $country) && !in_array($country, ['XX', 'T1'], true)) return ['C:' . $country, 'Land ' . $country];
|
||||
}
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP)) return ['unknown', 'Unbekannt'];
|
||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) return ['local', 'Lokales Netz'];
|
||||
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||
$p = explode('.', $ip);
|
||||
return ['N:' . $p[0] . '.' . $p[1], 'Netz ' . $p[0] . '.' . $p[1] . '.x.x'];
|
||||
}
|
||||
$bin = inet_pton($ip);
|
||||
$h = $bin === false ? ['0', '0'] : array_map(fn($w) => dechex($w), array_values(unpack('n2', substr($bin, 0, 4))));
|
||||
return ['N6:' . $h[0] . ':' . $h[1], 'Netz ' . $h[0] . ':' . $h[1] . '::/32'];
|
||||
}
|
||||
|
||||
/** Schreibt einen Protokolleintrag; gibt die Eintrags-Nummer zurück. */
|
||||
function login_guard_record(PDO $pdo, string $event, ?int $staffId, string $email, string $channel, string $detail = '', ?string $device = null): int
|
||||
{
|
||||
$ip = client_ip();
|
||||
[, $placeLabel] = login_guard_place($ip);
|
||||
$ua = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
|
||||
$pdo->prepare('INSERT INTO login_events (staff_id, email, event, channel, ip, place, device, detail, created_at) VALUES (?,?,?,?,?,?,?,?,?)')
|
||||
->execute([$staffId, mb_substr($email, 0, 255), $event, $channel, mb_substr($ip, 0, 64), mb_substr($placeLabel, 0, 100),
|
||||
mb_substr($device ?? login_guard_describe_device($ua), 0, 160), mb_substr($detail, 0, 255), db_now()]);
|
||||
if (random_int(1, 40) === 1) login_guard_purge($pdo);
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
function login_guard_purge(PDO $pdo): void
|
||||
{
|
||||
$cut = date('Y-m-d H:i:s', time() - login_guard_retention_days() * 86400);
|
||||
$pdo->prepare('DELETE FROM login_events WHERE created_at < ?')->execute([$cut]);
|
||||
}
|
||||
|
||||
/** Kennung dieses Browsers (Cookie); legt sie an, wenn sie fehlt. */
|
||||
function login_guard_device_id(string $channel, ?string $deviceName): string
|
||||
{
|
||||
if ($channel === 'app') return 'app:' . mb_strtolower(trim((string) $deviceName)) . '|' . login_guard_describe_device((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
$id = (string) ($_COOKIE[LOGIN_GUARD_COOKIE] ?? '');
|
||||
if (!preg_match('/^[a-f0-9]{32}$/', $id)) $id = bin2hex(random_bytes(16));
|
||||
if (!headers_sent() && PHP_SAPI !== 'cli') {
|
||||
setcookie(LOGIN_GUARD_COOKIE, $id, ['expires' => time() + 400 * 86400, 'path' => '/', 'secure' => !empty($_SERVER['HTTPS']), 'httponly' => true, 'samesite' => 'Lax']);
|
||||
}
|
||||
$_COOKIE[LOGIN_GUARD_COOKIE] = $id;
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach erfolgreicher Anmeldung aufrufen (Web: staff_login(); App: api/staff/v1/login.php).
|
||||
* Merkt Gerät und Ort, schreibt das Protokoll und warnt bei Neuem. Gibt das Ergebnis zurück: first | known | new_device | new_place | off.
|
||||
*/
|
||||
function login_guard_success(array $staff, string $channel = 'web', ?string $deviceName = null): string
|
||||
{
|
||||
if (!login_guard_enabled()) return 'off';
|
||||
try {
|
||||
$pdo = get_db();
|
||||
$sid = (int) $staff['id'];
|
||||
$now = db_now();
|
||||
$ip = client_ip();
|
||||
[$placeKey, $placeLabel] = login_guard_place($ip);
|
||||
$label = $channel === 'app' && trim((string) $deviceName) !== '' ? mb_substr(trim((string) $deviceName), 0, 120) : login_guard_describe_device((string) ($_SERVER['HTTP_USER_AGENT'] ?? ''));
|
||||
$hash = hash('sha256', $sid . '|' . login_guard_device_id($channel, $deviceName));
|
||||
|
||||
$st = $pdo->prepare('SELECT id FROM login_devices WHERE staff_id = ? AND device_hash = ?');
|
||||
$st->execute([$sid, $hash]);
|
||||
$devId = $st->fetchColumn();
|
||||
$st = $pdo->prepare('SELECT id FROM login_places WHERE staff_id = ? AND place_key = ?');
|
||||
$st->execute([$sid, $placeKey]);
|
||||
$plId = $st->fetchColumn();
|
||||
$st = $pdo->prepare('SELECT COUNT(*) FROM login_devices WHERE staff_id = ?');
|
||||
$st->execute([$sid]);
|
||||
$hadAny = (int) $st->fetchColumn() > 0;
|
||||
|
||||
if ($devId) {
|
||||
$pdo->prepare('UPDATE login_devices SET last_seen = ?, last_ip = ?, place = ?, label = ? WHERE id = ?')->execute([$now, $ip, $placeLabel, $label, $devId]);
|
||||
} else {
|
||||
$pdo->prepare('INSERT INTO login_devices (staff_id, device_hash, label, channel, first_ip, last_ip, place, first_seen, last_seen) VALUES (?,?,?,?,?,?,?,?,?)')
|
||||
->execute([$sid, $hash, $label, $channel, $ip, $ip, $placeLabel, $now, $now]);
|
||||
}
|
||||
if ($plId) {
|
||||
$pdo->prepare('UPDATE login_places SET last_seen = ? WHERE id = ?')->execute([$now, $plId]);
|
||||
} else {
|
||||
$pdo->prepare('INSERT INTO login_places (staff_id, place_key, label, first_seen, last_seen) VALUES (?,?,?,?,?)')->execute([$sid, $placeKey, $placeLabel, $now, $now]);
|
||||
}
|
||||
|
||||
$email = (string) $staff['email'];
|
||||
if (!$hadAny) {
|
||||
login_guard_record($pdo, 'success', $sid, $email, $channel, 'Erste Anmeldung, Gerät und Ort gemerkt', $label);
|
||||
return 'first';
|
||||
}
|
||||
if (!$devId) {
|
||||
$eid = login_guard_record($pdo, 'new_device', $sid, $email, $channel, 'Neues Gerät' . (!$plId ? ' und neuer Ort' : ''), $label);
|
||||
login_guard_alert($pdo, $staff, $eid, 'Neues Gerät', $label, $placeLabel, $ip, $channel);
|
||||
return 'new_device';
|
||||
}
|
||||
if (!$plId && get_setting('login_guard_warn_place', '1') === '1') {
|
||||
$eid = login_guard_record($pdo, 'new_place', $sid, $email, $channel, 'Neuer Ort', $label);
|
||||
login_guard_alert($pdo, $staff, $eid, 'Neuer Ort', $label, $placeLabel, $ip, $channel);
|
||||
return 'new_place';
|
||||
}
|
||||
login_guard_record($pdo, 'success', $sid, $email, $channel, '', $label);
|
||||
return 'known';
|
||||
} catch (Throwable $e) {
|
||||
error_log('login_guard_success: ' . $e->getMessage());
|
||||
return 'off';
|
||||
}
|
||||
}
|
||||
|
||||
/** Warn-Mail an den Mitarbeiter mit einmaligem Link „Das war ich nicht“. */
|
||||
function login_guard_alert(PDO $pdo, array $staff, int $eventId, string $reason, string $device, string $place, string $ip, string $channel): void
|
||||
{
|
||||
$token = bin2hex(random_bytes(24));
|
||||
$pdo->prepare('UPDATE login_events SET token_hash = ? WHERE id = ?')->execute([hash('sha256', $token), $eventId]);
|
||||
$link = base_url() . '/staff/login_alert.php?t=' . $token;
|
||||
if (getenv('DESKHUB_TESTING') === '1') $GLOBALS['login_guard_last_link'] = $link; // nur für Tests
|
||||
$name = e((string) ($staff['name'] ?? ''));
|
||||
$inner = '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Neue Anmeldung bei Ihrem Konto</h2>'
|
||||
. '<p style="margin:0 0 12px;color:#334155">Hallo ' . $name . ', Ihr Mitarbeiter-Konto wurde gerade von einem bisher unbekannten ' . ($reason === 'Neuer Ort' ? 'Ort' : 'Gerät') . ' aus genutzt.</p>'
|
||||
. '<table style="margin:0 0 16px;font-size:14px;color:#334155"><tr><td style="padding:2px 14px 2px 0;color:#64748b">Zeit</td><td>' . e(date('d.m.Y H:i')) . '</td></tr>'
|
||||
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">Gerät</td><td>' . e($device) . ($channel === 'app' ? ' (App)' : '') . '</td></tr>'
|
||||
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">Ort</td><td>' . e($place) . '</td></tr>'
|
||||
. '<tr><td style="padding:2px 14px 2px 0;color:#64748b">IP-Adresse</td><td>' . e($ip) . '</td></tr></table>'
|
||||
. '<p style="margin:0 0 16px;color:#334155"><strong>Das waren Sie?</strong> Dann müssen Sie nichts tun.</p>'
|
||||
. '<p style="margin:0 0 16px;color:#334155"><strong>Das waren Sie nicht?</strong> Dann sichern Sie Ihr Konto sofort: Das Passwort wird ungültig, alle App-Anmeldungen werden beendet und die Administratoren erfahren davon.</p>'
|
||||
. '<p style="margin:0 0 8px"><a href="' . e($link) . '" style="display:inline-block;background:#dc2626;color:#ffffff;text-decoration:none;padding:11px 20px;border-radius:8px;font-weight:600">Das war ich nicht – Konto sichern</a></p>'
|
||||
. '<p style="margin:12px 0 0;font-size:12px;color:#64748b">Der Link gilt 7 Tage und kann einmal verwendet werden.</p>';
|
||||
send_mail((string) $staff['email'], 'Neue Anmeldung bei Ihrem Konto (' . $reason . ')', email_wrap($inner));
|
||||
}
|
||||
|
||||
/** Fehlversuch ins Protokoll. $staff ist null, wenn es die Adresse nicht gibt. */
|
||||
function login_guard_failed(?array $staff, string $email, string $channel, string $detail): void
|
||||
{
|
||||
if (!login_guard_enabled()) return;
|
||||
try {
|
||||
login_guard_record(get_db(), 'failed', $staff ? (int) $staff['id'] : null, $email, $channel, $detail);
|
||||
} catch (Throwable $e) {
|
||||
error_log('login_guard_failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Die Konto-Sperre hat gegriffen: einmal je Sperrzeit ins Protokoll, Mail an den Kontoinhaber und die Administratoren. */
|
||||
function login_guard_locked(string $email, string $channel): void
|
||||
{
|
||||
if (!login_guard_enabled()) return;
|
||||
try {
|
||||
$pdo = get_db();
|
||||
// Steuerzeichen und Zeilenumbrüche raus (die Adresse steht im Betreff), Länge begrenzen
|
||||
$email = mb_substr(trim((string) preg_replace('/[\x00-\x1F\x7F]+/', ' ', $email)), 0, 120);
|
||||
$cut = date('Y-m-d H:i:s', time() - login_guard_lock_minutes() * 60);
|
||||
$st = $pdo->prepare("SELECT COUNT(*) FROM login_events WHERE event = 'locked' AND LOWER(email) = LOWER(?) AND created_at >= ?");
|
||||
$st->execute([$email, $cut]);
|
||||
if ((int) $st->fetchColumn() > 0) return;
|
||||
$st = $pdo->prepare('SELECT * FROM staff WHERE LOWER(email) = LOWER(?) AND is_active = 1');
|
||||
$st->execute([$email]);
|
||||
$staff = $st->fetch() ?: null;
|
||||
if (!$staff) return; // unbekannte Adresse: kein Eintrag und keine Mail (sonst lässt sich der Posteingang der Administratoren füllen); die Fehlversuche stehen schon im Protokoll
|
||||
login_guard_record($pdo, 'locked', (int) $staff['id'], $email, $channel, 'Zu viele Fehlversuche, Anmeldung für ' . login_guard_lock_minutes() . ' Minuten gesperrt');
|
||||
$ip = client_ip();
|
||||
$msg = '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Anmeldung gesperrt</h2>'
|
||||
. '<p style="margin:0 0 12px;color:#334155">Für das Konto <strong>' . e($email) . '</strong> gab es zu viele falsche Anmeldeversuche. Die Anmeldung ist für ' . login_guard_lock_minutes() . ' Minuten gesperrt.</p>'
|
||||
. '<p style="margin:0 0 12px;color:#334155">Letzte IP-Adresse: ' . e($ip) . '. Waren Sie das nicht, ändern Sie bitte Ihr Passwort, sobald die Sperre vorbei ist.</p>';
|
||||
send_mail((string) $staff['email'], 'Ihre Anmeldung wurde gesperrt', email_wrap($msg));
|
||||
login_guard_notify_admins('Anmeldung gesperrt: ' . $email, $msg, (string) $staff['email']);
|
||||
} catch (Throwable $e) {
|
||||
error_log('login_guard_locked: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Mail an alle aktiven Administratoren (abschaltbar). $skipEmail: dieser Empfänger hat die Mail schon bekommen. */
|
||||
function login_guard_notify_admins(string $subject, string $innerHtml, ?string $skipEmail = null): void
|
||||
{
|
||||
if (get_setting('login_guard_notify_admins', '1') !== '1') return;
|
||||
foreach (get_db()->query("SELECT email FROM staff WHERE role = 'admin' AND is_active = 1")->fetchAll(PDO::FETCH_COLUMN) as $adminMail) {
|
||||
if ($skipEmail !== null && strcasecmp((string) $adminMail, $skipEmail) === 0) continue;
|
||||
if (filter_var($adminMail, FILTER_VALIDATE_EMAIL)) send_mail((string) $adminMail, $subject, email_wrap($innerHtml));
|
||||
}
|
||||
}
|
||||
|
||||
/** Alarm-Link prüfen: gibt die Protokollzeile zurück, wenn der Link gültig ist (7 Tage, einmal verwendbar). */
|
||||
function login_guard_find_alert(PDO $pdo, string $token): ?array
|
||||
{
|
||||
if (!preg_match('/^[a-f0-9]{48}$/', $token)) return null;
|
||||
$st = $pdo->prepare('SELECT * FROM login_events WHERE token_hash = ? AND token_used = 0 AND created_at >= ?');
|
||||
$st->execute([hash('sha256', $token), date('Y-m-d H:i:s', time() - 7 * 86400)]);
|
||||
return $st->fetch() ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* „Das war ich nicht“: Passwort ungültig machen, App-Anmeldungen beenden, alle gemerkten Geräte und Orte vergessen, laufende
|
||||
* Browser-Sitzungen beenden (current_staff() prüft den Zeitpunkt), Protokoll und Mail an die Administratoren.
|
||||
*/
|
||||
function login_guard_revoke(PDO $pdo, array $event): bool
|
||||
{
|
||||
$sid = (int) ($event['staff_id'] ?? 0);
|
||||
$st = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
|
||||
$st->execute([$sid]);
|
||||
$staff = $st->fetch();
|
||||
if (!$staff) return false;
|
||||
$pdo->prepare('UPDATE login_events SET token_used = 1 WHERE id = ?')->execute([(int) $event['id']]);
|
||||
$pdo->prepare('UPDATE staff SET password_hash = ? WHERE id = ?')->execute([password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), $sid]);
|
||||
$pdo->prepare('DELETE FROM mobile_api_tokens WHERE staff_id = ?')->execute([$sid]);
|
||||
$pdo->prepare('DELETE FROM login_devices WHERE staff_id = ?')->execute([$sid]);
|
||||
$pdo->prepare('DELETE FROM login_places WHERE staff_id = ?')->execute([$sid]);
|
||||
// Auch Passkeys: Wer das Konto übernommen hat, könnte einen eigenen eingetragen haben
|
||||
try { $pdo->prepare('DELETE FROM staff_passkeys WHERE staff_id = ?')->execute([$sid]); } catch (Throwable $e) { /* Tabelle fehlt vor dem Datenbank-Update */ }
|
||||
set_setting('login_guard_revoke_' . $sid, (string) time());
|
||||
login_guard_record($pdo, 'revoked', $sid, (string) $staff['email'], 'web', 'Konto gesichert: „Das war ich nicht“ nach Eintrag ' . (int) $event['id']);
|
||||
$msg = '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Konto gesichert</h2>'
|
||||
. '<p style="margin:0 0 12px;color:#334155">' . e((string) $staff['name']) . ' (' . e((string) $staff['email']) . ') hat eine Anmeldung als „nicht von mir“ gemeldet. Das Passwort wurde ungültig gemacht, alle App-Anmeldungen wurden beendet.</p>'
|
||||
. '<p style="margin:0 0 12px;color:#334155">Gemeldet war: ' . e((string) $event['device']) . ', ' . e((string) $event['place']) . ', IP ' . e((string) $event['ip']) . '. Bitte prüfen Sie das Protokoll im Adminportal unter „Anmelde-Wächter“.</p>';
|
||||
login_guard_notify_admins('Konto gesichert: ' . $staff['email'], $msg);
|
||||
send_mail((string) $staff['email'], 'Ihr Konto wurde gesichert', email_wrap('<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Ihr Konto wurde gesichert</h2><p style="margin:0;color:#334155">Das Passwort ist ungültig. Setzen Sie über „Passwort vergessen?“ auf der Anmeldeseite ein neues Passwort.</p>'));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Nach „Das war ich nicht“ sind ältere Browser-Sitzungen ungültig. */
|
||||
function login_guard_session_revoked(int $staffId): bool
|
||||
{
|
||||
$revoked = (int) get_setting('login_guard_revoke_' . $staffId, '0');
|
||||
return $revoked > 0 && (int) ($_SESSION['staff_login_at'] ?? 0) <= $revoked;
|
||||
}
|
||||
|
||||
/** Namen der Ereignisarten für die Anzeige. */
|
||||
function login_guard_event_labels(): array
|
||||
{
|
||||
return [
|
||||
'success' => 'Angemeldet',
|
||||
'new_device' => 'Neues Gerät',
|
||||
'new_place' => 'Neuer Ort',
|
||||
'failed' => 'Fehlversuch',
|
||||
'locked' => 'Gesperrt',
|
||||
'revoked' => 'Konto gesichert',
|
||||
'signout' => 'Überall abgemeldet',
|
||||
'unlocked' => 'Sperre aufgehoben',
|
||||
'passkey_added' => 'Passkey hinzugefügt',
|
||||
'passkey_removed' => 'Passkey entfernt',
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
/**
|
||||
* includes/mail_fetch_manual.php
|
||||
*
|
||||
* E-Mail-Abruf von Hand starten (Knopf in der Ticketliste). Läuft über dieselbe Funktion wie der Cronjob. Damit der Abruf nicht doppelt
|
||||
* läuft (Doppelklick, zugleich der eingebaute Zeitgeber oder ein anderer Mitarbeiter), wird vorher der Zeitstempel des letzten Abrufs
|
||||
* atomar beansprucht: Wer ihn bekommt, ruft ab; alle anderen innerhalb der Sperrzeit bekommen eine Meldung.
|
||||
*/
|
||||
|
||||
/** Mindestabstand zwischen zwei Abrufen von Hand, in Sekunden. */
|
||||
const MAIL_FETCH_MANUAL_COOLDOWN = 20;
|
||||
|
||||
/** Gibt es etwas abzurufen (mindestens ein aktives Postfach)? */
|
||||
function mail_fetch_available(PDO $pdo): bool
|
||||
{
|
||||
try {
|
||||
return (int) $pdo->query('SELECT COUNT(*) FROM imap_accounts WHERE is_active = 1')->fetchColumn() > 0;
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Abruf. $runner ist nur für Tests austauschbar (liefert wie run_fetch_emails ['processed' => int, 'log' => string[]]).
|
||||
*
|
||||
* @return array{ok:bool,level:string,message:string,processed:int,new_tickets:int,replies:int,errors:string[]}
|
||||
*/
|
||||
function mail_fetch_manual(PDO $pdo, ?callable $runner = null): array
|
||||
{
|
||||
$res = ['ok' => false, 'level' => 'error', 'message' => '', 'processed' => 0, 'new_tickets' => 0, 'replies' => 0, 'errors' => []];
|
||||
if (!function_exists('imap_open') && $runner === null) {
|
||||
$res['message'] = 'Der Abruf ist nicht möglich: Die PHP-Erweiterung IMAP ist auf diesem Server nicht aktiv.';
|
||||
return $res;
|
||||
}
|
||||
if (!mail_fetch_available($pdo)) {
|
||||
$res['message'] = 'Es ist kein aktives Postfach eingerichtet (Adminportal → Postfächer (Abruf)).';
|
||||
return $res;
|
||||
}
|
||||
if (!pseudo_cron_try_claim($pdo, 'cron_fetch_emails_last_run', MAIL_FETCH_MANUAL_COOLDOWN)) {
|
||||
$res['level'] = 'info';
|
||||
$res['message'] = 'Der E-Mail-Abruf lief gerade eben schon (oder läuft noch). Bitte einige Sekunden warten und dann erneut versuchen.';
|
||||
return $res;
|
||||
}
|
||||
if ($runner === null) {
|
||||
if (!defined('DESKHUB_CRON_RUNNER')) define('DESKHUB_CRON_RUNNER', true);
|
||||
require_once __DIR__ . '/../cron/fetch_emails.php';
|
||||
$runner = fn() => run_fetch_emails($pdo, true);
|
||||
}
|
||||
@set_time_limit(120);
|
||||
try {
|
||||
$out = $runner();
|
||||
} catch (Throwable $e) {
|
||||
error_log('Manueller E-Mail-Abruf: ' . $e->getMessage());
|
||||
$res['message'] = 'Der E-Mail-Abruf ist mit einem Fehler abgebrochen: ' . mb_substr($e->getMessage(), 0, 200);
|
||||
return $res;
|
||||
}
|
||||
$res['processed'] = (int) ($out['processed'] ?? 0);
|
||||
foreach ((array) ($out['log'] ?? []) as $line) {
|
||||
$line = (string) $line;
|
||||
if (preg_match('/Neues Ticket #/', $line)) $res['new_tickets']++;
|
||||
elseif (preg_match('/Antwort zu Ticket #/', $line)) $res['replies']++;
|
||||
if (preg_match('/fehlgeschlagen|Abbruch|nicht installiert|Kein aktives Postfach/i', $line) && !str_contains($line, 'Anhang nicht übernommen')) {
|
||||
$res['errors'][] = mb_substr(preg_replace('/^\[[^\]]*\]\s*/', '', $line) ?? $line, 0, 220);
|
||||
}
|
||||
}
|
||||
$res['ok'] = $res['errors'] === [];
|
||||
if ($res['errors']) {
|
||||
$res['message'] = 'Der Abruf hatte Probleme: ' . implode(' | ', array_slice($res['errors'], 0, 3));
|
||||
} elseif ($res['processed'] === 0) {
|
||||
$res['level'] = 'info';
|
||||
$res['message'] = 'Abruf fertig: Keine neuen E-Mails.';
|
||||
} else {
|
||||
$res['level'] = 'success';
|
||||
$parts = [];
|
||||
if ($res['new_tickets'] > 0) $parts[] = $res['new_tickets'] . ' neue(s) Ticket(s)';
|
||||
if ($res['replies'] > 0) $parts[] = $res['replies'] . ' Antwort(en)';
|
||||
$other = $res['processed'] - $res['new_tickets'] - $res['replies'];
|
||||
if ($other > 0) $parts[] = $other . ' weitere (zum Beispiel Spam oder Systemmails, ohne Ticket)';
|
||||
$res['message'] = 'Abruf fertig: ' . $res['processed'] . ' E-Mail(s) verarbeitet' . ($parts ? ' (' . implode(', ', $parts) . ')' : '') . '.';
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
/**
|
||||
* includes/mail_queue.php
|
||||
*
|
||||
* Warteschlange für ausgehende Mails. Scheitert der Versand (SMTP-Server nicht erreichbar, falsche Zugangsdaten, Port gesperrt …),
|
||||
* wird die Mail hier gespeichert und vom Cron-Job "process_mail_queue" mit wachsenden Abständen erneut versucht. So gehen
|
||||
* Antworten, Eingangsbestätigungen und Passwort-Mails bei einer Störung nicht verloren.
|
||||
*
|
||||
* Tabelle mail_queue (Migration php_0064): status = pending (wartet), sent (nachträglich gesendet), failed (aufgegeben).
|
||||
*/
|
||||
|
||||
const MAIL_QUEUE_MAX_ATTEMPTS = 10;
|
||||
const MAIL_QUEUE_MAX_AGE_HOURS = 48;
|
||||
const MAIL_QUEUE_MAX_PENDING = 2000;
|
||||
/** Minuten bis zum nächsten Versuch nach dem 1., 2., 3. … Fehlversuch (der letzte Wert gilt für alle weiteren). */
|
||||
const MAIL_QUEUE_BACKOFF_MINUTES = [5, 10, 20, 40, 60, 120, 240, 360];
|
||||
|
||||
/** Ist die Warteschlange eingeschaltet? (Standard: ja; Einstellung mail_queue_enabled = 0 schaltet sie ab) */
|
||||
function mail_queue_enabled(): bool
|
||||
{
|
||||
return get_setting('mail_queue_enabled', '1') === '1';
|
||||
}
|
||||
|
||||
/** Merkt sich pro Anfrage, ob der letzte send_mail()-Aufruf wirklich versucht hat zu senden (nicht nur "Mailversand aus"). */
|
||||
function mail_attempted(?bool $set = null): bool
|
||||
{
|
||||
static $v = false;
|
||||
if ($set !== null) $v = $set;
|
||||
return $v;
|
||||
}
|
||||
|
||||
/** Wurde die zuletzt fehlgeschlagene Mail in die Warteschlange gelegt? */
|
||||
function mail_last_queued(?bool $set = null): bool
|
||||
{
|
||||
static $v = false;
|
||||
if ($set !== null) $v = $set;
|
||||
return $v;
|
||||
}
|
||||
|
||||
/** Legt eine Mail in die Warteschlange. @return int|null ID, null wenn nicht möglich (Tabelle fehlt, Warteschlange voll) */
|
||||
function mail_queue_add(PDO $pdo, string $to, string $subject, string $htmlBody, ?string $fromHeader, string $error): ?int
|
||||
{
|
||||
try {
|
||||
$pending = (int) $pdo->query("SELECT COUNT(*) FROM mail_queue WHERE status = 'pending'")->fetchColumn();
|
||||
if ($pending >= MAIL_QUEUE_MAX_PENDING) return null;
|
||||
$now = db_now();
|
||||
$pdo->prepare("INSERT INTO mail_queue (to_email, from_header, subject, body, status, attempts, next_attempt_at, last_error, created_at) VALUES (?,?,?,?, 'pending', 1, ?, ?, ?)")
|
||||
->execute([mb_substr($to, 0, 255), $fromHeader !== null && $fromHeader !== '' ? mb_substr($fromHeader, 0, 255) : null, mb_substr($subject, 0, 500), $htmlBody,
|
||||
date('Y-m-d H:i:s', time() + MAIL_QUEUE_BACKOFF_MINUTES[0] * 60), mb_substr($error, 0, 400), $now]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
} catch (Throwable $e) {
|
||||
error_log('mail_queue_add: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Versucht fällige Mails erneut zu senden (höchstens $limit pro Lauf). Jede Mail wird vorher atomar "reserviert", damit zwei
|
||||
* gleichzeitige Läufe sie nicht doppelt senden.
|
||||
* @return array{sent:int, failed:int, retry:int, gave_up:int}
|
||||
*/
|
||||
function mail_queue_run(PDO $pdo, int $limit = 20): array
|
||||
{
|
||||
$res = ['sent' => 0, 'failed' => 0, 'retry' => 0, 'gave_up' => 0];
|
||||
try {
|
||||
$now = db_now();
|
||||
$due = $pdo->prepare("SELECT id FROM mail_queue WHERE status = 'pending' AND next_attempt_at <= ? ORDER BY id LIMIT " . max(1, $limit));
|
||||
$due->execute([$now]);
|
||||
$ids = $due->fetchAll(PDO::FETCH_COLUMN);
|
||||
} catch (Throwable $e) {
|
||||
return $res;
|
||||
}
|
||||
foreach ($ids as $id) {
|
||||
// Reservieren: nur wer die Zeile verschiebt, darf senden
|
||||
$claim = $pdo->prepare("UPDATE mail_queue SET next_attempt_at = ? WHERE id = ? AND status = 'pending' AND next_attempt_at <= ?");
|
||||
$claim->execute([date('Y-m-d H:i:s', time() + 600), $id, db_now()]);
|
||||
if ($claim->rowCount() !== 1) continue;
|
||||
$s = $pdo->prepare('SELECT * FROM mail_queue WHERE id = ?');
|
||||
$s->execute([$id]);
|
||||
$m = $s->fetch();
|
||||
if (!$m) continue;
|
||||
$ok = send_mail($m['to_email'], $m['subject'], $m['body'], $m['from_header'] ?: null, false);
|
||||
$attempts = (int) $m['attempts'] + 1;
|
||||
if ($ok) {
|
||||
$pdo->prepare("UPDATE mail_queue SET status = 'sent', attempts = ?, sent_at = ?, last_error = NULL WHERE id = ?")->execute([$attempts, db_now(), $id]);
|
||||
$res['sent']++;
|
||||
continue;
|
||||
}
|
||||
$err = mb_substr(smtp_last_error() !== '' ? smtp_last_error() : 'Versand fehlgeschlagen', 0, 400);
|
||||
$tooOld = time() - (int) strtotime((string) $m['created_at']) > MAIL_QUEUE_MAX_AGE_HOURS * 3600;
|
||||
if ($attempts >= MAIL_QUEUE_MAX_ATTEMPTS || $tooOld) {
|
||||
$pdo->prepare("UPDATE mail_queue SET status = 'failed', attempts = ?, last_error = ? WHERE id = ?")->execute([$attempts, $err, $id]);
|
||||
$res['gave_up']++;
|
||||
} else {
|
||||
$wait = MAIL_QUEUE_BACKOFF_MINUTES[min($attempts - 1, count(MAIL_QUEUE_BACKOFF_MINUTES) - 1)];
|
||||
$pdo->prepare("UPDATE mail_queue SET attempts = ?, last_error = ?, next_attempt_at = ? WHERE id = ?")->execute([$attempts, $err, date('Y-m-d H:i:s', time() + $wait * 60), $id]);
|
||||
$res['retry']++;
|
||||
}
|
||||
$res['failed']++;
|
||||
}
|
||||
// Aufräumen: Erledigtes und Aufgegebenes nach 30 Tagen löschen
|
||||
try {
|
||||
$pdo->prepare("DELETE FROM mail_queue WHERE status IN ('sent','failed') AND created_at < ?")->execute([date('Y-m-d H:i:s', time() - 30 * 86400)]);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
|
||||
/** @return array{pending:int, failed:int, sent:int} Anzahl nach Status (für Anzeige und Gesundheitsprüfung) */
|
||||
function mail_queue_counts(PDO $pdo): array
|
||||
{
|
||||
$out = ['pending' => 0, 'failed' => 0, 'sent' => 0];
|
||||
try {
|
||||
foreach ($pdo->query('SELECT status, COUNT(*) AS n FROM mail_queue GROUP BY status')->fetchAll() as $r) {
|
||||
if (isset($out[$r['status']])) $out[$r['status']] = (int) $r['n'];
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
/**
|
||||
* includes/mail_reload.php
|
||||
*
|
||||
* Lädt die Anhänge einer schon abgerufenen E-Mail nachträglich ins Ticket (für Mails, die abgerufen wurden, bevor DeskHub Anhänge
|
||||
* übernommen hat, oder deren Anhang damals nicht übernommen werden konnte). Die Original-Mail wird im Postfach gesucht: über die
|
||||
* gespeicherte UID im aktuellen Ordner, sonst über die Message-ID.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/imap_spam.php';
|
||||
require_once __DIR__ . '/mail_text.php';
|
||||
|
||||
/**
|
||||
* Nachricht des Tickets, zu der eine Mail gehört: die Kundennachricht, die zur Zeit des Abrufs angelegt wurde (die nächstliegende).
|
||||
*
|
||||
* @param array<int,array{id:int|string,created_at:string}> $threads Kundennachrichten des Tickets
|
||||
*/
|
||||
function mail_reload_pick_thread(array $threads, string $sourceCreatedAt): ?int
|
||||
{
|
||||
$at = strtotime($sourceCreatedAt) ?: 0;
|
||||
$best = null;
|
||||
$bestDiff = PHP_INT_MAX;
|
||||
foreach ($threads as $t) {
|
||||
$diff = abs((strtotime((string) $t['created_at']) ?: 0) - $at);
|
||||
if ($diff < $bestDiff) {
|
||||
$bestDiff = $diff;
|
||||
$best = (int) $t['id'];
|
||||
}
|
||||
}
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet die Mail im geöffneten Ordner: über die UID (und passende Message-ID, falls beide bekannt), sonst nur über die Message-ID.
|
||||
*/
|
||||
function mail_reload_find_message(mixed $mbox, array $source): ?int
|
||||
{
|
||||
$wanted = imap_normalize_message_id((string) ($source['message_id'] ?? ''));
|
||||
$uid = (int) ($source['message_uid'] ?? 0);
|
||||
if ($uid > 0) {
|
||||
$num = (int) @imap_msgno($mbox, $uid);
|
||||
if ($num > 0) {
|
||||
if ($wanted === '') return $num;
|
||||
$h = @imap_headerinfo($mbox, $num);
|
||||
if ($h && imap_normalize_message_id($h->message_id ?? '') === $wanted) return $num;
|
||||
}
|
||||
}
|
||||
if ($wanted !== '') {
|
||||
$hits = @imap_search($mbox, 'ALL') ?: [];
|
||||
foreach ($hits as $num) {
|
||||
$h = @imap_headerinfo($mbox, (int) $num);
|
||||
if ($h && imap_normalize_message_id($h->message_id ?? '') === $wanted) return (int) $num;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die Anhänge aller Original-Mails eines Tickets nach. Bereits vorhandene Anhänge werden nicht doppelt gespeichert.
|
||||
*
|
||||
* @return array{error:?string,added:int,inline:int,html:int,skipped:int,existing:int,mails:int,failed:string[]}
|
||||
*/
|
||||
function reload_ticket_mail_attachments(PDO $pdo, array $ticket, ?int $staffId = null): array
|
||||
{
|
||||
$res = ['error' => null, 'added' => 0, 'inline' => 0, 'html' => 0, 'skipped' => 0, 'existing' => 0, 'mails' => 0, 'failed' => []];
|
||||
if (!function_exists('imap_open')) {
|
||||
$res['error'] = 'PHP-IMAP ist nicht installiert.';
|
||||
return $res;
|
||||
}
|
||||
$ticketId = (int) $ticket['id'];
|
||||
$srcStmt = $pdo->prepare(
|
||||
'SELECT tes.*, ia.label, ia.host, ia.port, ia.encryption, ia.username, ia.password_enc
|
||||
FROM ticket_email_sources tes JOIN imap_accounts ia ON ia.id = tes.imap_account_id
|
||||
WHERE tes.ticket_id = ? ORDER BY tes.id'
|
||||
);
|
||||
$srcStmt->execute([$ticketId]);
|
||||
$sources = $srcStmt->fetchAll();
|
||||
if (!$sources) {
|
||||
$res['error'] = 'Zu diesem Ticket ist keine Original-E-Mail gespeichert (nur per E-Mail abgerufene Tickets).';
|
||||
return $res;
|
||||
}
|
||||
$thStmt = $pdo->prepare("SELECT id, created_at FROM ticket_threads WHERE ticket_id = ? AND author_type = 'customer' AND is_internal_note = 0 ORDER BY id");
|
||||
$thStmt->execute([$ticketId]);
|
||||
$threads = $thStmt->fetchAll();
|
||||
if (!$threads) {
|
||||
$res['error'] = 'Das Ticket hat keine Kundennachricht, an die Anhänge gehängt werden könnten.';
|
||||
return $res;
|
||||
}
|
||||
|
||||
foreach ($sources as $source) {
|
||||
$folder = trim((string) ($source['current_folder'] ?? '')) ?: 'INBOX';
|
||||
$label = (string) ($source['label'] ?: $source['username']);
|
||||
$mbox = imap_connect_account($source, $folder);
|
||||
if (!$mbox) {
|
||||
$res['failed'][] = "[$label] Postfach nicht erreichbar: " . (imap_last_error() ?: 'unbekannter Fehler');
|
||||
continue;
|
||||
}
|
||||
$num = mail_reload_find_message($mbox, $source);
|
||||
if ($num === null) {
|
||||
imap_close($mbox);
|
||||
$res['failed'][] = "[$label] Original-E-Mail wurde im Ordner „{$folder}“ nicht gefunden (verschoben oder gelöscht?).";
|
||||
continue;
|
||||
}
|
||||
$threadId = mail_reload_pick_thread($threads, (string) $source['created_at']);
|
||||
$structure = @imap_fetchstructure($mbox, $num) ?: null;
|
||||
$log = [];
|
||||
$fetchPart = fn(string $no) => (string) imap_fetchbody($mbox, $num, $no);
|
||||
// Formatierte Fassung der Nachricht (HTML) nachtragen, wenn es noch keine gibt
|
||||
$htmlBody = null;
|
||||
if (ticket_mail_html_ready($pdo) && $structure) {
|
||||
$htmlBody = mail_html_for_display($structure, $fetchPart);
|
||||
$has = $pdo->prepare('SELECT message_html FROM ticket_threads WHERE id = ?');
|
||||
$has->execute([(int) $threadId]);
|
||||
if ($htmlBody !== null && trim((string) $has->fetchColumn()) === '') {
|
||||
$pdo->prepare('UPDATE ticket_threads SET message_html = ? WHERE id = ?')->execute([$htmlBody, (int) $threadId]);
|
||||
$res['html']++;
|
||||
}
|
||||
}
|
||||
$c = mail_store_attachments($pdo, $structure, $fetchPart, (int) $threadId, $ticketId, $label, $log, true, $htmlBody !== null);
|
||||
imap_close($mbox);
|
||||
$res['mails']++;
|
||||
foreach (['added', 'inline', 'skipped', 'existing'] as $k) $res[$k] += $c[$k];
|
||||
}
|
||||
if ($res['added'] + $res['inline'] + $res['html'] > 0) log_activity($pdo, $ticketId, $staffId, 'Anhänge und Bilder aus der E-Mail nachgeladen: ' . $res['added'] . ' Anhang/Anhänge, ' . $res['inline'] . ' Bild(er)' . ($res['html'] ? ', Formatierung' : ''));
|
||||
return $res;
|
||||
}
|
||||
@@ -144,6 +144,277 @@ function mail_collect_text_parts(object $part, string $no = ''): array
|
||||
return $found;
|
||||
}
|
||||
|
||||
/** Dateiname eines Anhangs: MIME-Kodierung (=?UTF-8?B?…?=) und RFC 2231 (UTF-8''…) werden aufgelöst. */
|
||||
function mail_decode_filename(string $value): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if (preg_match("/^([A-Za-z0-9_-]+)'[^']*'(.*)$/", $value, $m)) {
|
||||
$decoded = rawurldecode($m[2]);
|
||||
$cs = strtolower($m[1]);
|
||||
if ($cs !== 'utf-8' && $cs !== 'utf8') {
|
||||
try { $decoded = (string) mb_convert_encoding($decoded, 'UTF-8', $cs); } catch (Throwable) { /* bleibt */ }
|
||||
}
|
||||
return $decoded;
|
||||
}
|
||||
if (str_contains($value, '=?')) {
|
||||
$decoded = function_exists('iconv_mime_decode') ? @iconv_mime_decode($value, 0, 'UTF-8') : false;
|
||||
if ($decoded === false || $decoded === '') $decoded = mb_decode_mimeheader($value);
|
||||
return (string) $decoded;
|
||||
}
|
||||
return mb_check_encoding($value, 'UTF-8') ? $value : (string) mb_convert_encoding($value, 'UTF-8', 'ISO-8859-1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sammelt die Anhänge einer IMAP-Struktur: Teile mit Dateinamen oder der Anordnung "attachment" sowie weitergeleitete Mails
|
||||
* (message/rfc822). Bilder mit Content-ID, die im Text der Mail verwendet werden (Logos, Signaturbilder, eingefügte Fotos), sind als
|
||||
* "inline" gekennzeichnet: Sie gehören zur Darstellung der Mail und werden nur dann als Anhang gezeigt, wenn es keine HTML-Fassung gibt.
|
||||
*
|
||||
* @return array<int,array{no:string,name:string,encoding:int,bytes:int,inline:bool,cid:string,eml:bool}>
|
||||
*/
|
||||
function mail_collect_attachment_parts(object $part, string $no = ''): array
|
||||
{
|
||||
$type = (int) ($part->type ?? -1);
|
||||
if (!empty($part->parts) && is_array($part->parts) && $type !== 2) {
|
||||
$found = [];
|
||||
foreach ($part->parts as $i => $sub) {
|
||||
$found = array_merge($found, mail_collect_attachment_parts($sub, $no === '' ? (string) ($i + 1) : $no . '.' . ($i + 1)));
|
||||
}
|
||||
return $found;
|
||||
}
|
||||
$disposition = strtolower((string) ($part->disposition ?? ''));
|
||||
$name = '';
|
||||
foreach (array_merge((array) ($part->dparameters ?? []), (array) ($part->parameters ?? [])) as $p) {
|
||||
$attr = strtolower((string) ($p->attribute ?? ''));
|
||||
if ($name === '' && ($attr === 'filename' || $attr === 'filename*' || $attr === 'name' || $attr === 'name*')) $name = mail_decode_filename((string) $p->value);
|
||||
}
|
||||
$cid = strtolower(trim((string) ($part->id ?? ''), " \t\r\n<>"));
|
||||
$isEml = $type === 2;
|
||||
$inline = $type === 5 && $disposition !== 'attachment' && !empty($part->ifid) && $cid !== '';
|
||||
if (!$isEml && !$inline && $name === '' && $disposition !== 'attachment') return [];
|
||||
if ($name === '') {
|
||||
if ($isEml) $name = 'Weitergeleitete E-Mail.eml';
|
||||
else $name = 'Anhang' . (!empty($part->subtype) ? '.' . strtolower((string) $part->subtype) : '');
|
||||
}
|
||||
return [['no' => $no === '' ? '1' : $no, 'name' => $name, 'encoding' => (int) ($part->encoding ?? 0), 'bytes' => (int) ($part->bytes ?? 0), 'inline' => $inline, 'cid' => $cid, 'eml' => $isEml]];
|
||||
}
|
||||
|
||||
/** Dateiendung für ein Rasterbild anhand seines Inhalts (nur gif, jpg, png, webp), sonst null: HTML oder Skripte im Bild-Gewand fallen so durch. */
|
||||
function mail_image_extension(string $data): ?string
|
||||
{
|
||||
$info = @getimagesizefromstring($data);
|
||||
if (!$info) return null;
|
||||
return match ($info[2] ?? 0) {
|
||||
IMAGETYPE_GIF => 'gif',
|
||||
IMAGETYPE_JPEG => 'jpg',
|
||||
IMAGETYPE_PNG => 'png',
|
||||
IMAGETYPE_WEBP => 'webp',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Name einer weitergeleiteten Mail (.eml) aus ihrem Betreff. */
|
||||
function mail_eml_name(string $raw, string $fallback): string
|
||||
{
|
||||
if (preg_match('/^Subject:[ \t]*(.*(?:\r?\n[ \t]+.*)*)/mi', substr($raw, 0, 8192), $m)) {
|
||||
$subject = trim(mail_decode_filename(preg_replace('/\r?\n[ \t]+/', ' ', $m[1]) ?? ''));
|
||||
$subject = trim((string) preg_replace('~[\\\\/:*?"<>|\x00-\x1F]+~u', ' ', $subject));
|
||||
if ($subject !== '') return mb_substr($subject, 0, 120) . '.eml';
|
||||
}
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML-Fassung einer Mail für die Anzeige im Ticket (bereinigt: nur erlaubte Tags und Eigenschaften), null ohne HTML-Teil.
|
||||
*
|
||||
* @param callable(string):string $fetchPart liefert den rohen Inhalt eines Teils (imap_fetchbody)
|
||||
*/
|
||||
function mail_html_for_display(object $structure, callable $fetchPart): ?string
|
||||
{
|
||||
foreach (mail_collect_text_parts($structure) as $p) {
|
||||
if ($p['subtype'] !== 'HTML') continue;
|
||||
$html = mail_decode_part($fetchPart($p['no']), $p['encoding'], $p['charset']);
|
||||
if (trim($html) === '' || strlen($html) > 3000000) return null;
|
||||
$clean = sanitize_staff_html($html);
|
||||
if (trim(strip_tags($clean)) === '' && stripos($clean, '<img') === false) return null;
|
||||
return strlen($clean) > 400000 ? null : $clean;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Rohdaten eines Mail-Teils nach der Transferkodierung (Anhänge sind Binärdaten, kein Zeichensatz). */
|
||||
function mail_decode_binary(string $raw, int $encoding): string
|
||||
{
|
||||
return match ($encoding) {
|
||||
3 => (string) base64_decode(preg_replace('/\s+/', '', $raw) ?? $raw, false),
|
||||
4 => quoted_printable_decode($raw),
|
||||
default => $raw,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Übernimmt die Anhänge einer Mail an die Nachricht $threadId des Tickets. Nicht übernommene Anhänge (Typ nicht erlaubt, zu groß)
|
||||
* werden im Verlauf des Tickets vermerkt, damit niemand glaubt, die Mail hätte keinen gehabt.
|
||||
*
|
||||
* @param callable(string):string $fetchPart liefert den rohen Inhalt eines Teils (imap_fetchbody)
|
||||
* @param bool $skipExisting Anhänge, die an dieser Nachricht schon hängen (gleicher Name und gleiche Größe), nicht noch einmal speichern
|
||||
* @param bool $inlineImages die Mail hat eine HTML-Fassung: Bilder mit Content-ID werden als eingebettete Bilder gespeichert (und nicht als
|
||||
* Anhang aufgelistet); sonst zählen sie wie jeder andere Anhang
|
||||
* @return array{added:int,inline:int,skipped:int,existing:int}
|
||||
*/
|
||||
function mail_store_attachments(PDO $pdo, ?object $structure, callable $fetchPart, int $threadId, int $ticketId, string $label, array &$log, bool $skipExisting = false, bool $inlineImages = false): array
|
||||
{
|
||||
$count = ['added' => 0, 'inline' => 0, 'skipped' => 0, 'existing' => 0];
|
||||
if (!$structure || $threadId <= 0) return $count;
|
||||
$max = get_max_upload_size();
|
||||
$parts = mail_collect_attachment_parts($structure);
|
||||
$imageExts = ['png', 'jpg', 'jpeg', 'gif', 'webp'];
|
||||
foreach (array_slice($parts, 0, 40) as $p) {
|
||||
$reason = null;
|
||||
$name = $p['name'];
|
||||
$asInline = $inlineImages && $p['inline'];
|
||||
// Vorab nach Größe prüfen, ohne den Teil zu laden (Base64 ist rund ein Drittel größer als die Datei)
|
||||
$tooBig = $p['bytes'] > 0 && (int) ($p['encoding'] === 3 ? $p['bytes'] * 0.75 : $p['bytes']) > $max;
|
||||
if ($tooBig) $reason = 'zu groß (max. ' . round($max / 1048576) . ' MB)';
|
||||
if ($reason === null) {
|
||||
$raw = $fetchPart($p['no']);
|
||||
$data = $p['eml'] ? $raw : mail_decode_binary($raw, $p['encoding']);
|
||||
$opts = ['extra_allowed' => mail_attachment_extra_extensions()];
|
||||
if ($p['eml']) {
|
||||
$name = mail_eml_name($data, $name);
|
||||
if (!str_ends_with(strtolower($name), '.eml')) $name .= '.eml';
|
||||
$opts['extra_allowed'][] = 'eml';
|
||||
} elseif ($asInline) {
|
||||
$ext = mail_image_extension($data);
|
||||
if ($ext === null) {
|
||||
$log[] = "[$label] → Eingebettetes Bild übersprungen (kein Bild): {$name}";
|
||||
continue;
|
||||
}
|
||||
if (!in_array(strtolower(pathinfo($name, PATHINFO_EXTENSION)), $imageExts, true)) $name = 'bild-' . substr(md5($p['cid']), 0, 8) . '.' . $ext;
|
||||
$opts = ['extra_allowed' => $imageExts, 'content_id' => $p['cid'], 'inline' => true];
|
||||
}
|
||||
if ($skipExisting) {
|
||||
if ($asInline) {
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = ? AND content_id = ?');
|
||||
$dup->execute([$threadId, mb_substr($p['cid'], 0, 255)]);
|
||||
} else {
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM ticket_attachments WHERE thread_id = ? AND original_name = ? AND filesize = ?');
|
||||
$dup->execute([$threadId, mb_substr(trim(basename(str_replace(chr(92), '/', $name))), 0, 255), strlen($data)]);
|
||||
}
|
||||
if ((int) $dup->fetchColumn() > 0) {
|
||||
$count['existing']++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$res = store_ticket_attachment_data($pdo, $threadId, $name, $data, $opts);
|
||||
if ($res['ok']) {
|
||||
$log[] = "[$label] → " . ($asInline ? 'Eingebettetes Bild' : 'Anhang') . " übernommen: {$res['name']}";
|
||||
$count[$asInline ? 'inline' : 'added']++;
|
||||
continue;
|
||||
}
|
||||
$reason = (string) ($res['error'] ?? 'unbekannter Fehler');
|
||||
}
|
||||
if ($asInline) {
|
||||
$log[] = "[$label] → Eingebettetes Bild nicht übernommen: {$name} ({$reason})";
|
||||
continue;
|
||||
}
|
||||
$count['skipped']++;
|
||||
$log[] = "[$label] → Anhang nicht übernommen: {$name} ({$reason})";
|
||||
log_activity($pdo, $ticketId, null, 'Anhang aus E-Mail nicht übernommen: ' . mb_substr($name, 0, 80) . ' (' . mb_substr($reason, 0, 100) . ')');
|
||||
}
|
||||
if (count($parts) > 40) log_activity($pdo, $ticketId, null, 'Anhänge aus E-Mail nicht übernommen: mehr als 40 Teile, die übrigen fehlen');
|
||||
return $count;
|
||||
}
|
||||
|
||||
/** Kopfzeilen-Schlüssel eines zitierten Mailkopfs ("Von: …", "Gesendet: …") am Zeilenanfang. */
|
||||
const MAIL_QUOTE_HEADER_KEYS = '(?:Von|From|Gesendet|Sent|Datum|Date|An|To|Cc|Betreff|Subject)';
|
||||
|
||||
/**
|
||||
* Trennt eine Nachricht (Text) in den neuen Teil und den zitierten Verlauf früherer Mails. Der Verlauf beginnt bei der ersten
|
||||
* Zeile, die ein Zitat ankündigt: "Am … schrieb …:", "On … wrote:", "-----Ursprüngliche Nachricht-----", ein zitierter Mailkopf
|
||||
* ("Von: … Gesendet: … An: … Betreff: …") oder eine mit ">" beginnende Zeile. Steht vor dieser Stelle kein Text (die ganze Mail ist
|
||||
* zitiert oder weitergeleitet), wird nichts getrennt.
|
||||
*
|
||||
* @return array{0:string,1:string} [neuer Teil, zitierter Verlauf (leer, wenn es keinen gibt)]
|
||||
*/
|
||||
function mail_split_quoted_text(string $text): array
|
||||
{
|
||||
$text = str_replace(["\r\n", "\r"], "\n", $text);
|
||||
$lines = explode("\n", $text);
|
||||
$n = count($lines);
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$line = $lines[$i];
|
||||
$isMarker = false;
|
||||
if (preg_match('/^\s*-{2,}\s*(Original Message|Ursprüngliche Nachricht|Urspr\. Nachricht|Weitergeleitete Nachricht|Forwarded message)\s*-{2,}\s*$/iu', $line)) {
|
||||
$isMarker = true;
|
||||
} elseif (preg_match('/^\s*(Am|On)\s.+/iu', $line)) {
|
||||
$joined = $line . ' ' . ($lines[$i + 1] ?? '');
|
||||
if (preg_match('/^\s*(Am|On)\s.{5,200}\s(schrieb|wrote)\b.*:\s*$/iu', $joined) || preg_match('/^\s*(Am|On)\s.{5,200}\s(schrieb|wrote)\b.*:\s*$/iu', $line)) $isMarker = true;
|
||||
} elseif (preg_match('/^\s*>/', $line)) {
|
||||
$isMarker = true;
|
||||
} elseif (preg_match('/^\s*' . MAIL_QUOTE_HEADER_KEYS . '\s*:/iu', $line)) {
|
||||
// Zitierter Mailkopf: mehrere solche Zeilen hintereinander, darunter "Von"
|
||||
$keys = [];
|
||||
for ($j = $i; $j < min($n, $i + 7); $j++) {
|
||||
if (preg_match('/^\s*(' . MAIL_QUOTE_HEADER_KEYS . ')\s*:/iu', $lines[$j], $m)) $keys[] = mb_strtolower($m[1]);
|
||||
elseif (trim($lines[$j]) !== '') break;
|
||||
}
|
||||
if (count($keys) >= 3 && (in_array('von', $keys, true) || in_array('from', $keys, true))) $isMarker = true;
|
||||
}
|
||||
if (!$isMarker) continue;
|
||||
$body = trim(implode("\n", array_slice($lines, 0, $i)));
|
||||
if ($body === '') return [$text, ''];
|
||||
return [$body, trim(implode("\n", array_slice($lines, $i)))];
|
||||
}
|
||||
return [$text, ''];
|
||||
}
|
||||
|
||||
/** Bringt ein HTML-Bruchstück in Ordnung (offene Tags schließen, überzählige schließende Tags entfernen). */
|
||||
function mail_balance_html(string $html): string
|
||||
{
|
||||
if (trim($html) === '' || !class_exists('DOMDocument')) return $html;
|
||||
$prev = libxml_use_internal_errors(true);
|
||||
$dom = new DOMDocument();
|
||||
$ok = $dom->loadHTML('<?xml encoding="UTF-8"><div id="dh-root">' . $html . '</div>', LIBXML_NOWARNING | LIBXML_NOERROR | LIBXML_NONET | LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD);
|
||||
libxml_clear_errors();
|
||||
libxml_use_internal_errors($prev);
|
||||
if (!$ok) return $html;
|
||||
$root = $dom->getElementById('dh-root');
|
||||
if (!$root) return $html;
|
||||
$out = '';
|
||||
foreach ($root->childNodes as $child) $out .= $dom->saveHTML($child);
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trennt bereinigtes Mail-HTML in den neuen Teil und den zitierten Verlauf. Gesucht wird die Trennlinie (<hr>) oder der Block mit
|
||||
* oberem Rahmen, den Outlook vor dem zitierten Mailkopf setzt, und danach der Mailkopf mit mindestens drei Angaben (Von, Gesendet, An …).
|
||||
*
|
||||
* @return array{0:string,1:string}
|
||||
*/
|
||||
function mail_split_quoted_html(string $html): array
|
||||
{
|
||||
if (!preg_match_all('/<hr\b[^>]*>|<div\b[^>]*border-top[^>]*>/i', $html, $matches, PREG_OFFSET_CAPTURE)) return [$html, ''];
|
||||
foreach ($matches[0] as [$tag, $offset]) {
|
||||
$after = html_entity_decode(preg_replace('/<[^>]*>/', ' ', substr($html, $offset, 1500)) ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$after = preg_replace('/\s+/u', ' ', $after) ?? $after;
|
||||
preg_match_all('/(?:^|\s)(' . MAIL_QUOTE_HEADER_KEYS . ')\s*:/iu', mb_substr($after, 0, 600), $m);
|
||||
$keys = array_map('mb_strtolower', $m[1] ?? []);
|
||||
if (count($keys) < 3 || !(in_array('von', $keys, true) || in_array('from', $keys, true))) continue;
|
||||
$head = substr($html, 0, $offset);
|
||||
if (trim(strip_tags($head)) === '' && stripos($head, '<img') === false) return [$html, ''];
|
||||
$a = mail_balance_html($head);
|
||||
$b = mail_balance_html(substr($html, $offset));
|
||||
return [$a, $b];
|
||||
}
|
||||
return [$html, ''];
|
||||
}
|
||||
|
||||
/** Zuklappbarer Block für zitierten Verlauf (derselbe Aufbau wie bei eingeklappten Zitaten). */
|
||||
function mail_quote_wrap(string $contentHtml): string
|
||||
{
|
||||
return '<div class="email-quote-wrap"><button type="button" class="email-quote-toggle" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display===\'none\'?\'block\':\'none\';this.textContent=this.nextElementSibling.style.display===\'none\'?\'▶ Verlauf anzeigen\':\'▼ Verlauf ausblenden\'">▶ Verlauf anzeigen</button><div class="email-quoted" style="display:none">' . $contentHtml . '</div></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Bester Text einer Mail: bevorzugt text/plain; ist der nur eine kurze Floskel ("Bitte HTML-Ansicht verwenden") und die HTML-Fassung
|
||||
* deutlich länger, wird die HTML-Fassung als Text genommen.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* includes/mail_threading.php
|
||||
*
|
||||
* Ordnet eine eingehende Mail einem bestehenden Ticket zu (Antwort statt neues Ticket):
|
||||
* 1. Ticketnummer im Betreff "[#260101-0001]" (so wie bisher, ohne weitere Prüfung);
|
||||
* 2. andere Nummern im Betreff "[#923425]" (zum Beispiel aus einem früheren Ticketsystem), wenn es ein Ticket mit genau dieser Nummer gibt;
|
||||
* 3. die Kopfzeilen In-Reply-To und References: DeskHub schreibt in jede Mail zu einem Ticket eine Message-ID mit der Ticketnummer
|
||||
* (<deskhub.260101-0001.…@host>), die Mailprogramme in der Antwort mitschicken. Außerdem zählen die Message-IDs früher
|
||||
* abgerufener Mails des Tickets.
|
||||
* Bei 2 und 3 (die nicht schon durch die DeskHub-Nummer im Betreff gesichert sind) muss der Absender zum Ticket gehören
|
||||
* (Auftraggeber oder früherer Verfasser), damit niemand mit einer geratenen Nummer fremden Tickets Nachrichten unterschieben kann.
|
||||
*/
|
||||
|
||||
/** Message-IDs aus In-Reply-To und References, klein und ohne spitze Klammern. @return string[] */
|
||||
function mail_reference_ids(string $rawHeader): array
|
||||
{
|
||||
$ids = [];
|
||||
// Kopfzeilen können gefaltet sein (Fortsetzungszeilen beginnen mit Leerraum)
|
||||
$unfolded = preg_replace('/\r?\n[ \t]+/', ' ', $rawHeader) ?? $rawHeader;
|
||||
foreach (['In-Reply-To', 'References'] as $name) {
|
||||
if (preg_match_all('/^' . $name . ':(.*)$/mi', $unfolded, $m)) {
|
||||
foreach ($m[1] as $value) {
|
||||
if (preg_match_all('/<([^<>\s]+)>/', $value, $mm)) foreach ($mm[1] as $id) $ids[] = strtolower(trim($id));
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($ids));
|
||||
}
|
||||
|
||||
/** Ticketnummer aus einer von DeskHub erzeugten Message-ID (<deskhub.NUMMER.zufall@host>) oder null. */
|
||||
function mail_ticket_number_from_message_id(string $id): ?string
|
||||
{
|
||||
return preg_match('/^deskhub\.([a-z0-9-]{4,24})\.[a-z0-9]+@/i', $id, $m) ? $m[1] : null;
|
||||
}
|
||||
|
||||
/** Message-ID für eine ausgehende Mail zu einem Ticket (Nummer steht im Betreff als [#NUMMER]); null, wenn der Betreff keine Nummer enthält. */
|
||||
function mail_ticket_message_id(string $subject, string $host): ?string
|
||||
{
|
||||
if (!preg_match('/\[#([A-Za-z0-9-]{4,24})\]/', $subject, $m)) return null;
|
||||
$host = preg_replace('/[^A-Za-z0-9.-]/', '', $host) ?: 'localhost';
|
||||
return '<deskhub.' . $m[1] . '.' . bin2hex(random_bytes(8)) . '@' . $host . '>';
|
||||
}
|
||||
|
||||
/** Gehört der Absender zum Ticket (Auftraggeber oder früherer Verfasser einer Kundennachricht)? */
|
||||
function mail_sender_belongs_to_ticket(PDO $pdo, array $ticket, string $fromEmail): bool
|
||||
{
|
||||
$from = strtolower(trim($fromEmail));
|
||||
if ($from === '') return false;
|
||||
if (strtolower(trim((string) ($ticket['requester_email'] ?? ''))) === $from) return true;
|
||||
try {
|
||||
$stmt = $pdo->prepare("SELECT 1 FROM ticket_threads tt JOIN users u ON u.id = tt.user_id WHERE tt.ticket_id = ? AND tt.author_type = 'customer' AND LOWER(u.email) = ? LIMIT 1");
|
||||
$stmt->execute([(int) $ticket['id'], $from]);
|
||||
return (bool) $stmt->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht das Ticket zu einer eingehenden Mail.
|
||||
*
|
||||
* @return array{ticket:array,via:string}|null via: subject (DeskHub-Nummer), legacy (andere Nummer im Betreff), header (In-Reply-To/References)
|
||||
*/
|
||||
function mail_find_ticket_for_reply(PDO $pdo, string $subject, string $rawHeader, string $fromEmail): ?array
|
||||
{
|
||||
$byNumber = $pdo->prepare('SELECT * FROM tickets WHERE LOWER(ticket_number) = LOWER(?)');
|
||||
|
||||
// 1. DeskHub-Nummer im Betreff wie bisher
|
||||
if (preg_match('/\[#([0-9]{6}-[0-9]{4})\]/', $subject, $m)) {
|
||||
$byNumber->execute([$m[1]]);
|
||||
if ($t = $byNumber->fetch()) return ['ticket' => $t, 'via' => 'subject'];
|
||||
}
|
||||
// 2. Andere Nummer im Betreff (genau diese Nummer muss es als Ticket geben)
|
||||
if (preg_match_all('/\[#([A-Za-z0-9-]{4,24})\]/', $subject, $all)) {
|
||||
foreach ($all[1] as $num) {
|
||||
$byNumber->execute([$num]);
|
||||
if (($t = $byNumber->fetch()) && mail_sender_belongs_to_ticket($pdo, $t, $fromEmail)) return ['ticket' => $t, 'via' => 'legacy'];
|
||||
}
|
||||
}
|
||||
// 3. Kopfzeilen
|
||||
$ids = mail_reference_ids($rawHeader);
|
||||
foreach ($ids as $id) {
|
||||
$num = mail_ticket_number_from_message_id($id);
|
||||
if ($num !== null) {
|
||||
$byNumber->execute([$num]);
|
||||
if (($t = $byNumber->fetch()) && mail_sender_belongs_to_ticket($pdo, $t, $fromEmail)) return ['ticket' => $t, 'via' => 'header'];
|
||||
}
|
||||
}
|
||||
if ($ids) {
|
||||
// Message-IDs früher abgerufener Mails des Tickets (gespeichert mit oder ohne spitze Klammern): grob in SQL vorfiltern, genau in PHP prüfen
|
||||
$ids = array_slice($ids, 0, 15);
|
||||
$likes = implode(' OR ', array_fill(0, count($ids), 'LOWER(message_id) LIKE ?'));
|
||||
$stmt = $pdo->prepare("SELECT ticket_id, message_id FROM ticket_email_sources WHERE message_id IS NOT NULL AND ({$likes}) ORDER BY id DESC LIMIT 50");
|
||||
$stmt->execute(array_map(fn($id) => '%' . str_replace(['%', '_'], ['\%', '\_'], $id) . '%', $ids));
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
if (!in_array(strtolower(trim((string) $row['message_id'], '<> ')), $ids, true)) continue;
|
||||
$byId = $pdo->prepare('SELECT * FROM tickets WHERE id = ?');
|
||||
$byId->execute([(int) $row['ticket_id']]);
|
||||
if (($t = $byId->fetch()) && mail_sender_belongs_to_ticket($pdo, $t, $fromEmail)) return ['ticket' => $t, 'via' => 'header'];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -15,7 +15,27 @@ function smtp_last_error(): string
|
||||
return func_num_args() > 0 ? ($lastError = func_get_arg(0)) : $lastError;
|
||||
}
|
||||
|
||||
function send_mail(string $to, string $subject, string $htmlBody, ?string $fromOverride = null): bool
|
||||
/**
|
||||
* Sendet eine Mail. Scheitert der Versand, obwohl ein Versandweg eingerichtet ist, kommt die Mail in die Warteschlange
|
||||
* (includes/mail_queue.php) und wird später erneut versucht; der Rückgabewert bleibt dann false, mail_last_queued() ist true.
|
||||
* $queue = false für Test-Mails und für die Wiederholungsläufe der Warteschlange selbst.
|
||||
*/
|
||||
function send_mail(string $to, string $subject, string $htmlBody, ?string $fromOverride = null, bool $queue = true): bool
|
||||
{
|
||||
mail_attempted(false);
|
||||
mail_last_queued(false);
|
||||
$ok = send_mail_now($to, $subject, $htmlBody, $fromOverride);
|
||||
if (!$ok && $queue && mail_attempted() && mail_queue_enabled()) {
|
||||
try {
|
||||
if (mail_queue_add(get_db(), $to, $subject, $htmlBody, $fromOverride, smtp_last_error()) !== null) mail_last_queued(true);
|
||||
} catch (Throwable $e) {
|
||||
error_log('send_mail queue: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
return $ok;
|
||||
}
|
||||
|
||||
function send_mail_now(string $to, string $subject, string $htmlBody, ?string $fromOverride = null): bool
|
||||
{
|
||||
$cfg = [
|
||||
'enabled' => get_setting('mail_enabled', config()['mail']['enabled'] ? '1' : '0') === '1',
|
||||
@@ -45,6 +65,7 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO
|
||||
(string) $own['smtp_username'],
|
||||
$own['smtp_password_enc'] ? decrypt_secret($own['smtp_password_enc']) : '',
|
||||
);
|
||||
mail_attempted(true);
|
||||
$ok = $mailer->send($to, $fromHeader, $subject, $htmlBody);
|
||||
if (!$ok) {
|
||||
smtp_last_error($mailer->getLastError());
|
||||
@@ -62,6 +83,7 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO
|
||||
get_setting('smtp_username', ''),
|
||||
decrypt_secret(get_setting('smtp_password', '')),
|
||||
);
|
||||
mail_attempted(true);
|
||||
$ok = $mailer->send($to, $fromHeader, $subject, $htmlBody);
|
||||
if (!$ok) {
|
||||
smtp_last_error($mailer->getLastError());
|
||||
@@ -72,9 +94,13 @@ function send_mail(string $to, string $subject, string $htmlBody, ?string $fromO
|
||||
}
|
||||
|
||||
$headers = "MIME-Version: 1.0\r\n";
|
||||
$headers .= "X-DeskHub-Mail: 1\r\n"; // erkennt der E-Mail-Abruf wieder und macht daraus kein Ticket
|
||||
$headers .= "Content-type: text/html; charset=UTF-8\r\n";
|
||||
$headers .= "From: {$fromHeader}\r\n";
|
||||
$mid = mail_ticket_message_id($subject, preg_match('/@([^>\s]+)>?\s*$/', $fromHeader, $hm) ? $hm[1] : ($_SERVER['SERVER_NAME'] ?? 'localhost'));
|
||||
if ($mid !== null) $headers .= "Message-ID: {$mid}\r\n";
|
||||
|
||||
mail_attempted(true);
|
||||
return @mail($to, $subject, $htmlBody, $headers);
|
||||
}
|
||||
|
||||
@@ -169,70 +195,118 @@ function render_email_template(string $key, array $vars, string $defaultSubject,
|
||||
// Einheitliches, modernes E-Mail-Design
|
||||
// ====================================================================
|
||||
// email_wrap() umschließt JEDEN Vorlagentext (Standard oder vom Admin
|
||||
// angepasst) mit demselben Branding-Rahmen, damit alle System-Mails
|
||||
// gleich aussehen. email_badge()/email_button() sind wiederverwendbare
|
||||
// Bausteine für die Standardtexte der einzelnen Vorlagen.
|
||||
// angepasst) mit demselben Rahmen, damit alle System-Mails gleich aussehen.
|
||||
// email_badge()/email_button()/email_p() sind wiederverwendbare Bausteine.
|
||||
// Alles ist tabellenbasiert mit Inline-Styles und für Outlook (Word-Engine),
|
||||
// Gmail, Apple Mail und Handys gebaut: feste Breite 600 px, die auf kleinen
|
||||
// Bildschirmen schrumpft; der Knopf ist in Outlook ein echter Knopf (VML).
|
||||
|
||||
/** Branding-Rahmen (Header, weiße Karte, Footer) um einen HTML-Textkörper. */
|
||||
const EMAIL_FONT = "'Segoe UI',-apple-system,BlinkMacSystemFont,Roboto,Helvetica,Arial,sans-serif";
|
||||
|
||||
/** Rahmen (Kopf mit Name oder Logo, weiße Karte, Fußzeile) um einen HTML-Textkörper. */
|
||||
function email_wrap(string $innerHtml): string
|
||||
{
|
||||
$siteName = e(get_setting('display_name', config()['site']['name'] ?? 'DeskHub Support'));
|
||||
$siteNameRaw = (string) get_setting('display_name', config()['site']['name'] ?? 'DeskHub Support');
|
||||
$siteName = e($siteNameRaw);
|
||||
$font = EMAIL_FONT;
|
||||
$logo = (string) get_setting('logo_path', '');
|
||||
$brand = '<span style="font-family:' . $font . ';color:#ffffff;font-size:20px;font-weight:700;letter-spacing:-.01em;">' . $siteName . '</span>';
|
||||
if ($logo !== '' && (str_starts_with($logo, '/') || preg_match('#^https://#i', $logo))) {
|
||||
$src = e(str_starts_with($logo, '/') ? base_url() . $logo : $logo);
|
||||
$img = '<img src="' . $src . '" alt="' . $siteName . '" height="34" style="display:inline-block;height:34px;width:auto;border:0;vertical-align:middle;">';
|
||||
$brand = get_setting('logo_hide_name', '0') === '1' ? $img : $img . '<span style="font-family:' . $font . ';color:#ffffff;font-size:20px;font-weight:700;vertical-align:middle;padding-left:12px;">' . $siteName . '</span>';
|
||||
}
|
||||
$home = e(base_url());
|
||||
return <<<HTML
|
||||
<div style="background:#f5f7f9;padding:32px 16px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width:520px;margin:0 auto;background:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e2e8f0;">
|
||||
<tr>
|
||||
<td style="background:#0f6f6a;padding:20px 28px;">
|
||||
<span style="color:#ffffff;font-size:18px;font-weight:700;letter-spacing:-.01em;">{$siteName}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:32px 28px;">
|
||||
{$innerHtml}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:18px 28px;background:#f5f7f9;border-top:1px solid #e2e8f0;">
|
||||
<p style="margin:0;color:#94a3b8;font-size:12px;">Diese E-Mail wurde automatisch von {$siteName} versendet.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="#eef2f6" style="background:#eef2f6;">
|
||||
<tr>
|
||||
<td align="center" style="padding:28px 12px;">
|
||||
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;">
|
||||
<tr>
|
||||
<td bgcolor="#0f6f6a" style="background:#0f6f6a;border-radius:14px 14px 0 0;padding:24px 36px;">
|
||||
{$brand}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td height="4" bgcolor="#14958d" style="background:#14958d;font-size:0;line-height:0;height:4px;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" style="background:#ffffff;padding:36px 36px 34px;font-family:{$font};color:#1f2937;font-size:15px;line-height:1.6;border-left:1px solid #e2e8f0;border-right:1px solid #e2e8f0;">
|
||||
{$innerHtml}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td bgcolor="#ffffff" style="background:#ffffff;border:1px solid #e2e8f0;border-top:1px solid #edf1f5;border-radius:0 0 14px 14px;padding:18px 36px;font-family:{$font};">
|
||||
<span style="color:#94a3b8;font-size:12px;line-height:1.5;">Diese E-Mail wurde automatisch von <a href="{$home}" style="color:#64748b;text-decoration:none;">{$siteName}</a> versendet. Bitte antworten Sie nur, wenn es um Ihr Ticket geht.</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/** Farbige Hervorhebungs-Box, z.B. für die Ticketnummer. $label/$value dürfen bereits ersetzte {platzhalter} enthalten. */
|
||||
/** Hervorgehobene Karte, z.B. für die Ticketnummer (volle Breite, farbiger Balken links). $label/$value dürfen {platzhalter} enthalten. */
|
||||
function email_badge(string $label, string $value, string $color = '#0f6f6a', string $bg = '#e6f4f3'): string
|
||||
{
|
||||
$font = EMAIL_FONT;
|
||||
return <<<HTML
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" style="margin:0 0 24px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 22px;">
|
||||
<tr>
|
||||
<td style="background:{$bg};border-radius:8px;padding:14px 18px;">
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:{$color};margin-bottom:2px;">{$label}</div>
|
||||
<div style="font-size:20px;font-weight:700;color:{$color};font-family:'SFMono-Regular',Consolas,'Liberation Mono',monospace;">{$value}</div>
|
||||
<td width="5" bgcolor="{$color}" style="background:{$color};width:5px;font-size:0;line-height:0;"> </td>
|
||||
<td bgcolor="{$bg}" style="background:{$bg};padding:16px 20px;font-family:{$font};">
|
||||
<div style="font-size:11px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:{$color};line-height:1.4;">{$label}</div>
|
||||
<div style="font-size:24px;font-weight:700;color:{$color};line-height:1.3;font-family:'SFMono-Regular',Consolas,'Liberation Mono',monospace;">{$value}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/** Button-artiger Call-to-Action-Link. */
|
||||
/** Knopf mit Link: in Outlook ein echter Knopf (VML), sonst ein abgerundeter Link. */
|
||||
function email_button(string $label, string $url, string $color = '#0f6f6a'): string
|
||||
{
|
||||
$font = EMAIL_FONT;
|
||||
$plain = trim(strip_tags($label));
|
||||
$w = max(180, (int) round(mb_strlen($plain) * 9.2 + 64));
|
||||
return <<<HTML
|
||||
<table role="presentation" cellpadding="0" cellspacing="0">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:6px 0 4px;">
|
||||
<tr>
|
||||
<td style="background:{$color};border-radius:8px;">
|
||||
<a href="{$url}" style="display:inline-block;padding:12px 24px;color:#ffffff;font-size:14px;font-weight:600;text-decoration:none;">{$label}</a>
|
||||
<td align="center" bgcolor="{$color}" style="background:{$color};border-radius:10px;">
|
||||
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w="urn:schemas-microsoft-com:office:word" href="{$url}" style="height:48px;v-text-anchor:middle;width:{$w}px;" arcsize="20%" stroke="f" fillcolor="{$color}"><w:anchorlock/><center style="color:#ffffff;font-family:'Segoe UI',Arial,sans-serif;font-size:15px;font-weight:bold;">{$label}</center></v:roundrect><![endif]-->
|
||||
<!--[if !mso]><!-- --><a href="{$url}" style="display:inline-block;padding:14px 30px;font-family:{$font};color:#ffffff;font-size:15px;font-weight:600;line-height:1.2;text-decoration:none;border-radius:10px;background:{$color};">{$label}</a><!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
HTML;
|
||||
}
|
||||
|
||||
/** Wiederverwendeter Inline-Style für normale Absätze in Mail-Vorlagen. */
|
||||
/** Normaler Absatz. */
|
||||
function email_p(string $html, string $marginBottom = '16px'): string
|
||||
{
|
||||
return "<p style=\"margin:0 0 {$marginBottom};color:#16202a;font-size:15px;line-height:1.5;\">{$html}</p>";
|
||||
return "<p style=\"margin:0 0 {$marginBottom};color:#1f2937;font-size:15px;line-height:1.6;font-family:" . EMAIL_FONT . ";\">{$html}</p>";
|
||||
}
|
||||
|
||||
/** Zitat-/Textkasten für die eigentliche Nachricht (zum Beispiel die Antwort des Supports). */
|
||||
function email_quote(string $html): string
|
||||
{
|
||||
return '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:0 0 24px;"><tr>'
|
||||
. '<td width="4" bgcolor="#0f6f6a" style="background:#0f6f6a;width:4px;font-size:0;line-height:0;"> </td>'
|
||||
. '<td bgcolor="#f6f9fb" style="background:#f6f9fb;padding:16px 20px;color:#1f2937;font-size:15px;line-height:1.65;font-family:' . EMAIL_FONT . ';">' . $html . '</td>'
|
||||
. '</tr></table>';
|
||||
}
|
||||
|
||||
/** Überschrift im Mailtext. */
|
||||
function email_h(string $text): string
|
||||
{
|
||||
return '<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:' . EMAIL_FONT . ';">' . $text . '</h2>';
|
||||
}
|
||||
|
||||
/** Kleingedruckter Hinweis unter dem Knopf. */
|
||||
function email_small(string $html): string
|
||||
{
|
||||
return '<p style="margin:22px 0 0;color:#64748b;font-size:13px;line-height:1.55;font-family:' . EMAIL_FONT . ';">' . $html . '</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,25 +316,28 @@ function email_p(string $html, string $marginBottom = '16px'): string
|
||||
*/
|
||||
function email_ticket_created_default_body(): string
|
||||
{
|
||||
return email_p('Hallo {customer_name},')
|
||||
return email_h('Wir haben Ihre Anfrage erhalten')
|
||||
. email_p('Hallo {customer_name},')
|
||||
. email_p('vielen Dank für Ihre Anfrage. Wir haben Ihr Ticket erhalten und kümmern uns so schnell wie möglich darum.', '20px')
|
||||
. email_badge('Ihre Ticketnummer', '#{ticket_number}')
|
||||
. email_p('<strong>Betreff:</strong> {subject}', '24px')
|
||||
. email_button('Ticket ansehen', '{ticket_url}')
|
||||
. '<p style="margin:24px 0 0;color:#64748b;font-size:13px;line-height:1.5;">Bewahren Sie diese Ticketnummer gut auf – damit können Sie den Status Ihrer Anfrage jederzeit auch ohne Kundenkonto unter <a href="{lookup_url}" style="color:#0f6f6a;">{lookup_url}</a> prüfen.</p>';
|
||||
. email_small('Bewahren Sie diese Ticketnummer gut auf – damit können Sie den Status Ihrer Anfrage jederzeit auch ohne Kundenkonto unter <a href="{lookup_url}" style="color:#0f6f6a;">{lookup_url}</a> prüfen.');
|
||||
}
|
||||
|
||||
function email_new_reply_default_body(): string
|
||||
{
|
||||
return email_p('Hallo {customer_name},')
|
||||
. email_p('es gibt eine neue Antwort zu Ihrem Ticket <strong>#{ticket_number}</strong>:')
|
||||
. '<div style="background:#f8fafc;border-left:3px solid #0f6f6a;border-radius:0 6px 6px 0;padding:14px 18px;margin:0 0 24px;color:#16202a;font-size:14px;line-height:1.6;">{message}</div>'
|
||||
return email_h('Neue Antwort zu Ihrem Ticket')
|
||||
. email_p('Hallo {customer_name},')
|
||||
. email_p('es gibt eine neue Antwort zu Ihrem Ticket <strong>#{ticket_number}</strong>:', '18px')
|
||||
. email_quote('{message}')
|
||||
. email_button('Ticket ansehen', '{ticket_url}');
|
||||
}
|
||||
|
||||
function email_new_ticket_department_default_body(): string
|
||||
{
|
||||
return email_p('Ein neues Ticket wurde erstellt.')
|
||||
return email_h('Neues Ticket')
|
||||
. email_p('Ein neues Ticket wurde erstellt.', '18px')
|
||||
. email_badge('Ticket', '#{ticket_number}')
|
||||
. email_p('<strong>Von:</strong> {customer_name} ({customer_email})', '6px')
|
||||
. email_p('<strong>Abteilung:</strong> {department_name}', '6px')
|
||||
@@ -270,7 +347,8 @@ function email_new_ticket_department_default_body(): string
|
||||
|
||||
function email_ticket_overdue_default_body(): string
|
||||
{
|
||||
return email_p('Das folgende Ticket hat die SLA-Lösungsfrist überschritten und benötigt Aufmerksamkeit:')
|
||||
return email_h('SLA-Frist überschritten')
|
||||
. email_p('Das folgende Ticket hat die SLA-Lösungsfrist überschritten und benötigt Aufmerksamkeit:', '18px')
|
||||
. email_badge('Ticket', '#{ticket_number}', '#b91c1c', '#fee2e2')
|
||||
. email_p('<strong>Betreff:</strong> {subject}', '6px')
|
||||
. email_p('<strong>Abteilung:</strong> {department_name}', '24px')
|
||||
@@ -279,8 +357,9 @@ function email_ticket_overdue_default_body(): string
|
||||
|
||||
function email_live_chat_transcript_default_body(): string
|
||||
{
|
||||
return email_p('Hallo {customer_name},')
|
||||
. email_p('vielen Dank für Ihren Chat mit uns. Hier der vollständige Verlauf zu Ihrer Unterlage:', '20px')
|
||||
return email_h('Ihr Chat-Verlauf')
|
||||
. email_p('Hallo {customer_name},')
|
||||
. email_p('vielen Dank für Ihren Chat mit uns. Hier der vollständige Verlauf zu Ihrer Anfrage:', '20px')
|
||||
. '{transcript}'
|
||||
. email_p('Bei weiteren Fragen antworten Sie einfach direkt auf diese E-Mail.', '20px')
|
||||
. email_button('Ticket ansehen', '{ticket_url}');
|
||||
@@ -289,9 +368,10 @@ function email_live_chat_transcript_default_body(): string
|
||||
/** Fertige, versandbereite HTML-Mail für Passwort-zurücksetzen-Links (Kunden- und Mitarbeiterbereich). */
|
||||
function email_password_reset_body(string $link): string
|
||||
{
|
||||
$inner = email_p('Klicken Sie auf den folgenden Button, um Ihr Passwort zurückzusetzen. Der Link ist <strong>1 Stunde</strong> lang gültig.')
|
||||
$inner = email_h('Passwort zurücksetzen')
|
||||
. email_p('Klicken Sie auf den folgenden Knopf, um ein neues Passwort festzulegen. Der Link ist <strong>1 Stunde</strong> lang gültig.', '22px')
|
||||
. email_button('Passwort zurücksetzen', e($link))
|
||||
. '<p style="margin:20px 0 0;color:#64748b;font-size:13px;line-height:1.5;">Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail einfach – es wird nichts verändert.</p>';
|
||||
. email_small('Falls Sie dies nicht angefordert haben, ignorieren Sie diese E-Mail einfach – es wird nichts verändert.');
|
||||
return email_wrap($inner);
|
||||
}
|
||||
|
||||
@@ -323,10 +403,11 @@ function notify_customer_new_reply(array $ticket, string $message, string $signa
|
||||
/** Standardtext der Erinnerung an Kunden (siehe cron/remind_customers.php). */
|
||||
function email_customer_reminder_default_body(): string
|
||||
{
|
||||
return '<h2 style="margin:0 0 12px;font-size:20px;color:#0f172a;">Brauchen Sie noch Hilfe?</h2>'
|
||||
. '<p style="margin:0 0 12px;color:#334155;font-size:15px;line-height:1.6;">Hallo {customer_name}, wir haben Ihnen zu Ihrem Ticket <strong>#{ticket_number}</strong> („{subject}“) geantwortet und noch nichts von Ihnen gehört. Hat sich Ihr Anliegen erledigt, brauchen Sie nichts zu tun. Haben Sie noch Fragen, antworten Sie einfach.</p>'
|
||||
return email_h('Brauchen Sie noch Hilfe?')
|
||||
. email_p('Hallo {customer_name}, wir haben Ihnen zu Ihrem Ticket <strong>#{ticket_number}</strong> („{subject}“) geantwortet und noch nichts von Ihnen gehört. Hat sich Ihr Anliegen erledigt, brauchen Sie nichts zu tun. Haben Sie noch Fragen, antworten Sie einfach.', '16px')
|
||||
. '{close_note}'
|
||||
. '<p style="margin:20px 0 0;"><a href="{ticket_url}" style="background:#0f6f6a;color:#ffffff;text-decoration:none;padding:11px 20px;border-radius:8px;font-weight:600;display:inline-block;">Ticket ansehen</a></p>';
|
||||
. '<div style="height:8px;line-height:8px;font-size:0;"> </div>'
|
||||
. email_button('Ticket ansehen', '{ticket_url}');
|
||||
}
|
||||
|
||||
function notify_customer_ticket_created(array $ticket): void
|
||||
@@ -387,7 +468,7 @@ function notify_customer_chat_transcript(array $ticket, array $messages): void
|
||||
// rohes Plain-Text (siehe live_chat.php-Konvention) – für die E-Mail beides einheitlich
|
||||
// auf reinen Text reduzieren, sonst würden HTML-Tags als sichtbarer Text erscheinen.
|
||||
$rawMessage = (string) $m['message'];
|
||||
$plainMessage = preg_match('/<[a-zA-Z][^>]*>/i', $rawMessage)
|
||||
$plainMessage = message_is_html($rawMessage)
|
||||
? trim(html_entity_decode(strip_tags(str_replace(['<br>', '<br/>', '<br />', '</p>'], "\n", $rawMessage)), ENT_QUOTES | ENT_HTML5, 'UTF-8'))
|
||||
: $rawMessage;
|
||||
$rows .= email_chat_transcript_row(e($label), $plainMessage, e($time), $isStaff);
|
||||
@@ -433,7 +514,7 @@ function notify_staff_new_ticket(array $ticket): void
|
||||
|
||||
if ($deptId) {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT DISTINCT email FROM staff WHERE is_active = 1 AND (
|
||||
"SELECT DISTINCT id FROM staff WHERE is_active = 1 AND (
|
||||
role = 'admin'
|
||||
OR department_id = ?
|
||||
OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1)
|
||||
@@ -441,9 +522,10 @@ function notify_staff_new_ticket(array $ticket): void
|
||||
);
|
||||
$stmt->execute([$deptId, $deptId]);
|
||||
} else {
|
||||
$stmt = $pdo->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'");
|
||||
$stmt = $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'");
|
||||
}
|
||||
$recipients = $stmt->fetchAll(PDO::FETCH_COLUMN);
|
||||
// Mitarbeiter im Urlaub: stattdessen bekommt ihre Vertretung die Mail
|
||||
$recipients = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $stmt->fetchAll(PDO::FETCH_COLUMN)));
|
||||
if (!$recipients) return;
|
||||
|
||||
[$subject, $body] = render_email_template('new_ticket_department', [
|
||||
@@ -464,7 +546,8 @@ function notify_staff_new_ticket(array $ticket): void
|
||||
/** Benachrichtigt alle aktiven Admins per E-Mail, wenn ein Ticket neu als überfällig erkannt wurde */
|
||||
function notify_admins_overdue_ticket(array $ticket): void
|
||||
{
|
||||
$admins = get_db()->query("SELECT email FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$pdo = get_db();
|
||||
$admins = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN)));
|
||||
if (!$admins) return;
|
||||
|
||||
[$subject, $body] = render_email_template('ticket_overdue', [
|
||||
@@ -479,3 +562,102 @@ function notify_admins_overdue_ticket(array $ticket): void
|
||||
email_send_and_log($email, $subject, $body, null, 'Ticket-überfällig-Mail', $ticket['ticket_number']);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SLA-Überschreitung: nur an Betroffene ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wer bei einer SLA-Überschreitung eine Mail bekommt: der Bearbeiter des Tickets (ist er im Urlaub, seine Vertretung), sonst die
|
||||
* Mitarbeiter der Abteilung (Hauptabteilung oder zusätzliche Abteilung mit Benachrichtigung), ohne zugewiesenes Ticket und ohne Abteilungs-
|
||||
* Mitarbeiter die aktiven Administratoren. Nie die Postfach-Adresse der Abteilung: Sie wird vom E-Mail-Abruf gelesen und würde Tickets erzeugen.
|
||||
* @return list<string> E-Mail-Adressen
|
||||
*/
|
||||
/** Wer bei SLA-Überschreitung benachrichtigt wird: „admins“ (Standard: nur die Administratoren, nur per E-Mail) oder „assignee“ (Bearbeiter bzw. Abteilung, Mail und Push). */
|
||||
function sla_notify_mode(): string
|
||||
{
|
||||
return get_setting('sla_notify_mode', 'admins') === 'assignee' ? 'assignee' : 'admins';
|
||||
}
|
||||
|
||||
function sla_overdue_recipients(PDO $pdo, array $ticket): array
|
||||
{
|
||||
$emails = [];
|
||||
$assignee = (int) ($ticket['assigned_staff_id'] ?? 0);
|
||||
if (sla_notify_mode() === 'admins') {
|
||||
$assignee = 0; // nur Administratoren: weder der Bearbeiter noch die Abteilung
|
||||
$ticket['department_id'] = null;
|
||||
}
|
||||
if ($assignee > 0) {
|
||||
// Im Urlaub: an die Vertretung; ohne nutzbare Vertretung gar nicht an den Abwesenden (dann gehen die Abteilung bzw. die Administratoren vor)
|
||||
$target = staff_is_on_vacation($assignee) ? vacation_target($pdo, $assignee) : $assignee;
|
||||
if ($target !== null) {
|
||||
$q = $pdo->prepare('SELECT email FROM staff WHERE id = ? AND is_active = 1 AND is_on_vacation = 0');
|
||||
$q->execute([$target]);
|
||||
$e = $q->fetchColumn();
|
||||
if ($e) $emails[] = (string) $e;
|
||||
}
|
||||
}
|
||||
if (!$emails && !empty($ticket['department_id'])) {
|
||||
$q = $pdo->prepare(
|
||||
"SELECT DISTINCT id FROM staff WHERE is_active = 1 AND (department_id = ?
|
||||
OR id IN (SELECT staff_id FROM staff_department WHERE department_id = ? AND notify = 1))"
|
||||
);
|
||||
$q->execute([(int) $ticket['department_id'], (int) $ticket['department_id']]);
|
||||
$emails = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $q->fetchAll(PDO::FETCH_COLUMN)));
|
||||
}
|
||||
if (!$emails) {
|
||||
$emails = staff_emails_by_ids($pdo, vacation_expand_ids($pdo, $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN)));
|
||||
}
|
||||
$own = mail_own_system_addresses($pdo);
|
||||
$out = [];
|
||||
foreach ($emails as $e) {
|
||||
$e = trim($e);
|
||||
if ($e !== '' && filter_var($e, FILTER_VALIDATE_EMAIL) && !in_array(strtolower($e), $own, true)) $out[strtolower($e)] = $e;
|
||||
}
|
||||
return array_values($out);
|
||||
}
|
||||
|
||||
/** Sendet die SLA-Mail (Vorlage „ticket_overdue“) an die Betroffenen. Liefert die Zahl der Empfänger. */
|
||||
function notify_overdue_ticket_staff(array $ticket): int
|
||||
{
|
||||
$pdo = get_db();
|
||||
$recipients = sla_overdue_recipients($pdo, $ticket);
|
||||
if (!$recipients) return 0;
|
||||
[$subject, $body] = render_email_template('ticket_overdue', [
|
||||
'ticket_number' => $ticket['ticket_number'],
|
||||
'subject' => e($ticket['subject']),
|
||||
'department_name' => e($ticket['department_name'] ?? ''),
|
||||
'ticket_url' => base_url() . '/staff/ticket_view.php?n=' . urlencode($ticket['ticket_number']),
|
||||
], "SLA überschritten: Ticket #{ticket_number}", email_ticket_overdue_default_body());
|
||||
$body = email_wrap($body);
|
||||
foreach ($recipients as $email) email_send_and_log($email, $subject, $body, null, 'SLA-überschritten-Mail', $ticket['ticket_number']);
|
||||
return count($recipients);
|
||||
}
|
||||
|
||||
// ── Schutz vor Mail-Schleifen ────────────────────────────────────────────────
|
||||
|
||||
/** Adressen (klein geschrieben), von denen DeskHub selbst Mails verschickt: Standard-Absender und Absender-/Abteilungsadressen. */
|
||||
function mail_own_system_addresses(PDO $pdo): array
|
||||
{
|
||||
$out = [];
|
||||
$add = function ($a) use (&$out) {
|
||||
$a = strtolower(trim((string) $a));
|
||||
if ($a !== '' && str_contains($a, '@')) $out[$a] = true;
|
||||
};
|
||||
$add(get_setting('smtp_from_email', config()['mail']['from_email'] ?? ''));
|
||||
$add(config()['mail']['from_email'] ?? '');
|
||||
try {
|
||||
foreach ($pdo->query('SELECT email FROM email_addresses')->fetchAll(PDO::FETCH_COLUMN) as $a) $add($a);
|
||||
foreach ($pdo->query('SELECT email FROM departments')->fetchAll(PDO::FETCH_COLUMN) as $a) $add($a);
|
||||
} catch (Throwable $e) {
|
||||
}
|
||||
return array_keys($out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist das eine Mail, die DeskHub selbst verschickt hat (und die über ein abgerufenes Postfach zurückkommt)? Daraus darf kein Ticket werden.
|
||||
* Erkennung: unsere Kopfzeile X-DeskHub-Mail oder ein Absender, der eine unserer eigenen Systemadressen ist.
|
||||
*/
|
||||
function mail_is_own_system_mail(PDO $pdo, string $fromEmail, string $rawHeader = ''): bool
|
||||
{
|
||||
if ($rawHeader !== '' && preg_match('/^X-DeskHub-Mail:/mi', $rawHeader)) return true;
|
||||
return in_array(strtolower(trim($fromEmail)), mail_own_system_addresses($pdo), true);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Das Handbuch (PDF) für Administratoren: Adminportal → Hilfe (staff/help.php).
|
||||
* Die Datei kommt mit jedem Update im Programmpaket mit (docs/DeskHub-Handbuch.pdf). Der Ordner docs/ ist für den Webserver gesperrt
|
||||
* (.htaccess bzw. die nginx-Regeln); ausgeliefert wird die Datei nur über die Hilfe-Seite an echte Administratoren.
|
||||
*/
|
||||
|
||||
const MANUAL_FILE = 'DeskHub-Handbuch.pdf';
|
||||
|
||||
function manual_path(): string
|
||||
{
|
||||
return (string) (getenv('DESKHUB_MANUAL_PATH') ?: dirname(__DIR__) . '/docs/' . MANUAL_FILE);
|
||||
}
|
||||
|
||||
/** @return array{size:int,modified_at:string,sha256:string}|null */
|
||||
function manual_info(): ?array
|
||||
{
|
||||
$path = manual_path();
|
||||
if (!is_file($path)) return null;
|
||||
$size = (int) filesize($path);
|
||||
$mtime = (int) filemtime($path);
|
||||
// Die Prüfsumme wird nur neu berechnet, wenn sich Größe oder Änderungsdatum ändern
|
||||
$cache = json_decode((string) get_setting('manual_file_cache', ''), true);
|
||||
if (!is_array($cache) || (int) ($cache['size'] ?? -1) !== $size || (int) ($cache['mtime'] ?? -1) !== $mtime || empty($cache['sha256'])) {
|
||||
$cache = ['size' => $size, 'mtime' => $mtime, 'sha256' => (string) hash_file('sha256', $path)];
|
||||
set_setting('manual_file_cache', json_encode($cache));
|
||||
}
|
||||
return ['size' => $size, 'modified_at' => date('Y-m-d H:i:s', $mtime), 'sha256' => (string) $cache['sha256']];
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
/**
|
||||
* „Meine Woche“: persönliche Kennzahlen eines Mitarbeiters (gelöste Tickets, gesendete Antworten, durchschnittliche Zeit bis zur ersten
|
||||
* Antwort, Zufriedenheit der Kunden, Pünktlichkeit). Sichtbar nur für den Mitarbeiter selbst (staff/my_week.php).
|
||||
*/
|
||||
|
||||
/** @return array<string,string> */
|
||||
function my_week_periods(): array
|
||||
{
|
||||
return ['week' => 'Diese Woche', 'lastweek' => 'Letzte Woche', 'month' => 'Dieser Monat', 'lastmonth' => 'Letzter Monat'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeitraum und der gleich lange Zeitraum davor (zum Vergleichen).
|
||||
* @return array{from:string,to:string,prev_from:string,prev_to:string,label:string}
|
||||
*/
|
||||
function my_week_range(string $key, ?int $now = null): array
|
||||
{
|
||||
$now = $now ?? time();
|
||||
$labels = my_week_periods();
|
||||
if (!isset($labels[$key])) $key = 'week';
|
||||
$monday = strtotime('monday this week', $now);
|
||||
switch ($key) {
|
||||
case 'lastweek':
|
||||
$from = strtotime('-7 days', $monday);
|
||||
$to = $monday - 1;
|
||||
break;
|
||||
case 'month':
|
||||
$from = strtotime(date('Y-m-01', $now));
|
||||
$to = $now;
|
||||
break;
|
||||
case 'lastmonth':
|
||||
$first = strtotime(date('Y-m-01', $now));
|
||||
$from = strtotime('-1 month', $first);
|
||||
$to = $first - 1;
|
||||
break;
|
||||
default:
|
||||
$from = $monday;
|
||||
$to = $now;
|
||||
}
|
||||
// Vergleichszeitraum nach dem Kalender (nicht in Sekunden): Sommer-/Winterzeit verschiebt sonst die Grenze um eine Stunde
|
||||
if ($key === 'week' || $key === 'lastweek') {
|
||||
$prevFrom = strtotime('-7 days', $from);
|
||||
$prevTo = strtotime('-7 days', $to);
|
||||
} elseif ($key === 'month') {
|
||||
$prevFrom = strtotime('-1 month', $from);
|
||||
$prevTo = min(strtotime('-1 month', $to), $from - 1); // gleich weit in den Vormonat, höchstens bis dessen Ende
|
||||
} else {
|
||||
$prevFrom = strtotime('-1 month', $from);
|
||||
$prevTo = $from - 1;
|
||||
}
|
||||
$fmt = fn(int $ts) => date('Y-m-d H:i:s', $ts);
|
||||
return ['from' => $fmt($from), 'to' => $fmt($to), 'prev_from' => $fmt($prevFrom), 'prev_to' => $fmt($prevTo), 'label' => $labels[$key]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Kennzahlen eines Mitarbeiters in einem Zeitraum.
|
||||
* @return array{resolved:int,replies:int,first_response_seconds:?int,first_response_count:int,csat_avg:?float,csat_count:int,csat_positive:?int,on_time:?int}
|
||||
*/
|
||||
function my_week_stats(PDO $pdo, int $staffId, string $from, string $to): array
|
||||
{
|
||||
$one = function (string $sql, array $params) use ($pdo) {
|
||||
$s = $pdo->prepare($sql);
|
||||
$s->execute($params);
|
||||
return $s->fetch(PDO::FETCH_NUM) ?: [];
|
||||
};
|
||||
|
||||
// Gelöst/geschlossen und mir zugewiesen; „pünktlich“ = vor Ablauf der Lösungsfrist (oder ohne Frist)
|
||||
[$resolved, $onTime] = $one(
|
||||
"SELECT COUNT(*), SUM(CASE WHEN resolution_due_at IS NULL OR COALESCE(resolved_at, closed_at) <= resolution_due_at THEN 1 ELSE 0 END)
|
||||
FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status IN ('resolved','closed') AND COALESCE(resolved_at, closed_at) BETWEEN ? AND ?",
|
||||
[$staffId, $from, $to]
|
||||
) + [0, 0];
|
||||
|
||||
[$replies] = $one(
|
||||
"SELECT COUNT(*) FROM ticket_threads WHERE staff_id = ? AND author_type = 'staff' AND is_internal_note = 0 AND created_at BETWEEN ? AND ?",
|
||||
[$staffId, $from, $to]
|
||||
) + [0];
|
||||
|
||||
// Zeit bis zur ersten Antwort: nur Tickets, deren erste öffentliche Antwort von mir stammt (Arbeitszeit, wenn Geschäftszeiten eingerichtet sind)
|
||||
$st = $pdo->prepare(
|
||||
"SELECT t.created_at, t.first_responded_at FROM tickets t
|
||||
WHERE t.is_spam = 0 AND t.first_responded_at BETWEEN ? AND ?
|
||||
AND (SELECT th.staff_id FROM ticket_threads th WHERE th.ticket_id = t.id AND th.author_type = 'staff' AND th.is_internal_note = 0 ORDER BY th.created_at, th.id LIMIT 1) = ?
|
||||
ORDER BY t.id DESC LIMIT 500"
|
||||
);
|
||||
$st->execute([$from, $to, $staffId]);
|
||||
$sum = 0; $n = 0;
|
||||
foreach ($st->fetchAll() as $r) {
|
||||
if (empty($r['created_at']) || empty($r['first_responded_at'])) continue;
|
||||
$sum += ticket_sla_working_seconds_between((string) $r['created_at'], (string) $r['first_responded_at']);
|
||||
$n++;
|
||||
}
|
||||
|
||||
[$csatN, $csatAvg, $csatGood] = $one(
|
||||
"SELECT COUNT(*), AVG(rating), SUM(CASE WHEN rating >= 4 THEN 1 ELSE 0 END) FROM ticket_csat
|
||||
WHERE staff_id = ? AND rating IS NOT NULL AND answered_at BETWEEN ? AND ?",
|
||||
[$staffId, $from, $to]
|
||||
) + [0, null, 0];
|
||||
|
||||
return [
|
||||
'resolved' => (int) $resolved,
|
||||
'replies' => (int) $replies,
|
||||
'first_response_seconds' => $n > 0 ? (int) round($sum / $n) : null,
|
||||
'first_response_count' => $n,
|
||||
'csat_avg' => (int) $csatN > 0 ? round((float) $csatAvg, 2) : null,
|
||||
'csat_count' => (int) $csatN,
|
||||
'csat_positive' => (int) $csatN > 0 ? (int) round(100 * (int) $csatGood / (int) $csatN) : null,
|
||||
'on_time' => (int) $resolved > 0 ? (int) round(100 * (int) $onTime / (int) $resolved) : null,
|
||||
];
|
||||
}
|
||||
|
||||
/** Was jetzt offen ist (unabhängig vom Zeitraum). @return array{open:int,overdue:int,waiting:int} */
|
||||
function my_week_now(PDO $pdo, int $staffId, ?string $now = null): array
|
||||
{
|
||||
$now = $now ?? db_now();
|
||||
$count = function (string $extra, array $params = []) use ($pdo, $staffId): int {
|
||||
$s = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed'){$extra}");
|
||||
$s->execute(array_merge([$staffId], $params));
|
||||
return (int) $s->fetchColumn();
|
||||
};
|
||||
return [
|
||||
'open' => $count(''),
|
||||
'overdue' => $count(' AND resolution_due_at IS NOT NULL AND resolution_due_at < ?', [$now]),
|
||||
'waiting' => $count(" AND status = 'open'"),
|
||||
];
|
||||
}
|
||||
|
||||
/** Gelöste Tickets je Tag im Zeitraum (für das Balkendiagramm). @return array<string,int> Datum => Anzahl */
|
||||
function my_week_per_day(PDO $pdo, int $staffId, string $from, string $to): array
|
||||
{
|
||||
$out = [];
|
||||
for ($d = strtotime(date('Y-m-d', strtotime($from))); $d <= strtotime($to); $d = strtotime('+1 day', $d)) $out[date('Y-m-d', $d)] = 0;
|
||||
$s = $pdo->prepare("SELECT COALESCE(resolved_at, closed_at) AS at FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status IN ('resolved','closed') AND COALESCE(resolved_at, closed_at) BETWEEN ? AND ?");
|
||||
$s->execute([$staffId, $from, $to]);
|
||||
foreach ($s->fetchAll(PDO::FETCH_COLUMN) as $at) {
|
||||
$day = date('Y-m-d', strtotime((string) $at));
|
||||
if (isset($out[$day])) $out[$day]++;
|
||||
}
|
||||
return count($out) > 62 ? array_slice($out, -62, null, true) : $out;
|
||||
}
|
||||
|
||||
/** Dauer kurz und lesbar: „2 Std 15 Min“, „40 Min“, „–“. */
|
||||
function my_week_fmt_duration(?int $seconds): string
|
||||
{
|
||||
if ($seconds === null) return '–';
|
||||
if ($seconds < 60) return 'unter 1 Min';
|
||||
$m = (int) round($seconds / 60);
|
||||
if ($m < 60) return $m . ' Min';
|
||||
$h = intdiv($m, 60);
|
||||
$rest = $m % 60;
|
||||
if ($h >= 48) return round($h / 24) . ' Tage';
|
||||
return $h . ' Std' . ($rest ? ' ' . $rest . ' Min' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Veränderung zum Zeitraum davor als Text und Richtung (+1 besser, -1 schlechter, 0 gleich/unbekannt).
|
||||
* @return array{text:string,trend:int}
|
||||
*/
|
||||
function my_week_delta(?float $now, ?float $before, bool $lowerIsBetter = false, string $unit = ''): array
|
||||
{
|
||||
if ($now === null || $before === null) return ['text' => 'kein Vergleich', 'trend' => 0];
|
||||
$diff = $now - $before;
|
||||
if (abs($diff) < 0.005) return ['text' => 'wie davor', 'trend' => 0];
|
||||
$good = $lowerIsBetter ? $diff < 0 : $diff > 0;
|
||||
$num = abs($diff) >= 10 || floor(abs($diff)) === abs($diff) ? (string) round(abs($diff)) : number_format(abs($diff), 1, ',', '');
|
||||
return ['text' => ($diff > 0 ? '▲ ' : '▼ ') . $num . $unit . ' zum Zeitraum davor', 'trend' => $good ? 1 : -1];
|
||||
}
|
||||
@@ -89,7 +89,7 @@ function notify_new_reply_channels(array $ticket, string $excerpt): void
|
||||
// Antwort des Kunden holt das Ticket aus der Wiedervorlage und setzt die Erinnerung zurück; Beobachter erfahren davon
|
||||
$pdo = get_db();
|
||||
ticket_snooze_clear($pdo, (int) $ticket['id'], null, 'Wiedervorlage beendet (Kunde hat geantwortet)');
|
||||
$pdo->prepare('UPDATE tickets SET reminder_sent_at = NULL WHERE id = ?')->execute([$ticket['id']]);
|
||||
$pdo->prepare('UPDATE tickets SET reminder_sent_at = NULL, updated_at = updated_at WHERE id = ?')->execute([$ticket['id']]);
|
||||
ticket_followers_notify($ticket, 'Neue Kundenantwort');
|
||||
} catch (Throwable $e) {
|
||||
// Datenbank noch nicht aktualisiert: nicht schlimm
|
||||
@@ -119,13 +119,19 @@ function notify_team_assignment(PDO $pdo, array $ticket, int $teamId, ?int $acto
|
||||
$team = $stmt->fetch();
|
||||
if (!$team || !$team['is_active'] || $team['no_alerts']) return 0;
|
||||
|
||||
$members = $pdo->prepare('SELECT s.id, s.email FROM team_members tm JOIN staff s ON s.id = tm.staff_id WHERE tm.team_id = ? AND s.is_active = 1');
|
||||
$members = $pdo->prepare('SELECT s.id FROM team_members tm JOIN staff s ON s.id = tm.staff_id WHERE tm.team_id = ? AND s.is_active = 1');
|
||||
$members->execute([$teamId]);
|
||||
// Mitglieder im Urlaub: stattdessen ihre Vertretung
|
||||
$memberRows = [];
|
||||
foreach (vacation_expand_ids($pdo, $members->fetchAll(PDO::FETCH_COLUMN)) as $mid) {
|
||||
$em = staff_emails_by_ids($pdo, [$mid]);
|
||||
if ($em) $memberRows[] = ['id' => $mid, 'email' => $em[0]];
|
||||
}
|
||||
$sent = 0;
|
||||
$subject = 'Ticket #' . $ticket['ticket_number'] . ' wurde Team „' . $team['name'] . '“ zugewiesen';
|
||||
$body = '<p>Das Ticket <strong>#' . e($ticket['ticket_number']) . '</strong> („' . e($ticket['subject']) . '“) wurde dem Team <strong>'
|
||||
. e($team['name']) . '</strong> zugewiesen.</p>';
|
||||
foreach ($members->fetchAll() as $m) {
|
||||
foreach ($memberRows as $m) {
|
||||
if ($actorId !== null && (int) $m['id'] === $actorId) continue;
|
||||
if (send_mail($m['email'], $subject, $body)) $sent++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Rechte-Katalog: Alle Berechtigungen an einer Stelle, gruppiert, mit Beschreibung und Standardwert.
|
||||
*
|
||||
* - 'agent' : Standardwert für Mitarbeiter ohne eigene Rolle (und für Rechte, die in einer älteren Rolle noch fehlen). Rechte, die bis zur
|
||||
* Einführung des Katalogs jeder Mitarbeiter hatte, stehen auf true; neue Adminportal-Bereiche auf false.
|
||||
* - 'area' : Bereich des Adminportals („Co-Admin“): Wer so ein Recht hat, sieht das Adminportal im Menü.
|
||||
* - Echte Administratoren haben immer alle Rechte (has_permission()).
|
||||
* Bewusst nicht delegierbar (immer nur echte Administratoren): Einstellungen, Backup, System-Update, Plugins, API-Schlüssel, Webhooks, osTicket-Import,
|
||||
* Sicherheitsprüfung, Passkeys ein-/ausschalten und die Datenschutz-Löschfunktionen.
|
||||
*/
|
||||
function permission_catalog(): array
|
||||
{
|
||||
$p = fn(string $label, string $hint, bool $agent, bool $area = false) => ['label' => $label, 'hint' => $hint, 'agent' => $agent, 'area' => $area];
|
||||
return [
|
||||
'tickets' => ['title' => 'Tickets bearbeiten', 'items' => [
|
||||
'reply_ticket' => $p('Tickets beantworten', 'Antworten, Antworten einplanen, Anhänge aus E-Mails nachladen', true),
|
||||
'post_note' => $p('Interne Notizen schreiben', 'Notizen, die nur das Team sieht', true),
|
||||
'close_ticket' => $p('Tickets schließen/lösen', 'Status auf Gelöst oder Geschlossen setzen, als Spam markieren', true),
|
||||
'assign_ticket' => $p('Tickets zuweisen (Mitarbeiter/Team)', 'Bearbeiter oder Team ändern', true),
|
||||
'transfer_ticket' => $p('Tickets übertragen (Abteilung ändern)', 'Ticket in eine andere Abteilung verschieben', true),
|
||||
'create_ticket' => $p('Tickets im Namen von Kunden erstellen', 'Neues Ticket anlegen', true),
|
||||
'merge_split_ticket' => $p('Tickets zusammenführen und teilen', 'Doppelte Tickets vereinen, Nachrichten abspalten', true),
|
||||
'link_tickets' => $p('Tickets verknüpfen', 'Verwandt, doppelt oder blockiert', true),
|
||||
'delete_ticket' => $p('Tickets löschen', 'Endgültig löschen, auch per Sammelaktion', false),
|
||||
]],
|
||||
'customers' => ['title' => 'Kunden und Sperren', 'items' => [
|
||||
'view_customers' => $p('Kundenprofile ansehen', 'Profil eines Kunden mit allen seinen Tickets', true),
|
||||
'manage_customers' => $p('Kundenverzeichnis verwalten', 'Alle Kunden-Konten ansehen und Organisationen zuordnen', false, true),
|
||||
'manage_organizations' => $p('Organisationen verwalten', 'Mehrere Kunden zu einer Firma bündeln', false, true),
|
||||
'manage_banlist' => $p('Sperrliste und Absender sperren', 'Adressen und Domains blockieren', true),
|
||||
]],
|
||||
'reports' => ['title' => 'Auswertung und Export', 'items' => [
|
||||
'view_reports' => $p('Berichte einsehen', 'Übersicht, Berichte, Bericht-Baukasten (nur eigene Abteilungen)', true),
|
||||
'schedule_reports' => $p('Berichte per E-Mail versenden', 'Zeitpläne im Bericht-Baukasten (externe Empfänger nur Administratoren)', true),
|
||||
'export_tickets' => $p('Tickets als CSV exportieren', 'Ticketliste für Excel herunterladen', false),
|
||||
'view_time_entries' => $p('Zeiterfassung einsehen', 'Auswertung der erfassten Zeiten', false, true),
|
||||
'view_audit_log' => $p('Admin-Protokoll einsehen', 'Wer hat was geändert', false, true),
|
||||
]],
|
||||
'mail' => ['title' => 'E-Mail', 'items' => [
|
||||
'fetch_mail' => $p('E-Mails jetzt abrufen', 'Den Abruf per Knopf auslösen', true),
|
||||
'manage_email_addresses' => $p('E-Mail-Adressen und Postfächer verwalten', 'Absender, Eingang, SMTP und IMAP je Adresse', false, true),
|
||||
'manage_email_templates' => $p('E-Mail-Vorlagen verwalten', 'Texte der automatischen Mails', false, true),
|
||||
'view_mail_queue' => $p('Mail-Warteschlange verwalten', 'Nicht zugestellte Mails ansehen, erneut senden, löschen', false, true),
|
||||
]],
|
||||
'content' => ['title' => 'Inhalte und Vorlagen', 'items' => [
|
||||
'manage_canned' => $p('Textbausteine verwalten', 'Vorlagen für Antworten', false, true),
|
||||
'manage_ticket_templates' => $p('Ticket-Vorlagen verwalten', 'Vorgefüllte Tickets für Mitarbeiter und Kunden', false, true),
|
||||
'manage_kb' => $p('Wissensdatenbank verwalten', 'Artikel und Kategorien', false, true),
|
||||
'manage_assistant' => $p('Digitalen Assistenten verwalten', 'Antworten, Fragen-Log, Einstellungen', false, true),
|
||||
'manage_site_pages' => $p('Seiten im Kundenportal verwalten', 'Impressum, AGB und weitere Seiten', false, true),
|
||||
'manage_languages' => $p('Sprachen verwalten', 'Sprachpakete und Standardsprache', false, true),
|
||||
'manage_status_page' => $p('Statusseite verwalten', 'Dienste und Störungsmeldungen', false, true),
|
||||
'share_views' => $p('Ansichten mit dem Team teilen', 'Gespeicherte Filter für alle sichtbar machen', true),
|
||||
]],
|
||||
'setup' => ['title' => 'Einrichtung', 'items' => [
|
||||
'manage_departments' => $p('Abteilungen verwalten', 'Abteilungen, Formulare, automatische Zuweisung', false, true),
|
||||
'manage_priorities' => $p('Prioritäten / SLA verwalten', 'Prioritäten und SLA-Pläne', false, true),
|
||||
'manage_help_topics' => $p('Hilfethemen verwalten', 'Auswahl für Kunden beim Ticket-Erstellen', false, true),
|
||||
'manage_custom_fields' => $p('Ticket-Felder und Listen verwalten', 'Zusatzfelder und Auswahllisten', false, true),
|
||||
'manage_automation' => $p('Ticket-Regeln und wiederkehrende Tickets verwalten', 'Automatische Aktionen bei neuen Tickets, regelmäßige Tickets', false, true),
|
||||
'manage_escalations' => $p('Eskalationsregeln verwalten', 'Aktionen bei überfälligen oder liegengebliebenen Tickets', false, true),
|
||||
'manage_business_hours' => $p('Geschäftszeiten verwalten', 'Zeitfenster und Feiertage für die SLA', false, true),
|
||||
'manage_problems' => $p('Problemmanagement', 'Probleme erfassen und mit Tickets verknüpfen', false, true),
|
||||
]],
|
||||
'team' => ['title' => 'Team und Sicherheit', 'items' => [
|
||||
'manage_staff' => $p('Mitarbeiter verwalten', 'Konten, Sperre aufheben, Überall abmelden, Passkeys entfernen', false, true),
|
||||
'manage_teams' => $p('Teams verwalten', 'Gruppen von Mitarbeitern', false, true),
|
||||
'manage_roles' => $p('Rollen verwalten', 'Rechte vergeben', false, true),
|
||||
'manage_availability' => $p('Status und Schichten der Kollegen ändern', 'Status anderer Mitarbeiter setzen, automatisches Offline einstellen', false, true),
|
||||
'view_login_guard' => $p('Anmelde-Wächter ansehen', 'Anmeldeprotokoll und gesperrte Konten; Einstellungen bleiben Administratoren', false, true),
|
||||
'view_help' => $p('Hilfe und Handbuch', 'Hilfe-Seite mit dem Handbuch (PDF)', false, true),
|
||||
]],
|
||||
];
|
||||
}
|
||||
|
||||
/** Alle Rechte als schlüssel => Beschriftung (Reihenfolge wie im Katalog). */
|
||||
function permission_labels(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (permission_catalog() as $g) foreach ($g['items'] as $k => $i) $out[$k] = $i['label'];
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Rechte für Adminportal-Bereiche („Co-Admin“): schlüssel => Beschriftung. */
|
||||
function admin_section_permissions(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (permission_catalog() as $g) foreach ($g['items'] as $k => $i) if ($i['area']) $out[$k] = $i['label'];
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Standardrechte: Administrator alles, Mitarbeiter die Standardwerte des Katalogs. */
|
||||
function default_permissions(string $role): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (permission_catalog() as $g) foreach ($g['items'] as $k => $i) $out[$k] = $role === 'admin' ? true : (bool) $i['agent'];
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorlagen für Rollen: Schlüssel => [Name, Beschreibung, Rechte, als Rolle anlegen?].
|
||||
* Die Vorlagen mit „true“ werden bei der Installation und beim Update als echte Rollen angelegt (siehe permission_seed_default_roles()); sie lassen sich danach
|
||||
* wie jede andere Rolle ändern oder löschen. „Mitarbeiter“ ist nur eine Vorlage im Formular, weil das die Standardrechte ohne Rolle sind.
|
||||
*/
|
||||
function permission_presets(): array
|
||||
{
|
||||
$all = array_keys(permission_labels());
|
||||
$on = fn(array $keys) => array_fill_keys($keys, true) + array_fill_keys($all, false);
|
||||
$agent = array_keys(array_filter(default_permissions('agent')));
|
||||
$lead = array_merge($agent, ['delete_ticket', 'export_tickets', 'view_time_entries', 'manage_canned', 'manage_kb', 'manage_teams', 'manage_availability']);
|
||||
return [
|
||||
'mitarbeiter' => ['Mitarbeiter', 'Die Standardrechte ohne Rolle: Tickets bearbeiten, Berichte der eigenen Abteilungen.', $on($agent), false],
|
||||
'co_admin' => ['Co-Administrator', 'Alle Rechte, die sich vergeben lassen. Nur Einstellungen, Backup, System-Update, Plugins, API-Schlüssel, Webhooks, Import und Sicherheitsprüfung bleiben echten Administratoren.', $on($all), true],
|
||||
'support_manager' => ['Support-Manager', 'Leitet den Support: Tickets, Einrichtung (Abteilungen, Prioritäten, Themen, Regeln, Felder), Mitarbeiter, Teams, Protokolle und Mail-Warteschlange.', $on(array_merge($lead, [
|
||||
'manage_departments', 'manage_priorities', 'manage_help_topics', 'manage_custom_fields', 'manage_automation', 'manage_escalations', 'manage_business_hours', 'manage_problems',
|
||||
'manage_ticket_templates', 'manage_staff', 'view_audit_log', 'view_login_guard', 'view_mail_queue', 'manage_customers', 'manage_organizations', 'manage_email_templates', 'view_help'])), true],
|
||||
'it' => ['IT / Technik', 'Betrieb und Technik: Tickets bearbeiten, dazu Postfächer und E-Mail-Adressen, Mail-Warteschlange, Anmelde-Wächter, Statusseite, Regeln, Eskalationen, Protokoll und Hilfe.', $on(array_merge($agent, [
|
||||
'manage_email_addresses', 'view_mail_queue', 'view_login_guard', 'manage_status_page', 'manage_automation', 'manage_escalations', 'view_audit_log', 'manage_problems', 'manage_custom_fields', 'view_help'])), true],
|
||||
'teamleitung' => ['Teamleitung', 'Führt ein Team: wie Mitarbeiter, dazu Löschen, Export, Zeiterfassung, Textbausteine, Wissensdatenbank, Teams und Schichten.', $on($lead), true],
|
||||
'support_2' => ['Support 2nd Level', 'Erfahrene Mitarbeiter: Tickets bearbeiten, Probleme verwalten, Export und Zeiterfassung.', $on(array_merge($agent, ['manage_problems', 'export_tickets', 'view_time_entries'])), true],
|
||||
'support_1' => ['Support 1st Level', 'Einstieg: antworten, Notizen, zuweisen, Tickets anlegen und verknüpfen, E-Mails abrufen. Kein Schließen, Verschieben, Zusammenführen oder Sperren.', $on(['reply_ticket', 'post_note', 'assign_ticket', 'create_ticket', 'link_tickets', 'view_customers', 'fetch_mail']), true],
|
||||
'redaktion' => ['Redaktion (Wissen und Inhalte)', 'Pflegt Inhalte: Wissensdatenbank, digitaler Assistent, Textbausteine, Ticket- und E-Mail-Vorlagen, Seiten und Sprachen.', $on(['reply_ticket', 'post_note', 'view_customers', 'manage_kb', 'manage_assistant', 'manage_canned', 'manage_ticket_templates', 'manage_email_templates', 'manage_site_pages', 'manage_languages']), true],
|
||||
'auswertung' => ['Auswertung / Controlling', 'Zahlen und Berichte: Berichte (auch per E-Mail), Export, Zeiterfassung, Protokoll, Ansichten teilen. Kann keine Tickets bearbeiten.', $on(['view_reports', 'schedule_reports', 'export_tickets', 'view_time_entries', 'view_audit_log', 'share_views', 'view_customers']), true],
|
||||
'teamverwaltung' => ['Teamverwaltung / Schichtplanung', 'Verwaltet Personal: Mitarbeiter, Teams, Status und Schichten, Geschäftszeiten, Anmelde-Wächter ansehen.', $on(['view_reports', 'manage_staff', 'manage_teams', 'manage_availability', 'manage_business_hours', 'view_login_guard', 'view_audit_log']), true],
|
||||
'aushilfe' => ['Aushilfe / Praktikant', 'Nur das Nötigste: antworten und Notizen schreiben, Kundenprofile ansehen.', $on(['reply_ticket', 'post_note', 'view_customers']), true],
|
||||
'nur_lesen' => ['Nur lesen', 'Tickets, Kundenprofile und Berichte ansehen, nichts ändern.', $on(['view_customers', 'view_reports']), true],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt die vordefinierten Rollen an, die es noch nicht gibt (Vergleich über den Namen, Groß-/Kleinschreibung egal). Vorhandene Rollen werden nie verändert.
|
||||
* @return list<string> Namen der neu angelegten Rollen
|
||||
*/
|
||||
function permission_seed_default_roles(PDO $pdo): array
|
||||
{
|
||||
$created = [];
|
||||
$exists = $pdo->prepare('SELECT COUNT(*) FROM roles WHERE LOWER(name) = LOWER(?)');
|
||||
$now = date('Y-m-d H:i:s');
|
||||
foreach (permission_presets() as [$name, $desc, $perms, $seed]) {
|
||||
if (!$seed) continue;
|
||||
$exists->execute([$name]);
|
||||
if ((int) $exists->fetchColumn() > 0) continue;
|
||||
$pdo->prepare('INSERT INTO roles (name, permissions, is_active, notes, created_at, updated_at) VALUES (?,?,1,?,?,?)')
|
||||
->execute([$name, json_encode($perms), 'Vordefinierte Rolle – frei änderbar. ' . $desc, $now, $now]);
|
||||
$created[] = $name;
|
||||
}
|
||||
return $created;
|
||||
}
|
||||
|
||||
/** Namen der vordefinierten Rollen, die auf dieser Installation fehlen. @return list<string> */
|
||||
function permission_missing_default_roles(PDO $pdo): array
|
||||
{
|
||||
$have = array_map('mb_strtolower', $pdo->query('SELECT name FROM roles')->fetchAll(PDO::FETCH_COLUMN));
|
||||
$missing = [];
|
||||
foreach (permission_presets() as [$name, , , $seed]) if ($seed && !in_array(mb_strtolower($name), $have, true)) $missing[] = $name;
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechte, die es schon vor dem Katalog gab. Fehlt eines davon in einer gespeicherten Rolle, gilt wie bisher „nein“; bei allen neueren Rechten gilt
|
||||
* der Standardwert des Katalogs (so behalten bestehende Rollen, was bisher für alle offen war, und neue Adminbereiche bleiben aus).
|
||||
*/
|
||||
function permission_legacy_keys(): array
|
||||
{
|
||||
return ['reply_ticket', 'close_ticket', 'delete_ticket', 'transfer_ticket', 'post_note', 'assign_ticket', 'manage_canned', 'view_reports', 'manage_kb', 'create_ticket',
|
||||
'manage_departments', 'manage_priorities', 'manage_help_topics', 'manage_email_addresses', 'manage_email_templates', 'manage_organizations', 'manage_teams', 'manage_roles',
|
||||
'manage_staff', 'view_audit_log', 'manage_escalations', 'view_time_entries', 'manage_business_hours', 'manage_assistant'];
|
||||
}
|
||||
|
||||
/** Wirksame Rechte einer gespeicherten Rolle (so, wie has_permission() sie auswertet): fehlende alte Rechte = nein, fehlende neuere = Standardwert. */
|
||||
function permission_effective(array $rolePerms): array
|
||||
{
|
||||
$defaults = default_permissions('agent');
|
||||
$legacy = permission_legacy_keys();
|
||||
$out = [];
|
||||
foreach (array_keys($defaults) as $k) {
|
||||
$out[$k] = array_key_exists($k, $rolePerms) ? (bool) $rolePerms[$k] : (!in_array($k, $legacy, true) && $defaults[$k]);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
@@ -34,13 +34,15 @@ function plugin_known_permissions(): array
|
||||
'api.endpoints' => 'Eigene Endpunkte in der Mitarbeiter-API',
|
||||
'api.public' => 'Öffentliche Webhook-Adresse (zum Beispiel für Discord oder Telegram); das Plugin prüft die Echtheit der Anfragen selbst',
|
||||
'db.migrations' => 'Eigene Datenbank-Tabellen anlegen',
|
||||
'auth.customer' => 'Kunden über einen externen Dienst anmelden und Kundenkonten anlegen (zum Beispiel authentik): Knopf auf der Anmeldeseite des Kundenportals. ACHTUNG: Das Plugin entscheidet selbst, wer sich anmelden darf und ob Konten entstehen.',
|
||||
'auth.login' => 'Mitarbeiter über einen externen Dienst anmelden (zum Beispiel authentik): Knopf auf der Anmeldeseite und eigene Anmelde-Adresse. ACHTUNG: Das Plugin entscheidet selbst, wer sich anmelden darf.',
|
||||
];
|
||||
}
|
||||
|
||||
/** Gemeinsamer Zustand (Hooks, Menüs, aktuell ausgeführtes Plugin). */
|
||||
function &plugin_state(): array
|
||||
{
|
||||
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'webhooks' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false];
|
||||
static $state = ['hooks' => [], 'filters' => [], 'menus' => [], 'api' => [], 'webhooks' => [], 'logins' => [], 'login_loggedin' => [], 'customer_logins' => [], 'perms' => [], 'loaded' => [], 'current' => null, 'booted' => false, 'shutdown' => false];
|
||||
return $state;
|
||||
}
|
||||
|
||||
@@ -128,6 +130,8 @@ function plugin_filter_permission(string $hook): ?string
|
||||
{
|
||||
if ($hook === 'ticket.view.sidebar' || $hook === 'staff.page.top') return 'ui.sidebar';
|
||||
if (str_starts_with($hook, 'ticket.')) return 'hooks.tickets';
|
||||
if ($hook === 'staff.login.buttons') return 'auth.login'; // Knöpfe auf der Mitarbeiter-Anmeldeseite
|
||||
if ($hook === 'customer.login.buttons') return 'auth.customer'; // Knöpfe auf der Kunden-Anmeldeseite
|
||||
if ($hook === 'backup.list.rows') return 'ui.menu'; // wer einen Reiter unter „Backup“ anbieten darf, darf auch die Liste der Backups ergänzen
|
||||
return null;
|
||||
}
|
||||
@@ -182,6 +186,37 @@ function plugin_register_webhook(string $endpoint, callable $handler): void
|
||||
$state['webhooks'][$slug][$endpoint] = $handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert eine Anmelde-Adresse für Mitarbeiter: GET /staff/plugin_login.php?p=<slug>&e=<name>. Gedacht für externe Anmeldedienste (OpenID Connect, SAML …).
|
||||
* Der Handler erhält ['query' => $_GET, 'ip' => …] (die Sitzung $_SESSION steht zur Verfügung) und gibt eines davon zurück:
|
||||
* ['redirect' => 'https://…'] weiter zum Anmeldedienst (nur https, oder http zu localhost)
|
||||
* ['staff_id' => 12] dieser Mitarbeiter ist geprüft: DeskHub meldet ihn an (nur aktive Konten, mit Eintrag im Admin-Protokoll)
|
||||
* ['error' => 'Text'] zurück zur Anmeldeseite mit dieser Meldung
|
||||
* Den Link zum Starten baut plugin_login_url(). Wer anmelden darf, entscheidet allein das Plugin (Berechtigung auth.login).
|
||||
*
|
||||
* Mit $whenLoggedIn = true läuft die Adresse auch für bereits angemeldete Mitarbeiter (zum Beispiel, um ein Konto beim Anmeldedienst zu verknüpfen).
|
||||
* Der Handler bekommt dann zusätzlich ['staff' => <angemeldeter Mitarbeiter>] und darf nur zurückgeben:
|
||||
* ['redirect' => 'https://…'] weiter zum Anmeldedienst
|
||||
* ['local' => '/staff/…', 'notice' => 'Text'] zurück in den Mitarbeiterbereich, mit Hinweis
|
||||
* ['error' => 'Text', 'back' => '/staff/…'] zurück mit Fehlermeldung
|
||||
* Ein anderer Mitarbeiter kann so nie angemeldet werden (staff_id wird dann abgelehnt).
|
||||
*/
|
||||
function plugin_register_login(string $endpoint, callable $handler, bool $whenLoggedIn = false): void
|
||||
{
|
||||
$state = &plugin_state();
|
||||
$slug = $state['current'];
|
||||
if ($slug === null || !preg_match('/^[a-z0-9_-]{1,40}$/', $endpoint)) return;
|
||||
if (!plugin_permission_allowed('auth.login')) return;
|
||||
$state['logins'][$slug][$endpoint] = $handler;
|
||||
$state['login_loggedin'][$slug][$endpoint] = $whenLoggedIn;
|
||||
}
|
||||
|
||||
/** Adresse (ohne Domain) einer mit plugin_register_login() angemeldeten Anmelde-Adresse. */
|
||||
function plugin_login_url(string $slug, string $endpoint): string
|
||||
{
|
||||
return '/staff/plugin_login.php?p=' . rawurlencode($slug) . '&e=' . rawurlencode($endpoint);
|
||||
}
|
||||
|
||||
function plugin_register_api(string $endpoint, callable $handler, ?string $permission = null): void
|
||||
{
|
||||
if (!plugin_permission_allowed('api.endpoints')) return;
|
||||
@@ -595,6 +630,21 @@ function catalog_refresh_background(): void
|
||||
*
|
||||
* @return array<int, array{slug:string, name:string, installed:string, available:string}>
|
||||
*/
|
||||
/**
|
||||
* Installiert alle Plugin-Updates aus dem Katalog (jedes einzeln, mit Prüfsumme und Signatur; ein Fehler stoppt die anderen nicht).
|
||||
* @return array{updated:list<string>,failed:array<string,string>} Namen mit Version bzw. Slug => Fehlertext
|
||||
*/
|
||||
function plugin_catalog_update_all(PDO $pdo): array
|
||||
{
|
||||
$out = ['updated' => [], 'failed' => []];
|
||||
foreach (plugin_updates_available($pdo) as $u) {
|
||||
$r = plugin_catalog_install($pdo, $u['slug']);
|
||||
if (!empty($r['ok'])) $out['updated'][] = $u['name'] . ' ' . $r['version'];
|
||||
else $out['failed'][$u['slug']] = (string) ($r['error'] ?? 'Unbekannter Fehler');
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
function plugin_updates_available(PDO $pdo): array
|
||||
{
|
||||
$cached = json_decode((string) get_setting('plugin_catalog_cache', ''), true);
|
||||
@@ -983,3 +1033,79 @@ function catalog_thumb(string $kind, array $c): string
|
||||
}
|
||||
return '<div aria-hidden="true" style="' . $box . 'display:flex;align-items:center;justify-content:center;font-weight:700;color:var(--muted,#64748b);font-size:1.3rem">' . e(mb_strtoupper(mb_substr((string) $c['name'], 0, 1))) . '</div>';
|
||||
}
|
||||
|
||||
// ── Anmeldung von Kunden über Plugins (Berechtigung auth.customer) ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Registriert eine Anmelde-Adresse für Kunden: GET /plugin_login.php?p=<slug>&e=<name> (öffentlich, Kundenportal).
|
||||
* Der Handler erhält ['query' => $_GET, 'ip' => …] und gibt zurück:
|
||||
* ['redirect' => 'https://…'] weiter zum Anmeldedienst (nur https, oder http zu localhost)
|
||||
* ['customer' => ['email' => …, 'name' => …, 'create' => true|false, 'via' => 'Dienst']]
|
||||
* diese Person ist vom Dienst bestätigt: DeskHub meldet das Kundenkonto mit dieser
|
||||
* E-Mail-Adresse an; gibt es keines und 'create' ist true, wird es angelegt
|
||||
* ['error' => 'Text'] zurück zur Kunden-Anmeldeseite mit dieser Meldung
|
||||
* Die Rückkehr vom Dienst darf auch über eine mit plugin_register_login() angemeldete Adresse kommen, wenn das Plugin beide Berechtigungen hat.
|
||||
* Das Plugin trägt die Verantwortung, dass die E-Mail-Adresse wirklich bestätigt ist.
|
||||
*/
|
||||
function plugin_register_customer_login(string $endpoint, callable $handler): void
|
||||
{
|
||||
$state = &plugin_state();
|
||||
$slug = $state['current'];
|
||||
if ($slug === null || !preg_match('/^[a-z0-9_-]{1,40}$/', $endpoint)) return;
|
||||
if (!plugin_permission_allowed('auth.customer')) return;
|
||||
$state['customer_logins'][$slug][$endpoint] = $handler;
|
||||
}
|
||||
|
||||
/** Adresse (ohne Domain) einer mit plugin_register_customer_login() angemeldeten Kunden-Anmelde-Adresse. */
|
||||
function plugin_customer_login_url(string $slug, string $endpoint): string
|
||||
{
|
||||
return '/plugin_login.php?p=' . rawurlencode($slug) . '&e=' . rawurlencode($endpoint);
|
||||
}
|
||||
|
||||
/** Gilt die Weiterleitung zu einem Anmeldedienst? Nur https (oder http zu localhost zum Testen), keine Zeichen < Leerzeichen. */
|
||||
function plugin_login_external_url_ok(string $url): bool
|
||||
{
|
||||
if ($url === '' || strlen($url) > 4000 || preg_match('/[\x00-\x20]/', $url)) return false;
|
||||
$p = parse_url($url);
|
||||
if (!$p || empty($p['host'])) return false;
|
||||
$scheme = $p['scheme'] ?? '';
|
||||
return $scheme === 'https' || ($scheme === 'http' && in_array($p['host'], ['localhost', '127.0.0.1', '[::1]'], true));
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet einen vom Plugin bestätigten Kunden an (und legt das Konto an, wenn erlaubt). Endet immer mit einer Weiterleitung.
|
||||
* Ein Plugin ohne Berechtigung auth.customer kommt hier nicht durch.
|
||||
* @param array{email?:string,name?:string,create?:bool,via?:string} $c
|
||||
*/
|
||||
function plugin_customer_login_finish(string $slug, array $c): never
|
||||
{
|
||||
$fail = function (string $message): never {
|
||||
flash('error', $message);
|
||||
redirect('/login.php');
|
||||
};
|
||||
if (!in_array('auth.customer', plugin_state()['perms'][$slug] ?? [], true)) $fail('Diese Anmeldung ist nicht erlaubt.');
|
||||
$email = strtolower(trim((string) ($c['email'] ?? '')));
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 150) $fail('Die E-Mail-Adresse ist ungültig.');
|
||||
if (is_banned($email)) $fail('Mit dieser E-Mail-Adresse ist keine Anmeldung möglich.');
|
||||
$pdo = get_db();
|
||||
$st = $pdo->prepare('SELECT * FROM users WHERE LOWER(email) = ?');
|
||||
$st->execute([$email]);
|
||||
$user = $st->fetch();
|
||||
$created = false;
|
||||
$via = mb_substr((string) ($c['via'] ?? $slug), 0, 60);
|
||||
if (!$user) {
|
||||
if (empty($c['create'])) $fail('Für diese E-Mail-Adresse gibt es kein Kundenkonto.');
|
||||
$name = trim((string) preg_replace('/\s+/u', ' ', strip_tags((string) ($c['name'] ?? ''))));
|
||||
if ($name === '') $name = ucfirst(explode('@', $email)[0]);
|
||||
$name = mb_substr($name, 0, 120);
|
||||
// Ein Passwort gibt es nicht: Wer eines möchte, nutzt „Passwort vergessen“
|
||||
$pdo->prepare('INSERT INTO users (name, email, password_hash, created_at) VALUES (?,?,?,?)')
|
||||
->execute([$name, $email, password_hash(bin2hex(random_bytes(24)), PASSWORD_DEFAULT), db_now()]);
|
||||
$user = ['id' => (int) $pdo->lastInsertId(), 'name' => $name, 'email' => $email];
|
||||
audit_log(null, 'Kundenkonto über ' . $via . ' angelegt: ' . mb_substr($email, 0, 120));
|
||||
$created = true;
|
||||
}
|
||||
customer_login($user);
|
||||
if ($created) flash('success', 'Willkommen! Ihr Konto wurde erstellt.');
|
||||
redirect('/home.php');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
/** Problem records group related tickets and retain root-cause documentation. */
|
||||
|
||||
function ticket_resolution_reasons(): array
|
||||
{
|
||||
return [
|
||||
'fixed' => 'Fehler behoben',
|
||||
'guidance' => 'Anleitung oder Beratung',
|
||||
'duplicate' => 'Doppelt / zusammengeführt',
|
||||
'not_reproducible' => 'Nicht reproduzierbar',
|
||||
'no_action' => 'Keine Maßnahme erforderlich',
|
||||
'spam' => 'Spam oder Sperre',
|
||||
'automated' => 'Automatisch geschlossen',
|
||||
'other' => 'Sonstiges',
|
||||
];
|
||||
}
|
||||
|
||||
function ticket_problem_statuses(): array
|
||||
{
|
||||
return [
|
||||
'investigating' => 'Wird untersucht',
|
||||
'workaround' => 'Workaround verfügbar',
|
||||
'monitoring' => 'Wird beobachtet',
|
||||
'resolved' => 'Behoben',
|
||||
];
|
||||
}
|
||||
|
||||
function ticket_problem_number(int $id): string
|
||||
{
|
||||
return 'PRB-' . str_pad((string) $id, 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
function ticket_problems(PDO $pdo, bool $activeOnly = false): array
|
||||
{
|
||||
$where = $activeOnly ? "WHERE p.status <> 'resolved'" : '';
|
||||
return $pdo->query("SELECT p.*, s.name AS creator_name, (SELECT COUNT(*) FROM problem_tickets pt WHERE pt.problem_id = p.id) AS ticket_count FROM problems p LEFT JOIN staff s ON s.id = p.created_by $where ORDER BY CASE WHEN p.status = 'resolved' THEN 1 ELSE 0 END, p.updated_at DESC, p.id DESC LIMIT 300")->fetchAll();
|
||||
}
|
||||
|
||||
function ticket_problem_get(PDO $pdo, int $id): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT p.*, s.name AS creator_name FROM problems p LEFT JOIN staff s ON s.id = p.created_by WHERE p.id = ?');
|
||||
$stmt->execute([$id]);
|
||||
return $stmt->fetch() ?: null;
|
||||
}
|
||||
|
||||
function ticket_problem_create(PDO $pdo, array $input, int $staffId): int
|
||||
{
|
||||
$title = trim(mb_substr((string) ($input['title'] ?? ''), 0, 200));
|
||||
if ($title === '') throw new InvalidArgumentException('Bitte einen Titel für das Problem angeben.');
|
||||
$status = (string) ($input['status'] ?? 'investigating');
|
||||
if (!isset(ticket_problem_statuses()[$status])) $status = 'investigating';
|
||||
$resolution = trim((string) ($input['resolution'] ?? ''));
|
||||
if ($status === 'resolved' && $resolution === '') throw new InvalidArgumentException('Bitte dokumentieren Sie die dauerhafte Lösung, bevor Sie das Problem als behoben markieren.');
|
||||
$now = db_now();
|
||||
$stmt = $pdo->prepare('INSERT INTO problems (title, description, root_cause, workaround, resolution, status, created_by, updated_by, created_at, updated_at, resolved_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)');
|
||||
$stmt->execute([
|
||||
$title,
|
||||
trim((string) ($input['description'] ?? '')) ?: null,
|
||||
trim((string) ($input['root_cause'] ?? '')) ?: null,
|
||||
trim((string) ($input['workaround'] ?? '')) ?: null,
|
||||
$resolution ?: null,
|
||||
$status, $staffId, $staffId, $now, $now, $status === 'resolved' ? $now : null,
|
||||
]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
function ticket_problem_update(PDO $pdo, int $id, array $input, int $staffId): bool
|
||||
{
|
||||
$title = trim(mb_substr((string) ($input['title'] ?? ''), 0, 200));
|
||||
$status = (string) ($input['status'] ?? 'investigating');
|
||||
if ($title === '') throw new InvalidArgumentException('Bitte einen Titel für das Problem angeben.');
|
||||
if (!isset(ticket_problem_statuses()[$status])) throw new InvalidArgumentException('Der Problemstatus ist ungültig.');
|
||||
$resolution = trim((string) ($input['resolution'] ?? ''));
|
||||
if ($status === 'resolved' && $resolution === '') throw new InvalidArgumentException('Bitte dokumentieren Sie die dauerhafte Lösung, bevor Sie das Problem als behoben markieren.');
|
||||
$current = ticket_problem_get($pdo, $id);
|
||||
if (!$current) return false;
|
||||
$resolvedAt = $status === 'resolved' ? ($current['resolved_at'] ?: db_now()) : null;
|
||||
$stmt = $pdo->prepare('UPDATE problems SET title = ?, description = ?, root_cause = ?, workaround = ?, resolution = ?, status = ?, updated_by = ?, updated_at = ?, resolved_at = ? WHERE id = ?');
|
||||
$stmt->execute([
|
||||
$title,
|
||||
trim((string) ($input['description'] ?? '')) ?: null,
|
||||
trim((string) ($input['root_cause'] ?? '')) ?: null,
|
||||
trim((string) ($input['workaround'] ?? '')) ?: null,
|
||||
$resolution ?: null,
|
||||
$status, $staffId, db_now(), $resolvedAt, $id,
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
function ticket_problem_tickets(PDO $pdo, int $problemId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT t.id, t.ticket_number, t.subject, t.status, t.requester_name, t.created_at, s.name AS linked_by_name FROM problem_tickets pt JOIN tickets t ON t.id = pt.ticket_id LEFT JOIN staff s ON s.id = pt.linked_by WHERE pt.problem_id = ? ORDER BY pt.linked_at DESC, t.id DESC');
|
||||
$stmt->execute([$problemId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function ticket_problem_link(PDO $pdo, int $problemId, int $ticketId, int $staffId): bool
|
||||
{
|
||||
if (!ticket_problem_get($pdo, $problemId)) return false;
|
||||
$ticket = $pdo->prepare('SELECT id FROM tickets WHERE id = ?');
|
||||
$ticket->execute([$ticketId]);
|
||||
if (!$ticket->fetchColumn()) return false;
|
||||
$insertIgnore = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'INSERT OR IGNORE' : 'INSERT IGNORE';
|
||||
$stmt = $pdo->prepare("$insertIgnore INTO problem_tickets (problem_id, ticket_id, linked_by, linked_at) VALUES (?,?,?,?)");
|
||||
$stmt->execute([$problemId, $ticketId, $staffId, db_now()]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Löst die Verknüpfung. Liefert false, wenn es sie nicht gab. */
|
||||
function ticket_problem_unlink(PDO $pdo, int $problemId, int $ticketId): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('DELETE FROM problem_tickets WHERE problem_id = ? AND ticket_id = ?');
|
||||
$stmt->execute([$problemId, $ticketId]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
function ticket_problems_for_ticket(PDO $pdo, int $ticketId): array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT p.* FROM problems p JOIN problem_tickets pt ON pt.problem_id = p.id WHERE pt.ticket_id = ? ORDER BY p.updated_at DESC, p.id DESC');
|
||||
$stmt->execute([$ticketId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function ticket_record_resolution(PDO $pdo, int $ticketId, string $reason, string $note = ''): bool
|
||||
{
|
||||
if (!isset(ticket_resolution_reasons()[$reason])) return false;
|
||||
$pdo->prepare('UPDATE tickets SET resolution_code = ?, resolution_note = ? WHERE id = ?')
|
||||
->execute([$reason, trim(mb_substr($note, 0, 2000)) ?: null, $ticketId]);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -39,9 +39,12 @@ function pseudo_cron_job_definitions(): array
|
||||
'anonymize_data' => ['setting' => 'cron_anonymize_data_last_run', 'interval' => 86400, 'file' => 'anonymize_data.php', 'fn' => 'run_anonymization'],
|
||||
'auto_close_tickets'=> ['setting' => 'cron_auto_close_tickets_last_run','interval' => 86400, 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'],
|
||||
'process_snoozes' => ['setting' => 'cron_process_snoozes_last_run', 'interval' => 120, 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'],
|
||||
'process_mail_queue' => ['setting' => 'cron_process_mail_queue_last_run', 'interval' => 120, 'file' => 'process_mail_queue.php', 'fn' => 'run_process_mail_queue'],
|
||||
'remind_customers' => ['setting' => 'cron_remind_customers_last_run', 'interval' => 3600, 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'],
|
||||
'send_csat' => ['setting' => 'cron_send_csat_last_run', 'interval' => 300, 'file' => 'send_csat.php', 'fn' => 'run_send_csat'],
|
||||
'status_checks' => ['setting' => 'cron_status_checks_last_run', 'interval' => 60, 'file' => 'status_checks.php', 'fn' => 'run_status_checks'],
|
||||
'send_scheduled_replies' => ['setting' => 'cron_send_scheduled_replies_last_run', 'interval' => 60, 'file' => 'send_scheduled_replies.php', 'fn' => 'run_send_scheduled_replies'],
|
||||
'send_report_schedules' => ['setting' => 'cron_send_report_schedules_last_run', 'interval' => 300, 'file' => 'send_report_schedules.php', 'fn' => 'run_send_report_schedules'],
|
||||
'run_plugins' => ['setting' => 'cron_run_plugins_last_run', 'interval' => 60, 'file' => 'run_plugins.php', 'fn' => 'run_plugins_cron'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
/**
|
||||
* Berichte per E-Mail nach Zeitplan: Ein gespeicherter Bericht aus dem Bericht-Baukasten wird täglich, wöchentlich oder monatlich
|
||||
* für einen relativen Zeitraum (z. B. „letzte Woche“) neu berechnet und als Tabelle an Empfänger geschickt
|
||||
* (cron/send_report_schedules.php). Der Bericht wird mit den Abteilungsrechten des Einrichters berechnet.
|
||||
*/
|
||||
|
||||
const REPORT_SCHEDULE_MAX_RECIPIENTS = 10;
|
||||
|
||||
/** @return array<string,string> */
|
||||
function report_schedule_periods(): array
|
||||
{
|
||||
return [
|
||||
'yesterday' => 'Gestern',
|
||||
'last_7' => 'Letzte 7 Tage',
|
||||
'last_week' => 'Letzte Kalenderwoche (Mo–So)',
|
||||
'last_30' => 'Letzte 30 Tage',
|
||||
'last_month' => 'Letzter Kalendermonat',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string,string> */
|
||||
function report_schedule_frequencies(): array
|
||||
{
|
||||
return ['daily' => 'Täglich', 'weekly' => 'Wöchentlich', 'monthly' => 'Monatlich'];
|
||||
}
|
||||
|
||||
/** @return array<int,string> */
|
||||
function report_schedule_weekdays(): array
|
||||
{
|
||||
return [1 => 'Montag', 2 => 'Dienstag', 3 => 'Mittwoch', 4 => 'Donnerstag', 5 => 'Freitag', 6 => 'Samstag', 7 => 'Sonntag'];
|
||||
}
|
||||
|
||||
/** Zeitraum (von, bis; beide Y-m-d) für einen relativen Zeitraum, bezogen auf $now. */
|
||||
function report_period_range(string $period, ?int $now = null): array
|
||||
{
|
||||
$now = $now ?? time();
|
||||
$day = fn(string $mod) => date('Y-m-d', strtotime($mod, $now));
|
||||
switch ($period) {
|
||||
case 'yesterday': return [$day('-1 day'), $day('-1 day')];
|
||||
case 'last_week':
|
||||
$monday = strtotime('monday this week', $now);
|
||||
return [date('Y-m-d', strtotime('-7 days', $monday)), date('Y-m-d', strtotime('-1 day', $monday))];
|
||||
case 'last_30': return [$day('-30 days'), $day('-1 day')];
|
||||
case 'last_month':
|
||||
$first = strtotime(date('Y-m-01', $now));
|
||||
return [date('Y-m-d', strtotime('-1 month', $first)), date('Y-m-d', strtotime('-1 day', $first))];
|
||||
case 'last_7':
|
||||
default: return [$day('-7 days'), $day('-1 day')];
|
||||
}
|
||||
}
|
||||
|
||||
/** Nächster Sendezeitpunkt (Y-m-d H:i:s) nach $after für den Zeitplan. */
|
||||
function report_schedule_next(array $s, ?int $after = null): string
|
||||
{
|
||||
$after = $after ?? time();
|
||||
$time = preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', (string) ($s['send_time'] ?? '')) ? (string) $s['send_time'] : '08:00';
|
||||
$freq = (string) ($s['frequency'] ?? 'weekly');
|
||||
$weekday = max(1, min(7, (int) ($s['weekday'] ?? 1)));
|
||||
$monthday = max(1, min(28, (int) ($s['monthday'] ?? 1)));
|
||||
for ($i = 0; $i <= 62; $i++) {
|
||||
$dayTs = strtotime("+{$i} days", strtotime(date('Y-m-d', $after)));
|
||||
if ($freq === 'weekly' && (int) date('N', $dayTs) !== $weekday) continue;
|
||||
if ($freq === 'monthly' && (int) date('j', $dayTs) !== $monthday) continue;
|
||||
if ($freq === 'daily' && !empty($s['weekdays_only']) && (int) date('N', $dayTs) > 5) continue;
|
||||
$candidate = strtotime(date('Y-m-d', $dayTs) . ' ' . $time . ':00');
|
||||
if ($candidate !== false && $candidate > $after) return date('Y-m-d H:i:s', $candidate);
|
||||
}
|
||||
return date('Y-m-d H:i:s', $after + 86400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft die Empfängerliste (Komma, Semikolon oder Zeilenumbruch getrennt). Nicht-Administratoren dürfen nur an aktive Mitarbeiter senden.
|
||||
* @return array{0:list<string>,1:?string} [Adressen, Fehlermeldung]
|
||||
*/
|
||||
function report_schedule_recipients(PDO $pdo, string $input, array $staff): array
|
||||
{
|
||||
$list = [];
|
||||
foreach (preg_split('/[\s,;]+/', trim($input), -1, PREG_SPLIT_NO_EMPTY) ?: [] as $addr) {
|
||||
$addr = strtolower(trim($addr));
|
||||
if (!filter_var($addr, FILTER_VALIDATE_EMAIL)) return [[], 'Ungültige E-Mail-Adresse: ' . $addr];
|
||||
$list[$addr] = $addr;
|
||||
}
|
||||
$list = array_values($list);
|
||||
if (!$list) return [[], 'Bitte mindestens einen Empfänger angeben.'];
|
||||
if (count($list) > REPORT_SCHEDULE_MAX_RECIPIENTS) return [[], 'Höchstens ' . REPORT_SCHEDULE_MAX_RECIPIENTS . ' Empfänger je Zeitplan.'];
|
||||
if (($staff['role'] ?? '') !== 'admin') {
|
||||
$known = array_map('strtolower', $pdo->query('SELECT email FROM staff WHERE is_active = 1')->fetchAll(PDO::FETCH_COLUMN));
|
||||
foreach ($list as $addr) {
|
||||
if (!in_array($addr, $known, true)) return [[], 'Nur Administratoren dürfen Berichte an Adressen außerhalb der Mitarbeiter senden: ' . $addr];
|
||||
}
|
||||
}
|
||||
return [$list, null];
|
||||
}
|
||||
|
||||
/** Legt einen Zeitplan an. Gibt eine Fehlermeldung zurück oder null. */
|
||||
function report_schedule_create(PDO $pdo, array $staff, array $in): ?string
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM saved_reports WHERE id = ?');
|
||||
$stmt->execute([(int) ($in['report_id'] ?? 0)]);
|
||||
$report = $stmt->fetch();
|
||||
if (!$report || ((int) $report['staff_id'] !== (int) $staff['id'] && !(int) $report['is_shared'] && ($staff['role'] ?? '') !== 'admin')) return 'Bericht nicht gefunden.';
|
||||
[$recipients, $err] = report_schedule_recipients($pdo, (string) ($in['recipients'] ?? ''), $staff);
|
||||
if ($err) return $err;
|
||||
$freq = isset(report_schedule_frequencies()[$in['frequency'] ?? '']) ? (string) $in['frequency'] : 'weekly';
|
||||
$period = isset(report_schedule_periods()[$in['period'] ?? '']) ? (string) $in['period'] : 'last_7';
|
||||
$time = preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', (string) ($in['send_time'] ?? '')) ? (string) $in['send_time'] : '08:00';
|
||||
$row = [
|
||||
'frequency' => $freq, 'weekday' => max(1, min(7, (int) ($in['weekday'] ?? 1))), 'monthday' => max(1, min(28, (int) ($in['monthday'] ?? 1))),
|
||||
'send_time' => $time, 'weekdays_only' => !empty($in['weekdays_only']) ? 1 : 0,
|
||||
];
|
||||
// Weniger Mails: Standard ist „nur bei Daten“ und „nur bei Änderung“ (ein leeres Kontrollkästchen im Formular schaltet es ab, siehe „quiet_form“)
|
||||
$onlyData = !empty($in['quiet_form']) ? (!empty($in['only_if_data']) ? 1 : 0) : 1;
|
||||
$skipSame = !empty($in['quiet_form']) ? (!empty($in['skip_unchanged']) ? 1 : 0) : 1;
|
||||
$pdo->prepare('INSERT INTO report_schedules (report_id, staff_id, recipients, frequency, weekday, monthday, send_time, period, enabled, next_run_at, created_at, only_if_data, skip_unchanged, weekdays_only) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)')
|
||||
->execute([(int) $report['id'], (int) $staff['id'], implode(', ', $recipients), $freq, $row['weekday'], $row['monthday'], $time, $period, 1, report_schedule_next($row), db_now(), $onlyData, $skipSame, $row['weekdays_only']]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Baut Betreff und HTML-Text für einen Zeitplan. @return array{0:string,1:string,2:int,3:string}|null [Betreff, HTML, Zeilen, Prüfsumme] oder null, wenn der Bericht fehlt. */
|
||||
function report_schedule_render(PDO $pdo, array $schedule, ?int $now = null): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT * FROM saved_reports WHERE id = ?');
|
||||
$stmt->execute([(int) $schedule['report_id']]);
|
||||
$saved = $stmt->fetch();
|
||||
if (!$saved) return null;
|
||||
$owner = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
|
||||
$owner->execute([(int) $schedule['staff_id']]);
|
||||
$ownerRow = $owner->fetch();
|
||||
if (!$ownerRow || (isset($ownerRow['is_active']) && (int) $ownerRow['is_active'] === 0)) return null;
|
||||
|
||||
[$from, $to] = report_period_range((string) $schedule['period'], $now);
|
||||
$cfgIn = json_decode((string) $saved['config'], true) ?: [];
|
||||
$cfgIn['from'] = $from;
|
||||
$cfgIn['to'] = $to;
|
||||
$cfg = report_clean_config($pdo, $cfgIn);
|
||||
$report = report_run($pdo, $cfg, staff_department_ids($ownerRow));
|
||||
|
||||
$fmt = function ($v, string $unit): string {
|
||||
if ($v === null || $v === '') return '–';
|
||||
if ($unit === '%' || $unit === 'Std') return number_format((float) $v, 1, ',', '.') . ($unit === '%' ? ' %' : ' Std');
|
||||
return (string) $v;
|
||||
};
|
||||
$th = 'style="text-align:left;padding:6px 10px;border-bottom:2px solid #cbd5e1;background:#f1f5f9;font-size:13px"';
|
||||
$td = 'style="padding:6px 10px;border-bottom:1px solid #e2e8f0;font-size:13px"';
|
||||
$html = '<h2 style="margin:0 0 6px;font-size:20px;color:#0f172a">' . e($saved['name']) . '</h2>'
|
||||
. '<p style="margin:0 0 14px;color:#64748b;font-size:14px">Zeitraum: ' . e(date('d.m.Y', strtotime($from))) . ' bis ' . e(date('d.m.Y', strtotime($to))) . '</p>'
|
||||
. '<table style="border-collapse:collapse;width:100%"><thead><tr>';
|
||||
foreach ($report['columns'] as $c) $html .= '<th ' . $th . '>' . e($c['label']) . '</th>';
|
||||
$html .= '</tr></thead><tbody>';
|
||||
foreach ($report['rows'] as $row) {
|
||||
$html .= '<tr><td ' . $td . '><strong>' . e((string) $row['label']) . '</strong></td>';
|
||||
foreach (array_slice($report['columns'], 1) as $c) $html .= '<td ' . $td . '>' . e($fmt($row[$c['key']] ?? null, (string) $c['unit'])) . '</td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
if (!$report['rows']) {
|
||||
$html .= '<tr><td ' . $td . ' colspan="' . count($report['columns']) . '">Keine Tickets im Zeitraum.</td></tr>';
|
||||
} else {
|
||||
$html .= '<tr><td ' . $td . '><strong>Gesamt</strong></td>';
|
||||
foreach (array_slice($report['columns'], 1) as $c) $html .= '<td ' . $td . '><strong>' . e($fmt($report['totals'][$c['key']] ?? null, (string) $c['unit'])) . '</strong></td>';
|
||||
$html .= '</tr>';
|
||||
}
|
||||
$html .= '</tbody></table><p style="margin:16px 0 0;color:#94a3b8;font-size:12px">Automatisch versendet von DeskHub (' . e(strtolower(report_schedule_frequencies()[$schedule['frequency']] ?? '')) . ').</p>';
|
||||
if (function_exists('email_wrap')) $html = email_wrap($html);
|
||||
$subject = 'DeskHub-Bericht: ' . $saved['name'] . ' (' . date('d.m.', strtotime($from)) . '–' . date('d.m.Y', strtotime($to)) . ')';
|
||||
// Prüfsumme nur der Zahlen (ohne Zeitraum): gleiche Zahlen wie beim letzten Versand = „nichts Neues“
|
||||
$hash = sha1(json_encode([$report['columns'], $report['rows'], $report['totals']], JSON_UNESCAPED_UNICODE) ?: '');
|
||||
return [$subject, $html, count($report['rows']), $hash];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet einen Zeitplan. Mit $respectQuiet = true (automatischer Lauf) wird nichts gesendet, wenn der Bericht leer ist („nur bei Daten“)
|
||||
* oder dieselben Zahlen enthält wie beim letzten Versand („nur bei Änderung“); „Test senden“ sendet immer.
|
||||
* @return array{sent:int,error:?string,skipped:?string,hash:?string}
|
||||
*/
|
||||
function report_schedule_send(PDO $pdo, array $schedule, bool $respectQuiet = false): array
|
||||
{
|
||||
$rendered = report_schedule_render($pdo, $schedule);
|
||||
if ($rendered === null) return ['sent' => 0, 'error' => 'Bericht oder Einrichter existiert nicht mehr oder ist deaktiviert.', 'skipped' => null, 'hash' => null];
|
||||
[$subject, $html, $rows, $hash] = $rendered;
|
||||
if ($respectQuiet) {
|
||||
if (!empty($schedule['only_if_data']) && $rows === 0) return ['sent' => 0, 'error' => null, 'skipped' => 'Übersprungen: keine Daten im Zeitraum', 'hash' => $hash];
|
||||
if (!empty($schedule['skip_unchanged']) && !empty($schedule['last_hash']) && hash_equals((string) $schedule['last_hash'], $hash)) {
|
||||
return ['sent' => 0, 'error' => null, 'skipped' => 'Übersprungen: keine Änderung seit dem letzten Versand', 'hash' => $hash];
|
||||
}
|
||||
}
|
||||
$sent = 0;
|
||||
foreach (preg_split('/[\s,;]+/', (string) $schedule['recipients'], -1, PREG_SPLIT_NO_EMPTY) ?: [] as $to) {
|
||||
if (send_mail($to, $subject, $html)) $sent++;
|
||||
}
|
||||
return ['sent' => $sent, 'error' => $sent === 0 ? 'Kein Empfänger erreicht (E-Mail-Versand prüfen).' : null, 'skipped' => null, 'hash' => $hash];
|
||||
}
|
||||
|
||||
/** Sendet alle fälligen Zeitpläne. @return array{sent:int,failed:int} */
|
||||
function report_schedules_run_due(PDO $pdo): array
|
||||
{
|
||||
$now = db_now();
|
||||
$stmt = $pdo->prepare('SELECT * FROM report_schedules WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ? ORDER BY next_run_at LIMIT 50');
|
||||
$stmt->execute([$now]);
|
||||
$ok = 0; $bad = 0;
|
||||
foreach ($stmt->fetchAll() as $s) {
|
||||
// Zeitplan atomar weiterschalten: nur ein Lauf darf senden, auch bei gleichzeitigen Anfragen
|
||||
$next = report_schedule_next($s);
|
||||
$claim = $pdo->prepare('UPDATE report_schedules SET next_run_at = ? WHERE id = ? AND next_run_at = ?');
|
||||
$claim->execute([$next, (int) $s['id'], $s['next_run_at']]);
|
||||
if ($claim->rowCount() === 0) continue;
|
||||
try {
|
||||
$r = report_schedule_send($pdo, $s, true);
|
||||
} catch (Throwable $e) {
|
||||
$r = ['sent' => 0, 'error' => $e->getMessage(), 'skipped' => null, 'hash' => null];
|
||||
}
|
||||
$pdo->prepare('UPDATE report_schedules SET last_sent_at = ?, last_error = ?, last_note = ?, last_hash = ? WHERE id = ?')
|
||||
->execute([$r['sent'] > 0 ? $now : $s['last_sent_at'], $r['error'], $r['skipped'], $r['sent'] > 0 ? $r['hash'] : ($s['last_hash'] ?? null), (int) $s['id']]);
|
||||
if ($r['error'] !== null) $bad++;
|
||||
elseif ($r['skipped'] === null) $ok++;
|
||||
}
|
||||
return ['sent' => $ok, 'failed' => $bad];
|
||||
}
|
||||
@@ -16,6 +16,7 @@ function report_dimensions(PDO $pdo): array
|
||||
'team' => ['Team', 'COALESCE(tm.name, \'ohne Team\')', 'LEFT JOIN teams tm ON tm.id = t.team_id'],
|
||||
'priority' => ['Priorität', 'p.name', 'JOIN priorities p ON p.id = t.priority_id'],
|
||||
'status' => ['Status', 't.status', ''],
|
||||
'resolution_code' => ['Abschlussgrund', "CASE t.resolution_code WHEN 'fixed' THEN 'Fehler behoben' WHEN 'guidance' THEN 'Anleitung oder Beratung' WHEN 'duplicate' THEN 'Doppelt / zusammengeführt' WHEN 'not_reproducible' THEN 'Nicht reproduzierbar' WHEN 'no_action' THEN 'Keine Maßnahme erforderlich' WHEN 'spam' THEN 'Spam oder Sperre' WHEN 'automated' THEN 'Automatisch geschlossen' WHEN 'other' THEN 'Sonstiges' ELSE 'Nicht erfasst' END", ''],
|
||||
'source' => ['Quelle', 't.source', ''],
|
||||
'day' => ['Tag', $bucket('%Y-%m-%d', '%Y-%m-%d'), ''],
|
||||
'week' => ['Woche', $bucket('%Y-W%W', '%x-W%v'), ''],
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
/**
|
||||
* Zeitversetzt gesendete Antworten: Eine öffentliche Antwort wird geschrieben und zu einem späteren Zeitpunkt an den Kunden
|
||||
* geschickt (Hintergrundjob cron/send_scheduled_replies.php, alle Minute über den eingebauten Cron oder echten Cronjob).
|
||||
* Bis dahin steht sie im Ticket als „geplant“ und lässt sich vom Verfasser oder einem Administrator abbrechen.
|
||||
* Anhänge werden bei zeitversetztem Senden nicht unterstützt.
|
||||
*/
|
||||
|
||||
const SCHEDULED_REPLY_MAX_DAYS = 90;
|
||||
|
||||
/** Parst die Eingabe eines datetime-local-Feldes (Y-m-d\TH:i). Gibt "Y-m-d H:i:s" oder null zurück. */
|
||||
function scheduled_reply_parse_time(string $input): ?string
|
||||
{
|
||||
$input = trim($input);
|
||||
if (!preg_match('/^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2})(?::\d{2})?$/', $input, $m)) return null;
|
||||
$ts = strtotime($m[1] . ' ' . $m[2] . ':00');
|
||||
return $ts === false ? null : date('Y-m-d H:i:s', $ts);
|
||||
}
|
||||
|
||||
/** Legt eine geplante Antwort an. Gibt eine Fehlermeldung zurück oder null bei Erfolg. $message ist bereits bereinigtes HTML. */
|
||||
function scheduled_reply_create(PDO $pdo, array $ticket, int $staffId, string $message, ?string $sendAt, array $opts = []): ?string
|
||||
{
|
||||
if (trim(strip_tags($message)) === '') return 'Bitte geben Sie eine Nachricht ein.';
|
||||
if ($sendAt === null) return 'Bitte Datum und Uhrzeit für das Senden angeben.';
|
||||
$ts = strtotime($sendAt);
|
||||
if ($ts === false || $ts < time() + 30) return 'Der Sendezeitpunkt muss in der Zukunft liegen.';
|
||||
if ($ts > time() + SCHEDULED_REPLY_MAX_DAYS * 86400) return 'Der Sendezeitpunkt darf höchstens ' . SCHEDULED_REPLY_MAX_DAYS . ' Tage entfernt liegen.';
|
||||
$status = (string) ($opts['set_status'] ?? 'answered');
|
||||
if (!in_array($status, ['answered', 'pending', 'resolved', 'closed'], true)) $status = 'answered';
|
||||
$code = (string) ($opts['resolution_code'] ?? '');
|
||||
if (in_array($status, ['resolved', 'closed'], true) && !isset(ticket_resolution_reasons()[$code])) return 'Bitte beim Schließen einen Abschlussgrund auswählen.';
|
||||
$pdo->prepare(
|
||||
'INSERT INTO scheduled_replies (ticket_id, staff_id, message, set_status, resolution_code, resolution_note, signature_choice, send_at, state, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)'
|
||||
)->execute([
|
||||
(int) $ticket['id'], $staffId, $message, $status,
|
||||
in_array($status, ['resolved', 'closed'], true) ? $code : null,
|
||||
trim(mb_substr((string) ($opts['resolution_note'] ?? ''), 0, 2000)) ?: null,
|
||||
isset($opts['signature_choice']) ? (string) $opts['signature_choice'] : null,
|
||||
$sendAt, 'pending', db_now(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> Ausstehende geplante Antworten eines Tickets. */
|
||||
function scheduled_replies_pending(PDO $pdo, int $ticketId): array
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT r.*, s.name AS staff_name FROM scheduled_replies r LEFT JOIN staff s ON s.id = r.staff_id WHERE r.ticket_id = ? AND r.state = 'pending' ORDER BY r.send_at");
|
||||
$stmt->execute([$ticketId]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
/** Bricht eine geplante Antwort ab (Verfasser oder Administrator). */
|
||||
function scheduled_reply_cancel(PDO $pdo, int $id, int $ticketId, array $staff): bool
|
||||
{
|
||||
$stmt = $pdo->prepare("UPDATE scheduled_replies SET state = 'cancelled' WHERE id = ? AND ticket_id = ? AND state = 'pending' AND (staff_id = ? OR ? = 'admin')");
|
||||
$stmt->execute([$id, $ticketId, (int) $staff['id'], (string) ($staff['role'] ?? '')]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet eine fällige geplante Antwort: legt die Nachricht im Ticket an, setzt den Status, benachrichtigt den Kunden und die Beobachter.
|
||||
* Gibt true bei Erfolg zurück; bei Fehlern steht der Grund in der Zeile (state = failed).
|
||||
*/
|
||||
function scheduled_reply_deliver(PDO $pdo, array $row): bool
|
||||
{
|
||||
// Atomar übernehmen: nur ein Lauf darf die Antwort senden
|
||||
$claim = $pdo->prepare("UPDATE scheduled_replies SET state = 'sending' WHERE id = ? AND state = 'pending'");
|
||||
$claim->execute([(int) $row['id']]);
|
||||
if ($claim->rowCount() === 0) return false;
|
||||
$fail = function (string $why) use ($pdo, $row): bool {
|
||||
$pdo->prepare("UPDATE scheduled_replies SET state = 'failed', error = ? WHERE id = ?")->execute([mb_substr($why, 0, 500), (int) $row['id']]);
|
||||
return false;
|
||||
};
|
||||
try {
|
||||
$t = $pdo->prepare('SELECT * FROM tickets WHERE id = ?');
|
||||
$t->execute([(int) $row['ticket_id']]);
|
||||
$ticket = $t->fetch();
|
||||
if (!$ticket) return $fail('Das Ticket existiert nicht mehr.');
|
||||
$s = $pdo->prepare('SELECT * FROM staff WHERE id = ?');
|
||||
$s->execute([(int) $row['staff_id']]);
|
||||
$staff = $s->fetch();
|
||||
if (!$staff || (isset($staff['is_active']) && (int) $staff['is_active'] === 0)) return $fail('Der Verfasser ist nicht mehr aktiv.');
|
||||
|
||||
$now = db_now();
|
||||
$message = (string) $row['message'];
|
||||
$newStatus = (string) $row['set_status'];
|
||||
$pdo->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?)')
|
||||
->execute([$ticket['id'], 'staff', $staff['id'], $message, 0, $now]);
|
||||
$threadId = (int) $pdo->lastInsertId();
|
||||
|
||||
$terminal = in_array($newStatus, ['resolved', 'closed'], true);
|
||||
$resolvedAt = $newStatus === 'resolved' && empty($ticket['resolved_at']) ? $now : $ticket['resolved_at'];
|
||||
$closedAt = $newStatus === 'closed' && empty($ticket['closed_at']) ? $now : $ticket['closed_at'];
|
||||
$pdo->prepare('UPDATE tickets SET status = ?, updated_at = ?, first_responded_at = COALESCE(first_responded_at, ?), assigned_staff_id = COALESCE(assigned_staff_id, ?), resolved_at = ?, closed_at = ?, resolution_code = ?, resolution_note = ? WHERE id = ?')
|
||||
->execute([
|
||||
$newStatus, $now, $now, (int) $staff['id'], $resolvedAt, $closedAt,
|
||||
$terminal ? $row['resolution_code'] : $ticket['resolution_code'],
|
||||
$terminal ? ($row['resolution_note'] !== null && $row['resolution_note'] !== '' ? $row['resolution_note'] : $ticket['resolution_note']) : $ticket['resolution_note'],
|
||||
$ticket['id'],
|
||||
]);
|
||||
if (function_exists('ticket_sla_status_transition')) ticket_sla_status_transition($pdo, (int) $ticket['id'], (string) $ticket['status'], $newStatus, (int) $staff['id']);
|
||||
|
||||
$signature = resolve_reply_signature($pdo, $staff, (int) $ticket['department_id'], $row['signature_choice'] !== null ? (string) $row['signature_choice'] : null);
|
||||
$collab = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE ticket_id = ?');
|
||||
$collab->execute([$ticket['id']]);
|
||||
$ccEmails = array_column($collab->fetchAll(), 'email');
|
||||
notify_customer_new_reply($ticket, $message, $signature, $ccEmails);
|
||||
save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket);
|
||||
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Geplante Antwort gesendet (eingeplant am ' . format_dt((string) $row['created_at']) . ')');
|
||||
if (file_exists(__DIR__ . '/push.php')) {
|
||||
require_once __DIR__ . '/push.php';
|
||||
if (function_exists('push_new_reply')) push_new_reply($ticket, $staff['name']);
|
||||
}
|
||||
ticket_followers_notify($ticket, 'Antwort von ' . $staff['name'], (int) $staff['id']);
|
||||
webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'staff', 'internal' => false, 'scheduled' => true]);
|
||||
|
||||
$pdo->prepare("UPDATE scheduled_replies SET state = 'sent', sent_at = ? WHERE id = ?")->execute([$now, (int) $row['id']]);
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
return $fail($e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Sendet alle fälligen geplanten Antworten. @return array{sent:int,failed:int} */
|
||||
function scheduled_replies_run_due(PDO $pdo, int $limit = 50): array
|
||||
{
|
||||
$stmt = $pdo->prepare("SELECT * FROM scheduled_replies WHERE state = 'pending' AND send_at <= ? ORDER BY send_at LIMIT " . (int) $limit);
|
||||
$stmt->execute([db_now()]);
|
||||
$sent = 0; $failed = 0;
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
scheduled_reply_deliver($pdo, $row) ? $sent++ : $failed++;
|
||||
}
|
||||
return ['sent' => $sent, 'failed' => $failed];
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
/**
|
||||
* „Von allen Geräten abmelden“: beendet alle Anmeldungen eines Mitarbeiters auf einmal.
|
||||
* - Browser-Sitzungen (current_staff() prüft den Zeitpunkt „login_guard_revoke_<id>“, siehe includes/login_guard.php)
|
||||
* - Anmeldungen der Apps (Android, Windows-Programm, Chrome-Erweiterung): ihre Tokens werden gelöscht, die Programme fragen danach neu nach der Anmeldung
|
||||
* - Push: Browser-Abonnements und Android-Geräte, damit ein verlorenes Gerät keine Benachrichtigungen mehr bekommt
|
||||
* - Ticket-Sperren des Mitarbeiters und laufende Zeiterfassung
|
||||
* Das Passwort bleibt unverändert; wer es zusätzlich ändern will, tut das im Profil oder über „Das war ich nicht“.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param bool $keepCurrentSession true = die Sitzung, in der das gerade geschieht, bleibt angemeldet (nur bei eigener Abmeldung sinnvoll)
|
||||
* @return array{at:int,apps:int,browsers:int,devices:int,locks:int}
|
||||
*/
|
||||
function staff_sign_out_everywhere(PDO $pdo, int $staffId, ?int $byStaffId = null, bool $keepCurrentSession = false): array
|
||||
{
|
||||
$st = $pdo->prepare('SELECT id, name, email FROM staff WHERE id = ?');
|
||||
$st->execute([$staffId]);
|
||||
$staff = $st->fetch();
|
||||
$res = ['at' => 0, 'apps' => 0, 'browsers' => 0, 'devices' => 0, 'locks' => 0];
|
||||
if (!$staff) return $res;
|
||||
|
||||
$count = function (string $table) use ($pdo, $staffId): int {
|
||||
try {
|
||||
$c = $pdo->prepare("SELECT COUNT(*) FROM {$table} WHERE staff_id = ?");
|
||||
$c->execute([$staffId]);
|
||||
return (int) $c->fetchColumn();
|
||||
} catch (Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
$res['apps'] = $count('mobile_api_tokens');
|
||||
$res['browsers'] = $count('push_subscriptions');
|
||||
$res['devices'] = $count('push_devices');
|
||||
|
||||
$at = time();
|
||||
$res['at'] = $at;
|
||||
set_setting('login_guard_revoke_' . $staffId, (string) $at);
|
||||
foreach (['mobile_api_tokens', 'push_subscriptions', 'push_devices'] as $table) {
|
||||
try { $pdo->prepare("DELETE FROM {$table} WHERE staff_id = ?")->execute([$staffId]); } catch (Throwable $e) { error_log('signout ' . $table . ': ' . $e->getMessage()); }
|
||||
}
|
||||
try {
|
||||
$l = $pdo->prepare('UPDATE tickets SET locked_by = NULL, locked_at = NULL, updated_at = updated_at WHERE locked_by = ?');
|
||||
$l->execute([$staffId]);
|
||||
$res['locks'] = $l->rowCount();
|
||||
} catch (Throwable $e) {
|
||||
error_log('signout locks: ' . $e->getMessage());
|
||||
}
|
||||
if (function_exists('finalize_all_sessions_for_staff')) {
|
||||
try { finalize_all_sessions_for_staff($pdo, $staffId); } catch (Throwable $e) { error_log('signout time: ' . $e->getMessage()); }
|
||||
}
|
||||
|
||||
// Die eigene Sitzung bleibt, wenn gewünscht: Ihr Anmeldezeitpunkt liegt dann hinter dem Zeitpunkt des Widerrufs
|
||||
if ($keepCurrentSession && $byStaffId === $staffId && PHP_SAPI !== 'cli' && session_status() === PHP_SESSION_ACTIVE) {
|
||||
$_SESSION['staff_login_at'] = $at + 1;
|
||||
}
|
||||
|
||||
$self = $byStaffId === null || $byStaffId === $staffId;
|
||||
$detail = $self ? 'Von allen Geräten abgemeldet (eigene Veranlassung)' : 'Von allen Geräten abgemeldet durch Administrator #' . (int) $byStaffId;
|
||||
try { login_guard_record($pdo, 'signout', $staffId, (string) $staff['email'], 'web', $detail); } catch (Throwable $e) { error_log('signout log: ' . $e->getMessage()); }
|
||||
audit_log($byStaffId ?? $staffId, "Mitarbeiter #{$staffId} von allen Geräten abgemeldet ({$res['apps']} App-Anmeldungen, {$res['browsers']} Browser-Push, {$res['devices']} App-Geräte)");
|
||||
|
||||
if (!$self && trim((string) $staff['email']) !== '') {
|
||||
send_mail((string) $staff['email'], 'Sie wurden von allen Geräten abgemeldet',
|
||||
email_wrap('<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Von allen Geräten abgemeldet</h2><p style="margin:0;color:#334155">Ein Administrator hat Ihre Anmeldungen in DeskHub beendet. Bitte melden Sie sich neu an. Haben Sie das nicht erwartet, wenden Sie sich an Ihre Administration.</p>'));
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
|
||||
/** Aktive App-Anmeldungen eines Mitarbeiters (Android, Windows, Chrome) für die Anzeige. @return list<array<string,mixed>> */
|
||||
function staff_app_logins(PDO $pdo, int $staffId): array
|
||||
{
|
||||
$s = $pdo->prepare('SELECT device_name, last_used_at, created_at FROM mobile_api_tokens WHERE staff_id = ? AND revoked_at IS NULL AND expires_at > ? ORDER BY COALESCE(last_used_at, created_at) DESC');
|
||||
$s->execute([$staffId, db_now()]);
|
||||
return $s->fetchAll();
|
||||
}
|
||||
@@ -86,7 +86,19 @@ class SmtpMailer
|
||||
STREAM_CLIENT_CONNECT, stream_context_create(['ssl' => ['verify_peer' => true, 'verify_peer_name' => true]])
|
||||
);
|
||||
if (!$this->socket) {
|
||||
throw new \RuntimeException("Verbindung zu {$this->host}:{$this->port} fehlgeschlagen: {$errstr} ({$errno})");
|
||||
// Bei SSL-Fehlern bleibt $errstr oft leer; der eigentliche Grund steht dann in der letzten PHP-Warnung
|
||||
$reason = trim((string) $errstr);
|
||||
if ($reason === '') {
|
||||
$last = (string) (error_get_last()['message'] ?? '');
|
||||
$reason = trim(preg_replace('/^stream_socket_client\(\):\s*/', '', $last));
|
||||
}
|
||||
if ($reason === '') $reason = $this->encryption === 'ssl'
|
||||
? 'keine Antwort. Prüfen, ob Port und Verschlüsselung zusammenpassen (SSL = 465, STARTTLS = 587) und ob der Server ausgehend verbinden darf'
|
||||
: 'keine Antwort (Zeitüberschreitung oder Sperre durch eine Firewall)';
|
||||
if ($this->encryption === 'ssl' && $this->port === 587) $reason .= ' – Port 587 ist für STARTTLS gedacht, bei „SSL/TLS“ gehört Port 465 dazu';
|
||||
elseif ($this->encryption === 'tls' && $this->port === 465) $reason .= ' – Port 465 ist für SSL/TLS gedacht, bei „STARTTLS“ gehört Port 587 dazu';
|
||||
elseif (stripos($reason, 'certificate') !== false || stripos($reason, 'verify') !== false) $reason .= ' – möglicherweise fehlt auf diesem Server die Liste vertrauenswürdiger Zertifizierungsstellen (CA-Bundle: curl.cainfo/openssl.cafile in der PHP-Konfiguration)';
|
||||
throw new \RuntimeException("Verbindung zu {$this->host}:{$this->port} fehlgeschlagen: {$reason}" . ($errno ? " ({$errno})" : ''));
|
||||
}
|
||||
stream_set_timeout($this->socket, $this->timeout);
|
||||
$this->readResponse([220]);
|
||||
@@ -123,6 +135,11 @@ class SmtpMailer
|
||||
$headers[] = "From: {$from}";
|
||||
$headers[] = "Subject: {$encodedSubject}";
|
||||
$headers[] = "MIME-Version: 1.0";
|
||||
$headers[] = "X-DeskHub-Mail: 1"; // erkennt der E-Mail-Abruf wieder und macht daraus kein Ticket
|
||||
// Mails zu einem Ticket bekommen eine Message-ID mit der Ticketnummer: Antwortet der Kunde, nennt sein Mailprogramm sie in
|
||||
// In-Reply-To/References, und der E-Mail-Abruf ordnet die Antwort auch dann dem Ticket zu, wenn der Betreff geändert wurde.
|
||||
$mid = function_exists('mail_ticket_message_id') ? mail_ticket_message_id($subject, preg_match('/@([^>\s]+)>?\s*$/', $from, $hm) ? $hm[1] : ($_SERVER['SERVER_NAME'] ?? 'localhost')) : null;
|
||||
if ($mid !== null) $headers[] = "Message-ID: {$mid}";
|
||||
$headers[] = "Content-Type: text/html; charset=UTF-8";
|
||||
$headers[] = "Content-Transfer-Encoding: base64";
|
||||
return implode("\r\n", $headers) . "\r\n";
|
||||
|
||||
@@ -42,6 +42,16 @@ function status_app_connection(PDO $pdo, string $platform): array
|
||||
return ['online' => $lastSeen !== null && strtotime($lastSeen) >= strtotime($cutoff), 'last_seen' => $lastSeen, 'timeout_minutes' => $timeoutMinutes];
|
||||
}
|
||||
|
||||
/** Prüfadresse der Plugin-Webseite (ihr health.php, das ohne Zugangsschlüssel antwortet); leer, wenn kein offizieller Katalog eingetragen ist. */
|
||||
function status_plugin_site_url(): string
|
||||
{
|
||||
if (!function_exists('catalog_defaults')) return '';
|
||||
$parts = parse_url((string) (catalog_defaults()['plugins_url'] ?? ''));
|
||||
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return '';
|
||||
$path = rtrim(str_replace(chr(92), '/', dirname((string) ($parts['path'] ?? '/'))), '/');
|
||||
return 'https://' . $parts['host'] . (!empty($parts['port']) ? ':' . (int) $parts['port'] : '') . $path . '/health.php';
|
||||
}
|
||||
|
||||
function status_component_monitor_target(PDO $pdo, array $component): ?array
|
||||
{
|
||||
$type = (string) ($component['monitor_type'] ?? 'auto');
|
||||
@@ -51,7 +61,14 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array
|
||||
|
||||
if ($type === 'manual') return null;
|
||||
if ($type === 'auto') {
|
||||
if ($target !== '') return ['type' => 'http', 'target' => $target, 'label' => 'Webadresse'];
|
||||
if ($target !== '') {
|
||||
if (str_contains($name, 'minecraft')) return ['type' => 'minecraft', 'target' => $target, 'label' => 'Minecraft-Server'];
|
||||
return ['type' => 'http', 'target' => $target, 'label' => 'Webadresse'];
|
||||
}
|
||||
if (str_contains($name, 'plugin') && (str_contains($name, 'webseite') || str_contains($name, 'website') || str_contains($name, 'seite') || str_contains($name, 'katalog'))) {
|
||||
$url = status_plugin_site_url();
|
||||
return $url !== '' ? ['type' => 'plugin_site', 'target' => $url, 'label' => 'Plugin-Webseite'] : null;
|
||||
}
|
||||
if (str_contains($name, 'android') || (str_contains($category, 'app') && str_contains($name, 'mobil'))) return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung'];
|
||||
if (str_contains($name, 'windows') || str_contains($name, 'desktop') || str_contains($name, 'windows-programm')) return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung'];
|
||||
if (str_contains($name, 'datenbank') || str_contains($name, 'database')) return ['type' => 'database', 'target' => '', 'label' => 'Datenbank'];
|
||||
@@ -61,6 +78,10 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array
|
||||
if ((str_contains($name, 'push') || str_contains($category, 'benachrichtigung')) && function_exists('fcm_configured') && fcm_configured()) return ['type' => 'fcm', 'target' => '', 'label' => 'Push-Dienst'];
|
||||
return null;
|
||||
}
|
||||
if ($type === 'plugin_site') {
|
||||
$url = status_plugin_site_url();
|
||||
return $url !== '' ? ['type' => 'plugin_site', 'target' => $url, 'label' => 'Plugin-Webseite'] : null;
|
||||
}
|
||||
if ($type === 'database') return ['type' => 'database', 'target' => '', 'label' => 'Datenbank'];
|
||||
if ($type === 'android_app') return ['type' => 'android_app', 'target' => '', 'label' => 'Android-App-Verbindung'];
|
||||
if ($type === 'windows_app') return ['type' => 'windows_app', 'target' => '', 'label' => 'Windows-Programm-Verbindung'];
|
||||
@@ -70,6 +91,7 @@ function status_component_monitor_target(PDO $pdo, array $component): ?array
|
||||
if ($target === '') $target = trim((string) get_setting('smtp_host', '')) . ':' . ((int) get_setting('smtp_port', '587') ?: 587);
|
||||
return $target !== ':' ? ['type' => 'tcp', 'target' => $target, 'label' => 'E-Mail-Server'] : null;
|
||||
}
|
||||
if ($type === 'tcp' && $target !== '' && str_contains($name, 'minecraft')) return ['type' => 'minecraft', 'target' => $target, 'label' => 'Minecraft-Server'];
|
||||
if (in_array($type, ['http', 'tcp'], true) && $target !== '') return ['type' => $type, 'target' => $target, 'label' => $type === 'http' ? 'Webadresse' : 'Serververbindung'];
|
||||
return null;
|
||||
}
|
||||
@@ -83,11 +105,9 @@ function status_execute_component_check(PDO $pdo, array $check): array
|
||||
try { $pdo->query('SELECT 1'); $ok = true; } catch (Throwable $e) { $error = 'Datenbankabfrage fehlgeschlagen'; }
|
||||
} elseif (in_array($check['type'], ['android_app', 'windows_app'], true)) {
|
||||
$platform = $check['type'] === 'windows_app' ? 'windows' : 'android';
|
||||
$connection = status_app_connection($pdo, $platform);
|
||||
$ok = $connection['online'];
|
||||
if (!$ok) $error = $connection['last_seen'] === null
|
||||
? 'Noch keine Verbindung von ' . ($platform === 'windows' ? 'Windows' : 'Android') . ' gemeldet'
|
||||
: 'Keine aktuelle Verbindung von ' . ($platform === 'windows' ? 'Windows' : 'Android');
|
||||
// Erreichbar heißt: Der Server nimmt Anfragen der App an (Datenbank und Schnittstelle laufen). Ob gerade ein PC oder Handy
|
||||
// eingeschaltet ist, sagt darüber nichts: Ohne laufendes Programm ist der Dienst trotzdem erreichbar.
|
||||
try { $pdo->query('SELECT 1'); $ok = true; } catch (Throwable $e) { $error = 'Schnittstelle für die App nicht erreichbar (Datenbankabfrage fehlgeschlagen)'; }
|
||||
} elseif ($check['type'] === 'mail') {
|
||||
$endpoints = status_configured_mail_endpoints($pdo);
|
||||
$ok = count($endpoints) > 0;
|
||||
@@ -117,13 +137,17 @@ function status_execute_component_check(PDO $pdo, array $check): array
|
||||
if ($ok) fclose($socket); else $error = 'Push-Dienst nicht erreichbar';
|
||||
} else $error = 'Push-Dienst nicht eingerichtet';
|
||||
} else $error = 'Push-Dienst nicht eingerichtet';
|
||||
} elseif ($check['type'] === 'tcp') {
|
||||
if (preg_match('/^([a-zA-Z0-9.-]+):(\\d{1,5})$/', (string) $check['target'], $m) && (int) $m[2] > 0 && (int) $m[2] <= 65535) {
|
||||
$socket = @stream_socket_client('tcp://' . $m[1] . ':' . $m[2], $errno, $errstr, 4, STREAM_CLIENT_CONNECT);
|
||||
} elseif (in_array($check['type'], ['tcp', 'minecraft'], true)) {
|
||||
$endpoint = $check['type'] === 'minecraft'
|
||||
? status_resolve_minecraft_target((string) $check['target'])
|
||||
: status_parse_host_port((string) $check['target']);
|
||||
if ($endpoint !== null) {
|
||||
$host = strpos($endpoint['host'], ':') !== false ? '[' . $endpoint['host'] . ']' : $endpoint['host'];
|
||||
$socket = @stream_socket_client('tcp://' . $host . ':' . $endpoint['port'], $errno, $errstr, 4, STREAM_CLIENT_CONNECT);
|
||||
$ok = is_resource($socket);
|
||||
if ($ok) fclose($socket); else $error = 'Server oder Port nicht erreichbar';
|
||||
} else $error = 'Ungültiges Prüfziel';
|
||||
} elseif ($check['type'] === 'http') {
|
||||
} elseif (in_array($check['type'], ['http', 'plugin_site'], true)) {
|
||||
$url = (string) $check['target'];
|
||||
if (filter_var($url, FILTER_VALIDATE_URL) && in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)) {
|
||||
if (function_exists('curl_init')) {
|
||||
@@ -147,7 +171,54 @@ function status_execute_component_check(PDO $pdo, array $check): array
|
||||
return ['ok' => $ok, 'error' => mb_substr($error, 0, 255), 'ms' => (int) round((microtime(true) - $started) * 1000)];
|
||||
}
|
||||
|
||||
function status_run_component_checks(PDO $pdo): int
|
||||
/** Sollen Administratoren bei Ausfall und Wiederkehr eines Dienstes benachrichtigt werden? (Standard: ja) */
|
||||
function status_notify_admins_enabled(): bool
|
||||
{
|
||||
return get_setting('status_notify_admins', '1') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Benachrichtigt die aktiven Administratoren per E-Mail und Push, wenn ein überwachter Dienst ausfällt oder wieder läuft.
|
||||
* Höchstens eine Meldung je Dienst und Richtung innerhalb von 10 Minuten (kein Dauerfeuer bei einem flackernden Dienst).
|
||||
*
|
||||
* @return int Anzahl der angeschriebenen Administratoren
|
||||
*/
|
||||
function status_notify_admins(PDO $pdo, array $component, bool $recovered, string $error = ''): int
|
||||
{
|
||||
if (!status_notify_admins_enabled()) return 0;
|
||||
$key = 'status_notified_' . ($recovered ? 'up_' : 'down_') . (int) $component['id'];
|
||||
$last = (string) get_setting($key, '');
|
||||
if ($last !== '' && strtotime($last) > time() - 600) return 0;
|
||||
set_setting($key, db_now());
|
||||
|
||||
$ids = $pdo->query("SELECT id FROM staff WHERE is_active = 1 AND role = 'admin'")->fetchAll(PDO::FETCH_COLUMN);
|
||||
$name = (string) $component['name'];
|
||||
$internal = !empty($component['is_internal']);
|
||||
$title = $recovered ? "Dienst läuft wieder: {$name}" : "Dienst nicht erreichbar: {$name}";
|
||||
$url = base_url() . '/staff/status_page.php';
|
||||
$inner = '<p><strong>' . e($title) . '</strong></p>'
|
||||
. '<p>' . ($recovered ? 'Die automatische Prüfung erreicht den Dienst wieder.' : 'Die automatische Prüfung erreicht den Dienst seit mindestens zwei Prüfungen nicht.') . '</p>'
|
||||
. ($error !== '' && !$recovered ? '<p>Fehler: ' . e($error) . '</p>' : '')
|
||||
. ($internal ? '<p style="color:#64748b">Dieser Dienst ist intern und steht nicht auf der öffentlichen Statusseite.</p>' : '')
|
||||
. '<p><a href="' . e($url) . '">Statusseite im Mitarbeiterbereich öffnen</a></p>';
|
||||
$sent = 0;
|
||||
if (function_exists('staff_emails_by_ids')) {
|
||||
$emails = staff_emails_by_ids($pdo, function_exists('vacation_expand_ids') ? vacation_expand_ids($pdo, $ids) : $ids);
|
||||
foreach ($emails as $email) {
|
||||
email_send_and_log($email, '[DeskHub] ' . $title, email_wrap($inner), null, 'Statusmeldung');
|
||||
$sent++;
|
||||
}
|
||||
}
|
||||
if (function_exists('push_to_staff')) {
|
||||
foreach ($ids as $id) {
|
||||
try { push_to_staff((int) $id, $title, $recovered ? 'Der Dienst läuft wieder.' : ($error !== '' ? $error : 'Die Prüfung schlägt fehl.'), '/staff/status_page.php'); } catch (Throwable $e) { /* Push ist optional */ }
|
||||
}
|
||||
}
|
||||
try { audit_log(null, mb_substr('Statusseite: ' . $title . ' (Administratoren benachrichtigt)', 0, 250)); } catch (Throwable $e) { /* Protokoll ist optional */ }
|
||||
return $sent;
|
||||
}
|
||||
|
||||
function status_run_component_checks(PDO $pdo, bool $force = false): int
|
||||
{
|
||||
$components = status_components($pdo);
|
||||
$update = $pdo->prepare('UPDATE status_components SET last_checked_at = ?, last_check_ms = ?, last_check_error = ?, consecutive_failures = ? WHERE id = ?');
|
||||
@@ -156,15 +227,20 @@ function status_run_component_checks(PDO $pdo): int
|
||||
foreach ($components as $component) {
|
||||
$check = status_component_monitor_target($pdo, $component);
|
||||
if ($check === null) continue;
|
||||
$intervalMinutes = max(1, (int) ($component['check_interval_minutes'] ?? 1));
|
||||
$lastChecked = trim((string) ($component['last_checked_at'] ?? ''));
|
||||
if (!$force && $lastChecked !== '' && strtotime($lastChecked) > time() - $intervalMinutes * 60) continue;
|
||||
$result = status_execute_component_check($pdo, $check);
|
||||
$failures = $result['ok'] ? 0 : (int) ($component['consecutive_failures'] ?? 0) + 1;
|
||||
$update->execute([db_now(), $result['ms'], $result['ok'] ? null : $result['error'], $failures, (int) $component['id']]);
|
||||
$currentStatus = (string) $component['status'];
|
||||
if ($result['ok'] && $currentStatus !== 'maintenance' && $currentStatus !== 'operational') {
|
||||
status_set_component($pdo, (int) $component['id'], 'operational');
|
||||
if ($currentStatus === 'outage') status_notify_admins($pdo, $component, true);
|
||||
$currentStatus = 'operational';
|
||||
} elseif (!$result['ok'] && $failures >= 2 && $currentStatus !== 'maintenance' && $currentStatus !== 'outage') {
|
||||
status_set_component($pdo, (int) $component['id'], 'outage');
|
||||
status_notify_admins($pdo, $component, false, (string) $result['error']);
|
||||
$currentStatus = 'outage';
|
||||
}
|
||||
$sample->execute([(int) $component['id'], $currentStatus, db_now(), $result['ms']]);
|
||||
@@ -186,7 +262,7 @@ function status_discover_and_check(PDO $pdo): array
|
||||
if (status_component_monitor_target($pdo, $component) !== null) $recognized[] = (string) $component['name'];
|
||||
elseif (($component['monitor_type'] ?? 'auto') === 'auto') $pending[] = (string) $component['name'];
|
||||
}
|
||||
$checked = status_run_component_checks($pdo);
|
||||
$checked = status_run_component_checks($pdo, true);
|
||||
return ['recognized' => $recognized, 'pending' => $pending, 'checked' => $checked];
|
||||
}
|
||||
|
||||
@@ -201,9 +277,41 @@ function status_incident_states(): array
|
||||
];
|
||||
}
|
||||
|
||||
function status_components(PDO $pdo): array
|
||||
/** Gibt es die Spalte is_internal schon (Datenbank-Update eingespielt)? Bis dahin verhält sich alles wie bisher. */
|
||||
function status_has_internal(PDO $pdo): bool
|
||||
{
|
||||
return $pdo->query("SELECT c.*, COALESCE(cat.name, NULLIF(c.category, '')) AS category_name FROM status_components c LEFT JOIN status_categories cat ON cat.id = c.category_id ORDER BY COALESCE(cat.sort_order, 0), COALESCE(cat.name, c.category, ''), c.sort_order, c.name")->fetchAll();
|
||||
static $cache = [];
|
||||
$key = spl_object_id($pdo);
|
||||
if (isset($cache[$key])) return $cache[$key];
|
||||
try {
|
||||
$rows = $pdo->query($pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite' ? 'PRAGMA table_info(status_components)' : 'SHOW COLUMNS FROM status_components')->fetchAll();
|
||||
foreach ($rows as $r) if (($r['name'] ?? $r['Field'] ?? '') === 'is_internal') return $cache[$key] = true;
|
||||
} catch (Throwable $e) {
|
||||
// Tabelle fehlt: nichts zu filtern
|
||||
}
|
||||
return $cache[$key] = false;
|
||||
}
|
||||
|
||||
/** SQL-Zusatz: nur öffentliche (nicht interne) Dienste; $alias ist der Tabellenname von status_components. */
|
||||
function status_public_sql(PDO $pdo, string $alias = 'c'): string
|
||||
{
|
||||
return status_has_internal($pdo) ? " AND COALESCE({$alias}.is_internal, 0) = 0" : '';
|
||||
}
|
||||
|
||||
/** Nicht öffentlich sichtbar, aber geprüft: Dienst als „intern“ markieren oder die Markierung entfernen. */
|
||||
function status_set_component_internal(PDO $pdo, int $id, bool $internal): bool
|
||||
{
|
||||
if (!status_has_internal($pdo)) return false;
|
||||
$pdo->prepare('UPDATE status_components SET is_internal = ? WHERE id = ?')->execute([$internal ? 1 : 0, $id]);
|
||||
return (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
|
||||
}
|
||||
|
||||
/** @param bool $publicOnly nur Dienste, die auf der öffentlichen Statusseite stehen dürfen (nicht intern) */
|
||||
function status_components(PDO $pdo, bool $includeDisabled = false, bool $publicOnly = false): array
|
||||
{
|
||||
$where = $includeDisabled ? 'WHERE 1 = 1 ' : 'WHERE COALESCE(c.is_enabled, 1) = 1 ';
|
||||
if ($publicOnly) $where .= status_public_sql($pdo) . ' ';
|
||||
return $pdo->query("SELECT c.*, COALESCE(cat.name, NULLIF(c.category, '')) AS category_name FROM status_components c LEFT JOIN status_categories cat ON cat.id = c.category_id {$where}ORDER BY COALESCE(cat.sort_order, 0), COALESCE(cat.name, c.category, ''), c.sort_order, c.name")->fetchAll();
|
||||
}
|
||||
|
||||
function status_categories(PDO $pdo): array
|
||||
@@ -222,11 +330,75 @@ function status_monitor_types(): array
|
||||
'smtp' => 'SMTP-Server',
|
||||
'mail' => 'E-Mail (SMTP und IMAP)',
|
||||
'fcm' => 'Push-Dienst',
|
||||
'plugin_site' => 'Plugin-Webseite (offizieller Katalog)',
|
||||
'tcp' => 'Server und Port',
|
||||
'manual' => 'Manuell verwalten',
|
||||
];
|
||||
}
|
||||
|
||||
/** Parses hostname:port, IPv4:port, or [IPv6]:port for TCP service checks. */
|
||||
function status_parse_host_port(string $target): ?array
|
||||
{
|
||||
$target = trim($target);
|
||||
if (preg_match('/^\[([0-9a-fA-F:.]+)\]:(\d{1,5})$/D', $target, $m)) {
|
||||
if (filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) === false) return null;
|
||||
$host = $m[1];
|
||||
$port = (int) $m[2];
|
||||
} elseif (preg_match('/^([A-Za-z0-9.-]+):(\d{1,5})$/D', $target, $m)) {
|
||||
$host = $m[1];
|
||||
$port = (int) $m[2];
|
||||
if (strlen($host) > 253) return null;
|
||||
foreach (explode('.', rtrim($host, '.')) as $label) {
|
||||
if ($label === '' || strlen($label) > 63 || $label[0] === '-' || substr($label, -1) === '-') return null;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if ($port < 1 || $port > 65535) return null;
|
||||
return ['host' => $host, 'port' => $port];
|
||||
}
|
||||
|
||||
/** Minecraft accepts a bare host and resolves its Java port through DNS SRV. */
|
||||
function status_parse_minecraft_target(string $target): ?array
|
||||
{
|
||||
$target = trim($target);
|
||||
$explicit = status_parse_host_port($target);
|
||||
if ($explicit !== null) return $explicit;
|
||||
|
||||
if (preg_match('/^\[([0-9a-fA-F:.]+)\]$/D', $target, $m)) {
|
||||
return filter_var($m[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false
|
||||
? ['host' => $m[1], 'port' => null]
|
||||
: null;
|
||||
}
|
||||
if (filter_var($target, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) return ['host' => $target, 'port' => null];
|
||||
if (strlen($target) > 253 || $target === '') return null;
|
||||
foreach (explode('.', rtrim($target, '.')) as $label) {
|
||||
if ($label === '' || strlen($label) > 63 || $label[0] === '-' || substr($label, -1) === '-' || !preg_match('/^[A-Za-z0-9-]+$/D', $label)) return null;
|
||||
}
|
||||
return ['host' => rtrim($target, '.'), 'port' => null];
|
||||
}
|
||||
|
||||
function status_resolve_minecraft_target(string $target): ?array
|
||||
{
|
||||
$endpoint = status_parse_minecraft_target($target);
|
||||
if ($endpoint === null || $endpoint['port'] !== null) return $endpoint;
|
||||
$host = $endpoint['host'];
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) !== false) return ['host' => $host, 'port' => 25565];
|
||||
|
||||
if (function_exists('dns_get_record') && defined('DNS_SRV')) {
|
||||
$records = @dns_get_record('_minecraft._tcp.' . $host, DNS_SRV);
|
||||
if (is_array($records) && $records) {
|
||||
usort($records, static fn(array $a, array $b): int => ((int) ($a['pri'] ?? 0)) <=> ((int) ($b['pri'] ?? 0)));
|
||||
foreach ($records as $record) {
|
||||
$srvHost = rtrim((string) ($record['target'] ?? ''), '.');
|
||||
$port = (int) ($record['port'] ?? 0);
|
||||
if ($srvHost !== '' && $port >= 1 && $port <= 65535 && status_parse_minecraft_target($srvHost) !== null) return ['host' => $srvHost, 'port' => $port];
|
||||
}
|
||||
}
|
||||
}
|
||||
return ['host' => $host, 'port' => 25565];
|
||||
}
|
||||
|
||||
/** SMTP- und IMAP-Ziele aus den eingerichteten Konten der Installation. */
|
||||
function status_configured_mail_endpoints(PDO $pdo): array
|
||||
{
|
||||
@@ -259,9 +431,9 @@ function status_component_detected_targets(PDO $pdo, array $check): array
|
||||
$platform = $check['type'] === 'windows_app' ? 'windows' : 'android';
|
||||
$label = $platform === 'windows' ? 'Windows' : 'Android';
|
||||
$connection = status_app_connection($pdo, $platform);
|
||||
if ($connection['last_seen'] === null) return ['Noch keine Verbindung von ' . $label . ' gemeldet'];
|
||||
if ($connection['last_seen'] === null) return ['Server erreichbar · noch kein Gerät mit ' . $label . ' angemeldet gewesen'];
|
||||
$when = format_dt($connection['last_seen']);
|
||||
return [$connection['online'] ? 'Verbunden · letzter Kontakt ' . $when : 'Letzter Kontakt ' . $when . ' · älter als ' . (int) $connection['timeout_minutes'] . ' Minuten'];
|
||||
return ['Server erreichbar · ' . ($connection['online'] ? 'Gerät gerade verbunden, letzter Kontakt ' . $when : 'kein Gerät gerade aktiv, letzter Kontakt ' . $when)];
|
||||
}
|
||||
if ($check['type'] === 'database') {
|
||||
$db = (array) (config()['db'] ?? []);
|
||||
@@ -291,14 +463,23 @@ function status_component_detected_targets(PDO $pdo, array $check): array
|
||||
return [(string) ($check['target'] ?? '')];
|
||||
}
|
||||
|
||||
function status_set_component_monitor(PDO $pdo, int $id, string $type, string $target): bool
|
||||
function status_set_component_monitor(PDO $pdo, int $id, string $type, string $target, int $intervalMinutes = 1): bool
|
||||
{
|
||||
if (!isset(status_monitor_types()[$type])) return false;
|
||||
if (!in_array($intervalMinutes, [1, 2, 5, 10, 15, 30, 60], true)) return false;
|
||||
$target = trim(mb_substr($target, 0, 500));
|
||||
if ($type === 'http' && ($target === '' || !filter_var($target, FILTER_VALIDATE_URL) || !in_array(strtolower((string) parse_url($target, PHP_URL_SCHEME)), ['http', 'https'], true))) return false;
|
||||
if ($type === 'tcp' && $target !== '' && !preg_match('/^[a-zA-Z0-9.-]+:\\d{1,5}$/', $target)) return false;
|
||||
$stmt = $pdo->prepare('UPDATE status_components SET monitor_type = ?, monitor_target = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?');
|
||||
$stmt->execute([$type, $target !== '' ? $target : null, $id]);
|
||||
if ($type === 'tcp' && $target !== '') {
|
||||
$componentStmt = $pdo->prepare('SELECT name FROM status_components WHERE id = ?');
|
||||
$componentStmt->execute([$id]);
|
||||
$componentName = mb_strtolower((string) $componentStmt->fetchColumn());
|
||||
$validTarget = str_contains($componentName, 'minecraft')
|
||||
? status_parse_minecraft_target($target) !== null
|
||||
: status_parse_host_port($target) !== null;
|
||||
if (!$validTarget) return false;
|
||||
}
|
||||
$stmt = $pdo->prepare('UPDATE status_components SET monitor_type = ?, monitor_target = ?, check_interval_minutes = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?');
|
||||
$stmt->execute([$type, $target !== '' ? $target : null, $intervalMinutes, $id]);
|
||||
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
|
||||
}
|
||||
|
||||
@@ -331,21 +512,30 @@ function status_overall(PDO $pdo): array
|
||||
{
|
||||
$states = status_component_states();
|
||||
$worst = 'operational';
|
||||
foreach (status_components($pdo) as $c) {
|
||||
foreach (status_components($pdo, false, true) as $c) {
|
||||
if (($states[$c['status']][2] ?? 0) > $states[$worst][2]) $worst = $c['status'];
|
||||
}
|
||||
// Eine offene Störung ohne zugeordnete Komponente zählt mindestens als „eingeschränkt“
|
||||
$open = (int) $pdo->query("SELECT COUNT(*) FROM status_incidents WHERE status <> 'resolved'")->fetchColumn();
|
||||
$open = (int) $pdo->query("SELECT COUNT(*) FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE i.status <> 'resolved' AND (i.component_id IS NULL OR (COALESCE(c.is_enabled, 1) = 1" . status_public_sql($pdo) . "))")->fetchColumn();
|
||||
if ($open > 0 && $states[$worst][2] < $states['degraded'][2] && $worst !== 'maintenance') $worst = 'degraded';
|
||||
$activeMaintenance = status_active_maintenance_map($pdo);
|
||||
$hasActiveMaintenance = isset($activeMaintenance[0]);
|
||||
if (!$hasActiveMaintenance) {
|
||||
foreach (status_components($pdo, false, true) as $component) {
|
||||
if (isset($activeMaintenance[(int) $component['id']])) { $hasActiveMaintenance = true; break; }
|
||||
}
|
||||
}
|
||||
if ($hasActiveMaintenance && $states[$worst][2] < $states['maintenance'][2]) $worst = 'maintenance';
|
||||
return ['key' => $worst, 'label' => $worst === 'operational' ? 'Alle Systeme laufen' : ($worst === 'maintenance' ? 'Wartungsarbeiten' : ($worst === 'outage' ? 'Ausfall' : 'Teilweise eingeschränkt')), 'color' => $states[$worst][1]];
|
||||
}
|
||||
|
||||
/** Offene Störungen und die der letzten Tage (neueste zuerst), jeweils mit ihren Updates. */
|
||||
function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false): array
|
||||
function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false, bool $includeDisabledComponents = false): array
|
||||
{
|
||||
$since = date('Y-m-d H:i:s', time() - max(1, $days) * 86400);
|
||||
$componentFilter = $includeDisabledComponents ? '' : ' AND (i.component_id IS NULL OR (COALESCE(c.is_enabled, 1) = 1' . status_public_sql($pdo) . '))';
|
||||
$sql = "SELECT i.*, c.name AS component_name FROM status_incidents i LEFT JOIN status_components c ON c.id = i.component_id WHERE "
|
||||
. ($onlyOpen ? "i.status <> 'resolved'" : "(i.status <> 'resolved' OR i.created_at >= ?)") . ' ORDER BY i.created_at DESC, i.id DESC LIMIT 50';
|
||||
. ($onlyOpen ? "i.status <> 'resolved'" : "(i.status <> 'resolved' OR i.created_at >= ?)") . $componentFilter . ' ORDER BY i.created_at DESC, i.id DESC LIMIT 50';
|
||||
$stmt = $pdo->prepare($sql);
|
||||
$stmt->execute($onlyOpen ? [] : [$since]);
|
||||
$rows = $stmt->fetchAll();
|
||||
@@ -357,6 +547,59 @@ function status_incidents(PDO $pdo, int $days = 14, bool $onlyOpen = false): arr
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** Liefert geplante und aktuell laufende Wartungsfenster (oder die Admin-Historie). */
|
||||
function status_maintenance_windows(PDO $pdo, bool $includeHistory = false): array
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$since = date('Y-m-d H:i:s', time() - 60 * 86400);
|
||||
$where = $includeHistory
|
||||
? 'WHERE (w.ends_at >= ? OR w.cancelled_at >= ?)'
|
||||
: 'WHERE w.cancelled_at IS NULL AND w.ends_at >= ? AND (w.component_id IS NULL OR (COALESCE(c.is_enabled, 1) = 1' . status_public_sql($pdo) . '))';
|
||||
$stmt = $pdo->prepare("SELECT w.*, c.name AS component_name FROM status_maintenance_windows w LEFT JOIN status_components c ON c.id = w.component_id $where ORDER BY w.starts_at ASC, w.id ASC LIMIT 100");
|
||||
$stmt->execute($includeHistory ? [$since, $since] : [$now]);
|
||||
return $stmt->fetchAll();
|
||||
}
|
||||
|
||||
function status_create_maintenance_window(PDO $pdo, string $title, string $body, string $startsAt, string $endsAt, ?int $componentId, ?int $staffId): array
|
||||
{
|
||||
$title = trim(mb_substr($title, 0, 200));
|
||||
$body = trim(mb_substr($body, 0, 4000));
|
||||
$start = DateTimeImmutable::createFromFormat('!Y-m-d\\TH:i', $startsAt);
|
||||
$end = DateTimeImmutable::createFromFormat('!Y-m-d\\TH:i', $endsAt);
|
||||
$startErrors = DateTimeImmutable::getLastErrors();
|
||||
$endErrors = DateTimeImmutable::getLastErrors();
|
||||
if ($title === '') return ['ok' => false, 'error' => 'Bitte einen Titel für die Wartung angeben.'];
|
||||
if (!$start || !$end || ($startErrors && ($startErrors['warning_count'] || $startErrors['error_count'])) || ($endErrors && ($endErrors['warning_count'] || $endErrors['error_count']))) return ['ok' => false, 'error' => 'Bitte gültige Start- und Endzeiten angeben.'];
|
||||
if ($end <= $start || $end <= new DateTimeImmutable()) return ['ok' => false, 'error' => 'Die Endzeit muss nach der Startzeit und in der Zukunft liegen.'];
|
||||
if ($componentId !== null) {
|
||||
$component = $pdo->prepare('SELECT COUNT(*) FROM status_components WHERE id = ? AND COALESCE(is_enabled, 1) = 1');
|
||||
$component->execute([$componentId]);
|
||||
if (!(int) $component->fetchColumn()) return ['ok' => false, 'error' => 'Der ausgewählte Dienst ist nicht verfügbar.'];
|
||||
}
|
||||
$now = db_now();
|
||||
$pdo->prepare('INSERT INTO status_maintenance_windows (title, body, component_id, starts_at, ends_at, created_by, created_at) VALUES (?,?,?,?,?,?,?)')
|
||||
->execute([$title, $body !== '' ? $body : null, $componentId, $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s'), $staffId, $now]);
|
||||
return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
|
||||
}
|
||||
|
||||
function status_cancel_maintenance_window(PDO $pdo, int $id): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('UPDATE status_maintenance_windows SET cancelled_at = ? WHERE id = ? AND cancelled_at IS NULL');
|
||||
$stmt->execute([db_now(), $id]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/** Aktive Fenster nach Dienst-ID; Schlüssel 0 steht für eine globale Wartung. */
|
||||
function status_active_maintenance_map(PDO $pdo): array
|
||||
{
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$stmt = $pdo->prepare('SELECT id, component_id FROM status_maintenance_windows WHERE cancelled_at IS NULL AND starts_at <= ? AND ends_at >= ?');
|
||||
$stmt->execute([$now, $now]);
|
||||
$active = [];
|
||||
foreach ($stmt->fetchAll() as $window) $active[(int) ($window['component_id'] ?? 0)] = true;
|
||||
return $active;
|
||||
}
|
||||
|
||||
function status_set_component(PDO $pdo, int $id, string $status): bool
|
||||
{
|
||||
if (!isset(status_component_states()[$status])) return false;
|
||||
@@ -381,6 +624,13 @@ function status_toggle_component_maintenance(PDO $pdo, int $id): bool
|
||||
return status_set_component($pdo, $id, $current === 'maintenance' ? 'operational' : 'maintenance');
|
||||
}
|
||||
|
||||
function status_set_component_enabled(PDO $pdo, int $id, bool $enabled): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('UPDATE status_components SET is_enabled = ?, consecutive_failures = 0, last_check_error = NULL WHERE id = ?');
|
||||
$stmt->execute([$enabled ? 1 : 0, $id]);
|
||||
return $stmt->rowCount() > 0 || (bool) $pdo->query('SELECT 1 FROM status_components WHERE id = ' . (int) $id)->fetchColumn();
|
||||
}
|
||||
|
||||
/** Letzte einzelne Prüfungen sowie Verfügbarkeit der letzten 24 Stunden. */
|
||||
function status_component_timeline(PDO $pdo, int $componentId, int $checks = 30): array
|
||||
{
|
||||
@@ -463,6 +713,7 @@ function status_save_component(PDO $pdo, ?int $id, string $name, string $descrip
|
||||
function status_delete_component(PDO $pdo, int $id): void
|
||||
{
|
||||
$pdo->prepare('UPDATE status_incidents SET component_id = NULL WHERE component_id = ?')->execute([$id]);
|
||||
$pdo->prepare('UPDATE status_maintenance_windows SET cancelled_at = ? WHERE component_id = ? AND cancelled_at IS NULL')->execute([db_now(), $id]);
|
||||
$pdo->prepare('DELETE FROM status_component_events WHERE component_id = ?')->execute([$id]);
|
||||
$pdo->prepare('DELETE FROM status_components WHERE id = ?')->execute([$id]);
|
||||
}
|
||||
@@ -474,8 +725,11 @@ function status_restore_core_components(PDO $pdo): int
|
||||
['DeskHub-Supportportal', 'Webseite', 'auto', ['DeskHub Supportportal']],
|
||||
['DeskHub-API', 'API-Schnittstellen', 'auto', ['DeskHub API']],
|
||||
['Datenbank', 'Server & Datenbank', 'database', ['Datenbank Server', 'Database']],
|
||||
['Backup-Server', 'Server & Datenbank', 'tcp', ['Backup Server', 'FTP-Backup-Server']],
|
||||
['Maildienst', 'E-Mail', 'mail', ['Mail Dienst', 'Mail Server']],
|
||||
['Push-Dienst', 'Benachrichtigungen', 'fcm', ['Push Dienst']],
|
||||
['Plugin-Webseite', 'Server & Datenbank', 'plugin_site', ['Plugin-Website', 'Pluginseite', 'Plugin Webseite']],
|
||||
['Minecraft-Server', 'Apps', 'tcp', ['Minecraft Server']],
|
||||
['Android-App', 'Apps', 'android_app', ['Windelgeschichten-Android-App', 'DeskHub-Mitarbeiter-App für Android']],
|
||||
['Windows-Programm', 'Apps', 'windows_app', ['DeskHub-Mitarbeiter-App für Windows', 'Windows-App']],
|
||||
];
|
||||
@@ -496,6 +750,7 @@ function status_restore_core_components(PDO $pdo): int
|
||||
$maxOrder = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM status_components')->fetchColumn();
|
||||
$restored = 0;
|
||||
foreach ($defaults as [$name, $categoryName, $monitorType, $aliases]) {
|
||||
if ($monitorType === 'plugin_site' && status_plugin_site_url() === '') continue; // ohne offiziellen Katalog gibt es nichts zu prüfen
|
||||
$existing = null;
|
||||
foreach (array_merge([$name], $aliases) as $candidate) {
|
||||
$find->execute([$candidate]);
|
||||
@@ -512,6 +767,8 @@ function status_restore_core_components(PDO $pdo): int
|
||||
$id = status_save_component($pdo, null, $name, '', $maxOrder + 10, $categoryId);
|
||||
if ($id) {
|
||||
status_set_component_monitor($pdo, $id, $monitorType, '');
|
||||
if (in_array($name, ['Minecraft-Server', 'Backup-Server'], true)) status_set_component_enabled($pdo, $id, false);
|
||||
if ($monitorType === 'plugin_site') status_set_component_internal($pdo, $id, true); // nur für Mitarbeiter sichtbar
|
||||
$maxOrder += 10;
|
||||
$restored++;
|
||||
}
|
||||
@@ -587,12 +844,16 @@ function status_summary(PDO $pdo): array
|
||||
'overall' => $overall,
|
||||
'componentStates' => array_map(fn($k, $v) => ['key' => $k, 'label' => $v[0]], array_keys($states), $states),
|
||||
'incidentStates' => array_map(fn($k, $v) => ['key' => $k, 'label' => $v], array_keys(status_incident_states()), status_incident_states()),
|
||||
'components' => array_map(fn($c) => ['id' => (int) $c['id'], 'name' => (string) $c['name'], 'description' => (string) ($c['description'] ?? ''), 'category' => (string) ($c['category_name'] ?? ''), 'categoryId' => $c['category_id'] !== null ? (int) $c['category_id'] : null, 'status' => (string) $c['status'], 'statusLabel' => $states[$c['status']][0] ?? $c['status']], status_components($pdo)),
|
||||
'components' => array_map(fn($c) => ['id' => (int) $c['id'], 'name' => (string) $c['name'], 'description' => (string) ($c['description'] ?? ''), 'category' => (string) ($c['category_name'] ?? ''), 'categoryId' => $c['category_id'] !== null ? (int) $c['category_id'] : null, 'status' => (string) $c['status'], 'statusLabel' => $states[$c['status']][0] ?? $c['status'], 'internal' => !empty($c['is_internal'])], status_components($pdo)),
|
||||
'incidents' => array_map(fn($i) => [
|
||||
'id' => (int) $i['id'], 'title' => (string) $i['title'], 'status' => (string) $i['status'], 'statusLabel' => status_incident_states()[$i['status']] ?? $i['status'],
|
||||
'component' => $i['component_name'] ?? null, 'componentId' => $i['component_id'] !== null ? (int) $i['component_id'] : null,
|
||||
'createdAt' => (string) $i['created_at'], 'updatedAt' => (string) $i['updated_at'], 'resolvedAt' => $i['resolved_at'],
|
||||
'updates' => array_map(fn($u) => ['status' => (string) $u['status'], 'statusLabel' => status_incident_states()[$u['status']] ?? $u['status'], 'body' => (string) ($u['body'] ?? ''), 'createdAt' => (string) $u['created_at']], $i['updates']),
|
||||
], status_incidents($pdo, 14)),
|
||||
'maintenanceWindows' => array_map(static fn(array $w): array => [
|
||||
'id' => (int) $w['id'], 'title' => (string) $w['title'], 'body' => (string) ($w['body'] ?? ''),
|
||||
'component' => (string) ($w['component_name'] ?? ''), 'startsAt' => (string) $w['starts_at'], 'endsAt' => (string) $w['ends_at'],
|
||||
], status_maintenance_windows($pdo)),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* „Braucht Aufmerksamkeit“: der Zähler in Chrome-Erweiterung und Windows-Programm. Er zeigt den echten Stand und keine aufsummierten Ereignisse:
|
||||
* Ein Ticket zählt, solange es offen (unbeantwortet) ist, dem Mitarbeiter gehört oder noch niemandem zugewiesen ist und er es seit der
|
||||
* letzten Kundennachricht noch nicht geöffnet hat. Wird es geöffnet, beantwortet, geschlossen, jemand anderem zugewiesen oder zurückgestellt,
|
||||
* verschwindet es beim nächsten Abruf aus dem Zähler.
|
||||
*/
|
||||
|
||||
/** Merkt, dass der Mitarbeiter das Ticket jetzt gesehen hat. */
|
||||
function ticket_ack(PDO $pdo, int $ticketId, int $staffId): void
|
||||
{
|
||||
try {
|
||||
$now = db_now();
|
||||
$upd = $pdo->prepare('UPDATE ticket_acks SET acked_at = ? WHERE staff_id = ? AND ticket_id = ?');
|
||||
$upd->execute([$now, $staffId, $ticketId]);
|
||||
if ($upd->rowCount() === 0) {
|
||||
$exists = $pdo->prepare('SELECT 1 FROM ticket_acks WHERE staff_id = ? AND ticket_id = ?');
|
||||
$exists->execute([$staffId, $ticketId]);
|
||||
if (!$exists->fetchColumn()) {
|
||||
$pdo->prepare('INSERT INTO ticket_acks (staff_id, ticket_id, acked_at) VALUES (?,?,?)')->execute([$staffId, $ticketId, $now]);
|
||||
}
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
error_log('ticket_ack: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tickets, die für den Mitarbeiter Aufmerksamkeit brauchen.
|
||||
* @return array{unassigned:list<int>,mine:list<int>}
|
||||
*/
|
||||
function staff_attention_tickets(PDO $pdo, array $staff, int $limit = 300): array
|
||||
{
|
||||
[$deptSql, $deptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
|
||||
$snooze = function_exists('ticket_not_snoozed_sql') ? ticket_not_snoozed_sql('t') : '1=1';
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id, t.assigned_staff_id
|
||||
FROM tickets t
|
||||
LEFT JOIN ticket_acks a ON a.ticket_id = t.id AND a.staff_id = ?
|
||||
WHERE t.status = 'open' AND t.is_spam = 0 AND t.merged_into IS NULL AND t.source <> 'staff'
|
||||
AND (t.assigned_staff_id IS NULL OR t.assigned_staff_id = ?)
|
||||
AND {$snooze}{$deptSql}
|
||||
AND (a.acked_at IS NULL OR a.acked_at < COALESCE((SELECT MAX(th.created_at) FROM ticket_threads th WHERE th.ticket_id = t.id AND th.author_type = 'customer'), t.created_at))
|
||||
ORDER BY t.id DESC
|
||||
LIMIT " . max(1, $limit)
|
||||
);
|
||||
$stmt->execute([(int) $staff['id'], (int) $staff['id'], ...$deptParams]);
|
||||
$out = ['unassigned' => [], 'mine' => []];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
$out[$row['assigned_staff_id'] === null ? 'unassigned' : 'mine'][] = (int) $row['id'];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
@@ -76,7 +76,14 @@ function ticket_snooze_wake_due(PDO $pdo): int
|
||||
} catch (Throwable $e) {
|
||||
return 0; // Spalte fehlt (Datenbank noch nicht aktualisiert)
|
||||
}
|
||||
$woken = 0;
|
||||
foreach ($due as $t) {
|
||||
// Erledigte Tickets (gelöst oder geschlossen) brauchen keine Wiedervorlage mehr: still aufräumen, keine Benachrichtigung
|
||||
if (in_array((string) $t['status'], ['resolved', 'closed'], true)) {
|
||||
$pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL WHERE id = ?')->execute([$t['id']]);
|
||||
continue;
|
||||
}
|
||||
$woken++;
|
||||
$pdo->prepare('UPDATE tickets SET snoozed_until = NULL, snoozed_by = NULL, updated_at = ? WHERE id = ?')->execute([db_now(), $t['id']]);
|
||||
log_activity($pdo, (int) $t['id'], null, 'Wiedervorlage fällig');
|
||||
$to = (int) ($t['snoozed_by'] ?: ($t['assigned_staff_id'] ?? 0));
|
||||
@@ -88,7 +95,7 @@ function ticket_snooze_wake_due(PDO $pdo): int
|
||||
}
|
||||
}
|
||||
}
|
||||
return count($due);
|
||||
return $woken;
|
||||
}
|
||||
|
||||
// ── Beobachter ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
/**
|
||||
* Verknüpfte Tickets: beliebige Tickets lassen sich miteinander verbinden (verwandt, doppelt, blockiert), unabhängig von den
|
||||
* „Problemen“ (includes/problems.php). Eine Verknüpfung wird einmal gespeichert (ticket_id → linked_ticket_id) und von beiden
|
||||
* Tickets aus angezeigt, mit der jeweils passenden Bezeichnung.
|
||||
*/
|
||||
|
||||
/** @return array<string,array{0:string,1:string}> Art => [Bezeichnung vom Quell-Ticket aus, Bezeichnung vom Ziel-Ticket aus] */
|
||||
function ticket_link_types(): array
|
||||
{
|
||||
return [
|
||||
'related' => ['Verwandt mit', 'Verwandt mit'],
|
||||
'duplicate' => ['Doppelt zu', 'Hat Duplikat'],
|
||||
'blocks' => ['Blockiert', 'Blockiert durch'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Verknüpft zwei Tickets. Gibt eine Fehlermeldung zurück oder null bei Erfolg.
|
||||
* Der Mitarbeiter muss beide Tickets sehen dürfen ($deptScope: Abteilungs-IDs des Mitarbeiters, null = alle).
|
||||
*/
|
||||
function ticket_link_add(PDO $pdo, int $ticketId, string $targetNumber, string $type, int $staffId, ?array $deptScope = null): ?string
|
||||
{
|
||||
if (!isset(ticket_link_types()[$type])) return 'Ungültige Art der Verknüpfung.';
|
||||
$targetNumber = ltrim(trim($targetNumber), '#');
|
||||
if ($targetNumber === '') return 'Bitte eine Ticketnummer angeben.';
|
||||
$stmt = $pdo->prepare('SELECT id, department_id FROM tickets WHERE ticket_number = ?');
|
||||
$stmt->execute([$targetNumber]);
|
||||
$target = $stmt->fetch();
|
||||
if (!$target) return 'Ticket #' . $targetNumber . ' wurde nicht gefunden.';
|
||||
$targetId = (int) $target['id'];
|
||||
if ($targetId === $ticketId) return 'Ein Ticket lässt sich nicht mit sich selbst verknüpfen.';
|
||||
if ($deptScope !== null && !in_array((int) $target['department_id'], array_map('intval', $deptScope), true)) {
|
||||
return 'Ticket #' . $targetNumber . ' gehört zu einer Abteilung, auf die Sie keinen Zugriff haben.';
|
||||
}
|
||||
$exists = $pdo->prepare('SELECT 1 FROM ticket_links WHERE (ticket_id = ? AND linked_ticket_id = ?) OR (ticket_id = ? AND linked_ticket_id = ?)');
|
||||
$exists->execute([$ticketId, $targetId, $targetId, $ticketId]);
|
||||
if ($exists->fetchColumn()) return 'Die beiden Tickets sind bereits verknüpft.';
|
||||
$pdo->prepare('INSERT INTO ticket_links (ticket_id, linked_ticket_id, link_type, created_by, created_at) VALUES (?,?,?,?,?)')
|
||||
->execute([$ticketId, $targetId, $type, $staffId > 0 ? $staffId : null, db_now()]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Entfernt eine Verknüpfung, sofern sie zu diesem Ticket gehört. */
|
||||
function ticket_link_remove(PDO $pdo, int $linkId, int $ticketId): bool
|
||||
{
|
||||
$stmt = $pdo->prepare('DELETE FROM ticket_links WHERE id = ? AND (ticket_id = ? OR linked_ticket_id = ?)');
|
||||
$stmt->execute([$linkId, $ticketId, $ticketId]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Verknüpfungen eines Tickets aus dessen Sicht. Tickets aus Abteilungen, die der Mitarbeiter nicht sehen darf, bleiben weg.
|
||||
* @return list<array{id:int,type:string,label:string,number:string,subject:string,status:string}>
|
||||
*/
|
||||
function ticket_links_for(PDO $pdo, int $ticketId, ?array $deptScope = null): array
|
||||
{
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT l.id, l.ticket_id, l.linked_ticket_id, l.link_type, t.ticket_number, t.subject, t.status, t.department_id
|
||||
FROM ticket_links l
|
||||
JOIN tickets t ON t.id = CASE WHEN l.ticket_id = ? THEN l.linked_ticket_id ELSE l.ticket_id END
|
||||
WHERE l.ticket_id = ? OR l.linked_ticket_id = ?
|
||||
ORDER BY l.id'
|
||||
);
|
||||
$stmt->execute([$ticketId, $ticketId, $ticketId]);
|
||||
$types = ticket_link_types();
|
||||
$scope = $deptScope === null ? null : array_map('intval', $deptScope);
|
||||
$out = [];
|
||||
foreach ($stmt->fetchAll() as $row) {
|
||||
if ($scope !== null && !in_array((int) $row['department_id'], $scope, true)) continue;
|
||||
$outgoing = (int) $row['ticket_id'] === $ticketId;
|
||||
$labels = $types[$row['link_type']] ?? ['Verknüpft mit', 'Verknüpft mit'];
|
||||
$out[] = [
|
||||
'id' => (int) $row['id'], 'type' => (string) $row['link_type'], 'label' => $outgoing ? $labels[0] : $labels[1],
|
||||
'number' => (string) $row['ticket_number'], 'subject' => (string) $row['subject'], 'status' => (string) $row['status'],
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Offene Tickets, die dieses Ticket blockieren (Hinweis vor dem Schließen). */
|
||||
function ticket_open_blockers(PDO $pdo, int $ticketId): array
|
||||
{
|
||||
return array_values(array_filter(ticket_links_for($pdo, $ticketId), fn($l) => $l['type'] === 'blocks' && $l['label'] === 'Blockiert durch' && !in_array($l['status'], ['resolved', 'closed'], true)));
|
||||
}
|
||||
|
||||
/** Beim Löschen eines Tickets die Verknüpfungen mit entfernen. */
|
||||
function ticket_links_purge(PDO $pdo, int $ticketId): void
|
||||
{
|
||||
$pdo->prepare('DELETE FROM ticket_links WHERE ticket_id = ? OR linked_ticket_id = ?')->execute([$ticketId, $ticketId]);
|
||||
}
|
||||
@@ -81,3 +81,88 @@ function update_http_headers(): array
|
||||
[$token] = update_token_info();
|
||||
return $token !== '' ? ['Authorization: token ' . $token] : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gehört diese Datei aus dem Update-Paket nicht auf die Installation? Plugins, Uploads, Backups und App-Dateien gehören dem System, die Plugin-Webseite
|
||||
* und Entwickler-Ordner gehören nicht auf Kunden-Installationen. Ausnahme: Das Handbuch (docs/DeskHub-Handbuch.pdf) kommt mit jedem Update (Adminportal → Hilfe).
|
||||
*/
|
||||
function update_path_is_skipped(string $relNorm): bool
|
||||
{
|
||||
// Die Konfiguration gehört der Installation: nichts aus config/ wird je überschrieben oder neu angelegt (auch nicht bei anderer Groß-/Kleinschreibung)
|
||||
if (preg_match('#^(\./)*config(/|$)#i', $relNorm) || preg_match('#(^|/)(\.env|config\.php)$#i', $relNorm) && !str_contains($relNorm, '/')) return true;
|
||||
if ($relNorm === 'docs/DeskHub-Handbuch.pdf' || $relNorm === 'tools/fix-permissions.sh') return false;
|
||||
return (bool) (preg_match('#^(plugins|uploads|backups|app_downloads)/#', $relNorm)
|
||||
|| preg_match('#^(plugin-webseite|tools|tests|docs|desktop|mobile|theme-packages|vorschaubilder|\.gitea|\.github)/#', $relNorm));
|
||||
}
|
||||
|
||||
|
||||
// ─── Updates von der Plugin-Webseite ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Adresse der Update-Schnittstelle auf der offiziellen Plugin-Webseite (app-release.php neben dem Katalog) oder '' ohne offiziellen Katalog.
|
||||
* Der Zugangsschlüssel wird nur an diesen Server geschickt (catalog_access_headers).
|
||||
*/
|
||||
function update_publisher_endpoint(array $query): string
|
||||
{
|
||||
$base = function_exists('catalog_defaults') ? (string) (catalog_defaults()['plugins_url'] ?? '') : '';
|
||||
$parts = parse_url($base);
|
||||
if (!$parts || ($parts['scheme'] ?? '') !== 'https' || empty($parts['host'])) return '';
|
||||
$path = rtrim(str_replace('\\', '/', dirname((string) ($parts['path'] ?? '/'))), '/');
|
||||
return 'https://' . $parts['host'] . (isset($parts['port']) ? ':' . (int) $parts['port'] : '') . $path . '/app-release.php?' . http_build_query(['platform' => 'server'] + $query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die auf der Plugin-Webseite veröffentlichten System-Updates, neueste zuerst. Leer, wenn die Seite nicht antwortet oder nichts
|
||||
* veröffentlicht ist (dann greift die Ausweichquelle). Jeder Eintrag hat dieselben Felder wie ein Gitea-Release (tag_name, published_at, body)
|
||||
* plus sha256, size, signature und source = 'publisher'.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
function update_publisher_releases(): array
|
||||
{
|
||||
static $cache = null;
|
||||
if ($cache !== null && !isset($GLOBALS['deskhub_http_get'])) return $cache;
|
||||
$url = update_publisher_endpoint(['list' => 1]);
|
||||
$out = [];
|
||||
if ($url !== '') {
|
||||
[$status, $body] = plugin_http_get($url, 1048576, 8);
|
||||
$data = $status === 200 && is_string($body) ? json_decode($body, true) : null;
|
||||
foreach ((array) ($data['releases'] ?? []) as $r) {
|
||||
$v = (string) ($r['version'] ?? '');
|
||||
if (!is_array($r) || !preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $v) || !preg_match('/^[0-9a-f]{64}$/i', (string) ($r['sha256'] ?? ''))) continue;
|
||||
$out[] = ['tag_name' => $v, 'published_at' => (string) ($r['created_at'] ?? ''), 'body' => (string) ($r['notes'] ?? ''), 'sha256' => strtolower((string) $r['sha256']),
|
||||
'size' => (int) ($r['size'] ?? 0), 'signature' => (string) ($r['signature'] ?? ''), 'source' => 'publisher'];
|
||||
}
|
||||
usort($out, fn($a, $b) => version_compare($b['tag_name'], $a['tag_name']));
|
||||
}
|
||||
return $cache = $out;
|
||||
}
|
||||
|
||||
/** Ein Eintrag aus update_publisher_releases() zu einem Versions-Tag (mit oder ohne „v“) oder null. */
|
||||
function update_publisher_find(string $tag): ?array
|
||||
{
|
||||
$v = ltrim(trim($tag), 'vV');
|
||||
foreach (update_publisher_releases() as $r) if ($r['tag_name'] === $v) return $r;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt das Paket einer Version von der Plugin-Webseite und prüft Prüfsumme und (bei eingebautem Herausgeber-Schlüssel) Signatur.
|
||||
*
|
||||
* @return array{0:?string,1:?string} [Pfad zur ZIP-Datei, Fehlertext]; bei Fehler ist die Datei schon gelöscht
|
||||
*/
|
||||
function update_publisher_fetch(array $release): array
|
||||
{
|
||||
$url = update_publisher_endpoint(['version' => $release['tag_name'], 'download' => 1]);
|
||||
if ($url === '') return [null, 'Die Plugin-Webseite ist nicht eingetragen.'];
|
||||
[$status, $body, $err] = plugin_http_get($url, 300 * 1048576, 180);
|
||||
if ($status !== 200 || !is_string($body)) return [null, 'Download von der Plugin-Webseite fehlgeschlagen' . ($err ? ': ' . $err : " (HTTP $status).")];
|
||||
if (!hash_equals((string) $release['sha256'], hash('sha256', $body))) return [null, 'Die Prüfsumme des Pakets stimmt nicht. Es wird nicht installiert.'];
|
||||
$tmp = tempnam(sys_get_temp_dir(), 'dh_') . '.zip';
|
||||
file_put_contents($tmp, $body);
|
||||
if (update_signature_required()) {
|
||||
$problem = update_verify_package((string) $release['tag_name'], $tmp, (string) $release['signature']);
|
||||
if ($problem !== null) { @unlink($tmp); return [null, $problem]; }
|
||||
}
|
||||
return [$tmp, null];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
/**
|
||||
* Abwesenheit im Profil: Offene Tickets an die Vertretung weitergeben und nach dem Urlaub zurückgeben.
|
||||
* Das Plugin „Urlaub und Vertretung“ macht dasselbe nach Datum; ist dort eine Abwesenheit aktiv, übernimmt das Plugin und der Server hält sich heraus.
|
||||
* Welche Tickets weitergegeben wurden, steht in vacation_moves: Zurückgegeben wird nur, was noch bei der Vertretung liegt und offen ist
|
||||
* (hat jemand ein Ticket inzwischen umgehängt oder erledigt, bleibt es so).
|
||||
*/
|
||||
|
||||
/** Wer arbeitet an Stelle von $staffId? Folgt Vertretungsketten (höchstens drei Stufen, ohne Schleifen); null ohne nutzbare Vertretung. */
|
||||
function vacation_target(PDO $pdo, int $staffId): ?int
|
||||
{
|
||||
$get = $pdo->prepare('SELECT id, is_active, is_on_vacation, vacation_substitute_id FROM staff WHERE id = ?');
|
||||
$get->execute([$staffId]);
|
||||
$row = $get->fetch();
|
||||
if (!$row || !(int) $row['is_on_vacation']) return null;
|
||||
$seen = [$staffId];
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$sub = (int) ($row['vacation_substitute_id'] ?? 0);
|
||||
if ($sub <= 0 || in_array($sub, $seen, true)) return null;
|
||||
$get->execute([$sub]);
|
||||
$row = $get->fetch();
|
||||
if (!$row || !(int) $row['is_active']) return null;
|
||||
if (!(int) $row['is_on_vacation']) return $sub;
|
||||
$seen[] = $sub;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Übernimmt das Plugin „Urlaub und Vertretung“ gerade diese Abwesenheit? */
|
||||
function vacation_plugin_owns(PDO $pdo, int $staffId): bool
|
||||
{
|
||||
try {
|
||||
$q = $pdo->prepare("SELECT COUNT(*) FROM plugin_vertretung_absences WHERE staff_id = ? AND status = 'active'");
|
||||
$q->execute([$staffId]);
|
||||
return (int) $q->fetchColumn() > 0;
|
||||
} catch (Throwable $e) {
|
||||
return false; // Plugin nicht installiert
|
||||
}
|
||||
}
|
||||
|
||||
/** Gibt die offenen Tickets von $staffId an die Vertretung weiter (die Abwesenheit muss schon eingetragen sein). Liefert die Zahl. */
|
||||
function vacation_hand_over(PDO $pdo, int $staffId): int
|
||||
{
|
||||
if (vacation_plugin_owns($pdo, $staffId)) return 0;
|
||||
$to = vacation_target($pdo, $staffId);
|
||||
if ($to === null || $to === $staffId) return 0;
|
||||
$name = (string) $pdo->query('SELECT name FROM staff WHERE id = ' . $staffId)->fetchColumn();
|
||||
$toName = (string) $pdo->query('SELECT name FROM staff WHERE id = ' . $to)->fetchColumn();
|
||||
$t = $pdo->prepare("SELECT id, ticket_number, subject FROM tickets WHERE assigned_staff_id = ? AND is_spam = 0 AND status NOT IN ('resolved','closed') ORDER BY id");
|
||||
$t->execute([$staffId]);
|
||||
$tickets = $t->fetchAll();
|
||||
$own = !$pdo->inTransaction();
|
||||
if ($own) $pdo->beginTransaction(); // entweder alles (Zuweisung und Vermerk zur Rückgabe) oder nichts
|
||||
try {
|
||||
$move = $pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?');
|
||||
$note = $pdo->prepare('INSERT INTO vacation_moves (ticket_id, from_staff_id, to_staff_id, created_at) VALUES (?,?,?,?)');
|
||||
foreach ($tickets as $row) {
|
||||
$move->execute([$to, $row['id']]);
|
||||
$note->execute([$row['id'], $staffId, $to, db_now()]);
|
||||
log_activity($pdo, (int) $row['id'], $staffId, 'An die Vertretung ' . $toName . ' weitergegeben (' . $name . ' ist abwesend)');
|
||||
}
|
||||
if ($own) $pdo->commit();
|
||||
} catch (Throwable $e) {
|
||||
if ($own && $pdo->inTransaction()) $pdo->rollBack();
|
||||
throw $e;
|
||||
}
|
||||
if ($tickets) {
|
||||
$list = '';
|
||||
foreach (array_slice($tickets, 0, 15) as $row) $list .= '<li>#' . e((string) $row['ticket_number']) . ' – ' . e(mb_strimwidth((string) $row['subject'], 0, 70, '…')) . '</li>';
|
||||
$mail = $pdo->prepare('SELECT email FROM staff WHERE id = ?');
|
||||
$mail->execute([$to]);
|
||||
$addr = (string) $mail->fetchColumn();
|
||||
if ($addr !== '') send_mail($addr, 'Vertretung für ' . $name . ': ' . count($tickets) . ' Tickets', email_wrap(
|
||||
'<h2 style="margin:0 0 14px;color:#0f172a;font-size:22px;line-height:1.3;font-weight:700;font-family:Segoe UI,Arial,sans-serif;">Sie vertreten ' . e($name) . '</h2>'
|
||||
. '<p style="margin:0 0 12px;color:#334155">' . count($tickets) . ' offene Tickets wurden Ihnen zugewiesen. Neue Tickets von ' . e($name) . ' kommen ebenfalls zu Ihnen, solange die Abwesenheit dauert.</p>'
|
||||
. '<ul style="color:#334155">' . $list . '</ul>'));
|
||||
}
|
||||
return count($tickets);
|
||||
}
|
||||
|
||||
/** Gibt weitergegebene Tickets zurück, die noch bei der Vertretung liegen und offen sind. Liefert die Zahl der zurückgegebenen Tickets. */
|
||||
function vacation_take_back(PDO $pdo, int $staffId): int
|
||||
{
|
||||
if (vacation_plugin_owns($pdo, $staffId)) return 0;
|
||||
$m = $pdo->prepare('SELECT m.id, m.ticket_id, m.to_staff_id, t.assigned_staff_id, t.status FROM vacation_moves m JOIN tickets t ON t.id = m.ticket_id WHERE m.from_staff_id = ? AND m.returned_at IS NULL ORDER BY m.id');
|
||||
$m->execute([$staffId]);
|
||||
$back = 0;
|
||||
$name = (string) $pdo->query('SELECT name FROM staff WHERE id = ' . $staffId)->fetchColumn();
|
||||
foreach ($m->fetchAll() as $row) {
|
||||
$stillThere = (int) $row['assigned_staff_id'] === (int) $row['to_staff_id'] && !in_array($row['status'], ['resolved', 'closed'], true);
|
||||
if ($stillThere) {
|
||||
$pdo->prepare('UPDATE tickets SET assigned_staff_id = ? WHERE id = ?')->execute([$staffId, $row['ticket_id']]);
|
||||
log_activity($pdo, (int) $row['ticket_id'], $staffId, 'Zurück an ' . $name . ' (Abwesenheit beendet)');
|
||||
$back++;
|
||||
}
|
||||
$pdo->prepare('UPDATE vacation_moves SET returned_at = ? WHERE id = ?')->execute([db_now(), $row['id']]);
|
||||
}
|
||||
return $back;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wird nach dem Speichern der Abwesenheit im Profil aufgerufen. $before/$after: [is_on_vacation, vacation_substitute_id].
|
||||
* Beim Speichern mit Haken: weitergeben (auch erneut, es zählen nur Tickets, die noch beim Abwesenden liegen); Ende → zurückgeben; neue Vertretung → erst zurück, dann an die neue.
|
||||
* Liefert [weitergegeben, zurückgegeben].
|
||||
*/
|
||||
function vacation_apply_change(PDO $pdo, int $staffId, array $before, array $after, bool $handOver): array
|
||||
{
|
||||
$handed = $back = 0;
|
||||
$wasOn = (int) $before[0] === 1;
|
||||
$isOn = (int) $after[0] === 1;
|
||||
$changedSub = (int) ($before[1] ?? 0) !== (int) ($after[1] ?? 0);
|
||||
if ($wasOn && (!$isOn || $changedSub)) $back = vacation_take_back($pdo, $staffId);
|
||||
if ($isOn && $handOver) $handed = vacation_hand_over($pdo, $staffId); // idempotent: nur Tickets, die noch beim Abwesenden liegen
|
||||
return [$handed, $back];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt Mitarbeiter im Urlaub durch ihre Vertretung (folgt Ketten), damit Benachrichtigungen dort ankommen, wo jemand arbeitet.
|
||||
* Ohne nutzbare Vertretung entfällt der Eintrag. Ergebnis: eindeutige Mitarbeiter-IDs, die aktiv und anwesend sind.
|
||||
* @param int[] $ids
|
||||
* @return int[]
|
||||
*/
|
||||
function vacation_expand_ids(PDO $pdo, array $ids): array
|
||||
{
|
||||
$out = [];
|
||||
$ids = array_values(array_filter(array_unique(array_map('intval', $ids)), fn($id) => $id > 0));
|
||||
if (!$ids) return [];
|
||||
// Wer im Urlaub ist, in einer Abfrage ermitteln (statt eine je Mitarbeiter)
|
||||
$inIds = implode(',', array_fill(0, count($ids), '?'));
|
||||
$away = $pdo->prepare("SELECT id FROM staff WHERE is_on_vacation = 1 AND id IN ($inIds)");
|
||||
$away->execute($ids);
|
||||
$awayIds = array_flip(array_map('intval', $away->fetchAll(PDO::FETCH_COLUMN)));
|
||||
foreach ($ids as $id) {
|
||||
$target = isset($awayIds[$id]) ? vacation_target($pdo, $id) : $id;
|
||||
if ($target !== null) $out[$target] = $target;
|
||||
}
|
||||
if (!$out) return [];
|
||||
$in = implode(',', array_fill(0, count($out), '?'));
|
||||
$q = $pdo->prepare("SELECT id FROM staff WHERE is_active = 1 AND is_on_vacation = 0 AND id IN ($in)");
|
||||
$q->execute(array_values($out));
|
||||
return array_map('intval', $q->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
/** E-Mail-Adressen der Mitarbeiter-IDs (nur aktive, anwesende). @param int[] $ids @return string[] */
|
||||
function staff_emails_by_ids(PDO $pdo, array $ids): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_map('intval', $ids)));
|
||||
if (!$ids) return [];
|
||||
$in = implode(',', array_fill(0, count($ids), '?'));
|
||||
$q = $pdo->prepare("SELECT DISTINCT email FROM staff WHERE is_active = 1 AND is_on_vacation = 0 AND id IN ($in)");
|
||||
$q->execute($ids);
|
||||
return array_map('strval', $q->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
/**
|
||||
* Erinnerung an den Bearbeiter: Wartet ein ihm zugewiesenes Ticket seit mehreren Arbeitsstunden auf eine Antwort, bekommt er einen Hinweis
|
||||
* (Push auf die Android-App und im Browser, auf Wunsch auch per E-Mail), bevor die SLA-Frist reißt. Pro Kundennachricht gibt es nur eine
|
||||
* Erinnerung. Gezählt wird in Arbeitszeit (Geschäftszeiten und Feiertage aus der SLA-Einstellung, sonst die Uhr).
|
||||
* Läuft im Hintergrundjob „SLA-Prüfung“ (cron/check_sla.php, alle 15 Minuten).
|
||||
*/
|
||||
|
||||
function wait_reminder_enabled(): bool
|
||||
{
|
||||
return get_setting('wait_reminder_enabled', '1') === '1';
|
||||
}
|
||||
|
||||
function wait_reminder_hours(): int
|
||||
{
|
||||
return max(1, min(72, (int) get_setting('wait_reminder_hours', '4')));
|
||||
}
|
||||
|
||||
function wait_reminder_email(): bool
|
||||
{
|
||||
return get_setting('wait_reminder_email', '0') === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft alle zugewiesenen, unbeantworteten Tickets und erinnert die Bearbeiter.
|
||||
* @return array{checked:int,sent:int,log:string[]}
|
||||
*/
|
||||
function wait_reminders_run(PDO $pdo, ?string $now = null): array
|
||||
{
|
||||
$res = ['checked' => 0, 'sent' => 0, 'log' => []];
|
||||
if (!wait_reminder_enabled()) return $res;
|
||||
$now = $now ?? db_now();
|
||||
$needSeconds = wait_reminder_hours() * 3600;
|
||||
$snooze = function_exists('ticket_not_snoozed_sql') ? ticket_not_snoozed_sql('t') : '1=1';
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.*, s.name AS staff_name, s.email AS staff_email,
|
||||
COALESCE((SELECT MAX(th.created_at) FROM ticket_threads th WHERE th.ticket_id = t.id AND th.author_type = 'customer'), t.created_at) AS last_customer_at
|
||||
FROM tickets t
|
||||
JOIN staff s ON s.id = t.assigned_staff_id AND s.is_active = 1
|
||||
WHERE t.status = 'open' AND t.is_spam = 0 AND t.merged_into IS NULL AND t.source <> 'staff'
|
||||
AND t.sla_paused_at IS NULL AND t.is_overdue = 0
|
||||
AND (t.resolution_due_at IS NULL OR t.resolution_due_at > ?)
|
||||
AND {$snooze}
|
||||
ORDER BY t.id
|
||||
LIMIT 500"
|
||||
);
|
||||
$stmt->execute([$now]);
|
||||
$mark = $pdo->prepare('UPDATE tickets SET wait_reminded_at = ?, updated_at = updated_at WHERE id = ?');
|
||||
|
||||
foreach ($stmt->fetchAll() as $t) {
|
||||
$res['checked']++;
|
||||
$since = (string) $t['last_customer_at'];
|
||||
// Pro Kundennachricht nur eine Erinnerung
|
||||
if (!empty($t['wait_reminded_at']) && (string) $t['wait_reminded_at'] >= $since) continue;
|
||||
$waited = ticket_sla_working_seconds_between($since, $now);
|
||||
if ($waited < $needSeconds) continue;
|
||||
|
||||
$mark->execute([$now, (int) $t['id']]);
|
||||
$hours = (int) floor($waited / 3600);
|
||||
$assigned = (int) $t['assigned_staff_id'];
|
||||
log_activity($pdo, (int) $t['id'], null, "Erinnerung an den Bearbeiter: wartet seit {$hours} Std. auf Antwort");
|
||||
if (function_exists('fcm_notify_staff') && fcm_configured()) {
|
||||
fcm_notify_staff([$assigned], fcm_data('ticket_wait', $t), (int) $t['department_id']);
|
||||
}
|
||||
if (function_exists('push_to_staff')) {
|
||||
[$title, $body] = function_exists('fcm_message') ? fcm_message('ticket_wait', $t) : ['Ticket wartet auf Antwort', '#' . $t['ticket_number']];
|
||||
try { push_to_staff($assigned, $title, $body, '/staff/ticket_view.php?n=' . urlencode((string) $t['ticket_number'])); } catch (Throwable $e) { error_log('wait_reminder push: ' . $e->getMessage()); }
|
||||
}
|
||||
if (wait_reminder_email() && trim((string) $t['staff_email']) !== '') {
|
||||
$url = base_url() . '/staff/ticket_view.php?n=' . urlencode((string) $t['ticket_number']);
|
||||
$inner = email_p('Hallo ' . e(explode(' ', trim((string) $t['staff_name']))[0]) . ', das Ticket <strong>#' . e((string) $t['ticket_number']) . '</strong> „' . e(mb_strimwidth((string) $t['subject'], 0, 80, '…')) . '“ wartet seit etwa ' . $hours . ' Stunden auf deine Antwort.')
|
||||
. email_button('Ticket öffnen', $url);
|
||||
email_send_and_log((string) $t['staff_email'], 'Ticket #' . $t['ticket_number'] . ' wartet auf deine Antwort', email_wrap($inner), null, 'Erinnerung-an-Bearbeiter-Mail', (string) $t['ticket_number']);
|
||||
}
|
||||
$res['sent']++;
|
||||
$res['log'][] = "[ok] Erinnerung an {$t['staff_name']}: Ticket #{$t['ticket_number']} wartet seit {$hours} Std.";
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
@@ -21,18 +21,26 @@ function web_handoff_pages(array $staff): array
|
||||
// Alle Mitarbeiter
|
||||
['mentions', 'Erwähnungen', '/staff/mentions.php', 'Allgemein', true],
|
||||
['agent_directory', 'Mitarbeiterverzeichnis', '/staff/agent_directory.php', 'Allgemein', true],
|
||||
['apps', 'Apps und Downloads', '/staff/apps.php', 'Allgemein', true],
|
||||
// Mit Recht
|
||||
['assistant', 'Digitaler Assistent (Antworten, Fragen-Log)', '/staff/assistant.php', 'Verwaltung', $perm('manage_assistant')],
|
||||
['imap_accounts', 'Postfächer (E-Mail-Abruf)', '/staff/imap_accounts.php', 'Verwaltung', $perm('manage_email_addresses')],
|
||||
// Nur Administratoren
|
||||
['settings', 'Einstellungen', '/staff/settings.php', 'Verwaltung', $isAdmin],
|
||||
['recurring_tickets', 'Wiederkehrende Tickets', '/staff/recurring_tickets.php', 'Verwaltung', $isAdmin],
|
||||
['canned_responses', 'Textbausteine', '/staff/canned_responses.php', 'Verwaltung', $isAdmin],
|
||||
['custom_fields', 'Ticket-Felder', '/staff/custom_fields.php', 'Verwaltung', $isAdmin],
|
||||
['custom_lists', 'Benutzerdefinierte Listen', '/staff/custom_lists.php', 'Verwaltung', $isAdmin],
|
||||
['site_pages', 'Seiten (Impressum, Datenschutz …)', '/staff/site_pages.php', 'Verwaltung', $isAdmin],
|
||||
['ticket_templates', 'Ticket-Vorlagen', '/staff/ticket_templates.php', 'Verwaltung', $isAdmin],
|
||||
['status_page', 'Statusseite (Dienste, Meldungen)', '/staff/status_page.php', 'Verwaltung', $isAdmin],
|
||||
['recurring_tickets', 'Wiederkehrende Tickets', '/staff/recurring_tickets.php', 'Verwaltung', $perm('manage_automation')],
|
||||
['canned_responses', 'Textbausteine', '/staff/canned_responses.php', 'Verwaltung', $perm('manage_canned')],
|
||||
['custom_fields', 'Ticket-Felder', '/staff/custom_fields.php', 'Verwaltung', $perm('manage_custom_fields')],
|
||||
['custom_lists', 'Benutzerdefinierte Listen', '/staff/custom_lists.php', 'Verwaltung', $perm('manage_custom_fields')],
|
||||
['site_pages', 'Seiten (Impressum, Datenschutz …)', '/staff/site_pages.php', 'Verwaltung', $perm('manage_site_pages')],
|
||||
['ticket_templates', 'Ticket-Vorlagen', '/staff/ticket_templates.php', 'Verwaltung', $perm('manage_ticket_templates')],
|
||||
['status_page', 'Statusseite (Dienste, Meldungen)', '/staff/status_page.php', 'Verwaltung', $perm('manage_status_page')],
|
||||
['languages', 'Sprachen', '/staff/languages.php', 'Verwaltung', $perm('manage_languages')],
|
||||
['mail_queue', 'Mail-Warteschlange', '/staff/mail_queue.php', 'Verwaltung', $perm('view_mail_queue')],
|
||||
['login_log', 'Anmelde-Wächter', '/staff/login_log.php', 'System', $perm('view_login_guard')],
|
||||
['problems', 'Problemmanagement', '/staff/problems.php', 'Verwaltung', $perm('manage_problems')],
|
||||
['customers', 'Kundenverzeichnis', '/staff/customers.php', 'Verwaltung', $perm('manage_customers')],
|
||||
['banlist', 'Sperrliste', '/staff/banlist.php', 'Allgemein', $perm('manage_banlist')],
|
||||
['help', 'Hilfe und Handbuch', '/staff/help.php', 'System', $perm('view_help')],
|
||||
['webhooks', 'Webhooks', '/staff/webhooks.php', 'Verwaltung', $isAdmin],
|
||||
['api_keys', 'API-Schlüssel', '/staff/api_keys.php', 'Verwaltung', $isAdmin],
|
||||
['plugins', 'Plugins und Themes', '/staff/plugins.php', 'System', $isAdmin],
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
<?php
|
||||
/**
|
||||
* Passkeys (WebAuthn) für die Anmeldung der Mitarbeiter, ohne Fremdbibliothek: CBOR lesen, Registrierung und Anmeldung prüfen.
|
||||
*
|
||||
* Unterstützt: ES256 (ECDSA P-256) und RS256, Attestation „none“ (bei Passkeys üblich, die Herkunft des Geräts wird nicht geprüft).
|
||||
* Verlangt wird immer Nutzerprüfung (Fingerabdruck, Gesicht oder PIN): Ein Passkey ersetzt deshalb Passwort und 2FA-Code.
|
||||
* Relying-Party-ID und Origin kommen aus der eingestellten Website-URL (Einstellungen → Anzeigename & URL); Passkeys gelten nur dafür.
|
||||
*/
|
||||
|
||||
/** Passkeys eingeschaltet? (Adminportal → Anmelde-Wächter) */
|
||||
function webauthn_enabled(): bool
|
||||
{
|
||||
return get_setting('passkeys_enabled', '1') === '1';
|
||||
}
|
||||
|
||||
function webauthn_b64u_encode(string $raw): string
|
||||
{
|
||||
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
|
||||
}
|
||||
|
||||
function webauthn_b64u_decode(string $s): string
|
||||
{
|
||||
$s = strtr($s, '-_', '+/');
|
||||
$pad = strlen($s) % 4;
|
||||
if ($pad) $s .= str_repeat('=', 4 - $pad);
|
||||
$r = base64_decode($s, true);
|
||||
return $r === false ? '' : $r;
|
||||
}
|
||||
|
||||
/** Minimaler CBOR-Leser (genug für Attestation-Objekt und COSE-Schlüssel). @return mixed */
|
||||
function webauthn_cbor_decode(string $d, int &$o = 0, int $depth = 0)
|
||||
{
|
||||
if ($depth > 8 || $o >= strlen($d)) throw new RuntimeException('CBOR ungültig');
|
||||
$b = ord($d[$o++]);
|
||||
$major = $b >> 5;
|
||||
$info = $b & 31;
|
||||
if ($major === 7) {
|
||||
if ($info === 20) return false;
|
||||
if ($info === 21) return true;
|
||||
if ($info === 22 || $info === 23) return null;
|
||||
throw new RuntimeException('CBOR: nicht unterstützter Wert');
|
||||
}
|
||||
if ($info < 24) {
|
||||
$n = $info;
|
||||
} elseif ($info === 24) {
|
||||
if ($o + 1 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
|
||||
$n = ord($d[$o++]);
|
||||
} elseif ($info === 25) {
|
||||
if ($o + 2 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
|
||||
$n = unpack('n', substr($d, $o, 2))[1]; $o += 2;
|
||||
} elseif ($info === 26) {
|
||||
if ($o + 4 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
|
||||
$n = unpack('N', substr($d, $o, 4))[1]; $o += 4;
|
||||
} elseif ($info === 27) {
|
||||
if ($o + 8 > strlen($d)) throw new RuntimeException('CBOR zu kurz');
|
||||
$n = unpack('J', substr($d, $o, 8))[1]; $o += 8;
|
||||
if ($n < 0) throw new RuntimeException('CBOR: Zahl zu groß');
|
||||
} else {
|
||||
throw new RuntimeException('CBOR: unbestimmte Länge nicht unterstützt');
|
||||
}
|
||||
switch ($major) {
|
||||
case 0: return $n;
|
||||
case 1: return -1 - $n;
|
||||
case 2:
|
||||
case 3:
|
||||
if ($n > strlen($d) - $o) throw new RuntimeException('CBOR zu kurz');
|
||||
$s = substr($d, $o, $n); $o += $n;
|
||||
return $s;
|
||||
case 4:
|
||||
if ($n > 1000) throw new RuntimeException('CBOR: Liste zu lang');
|
||||
$a = [];
|
||||
for ($i = 0; $i < $n; $i++) $a[] = webauthn_cbor_decode($d, $o, $depth + 1);
|
||||
return $a;
|
||||
case 5:
|
||||
if ($n > 1000) throw new RuntimeException('CBOR: Tabelle zu lang');
|
||||
$m = [];
|
||||
for ($i = 0; $i < $n; $i++) {
|
||||
$k = webauthn_cbor_decode($d, $o, $depth + 1);
|
||||
$m[is_int($k) ? $k : (string) $k] = webauthn_cbor_decode($d, $o, $depth + 1);
|
||||
}
|
||||
return $m;
|
||||
case 6:
|
||||
return webauthn_cbor_decode($d, $o, $depth + 1);
|
||||
}
|
||||
throw new RuntimeException('CBOR ungültig');
|
||||
}
|
||||
|
||||
function webauthn_der_len(int $n): string
|
||||
{
|
||||
if ($n < 128) return chr($n);
|
||||
$b = ltrim(pack('N', $n), "\0");
|
||||
return chr(0x80 | strlen($b)) . $b;
|
||||
}
|
||||
|
||||
function webauthn_der(int $tag, string $content): string
|
||||
{
|
||||
return chr($tag) . webauthn_der_len(strlen($content)) . $content;
|
||||
}
|
||||
|
||||
function webauthn_der_int(string $raw): string
|
||||
{
|
||||
$raw = ltrim($raw, "\0");
|
||||
if ($raw === '' || (ord($raw[0]) & 0x80)) $raw = "\0" . $raw;
|
||||
return webauthn_der(0x02, $raw);
|
||||
}
|
||||
|
||||
function webauthn_pem(string $der): string
|
||||
{
|
||||
return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($der), 64, "\n") . "-----END PUBLIC KEY-----\n";
|
||||
}
|
||||
|
||||
/** COSE-Schlüssel (ES256 oder RS256) in eine PEM-Datei für OpenSSL umwandeln. @return array{0:string,1:int} PEM und Algorithmus */
|
||||
function webauthn_cose_to_pem(array $cose): array
|
||||
{
|
||||
$kty = $cose[1] ?? null;
|
||||
$alg = $cose[3] ?? null;
|
||||
if ($kty === 2 && $alg === -7 && ($cose[-1] ?? null) === 1 && strlen((string) ($cose[-2] ?? '')) === 32 && strlen((string) ($cose[-3] ?? '')) === 32) {
|
||||
$spki = hex2bin('3059301306072a8648ce3d020106082a8648ce3d030107034200') . "\x04" . $cose[-2] . $cose[-3];
|
||||
return [webauthn_pem($spki), -7];
|
||||
}
|
||||
if ($kty === 3 && $alg === -257 && is_string($cose[-1] ?? null) && is_string($cose[-2] ?? null) && strlen($cose[-1]) >= 128) {
|
||||
$rsaKey = webauthn_der(0x30, webauthn_der_int($cose[-1]) . webauthn_der_int($cose[-2]));
|
||||
$alg = hex2bin('300d06092a864886f70d0101010500');
|
||||
$spki = webauthn_der(0x30, $alg . webauthn_der(0x03, "\0" . $rsaKey));
|
||||
return [webauthn_pem($spki), -257];
|
||||
}
|
||||
throw new RuntimeException('Dieser Passkey-Typ wird nicht unterstützt (erlaubt: ES256, RS256).');
|
||||
}
|
||||
|
||||
/**
|
||||
* Relying Party: ['id' => Domain, 'origin' => Schema://Domain[:Port], 'secure' => bool, 'name' => Anzeigename].
|
||||
* Grundlage ist die Website-URL (Einstellungen → Anzeigename & URL). Wird DeskHub unter einer Subdomain dieser Adresse aufgerufen
|
||||
* (Website-URL https://beispiel.de, aufgerufen unter https://support.beispiel.de), gilt die Adresse, unter der die Seite gerade aufgerufen wird:
|
||||
* Der Browser verlangt genau diese Domain. Fremde Domains werden ignoriert, dann gilt die Website-URL.
|
||||
*/
|
||||
function webauthn_rp(): array
|
||||
{
|
||||
$u = parse_url(base_url());
|
||||
$siteHost = strtolower((string) ($u['host'] ?? ''));
|
||||
$scheme = strtolower((string) ($u['scheme'] ?? 'https'));
|
||||
$host = $siteHost;
|
||||
$port = isset($u['port']) ? (int) $u['port'] : null;
|
||||
// Mögliche Adressen, unter denen die Seite aufgerufen wird. Der Origin-Kopf stammt vom Browser (nicht fälschbar durch die Seite) und ist
|
||||
// zuverlässig, auch wenn ein Reverse-Proxy den Host-Kopf verändert; X-Forwarded-Host zählt nur von einem vertrauenswürdigen Proxy.
|
||||
$candidates = [];
|
||||
$originHdr = trim((string) ($_SERVER['HTTP_ORIGIN'] ?? ''));
|
||||
if ($originHdr !== '' && ($oh = parse_url($originHdr)) && !empty($oh['host'])) $candidates[] = strtolower((string) $oh['host']) . (isset($oh['port']) ? ':' . (int) $oh['port'] : '');
|
||||
if (function_exists('request_from_trusted_proxy') && request_from_trusted_proxy() && !empty($_SERVER['HTTP_X_FORWARDED_HOST'])) {
|
||||
$candidates[] = strtolower(trim(explode(',', (string) $_SERVER['HTTP_X_FORWARDED_HOST'])[0]));
|
||||
}
|
||||
$candidates[] = strtolower(trim((string) ($_SERVER['HTTP_HOST'] ?? '')));
|
||||
foreach ($candidates as $req) {
|
||||
if ($req === '' || !preg_match('/^([a-z0-9]([a-z0-9.-]*[a-z0-9])?)(?::(\d{1,5}))?$/', $req, $m)) continue;
|
||||
$reqHost = $m[1];
|
||||
if ($siteHost !== '' && ($reqHost === $siteHost || str_ends_with($reqHost, '.' . $siteHost))) {
|
||||
$host = $reqHost;
|
||||
$port = isset($m[3]) && $m[3] !== '' ? (int) $m[3] : null;
|
||||
if ($port !== null && (($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) $port = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$origin = $scheme . '://' . $host . ($port !== null ? ':' . $port : '');
|
||||
$secure = $scheme === 'https' || $host === 'localhost';
|
||||
return ['id' => $host, 'origin' => $origin, 'secure' => $secure, 'name' => (string) get_setting('display_name', config()['site']['name'] ?? 'DeskHub')];
|
||||
}
|
||||
|
||||
/** Feste Kennung des Mitarbeiters im Passkey (keine Personendaten). */
|
||||
function webauthn_user_handle(int $staffId): string
|
||||
{
|
||||
return hash_hmac('sha256', 'deskhub-passkey-user-' . $staffId, (string) (config()['app_key'] ?? 'deskhub'), true);
|
||||
}
|
||||
|
||||
function webauthn_challenge_new(string $type, ?int $staffId = null): string
|
||||
{
|
||||
$c = random_bytes(32);
|
||||
$_SESSION['webauthn_challenge'] = ['v' => webauthn_b64u_encode($c), 'type' => $type, 'exp' => time() + 300, 'staff' => $staffId];
|
||||
return webauthn_b64u_encode($c);
|
||||
}
|
||||
|
||||
/** Merkt die Challenge nur einmal: Ein zweiter Versuch mit derselben Challenge scheitert. */
|
||||
function webauthn_challenge_take(string $type, ?int $staffId = null): ?string
|
||||
{
|
||||
$c = $_SESSION['webauthn_challenge'] ?? null;
|
||||
unset($_SESSION['webauthn_challenge']);
|
||||
if (!is_array($c) || ($c['type'] ?? '') !== $type || (int) ($c['exp'] ?? 0) < time()) return null;
|
||||
if ($staffId !== null && (int) ($c['staff'] ?? 0) !== $staffId) return null;
|
||||
return (string) $c['v'];
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> */
|
||||
function webauthn_staff_passkeys(PDO $pdo, int $staffId): array
|
||||
{
|
||||
try {
|
||||
$s = $pdo->prepare('SELECT * FROM staff_passkeys WHERE staff_id = ? ORDER BY id');
|
||||
$s->execute([$staffId]);
|
||||
return $s->fetchAll();
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function webauthn_count_passkeys(PDO $pdo, int $staffId): int
|
||||
{
|
||||
return count(webauthn_staff_passkeys($pdo, $staffId));
|
||||
}
|
||||
|
||||
/** Optionen für die Registrierung (navigator.credentials.create). */
|
||||
function webauthn_creation_options(PDO $pdo, array $staff): array
|
||||
{
|
||||
$rp = webauthn_rp();
|
||||
$exclude = [];
|
||||
foreach (webauthn_staff_passkeys($pdo, (int) $staff['id']) as $k) $exclude[] = ['type' => 'public-key', 'id' => $k['credential_id']];
|
||||
return [
|
||||
'challenge' => webauthn_challenge_new('create', (int) $staff['id']),
|
||||
'rp' => ['id' => $rp['id'], 'name' => $rp['name']],
|
||||
'user' => ['id' => webauthn_b64u_encode(webauthn_user_handle((int) $staff['id'])), 'name' => (string) $staff['email'], 'displayName' => (string) $staff['name']],
|
||||
'pubKeyCredParams' => [['type' => 'public-key', 'alg' => -7], ['type' => 'public-key', 'alg' => -257]],
|
||||
'timeout' => 120000,
|
||||
'attestation' => 'none',
|
||||
'excludeCredentials' => $exclude,
|
||||
'authenticatorSelection' => ['residentKey' => 'required', 'requireResidentKey' => true, 'userVerification' => 'required'],
|
||||
];
|
||||
}
|
||||
|
||||
/** Optionen für die Anmeldung ohne Eingabe der Adresse (navigator.credentials.get). */
|
||||
function webauthn_request_options(): array
|
||||
{
|
||||
return [
|
||||
'challenge' => webauthn_challenge_new('get'),
|
||||
'rpId' => webauthn_rp()['id'],
|
||||
'timeout' => 120000,
|
||||
'userVerification' => 'required',
|
||||
'allowCredentials' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{0:bool,1:string} gemeinsame Prüfung von clientDataJSON */
|
||||
function webauthn_check_client_data(string $json, string $type, string $challenge): array
|
||||
{
|
||||
$cd = json_decode($json, true);
|
||||
if (!is_array($cd)) return [false, 'Antwort des Geräts ist ungültig.'];
|
||||
if (($cd['type'] ?? '') !== $type) return [false, 'Falscher Vorgang.'];
|
||||
if (!hash_equals($challenge, (string) ($cd['challenge'] ?? ''))) return [false, 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.'];
|
||||
if (($cd['origin'] ?? '') !== webauthn_rp()['origin']) return [false, 'Die Adresse stimmt nicht mit der Website-URL aus den Einstellungen überein (' . webauthn_rp()['origin'] . ').'];
|
||||
if (!empty($cd['crossOrigin'])) return [false, 'Eingebettete Seiten sind nicht erlaubt.'];
|
||||
return [true, ''];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrierung abschließen. $cred = ['id' => …, 'response' => ['clientDataJSON' => b64u, 'attestationObject' => b64u]].
|
||||
* @return array{ok:bool,error?:string,id?:int}
|
||||
*/
|
||||
function webauthn_register_finish(PDO $pdo, array $staff, array $cred, string $name): array
|
||||
{
|
||||
$challenge = webauthn_challenge_take('create', (int) $staff['id']);
|
||||
if ($challenge === null) return ['ok' => false, 'error' => 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.'];
|
||||
try {
|
||||
$clientData = webauthn_b64u_decode((string) ($cred['response']['clientDataJSON'] ?? ''));
|
||||
[$ok, $err] = webauthn_check_client_data($clientData, 'webauthn.create', $challenge);
|
||||
if (!$ok) return ['ok' => false, 'error' => $err];
|
||||
$att = webauthn_cbor_decode(webauthn_b64u_decode((string) ($cred['response']['attestationObject'] ?? '')));
|
||||
if (!is_array($att) || !is_string($att['authData'] ?? null)) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
|
||||
$ad = $att['authData'];
|
||||
if (strlen($ad) < 55) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
|
||||
if (!hash_equals(hash('sha256', webauthn_rp()['id'], true), substr($ad, 0, 32))) return ['ok' => false, 'error' => 'Der Passkey gehört zu einer anderen Domain.'];
|
||||
$flags = ord($ad[32]);
|
||||
if (!($flags & 0x01)) return ['ok' => false, 'error' => 'Anwesenheit nicht bestätigt.'];
|
||||
if (!($flags & 0x04)) return ['ok' => false, 'error' => 'Das Gerät hat Sie nicht geprüft (Fingerabdruck, Gesicht oder PIN nötig).'];
|
||||
if (!($flags & 0x40)) return ['ok' => false, 'error' => 'Das Gerät hat keinen Schlüssel geliefert.'];
|
||||
$signCount = unpack('N', substr($ad, 33, 4))[1];
|
||||
$idLen = unpack('n', substr($ad, 53, 2))[1];
|
||||
if ($idLen < 16 || $idLen > 1023 || strlen($ad) < 55 + $idLen) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
|
||||
$credId = substr($ad, 55, $idLen);
|
||||
$o = 55 + $idLen;
|
||||
$cose = webauthn_cbor_decode($ad, $o);
|
||||
if (!is_array($cose)) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
|
||||
[$pem, $alg] = webauthn_cose_to_pem($cose);
|
||||
if (!openssl_pkey_get_public($pem)) return ['ok' => false, 'error' => 'Der öffentliche Schlüssel ist ungültig.'];
|
||||
$idText = webauthn_b64u_encode($credId);
|
||||
if (hash_equals($idText, (string) ($cred['id'] ?? '')) === false) return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
|
||||
$dup = $pdo->prepare('SELECT COUNT(*) FROM staff_passkeys WHERE credential_id = ?');
|
||||
$dup->execute([$idText]);
|
||||
if ((int) $dup->fetchColumn() > 0) return ['ok' => false, 'error' => 'Dieser Passkey ist schon registriert.'];
|
||||
$name = trim(mb_substr($name, 0, 80));
|
||||
if ($name === '') $name = 'Passkey ' . date('d.m.Y');
|
||||
$pdo->prepare('INSERT INTO staff_passkeys (staff_id, credential_id, public_key, alg, sign_count, name, created_at) VALUES (?,?,?,?,?,?,?)')
|
||||
->execute([(int) $staff['id'], $idText, $pem, $alg, $signCount, $name, db_now()]);
|
||||
return ['ok' => true, 'id' => (int) $pdo->lastInsertId()];
|
||||
} catch (Throwable $e) {
|
||||
return ['ok' => false, 'error' => $e instanceof RuntimeException ? $e->getMessage() : 'Der Passkey konnte nicht geprüft werden.'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Anmeldung prüfen. $cred = ['id', 'response' => ['clientDataJSON', 'authenticatorData', 'signature', 'userHandle']] (alles base64url).
|
||||
* @return array{ok:bool,error?:string,staff?:array<string,mixed>,passkey?:array<string,mixed>}
|
||||
*/
|
||||
function webauthn_login_finish(PDO $pdo, array $cred): array
|
||||
{
|
||||
$challenge = webauthn_challenge_take('get');
|
||||
if ($challenge === null) return ['ok' => false, 'error' => 'Die Anfrage ist abgelaufen. Bitte noch einmal versuchen.'];
|
||||
try {
|
||||
$st = $pdo->prepare('SELECT * FROM staff_passkeys WHERE credential_id = ?');
|
||||
$st->execute([(string) ($cred['id'] ?? '')]);
|
||||
$key = $st->fetch();
|
||||
if (!$key) return ['ok' => false, 'error' => 'Dieser Passkey ist hier nicht registriert.'];
|
||||
$clientData = webauthn_b64u_decode((string) ($cred['response']['clientDataJSON'] ?? ''));
|
||||
[$ok, $err] = webauthn_check_client_data($clientData, 'webauthn.get', $challenge);
|
||||
if (!$ok) return ['ok' => false, 'error' => $err];
|
||||
$ad = webauthn_b64u_decode((string) ($cred['response']['authenticatorData'] ?? ''));
|
||||
$sig = webauthn_b64u_decode((string) ($cred['response']['signature'] ?? ''));
|
||||
if (strlen($ad) < 37 || $sig === '') return ['ok' => false, 'error' => 'Antwort des Geräts ist ungültig.'];
|
||||
if (!hash_equals(hash('sha256', webauthn_rp()['id'], true), substr($ad, 0, 32))) return ['ok' => false, 'error' => 'Der Passkey gehört zu einer anderen Domain.'];
|
||||
$flags = ord($ad[32]);
|
||||
if (!($flags & 0x01) || !($flags & 0x04)) return ['ok' => false, 'error' => 'Das Gerät hat Sie nicht geprüft (Fingerabdruck, Gesicht oder PIN nötig).'];
|
||||
$pub = openssl_pkey_get_public((string) $key['public_key']);
|
||||
if (!$pub) return ['ok' => false, 'error' => 'Der gespeicherte Schlüssel ist ungültig.'];
|
||||
$data = $ad . hash('sha256', $clientData, true);
|
||||
if (openssl_verify($data, $sig, $pub, OPENSSL_ALGO_SHA256) !== 1) return ['ok' => false, 'error' => 'Die Signatur des Passkeys ist ungültig.'];
|
||||
$handle = (string) ($cred['response']['userHandle'] ?? '');
|
||||
if ($handle !== '' && !hash_equals(webauthn_b64u_encode(webauthn_user_handle((int) $key['staff_id'])), $handle)) {
|
||||
return ['ok' => false, 'error' => 'Der Passkey gehört zu einem anderen Konto.'];
|
||||
}
|
||||
// Zähler gegen kopierte Passkeys: Er muss wachsen, wenn das Gerät überhaupt zählt
|
||||
$count = unpack('N', substr($ad, 33, 4))[1];
|
||||
$old = (int) $key['sign_count'];
|
||||
if (($count !== 0 || $old !== 0) && $count <= $old) return ['ok' => false, 'error' => 'Der Passkey wurde möglicherweise kopiert und ist abgelehnt worden. Bitte einen Administrator informieren.'];
|
||||
$sd = $pdo->prepare('SELECT * FROM staff WHERE id = ? AND is_active = 1');
|
||||
$sd->execute([(int) $key['staff_id']]);
|
||||
$staff = $sd->fetch();
|
||||
if (!$staff) return ['ok' => false, 'error' => 'Das Konto ist nicht aktiv.'];
|
||||
$pdo->prepare('UPDATE staff_passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?')->execute([$count, db_now(), (int) $key['id']]);
|
||||
return ['ok' => true, 'staff' => $staff, 'passkey' => $key];
|
||||
} catch (Throwable $e) {
|
||||
return ['ok' => false, 'error' => 'Der Passkey konnte nicht geprüft werden.'];
|
||||
}
|
||||
}
|
||||
|
||||
/** Alle Passkeys eines Mitarbeiters löschen. @return int Anzahl */
|
||||
function webauthn_remove_all(PDO $pdo, int $staffId): int
|
||||
{
|
||||
$n = webauthn_count_passkeys($pdo, $staffId);
|
||||
if ($n > 0) $pdo->prepare('DELETE FROM staff_passkeys WHERE staff_id = ?')->execute([$staffId]);
|
||||
return $n;
|
||||
}
|
||||
+28
-13
@@ -10,19 +10,35 @@ $pdo = get_db();
|
||||
$q = trim($_GET['q'] ?? '');
|
||||
|
||||
if ($q !== '') {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT a.*, c.name AS category_name FROM kb_articles a
|
||||
JOIN kb_categories c ON c.id = a.category_id
|
||||
WHERE a.is_published = 1 AND (a.title LIKE ? OR a.body LIKE ?)
|
||||
ORDER BY a.views DESC"
|
||||
);
|
||||
$like = '%' . $q . '%';
|
||||
$stmt->execute([$like, $like]);
|
||||
$articles = $stmt->fetchAll();
|
||||
$loadSearch = static function () use ($pdo, $q): array {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT a.*, c.name AS category_name FROM kb_articles a
|
||||
JOIN kb_categories c ON c.id = a.category_id
|
||||
WHERE a.is_published = 1 AND (a.title LIKE ? OR a.body LIKE ?)
|
||||
ORDER BY a.views DESC"
|
||||
);
|
||||
$like = '%' . $q . '%';
|
||||
$stmt->execute([$like, $like]);
|
||||
return $stmt->fetchAll();
|
||||
};
|
||||
$articles = function_exists('dhcm_cache_remember')
|
||||
? dhcm_cache_remember('kb.search.' . $q, $loadSearch)
|
||||
: $loadSearch();
|
||||
$categories = [];
|
||||
} else {
|
||||
$categories = $pdo->query('SELECT * FROM kb_categories ORDER BY sort_order, name')->fetchAll();
|
||||
$artStmt = $pdo->prepare('SELECT * FROM kb_articles WHERE category_id = ? AND is_published = 1 ORDER BY title');
|
||||
$loadIndex = static function () use ($pdo): array {
|
||||
$categories = $pdo->query('SELECT * FROM kb_categories ORDER BY sort_order, name')->fetchAll();
|
||||
$articlesByCategory = [];
|
||||
foreach ($pdo->query('SELECT * FROM kb_articles WHERE is_published = 1 ORDER BY title')->fetchAll() as $article) {
|
||||
$articlesByCategory[(int) $article['category_id']][] = $article;
|
||||
}
|
||||
return ['categories' => $categories, 'articles' => $articlesByCategory];
|
||||
};
|
||||
$kbIndex = function_exists('dhcm_cache_remember')
|
||||
? dhcm_cache_remember('kb.index', $loadIndex)
|
||||
: $loadIndex();
|
||||
$categories = $kbIndex['categories'] ?? [];
|
||||
$articlesByCategory = $kbIndex['articles'] ?? [];
|
||||
$articles = null;
|
||||
}
|
||||
|
||||
@@ -54,8 +70,7 @@ require __DIR__ . '/includes/header_customer.php';
|
||||
<div class="kb-category-grid">
|
||||
<?php foreach ($categories as $i => $cat): ?>
|
||||
<?php
|
||||
$artStmt->execute([$cat['id']]);
|
||||
$arts = $artStmt->fetchAll();
|
||||
$arts = $articlesByCategory[(int) $cat['id']] ?? [];
|
||||
if (!$arts) continue;
|
||||
$anyArticles = true;
|
||||
?>
|
||||
|
||||
@@ -9,13 +9,18 @@ if (get_setting('kb_enabled', '1') !== '1') {
|
||||
$pdo = get_db();
|
||||
$slug = $_GET['slug'] ?? '';
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT a.*, c.name AS category_name FROM kb_articles a
|
||||
JOIN kb_categories c ON c.id = a.category_id
|
||||
WHERE a.slug = ? AND a.is_published = 1'
|
||||
);
|
||||
$stmt->execute([$slug]);
|
||||
$article = $stmt->fetch();
|
||||
$loadArticle = static function () use ($pdo, $slug): ?array {
|
||||
$stmt = $pdo->prepare(
|
||||
'SELECT a.*, c.name AS category_name FROM kb_articles a
|
||||
JOIN kb_categories c ON c.id = a.category_id
|
||||
WHERE a.slug = ? AND a.is_published = 1'
|
||||
);
|
||||
$stmt->execute([$slug]);
|
||||
return $stmt->fetch() ?: null;
|
||||
};
|
||||
$article = function_exists('dhcm_cache_remember')
|
||||
? dhcm_cache_remember('kb.article.' . $slug, $loadArticle)
|
||||
: $loadArticle();
|
||||
|
||||
if (!$article) {
|
||||
flash('error', 'Dieser Artikel wurde nicht gefunden.');
|
||||
|
||||
@@ -248,7 +248,7 @@ if ($action !== 'typing') {
|
||||
|
||||
if ($action === 'typing') {
|
||||
if ($activeTicket && $activeTicket['status'] !== 'closed') {
|
||||
$pdo->prepare('UPDATE tickets SET typing_customer_until = ? WHERE id = ?')
|
||||
$pdo->prepare('UPDATE tickets SET typing_customer_until = ?, updated_at = updated_at WHERE id = ?')
|
||||
->execute([date('Y-m-d H:i:s', time() + 6), $activeTicket['id']]);
|
||||
}
|
||||
chat_json(['ok' => true]);
|
||||
|
||||
@@ -132,6 +132,10 @@ $imprintHtml = get_setting('footer_imprint_html', '');
|
||||
<span class="login-guest-links">Kein Konto nötig: <a href="/ticket_new.php">Ticket erstellen</a> · <a href="/ticket_lookup.php">Status prüfen</a></span>
|
||||
<a href="/staff/login.php" class="login-staff-link">Mitarbeiter-Login →</a>
|
||||
</div>
|
||||
<?php $loginButtons = (string) plugin_filter('customer.login.buttons', '', []); ?>
|
||||
<?php if ($loginButtons !== ''): ?>
|
||||
<div class="login-alt" style="margin-top:18px;padding-top:16px;border-top:1px solid #e2e8f0;display:grid;gap:10px"><?= $loginButtons ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
/**
|
||||
* plugin_login.php
|
||||
*
|
||||
* Gemeinsamer Einstieg für Kunden-Anmeldungen über Plugins (zum Beispiel authentik): GET ?p=<Plugin>&e=<Name>. Das Plugin braucht die
|
||||
* Berechtigung "auth.customer" und meldet die Adresse mit plugin_register_customer_login() an. Hier steht nur, was für alle gleich ist:
|
||||
* Weiterleitung, Anmeldung (und, wenn das Plugin es erlaubt, Anlegen des Kundenkontos), Fehler und Begrenzung der Aufrufe.
|
||||
* Die Rückkehr vom Dienst kann auch über staff/plugin_login.php kommen (gleiche Rückkehr-Adresse für alle).
|
||||
*/
|
||||
require_once __DIR__ . '/includes/bootstrap.php';
|
||||
|
||||
header('Cache-Control: no-store');
|
||||
header('Referrer-Policy: no-referrer');
|
||||
|
||||
function plugin_customer_login_back(string $message): never
|
||||
{
|
||||
flash('error', $message);
|
||||
redirect('/login.php');
|
||||
}
|
||||
|
||||
if (current_user()) redirect('/home.php');
|
||||
|
||||
$slug = (string) ($_GET['p'] ?? '');
|
||||
$endpoint = (string) ($_GET['e'] ?? '');
|
||||
$handler = plugin_state()['customer_logins'][$slug][$endpoint] ?? null;
|
||||
if (!is_callable($handler)) {
|
||||
http_response_code(404);
|
||||
echo 'Unbekannte Anmelde-Adresse.';
|
||||
exit;
|
||||
}
|
||||
if (!rate_limit_check('plugin_customer_login', client_ip(), 40, 15)) plugin_customer_login_back('Zu viele Anmeldeversuche. Bitte versuchen Sie es in einigen Minuten erneut.');
|
||||
|
||||
$result = plugin_guard($slug, fn() => $handler(['query' => $_GET, 'ip' => client_ip()]), null);
|
||||
if (!is_array($result)) plugin_customer_login_back('Die Anmeldung ist gerade nicht möglich. Bitte versuchen Sie es später noch einmal oder melden Sie sich mit E-Mail und Passwort an.');
|
||||
|
||||
if (!empty($result['redirect'])) {
|
||||
$url = (string) $result['redirect'];
|
||||
if (!plugin_login_external_url_ok($url)) plugin_customer_login_back('Die Adresse des Anmeldedienstes ist ungültig.');
|
||||
header('Location: ' . $url, true, 302);
|
||||
exit;
|
||||
}
|
||||
if (!empty($result['customer']) && is_array($result['customer'])) plugin_customer_login_finish($slug, $result['customer']);
|
||||
plugin_customer_login_back(mb_substr((string) ($result['error'] ?? 'Die Anmeldung wurde abgelehnt.'), 0, 300));
|
||||
@@ -22,7 +22,8 @@
|
||||
<tbody>
|
||||
<?php foreach ($recent as $t):
|
||||
[$label, $color] = status_label($t['status']);
|
||||
$isOverdue = $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time();
|
||||
$isDone = in_array($t['status'], ['resolved','closed'], true);
|
||||
$isOverdue = !$isDone && $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time();
|
||||
?>
|
||||
<tr class="<?= $isOverdue ? 'is-overdue' : '' ?>">
|
||||
<td><a href="/staff/ticket_view.php?n=<?= urlencode($t['ticket_number']) ?>" class="ticket-no">#<?= e($t['ticket_number']) ?></a></td>
|
||||
@@ -34,7 +35,7 @@
|
||||
<td><?= e($t['department_name']) ?></td>
|
||||
<td><span class="badge" style="background:<?= e($t['priority_color']) ?>"><?= e($t['priority_name']) ?></span></td>
|
||||
<td><span class="badge" style="background:<?= $color ?>"><?= e($label) ?></span></td>
|
||||
<td class="muted"><?= time_until($t['resolution_due_at']) ?></td>
|
||||
<td class="muted"><?= $isDone ? '–' : (!empty($t['sla_paused_at']) && ticket_sla_is_paused_status((string) $t['status']) ? 'Pausiert' : time_until($t['resolution_due_at'])) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
</div>
|
||||
<script src="/assets/js/richtext.js?v=<?= @filemtime(__DIR__ . '/../assets/js/richtext.js') ?: time() ?>"></script>
|
||||
<script src="/assets/js/live-tickets.js?v=<?= @filemtime(__DIR__ . '/../assets/js/live-tickets.js') ?: time() ?>"></script>
|
||||
<script src="/assets/js/staff-shortcuts.js?v=<?= @filemtime(__DIR__ . '/../assets/js/staff-shortcuts.js') ?: time() ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+434
-429
@@ -1,429 +1,434 @@
|
||||
<?php
|
||||
/** @var array $staff (current_staff(), wird von der aufrufenden Seite gesetzt) */
|
||||
$staff = current_staff();
|
||||
$activePage = $activePage ?? '';
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= e($pageTitle ?? 'DeskHub – Mitarbeiterbereich') ?></title>
|
||||
<meta name="csrf" content="<?= csrf_token() ?>">
|
||||
<?php
|
||||
// VAPID Public Key – aus Config oder automatisch aus DB
|
||||
if (file_exists(__DIR__ . '/../includes/push.php')) {
|
||||
require_once __DIR__ . '/../includes/push.php';
|
||||
$__vapidKeys = get_or_create_vapid_keys();
|
||||
$pushKey = $__vapidKeys['public_key'] ?? '';
|
||||
} else {
|
||||
$pushKey = config()['push']['public_key'] ?? '';
|
||||
}
|
||||
?>
|
||||
<?php if ($pushKey): ?>
|
||||
<meta name="push-key" content="<?= e($pushKey) ?>">
|
||||
<?php endif; ?>
|
||||
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
|
||||
<?= site_icon_links() ?>
|
||||
<link rel="manifest" href="/manifest-staff.php">
|
||||
<meta name="theme-color" content="#0f6f6a">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/staff/sw-staff.js', {scope: '/staff/'}).catch(function(){});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script>try { if (localStorage.getItem('deskhub_sidebar') === 'closed') document.documentElement.classList.add('sidebar-closed'); } catch (e) {}</script>
|
||||
<button type="button" id="sidebar-open" class="sidebar-toggle sidebar-toggle-open" aria-label="Seitenleiste einblenden" title="Seitenleiste einblenden" onclick="document.getElementById('sidebar-toggle').click()">☰</button>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<button type="button" id="sidebar-toggle" class="sidebar-toggle sidebar-toggle-close" aria-label="Seitenleiste einklappen" title="Seitenleiste einklappen">«</button>
|
||||
<div class="brand">
|
||||
<?php $logo = get_setting('logo_path'); ?>
|
||||
<?php if ($logo): ?>
|
||||
<img src="<?= e($logo) ?>" alt="<?= e(get_setting('display_name', 'DeskHub')) ?>" style="max-height:34px; max-width:180px; display:block">
|
||||
<?php else: ?>
|
||||
Desk<span>Hub</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<nav>
|
||||
<?php // Übersicht + ehemals separates "Berichte" wurden zu einer Seite zusammengelegt
|
||||
// (siehe staff/reports.php) – der Login landete ohnehin schon immer dort. ?>
|
||||
<a href="/staff/reports.php" class="<?= $activePage==='reports'?'active':'' ?>">Übersicht</a>
|
||||
<a href="/staff/report_builder.php" class="<?= $activePage==='report_builder'?'active':'' ?>">Bericht-Baukasten</a>
|
||||
<a href="/staff/tickets.php" class="<?= $activePage==='tickets'?'active':'' ?>">Tickets</a>
|
||||
<a href="/staff/tasks.php" class="<?= $activePage==='tasks'?'active':'' ?>">Aufgaben</a>
|
||||
<a href="/staff/banlist.php" class="<?= $activePage==='banlist'?'active':'' ?>">Sperrliste</a>
|
||||
<a href="/staff/agent_directory.php" class="<?= $activePage==='agent_directory'?'active':'' ?>">Verzeichnis</a>
|
||||
<a href="/staff/apps.php" class="<?= $activePage==='apps'?'active':'' ?>">Apps</a>
|
||||
<?php foreach (plugin_menu_items($staff ?? null) as $__pm): ?>
|
||||
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
<?php if (get_setting('live_chat_enabled', '0') === '1' && $staff): ?>
|
||||
<?php
|
||||
// Badge = Anzahl Live-Chats, deren letzte Nachricht vom Kunden kam (also unbeantwortet),
|
||||
// auf die eigenen Abteilungen begrenzt (wie die Ticketliste selbst).
|
||||
[$__lcDeptSql, $__lcDeptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
|
||||
$__lcSql = "SELECT COUNT(*) FROM tickets t WHERE t.source = 'chat' AND t.status != 'closed'
|
||||
AND (SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) = 'customer'"
|
||||
. $__lcDeptSql; // enthält bereits das führende " AND " (dept_filter_sql)
|
||||
$__lcStmt = get_db()->prepare($__lcSql);
|
||||
$__lcStmt->execute($__lcDeptParams);
|
||||
$__lcPending = (int) $__lcStmt->fetchColumn();
|
||||
?>
|
||||
<a href="/staff/live_chat.php" class="<?= $activePage==='live_chat'?'active':'' ?>">
|
||||
Live-Chat<?php if ($__lcPending): ?> <span class="nav-badge" style="background:var(--danger, #dc2626); color:#fff; border-radius:10px; min-width:18px; height:18px; font-size:.68rem; font-weight:700; display:inline-flex; align-items:center; justify-content:center; padding:0 5px; margin-left:4px; vertical-align:middle"><?= $__lcPending > 9 ? '9+' : $__lcPending ?></span><?php endif; ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// Adminportal auch für "Co-Admins" zeigen: Agenten, denen per benutzerdefinierter Rolle
|
||||
// gezielt einzelne Adminportal-Rechte zugewiesen wurden (admin_section_permissions()).
|
||||
// has_permission() liefert für echte Admins ohnehin immer true, daher genügt die reine
|
||||
// Rechteprüfung je Link – kein zusätzliches role==='admin' nötig.
|
||||
$hasAnyAdminAccess = false;
|
||||
if ($staff) {
|
||||
foreach (array_keys(admin_section_permissions()) as $__perm) {
|
||||
if (has_permission($staff, $__perm)) { $hasAnyAdminAccess = true; break; }
|
||||
}
|
||||
}
|
||||
?>
|
||||
<?php if ($hasAnyAdminAccess): ?>
|
||||
<div class="nav-section-label" id="admin-toggle" role="button" tabindex="0">
|
||||
Adminportal <span class="nav-toggle-chevron">▾</span>
|
||||
</div>
|
||||
<div class="nav-admin-block" id="admin-block">
|
||||
<span class="nav-group-label">Verwalten</span>
|
||||
<?php if (has_permission($staff, 'manage_departments')): ?><a href="/staff/departments.php" class="<?= $activePage==='departments'?'active':'' ?>">Abteilungen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_priorities')): ?><a href="/staff/priorities.php" class="<?= $activePage==='priorities'?'active':'' ?>">Prioritäten / SLA</a><a href="/staff/sla_plans.php" class="<?= $activePage==='sla_plans'?'active':'' ?>">SLA-Pläne</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_help_topics')): ?><a href="/staff/help_topics.php" class="<?= $activePage==='help_topics'?'active':'' ?>">Hilfethemen</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/custom_fields.php" class="<?= $activePage==='custom_fields'?'active':'' ?>">Ticket-Felder</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/api_keys.php" class="<?= $activePage==='api_keys'?'active':'' ?>">API-Schlüssel</a><a href="/staff/webhooks.php" class="<?= $activePage==='webhooks'?'active':'' ?>">Webhooks</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/custom_lists.php" class="<?= $activePage==='custom_lists'?'active':'' ?>">Benutzerdefinierte Listen</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/canned_responses.php" class="<?= $activePage==='canned'?'active':'' ?>">Vorlagen</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/site_pages.php" class="<?= $activePage==='site_pages'?'active':'' ?>">Seiten</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/languages.php" class="<?= $activePage==='languages'?'active':'' ?>">Sprachen</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/ticket_templates.php" class="<?= $activePage==='ticket_templates'?'active':'' ?>">Ticket-Vorlagen</a><a href="/staff/status_page.php" class="<?= $activePage==='status_page'?'active':'' ?>">Statusseite</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_email_addresses')): ?><a href="/staff/email_addresses.php" class="<?= $activePage==='email_addresses'?'active':'' ?>">E-Mail-Adressen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_email_templates')): ?><a href="/staff/email_templates.php" class="<?= $activePage==='email_templates'?'active':'' ?>">E-Mail-Vorlagen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_kb')): ?><a href="/staff/kb_manage.php" class="<?= $activePage==='kb'?'active':'' ?>">Wissensdatenbank</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_assistant')): ?><a href="/staff/assistant.php" class="<?= $activePage==='assistant'?'active':'' ?>">Digitaler Assistent</a><?php endif; ?>
|
||||
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/plugins.php" class="<?= $activePage==='plugins'?'active':'' ?>">Plugins</a><?php endif; ?>
|
||||
<?php foreach (plugin_admin_menu_items($staff) as $__pm): ?>
|
||||
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
<span class="nav-group-label">Kunden</span>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/customers.php" class="<?= $activePage==='customers'?'active':'' ?>">Kundenverzeichnis</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_organizations')): ?><a href="/staff/organizations.php" class="<?= $activePage==='organizations'?'active':'' ?>">Organisationen</a><?php endif; ?>
|
||||
|
||||
<span class="nav-group-label">Agenten</span>
|
||||
<?php if (has_permission($staff, 'manage_staff')): ?><a href="/staff/staff_manage.php" class="<?= $activePage==='staff'?'active':'' ?>">Mitarbeiter</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_teams')): ?><a href="/staff/teams.php" class="<?= $activePage==='teams'?'active':'' ?>">Teams</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_roles')): ?><a href="/staff/roles.php" class="<?= $activePage==='roles'?'active':'' ?>">Rollen</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/availability.php" class="<?= $activePage==='availability'?'active':'' ?>">Status & Schichten</a><?php endif; ?>
|
||||
|
||||
<span class="nav-group-label">System</span>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/security_check.php" class="<?= $activePage==='security_check'?'active':'' ?>">Sicherheitsprüfung</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'view_audit_log')): ?><a href="/staff/audit_log.php" class="<?= $activePage==='audit_log'?'active':'' ?>">Admin-Protokoll</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/ticket_rules.php" class="<?= $activePage==='ticket_rules'?'active':'' ?>">Ticket-Regeln</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_escalations')): ?><a href="/staff/escalation_rules.php" class="<?= $activePage==='escalation_rules'?'active':'' ?>">Eskalationsregeln</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/recurring_tickets.php" class="<?= $activePage==='recurring'?'active':'' ?>">Wiederkehrende Tickets</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'view_time_entries')): ?><a href="/staff/time_entries.php" class="<?= $activePage==='time_entries'?'active':'' ?>">Zeiterfassung</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_business_hours')): ?><a href="/staff/business_hours.php" class="<?= $activePage==='business_hours'?'active':'' ?>">Geschäftszeiten</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/settings.php" class="<?= $activePage==='settings'?'active':'' ?>">Einstellungen</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/backup.php" class="<?= $activePage==='backup'?'active':'' ?>">Backup</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/update.php" class="<?= $activePage==='update'?'active':'' ?>">System-Update</a><?php endif; ?>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var toggle = document.getElementById('admin-toggle');
|
||||
var block = document.getElementById('admin-block');
|
||||
var KEY = 'deskhub_admin_nav_collapsed';
|
||||
// In diesen Adminportal-Unterseiten immer ausgeklappt zeigen, damit die aktive Seite sichtbar bleibt
|
||||
var forceOpen = block.querySelector('a.active') !== null;
|
||||
function apply(collapsed) {
|
||||
block.style.display = collapsed ? 'none' : '';
|
||||
toggle.querySelector('.nav-toggle-chevron').textContent = collapsed ? '▸' : '▾';
|
||||
}
|
||||
var stored = localStorage.getItem(KEY);
|
||||
apply(!forceOpen && stored === '1');
|
||||
toggle.addEventListener('click', function () {
|
||||
var collapsed = block.style.display !== 'none';
|
||||
apply(collapsed);
|
||||
localStorage.setItem(KEY, collapsed ? '1' : '0');
|
||||
});
|
||||
toggle.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle.click(); }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<?php if ($staff): ?>
|
||||
<div style="display:flex; align-items:center; gap:10px; margin-bottom:8px">
|
||||
<?php if (!empty($staff['avatar_path'])): ?>
|
||||
<img src="<?= e($staff['avatar_path']) ?>" alt="" style="width:32px; height:32px; border-radius:50%; object-fit:cover; flex-shrink:0; border:1px solid #2a3744">
|
||||
<?php else: ?>
|
||||
<div style="width:32px; height:32px; border-radius:50%; background:rgba(15,111,106,.3); display:flex; align-items:center; justify-content:center; font-size:.85rem; font-weight:700; color:#8bc4c2; flex-shrink:0">
|
||||
<?= mb_strtoupper(mb_substr($staff['name'], 0, 1)) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div style="min-width:0">
|
||||
<div style="font-size:.82rem; font-weight:600; color:#fff; overflow:hidden; text-overflow:ellipsis; white-space:nowrap"><?= e($staff['name']) ?></div>
|
||||
<div style="font-size:.72rem; color:#4e6278"><?= $staff['role']==='admin'?'Administrator':'Mitarbeiter' ?></div>
|
||||
<?php if (!empty($staff['is_on_vacation'])): ?>
|
||||
<div style="font-size:.7rem; color:var(--accent)">🌴 Im Urlaub</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lang-row" style="margin-bottom:10px"><?= i18n_switcher_html('dark') ?></div>
|
||||
<div style="display:flex; gap:14px; align-items:center">
|
||||
|
||||
<?php
|
||||
$__unreadMentions = 0;
|
||||
$__unreadChat = 0;
|
||||
if ($staff) {
|
||||
$__ms = get_db()->prepare('SELECT COUNT(*) FROM staff_mentions WHERE mentioned_staff_id=? AND is_read=0');
|
||||
$__ms->execute([$staff['id']]);
|
||||
$__unreadMentions = (int)$__ms->fetchColumn();
|
||||
$__cs = get_db()->prepare('SELECT COUNT(*) FROM staff_chat_messages WHERE to_staff_id=? AND is_read=0');
|
||||
$__cs->execute([$staff['id']]);
|
||||
$__unreadChat = (int)$__cs->fetchColumn();
|
||||
}
|
||||
?>
|
||||
<a href="/staff/chat.php" title="Interner Chat" style="position:relative; color:#8b97a3; font-size:.78rem; text-decoration:none">
|
||||
💬<?php if ($__unreadChat > 0): ?><span style="position:absolute;top:-6px;right:-8px;background:#dc2626;color:#fff;border-radius:50%;font-size:.6rem;font-weight:700;width:15px;height:15px;display:flex;align-items:center;justify-content:center;line-height:1"><?= $__unreadChat > 9 ? '9+' : $__unreadChat ?></span><?php endif; ?>
|
||||
</a>
|
||||
<a href="/staff/mentions.php" title="Erwähnungen" style="position:relative; color:#8b97a3; font-size:.78rem; text-decoration:none">
|
||||
🔔<?php if ($__unreadMentions > 0): ?><span style="position:absolute;top:-6px;right:-8px;background:#dc2626;color:#fff;border-radius:50%;font-size:.6rem;font-weight:700;width:15px;height:15px;display:flex;align-items:center;justify-content:center;line-height:1"><?= $__unreadMentions > 9 ? '9+' : $__unreadMentions ?></span><?php endif; ?>
|
||||
</a>
|
||||
<a href="/staff/profile.php" style="color:#8b97a3; font-size:.78rem">Profil</a>
|
||||
<a href="/staff/logout.php" style="color:#8b97a3; font-size:.78rem">Abmelden</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<?php
|
||||
// Timeout in JS übergeben
|
||||
$_jsTimeout = (int)(get_setting('staff_session_timeout', '30') ?: 30);
|
||||
$_jsLastAct = $_SESSION['staff_last_activity'] ?? time();
|
||||
if (!isset($_SESSION['staff_last_activity'])) {
|
||||
$_SESSION['staff_last_activity'] = time();
|
||||
$_jsLastAct = time();
|
||||
}
|
||||
$_jsRemaining = max(10, ($_jsLastAct + $_jsTimeout * 60) - time());
|
||||
?>
|
||||
<script>
|
||||
window.sessionTimerRemaining = <?= (int)$_jsRemaining ?>;
|
||||
window.sessionTimerTimeout = <?= (int)$_jsTimeout ?>;
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
var remaining = window.sessionTimerRemaining || 0;
|
||||
var timeoutMin = window.sessionTimerTimeout || 30;
|
||||
|
||||
function init() {
|
||||
var el = document.getElementById('session-timer');
|
||||
var bar = document.getElementById('session-timer-bar');
|
||||
if (!el || !bar || remaining <= 0) return;
|
||||
|
||||
function fmt(s) {
|
||||
var m = Math.floor(s / 60);
|
||||
var sec = s % 60;
|
||||
return m + ':' + (sec < 10 ? '0' : '') + sec;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (remaining <= 0) {
|
||||
window.location.href = '/staff/login.php';
|
||||
return;
|
||||
}
|
||||
|
||||
el.textContent = fmt(remaining);
|
||||
|
||||
// Immer sichtbar
|
||||
bar.style.opacity = '1';
|
||||
bar.style.pointerEvents = 'auto';
|
||||
|
||||
// Farbe nach Dringlichkeit
|
||||
if (remaining <= 60) {
|
||||
bar.style.background = 'rgba(239,68,68,.9)';
|
||||
bar.style.borderColor = 'rgba(239,68,68,.5)';
|
||||
el.style.color = '#fff';
|
||||
} else if (remaining <= 300) {
|
||||
bar.style.background = 'rgba(245,158,11,.9)';
|
||||
bar.style.borderColor = 'rgba(245,158,11,.5)';
|
||||
el.style.color = '#fff';
|
||||
} else {
|
||||
bar.style.background = 'rgba(15,23,42,.72)';
|
||||
bar.style.borderColor = 'rgba(255,255,255,.1)';
|
||||
el.style.color = '#cbd5e1';
|
||||
}
|
||||
|
||||
remaining--;
|
||||
setTimeout(tick, 1000);
|
||||
}
|
||||
|
||||
tick();
|
||||
|
||||
// Bei jeder Nutzeraktion (Klick, Tastatur) Timer via fetch zurücksetzen
|
||||
var resetDebounce;
|
||||
function resetTimer() {
|
||||
clearTimeout(resetDebounce);
|
||||
resetDebounce = setTimeout(function() {
|
||||
fetch('/staff/session_ping.php', { method:'POST',
|
||||
headers:{'X-CSRF-Token': document.querySelector('meta[name=csrf]')?.content || ''},
|
||||
credentials:'same-origin' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){ if (d.remaining) remaining = d.remaining; })
|
||||
.catch(function(){});
|
||||
}, 2000); // max. alle 2 Sek. ein Ping
|
||||
}
|
||||
|
||||
['click','keydown','mousemove','scroll','touchstart'].forEach(function(ev) {
|
||||
document.addEventListener(ev, resetTimer, { passive: true });
|
||||
});
|
||||
|
||||
// Restzeit regelmäßig beim Server erfragen (verlängert nichts): Aktivität in einem anderen Tab verlängert die Zeit hier mit,
|
||||
// und ist die Sitzung wirklich abgelaufen, geht es sofort zur Anmeldung
|
||||
function syncTimer() {
|
||||
fetch('/staff/session_ping.php?check=1', { credentials:'same-origin', cache:'no-store' })
|
||||
.then(function(r){ if (r.status === 401) { window.location.href = '/staff/login.php'; return null; } return r.json(); })
|
||||
.then(function(d){ if (d && typeof d.remaining === 'number') remaining = d.remaining; })
|
||||
.catch(function(){});
|
||||
}
|
||||
setInterval(syncTimer, 30000);
|
||||
} // end init
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// ── Browser Push Notifications ───────────────────────────────────────────
|
||||
(function() {
|
||||
var pushKey = document.querySelector('meta[name=push-key]')?.content || document.getElementById('push-vapid-key')?.value;
|
||||
if (!pushKey || !('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
var btn = document.getElementById('push-toggle-btn');
|
||||
|
||||
function urlB64ToUint8(b64) {
|
||||
var pad = '='.repeat((4 - b64.length % 4) % 4);
|
||||
var raw = atob(b64.replace(/-/g,'+').replace(/_/g,'/') + pad);
|
||||
return Uint8Array.from([...raw].map(function(ch){ return ch.charCodeAt(0); }));
|
||||
}
|
||||
|
||||
function setBtn(active) {
|
||||
if (!btn) return;
|
||||
btn.textContent = active ? '🔔' : '🔕';
|
||||
btn.style.color = active ? '#22c55e' : '#8b97a3';
|
||||
btn.title = active ? 'Push aktiv – klicken zum Deaktivieren' : 'Push-Benachrichtigungen aktivieren';
|
||||
}
|
||||
|
||||
navigator.serviceWorker.ready.then(function(reg) {
|
||||
reg.pushManager.getSubscription().then(function(s) {
|
||||
setBtn(!!(s && Notification.permission === 'granted'));
|
||||
});
|
||||
});
|
||||
|
||||
window.togglePushNotifications = function() {
|
||||
navigator.serviceWorker.ready.then(function(reg) {
|
||||
reg.pushManager.getSubscription().then(function(sub) {
|
||||
if (sub) {
|
||||
sub.unsubscribe().then(function() {
|
||||
fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action:'unsubscribe', endpoint: sub.endpoint}) });
|
||||
setBtn(false);
|
||||
});
|
||||
} else {
|
||||
Notification.requestPermission().then(function(perm) {
|
||||
if (perm !== 'granted') { alert('Bitte Benachrichtigungen im Browser erlauben.'); return; }
|
||||
reg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey: urlB64ToUint8(pushKey) })
|
||||
.then(function(ns) {
|
||||
var j = ns.toJSON();
|
||||
fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action:'subscribe', endpoint:j.endpoint, keys:j.keys}) })
|
||||
.then(function() { setBtn(true); });
|
||||
}).catch(function(err) { alert('Push fehlgeschlagen: ' + err.message); });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<script src="/assets/js/sidebar.js?v=<?= @filemtime(__DIR__ . "/../assets/js/sidebar.js") ?: time() ?>"></script>
|
||||
<main class="main">
|
||||
<!-- Session-Timer: kompakte Pille oben rechts -->
|
||||
<div id="session-timer-bar" style="position:fixed;top:12px;right:16px;z-index:9999;display:flex;align-items:center;gap:6px;background:rgba(15,23,42,.75);backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.1);border-radius:20px;padding:4px 12px 4px 8px;pointer-events:none;opacity:0;transition:opacity .3s">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<span id="session-timer" style="font-size:.72rem;font-weight:700;color:#cbd5e1;font-variant-numeric:tabular-nums;letter-spacing:.02em"></span>
|
||||
</div>
|
||||
<?= plugin_filter('staff.page.top', '', ['staff' => $staff ?? null, 'activePage' => $activePage ?? '']) /* Hinweise von Plugins oben auf jeder Seite (fertiges HTML aus vertrauenswürdigem Plugin-Code) */ ?>
|
||||
<?php foreach (get_flashes() as $f): ?>
|
||||
<div class="flash <?= e($f['type']) ?>"><?= e($f['message']) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php
|
||||
if (isset($staff) && has_permission($staff, 'manage_email_addresses')) {
|
||||
foreach (system_health(get_db()) as $__issue): ?>
|
||||
<div class="flash <?= $__issue['level'] === 'error' ? 'error' : 'warning' ?>" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<strong><?= $__issue['level'] === 'error' ? 'Störung:' : 'Hinweis:' ?></strong> <?= e($__issue['message']) ?>
|
||||
<a href="/staff/email_addresses.php" style="margin-left:auto">E-Mail-Adressen öffnen</a>
|
||||
</div>
|
||||
<?php endforeach; } ?>
|
||||
<?php
|
||||
if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'update') {
|
||||
require_once __DIR__ . '/../database/migrate.php';
|
||||
$__pending = migrations_pending(get_db());
|
||||
if ($__pending): ?>
|
||||
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<strong>Datenbank veraltet:</strong> <?= count($__pending) ?> ausstehende Änderung(en) – der Code ist neuer als die Datenbank.
|
||||
<a href="/staff/update.php" style="margin-left:auto">Jetzt aktualisieren</a>
|
||||
</div>
|
||||
<?php endif; } ?>
|
||||
<?php
|
||||
if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'plugins' && plugin_official_configured()) {
|
||||
$__upd = plugin_updates_available(get_db());
|
||||
if ($__upd): ?>
|
||||
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<strong>Plugin-Updates verfügbar:</strong> <?= e(implode(', ', array_map(fn($u) => $u['name'] . ' ' . $u['available'], $__upd))) ?>
|
||||
<a href="/staff/plugins.php" style="margin-left:auto">Zu den Plugins</a>
|
||||
</div>
|
||||
<?php endif; } ?>
|
||||
<?php $lockedOverride = get_locked_ticket_override(); ?>
|
||||
<?php if ($lockedOverride && isset($staff) && $staff['role'] === 'admin'): ?>
|
||||
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
🔓 Als Admin können Sie die Sperre von „<?= e($lockedOverride['name']) ?>“ auf Ticket #<?= e($lockedOverride['number']) ?> sofort aufheben, statt auf den automatischen Timeout zu warten.
|
||||
<form method="post" action="/staff/ticket_view.php?n=<?= urlencode($lockedOverride['number']) ?>" style="margin:0">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="admin_force_unlock">
|
||||
<input type="hidden" name="locked_by_name" value="<?= e($lockedOverride['name']) ?>">
|
||||
<button type="submit" class="btn btn-sm" style="padding:4px 10px;font-size:.78rem">Sperre jetzt aufheben</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($staff) && $staff['role'] === 'admin' && (config()['db']['driver'] ?? 'sqlite') === 'sqlite'): ?>
|
||||
<div class="flash warning" style="display:flex;align-items:center;gap:10px">
|
||||
⚠️ <strong>SQLite-Modus aktiv:</strong> Nicht für den Produktivbetrieb geeignet.
|
||||
Bitte in <code>config/config.php</code> auf <code>driver = 'mysql'</code> umstellen und MySQL-Zugangsdaten eintragen.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($staff) && $staff['role'] === 'admin' && empty(config()['app_key']) && !get_setting('app_key')): ?>
|
||||
<div class="flash warning" style="display:flex;align-items:center;gap:10px">
|
||||
🔑 <strong>Kein App-Key gesetzt:</strong> Passwörter werden nur schwach verschlüsselt.
|
||||
Bitte in <code>config/config.php</code> den Wert <code>app_key</code> setzen.
|
||||
Generieren: <code>php -r "echo bin2hex(random_bytes(32));"</code>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
/** @var array $staff (current_staff(), wird von der aufrufenden Seite gesetzt) */
|
||||
$staff = current_staff();
|
||||
$activePage = $activePage ?? '';
|
||||
header('Content-Type: text/html; charset=UTF-8');
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><?= e($pageTitle ?? 'DeskHub – Mitarbeiterbereich') ?></title>
|
||||
<meta name="csrf" content="<?= csrf_token() ?>">
|
||||
<?php
|
||||
// VAPID Public Key – aus Config oder automatisch aus DB
|
||||
if (file_exists(__DIR__ . '/../includes/push.php')) {
|
||||
require_once __DIR__ . '/../includes/push.php';
|
||||
$__vapidKeys = get_or_create_vapid_keys();
|
||||
$pushKey = $__vapidKeys['public_key'] ?? '';
|
||||
} else {
|
||||
$pushKey = config()['push']['public_key'] ?? '';
|
||||
}
|
||||
?>
|
||||
<?php if ($pushKey): ?>
|
||||
<meta name="push-key" content="<?= e($pushKey) ?>">
|
||||
<?php endif; ?>
|
||||
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
|
||||
<?= site_icon_links() ?>
|
||||
<link rel="manifest" href="/manifest-staff.php">
|
||||
<meta name="theme-color" content="#0f6f6a">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.register('/staff/sw-staff.js', {scope: '/staff/'}).catch(function(){});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script>try { if (localStorage.getItem('deskhub_sidebar') === 'closed') document.documentElement.classList.add('sidebar-closed'); } catch (e) {}</script>
|
||||
<button type="button" id="sidebar-open" class="sidebar-toggle sidebar-toggle-open" aria-label="Seitenleiste einblenden" title="Seitenleiste einblenden" onclick="document.getElementById('sidebar-toggle').click()">☰</button>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" id="sidebar">
|
||||
<button type="button" id="sidebar-toggle" class="sidebar-toggle sidebar-toggle-close" aria-label="Seitenleiste einklappen" title="Seitenleiste einklappen">«</button>
|
||||
<div class="brand">
|
||||
<?php $logo = get_setting('logo_path'); ?>
|
||||
<?php if ($logo): ?>
|
||||
<img src="<?= e($logo) ?>" alt="<?= e(get_setting('display_name', 'DeskHub')) ?>" style="max-height:34px; max-width:180px; display:block">
|
||||
<?php else: ?>
|
||||
Desk<span>Hub</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<nav>
|
||||
<?php // Übersicht + ehemals separates "Berichte" wurden zu einer Seite zusammengelegt
|
||||
// (siehe staff/reports.php) – der Login landete ohnehin schon immer dort. ?>
|
||||
<a href="/staff/reports.php" class="<?= $activePage==='reports'?'active':'' ?>">Übersicht</a>
|
||||
<a href="/staff/report_builder.php" class="<?= $activePage==='report_builder'?'active':'' ?>">Bericht-Baukasten</a>
|
||||
<a href="/staff/tickets.php" class="<?= $activePage==='tickets'?'active':'' ?>">Tickets</a>
|
||||
<a href="/staff/my_week.php" class="<?= $activePage==='my_week'?'active':'' ?>">Meine Woche</a>
|
||||
<a href="/staff/tasks.php" class="<?= $activePage==='tasks'?'active':'' ?>">Aufgaben</a>
|
||||
<?php if (has_permission($staff, 'manage_banlist')): ?><a href="/staff/banlist.php" class="<?= $activePage==='banlist'?'active':'' ?>">Sperrliste</a><?php endif; ?>
|
||||
<a href="/staff/apps.php" class="<?= $activePage==='apps'?'active':'' ?>">Apps</a>
|
||||
<?php foreach (plugin_menu_items($staff ?? null) as $__pm): ?>
|
||||
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
<?php if (get_setting('live_chat_enabled', '0') === '1' && $staff): ?>
|
||||
<?php
|
||||
// Badge = Anzahl Live-Chats, deren letzte Nachricht vom Kunden kam (also unbeantwortet),
|
||||
// auf die eigenen Abteilungen begrenzt (wie die Ticketliste selbst).
|
||||
[$__lcDeptSql, $__lcDeptParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
|
||||
$__lcSql = "SELECT COUNT(*) FROM tickets t WHERE t.source = 'chat' AND t.status != 'closed'
|
||||
AND (SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) = 'customer'"
|
||||
. $__lcDeptSql; // enthält bereits das führende " AND " (dept_filter_sql)
|
||||
$__lcStmt = get_db()->prepare($__lcSql);
|
||||
$__lcStmt->execute($__lcDeptParams);
|
||||
$__lcPending = (int) $__lcStmt->fetchColumn();
|
||||
?>
|
||||
<a href="/staff/live_chat.php" class="<?= $activePage==='live_chat'?'active':'' ?>">
|
||||
Live-Chat<?php if ($__lcPending): ?> <span class="nav-badge" style="background:var(--danger, #dc2626); color:#fff; border-radius:10px; min-width:18px; height:18px; font-size:.68rem; font-weight:700; display:inline-flex; align-items:center; justify-content:center; padding:0 5px; margin-left:4px; vertical-align:middle"><?= $__lcPending > 9 ? '9+' : $__lcPending ?></span><?php endif; ?>
|
||||
</a>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// Adminportal auch für "Co-Admins" zeigen: Agenten, denen per benutzerdefinierter Rolle
|
||||
// gezielt einzelne Adminportal-Rechte zugewiesen wurden (admin_section_permissions()).
|
||||
// has_permission() liefert für echte Admins ohnehin immer true, daher genügt die reine
|
||||
// Rechteprüfung je Link – kein zusätzliches role==='admin' nötig.
|
||||
$hasAnyAdminAccess = false;
|
||||
if ($staff) {
|
||||
foreach (array_keys(admin_section_permissions()) as $__perm) {
|
||||
if (has_permission($staff, $__perm)) { $hasAnyAdminAccess = true; break; }
|
||||
}
|
||||
}
|
||||
?>
|
||||
<?php if ($hasAnyAdminAccess): ?>
|
||||
<div class="nav-section-label" id="admin-toggle" role="button" tabindex="0">
|
||||
Adminportal <span class="nav-toggle-chevron">▾</span>
|
||||
</div>
|
||||
<div class="nav-admin-block" id="admin-block">
|
||||
<span class="nav-group-label">Verwalten</span>
|
||||
<?php if (has_permission($staff, 'manage_departments')): ?><a href="/staff/departments.php" class="<?= $activePage==='departments'?'active':'' ?>">Abteilungen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_priorities')): ?><a href="/staff/priorities.php" class="<?= $activePage==='priorities'?'active':'' ?>">Prioritäten / SLA</a><a href="/staff/sla_plans.php" class="<?= $activePage==='sla_plans'?'active':'' ?>">SLA-Pläne</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_help_topics')): ?><a href="/staff/help_topics.php" class="<?= $activePage==='help_topics'?'active':'' ?>">Hilfethemen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_custom_fields')): ?><a href="/staff/custom_fields.php" class="<?= $activePage==='custom_fields'?'active':'' ?>">Ticket-Felder</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/api_keys.php" class="<?= $activePage==='api_keys'?'active':'' ?>">API-Schlüssel</a><a href="/staff/webhooks.php" class="<?= $activePage==='webhooks'?'active':'' ?>">Webhooks</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_custom_fields')): ?><a href="/staff/custom_lists.php" class="<?= $activePage==='custom_lists'?'active':'' ?>">Benutzerdefinierte Listen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_canned')): ?><a href="/staff/canned_responses.php" class="<?= $activePage==='canned'?'active':'' ?>">Vorlagen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_site_pages')): ?><a href="/staff/site_pages.php" class="<?= $activePage==='site_pages'?'active':'' ?>">Seiten</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_languages')): ?><a href="/staff/languages.php" class="<?= $activePage==='languages'?'active':'' ?>">Sprachen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_ticket_templates')): ?><a href="/staff/ticket_templates.php" class="<?= $activePage==='ticket_templates'?'active':'' ?>">Ticket-Vorlagen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_status_page')): ?><a href="/staff/status_page.php" class="<?= $activePage==='status_page'?'active':'' ?>">Statusseite</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_problems')): ?><a href="/staff/problems.php" class="<?= $activePage==='problems'?'active':'' ?>">Problemmanagement</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'view_mail_queue')): ?><a href="/staff/mail_queue.php" class="<?= $activePage==='mail_queue'?'active':'' ?>">Mail-Warteschlange</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'view_login_guard')): ?><a href="/staff/login_log.php" class="<?= $activePage==='login_log'?'active':'' ?>">Anmelde-Wächter</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_email_addresses')): ?><a href="/staff/email_addresses.php" class="<?= $activePage==='email_addresses'?'active':'' ?>">E-Mail-Adressen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_email_templates')): ?><a href="/staff/email_templates.php" class="<?= $activePage==='email_templates'?'active':'' ?>">E-Mail-Vorlagen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_kb')): ?><a href="/staff/kb_manage.php" class="<?= $activePage==='kb'?'active':'' ?>">Wissensdatenbank</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_assistant')): ?><a href="/staff/assistant.php" class="<?= $activePage==='assistant'?'active':'' ?>">Digitaler Assistent</a><?php endif; ?>
|
||||
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/plugins.php" class="<?= $activePage==='plugins'?'active':'' ?>">Plugins</a><?php endif; ?>
|
||||
<?php foreach (plugin_admin_menu_items($staff) as $__pm): ?>
|
||||
<a href="<?= e($__pm['url']) ?>" class="<?= ($activePage ?? '') === 'plugin_' . $__pm['plugin'] ? 'active' : '' ?>"><?= e($__pm['label']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
<span class="nav-group-label">Kunden</span>
|
||||
<?php if (has_permission($staff, 'manage_customers')): ?><a href="/staff/customers.php" class="<?= $activePage==='customers'?'active':'' ?>">Kundenverzeichnis</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_organizations')): ?><a href="/staff/organizations.php" class="<?= $activePage==='organizations'?'active':'' ?>">Organisationen</a><?php endif; ?>
|
||||
|
||||
<span class="nav-group-label">Agenten</span>
|
||||
<?php if (has_permission($staff, 'manage_staff')): ?><a href="/staff/staff_manage.php" class="<?= $activePage==='staff'?'active':'' ?>">Mitarbeiter</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_teams')): ?><a href="/staff/teams.php" class="<?= $activePage==='teams'?'active':'' ?>">Teams</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_roles')): ?><a href="/staff/roles.php" class="<?= $activePage==='roles'?'active':'' ?>">Rollen</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_availability')): ?><a href="/staff/availability.php" class="<?= $activePage==='availability'?'active':'' ?>">Status & Schichten</a><?php endif; ?>
|
||||
|
||||
<span class="nav-group-label">System</span>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/security_check.php" class="<?= $activePage==='security_check'?'active':'' ?>">Sicherheitsprüfung</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'view_audit_log')): ?><a href="/staff/audit_log.php" class="<?= $activePage==='audit_log'?'active':'' ?>">Admin-Protokoll</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_automation')): ?><a href="/staff/ticket_rules.php" class="<?= $activePage==='ticket_rules'?'active':'' ?>">Ticket-Regeln</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_escalations')): ?><a href="/staff/escalation_rules.php" class="<?= $activePage==='escalation_rules'?'active':'' ?>">Eskalationsregeln</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_automation')): ?><a href="/staff/recurring_tickets.php" class="<?= $activePage==='recurring'?'active':'' ?>">Wiederkehrende Tickets</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'view_time_entries')): ?><a href="/staff/time_entries.php" class="<?= $activePage==='time_entries'?'active':'' ?>">Zeiterfassung</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'manage_business_hours')): ?><a href="/staff/business_hours.php" class="<?= $activePage==='business_hours'?'active':'' ?>">Geschäftszeiten</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/settings.php" class="<?= $activePage==='settings'?'active':'' ?>">Einstellungen</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/backup.php" class="<?= $activePage==='backup'?'active':'' ?>">Backup</a><?php endif; ?>
|
||||
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/update.php" class="<?= $activePage==='update'?'active':'' ?>">System-Update</a><?php endif; ?>
|
||||
<?php if (has_permission($staff, 'view_help')): ?><a href="/staff/help.php" class="<?= $activePage==='help'?'active':'' ?>">Hilfe</a><?php endif; ?>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var toggle = document.getElementById('admin-toggle');
|
||||
var block = document.getElementById('admin-block');
|
||||
var KEY = 'deskhub_admin_nav_collapsed';
|
||||
// In diesen Adminportal-Unterseiten immer ausgeklappt zeigen, damit die aktive Seite sichtbar bleibt
|
||||
var forceOpen = block.querySelector('a.active') !== null;
|
||||
function apply(collapsed) {
|
||||
block.style.display = collapsed ? 'none' : '';
|
||||
toggle.querySelector('.nav-toggle-chevron').textContent = collapsed ? '▸' : '▾';
|
||||
}
|
||||
var stored = localStorage.getItem(KEY);
|
||||
apply(!forceOpen && stored === '1');
|
||||
toggle.addEventListener('click', function () {
|
||||
var collapsed = block.style.display !== 'none';
|
||||
apply(collapsed);
|
||||
localStorage.setItem(KEY, collapsed ? '1' : '0');
|
||||
});
|
||||
toggle.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle.click(); }
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php endif; ?>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<?php if ($staff): ?>
|
||||
<?php
|
||||
$__unreadMentions = 0;
|
||||
$__unreadChat = 0;
|
||||
$__ms = get_db()->prepare('SELECT COUNT(*) FROM staff_mentions WHERE mentioned_staff_id=? AND is_read=0');
|
||||
$__ms->execute([$staff['id']]);
|
||||
$__unreadMentions = (int)$__ms->fetchColumn();
|
||||
$__cs = get_db()->prepare('SELECT COUNT(*) FROM staff_chat_messages WHERE to_staff_id=? AND is_read=0');
|
||||
$__cs->execute([$staff['id']]);
|
||||
$__unreadChat = (int)$__cs->fetchColumn();
|
||||
$__onVacation = !empty($staff['is_on_vacation']);
|
||||
?>
|
||||
<div class="sf-user">
|
||||
<?php if (!empty($staff['avatar_path'])): ?>
|
||||
<img class="sf-avatar" src="<?= e($staff['avatar_path']) ?>" alt="">
|
||||
<?php else: ?>
|
||||
<div class="sf-avatar sf-avatar-letter"><?= mb_strtoupper(mb_substr($staff['name'], 0, 1)) ?></div>
|
||||
<?php endif; ?>
|
||||
<div class="sf-who">
|
||||
<div class="sf-name"><?= e($staff['name']) ?></div>
|
||||
<div class="sf-role"><?= $staff['role']==='admin'?'Administrator':'Mitarbeiter' ?></div>
|
||||
<?php if ($__onVacation): ?><span class="sf-vac">🌴 Im Urlaub</span><?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sf-actions">
|
||||
<a class="sf-btn" href="/staff/chat.php" title="Interner Chat" aria-label="Interner Chat">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8 8 0 0 1-11.6 7.1L3 21l1.9-5.4A8 8 0 1 1 21 12z"/></svg>
|
||||
<?php if ($__unreadChat > 0): ?><span class="sf-badge"><?= $__unreadChat > 9 ? '9+' : $__unreadChat ?></span><?php endif; ?>
|
||||
</a>
|
||||
<a class="sf-btn" href="/staff/mentions.php" title="Erwähnungen" aria-label="Erwähnungen">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0"/></svg>
|
||||
<?php if ($__unreadMentions > 0): ?><span class="sf-badge"><?= $__unreadMentions > 9 ? '9+' : $__unreadMentions ?></span><?php endif; ?>
|
||||
</a>
|
||||
<a class="sf-btn" href="/staff/profile.php" title="Profil" aria-label="Profil">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 21a8 8 0 0 0-16 0M12 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z"/></svg>
|
||||
</a>
|
||||
<a class="sf-btn sf-btn-out" href="/staff/logout.php" title="Abmelden" aria-label="Abmelden">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="lang-row"><?= i18n_switcher_html('dark') ?></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<?php
|
||||
// Timeout in JS übergeben
|
||||
$_jsTimeout = (int)(get_setting('staff_session_timeout', '30') ?: 30);
|
||||
$_jsLastAct = $_SESSION['staff_last_activity'] ?? time();
|
||||
if (!isset($_SESSION['staff_last_activity'])) {
|
||||
$_SESSION['staff_last_activity'] = time();
|
||||
$_jsLastAct = time();
|
||||
}
|
||||
$_jsRemaining = max(10, ($_jsLastAct + $_jsTimeout * 60) - time());
|
||||
?>
|
||||
<script>
|
||||
window.sessionTimerRemaining = <?= (int)$_jsRemaining ?>;
|
||||
window.sessionTimerTimeout = <?= (int)$_jsTimeout ?>;
|
||||
</script>
|
||||
<script>
|
||||
(function() {
|
||||
var remaining = window.sessionTimerRemaining || 0;
|
||||
var timeoutMin = window.sessionTimerTimeout || 30;
|
||||
|
||||
function init() {
|
||||
var el = document.getElementById('session-timer');
|
||||
var bar = document.getElementById('session-timer-bar');
|
||||
if (!el || !bar || remaining <= 0) return;
|
||||
|
||||
function fmt(s) {
|
||||
var m = Math.floor(s / 60);
|
||||
var sec = s % 60;
|
||||
return m + ':' + (sec < 10 ? '0' : '') + sec;
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (remaining <= 0) {
|
||||
window.location.href = '/staff/login.php';
|
||||
return;
|
||||
}
|
||||
|
||||
el.textContent = fmt(remaining);
|
||||
|
||||
// Immer sichtbar
|
||||
bar.style.opacity = '1';
|
||||
bar.style.pointerEvents = 'auto';
|
||||
|
||||
// Farbe nach Dringlichkeit
|
||||
if (remaining <= 60) {
|
||||
bar.style.background = 'rgba(239,68,68,.9)';
|
||||
bar.style.borderColor = 'rgba(239,68,68,.5)';
|
||||
el.style.color = '#fff';
|
||||
} else if (remaining <= 300) {
|
||||
bar.style.background = 'rgba(245,158,11,.9)';
|
||||
bar.style.borderColor = 'rgba(245,158,11,.5)';
|
||||
el.style.color = '#fff';
|
||||
} else {
|
||||
bar.style.background = 'rgba(15,23,42,.72)';
|
||||
bar.style.borderColor = 'rgba(255,255,255,.1)';
|
||||
el.style.color = '#cbd5e1';
|
||||
}
|
||||
|
||||
remaining--;
|
||||
setTimeout(tick, 1000);
|
||||
}
|
||||
|
||||
tick();
|
||||
|
||||
// Bei jeder Nutzeraktion (Klick, Tastatur) Timer via fetch zurücksetzen
|
||||
var resetDebounce;
|
||||
function resetTimer() {
|
||||
clearTimeout(resetDebounce);
|
||||
resetDebounce = setTimeout(function() {
|
||||
fetch('/staff/session_ping.php', { method:'POST',
|
||||
headers:{'X-CSRF-Token': document.querySelector('meta[name=csrf]')?.content || ''},
|
||||
credentials:'same-origin' })
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(d){ if (d.remaining) remaining = d.remaining; })
|
||||
.catch(function(){});
|
||||
}, 2000); // max. alle 2 Sek. ein Ping
|
||||
}
|
||||
|
||||
['click','keydown','mousemove','scroll','touchstart'].forEach(function(ev) {
|
||||
document.addEventListener(ev, resetTimer, { passive: true });
|
||||
});
|
||||
|
||||
// Restzeit regelmäßig beim Server erfragen (verlängert nichts): Aktivität in einem anderen Tab verlängert die Zeit hier mit,
|
||||
// und ist die Sitzung wirklich abgelaufen, geht es sofort zur Anmeldung
|
||||
function syncTimer() {
|
||||
fetch('/staff/session_ping.php?check=1', { credentials:'same-origin', cache:'no-store' })
|
||||
.then(function(r){ if (r.status === 401) { window.location.href = '/staff/login.php'; return null; } return r.json(); })
|
||||
.then(function(d){ if (d && typeof d.remaining === 'number') remaining = d.remaining; })
|
||||
.catch(function(){});
|
||||
}
|
||||
setInterval(syncTimer, 30000);
|
||||
} // end init
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// ── Browser Push Notifications ───────────────────────────────────────────
|
||||
(function() {
|
||||
var pushKey = document.querySelector('meta[name=push-key]')?.content || document.getElementById('push-vapid-key')?.value;
|
||||
if (!pushKey || !('serviceWorker' in navigator) || !('PushManager' in window)) return;
|
||||
var btn = document.getElementById('push-toggle-btn');
|
||||
|
||||
function urlB64ToUint8(b64) {
|
||||
var pad = '='.repeat((4 - b64.length % 4) % 4);
|
||||
var raw = atob(b64.replace(/-/g,'+').replace(/_/g,'/') + pad);
|
||||
return Uint8Array.from([...raw].map(function(ch){ return ch.charCodeAt(0); }));
|
||||
}
|
||||
|
||||
function setBtn(active) {
|
||||
if (!btn) return;
|
||||
btn.textContent = active ? '🔔' : '🔕';
|
||||
btn.style.color = active ? '#22c55e' : '#8b97a3';
|
||||
btn.title = active ? 'Push aktiv – klicken zum Deaktivieren' : 'Push-Benachrichtigungen aktivieren';
|
||||
}
|
||||
|
||||
navigator.serviceWorker.ready.then(function(reg) {
|
||||
reg.pushManager.getSubscription().then(function(s) {
|
||||
setBtn(!!(s && Notification.permission === 'granted'));
|
||||
});
|
||||
});
|
||||
|
||||
window.togglePushNotifications = function() {
|
||||
navigator.serviceWorker.ready.then(function(reg) {
|
||||
reg.pushManager.getSubscription().then(function(sub) {
|
||||
if (sub) {
|
||||
sub.unsubscribe().then(function() {
|
||||
fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action:'unsubscribe', endpoint: sub.endpoint}) });
|
||||
setBtn(false);
|
||||
});
|
||||
} else {
|
||||
Notification.requestPermission().then(function(perm) {
|
||||
if (perm !== 'granted') { alert('Bitte Benachrichtigungen im Browser erlauben.'); return; }
|
||||
reg.pushManager.subscribe({ userVisibleOnly:true, applicationServerKey: urlB64ToUint8(pushKey) })
|
||||
.then(function(ns) {
|
||||
var j = ns.toJSON();
|
||||
fetch('/staff/push_subscribe.php', { method:'POST', credentials:'same-origin',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({action:'subscribe', endpoint:j.endpoint, keys:j.keys}) })
|
||||
.then(function() { setBtn(true); });
|
||||
}).catch(function(err) { alert('Push fehlgeschlagen: ' + err.message); });
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<script src="/assets/js/sidebar.js?v=<?= @filemtime(__DIR__ . "/../assets/js/sidebar.js") ?: time() ?>"></script>
|
||||
<main class="main">
|
||||
<!-- Session-Timer: kompakte Pille oben rechts -->
|
||||
<div id="session-timer-bar" style="position:fixed;top:12px;right:16px;z-index:9999;display:flex;align-items:center;gap:6px;background:rgba(15,23,42,.75);backdrop-filter:blur(6px);border:1px solid rgba(255,255,255,.1);border-radius:20px;padding:4px 12px 4px 8px;pointer-events:none;opacity:0;transition:opacity .3s">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#94a3b8" stroke-width="2.5"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
<span id="session-timer" style="font-size:.72rem;font-weight:700;color:#cbd5e1;font-variant-numeric:tabular-nums;letter-spacing:.02em"></span>
|
||||
</div>
|
||||
<?= plugin_filter('staff.page.top', '', ['staff' => $staff ?? null, 'activePage' => $activePage ?? '']) /* Hinweise von Plugins oben auf jeder Seite (fertiges HTML aus vertrauenswürdigem Plugin-Code) */ ?>
|
||||
<?php foreach (get_flashes() as $f): ?>
|
||||
<div class="flash <?= e($f['type']) ?>"><?= e($f['message']) ?></div>
|
||||
<?php endforeach; ?>
|
||||
<?php
|
||||
if (isset($staff) && has_permission($staff, 'manage_email_addresses')) {
|
||||
foreach (system_health(get_db()) as $__issue): ?>
|
||||
<div class="flash <?= $__issue['level'] === 'error' ? 'error' : 'warning' ?>" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<strong><?= $__issue['level'] === 'error' ? 'Störung:' : 'Hinweis:' ?></strong> <?= e($__issue['message']) ?>
|
||||
<a href="/staff/email_addresses.php" style="margin-left:auto">E-Mail-Adressen öffnen</a>
|
||||
</div>
|
||||
<?php endforeach; } ?>
|
||||
<?php
|
||||
if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'update') {
|
||||
require_once __DIR__ . '/../database/migrate.php';
|
||||
$__pending = migrations_pending(get_db());
|
||||
if ($__pending): ?>
|
||||
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<strong>Datenbank veraltet:</strong> <?= count($__pending) ?> ausstehende Änderung(en) – der Code ist neuer als die Datenbank.
|
||||
<a href="/staff/update.php" style="margin-left:auto">Jetzt aktualisieren</a>
|
||||
</div>
|
||||
<?php endif; } ?>
|
||||
<?php
|
||||
if (isset($staff) && ($staff['role'] ?? '') === 'admin' && ($activePage ?? '') !== 'plugins' && plugin_official_configured()) {
|
||||
$__upd = plugin_updates_available(get_db());
|
||||
if ($__upd): ?>
|
||||
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
<strong>Plugin-Updates verfügbar:</strong> <?= e(implode(', ', array_map(fn($u) => $u['name'] . ' ' . $u['available'], $__upd))) ?>
|
||||
<a href="/staff/plugins.php" style="margin-left:auto">Zu den Plugins</a>
|
||||
</div>
|
||||
<?php endif; } ?>
|
||||
<?php $lockedOverride = get_locked_ticket_override(); ?>
|
||||
<?php if ($lockedOverride && isset($staff) && $staff['role'] === 'admin'): ?>
|
||||
<div class="flash warning" style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
|
||||
🔓 Als Admin können Sie die Sperre von „<?= e($lockedOverride['name']) ?>“ auf Ticket #<?= e($lockedOverride['number']) ?> sofort aufheben, statt auf den automatischen Timeout zu warten.
|
||||
<form method="post" action="/staff/ticket_view.php?n=<?= urlencode($lockedOverride['number']) ?>" style="margin:0">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="admin_force_unlock">
|
||||
<input type="hidden" name="locked_by_name" value="<?= e($lockedOverride['name']) ?>">
|
||||
<button type="submit" class="btn btn-sm" style="padding:4px 10px;font-size:.78rem">Sperre jetzt aufheben</button>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($staff) && $staff['role'] === 'admin' && (config()['db']['driver'] ?? 'sqlite') === 'sqlite'): ?>
|
||||
<div class="flash warning" style="display:flex;align-items:center;gap:10px">
|
||||
⚠️ <strong>SQLite-Modus aktiv:</strong> Nicht für den Produktivbetrieb geeignet.
|
||||
Bitte in <code>config/config.php</code> auf <code>driver = 'mysql'</code> umstellen und MySQL-Zugangsdaten eintragen.
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (isset($staff) && $staff['role'] === 'admin' && empty(config()['app_key']) && !get_setting('app_key')): ?>
|
||||
<div class="flash warning" style="display:flex;align-items:center;gap:10px">
|
||||
🔑 <strong>Kein App-Key gesetzt:</strong> Passwörter werden nur schwach verschlüsselt.
|
||||
Bitte in <code>config/config.php</code> den Wert <code>app_key</code> setzen.
|
||||
Generieren: <code>php -r "echo bin2hex(random_bytes(32));"</code>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
@@ -34,12 +34,13 @@
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th style="width:24px"><input type="checkbox" onclick="document.querySelectorAll('.ticket-check').forEach(c=>c.checked=this.checked)"></th>
|
||||
<th>Ticket</th><th>Betreff</th><th>Abteilung</th><th>Priorität</th><th>Status</th><th>Zugewiesen</th><th>Eingang</th><th>Fällig</th>
|
||||
<th>Ticket</th><th>Betreff</th><th>Abteilung</th><th>Priorität</th><th>Status</th><th>Zugewiesen</th><th>Letzte Aktivität</th><th>Fällig</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($tickets as $t):
|
||||
[$label, $color] = status_label($t['status']);
|
||||
$isOverdue = $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time() && !in_array($t['status'], ['resolved','closed']);
|
||||
$isPaused = !empty($t['sla_paused_at']) && ticket_sla_is_paused_status((string) $t['status']);
|
||||
$isOverdue = !in_array($t['status'], ['resolved','closed']) && (!empty($t['is_overdue']) || (!$isPaused && $t['resolution_due_at'] && strtotime($t['resolution_due_at']) < time()));
|
||||
?>
|
||||
<tr class="<?= $isOverdue ? 'is-overdue' : '' ?>">
|
||||
<td><input type="checkbox" class="ticket-check" name="ticket_ids[]" value="<?= $t['id'] ?>"></td>
|
||||
@@ -59,8 +60,8 @@
|
||||
<td><span class="badge" style="background:<?= e($t['priority_color']) ?>"><?= e($t['priority_name']) ?></span></td>
|
||||
<td><span class="badge" style="background:<?= $color ?>"><?= e($label) ?></span></td>
|
||||
<td><?= $t['staff_name'] ? e($t['staff_name']) : '<span class="muted">–</span>' ?></td>
|
||||
<td class="muted" style="white-space:nowrap" title="Ticket eingegangen"><?= e(format_dt($t['created_at'] ?? null)) ?></td>
|
||||
<td class="muted"><?= time_until($t['resolution_due_at']) ?></td>
|
||||
<td class="muted" style="white-space:nowrap" title="Eingegangen: <?= e(format_dt($t['created_at'] ?? null)) ?>"><?= e(format_dt($t['updated_at'] ?? $t['created_at'] ?? null)) ?></td>
|
||||
<td class="muted"><?= in_array($t['status'], ['resolved','closed'], true) ? '–' : ($isPaused ? 'Pausiert' : time_until($t['resolution_due_at'])) ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
|
||||
@@ -15,6 +15,7 @@ require __DIR__ . '/_header.php';
|
||||
?>
|
||||
<h1 style="margin-top:0">Apps für Mitarbeiter</h1>
|
||||
<p class="muted">Mit den Apps bearbeitet ihr Tickets am PC oder unterwegs. Beide haben ein helles und dunkles Design.</p>
|
||||
<p class="muted">Außerdem: das Plugin für WordPress (Tickets von eurer Webseite aus anlegen) und die Erweiterung für den Chrome-Browser.</p>
|
||||
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<strong>Serveradresse für die Anmeldung</strong>
|
||||
@@ -26,7 +27,7 @@ require __DIR__ . '/_header.php';
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:20px">
|
||||
<?php foreach ($visiblePlatforms as $key => $p): $a = $apps[$key]; $noun = $key === "minecraft" ? "Plugin" : "App"; ?>
|
||||
<?php foreach ($visiblePlatforms as $key => $p): $a = $apps[$key]; $noun = ['minecraft' => 'Plugin', 'wordpress' => 'Plugin', 'chrome' => 'Erweiterung'][$key] ?? 'App'; ?>
|
||||
<div class="card" style="margin:0">
|
||||
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:8px"><?= e($p['label']) ?></div>
|
||||
<?php if ($a): ?>
|
||||
@@ -52,6 +53,20 @@ require __DIR__ . '/_header.php';
|
||||
<li>Falls Windows warnt („Der Computer wurde geschützt“): <em>Weitere Informationen → Trotzdem ausführen</em>.</li>
|
||||
<li>App öffnen, Serveradresse eingeben und anmelden.</li>
|
||||
</ol>
|
||||
<?php elseif ($key === 'wordpress'): ?>
|
||||
<ol style="padding-left:18px;margin:6px 0 0;line-height:1.7">
|
||||
<li>ZIP herunterladen. In WordPress unter <em>Plugins → Installieren → Plugin hochladen</em> hochladen und aktivieren.</li>
|
||||
<li>In DeskHub unter Verwalten → API-Schlüssel einen Schlüssel mit dem Recht „Tickets anlegen“ erzeugen.</li>
|
||||
<li>In WordPress unter <em>Einstellungen → DeskHub Tickets</em> Serveradresse (oben) und Schlüssel eintragen und „Speichern und Verbindung testen“ klicken.</li>
|
||||
<li>Auf einer Seite den Shortcode <code>[deskhub_ticket]</code> einfügen. Die Statusabfrage für Kunden heißt <code>[deskhub_status]</code>.</li>
|
||||
</ol>
|
||||
<?php elseif ($key === 'chrome'): ?>
|
||||
<ol style="padding-left:18px;margin:6px 0 0;line-height:1.7">
|
||||
<li>ZIP herunterladen und in einen festen Ordner entpacken (der Ordner muss bleiben).</li>
|
||||
<li>In Chrome <code>chrome://extensions</code> öffnen, oben rechts den <em>Entwicklermodus</em> einschalten und <em>Entpackte Erweiterung laden</em> wählen. Den Ordner auswählen.</li>
|
||||
<li>Erweiterung öffnen, Serveradresse eingeben und anmelden.</li>
|
||||
<li>Updates: neue ZIP in denselben Ordner entpacken und die Erweiterung auf <code>chrome://extensions</code> neu laden.</li>
|
||||
</ol>
|
||||
<?php elseif ($key === 'minecraft'): ?>
|
||||
<ol style="padding-left:18px;margin:6px 0 0;line-height:1.7">
|
||||
<li>Jar herunterladen und in den Ordner <code>plugins</code> deines Spigot-, Paper- oder BungeeCord-Servers legen.</li>
|
||||
|
||||
@@ -38,7 +38,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['status']) && !isset($
|
||||
}
|
||||
|
||||
// ── Schichtplan (Admin-Seite) ─────────────────────────────────────────────────
|
||||
$admin = $staff['role'] === 'admin' ? $staff : null;
|
||||
$admin = has_permission($staff, 'manage_availability') ? $staff : null;
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
@@ -125,17 +125,36 @@ $activePage = 'availability';
|
||||
require __DIR__ . '/_header.php';
|
||||
|
||||
$statusLabels = [
|
||||
'available' => ['Verfügbar', '#22c55e', '🟢'],
|
||||
'busy' => ['Beschäftigt', '#f59e0b', '🟡'],
|
||||
'break' => ['Pause', '#6366f1', '🟣'],
|
||||
'offline' => ['Offline', '#94a3b8', '⚫'],
|
||||
'available' => ['Verfügbar', '#22c55e'],
|
||||
'busy' => ['Beschäftigt', '#f59e0b'],
|
||||
'break' => ['Pause', '#6366f1'],
|
||||
'offline' => ['Offline', '#94a3b8'],
|
||||
];
|
||||
$weekdays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
?>
|
||||
<style>
|
||||
.av-dot { display:inline-block; width:10px; height:10px; border-radius:50%; background:var(--c, #94a3b8); flex:none }
|
||||
.av-status { display:flex; gap:10px; flex-wrap:wrap; align-items:center }
|
||||
.status-btn { display:inline-flex; align-items:center; gap:8px; font:inherit; font-size:.88rem; font-weight:600; padding:8px 16px; border:1px solid var(--border); border-radius:8px; background:var(--surface); color:var(--ink-soft); cursor:pointer; transition:border-color .15s, background .15s, color .15s }
|
||||
.status-btn:hover { border-color:var(--c) }
|
||||
.status-btn.active { border-color:var(--c); background:color-mix(in srgb, var(--c) 12%, var(--surface)); color:var(--ink) }
|
||||
.av-note { flex:1; min-width:200px; margin:0 }
|
||||
.av-team { display:grid; grid-template-columns:repeat(auto-fill, minmax(250px, 1fr)); gap:12px }
|
||||
.av-agent { display:flex; align-items:center; gap:12px; padding:12px 14px; border:1px solid var(--border); border-radius:10px; background:var(--surface) }
|
||||
.av-avatar { position:relative; width:38px; height:38px; border-radius:50%; background:var(--primary); color:#fff; display:flex; align-items:center; justify-content:center; font-weight:700; flex:none }
|
||||
.av-avatar .status-dot { position:absolute; right:-2px; bottom:-2px; width:12px; height:12px; border-radius:50%; border:2px solid var(--surface); background:#94a3b8 }
|
||||
.av-agent .av-name { font-weight:600; overflow-wrap:anywhere }
|
||||
.av-agent .status-label { font-size:.8rem; font-weight:600 }
|
||||
.av-agent .status-note, .av-agent .av-seen { font-size:.76rem; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
|
||||
.av-edit { margin-left:auto; flex:none; padding:4px 10px; font-size:.78rem }
|
||||
.av-admin-row form { display:flex; gap:8px; flex-wrap:wrap; align-items:center; margin:0 }
|
||||
.av-admin-row select, .av-admin-row input[type=text] { margin:0; width:auto }
|
||||
.av-auto { display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:16px; padding-top:16px; border-top:1px solid var(--border-soft) }
|
||||
.av-auto label, .av-auto input { margin:0 }
|
||||
.av-auto input[type=number] { width:90px }
|
||||
</style>
|
||||
|
||||
<div class="topbar">
|
||||
<h1>Verfügbarkeit & Schichten</h1>
|
||||
</div>
|
||||
<h1 style="margin-top:0">Verfügbarkeit & Schichten</h1>
|
||||
|
||||
<!-- Hidden CSRF form für AJAX -->
|
||||
<form id="csrf-form" style="display:none"><?= csrf_field() ?></form>
|
||||
@@ -147,53 +166,38 @@ foreach ($agents as $a) { if ($a['id'] === $staff['id']) { $myAvail = $a; break;
|
||||
$myStatus = $myAvail['avail_status'] ?? 'available';
|
||||
?>
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:12px">Mein Status</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
|
||||
<?php foreach ($statusLabels as $sk => [$sl, $sc, $se]): ?>
|
||||
<button type="button" onclick="setMyStatus('<?= $sk ?>')"
|
||||
class="status-btn <?= $myStatus === $sk ? 'active' : '' ?>"
|
||||
data-status="<?= $sk ?>"
|
||||
style="display:flex;align-items:center;gap:7px;padding:8px 16px;border:2px solid <?= $myStatus === $sk ? $sc : 'var(--border)' ?>;border-radius:8px;background:<?= $myStatus === $sk ? $sc.'18' : 'transparent' ?>;cursor:pointer;font-size:.85rem;font-weight:<?= $myStatus === $sk ? '600' : '400' ?>;color:<?= $myStatus === $sk ? $sc : 'var(--ink-soft)' ?>;transition:all .15s">
|
||||
<span><?= $se ?></span> <?= $sl ?>
|
||||
<h3>Mein Status</h3>
|
||||
<div class="av-status">
|
||||
<?php foreach ($statusLabels as $sk => [$sl, $sc]): ?>
|
||||
<button type="button" onclick="setMyStatus('<?= $sk ?>')" class="status-btn <?= $myStatus === $sk ? 'active' : '' ?>" data-status="<?= $sk ?>" style="--c:<?= $sc ?>">
|
||||
<span class="av-dot" style="--c:<?= $sc ?>"></span><?= $sl ?>
|
||||
</button>
|
||||
<?php endforeach; ?>
|
||||
<div style="flex:1;min-width:160px">
|
||||
<input type="text" id="avail-note" value="<?= e($myAvail['avail_note'] ?? '') ?>"
|
||||
placeholder="Notiz (optional, z.B. «In Besprechung bis 14:00»)"
|
||||
style="width:100%;font-size:.83rem"
|
||||
oninput="scheduleStatusUpdate()">
|
||||
</div>
|
||||
<input type="text" id="avail-note" class="av-note" value="<?= e($myAvail['avail_note'] ?? '') ?>" maxlength="120"
|
||||
placeholder="Notiz (optional, z. B. „In Besprechung bis 14:00“)" oninput="scheduleStatusUpdate()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Team-Übersicht ─────────────────────────────────────────────────────────── -->
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:14px">Team-Übersicht</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px">
|
||||
<h3>Team-Übersicht</h3>
|
||||
<div class="av-team">
|
||||
<?php foreach ($agents as $ag):
|
||||
$as = $ag['avail_status'] ?? 'offline';
|
||||
[$asl, $asc, $ase] = $statusLabels[$as] ?? ['Unbekannt','#94a3b8','⚫'];
|
||||
$hasShift = isset($shiftsByStaff[$ag['id']]);
|
||||
[$asl, $asc] = $statusLabels[$as] ?? ['Unbekannt', '#94a3b8'];
|
||||
if (!empty($ag['is_on_vacation'])) { $asl = 'Urlaub'; $asc = '#94a3b8'; }
|
||||
$__la = staff_last_active_label($ag['last_active_at'] ?? null);
|
||||
?>
|
||||
<div id="agent-status-<?= $ag['id'] ?>" style="display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">
|
||||
<div style="width:36px;height:36px;border-radius:50%;background:var(--primary);color:#fff;display:flex;align-items:center;justify-content:center;font-size:.9rem;font-weight:700;flex-shrink:0;position:relative">
|
||||
<?= mb_strtoupper(mb_substr($ag['name'], 0, 1)) ?>
|
||||
<span class="status-dot" style="position:absolute;bottom:-2px;right:-2px;width:12px;height:12px;border-radius:50%;background:<?= $asc ?>;border:2px solid var(--surface)"></span>
|
||||
</div>
|
||||
<div class="av-agent" id="agent-status-<?= $ag['id'] ?>">
|
||||
<div class="av-avatar"><?= e(mb_strtoupper(mb_substr($ag['name'], 0, 1))) ?><span class="status-dot" style="background:<?= $asc ?>"></span></div>
|
||||
<div style="min-width:0;flex:1">
|
||||
<div style="font-size:.85rem;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
<?= e($ag['name']) ?>
|
||||
<?php if ($ag['is_on_vacation']): ?><span style="font-size:.7rem">🌴</span><?php endif; ?>
|
||||
</div>
|
||||
<div style="font-size:.75rem;font-weight:500"><span class="status-label" style="color:<?= $asc ?>"><?= $asl ?></span></div>
|
||||
<div class="status-note" style="font-size:.72rem;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><?= e($ag['avail_note'] ?? '') ?></div>
|
||||
<div class="av-name"><?= e($ag['name']) ?></div>
|
||||
<div><span class="status-label" style="color:<?= $asc ?>"><?= $asl ?></span></div>
|
||||
<div class="status-note"><?= e($ag['avail_note'] ?? '') ?></div>
|
||||
<?php if (!empty($ag['avail_auto'])): ?><div class="av-seen">automatisch offline</div>
|
||||
<?php elseif ($__la !== ''): ?><div class="av-seen">aktiv <?= e($__la) ?></div><?php endif; ?>
|
||||
</div>
|
||||
<?php if ($admin): ?>
|
||||
<a href="?edit=<?= $ag['id'] ?>" title="Schichtplan bearbeiten"
|
||||
style="color:var(--muted);font-size:.78rem;text-decoration:none;flex-shrink:0">⚙️</a>
|
||||
<?php endif; ?>
|
||||
<?php $__la = staff_last_active_label($ag['last_active_at'] ?? null); ?>
|
||||
<?php if ($__la !== '' || !empty($ag['avail_auto'])): ?><div style="font-size:.68rem;color:var(--muted);flex-shrink:0;text-align:right"><?= !empty($ag['avail_auto']) ? 'automatisch<br>offline' : '' ?><?= $__la !== '' && empty($ag['avail_auto']) ? 'aktiv ' . e($__la) : '' ?></div><?php endif; ?>
|
||||
<?php if ($admin): ?><a href="?edit=<?= $ag['id'] ?>" class="btn secondary av-edit" title="Schichtplan bearbeiten">Schichtplan</a><?php endif; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
@@ -202,34 +206,38 @@ $myStatus = $myAvail['avail_status'] ?? 'available';
|
||||
<?php if ($admin): ?>
|
||||
<!-- ── Admin: Status der Mitarbeiter ändern + Auto-Offline ───────────────────── -->
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<div style="font-size:.72rem;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:6px">Status der Mitarbeiter ändern (Admin)</div>
|
||||
<p class="muted" style="font-size:.8rem;margin-bottom:12px">Ein von dir gesetzter Status bleibt, bis der Mitarbeiter ihn selbst ändert oder du ihn erneut änderst.</p>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:.85rem">
|
||||
<h3>Status der Mitarbeiter ändern (Admin)</h3>
|
||||
<p class="muted" style="font-size:.85rem;margin:0 0 12px">Ein von dir gesetzter Status bleibt, bis der Mitarbeiter ihn selbst ändert oder du ihn erneut änderst.</p>
|
||||
<div style="overflow-x:auto"><table>
|
||||
<thead><tr><th>Mitarbeiter</th><th>Status und Notiz</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($agents as $ag): ?>
|
||||
<tr style="border-top:1px solid var(--border)">
|
||||
<td style="padding:6px 8px"><?= e($ag['name']) ?></td>
|
||||
<td style="padding:6px 8px">
|
||||
<form method="post" style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
|
||||
<tr class="av-admin-row">
|
||||
<td><strong><?= e($ag['name']) ?></strong></td>
|
||||
<td>
|
||||
<form method="post">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="set_staff_status">
|
||||
<input type="hidden" name="staff_id" value="<?= (int) $ag['id'] ?>">
|
||||
<select name="status" style="width:auto">
|
||||
<select name="status">
|
||||
<?php foreach ($statusLabels as $sk => [$sl]): ?><option value="<?= $sk ?>" <?= ($ag['avail_status'] ?? 'offline') === $sk ? 'selected' : '' ?>><?= $sl ?></option><?php endforeach; ?>
|
||||
</select>
|
||||
<input type="text" name="note" value="<?= e($ag['avail_note'] ?? '') ?>" placeholder="Notiz (optional)" maxlength="120" style="width:180px">
|
||||
<input type="text" name="note" value="<?= e($ag['avail_note'] ?? '') ?>" placeholder="Notiz (optional)" maxlength="120" style="width:220px">
|
||||
<button type="submit" class="btn secondary">Setzen</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</table>
|
||||
<form method="post" style="margin-top:14px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
</tbody>
|
||||
</table></div>
|
||||
<form method="post" class="av-auto">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="save_auto_offline">
|
||||
<label style="margin:0;font-weight:500">Automatisch offline nach</label>
|
||||
<input type="number" name="auto_offline_minutes" min="0" max="1440" value="<?= (int) staff_auto_offline_minutes() ?>" style="width:90px"> Minuten ohne Aktivität
|
||||
<label>Automatisch offline nach</label>
|
||||
<input type="number" name="auto_offline_minutes" min="0" max="1440" value="<?= (int) staff_auto_offline_minutes() ?>">
|
||||
<span>Minuten ohne Aktivität</span>
|
||||
<button type="submit" class="btn secondary">Speichern</button>
|
||||
<span class="muted" style="font-size:.78rem">0 = aus. Bei neuer Aktivität kommt der vorherige Status zurück.</span>
|
||||
<span class="muted" style="font-size:.8rem">0 = aus. Bei neuer Aktivität kommt der vorherige Status zurück.</span>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
@@ -309,18 +317,7 @@ var statusUpdateTimer;
|
||||
function setMyStatus(s) {
|
||||
currentStatus = s;
|
||||
document.querySelectorAll('.status-btn').forEach(function(btn) {
|
||||
var active = btn.dataset.status === s;
|
||||
var colors = {
|
||||
available: ['#22c55e','#22c55e18'],
|
||||
busy: ['#f59e0b','#f59e0b18'],
|
||||
break: ['#6366f1','#6366f118'],
|
||||
offline: ['#94a3b8','#94a3b818'],
|
||||
};
|
||||
var c = colors[btn.dataset.status] || ['var(--border)','transparent'];
|
||||
btn.style.borderColor = active ? c[0] : 'var(--border)';
|
||||
btn.style.background = active ? c[1] : 'transparent';
|
||||
btn.style.color = active ? c[0] : 'var(--ink-soft)';
|
||||
btn.style.fontWeight = active ? '600' : '400';
|
||||
btn.classList.toggle('active', btn.dataset.status === s);
|
||||
});
|
||||
sendStatusUpdate();
|
||||
}
|
||||
@@ -360,7 +357,7 @@ function refreshTeamStatus() {
|
||||
var colors = { available:'#22c55e', busy:'#f59e0b', break:'#6366f1', offline:'#94a3b8' };
|
||||
var labels = { available:'Verfügbar', busy:'Beschäftigt', break:'Pause', offline:'Offline' };
|
||||
var s = ag.is_on_vacation ? 'offline' : (ag.status || 'offline');
|
||||
var label = ag.is_on_vacation ? '🌴 Urlaub' : labels[s];
|
||||
var label = ag.is_on_vacation ? 'Urlaub' : labels[s];
|
||||
var color = colors[s] || '#94a3b8';
|
||||
el.querySelector('.status-dot').style.background = color;
|
||||
el.querySelector('.status-label').textContent = label;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$staff = require_admin_or_permission('manage_banlist');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
@@ -24,8 +24,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
->execute([$matchType, $value, $reason ?: null, $staff['id'], db_now()]);
|
||||
// Tickets schließen
|
||||
$col = $matchType === 'email' ? 'requester_email' : "" . sql_email_domain("requester_email") . "";
|
||||
$pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')")
|
||||
$priorStmt = $pdo->prepare("SELECT id,status FROM tickets WHERE $col=? AND status NOT IN ('closed')");
|
||||
$priorStmt->execute([$value]); $priorTickets = $priorStmt->fetchAll();
|
||||
$pdo->prepare("UPDATE tickets SET status='closed',resolution_code='spam',resolution_note='Im Rahmen einer Sperre geschlossen',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')")
|
||||
->execute([db_now(), db_now(), $value]);
|
||||
ticket_sla_transition_rows($pdo, $priorTickets, 'closed', (int) $staff['id']);
|
||||
audit_log($staff['id'], "Sperrliste: $value hinzugefügt ($matchType)");
|
||||
}
|
||||
flash('success', "$value gesperrt.");
|
||||
@@ -50,8 +53,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
->execute([$type, $val, $reason ?: null, $staff['id'], db_now()]);
|
||||
// Tickets schließen
|
||||
$col = $type === 'email' ? 'requester_email' : "" . sql_email_domain("requester_email") . "";
|
||||
$pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')")
|
||||
$priorStmt = $pdo->prepare("SELECT id,status FROM tickets WHERE $col=? AND status NOT IN ('closed')");
|
||||
$priorStmt->execute([$val]); $priorTickets = $priorStmt->fetchAll();
|
||||
$pdo->prepare("UPDATE tickets SET status='closed',resolution_code='spam',resolution_note='Im Rahmen einer Sperre geschlossen',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')")
|
||||
->execute([db_now(), db_now(), $val]);
|
||||
ticket_sla_transition_rows($pdo, $priorTickets, 'closed', (int) $staff['id']);
|
||||
$added++;
|
||||
}
|
||||
audit_log($staff['id'], "Sperrliste Massen-Import: $added hinzugefügt, $skipped übersprungen");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin();
|
||||
$admin = require_admin_or_permission('manage_canned');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$admin = require_admin();
|
||||
$admin = require_admin_or_permission('manage_custom_fields');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
require_once __DIR__ . '/_ui.php';
|
||||
$staff = require_admin();
|
||||
$staff = require_admin_or_permission('manage_custom_fields');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$staff = require_admin_or_permission('view_customers');
|
||||
$pdo = get_db();
|
||||
|
||||
$userId = (int) ($_GET['id'] ?? 0);
|
||||
@@ -44,6 +44,35 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
redirect('/staff/customer_profile.php?email=' . urlencode($guestEmail));
|
||||
}
|
||||
|
||||
if ($action === 'gdpr_export' || $action === 'gdpr_erase') {
|
||||
if ($staff['role'] !== 'admin' || !gdpr_tools_enabled()) {
|
||||
flash('error', 'Die Datenschutz-Werkzeuge sind nicht verfügbar (nur Administratoren, in den Einstellungen einzuschalten).');
|
||||
redirect($isGuest ? '/staff/customer_profile.php?email=' . urlencode($guestEmail) : '/staff/customer_profile.php?id=' . $userId);
|
||||
}
|
||||
$ref = $isGuest ? ['email' => $guestEmail] : ['user_id' => $userId];
|
||||
if ($action === 'gdpr_export') {
|
||||
$data = gdpr_customer_export($pdo, $ref);
|
||||
audit_log($staff['id'], 'Kundendaten exportiert (' . ($isGuest ? 'Gast ' . $guestEmail : 'Kunde #' . $userId) . ')');
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Content-Disposition: attachment; filename="kundendaten-' . ($isGuest ? 'gast' : $userId) . '-' . date('Ymd') . '.json"');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
echo json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
exit;
|
||||
}
|
||||
if (trim((string) ($_POST['confirm_email'] ?? '')) !== (string) $customer['email']) {
|
||||
flash('error', 'Zur Bestätigung muss die E-Mail-Adresse des Kunden genau eingegeben werden.');
|
||||
redirect($isGuest ? '/staff/customer_profile.php?email=' . urlencode($guestEmail) : '/staff/customer_profile.php?id=' . $userId);
|
||||
}
|
||||
$res = gdpr_customer_erase($pdo, $ref, !empty($_POST['force']));
|
||||
if ($res['error']) {
|
||||
flash('error', $res['error']);
|
||||
redirect($isGuest ? '/staff/customer_profile.php?email=' . urlencode($guestEmail) : '/staff/customer_profile.php?id=' . $userId);
|
||||
}
|
||||
audit_log($staff['id'], 'Kundendaten gelöscht (' . ($isGuest ? 'Gast' : 'Kunde #' . $userId) . '): ' . $res['tickets'] . ' Ticket(s) anonymisiert, ' . $res['messages'] . ' Nachricht(en) und ' . $res['files'] . ' Datei(en) entfernt');
|
||||
flash('success', 'Kundendaten gelöscht: ' . $res['tickets'] . ' Ticket(s) anonymisiert, ' . $res['messages'] . ' Nachricht(en) und ' . $res['files'] . ' Datei(en) entfernt.');
|
||||
redirect('/staff/customers.php');
|
||||
}
|
||||
|
||||
if ($action === 'add_note') {
|
||||
$note = trim($_POST['note'] ?? '');
|
||||
if ($note !== '') {
|
||||
@@ -219,6 +248,27 @@ require __DIR__ . '/_header.php';
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($staff['role'] === 'admin' && gdpr_tools_enabled()): ?>
|
||||
<div class="card" style="padding:16px 18px;margin-top:16px">
|
||||
<div style="font-size:.78rem; font-weight:700; text-transform:uppercase; letter-spacing:.06em; color:var(--muted); margin-bottom:8px">Datenschutz (DSGVO)</div>
|
||||
<form method="post" style="margin-bottom:12px">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="gdpr_export">
|
||||
<button type="submit" class="btn secondary" style="width:100%;justify-content:center">Alle Daten exportieren (JSON)</button>
|
||||
<p class="muted" style="font-size:.76rem;margin-top:6px">Auskunft: Stammdaten, alle Tickets mit Nachrichten und Anhangsnamen, Notizen. Die Datei bitte vor der Weitergabe prüfen.</p>
|
||||
</form>
|
||||
<form method="post" onsubmit="return confirm('Alle personenbezogenen Daten dieses Kunden unwiderruflich löschen?')">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="action" value="gdpr_erase">
|
||||
<label style="font-size:.78rem" for="gdpr_confirm">Zur Bestätigung die E-Mail-Adresse eingeben</label>
|
||||
<input type="text" id="gdpr_confirm" name="confirm_email" autocomplete="off" placeholder="<?= e($customer['email']) ?>" required>
|
||||
<label style="display:flex;align-items:center;gap:8px;font-weight:400;font-size:.8rem;margin-top:8px"><input type="checkbox" name="force" value="1" style="width:auto"> Auch bei noch offenen Tickets löschen</label>
|
||||
<button type="submit" class="btn" style="width:100%;justify-content:center;margin-top:10px;background:var(--danger,#dc2626)">Kundendaten löschen</button>
|
||||
<p class="muted" style="font-size:.76rem;margin-top:6px">Tickets bleiben als anonymisierte Einträge (Nummer, Betreff, Status, Zeiten). Nachrichten und Dateien des Kunden, Notizen und das Konto werden entfernt.</p>
|
||||
</form>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_staff_login();
|
||||
$staff = require_admin_or_permission('manage_customers');
|
||||
$pdo = get_db();
|
||||
|
||||
$errors = [];
|
||||
@@ -10,7 +10,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
verify_csrf();
|
||||
$action = $_POST['action'] ?? '';
|
||||
|
||||
if ($action === 'assign_org' && $staff['role'] === 'admin') {
|
||||
if ($action === 'assign_org') {
|
||||
$userId = (int) $_POST['user_id'];
|
||||
$orgId = $_POST['organization_id'] !== '' ? (int) $_POST['organization_id'] : null;
|
||||
$pdo->prepare('UPDATE users SET organization_id = ? WHERE id = ?')->execute([$orgId, $userId]);
|
||||
|
||||
@@ -17,7 +17,7 @@ $mine->execute(array_merge([$staff['id']], $deptParams)); $mine = (int)$mine->fe
|
||||
$unassigned = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed')" . $deptFilter);
|
||||
$unassigned->execute($deptParams); $unassigned = (int)$unassigned->fetchColumn();
|
||||
|
||||
$overdue = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed') AND resolution_due_at IS NOT NULL AND resolution_due_at < ?" . $deptFilter);
|
||||
$overdue = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status NOT IN ('resolved','closed') AND (is_overdue = 1 OR (sla_paused_at IS NULL AND resolution_due_at IS NOT NULL AND resolution_due_at < ?))" . $deptFilter);
|
||||
$overdue->execute(array_merge([$now], $deptParams)); $overdue = (int)$overdue->fetchColumn();
|
||||
|
||||
$unanswered = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status = 'open'" . $deptFilter);
|
||||
|
||||
@@ -312,7 +312,13 @@ require __DIR__ . '/_header.php';
|
||||
function sync() { box.style.opacity = on.checked ? '1' : '.5'; box.querySelectorAll('input,select').forEach(function (i) { i.disabled = !on.checked; }); }
|
||||
on.addEventListener('change', sync); sync();
|
||||
}
|
||||
toggler('imap_enabled', 'imap-fields'); toggler('smtp_enabled', 'smtp-fields');
|
||||
toggler('imap_enabled', 'imap-fields'); toggler('smtp_enabled', 'smtp-fields');
|
||||
// Verschlüsselung gewählt: Port passend setzen (nur wenn dort noch ein Standardport steht, eigene Ports bleiben)
|
||||
var enc = document.getElementById('smtp_encryption'), prt = document.getElementById('smtp_port');
|
||||
if (enc && prt) enc.addEventListener('change', function () {
|
||||
var std = { tls: '587', ssl: '465', none: '25' };
|
||||
if (['25', '465', '587', ''].indexOf(String(prt.value).trim()) !== -1) prt.value = std[enc.value] || prt.value;
|
||||
});
|
||||
|
||||
var af = document.getElementById('after_fetch'), ar = document.getElementById('archive-row');
|
||||
function syncAf() { ar.style.display = af.value === 'archive' ? '' : 'none'; }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/../includes/bootstrap.php';
|
||||
$staff = require_admin();
|
||||
$staff = require_admin_or_permission('export_tickets');
|
||||
$pdo = get_db();
|
||||
$myDeptIds = staff_department_ids($staff); // null = Admin, alle Abteilungen
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user