mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
454 Commits
v0.1.0
...
feature/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5aff20fc | ||
|
|
9e5aa77e41 | ||
|
|
0791a68283 | ||
|
|
e66679274d | ||
|
|
717fd35d76 | ||
|
|
5a3a5995cc | ||
|
|
a8e6d28eab | ||
|
|
9c9aeeab0f | ||
|
|
8a1253e101 | ||
|
|
661fa25da1 | ||
|
|
d77048a7dd | ||
|
|
4676af9b40 | ||
|
|
18c8e62228 | ||
|
|
e7703aef64 | ||
|
|
86d0497000 | ||
|
|
522f7d2bd2 | ||
|
|
2f807e4808 | ||
|
|
93d9db349b | ||
|
|
3417b60585 | ||
|
|
0f21fabd37 | ||
|
|
df00200464 | ||
|
|
3b41de7135 | ||
|
|
9fe0ea5a0f | ||
|
|
f8f708a664 | ||
|
|
c359259e45 | ||
|
|
55d12aaae1 | ||
|
|
9a1dd4861c | ||
|
|
1e985b71ec | ||
|
|
93d6a86f74 | ||
|
|
19a206d57c | ||
|
|
c0788c270b | ||
|
|
7765056074 | ||
|
|
639f5332e4 | ||
|
|
4a50145329 | ||
|
|
8aabffd1e7 | ||
|
|
b373427dc7 | ||
|
|
d2a4d60441 | ||
|
|
c3305b3df6 | ||
|
|
7584e59d0b | ||
|
|
d2f75cca6e | ||
|
|
250379d4bd | ||
|
|
7f89fd8ea1 | ||
|
|
0b45f3b473 | ||
|
|
9827a74ae2 | ||
|
|
3425847a44 | ||
|
|
47b778fab9 | ||
|
|
85d69f1f16 | ||
|
|
fca55fe0e1 | ||
|
|
f19abb9db6 | ||
|
|
e3bd50ed6b | ||
|
|
c582530899 | ||
|
|
fb5185a32f | ||
|
|
0a0854f771 | ||
|
|
4e635cde83 | ||
|
|
9fa9522237 | ||
|
|
04c44097d0 | ||
|
|
3d5a0cb974 | ||
|
|
da98e0571c | ||
|
|
f68f05d1aa | ||
|
|
8fdc4c1219 | ||
|
|
93148299a9 | ||
|
|
78d2ea1a25 | ||
|
|
14f559c4c2 | ||
|
|
61fd2b1187 | ||
|
|
9ea3c5dc29 | ||
|
|
cb30487a21 | ||
|
|
b11672732b | ||
|
|
97dcadc795 | ||
|
|
e7fa414c06 | ||
|
|
43073b5be2 | ||
|
|
9589c9106d | ||
|
|
8a0d2235a8 | ||
|
|
38f38790d5 | ||
|
|
e3cfc155b8 | ||
|
|
4b726635b2 | ||
|
|
e1185af281 | ||
|
|
f9c0d64f82 | ||
|
|
3d58f570bd | ||
|
|
400bc434b9 | ||
|
|
2ab28001be | ||
|
|
62d2f4bf4e | ||
|
|
3d4b20f7c8 | ||
|
|
155ed62fcc | ||
|
|
5daa6f2a25 | ||
|
|
47aa65d959 | ||
|
|
b0e638c28b | ||
|
|
24b62d4643 | ||
|
|
dd928508fd | ||
|
|
ead9cf2185 | ||
|
|
7578beb271 | ||
|
|
dc21ac8352 | ||
|
|
4de7868851 | ||
|
|
ffc016a1ec | ||
|
|
be69626970 | ||
|
|
f1dce88dab | ||
|
|
15411ec0c8 | ||
|
|
48f09421d0 | ||
|
|
36caadeb14 | ||
|
|
b4edcaa2dc | ||
|
|
a3dda8b03c | ||
|
|
d64f0c52be | ||
|
|
c80d51c2e1 | ||
|
|
46dea00b34 | ||
|
|
16fed4a2b7 | ||
|
|
9a2af2e743 | ||
|
|
2e3a517502 | ||
|
|
a69fb9c551 | ||
|
|
62b5730fa8 | ||
|
|
098ead8da6 | ||
|
|
d49082d7f3 | ||
|
|
cc88f034c7 | ||
|
|
9620c89545 | ||
|
|
f9c3f42289 | ||
|
|
fca4c26cfc | ||
|
|
d8f4ba1517 | ||
|
|
284d8cd786 | ||
|
|
411fc6ea5e | ||
|
|
02a8367d16 | ||
|
|
68f636c8ff | ||
|
|
9c44abf7aa | ||
|
|
b1ff97a82f | ||
|
|
ed32c6b217 | ||
|
|
8b950d99d6 | ||
|
|
e374d8b3de | ||
|
|
301d09e830 | ||
|
|
49af3d4371 | ||
|
|
b4a6145f40 | ||
|
|
06c6c874eb | ||
|
|
b796d232f5 | ||
|
|
26c50867b3 | ||
|
|
b8110e222a | ||
|
|
7673b365ca | ||
|
|
da5fc3f113 | ||
|
|
8c66068663 | ||
|
|
dd0cc0d60b | ||
|
|
3a482c1e6a | ||
|
|
ef9f353047 | ||
|
|
f1a1d2a266 | ||
|
|
f5efbad703 | ||
|
|
17242188c2 | ||
|
|
0a376b1caa | ||
|
|
10a8310e37 | ||
|
|
89131b9e77 | ||
|
|
c17c5dc6c0 | ||
|
|
3444281703 | ||
|
|
84e2365a6d | ||
|
|
92ac9948a0 | ||
|
|
8da358dbe6 | ||
|
|
b7b9092e64 | ||
|
|
15ac3e9a43 | ||
|
|
d03dd60864 | ||
|
|
827e0fe377 | ||
|
|
e78a551098 | ||
|
|
ae00fdb0e9 | ||
|
|
3c9160a08a | ||
|
|
4fb744db1d | ||
|
|
bc9b104c3f | ||
|
|
880c363ae4 | ||
|
|
8e6d1abbf3 | ||
|
|
d202bd9c47 | ||
|
|
992d8945df | ||
|
|
df2fe1da1e | ||
|
|
7339b79e35 | ||
|
|
6deb281565 | ||
|
|
6ba0b19d40 | ||
|
|
01f6f0f5ea | ||
|
|
aa3c64e496 | ||
|
|
eee13897c9 | ||
|
|
ac6e2b8079 | ||
|
|
50cc7053e4 | ||
|
|
73ce5f793d | ||
|
|
02a716897d | ||
|
|
e5ec11af44 | ||
|
|
ab263e725f | ||
|
|
f93c5370bf | ||
|
|
9faa8fe6e1 | ||
|
|
9948cb1fc1 | ||
|
|
3026edd27b | ||
|
|
b6bbcd7097 | ||
|
|
0d4ffa1061 | ||
|
|
b7abe3738e | ||
|
|
128a21ba63 | ||
|
|
e25461a439 | ||
|
|
ba8751c7c4 | ||
|
|
21b33a0028 | ||
|
|
97585b5771 | ||
|
|
ae76135373 | ||
|
|
69a8c8bb2b | ||
|
|
4ea55e5867 | ||
|
|
bbed618fdc | ||
|
|
d924fa74ec | ||
|
|
adf0d35c11 | ||
|
|
4ed8f16ae3 | ||
|
|
0a956fd9e7 | ||
|
|
09b168cddb | ||
|
|
31b9659f7e | ||
|
|
db7111da44 | ||
|
|
18ab1f714b | ||
|
|
00e2518196 | ||
|
|
6f6e5fb4c3 | ||
|
|
68228bccb2 | ||
|
|
2dd80ba6cc | ||
|
|
b783ea9ecd | ||
|
|
dce608e403 | ||
|
|
84c9cfe2f2 | ||
|
|
f14bd6413a | ||
|
|
eb19199bc6 | ||
|
|
0252d984cb | ||
|
|
18162b0ff5 | ||
|
|
3dab7440dd | ||
|
|
713e12e54e | ||
|
|
fc0a840ded | ||
|
|
28904b650e | ||
|
|
1d34a77eb2 | ||
|
|
49e045809b | ||
|
|
e90fa8307f | ||
|
|
895540d0a9 | ||
|
|
62270382dc | ||
|
|
29929467f6 | ||
|
|
02fe89dfdf | ||
|
|
03550a0ca6 | ||
|
|
2f1056dddb | ||
|
|
6e226cd743 | ||
|
|
19ed966504 | ||
|
|
33818f10b3 | ||
|
|
ee9d818d75 | ||
|
|
e3d8457523 | ||
|
|
67e42a0a54 | ||
|
|
fdbf88a9a6 | ||
|
|
c4daca32c5 | ||
|
|
4e10f9538f | ||
|
|
959cad8f74 | ||
|
|
e308ca78b1 | ||
|
|
4281736a6d | ||
|
|
9b0cf37bc7 | ||
|
|
a4f3e014d9 | ||
|
|
32bce2f749 | ||
|
|
ae7f5a98e7 | ||
|
|
e3f981aac2 | ||
|
|
bcb298bd6d | ||
|
|
620c4c97dc | ||
|
|
05da595470 | ||
|
|
a4d8a02b80 | ||
|
|
0860aa9d24 | ||
|
|
9c82efdf07 | ||
|
|
2560619c15 | ||
|
|
c03aad1abd | ||
|
|
0ee0175f04 | ||
|
|
0c1f06face | ||
|
|
86d625b18a | ||
|
|
83e17d4a40 | ||
|
|
5b27853546 | ||
|
|
f49f7b2c9b | ||
|
|
9e77500d94 | ||
|
|
2cf9b3aa8f | ||
|
|
64b41e3018 | ||
|
|
31014c1e29 | ||
|
|
d880717749 | ||
|
|
df0f3b2680 | ||
|
|
4b0cb2e282 | ||
|
|
d5699da234 | ||
|
|
96f06bae1d | ||
|
|
e1243178fe | ||
|
|
cfbc98705a | ||
|
|
f0d6b234e5 | ||
|
|
4b622afcfc | ||
|
|
45daeead61 | ||
|
|
95c1bcd4cb | ||
|
|
3b3f593080 | ||
|
|
4224fdd57e | ||
|
|
f4cfeaa718 | ||
|
|
04fcc1e3ae | ||
|
|
f145e821a8 | ||
|
|
eaaa83406d | ||
|
|
9a60e2b911 | ||
|
|
5a1e05374c | ||
|
|
ab4dbd64df | ||
|
|
8712cfb9dc | ||
|
|
7c1fe35754 | ||
|
|
b0bcc4f330 | ||
|
|
5593d141ea | ||
|
|
d080b07e60 | ||
|
|
64535ceea6 | ||
|
|
e54df74d5d | ||
|
|
27b40d863e | ||
|
|
b41d20839e | ||
|
|
7acadda6d8 | ||
|
|
cd7573dcf1 | ||
|
|
eb4debe481 | ||
|
|
fd77e1e901 | ||
|
|
401cd4be0a | ||
|
|
548307336a | ||
|
|
f534f90ca7 | ||
|
|
0290013d19 | ||
|
|
85f4a3049c | ||
|
|
4c27f1a2de | ||
|
|
69d3ff4f7b | ||
|
|
2b1da883fb | ||
|
|
c291170d79 | ||
|
|
d9925d632e | ||
|
|
ddf11b394d | ||
|
|
129c132f97 | ||
|
|
26637e6f84 | ||
|
|
612f40a4b0 | ||
|
|
8f34fac0a6 | ||
|
|
a374a52474 | ||
|
|
09586de2d5 | ||
|
|
678d27c93a | ||
|
|
7af1990935 | ||
|
|
2372ee0622 | ||
|
|
f147fb9725 | ||
|
|
d5a4df738f | ||
|
|
b3b84db004 | ||
|
|
d3d3a98b08 | ||
|
|
9f2ac70549 | ||
|
|
071895791c | ||
|
|
9a50e144b3 | ||
|
|
a77b8a5ed2 | ||
|
|
fcba96fbf6 | ||
|
|
d200de54a8 | ||
|
|
a882ec6ca0 | ||
|
|
3ee7839ca9 | ||
|
|
165391861a | ||
|
|
8d950c6d45 | ||
|
|
6c7b1b3f21 | ||
|
|
51cd919db6 | ||
|
|
9d279d4980 | ||
|
|
32c7e55a15 | ||
|
|
084647c2a6 | ||
|
|
469f128604 | ||
|
|
c9c221de62 | ||
|
|
878bbd359d | ||
|
|
a6528102fe | ||
|
|
bff766d363 | ||
|
|
2e8da98287 | ||
|
|
a820d8540f | ||
|
|
78ea8a673b | ||
|
|
8b50f33cc9 | ||
|
|
014bffe86d | ||
|
|
2dbde63043 | ||
|
|
876a41cb2a | ||
|
|
1036502e49 | ||
|
|
5bf4dc79c2 | ||
|
|
2592dd3b9e | ||
|
|
05f240efc9 | ||
|
|
d5b35ef420 | ||
|
|
7e5374d5b1 | ||
|
|
36cdae523f | ||
|
|
b2ad4b3785 | ||
|
|
5e4270e3f5 | ||
|
|
d4e71e7c2c | ||
|
|
5c6b32d5bb | ||
|
|
37400d239c | ||
|
|
50902e7705 | ||
|
|
498f29617e | ||
|
|
61cc80dc6e | ||
|
|
0a0b7a03b4 | ||
|
|
cc10af0b97 | ||
|
|
d3545b3c73 | ||
|
|
9e1413c15f | ||
|
|
ac85e778a4 | ||
|
|
9189910136 | ||
|
|
85315fc62f | ||
|
|
91b56ae92f | ||
|
|
845f0d19d8 | ||
|
|
d211e962f5 | ||
|
|
f0705e1e4a | ||
|
|
b990387775 | ||
|
|
a4d6ba3cdb | ||
|
|
3b41d90b07 | ||
|
|
b391f47d1b | ||
|
|
19cc05140a | ||
|
|
5592d87cd5 | ||
|
|
b518187ecb | ||
|
|
c09119af33 | ||
|
|
ceba49d054 | ||
|
|
01dd13b947 | ||
|
|
83301d03ca | ||
|
|
4969fcba7e | ||
|
|
48b2bb436e | ||
|
|
30ed47d3fb | ||
|
|
2bad9eaa3c | ||
|
|
78b41ea0b7 | ||
|
|
d8968399d6 | ||
|
|
5b7df869ad | ||
|
|
7c593f8f87 | ||
|
|
22b2933d85 | ||
|
|
6dd9d5bab0 | ||
|
|
9a8945b0dc | ||
|
|
fc614b796c | ||
|
|
b031598f79 | ||
|
|
07823291ae | ||
|
|
75012ea020 | ||
|
|
49de8d0900 | ||
|
|
156d2ff1a0 | ||
|
|
a01e1d6b0b | ||
|
|
9df91f4e4a | ||
|
|
e538fec7c7 | ||
|
|
aee5ea456e | ||
|
|
2c0ab5e15a | ||
|
|
0245eccaeb | ||
|
|
ee77de04ef | ||
|
|
056a63e193 | ||
|
|
024d841024 | ||
|
|
597f9ce802 | ||
|
|
18ac9acc2a | ||
|
|
f6d9dfa6bb | ||
|
|
64d422f5f7 | ||
|
|
b3b8b9fba9 | ||
|
|
e981d6bc01 | ||
|
|
859833452f | ||
|
|
33d139e3aa | ||
|
|
0c05ad240d | ||
|
|
4ad68b4f4e | ||
|
|
249b1b5820 | ||
|
|
1328692faf | ||
|
|
35c65d3bf0 | ||
|
|
c3cad88949 | ||
|
|
f4d4ea8b98 | ||
|
|
05ece9b0ee | ||
|
|
571054b816 | ||
|
|
f014137623 | ||
|
|
b2d327e8b1 | ||
|
|
c6ee2b5131 | ||
|
|
b689784701 | ||
|
|
b375cba5f7 | ||
|
|
635954f81d | ||
|
|
b7c9aa6f28 | ||
|
|
87b114a32a | ||
|
|
00e095ec4b | ||
|
|
b741105cfa | ||
|
|
16203ec748 | ||
|
|
06a35cb447 | ||
|
|
7c1b828ad3 | ||
|
|
ea90b0acb2 | ||
|
|
10cc5cf42a | ||
|
|
04bb8e50a7 | ||
|
|
6aef8856f5 | ||
|
|
06fef6e40f | ||
|
|
a9c874e540 | ||
|
|
21207a4058 | ||
|
|
0e7dec2f40 | ||
|
|
99c652a61b | ||
|
|
1e4f0afa67 | ||
|
|
655723db49 | ||
|
|
10d8540e6c | ||
|
|
cbdbcef9eb | ||
|
|
a519c119d4 | ||
|
|
375cee7589 | ||
|
|
ba07616111 | ||
|
|
63323d86c3 | ||
|
|
8db0a7d25e | ||
|
|
855db81104 | ||
|
|
055d93f7a3 |
61
.env.ci
61
.env.ci
@@ -1,57 +1,58 @@
|
||||
# Application
|
||||
APP_NAME=solidtime
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost
|
||||
APP_FORCE_HTTPS=false
|
||||
SESSION_SECURE_COOKIE=false
|
||||
APP_ENABLE_REGISTRATION=true
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Database
|
||||
DB_CONNECTION=pgsql_test
|
||||
|
||||
DB_TEST_HOST=127.0.0.1
|
||||
DB_TEST_PORT=5432
|
||||
DB_TEST_DATABASE=laravel
|
||||
DB_TEST_USERNAME=root
|
||||
DB_TEST_PASSWORD=root
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
# Broadcasting
|
||||
BROADCAST_DRIVER=null
|
||||
|
||||
# Cache
|
||||
CACHE_DRIVER=file
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Mail
|
||||
MAIL_MAILER=log
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||
MAIL_FROM_NAME="solidtime"
|
||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||
MAIL_REPLY_TO_NAME="solidtime"
|
||||
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=
|
||||
S3_USE_PATH_STYLE_ENDPOINT=false
|
||||
# Filesystems
|
||||
FILESYSTEM_DISK=local
|
||||
PUBLIC_FILESYSTEM_DISK=public
|
||||
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
PUSHER_HOST=
|
||||
PUSHER_PORT=443
|
||||
PUSHER_SCHEME=https
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
# Passport
|
||||
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
|
||||
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||
VITE_PUSHER_HOST="${PUSHER_HOST}"
|
||||
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
||||
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
||||
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||
# Auditing
|
||||
AUDITING_ENABLED=true
|
||||
|
||||
# Telescope
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
# Services
|
||||
GOTENBERG_URL=http://0.0.0.0:3000
|
||||
|
||||
77
.env.example
77
.env.example
@@ -1,17 +1,21 @@
|
||||
# Application
|
||||
APP_NAME=solidtime
|
||||
APP_ENV=local
|
||||
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://solidtime.test
|
||||
|
||||
APP_FORCE_HTTPS=false
|
||||
APP_ENABLE_REGISTRATION=true
|
||||
SUPER_ADMINS=admin@example.com
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=single
|
||||
LOG_DEPRECATIONS_CHANNEL=deprecation
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Database
|
||||
DB_CONNECTION=pgsql
|
||||
|
||||
DB_HOST=pgsql
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=laravel
|
||||
@@ -24,19 +28,20 @@ DB_TEST_DATABASE=laravel
|
||||
DB_TEST_USERNAME=root
|
||||
DB_TEST_PASSWORD=root
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
# Broadcasting
|
||||
BROADCAST_DRIVER=null
|
||||
|
||||
# Cache
|
||||
CACHE_DRIVER=file
|
||||
FILESYSTEM_DISK=local
|
||||
|
||||
# Queue
|
||||
QUEUE_CONNECTION=sync
|
||||
|
||||
# Session
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Mail
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=mailpit
|
||||
MAIL_PORT=1025
|
||||
@@ -44,34 +49,38 @@ MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
MAIL_FROM_NAME="solidtime"
|
||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||
MAIL_REPLY_TO_NAME="solidtime"
|
||||
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
# Filesystems
|
||||
FILESYSTEM_DISK=s3
|
||||
PUBLIC_FILESYSTEM_DISK=s3
|
||||
S3_ACCESS_KEY_ID=sail
|
||||
S3_SECRET_ACCESS_KEY=password
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=
|
||||
S3_USE_PATH_STYLE_ENDPOINT=false
|
||||
S3_BUCKET=local
|
||||
S3_URL=http://storage.solidtime.test/local
|
||||
S3_ENDPOINT=http://storage.solidtime.test
|
||||
S3_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
PUSHER_HOST=
|
||||
PUSHER_PORT=443
|
||||
PUSHER_SCHEME=https
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
# Passport
|
||||
PASSPORT_PERSONAL_ACCESS_CLIENT_ID="9e27f54d-5dfb-4dde-99d7-834518236c92"
|
||||
PASSPORT_PERSONAL_ACCESS_CLIENT_SECRET="EL5mXp3aF8ITjcwoOXRpbSK7zGrWhW4zTDpQXTkf"
|
||||
|
||||
VITE_HOST_NAME=vite.solidtime.test
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||
VITE_PUSHER_HOST="${PUSHER_HOST}"
|
||||
VITE_PUSHER_PORT="${PUSHER_PORT}"
|
||||
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
|
||||
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
|
||||
# Auditing
|
||||
AUDITING_ENABLED=true
|
||||
|
||||
# Telescope
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
# Services
|
||||
GOTENBERG_URL=http://gotenberg:3000
|
||||
|
||||
# Local setup
|
||||
NGINX_HOST_NAME=solidtime.test
|
||||
NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||
|
||||
FORWARD_DB_PORT=5432
|
||||
FORWARD_WEB_PORT=8083
|
||||
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
FORWARD_DB_PORT=54329
|
||||
VITE_HOST_NAME=vite.solidtime.test
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
#SAIL_XDEBUG_MODE=develop,debug,coverage
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
APP_NAME=solidtime
|
||||
APP_VERSION=0.0.0
|
||||
APP_BUILD=0
|
||||
VITE_APP_NAME=solidtime
|
||||
APP_ENV=production
|
||||
APP_DEBUG=false
|
||||
APP_FORCE_HTTPS=true
|
||||
SESSION_SECURE_COOKIE=true
|
||||
OCTANE_SERVER=frankenphp
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
/* eslint-env node */
|
||||
require("@rushstack/eslint-patch/modern-module-resolution")
|
||||
|
||||
module.exports = {
|
||||
extends: ['plugin:vue/vue3-essential', '@vue/eslint-config-typescript/recommended', '@vue/eslint-config-prettier'],
|
||||
rules: {
|
||||
'vue/multi-word-component-names': 'off',
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"unused-imports/no-unused-imports": "error",
|
||||
"unused-imports/no-unused-vars": "error",
|
||||
},
|
||||
plugins: ['unused-imports'],
|
||||
}
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: solidtime-io
|
||||
47
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
Normal file
47
.github/ISSUE_TEMPLATE/1_bug_report.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
name: Bug Report
|
||||
description: "Report a bug"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Before creating a new bug report, please check that there isn't already a similar issue.
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: "Steps To Reproduce"
|
||||
description: How do you trigger this bug? Please walk us through it step by step.
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: "Self-hosted or Cloud?"
|
||||
options:
|
||||
- Self-Hosted
|
||||
- solidtime Cloud
|
||||
- Both
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: "Version of solidtime: (for self-hosted)"
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
attributes:
|
||||
label: "solidtime self-hosting guide: (for self-hosted)"
|
||||
description: "Did you use the official guide to self-host solidtime? If yes, which one?"
|
||||
validations:
|
||||
required: false
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 🚀 Feature Request
|
||||
url: https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests
|
||||
about: Share ideas for new features
|
||||
- name: ❓ Ask a Question
|
||||
url: https://github.com/solidtime-io/solidtime/discussions/new?category=general
|
||||
about: Ask the community for help
|
||||
11
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
11
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
## What does this PR do?
|
||||
|
||||
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||
|
||||
- Fixes #XXXX (GitHub issue number)
|
||||
|
||||
## Checklist (DO NOT REMOVE)
|
||||
|
||||
- [ ] I read the [contributing guide](https://github.com/solidtime-io/solidtime/blob/main/CONTRIBUTING.md)
|
||||
- [ ] I signed the [Contributor License Agreement](https://cla-assistant.io/solidtime-io/solidtime).
|
||||
- [ ] I commented my code, particularly in hard-to-understand areas
|
||||
216
.github/workflows/build-onpremise.yml
vendored
Normal file
216
.github/workflows/build-onpremise.yml
vendored
Normal file
@@ -0,0 +1,216 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-onpremise.yml'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
DOCKER_REPO: registry.on-premise.solidtime.io/solidtime/solidtime
|
||||
|
||||
name: Build - On Premise
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- runs-on: "ubuntu-24.04-arm"
|
||||
platform: "linux/arm64"
|
||||
- runs-on: "ubuntu-24.04"
|
||||
platform: "linux/amd64"
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||
|
||||
- name: "Get build"
|
||||
id: release-build
|
||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Get Previous tag (normal push)"
|
||||
id: previoustag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
prefix: "v"
|
||||
|
||||
- name: "Get version"
|
||||
id: release-version
|
||||
run: |
|
||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ERROR: No previous tag found";
|
||||
exit 1;
|
||||
fi
|
||||
else
|
||||
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: "Copy .env template for production"
|
||||
run: |
|
||||
cp .env.production .env
|
||||
rm .env.production .env.ci .env.example
|
||||
|
||||
- name: "Add version to .env"
|
||||
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
|
||||
|
||||
- name: "Add build to .env"
|
||||
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
|
||||
|
||||
- name: "Output .env"
|
||||
run: cat .env
|
||||
|
||||
- name: "Setup PHP with PECL extension"
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: mbstring, dom, fileinfo, pgsql
|
||||
|
||||
- name: "Install dependencies"
|
||||
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: "Checkout invoicing extension"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: solidtime-io/extension-invoicing
|
||||
path: extensions/Invoicing
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||
|
||||
- name: "Install composer dependencies in invoicing extension"
|
||||
run: cd extensions/Invoicing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||
|
||||
- name: "Install npm dependencies in invoicing extension"
|
||||
run: cd extensions/Invoicing && npm ci
|
||||
|
||||
- name: "Activate invoicing extension"
|
||||
run: php artisan module:enable Invoicing
|
||||
|
||||
- name: "Install npm dependencies"
|
||||
run: npm ci
|
||||
|
||||
- name: "Build"
|
||||
run: npm run build
|
||||
|
||||
- name: "Prepare"
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: "Docker meta"
|
||||
id: "meta"
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_REPO }}
|
||||
|
||||
- name: "Login to solidtime OnPremise Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.on-premise.solidtime.io
|
||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||
|
||||
- name: "Set up QEMU"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: "Build and push by digest"
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/prod/Dockerfile
|
||||
build-args: |
|
||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: "Export digest"
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: "Upload digest"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: "Download digests"
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: "Login to solidtime OnPremise Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: registry.on-premise.solidtime.io
|
||||
username: ${{ secrets.ONPREMISE_USERNAME }}
|
||||
password: ${{ secrets.ONPREMISE_TOKEN }}
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: "Docker meta"
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_REPO }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: "Create manifest list and push"
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
|
||||
|
||||
- name: "Inspect image"
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.DOCKER_REPO }}:${{ steps.meta.outputs.version }}
|
||||
76
.github/workflows/build-private.yml
vendored
76
.github/workflows/build-private.yml
vendored
@@ -10,25 +10,68 @@ on:
|
||||
- '.github/workflows/build-private.yml'
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
name: Build - Private
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 20
|
||||
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||
|
||||
- name: "Get build"
|
||||
id: build
|
||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Get Previous tag (normal push)"
|
||||
id: previoustag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
prefix: "v"
|
||||
|
||||
- name: "Get version"
|
||||
id: version
|
||||
run: |
|
||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ERROR: No previous tag found";
|
||||
exit 1;
|
||||
fi
|
||||
else
|
||||
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: "Copy .env template for production"
|
||||
run: |
|
||||
cp .env.production .env
|
||||
rm .env.production .env.ci .env.example
|
||||
|
||||
- name: "Add version to .env"
|
||||
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
|
||||
|
||||
- name: "Add build to .env"
|
||||
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
|
||||
|
||||
- name: "Output .env"
|
||||
run: cat .env
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: "Copy .env template for production"
|
||||
run: cp .env.production .env && cat .env
|
||||
|
||||
- name: "Checkout billing extension"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -67,6 +110,24 @@ jobs:
|
||||
- name: "Install npm dependencies in services extension"
|
||||
run: cd extensions/Services && npm ci
|
||||
|
||||
- name: "Checkout invoicing extension"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: solidtime-io/extension-invoicing
|
||||
path: extensions/Invoicing
|
||||
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
|
||||
|
||||
- name: "Install composer dependencies in invoicing extension"
|
||||
uses: php-actions/composer@v6
|
||||
with:
|
||||
working_dir: "extensions/Invoicing"
|
||||
command: install
|
||||
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||
php_version: 8.3
|
||||
|
||||
- name: "Install npm dependencies in invoicing extension"
|
||||
run: cd extensions/Invoicing && npm ci
|
||||
|
||||
- name: "Setup PHP with PECL extension"
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
@@ -87,6 +148,9 @@ jobs:
|
||||
- name: "Activate services extension"
|
||||
run: php artisan module:enable Services
|
||||
|
||||
- name: "Activate invoicing extension"
|
||||
run: php artisan module:enable Invoicing
|
||||
|
||||
- name: "Install npm dependencies"
|
||||
run: npm ci
|
||||
|
||||
@@ -114,6 +178,9 @@ jobs:
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha,format=long
|
||||
|
||||
- name: "Set up QEMU"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -125,6 +192,7 @@ jobs:
|
||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||
file: docker/prod/Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
|
||||
186
.github/workflows/build-public.yml
vendored
186
.github/workflows/build-public.yml
vendored
@@ -11,26 +11,85 @@ on:
|
||||
- 'docker/prod/**'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
DOCKERHUB_REPO: solidtime/solidtime
|
||||
GHCR_REPO: ghcr.io/solidtime-io/solidtime
|
||||
|
||||
name: Build - Public
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- runs-on: "ubuntu-24.04-arm"
|
||||
platform: "linux/arm64"
|
||||
- runs-on: "ubuntu-24.04"
|
||||
platform: "linux/amd64"
|
||||
runs-on: ${{ matrix.runs-on }}
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: "Check out code"
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
|
||||
|
||||
- name: "Get build"
|
||||
id: release-build
|
||||
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "Get Previous tag (normal push)"
|
||||
id: previoustag
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
uses: "WyriHaximus/github-action-get-previous-tag@v1"
|
||||
with:
|
||||
prefix: "v"
|
||||
|
||||
- name: "Get version"
|
||||
id: release-version
|
||||
run: |
|
||||
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
|
||||
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
|
||||
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
|
||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ERROR: No previous tag found";
|
||||
exit 1;
|
||||
fi
|
||||
else
|
||||
version=$(echo "${{ github.ref }}" | cut -c 12-)
|
||||
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: "Copy .env template for production"
|
||||
run: cp .env.production .env
|
||||
run: |
|
||||
cp .env.production .env
|
||||
rm .env.production .env.ci .env.example
|
||||
|
||||
- name: "Add version to .env"
|
||||
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.release-version.outputs.app_version }}/g' .env
|
||||
|
||||
- name: "Add build to .env"
|
||||
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
|
||||
|
||||
- name: "Output .env"
|
||||
run: cat .env
|
||||
|
||||
- name: "Setup PHP with PECL extension"
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: mbstring, dom, fileinfo, pgsql
|
||||
|
||||
- name: "Install dependencies"
|
||||
uses: php-actions/composer@v6
|
||||
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
|
||||
with:
|
||||
command: install
|
||||
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||
php_version: 8.3
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
@@ -43,36 +102,117 @@ jobs:
|
||||
- name: "Build"
|
||||
run: npm run build
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: "Prepare"
|
||||
run: |
|
||||
platform=${{ matrix.platform }}
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: "Docker meta"
|
||||
id: "meta"
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: solidtime/solidtime
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
images: |
|
||||
${{ env.DOCKERHUB_REPO }}
|
||||
${{ env.GHCR_REPO }}
|
||||
|
||||
- name: "Login to Docker Hub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: "Login to GitHub Container Registry"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: "Set up QEMU"
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: "Build and push"
|
||||
- name: "Build and push by digest"
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/prod/Dockerfile
|
||||
build-args: |
|
||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
outputs: type=image,"name=${{ env.DOCKERHUB_REPO }},${{ env.GHCR_REPO }}",push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: "Export digest"
|
||||
run: |
|
||||
mkdir -p ${{ runner.temp }}/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||
|
||||
- name: "Upload digest"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 90
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: "Download digests"
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: "Login to Docker Hub"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: "Login to GHCR"
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: "Set up Docker Buildx"
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: "Docker meta"
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKERHUB_REPO }}
|
||||
${{ env.GHCR_REPO }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: "Create manifest list and push"
|
||||
working-directory: ${{ runner.temp }}/digests
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.GHCR_REPO }}@sha256:%s ' *)
|
||||
|
||||
- name: "Inspect image"
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.DOCKERHUB_REPO }}:${{ steps.meta.outputs.version }}
|
||||
docker buildx imagetools inspect ${{ env.GHCR_REPO }}:${{ steps.meta.outputs.version }}
|
||||
|
||||
3
.github/workflows/generate-api-docs.yml
vendored
3
.github/workflows/generate-api-docs.yml
vendored
@@ -3,6 +3,9 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api_docs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
.github/workflows/npm-build.yml
vendored
2
.github/workflows/npm-build.yml
vendored
@@ -1,6 +1,8 @@
|
||||
name: NPM Build
|
||||
|
||||
on: [push]
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
23
.github/workflows/npm-format-check.yml
vendored
Normal file
23
.github/workflows/npm-format-check.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: NPM Format Check
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
format-check:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: "Use Node.js"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: "Install npm dependencies"
|
||||
run: npm ci
|
||||
|
||||
- name: "Check code formatting"
|
||||
run: npm run format:check
|
||||
2
.github/workflows/npm-lint.yml
vendored
2
.github/workflows/npm-lint.yml
vendored
@@ -1,6 +1,8 @@
|
||||
name: NPM Lint
|
||||
|
||||
on: [push]
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
32
.github/workflows/npm-publish-api.yml
vendored
Normal file
32
.github/workflows/npm-publish-api.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Publish API package to NPM
|
||||
on:
|
||||
workflow_dispatch
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
# Setup .npmrc file to publish to npm
|
||||
- name: Install root project dependencies
|
||||
run: npm ci
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: ./resources/js/packages/api
|
||||
- name: Build package
|
||||
run: npm run build
|
||||
working-directory: ./resources/js/packages/api
|
||||
- name: Publish Package
|
||||
run: npm publish --provenance --access public
|
||||
working-directory: ./resources/js/packages/api
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
32
.github/workflows/npm-publish-ui.yml
vendored
Normal file
32
.github/workflows/npm-publish-ui.yml
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
name: Publish UI package to NPM
|
||||
on:
|
||||
workflow_dispatch
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
- name: Install root project dependencies
|
||||
run: npm ci
|
||||
- name: Install package dependencies
|
||||
run: npm ci
|
||||
working-directory: ./resources/js/packages/ui
|
||||
- name: Build package
|
||||
run: npm run build
|
||||
working-directory: ./resources/js/packages/ui
|
||||
- name: Publish Package
|
||||
run: npm publish --provenance --access public
|
||||
working-directory: ./resources/js/packages/ui
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
3
.github/workflows/npm-typecheck.yml
vendored
3
.github/workflows/npm-typecheck.yml
vendored
@@ -1,7 +1,8 @@
|
||||
name: NPM Typecheck
|
||||
|
||||
on: [push]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
2
.github/workflows/phpstan.yml
vendored
2
.github/workflows/phpstan.yml
vendored
@@ -1,5 +1,7 @@
|
||||
name: Static code analysis (PHPStan)
|
||||
on: push
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
phpstan:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
19
.github/workflows/phpunit.yml
vendored
19
.github/workflows/phpunit.yml
vendored
@@ -1,13 +1,18 @@
|
||||
name: PHPUnit Tests
|
||||
on: push
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
phpunit:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
postgres_version: [ 15, 16, 17 ]
|
||||
|
||||
services:
|
||||
pgsql_test:
|
||||
image: postgres:15
|
||||
image: postgres:${{ matrix.postgres_version }}
|
||||
env:
|
||||
PGPASSWORD: 'root'
|
||||
POSTGRES_DB: 'laravel'
|
||||
@@ -20,7 +25,15 @@ jobs:
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:8
|
||||
ports:
|
||||
- 3000:3000
|
||||
options: >-
|
||||
--health-cmd "curl --silent --fail http://localhost:3000/health"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
@@ -55,7 +68,7 @@ jobs:
|
||||
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
|
||||
|
||||
- name: "Upload coverage reports to Codecov"
|
||||
uses: codecov/codecov-action@v4.5.0
|
||||
uses: codecov/codecov-action@v5.4.3
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: solidtime-io/solidtime
|
||||
|
||||
4
.github/workflows/pint.yml
vendored
4
.github/workflows/pint.yml
vendored
@@ -1,5 +1,7 @@
|
||||
name: PHP Linting
|
||||
on: push
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
pint:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -10,6 +12,6 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: "Check code style"
|
||||
uses: aglipanci/laravel-pint-action@2.4
|
||||
uses: aglipanci/laravel-pint-action@2.5
|
||||
with:
|
||||
configPath: "pint.json"
|
||||
|
||||
26
.github/workflows/playwright.yml
vendored
26
.github/workflows/playwright.yml
vendored
@@ -1,5 +1,7 @@
|
||||
name: Playwright Tests
|
||||
on: [push]
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -27,45 +29,47 @@ jobs:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- name: "Setup node"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: Setup PHP
|
||||
- name: "Setup PHP"
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
|
||||
coverage: none
|
||||
|
||||
- name: Run composer install
|
||||
- name: "Run composer install"
|
||||
run: composer install -n --prefer-dist
|
||||
|
||||
- name: Prepare Laravel Application
|
||||
- name: "Prepare Laravel Application"
|
||||
run: |
|
||||
cp .env.ci .env
|
||||
php artisan key:generate
|
||||
php artisan migrate --seed
|
||||
php artisan passport:keys
|
||||
php artisan migrate --seed
|
||||
|
||||
- name: Install dependencies
|
||||
- name: "Install dependencies"
|
||||
run: npm ci
|
||||
|
||||
- name: Build Frontend
|
||||
- name: "Build Frontend"
|
||||
run: npm run build
|
||||
|
||||
- name: Run Laravel Server
|
||||
- name: "Run Laravel Server"
|
||||
run: php artisan serve > /dev/null 2>&1 &
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
- name: "Install Playwright Browsers"
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: Run Playwright tests
|
||||
- name: "Run Playwright tests"
|
||||
run: npx playwright test
|
||||
env:
|
||||
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- name: "Upload test results"
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
/.phpunit.cache
|
||||
/node_modules
|
||||
node_modules
|
||||
dist
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
|
||||
27
.prettierignore
Normal file
27
.prettierignore
Normal file
@@ -0,0 +1,27 @@
|
||||
# Ignore build outputs
|
||||
node_modules/
|
||||
vendor/
|
||||
storage/
|
||||
bootstrap/cache/
|
||||
public/build/
|
||||
public/hot/
|
||||
|
||||
# Ignore lock files
|
||||
package-lock.json
|
||||
composer.lock
|
||||
|
||||
# Ignore generated files
|
||||
*.min.js
|
||||
*.min.css
|
||||
|
||||
# Ignore test results
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
# Ignore IDE files
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Ignore OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -3,5 +3,6 @@
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true,
|
||||
"bracketSameLine": true,
|
||||
"quoteProps": "preserve"
|
||||
"quoteProps": "preserve",
|
||||
"printWidth": 100
|
||||
}
|
||||
|
||||
81
CONTRIBUTING.md
Normal file
81
CONTRIBUTING.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Contributing to solidtime
|
||||
|
||||
Contributions are greatly apprecited, please make sure to read the rules and vision for solidtime before contributing.
|
||||
|
||||
## Rules
|
||||
|
||||
### Issues for Bugs, Discussions for Feature requests
|
||||
|
||||
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancement are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
|
||||
|
||||
### Only work on approved issues
|
||||
|
||||
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||
|
||||
### Contributor License Agreement
|
||||
|
||||
You'll also notice that we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t worry - the process is quick and only takes a few clicks.
|
||||
|
||||
We want to be transparent about why we require the CLA and what it means for your contributions and the codebase. That’s why we’ve written a few paragraphs below outlining our plans and vision for solidtime in the **Vision** part of this document.
|
||||
|
||||
### Prevent Duplicate Work
|
||||
|
||||
Before you submit a new PR, make sure that none exists already. If you plan to work on an issue, make sure to let us and others know by commenting on the issue/discussion.
|
||||
|
||||
### Give context
|
||||
|
||||
Tell us what you thinking was behind the decisions you made while drafting the PR. Treat the PR itself as documentation for everyone who wants to go back and understand why certain decisions were made.
|
||||
|
||||
### Summarize your PR
|
||||
|
||||
Please make sure to include a short summary at the top of your PR to make it easy for us to quickly check what the PR is about, without looking at the code changes.
|
||||
|
||||
### Use Github Keywords and Auto-Link Issues
|
||||
|
||||
Use phrases like "Closes #123" or "Fixes #123" in the PR description to link the PR with the issue that you are adressing.
|
||||
|
||||
### Mention what you tested and how
|
||||
|
||||
Explain how you tested and validated the implementation.
|
||||
|
||||
### Keep Naming consistent
|
||||
|
||||
Look at existing code patterns and use naming conventions that already exist in the code base.
|
||||
|
||||
### Testing
|
||||
|
||||
We have an exhaustive test-suite of PHPUnit (Backend) and Playwright (Frontend) testing. Whereever applicable please make sure to write add tests to the codebase.
|
||||
|
||||
### Linting & Formatting
|
||||
|
||||
Make sure to run linting and formatting commands before you commit the changes.
|
||||
|
||||
For backend changes:
|
||||
|
||||
```
|
||||
composer fix
|
||||
composer analyse
|
||||
```
|
||||
|
||||
For frontend changes:
|
||||
|
||||
```
|
||||
npm run lint:fix
|
||||
npm run format
|
||||
```
|
||||
|
||||
## Vision
|
||||
|
||||
We started solidtime to provide an open infrastructure solution for time tracking—one that empowers teams and individuals to fully own their data, instead of depending on proprietary platforms. We believe infrastructure software should be open, accessible, and built to last. However, competing with established market leaders in this space requires long-term financial sustainability.
|
||||
|
||||
solidtime is licensed under the AGPL, which we believe is the best available license to strike a balance between openness and financial viability. The AGPL gives us, as the copyright holders, certain exclusive rights that we plan to leverage to fund development. To ensure we retain those rights across the entire codebase, we've put a CLA in place that contributors must sign before submitting code.
|
||||
|
||||
One of solidtime’s key advantages is that it's built to be self-hostable. This makes it a great solution for organizations like governments, healthcare providers, and enterprises that are required to keep data on their own infrastructure due to regulations or internal policies. These organizations may need custom licenses, integrations, or modifications that aren't suitable for the open-source version. To support them, we offer relicensed versions of solidtime along with support plans.
|
||||
|
||||
We’ll also provide proprietary extensions for solidtime. These will be available to enterprise customers with support plans, but also to individual users or teams who don’t need support, at much more accessible price points. For companies running solidtime on their own infrastructure, this is the easiest way to support the project while gaining additional functionality. While we plan to make it easier to build custom extensions in the future, our current APIs are still highly experimental.
|
||||
|
||||
Finally - and perhaps most importantly - we offer a hosted SaaS version called solidtime Cloud, for users who can’t or don’t want to run the software themselves. This version includes proprietary extensions, always runs the latest commit, and includes monitoring and billing features available exclusively on this hosted instance. We expect solidtime Cloud to play a critical role in funding the project long-term.
|
||||
|
||||
Having full control over the source code’s licensing also gives us the ability to change the license of the main project in the future. That said, we have no plans to do so and would only consider it in extreme cases - for example, if a malicious actor were to directly compete with our hosted service in a way that threatens the sustainability of the project, the legal interpretation of AGPL changes in a way that would make it unreasonable to use for certain companies, or a new similar license gains wide-spread adoption. Regardless, solidtime will always remain free to self-host for individuals and companies who use it as part of their work, and all previous releases will remain licensed under AGPL.
|
||||
|
||||
If you are using the open-source version of solidtime and want to support us, the best way to do so is to spread the word.
|
||||
87
README.md
87
README.md
@@ -13,96 +13,33 @@ solidtime is a modern open-source time tracking application for Freelancers and
|
||||
|
||||
- Time tracking: Track your time with a modern and easy-to-use interface
|
||||
- Projects: Create and manage projects and assign project members
|
||||
- Tasks: Create and manage tasks and assign tasks to project members
|
||||
- Tasks: Create and manage tasks and assign tasks to projects
|
||||
- Clients: Create and manage clients and assign clients to projects
|
||||
- Billable rates: Set billable rates for projects, project members, organization members and organizations
|
||||
- Multiple organizations: Create and manage multiple organizations with one account
|
||||
- Roles and permissions: Create and manage organizations
|
||||
- Import: Import your time tracking data from other time tracking applications (Supported: Toggl, Clockify, Timeentry CSV)
|
||||
|
||||
## Local setup for development
|
||||
## Self Hosting
|
||||
|
||||
**System requirements**
|
||||
* Docker
|
||||
If you are looking into self-hosting solidtime, you can find the guides [here](https://docs.solidtime.io/self-hosting/intro)
|
||||
|
||||
First you need to download or clone the repository f.e. with `git@github.com:solidtime-io/solidtime.git`.
|
||||
We also have an examples repository [here](https://github.com/solidtime-io/self-hosting-examples)
|
||||
|
||||
After that, execute the following commands **inside the project folder**:
|
||||
If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
--pull=always \
|
||||
-v "$(pwd)":/opt \
|
||||
-w /opt \
|
||||
laravelsail/php83-composer:latest \
|
||||
bash -c "composer install --ignore-platform-reqs"
|
||||
## Issues & Feature Requests
|
||||
|
||||
cp .env.example .env
|
||||
|
||||
./vendor/bin/sail up -d
|
||||
|
||||
./vendor/bin/sail artisan key:generate
|
||||
|
||||
./vendor/bin/sail artisan migrate:fresh --seed
|
||||
|
||||
./vendor/bin/sail php artisan passport:install
|
||||
|
||||
./vendor/bin/sail npm install
|
||||
|
||||
./vendor/bin/sail npm run build
|
||||
```
|
||||
|
||||
Make sure to set the APP_PORT and VITE_PORT inside your `.env` file to a port that is not already used by your system.
|
||||
|
||||
By default the application will run on [localhost:8083](http://localhost:8083/)
|
||||
|
||||
### Setup with Reverse Proxy
|
||||
|
||||
**Additional System Requirements**
|
||||
* Traefik 2 Reverse-Proxy (https://github.com/korridor/reverse-proxy-docker-traefik)
|
||||
|
||||
Add the following entry to your `/etc/hosts`
|
||||
|
||||
```
|
||||
127.0.0.1 solidtime.test
|
||||
127.0.0.1 playwright.solidtime.test
|
||||
127.0.0.1 vite.solidtime.test
|
||||
127.0.0.1 mail.solidtime.test
|
||||
```
|
||||
|
||||
### Running E2E Tests
|
||||
|
||||
`./vendor/bin/sail up -d ` will automatically start a Playwright UI server that you can access at `https://playwright.solidtime.test`.
|
||||
Make sure that you use HTTPS otherwise the resources will not be loaded correctly.
|
||||
|
||||
### Recording E2E Tests
|
||||
|
||||
To record E2E tests, you need to install and execute playwright locally (outside the Docker container) using:
|
||||
|
||||
```bash
|
||||
npx playwright install
|
||||
npx playwright codegen solidtime.test
|
||||
```
|
||||
|
||||
### E2E Troubleshooting
|
||||
|
||||
If E2E tests are not working at all, make sure you do not have the Vite server running and just run `npm run build` to update the version.
|
||||
If the E2E tests are not working consistently and fail with a timeout during the authentication, you might want to delete the `test-results/.auth` directory to force new test accounts to be created.
|
||||
|
||||
### Generate ZOD Client
|
||||
|
||||
The Zodius HTTP client is generated using the following command:
|
||||
|
||||
```bash
|
||||
npm run zod:generate
|
||||
```
|
||||
If you find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug.
|
||||
If you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is in a very early stage. The structure and APIs are still subject to change and not stable.
|
||||
Therefore, we do not currently accept any contributions, unless you are a member of the team.
|
||||
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
|
||||
|
||||
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
||||
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
|
||||
|
||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -4,16 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Enums\Weekday;
|
||||
use App\Events\NewsletterRegistered;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\TimezoneService;
|
||||
use App\Service\UserService;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
@@ -28,12 +26,18 @@ class CreateNewUser implements CreatesNewUsers
|
||||
/**
|
||||
* Create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
* @param array<string, mixed> $input
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
if (! config('app.enable_registration')) {
|
||||
throw ValidationException::withMessages([
|
||||
'email' => [__('Registration is disabled.')],
|
||||
]);
|
||||
}
|
||||
|
||||
Validator::make($input, [
|
||||
'name' => [
|
||||
'required',
|
||||
@@ -43,9 +47,9 @@ class CreateNewUser implements CreatesNewUsers
|
||||
'email' => [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'email:rfc,strict',
|
||||
'max:255',
|
||||
new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder {
|
||||
UniqueEloquent::make(User::class, 'email', function (Builder $builder): Builder {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->where('is_placeholder', '=', false);
|
||||
}),
|
||||
@@ -62,13 +66,21 @@ class CreateNewUser implements CreatesNewUsers
|
||||
if (app(TimezoneService::class)->isValid($input['timezone'])) {
|
||||
$timezone = $input['timezone'];
|
||||
} else {
|
||||
Log::debug('Invalid timezone', ['timezone' => $input['timezone']]);
|
||||
$timezone = app(TimezoneService::class)->mapLegacyTimezone($input['timezone']);
|
||||
if ($timezone === null) {
|
||||
Log::debug('Invalid timezone', ['timezone' => $input['timezone']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
|
||||
|
||||
$startOfWeek = Weekday::Monday;
|
||||
$numberFormat = null;
|
||||
$currencyFormat = null;
|
||||
$dateFormat = null;
|
||||
$intervalFormat = null;
|
||||
$timeFormat = null;
|
||||
$currency = null;
|
||||
if ($ipLookupResponse !== null) {
|
||||
$startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday;
|
||||
@@ -77,30 +89,22 @@ class CreateNewUser implements CreatesNewUsers
|
||||
}
|
||||
$currency = $ipLookupResponse->currency;
|
||||
}
|
||||
|
||||
$user = DB::transaction(function () use ($input, $timezone, $startOfWeek, $currency) {
|
||||
return tap(User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
'timezone' => $timezone ?? 'UTC',
|
||||
'week_start' => $startOfWeek,
|
||||
]), function (User $user) use ($currency): void {
|
||||
$organization = new Organization();
|
||||
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
|
||||
$organization->personal_team = true;
|
||||
$organization->currency = $currency ?? 'EUR';
|
||||
$organization->owner()->associate($user);
|
||||
$organization->save();
|
||||
|
||||
$organization->users()->attach(
|
||||
$user, [
|
||||
'role' => Role::Owner->value,
|
||||
]
|
||||
);
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
});
|
||||
$user = null;
|
||||
DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency, $numberFormat, $currencyFormat, $dateFormat, $intervalFormat, $timeFormat): void {
|
||||
$userService = app(UserService::class);
|
||||
$user = $userService->createUser(
|
||||
$input['name'],
|
||||
$input['email'],
|
||||
$input['password'],
|
||||
$timezone ?? 'UTC',
|
||||
$startOfWeek,
|
||||
$currency,
|
||||
$numberFormat,
|
||||
$currencyFormat,
|
||||
$dateFormat,
|
||||
$intervalFormat,
|
||||
$timeFormat
|
||||
);
|
||||
});
|
||||
|
||||
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];
|
||||
|
||||
@@ -6,7 +6,6 @@ namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -35,7 +34,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
'required',
|
||||
'email',
|
||||
'max:255',
|
||||
(new UniqueEloquent(User::class, 'email'))->ignore($user->id)->query(function (Builder $query) {
|
||||
UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', false);
|
||||
}),
|
||||
@@ -59,8 +58,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
$user->updateProfilePhoto($input['photo']);
|
||||
}
|
||||
|
||||
if ($input['email'] !== $user->email &&
|
||||
$user instanceof MustVerifyEmail) {
|
||||
if ($input['email'] !== $user->email) {
|
||||
$user->forceFill([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
|
||||
@@ -7,18 +7,16 @@ namespace App\Actions\Jetstream;
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\MemberService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\In;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
use Laravel\Jetstream\Contracts\AddsTeamMembers;
|
||||
use Laravel\Jetstream\Events\AddingTeamMember;
|
||||
use Laravel\Jetstream\Events\TeamMemberAdded;
|
||||
|
||||
class AddOrganizationMember implements AddsTeamMembers
|
||||
{
|
||||
@@ -36,15 +34,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
->where('is_placeholder', '=', false)
|
||||
->firstOrFail();
|
||||
|
||||
AddingTeamMember::dispatch($organization, $newOrganizationMember);
|
||||
|
||||
DB::transaction(function () use ($organization, $newOrganizationMember, $role) {
|
||||
$organization->users()->attach(
|
||||
$newOrganizationMember, ['role' => $role]
|
||||
);
|
||||
});
|
||||
|
||||
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
|
||||
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,13 +57,14 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
return array_filter([
|
||||
return [
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
(new ExistsEloquent(User::class, 'email', function (Builder $builder) {
|
||||
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
|
||||
/** @var Builder<User> $builder */
|
||||
return $builder->where('is_placeholder', '=', false);
|
||||
}))->withMessage(__('We were unable to find a registered user with this email address.')),
|
||||
})->withMessage(__('We were unable to find a registered user with this email address.')),
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
@@ -84,7 +75,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
Role::Employee->value,
|
||||
]),
|
||||
],
|
||||
]);
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,7 +83,7 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
*/
|
||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
|
||||
{
|
||||
return function ($validator) use ($team, $email) {
|
||||
return function ($validator) use ($team, $email): void {
|
||||
$validator->errors()->addIf(
|
||||
$team->hasRealUserWithEmail($email),
|
||||
'email',
|
||||
|
||||
@@ -4,15 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\AfterCreateOrganization;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\IpLookup\IpLookupServiceContract;
|
||||
use App\Service\OrganizationService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Contracts\CreatesTeams;
|
||||
use Laravel\Jetstream\Events\AddingTeam;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
|
||||
class CreateOrganization implements CreatesTeams
|
||||
@@ -33,24 +34,26 @@ class CreateOrganization implements CreatesTeams
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
])->validateWithBag('createTeam');
|
||||
|
||||
AddingTeam::dispatch($user);
|
||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
|
||||
|
||||
$organization = new Organization();
|
||||
$organization->name = $input['name'];
|
||||
$organization->personal_team = false;
|
||||
$organization->owner()->associate($user);
|
||||
$organization->save();
|
||||
$currency = null;
|
||||
if ($ipLookupResponse !== null) {
|
||||
$currency = $ipLookupResponse->currency;
|
||||
}
|
||||
|
||||
$organization->users()->attach(
|
||||
$user, [
|
||||
'role' => Role::Owner->value,
|
||||
]
|
||||
$organization = app(OrganizationService::class)->createOrganization(
|
||||
$input['name'],
|
||||
$user,
|
||||
false,
|
||||
$currency
|
||||
);
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
|
||||
$user->switchTeam($organization);
|
||||
|
||||
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
|
||||
$organization = $organization->refresh();
|
||||
AfterCreateOrganization::dispatch($organization);
|
||||
|
||||
return $organization;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ class InviteOrganizationMember implements InvitesTeamMembers
|
||||
*/
|
||||
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
throw new MovedToApiException();
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,6 @@ class RemoveOrganizationMember implements RemovesTeamMembers
|
||||
*/
|
||||
public function remove(User $user, Organization $organization, User $teamMember): void
|
||||
{
|
||||
throw new MovedToApiException();
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,6 @@ class UpdateMemberRole
|
||||
*/
|
||||
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
|
||||
{
|
||||
throw new MovedToApiException();
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class UpdateOrganization implements UpdatesTeamNames
|
||||
'currency' => [
|
||||
'required',
|
||||
'string',
|
||||
new CurrencyRule(),
|
||||
new CurrencyRule,
|
||||
],
|
||||
])->validateWithBag('updateTeamName');
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ class ValidateOrganizationDeletion
|
||||
public function validate(User $user, Organization $organization): void
|
||||
{
|
||||
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
|
||||
throw new AuthorizationException();
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ use App\Service\DeletionService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DeleteOrganizationCommand extends Command
|
||||
class OrganizationDeleteCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'admin:delete-organization
|
||||
protected $signature = 'admin:organization:delete
|
||||
{ organization : The ID of the organization to delete }';
|
||||
|
||||
/**
|
||||
@@ -24,7 +24,7 @@ class DeleteOrganizationCommand extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete a organization.';
|
||||
protected $description = 'Delete a organization';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
92
app/Console/Commands/Admin/UserCreateCommand.php
Normal file
92
app/Console/Commands/Admin/UserCreateCommand.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Admin;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\UserService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use LogicException;
|
||||
|
||||
class UserCreateCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'admin:user:create
|
||||
{ name : The name of the user }
|
||||
{ email : The email of the user }
|
||||
{ --ask-for-password : Ask for the password, otherwise the command will generate a random one }
|
||||
{ --verify-email : Verify the email address of the user }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Create a new user';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$name = $this->argument('name');
|
||||
$email = $this->argument('email');
|
||||
$askForPassword = (bool) $this->option('ask-for-password');
|
||||
$verifyEmail = (bool) $this->option('verify-email');
|
||||
|
||||
if (User::query()->where('email', $email)->where('is_placeholder', '=', false)->exists()) {
|
||||
$this->error('User with email "'.$email.'" already exists.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($askForPassword) {
|
||||
$outputPassword = false;
|
||||
$password = $this->secret('Enter the password');
|
||||
} else {
|
||||
$outputPassword = true;
|
||||
$password = bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
$user = null;
|
||||
DB::transaction(function () use (&$user, $name, $email, $password, $verifyEmail): void {
|
||||
$user = app(UserService::class)->createUser(
|
||||
$name,
|
||||
$email,
|
||||
$password,
|
||||
'UTC',
|
||||
Weekday::Monday,
|
||||
null,
|
||||
verifyEmail: $verifyEmail
|
||||
);
|
||||
});
|
||||
/** @var Organization|null $organization */
|
||||
$organization = $user->ownedTeams->first();
|
||||
if ($organization === null) {
|
||||
throw new LogicException('User does not have an organization');
|
||||
}
|
||||
|
||||
$this->info('Created user "'.$name.'" ("'.$email.'")');
|
||||
$this->line('ID: '.$user->getKey());
|
||||
$this->line('Name: '.$name);
|
||||
$this->line('Email: '.$email);
|
||||
if ($outputPassword) {
|
||||
$this->line('Password: '.$password);
|
||||
}
|
||||
$this->line('Timezone: '.$user->timezone);
|
||||
$this->line('Week start: '.$user->week_start->value);
|
||||
|
||||
// Organization
|
||||
$this->line('Currency: '.$organization->currency);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
61
app/Console/Commands/Admin/UserVerifyCommand.php
Normal file
61
app/Console/Commands/Admin/UserVerifyCommand.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Events\Verified;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class UserVerifyCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'admin:user:verify
|
||||
{ email : The email of the user to verify }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Verify the email address of an user';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$email = $this->argument('email');
|
||||
|
||||
$this->info('Start verifying user with email "'.$email.'"');
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = User::query()->where('email', $email)
|
||||
->where('is_placeholder', '=', false)
|
||||
->first();
|
||||
|
||||
if ($user === null) {
|
||||
$this->error('User with email "'.$email.'" not found.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if ($user->hasVerifiedEmail()) {
|
||||
$this->info('User with email "'.$email.'" already verified.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$user->markEmailAsVerified();
|
||||
event(new Verified($user));
|
||||
|
||||
$this->info('User with email "'.$email.'" has been verified.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Auth;
|
||||
|
||||
use App\Mail\AuthApiTokenExpirationReminderMail;
|
||||
use App\Mail\AuthApiTokenExpiredMail;
|
||||
use App\Models\Passport\Token;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class AuthSendReminderForExpiringApiTokensCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'auth:send-mails-expiring-api-tokens '.
|
||||
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Sends emails about expiring API tokens, one week before and when they expired.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
if ($dryRun) {
|
||||
$this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');
|
||||
}
|
||||
|
||||
$this->comment('Sending reminder emails about expiring API tokens...');
|
||||
$sentMails = 0;
|
||||
Token::query()
|
||||
->where('expires_at', '<=', Carbon::now()->addDays(7))
|
||||
->whereNull('reminder_sent_at')
|
||||
->with([
|
||||
'client',
|
||||
'user',
|
||||
])
|
||||
->whereHas('user', function (Builder $query): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', false);
|
||||
})
|
||||
->isApiToken(true)
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
|
||||
/** @var Collection<int, Token> $tokens */
|
||||
foreach ($tokens as $token) {
|
||||
$user = $token->user;
|
||||
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') reminding about API token '.$token->getKey());
|
||||
$sentMails++;
|
||||
if (! $dryRun) {
|
||||
Mail::to($user->email)
|
||||
->queue(new AuthApiTokenExpirationReminderMail($token, $user));
|
||||
$token->reminder_sent_at = Carbon::now();
|
||||
$token->save();
|
||||
}
|
||||
}
|
||||
});
|
||||
$this->comment('Finished sending '.$sentMails.' expiring API token emails...');
|
||||
|
||||
$this->comment('Sent emails about expired API tokens');
|
||||
$sentMails = 0;
|
||||
Token::query()
|
||||
->where('expires_at', '<=', Carbon::now())
|
||||
->whereNull('expired_info_sent_at')
|
||||
->with([
|
||||
'client',
|
||||
'user',
|
||||
])
|
||||
->whereHas('user', function (Builder $query): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', false);
|
||||
})
|
||||
->isApiToken(true)
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
|
||||
/** @var Collection<int, Token> $tokens */
|
||||
foreach ($tokens as $token) {
|
||||
$user = $token->user;
|
||||
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') about expired API token '.$token->getKey());
|
||||
$sentMails++;
|
||||
if (! $dryRun) {
|
||||
Mail::to($user->email)
|
||||
->queue(new AuthApiTokenExpiredMail($token, $user));
|
||||
$token->expired_info_sent_at = Carbon::now();
|
||||
$token->save();
|
||||
}
|
||||
}
|
||||
});
|
||||
$this->comment('Finished sending '.$sentMails.' expired API token emails...');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Correction;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Member;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class CorrectionPlaceholderMembersCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'correction:placeholder-members '.
|
||||
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Sets all members who belong to a placeholder user to role placeholder';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->comment('Sets all members who belong to a placeholder user to role placeholder...');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
if ($dryRun) {
|
||||
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
|
||||
}
|
||||
|
||||
$members = Member::query()
|
||||
->where('role', '!=', Role::Placeholder->value)
|
||||
->whereHas('user', function (Builder $builder): void {
|
||||
/** @var Builder<User> $builder */
|
||||
$builder->where('is_placeholder', '=', true);
|
||||
})
|
||||
->get();
|
||||
foreach ($members as $member) {
|
||||
/** @var Member $member */
|
||||
$member->role = Role::Placeholder->value;
|
||||
if (! $dryRun) {
|
||||
$member->save();
|
||||
}
|
||||
$this->line('Set role of member (id='.$member->getKey().') to placeholder');
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Report;
|
||||
|
||||
use App\Models\Report;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use LogicException;
|
||||
|
||||
class ReportSetExpiredToPrivateCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'report:set-expired-to-private '.
|
||||
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Makes public reports private if the public_until date has passed.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->comment('Makes public reports private if the public_until date has passed...');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
if ($dryRun) {
|
||||
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
|
||||
}
|
||||
|
||||
$resetReports = 0;
|
||||
Report::query()
|
||||
->where('public_until', '<', Carbon::now())
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $reports) use ($dryRun, &$resetReports): void {
|
||||
/** @var Collection<int, Report> $reports */
|
||||
foreach ($reports as $report) {
|
||||
$publicUntil = $report->public_until;
|
||||
if ($publicUntil === null) {
|
||||
throw new LogicException('public_until should not be null');
|
||||
}
|
||||
$this->info('Make report "'.$report->name.'" ('.$report->getKey().') private, expired: '.
|
||||
$publicUntil->toIso8601ZuluString().' ('.$publicUntil->diffForHumans().')');
|
||||
$resetReports++;
|
||||
if (! $dryRun) {
|
||||
$report->is_public = false;
|
||||
$report->share_secret = null;
|
||||
$report->save();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->comment('Finished setting '.$resetReports.' expired reports to private...');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\SelfHost;
|
||||
|
||||
use App\Service\ApiService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class SelfHostCheckForUpdateCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'self-host:check-for-update';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$apiService = app(ApiService::class);
|
||||
|
||||
$latestVersion = $apiService->checkForUpdate();
|
||||
if ($latestVersion === null) {
|
||||
$this->error('Failed to check for update, check the logs for more information.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Note: Cache for 13 hours, because the command runs twice daily (every 12 hours).
|
||||
Cache::put('latest_version', $latestVersion, 60 * 60 * 12);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
123
app/Console/Commands/SelfHost/SelfHostDatabaseConsistency.php
Normal file
123
app/Console/Commands/SelfHost/SelfHostDatabaseConsistency.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\SelfHost;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Database\Query\JoinClause;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class SelfHostDatabaseConsistency extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'self-host:database-consistency';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$hadAProblem = false;
|
||||
|
||||
// Task need to be part of project in time entries
|
||||
$problems = DB::table('time_entries')
|
||||
->select(['time_entries.id as id'])
|
||||
->join('tasks', 'time_entries.task_id', '=', 'tasks.id')
|
||||
->where('tasks.project_id', '!=', DB::raw('time_entries.project_id'))
|
||||
->get();
|
||||
$this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem);
|
||||
|
||||
// Client id is the client id of the project
|
||||
$problems = DB::table('time_entries')
|
||||
->select(['time_entries.id as id'])
|
||||
->join('projects', 'time_entries.project_id', '=', 'projects.id')
|
||||
->where(DB::raw('coalesce(projects.client_id::varchar, \'\')'), '!=', DB::raw('coalesce(time_entries.client_id::varchar, \'\')'))
|
||||
->get();
|
||||
$this->logProblems($problems, 'Time entries have a client that does not match the client of the project', $hadAProblem);
|
||||
|
||||
// Client id can only be not null if the project id is not null
|
||||
$problems = DB::table('time_entries')
|
||||
->select(['time_entries.id as id'])
|
||||
->whereNotNull('client_id')
|
||||
->whereNull('project_id')
|
||||
->get();
|
||||
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
|
||||
|
||||
// Every user needs to be a member of at least one organization
|
||||
$problems = DB::table('users')
|
||||
->select(['users.id as id'])
|
||||
->leftJoin('members', 'users.id', '=', 'members.user_id')
|
||||
->whereNull('members.id')
|
||||
->get();
|
||||
$this->logProblems($problems, 'Users are not member of any organization', $hadAProblem);
|
||||
|
||||
// Every organization needs at least an owner
|
||||
$problems = DB::table('organizations')
|
||||
->select(['organizations.id as id'])
|
||||
->leftJoin('members', function (JoinClause $join): void {
|
||||
$join->on('organizations.id', '=', 'members.organization_id')
|
||||
->where('members.role', '=', 'owner');
|
||||
})
|
||||
->whereNull('members.id')
|
||||
->get();
|
||||
$this->logProblems($problems, 'Organizations without an owner', $hadAProblem);
|
||||
|
||||
// Every member can only have one running time entry
|
||||
$problems = DB::table('time_entries')
|
||||
->select(['user_id as id'])
|
||||
->whereNull('end')
|
||||
->groupBy('user_id')
|
||||
->havingRaw('count(*) > 1')
|
||||
->get(['user_id', DB::raw('count(*) as count')]);
|
||||
$this->logProblems($problems, 'Users with more than one running time entry', $hadAProblem);
|
||||
|
||||
// Users have a current organization that they are not a member of
|
||||
$problems = DB::table('users')
|
||||
->select(['users.id as id'])
|
||||
->whereNotNull('current_team_id')
|
||||
->whereNotIn('current_team_id', function (Builder $query): void {
|
||||
$query->select('organization_id')
|
||||
->from('members')
|
||||
->whereColumn('members.user_id', 'users.id');
|
||||
})->get();
|
||||
$this->logProblems($problems, 'Users have a current organization that they are not a member of', $hadAProblem);
|
||||
|
||||
return $hadAProblem ? self::FAILURE : self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, \stdClass> $problems
|
||||
*/
|
||||
private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void
|
||||
{
|
||||
$message = 'Consistency problem: '.$message;
|
||||
if ($problems->isNotEmpty()) {
|
||||
$ids = $problems->pluck('id');
|
||||
$hadAProblem = true;
|
||||
Log::error($message, [
|
||||
'ids' => $ids,
|
||||
]);
|
||||
|
||||
$error = $message;
|
||||
foreach ($ids as $id) {
|
||||
$error .= "\n - ".$id;
|
||||
}
|
||||
$this->error($error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ class SelfHostGenerateKeysCommand extends Command
|
||||
*/
|
||||
protected $signature = 'self-host:generate-keys
|
||||
{ --length=4096 : The length of the passport private key }
|
||||
{ --multi-line : Whether to output the keys in multiple lines }
|
||||
{ --format=env : The format of the output (env, yaml) }';
|
||||
|
||||
/**
|
||||
@@ -34,6 +35,7 @@ class SelfHostGenerateKeysCommand extends Command
|
||||
{
|
||||
$format = $this->option('format');
|
||||
$key = RSA::createKey((int) $this->option('length'));
|
||||
$multiLine = (bool) $this->option('multi-line');
|
||||
|
||||
$publicKey = (string) $key->getPublicKey();
|
||||
$privateKey = (string) $key;
|
||||
@@ -41,12 +43,17 @@ class SelfHostGenerateKeysCommand extends Command
|
||||
|
||||
if ($format === 'env') {
|
||||
$this->line('APP_KEY="'.$appKey.'"');
|
||||
$this->line('PASSPORT_PRIVATE_KEY="'.$privateKey.'"');
|
||||
$this->line('PASSPORT_PUBLIC_KEY="'.$publicKey.'"');
|
||||
if ($multiLine) {
|
||||
$this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", "\n", $privateKey).'"');
|
||||
$this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", "\n", $publicKey).'"');
|
||||
} else {
|
||||
$this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", '\n', $privateKey).'"');
|
||||
$this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", '\n', $publicKey).'"');
|
||||
}
|
||||
} elseif ($format === 'yaml') {
|
||||
$this->line('APP_KEY: "'.$appKey.'"');
|
||||
$this->line("PASSPORT_PRIVATE_KEY: |\n ".Str::replace("\n", "\n ", $privateKey));
|
||||
$this->line("PASSPORT_PUBLIC_KEY: |\n ".Str::replace("\n", "\n ", $publicKey));
|
||||
$this->line("PASSPORT_PRIVATE_KEY: |\n ".Str::replace("\r\n", "\n ", $privateKey));
|
||||
$this->line("PASSPORT_PUBLIC_KEY: |\n ".Str::replace("\r\n", "\n ", $publicKey));
|
||||
} else {
|
||||
$this->error('Invalid format');
|
||||
|
||||
|
||||
44
app/Console/Commands/SelfHost/SelfHostTelemetryCommand.php
Normal file
44
app/Console/Commands/SelfHost/SelfHostTelemetryCommand.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\SelfHost;
|
||||
|
||||
use App\Service\ApiService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SelfHostTelemetryCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'self-host:telemetry';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = '';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$apiService = app(ApiService::class);
|
||||
|
||||
$success = $apiService->telemetry();
|
||||
|
||||
if (! $success) {
|
||||
$this->error('Failed to send telemetry data, check the logs for more information.');
|
||||
|
||||
return self::FAILURE;
|
||||
|
||||
}
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\TimeEntry;
|
||||
|
||||
use App\Mail\TimeEntryStillRunningMail;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class TimeEntrySendStillRunningMailsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'time-entry:send-still-running-mails '.
|
||||
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Sends emails to users who have running time entries for more than 8 hours.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->comment('Sending still running time entry emails...');
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
if ($dryRun) {
|
||||
$this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');
|
||||
}
|
||||
|
||||
$sentMails = 0;
|
||||
TimeEntry::query()
|
||||
->whereNull('end')
|
||||
->where('start', '<', now()->subHours(8))
|
||||
->whereNull('still_active_email_sent_at')
|
||||
->with([
|
||||
'user',
|
||||
])
|
||||
->whereHas('user', function (Builder $query): void {
|
||||
/** @var Builder<User> $query */
|
||||
$query->where('is_placeholder', '=', false);
|
||||
})
|
||||
->orderBy('created_at', 'asc')
|
||||
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
|
||||
/** @var Collection<int, TimeEntry> $timeEntries */
|
||||
foreach ($timeEntries as $timeEntry) {
|
||||
$user = $timeEntry->user;
|
||||
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') for time entry '.$timeEntry->getKey());
|
||||
$sentMails++;
|
||||
if (! $dryRun) {
|
||||
Mail::to($user->email)
|
||||
->queue(new TimeEntryStillRunningMail($timeEntry, $user));
|
||||
$timeEntry->still_active_email_sent_at = Carbon::now();
|
||||
$timeEntry->save();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->comment('Finished sending '.$sentMails.' still running time entry emails...');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,39 @@ class Kernel extends ConsoleKernel
|
||||
*/
|
||||
protected function schedule(Schedule $schedule): void
|
||||
{
|
||||
// $schedule->command('inspire')->hourly();
|
||||
$schedule->command('time-entry:send-still-running-mails')
|
||||
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
|
||||
->everyTenMinutes();
|
||||
|
||||
$schedule->command('auth:send-mails-expiring-api-tokens')
|
||||
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
|
||||
->everyTenMinutes();
|
||||
|
||||
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {
|
||||
// Convert string to a stable integer for seeding
|
||||
/** @var int $seed Take the first 8 hex chars → 32-bit int */
|
||||
$seed = hexdec(substr(hash('md5', config('app.key')), 0, 8));
|
||||
$seed = abs($seed); // Ensure it's positive
|
||||
mt_srand($seed);
|
||||
$firstHour = mt_rand(0, 23);
|
||||
$secondHour = ($firstHour + 12) % 24;
|
||||
$minuteOffset = mt_rand(0, 59);
|
||||
mt_srand(null); // Reset the random number generator
|
||||
|
||||
if (config('scheduling.tasks.self_hosting_check_for_update')) {
|
||||
$schedule->command('self-host:check-for-update')
|
||||
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
||||
}
|
||||
|
||||
if (config('scheduling.tasks.self_hosting_telemetry')) {
|
||||
$schedule->command('self-host:telemetry')
|
||||
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
|
||||
}
|
||||
}
|
||||
|
||||
$schedule->command('self-host:database-consistency')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
|
||||
->everySixHours();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
36
app/Enums/CurrencyFormat.php
Normal file
36
app/Enums/CurrencyFormat.php
Normal file
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum CurrencyFormat: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case ISOCodeBeforeWithSpace = 'iso-code-before-with-space';
|
||||
case ISOCodeAfterWithSpace = 'iso-code-after-with-space';
|
||||
|
||||
case SymbolBefore = 'symbol-before';
|
||||
|
||||
case SymbolAfter = 'symbol-after';
|
||||
|
||||
case SymbolBeforeWithSpace = 'symbol-before-with-space';
|
||||
|
||||
case SymbolAfterWithSpace = 'symbol-after-with-space';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function toSelectArray(): array
|
||||
{
|
||||
$selectArray = [];
|
||||
foreach (self::values() as $value) {
|
||||
$selectArray[(string) $value] = (string) __('enum.currency_format.'.$value);
|
||||
}
|
||||
|
||||
return $selectArray;
|
||||
}
|
||||
}
|
||||
48
app/Enums/DateFormat.php
Normal file
48
app/Enums/DateFormat.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum DateFormat: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case PointSeparatedDMYYYY = 'point-separated-d-m-yyyy';
|
||||
case SlashSeparatedMMDDYYYY = 'slash-separated-mm-dd-yyyy';
|
||||
|
||||
case SlashSeparatedDDMMYYYY = 'slash-separated-dd-mm-yyyy';
|
||||
|
||||
case HyphenSeparatedDDMMYYY = 'hyphen-separated-dd-mm-yyyy';
|
||||
|
||||
case HyphenSeparatedMMDDDYYYY = 'hyphen-separated-mm-dd-yyyy';
|
||||
|
||||
case HyphenSeparatedYYYYMMDD = 'hyphen-separated-yyyy-mm-dd';
|
||||
|
||||
public function toCarbonFormat(): string
|
||||
{
|
||||
return match ($this->value) {
|
||||
self::PointSeparatedDMYYYY->value => 'j.n.Y',
|
||||
self::SlashSeparatedMMDDYYYY->value => 'm/d/Y',
|
||||
self::SlashSeparatedDDMMYYYY->value => 'd/m/Y',
|
||||
self::HyphenSeparatedDDMMYYY->value => 'd-m-Y',
|
||||
self::HyphenSeparatedMMDDDYYYY->value => 'm-d-Y',
|
||||
self::HyphenSeparatedYYYYMMDD->value => 'Y-m-d',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function toSelectArray(): array
|
||||
{
|
||||
$selectArray = [];
|
||||
foreach (self::values() as $value) {
|
||||
$selectArray[(string) $value] = (string) __('enum.date_format.'.$value);
|
||||
}
|
||||
|
||||
return $selectArray;
|
||||
}
|
||||
}
|
||||
35
app/Enums/ExportFormat.php
Normal file
35
app/Enums/ExportFormat.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Maatwebsite\Excel\Excel;
|
||||
|
||||
enum ExportFormat: string
|
||||
{
|
||||
case CSV = 'csv';
|
||||
case PDF = 'pdf';
|
||||
case XLSX = 'xlsx';
|
||||
case ODS = 'ods';
|
||||
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CSV => 'csv',
|
||||
self::PDF => 'pdf',
|
||||
self::XLSX => 'xlsx',
|
||||
self::ODS => 'ods',
|
||||
};
|
||||
}
|
||||
|
||||
public function getExportPackageType(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::CSV => Excel::CSV,
|
||||
self::PDF => Excel::MPDF,
|
||||
self::XLSX => Excel::XLSX,
|
||||
self::ODS => Excel::ODS,
|
||||
};
|
||||
}
|
||||
}
|
||||
32
app/Enums/IntervalFormat.php
Normal file
32
app/Enums/IntervalFormat.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum IntervalFormat: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Decimal = 'decimal';
|
||||
case HoursMinutes = 'hours-minutes';
|
||||
|
||||
case HoursMinutesColonSeparated = 'hours-minutes-colon-separated';
|
||||
|
||||
case HoursMinutesSecondsColonSeparated = 'hours-minutes-seconds-colon-separated';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function toSelectArray(): array
|
||||
{
|
||||
$selectArray = [];
|
||||
foreach (self::values() as $value) {
|
||||
$selectArray[(string) $value] = (string) __('enum.interval_format.'.$value);
|
||||
}
|
||||
|
||||
return $selectArray;
|
||||
}
|
||||
}
|
||||
37
app/Enums/NumberFormat.php
Normal file
37
app/Enums/NumberFormat.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
/**
|
||||
* @info https://en.wikipedia.org/wiki/Decimal_separator
|
||||
*/
|
||||
enum NumberFormat: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case ThousandsPointDecimalComma = 'point-comma';
|
||||
|
||||
case ThousandsCommaDecimalPoint = 'comma-point';
|
||||
case ThousandsSpaceDecimalComma = 'space-comma';
|
||||
|
||||
case ThousandsSpaceDecimalPoint = 'space-point';
|
||||
|
||||
case ThousandsApostropheDecimalPoint = 'apostrophe-point';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function toSelectArray(): array
|
||||
{
|
||||
$selectArray = [];
|
||||
foreach (self::values() as $value) {
|
||||
$selectArray[(string) $value] = (string) __('enum.number_format.'.$value);
|
||||
}
|
||||
|
||||
return $selectArray;
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,12 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum TimeEntryAggregationType: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Day = 'day';
|
||||
case Week = 'week';
|
||||
case Month = 'month';
|
||||
@@ -15,6 +19,18 @@ enum TimeEntryAggregationType: string
|
||||
case Task = 'task';
|
||||
case Client = 'client';
|
||||
case Billable = 'billable';
|
||||
case Description = 'description';
|
||||
case Tag = 'tag';
|
||||
|
||||
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
|
||||
{
|
||||
return match ($timeEntryAggregationTypeInterval) {
|
||||
TimeEntryAggregationTypeInterval::Day => TimeEntryAggregationType::Day,
|
||||
TimeEntryAggregationTypeInterval::Week => TimeEntryAggregationType::Week,
|
||||
TimeEntryAggregationTypeInterval::Month => TimeEntryAggregationType::Month,
|
||||
TimeEntryAggregationTypeInterval::Year => TimeEntryAggregationType::Year,
|
||||
};
|
||||
}
|
||||
|
||||
public function toInterval(): ?TimeEntryAggregationTypeInterval
|
||||
{
|
||||
|
||||
16
app/Enums/TimeEntryRoundingType.php
Normal file
16
app/Enums/TimeEntryRoundingType.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum TimeEntryRoundingType: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Up = 'up';
|
||||
case Down = 'down';
|
||||
case Nearest = 'nearest';
|
||||
}
|
||||
28
app/Enums/TimeFormat.php
Normal file
28
app/Enums/TimeFormat.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
|
||||
enum TimeFormat: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case TwelveHours = '12-hours';
|
||||
case TwentyFourHours = '24-hours';
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function toSelectArray(): array
|
||||
{
|
||||
$selectArray = [];
|
||||
foreach (self::values() as $value) {
|
||||
$selectArray[(string) $value] = (string) __('enum.time_format.'.$value);
|
||||
}
|
||||
|
||||
return $selectArray;
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,13 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
enum Weekday: string
|
||||
{
|
||||
use LaravelEnumHelper;
|
||||
|
||||
case Monday = 'monday';
|
||||
case Tuesday = 'tuesday';
|
||||
case Wednesday = 'wednesday';
|
||||
|
||||
26
app/Events/AfterCreateOrganization.php
Normal file
26
app/Events/AfterCreateOrganization.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* This event is fired after an organization has been created.
|
||||
* This event does NOT fire when an organization is created as part of a registration.
|
||||
*/
|
||||
class AfterCreateOrganization
|
||||
{
|
||||
use Dispatchable;
|
||||
use SerializesModels;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public function __construct(Organization $organization)
|
||||
{
|
||||
$this->organization = $organization;
|
||||
}
|
||||
}
|
||||
14
app/Events/DatabaseSeederAfterSeed.php
Normal file
14
app/Events/DatabaseSeederAfterSeed.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class DatabaseSeederAfterSeed
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct() {}
|
||||
}
|
||||
14
app/Events/DatabaseSeederBeforeDelete.php
Normal file
14
app/Events/DatabaseSeederBeforeDelete.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class DatabaseSeederBeforeDelete
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct() {}
|
||||
}
|
||||
24
app/Events/MemberMadeToPlaceholder.php
Normal file
24
app/Events/MemberMadeToPlaceholder.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class MemberMadeToPlaceholder
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public Member $member;
|
||||
|
||||
public function __construct(Member $member, Organization $organization)
|
||||
{
|
||||
$this->member = $member;
|
||||
$this->organization = $organization;
|
||||
}
|
||||
}
|
||||
24
app/Events/MemberRemoved.php
Normal file
24
app/Events/MemberRemoved.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class MemberRemoved
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public Member $member;
|
||||
|
||||
public function __construct(Member $member, Organization $organization)
|
||||
{
|
||||
$this->member = $member;
|
||||
$this->organization = $organization;
|
||||
}
|
||||
}
|
||||
10
app/Exceptions/Api/ChangingRoleOfPlaceholderIsNotAllowed.php
Normal file
10
app/Exceptions/Api/ChangingRoleOfPlaceholderIsNotAllowed.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
|
||||
{
|
||||
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class FeatureIsNotAvailableInFreePlanApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'feature_is_not_available_in_free_plan';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class InvitationForTheEmailAlreadyExistsApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'invitation_for_the_email_already_exists';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
|
||||
{
|
||||
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
|
||||
{
|
||||
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
|
||||
}
|
||||
10
app/Exceptions/Api/OverlappingTimeEntryApiException.php
Normal file
10
app/Exceptions/Api/OverlappingTimeEntryApiException.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class OverlappingTimeEntryApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'overlapping_time_entry';
|
||||
}
|
||||
10
app/Exceptions/Api/PdfRendererIsNotConfiguredException.php
Normal file
10
app/Exceptions/Api/PdfRendererIsNotConfiguredException.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class PdfRendererIsNotConfiguredException extends ApiException
|
||||
{
|
||||
public const string KEY = 'pdf_renderer_is_not_configured';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class PersonalAccessClientIsNotConfiguredException extends ApiException
|
||||
{
|
||||
public const string KEY = 'personal_access_client_is_not_configured';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
|
||||
{
|
||||
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->reportable(function (Throwable $e) {
|
||||
$this->reportable(function (Throwable $e): void {
|
||||
//
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Extensions\Auditing\Resolvers;
|
||||
|
||||
use Illuminate\Support\Facades\Request;
|
||||
use OwenIt\Auditing\Contracts\Auditable;
|
||||
use OwenIt\Auditing\Contracts\Resolver;
|
||||
|
||||
class CustomIpAddressResolver implements Resolver
|
||||
{
|
||||
private static function anonymizeIpAddress(string $ipAddress): string
|
||||
{
|
||||
/** @source https://stackoverflow.com/a/48777412 */
|
||||
return preg_replace(
|
||||
['/\.\d*$/', '/[\da-f]*:[\da-f]*$/'],
|
||||
['.0', '0:0'],
|
||||
$ipAddress
|
||||
);
|
||||
}
|
||||
|
||||
public static function resolve(Auditable $auditable): string
|
||||
{
|
||||
$ip = $auditable->preloadedResolverData['ip_address'] ?? Request::ip();
|
||||
|
||||
if ($ip !== null) {
|
||||
$ip = self::anonymizeIpAddress($ip);
|
||||
}
|
||||
|
||||
return $ip;
|
||||
}
|
||||
}
|
||||
@@ -24,20 +24,20 @@ class ApiExceptionTypeToSchema extends ExceptionToResponseExtension
|
||||
|
||||
public function toResponse(Type $type): Response
|
||||
{
|
||||
$validationResponseBodyType = (new OpenApiTypes\ObjectType())
|
||||
$validationResponseBodyType = (new OpenApiTypes\ObjectType)
|
||||
->addProperty(
|
||||
'error',
|
||||
(new OpenApiTypes\BooleanType())
|
||||
(new OpenApiTypes\BooleanType)
|
||||
->setDescription('Whether the response is an error.')
|
||||
)
|
||||
->addProperty(
|
||||
'key',
|
||||
(new OpenApiTypes\StringType())
|
||||
(new OpenApiTypes\StringType)
|
||||
->setDescription('Error key.')
|
||||
)
|
||||
->addProperty(
|
||||
'message',
|
||||
(new OpenApiTypes\StringType())
|
||||
(new OpenApiTypes\StringType)
|
||||
->setDescription('Error message.')
|
||||
)
|
||||
->setRequired(['error', 'key', 'message']);
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Extensions\Scramble;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
|
||||
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
|
||||
use Dedoc\Scramble\Support\Generator\Response;
|
||||
use Dedoc\Scramble\Support\Generator\Schema;
|
||||
@@ -27,13 +28,10 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
|
||||
&& $type->isInstanceOf(PaginatedResourceCollection::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Generic $type
|
||||
*/
|
||||
public function toResponse(Type $type): ?Response
|
||||
public function toSchema(Type $type): ?OpenApiObjectType
|
||||
{
|
||||
/** @var Type|null $collectingClassType */
|
||||
$collectingClassType = $type->templateTypes[0];
|
||||
$collectingClassType = $type->templateTypes[0] ?? null;
|
||||
|
||||
if (! $collectingClassType instanceof ObjectType) {
|
||||
return null;
|
||||
@@ -43,41 +41,64 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! ($collectingType = $this->openApiTransformer->transform($collectingClassType))) {
|
||||
return null;
|
||||
$collectingType = $this->openApiTransformer->transform($collectingClassType);
|
||||
|
||||
$newType = new OpenApiObjectType;
|
||||
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));
|
||||
if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) {
|
||||
$newType->addProperty(
|
||||
'meta',
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
||||
->setRequired(['total'])
|
||||
);
|
||||
$newType->setRequired(['data', 'meta']);
|
||||
} else {
|
||||
$newType->addProperty(
|
||||
'links',
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('first', (new StringType)->nullable(true))
|
||||
->addProperty('last', (new StringType)->nullable(true))
|
||||
->addProperty('prev', (new StringType)->nullable(true))
|
||||
->addProperty('next', (new StringType)->nullable(true))
|
||||
->setRequired(['first', 'last', 'prev', 'next'])
|
||||
);
|
||||
$newType->addProperty(
|
||||
'meta',
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('current_page', new IntegerType)
|
||||
->addProperty('from', (new IntegerType)->nullable(true))
|
||||
->addProperty('last_page', new IntegerType)
|
||||
->addProperty('links', (new ArrayType)->setItems(
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('url', (new StringType)->nullable(true))
|
||||
->addProperty('label', new StringType)
|
||||
->addProperty('active', new BooleanType)
|
||||
->setRequired(['url', 'label', 'active'])
|
||||
)->setDescription('Generated paginator links.'))
|
||||
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
|
||||
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
|
||||
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
|
||||
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
||||
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
|
||||
);
|
||||
$newType->setRequired(['data', 'links', 'meta']);
|
||||
}
|
||||
|
||||
$type = new OpenApiObjectType;
|
||||
$type->addProperty('data', (new ArrayType())->setItems($collectingType));
|
||||
$type->addProperty(
|
||||
'links',
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('first', (new StringType)->nullable(true))
|
||||
->addProperty('last', (new StringType)->nullable(true))
|
||||
->addProperty('prev', (new StringType)->nullable(true))
|
||||
->addProperty('next', (new StringType)->nullable(true))
|
||||
->setRequired(['first', 'last', 'prev', 'next'])
|
||||
);
|
||||
$type->addProperty(
|
||||
'meta',
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('current_page', new IntegerType)
|
||||
->addProperty('from', (new IntegerType)->nullable(true))
|
||||
->addProperty('last_page', new IntegerType)
|
||||
->addProperty('links', (new ArrayType)->setItems(
|
||||
(new OpenApiObjectType)
|
||||
->addProperty('url', (new StringType)->nullable(true))
|
||||
->addProperty('label', new StringType)
|
||||
->addProperty('active', new BooleanType)
|
||||
->setRequired(['url', 'label', 'active'])
|
||||
)->setDescription('Generated paginator links.'))
|
||||
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
|
||||
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
|
||||
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
|
||||
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
|
||||
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
|
||||
);
|
||||
$type->setRequired(['data', 'links', 'meta']);
|
||||
return $newType;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Generic $type
|
||||
*/
|
||||
public function toResponse(Type $type): ?Response
|
||||
{
|
||||
/** @var ObjectType|null $collectingClassType */
|
||||
$collectingClassType = $type->templateTypes[0] ?? null;
|
||||
if (! $collectingClassType instanceof ObjectType) {
|
||||
return null;
|
||||
}
|
||||
$type = $this->toSchema($type);
|
||||
|
||||
return Response::make(200)
|
||||
->description('Paginated set of `'.$this->components->uniqueSchemaName($collectingClassType->name).'`')
|
||||
|
||||
95
app/Filament/Resources/AuditResource.php
Normal file
95
app/Filament/Resources/AuditResource.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\AuditResource\Pages;
|
||||
use App\Models\Audit;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Str;
|
||||
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
||||
|
||||
class AuditResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Audit::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-archive-box';
|
||||
|
||||
protected static ?string $navigationGroup = 'System';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('user_type')
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('user_id'),
|
||||
Forms\Components\TextInput::make('event')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('auditable_type')
|
||||
->required()
|
||||
->maxLength(255),
|
||||
Forms\Components\TextInput::make('auditable_id')
|
||||
->required(),
|
||||
PrettyJsonField::make('old_values'),
|
||||
PrettyJsonField::make('new_values'),
|
||||
Forms\Components\Textarea::make('url'),
|
||||
Forms\Components\TextInput::make('ip_address'),
|
||||
Forms\Components\TextInput::make('user_agent')
|
||||
->maxLength(1023),
|
||||
Forms\Components\TextInput::make('tags')
|
||||
->maxLength(255),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('user.name'),
|
||||
Tables\Columns\TextColumn::make('event'),
|
||||
Tables\Columns\TextColumn::make('auditable_type'),
|
||||
Tables\Columns\TextColumn::make('auditable_id'),
|
||||
IconColumn::make('was_command')
|
||||
->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))
|
||||
->boolean(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->sortable()
|
||||
->dateTime(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->sortable()
|
||||
->dateTime(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
])
|
||||
->defaultSort('created_at', 'desc');
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListAudits::route('/'),
|
||||
'create' => Pages\CreateAudit::route('/create'),
|
||||
'view' => Pages\ViewAudit::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
13
app/Filament/Resources/AuditResource/Pages/CreateAudit.php
Normal file
13
app/Filament/Resources/AuditResource/Pages/CreateAudit.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AuditResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AuditResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateAudit extends CreateRecord
|
||||
{
|
||||
protected static string $resource = AuditResource::class;
|
||||
}
|
||||
18
app/Filament/Resources/AuditResource/Pages/ListAudits.php
Normal file
18
app/Filament/Resources/AuditResource/Pages/ListAudits.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AuditResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AuditResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListAudits extends ListRecords
|
||||
{
|
||||
protected static string $resource = AuditResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
13
app/Filament/Resources/AuditResource/Pages/ViewAudit.php
Normal file
13
app/Filament/Resources/AuditResource/Pages/ViewAudit.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\AuditResource\Pages;
|
||||
|
||||
use App\Filament\Resources\AuditResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewAudit extends ViewRecord
|
||||
{
|
||||
protected static string $resource = AuditResource::class;
|
||||
}
|
||||
@@ -60,8 +60,13 @@ class ClientResource extends Resource
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('organization')
|
||||
->label('Organization')
|
||||
->relationship('organization', 'name')
|
||||
->searchable(),
|
||||
SelectFilter::make('organization_id')
|
||||
->label('Organization ID')
|
||||
->relationship('organization', 'id')
|
||||
->searchable(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
|
||||
@@ -15,7 +15,8 @@ class EditClient extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\DeleteAction::make()
|
||||
->icon('heroicon-m-trash'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class ListClients extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
Actions\CreateAction::make()
|
||||
->icon('heroicon-s-plus'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
118
app/Filament/Resources/FailedJobResource.php
Normal file
118
app/Filament/Resources/FailedJobResource.php
Normal file
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\FailedJobResource\Pages\ListFailedJobs;
|
||||
use App\Filament\Resources\FailedJobResource\Pages\ViewFailedJobs;
|
||||
use App\Models\FailedJob;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\BulkAction;
|
||||
use Filament\Tables\Actions\DeleteAction;
|
||||
use Filament\Tables\Actions\DeleteBulkAction;
|
||||
use Filament\Tables\Actions\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
|
||||
|
||||
/**
|
||||
* @source https://gitlab.com/amvisor/filament-failed-jobs
|
||||
*/
|
||||
class FailedJobResource extends Resource
|
||||
{
|
||||
protected static ?string $model = FailedJob::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-exclamation-circle';
|
||||
|
||||
protected static ?string $navigationGroup = 'System';
|
||||
|
||||
public static function getNavigationBadge(): ?string
|
||||
{
|
||||
return (string) FailedJob::query()->count();
|
||||
}
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
TextInput::make('uuid')->disabled()->columnSpan(4),
|
||||
TextInput::make('failed_at')->disabled(),
|
||||
TextInput::make('id')->disabled(),
|
||||
TextInput::make('connection')->disabled(),
|
||||
TextInput::make('queue')->disabled(),
|
||||
|
||||
// make text a little bit smaller because often a complete Stack Trace is shown:
|
||||
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
|
||||
PrettyJsonField::make('payload')->disabled()->columnSpan(4),
|
||||
])->columns(4);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->columns([
|
||||
TextColumn::make('id')->sortable()->searchable()->toggleable(),
|
||||
TextColumn::make('failed_at')->sortable()->searchable(false)->toggleable(),
|
||||
TextColumn::make('exception')
|
||||
->sortable()
|
||||
->searchable()
|
||||
->toggleable()
|
||||
->wrap()
|
||||
->limit(200)
|
||||
->tooltip(fn (FailedJob $record) => "{$record->failed_at} UUID: {$record->uuid}; Connection: {$record->connection}; Queue: {$record->queue};"),
|
||||
TextColumn::make('uuid')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('connection')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
|
||||
TextColumn::make('queue')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([])
|
||||
->bulkActions([
|
||||
BulkAction::make('retry')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->label('Retry selected')
|
||||
->requiresConfirmation()
|
||||
->action(function (Collection $records): void {
|
||||
/** @var FailedJob $record */
|
||||
foreach ($records as $record) {
|
||||
Artisan::call("queue:retry {$record->uuid}");
|
||||
}
|
||||
Notification::make()
|
||||
->title("{$records->count()} jobs have been pushed back onto the queue.")
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
DeleteBulkAction::make(),
|
||||
])
|
||||
->actions([
|
||||
DeleteAction::make(),
|
||||
ViewAction::make(),
|
||||
Action::make('retry')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->label('Retry')
|
||||
->requiresConfirmation()
|
||||
->action(function (FailedJob $record): void {
|
||||
Artisan::call("queue:retry {$record->uuid}");
|
||||
Notification::make()
|
||||
->title("The job with uuid '{$record->uuid}' has been pushed back onto the queue.")
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListFailedJobs::route('/'),
|
||||
'view' => ViewFailedJobs::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\FailedJobResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FailedJobResource;
|
||||
use App\Models\FailedJob;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
class ListFailedJobs extends ListRecords
|
||||
{
|
||||
protected static string $resource = FailedJobResource::class;
|
||||
|
||||
public function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('retry_all')
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->label('Retry all')
|
||||
->requiresConfirmation()
|
||||
->action(function (): void {
|
||||
Artisan::call('queue:retry all');
|
||||
Notification::make()
|
||||
->title('All failed jobs have been pushed back onto the queue.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
|
||||
Action::make('delete_all')
|
||||
->icon('heroicon-o-trash')
|
||||
->label('Delete all')
|
||||
->requiresConfirmation()
|
||||
->color('danger')
|
||||
->action(function (): void {
|
||||
FailedJob::truncate();
|
||||
Notification::make()
|
||||
->title('All failed jobs have been removed.')
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\FailedJobResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FailedJobResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewFailedJobs extends ViewRecord
|
||||
{
|
||||
protected static string $resource = FailedJobResource::class;
|
||||
}
|
||||
114
app/Filament/Resources/OrganizationInvitationResource.php
Normal file
114
app/Filament/Resources/OrganizationInvitationResource.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Filament\Resources\OrganizationInvitationResource\Pages;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Service\OrganizationInvitationService;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class OrganizationInvitationResource extends Resource
|
||||
{
|
||||
protected static ?string $model = OrganizationInvitation::class;
|
||||
|
||||
protected static ?string $label = 'Invitations';
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-user-plus';
|
||||
|
||||
protected static ?string $navigationGroup = 'Users';
|
||||
|
||||
protected static ?int $navigationSort = 9;
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->columns(1)
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('email')
|
||||
->label('Email')
|
||||
->disabledOn(['edit'])
|
||||
->required(),
|
||||
Select::make('role')
|
||||
->options(Role::class),
|
||||
Forms\Components\Select::make('organization_id')
|
||||
->label('Organization')
|
||||
->relationship(name: 'organization', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
->disabledOn(['edit'])
|
||||
->required(),
|
||||
Forms\Components\DateTimePicker::make('created_at')
|
||||
->label('Created At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
Forms\Components\DateTimePicker::make('updated_at')
|
||||
->label('Updated At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('organization.name')
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('email')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('role'),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Created At')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->label('Updated At')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\BulkAction::make('resend')
|
||||
->label('Resend')
|
||||
->action(function (Collection $records): void {
|
||||
foreach ($records as $organizationInvite) {
|
||||
app(OrganizationInvitationService::class)->resend($organizationInvite);
|
||||
}
|
||||
}),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListOrganizationInvitations::route('/'),
|
||||
'edit' => Pages\EditOrganizationInvitation::route('/{record}/edit'),
|
||||
'view' => Pages\ViewOrganizationInvitation::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OrganizationInvitationResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditOrganizationInvitation extends EditRecord
|
||||
{
|
||||
protected static string $resource = OrganizationInvitationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make()
|
||||
->icon('heroicon-m-trash'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OrganizationInvitationResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListOrganizationInvitations extends ListRecords
|
||||
{
|
||||
protected static string $resource = OrganizationInvitationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OrganizationInvitationResource;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewOrganizationInvitation extends ViewRecord
|
||||
{
|
||||
protected static string $resource = OrganizationInvitationResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
EditAction::make('edit')
|
||||
->icon('heroicon-s-pencil'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,17 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Enums\CurrencyFormat;
|
||||
use App\Enums\DateFormat;
|
||||
use App\Enums\IntervalFormat;
|
||||
use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use App\Filament\Resources\OrganizationResource\Pages;
|
||||
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
|
||||
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
|
||||
use App\Models\Organization;
|
||||
use App\Service\DeletionService;
|
||||
use App\Service\Export\ExportService;
|
||||
use App\Service\Import\Importers\ImporterProvider;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\ReportDto;
|
||||
@@ -45,10 +53,28 @@ class OrganizationResource extends Resource
|
||||
->maxLength(255),
|
||||
Forms\Components\Toggle::make('personal_team')
|
||||
->label('Is personal?')
|
||||
->hiddenOn(['create'])
|
||||
->required(),
|
||||
Forms\Components\Select::make('user_id')
|
||||
->label('Owner')
|
||||
->relationship(name: 'owner', titleAttribute: 'email')
|
||||
->searchable(['name', 'email'])
|
||||
->disabledOn(['edit'])
|
||||
->required(),
|
||||
Select::make('date_format')
|
||||
->options(DateFormat::toSelectArray())
|
||||
->required(),
|
||||
Select::make('currency_format')
|
||||
->options(CurrencyFormat::toSelectArray())
|
||||
->required(),
|
||||
Select::make('interval_format')
|
||||
->options(IntervalFormat::toSelectArray())
|
||||
->required(),
|
||||
Select::make('number_format')
|
||||
->options(NumberFormat::toSelectArray())
|
||||
->required(),
|
||||
Select::make('time_format')
|
||||
->options(TimeFormat::toSelectArray())
|
||||
->required(),
|
||||
Forms\Components\Select::make('currency')
|
||||
->label('Currency')
|
||||
@@ -61,6 +87,7 @@ class OrganizationResource extends Resource
|
||||
|
||||
return $select;
|
||||
})
|
||||
->required()
|
||||
->searchable(),
|
||||
Forms\Components\TextInput::make('billable_rate')
|
||||
->label('Billable rate (in Cents)')
|
||||
@@ -69,13 +96,16 @@ class OrganizationResource extends Resource
|
||||
'nullable',
|
||||
'integer',
|
||||
'gt:0',
|
||||
'max:2147483647',
|
||||
])
|
||||
->numeric(),
|
||||
Forms\Components\DateTimePicker::make('created_at')
|
||||
->label('Created At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
Forms\Components\DateTimePicker::make('updated_at')
|
||||
->label('Updated At')
|
||||
->hiddenOn(['create'])
|
||||
->disabled(),
|
||||
]);
|
||||
}
|
||||
@@ -95,7 +125,7 @@ class OrganizationResource extends Resource
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('currency'),
|
||||
TextColumn::make('billable_rate')
|
||||
->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100),
|
||||
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
@@ -110,9 +140,37 @@ class OrganizationResource extends Resource
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DeleteAction::make()
|
||||
->using(function (Organization $record): void {
|
||||
app(DeletionService::class)->deleteOrganization($record);
|
||||
}),
|
||||
Action::make('Export')
|
||||
->icon('heroicon-o-arrow-down-tray')
|
||||
->action(function (Organization $record) {
|
||||
try {
|
||||
$file = app(ExportService::class)->export($record);
|
||||
Notification::make()
|
||||
->title('Export successful')
|
||||
->success()
|
||||
->persistent()
|
||||
->send();
|
||||
|
||||
return response()->streamDownload(function () use ($file): void {
|
||||
echo Storage::disk(config('filesystems.private'))->get($file);
|
||||
}, 'export.zip');
|
||||
} catch (\Exception $exception) {
|
||||
report($exception);
|
||||
Notification::make()
|
||||
->title('Export failed')
|
||||
->danger()
|
||||
->body('Message: '.$exception->getMessage())
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
Action::make('Import')
|
||||
->icon('heroicon-o-inbox-arrow-down')
|
||||
->action(function (Organization $record, array $data) {
|
||||
->action(function (Organization $record, array $data): void {
|
||||
try {
|
||||
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
|
||||
if ($file === null) {
|
||||
@@ -173,8 +231,6 @@ class OrganizationResource extends Resource
|
||||
]),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -182,6 +238,7 @@ class OrganizationResource extends Resource
|
||||
{
|
||||
return [
|
||||
UsersRelationManager::class,
|
||||
InvitationsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ class DeleteOrganization extends DeleteAction
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
// TODO: check why setting the icon is necessary
|
||||
$this->icon('heroicon-m-trash');
|
||||
$this->action(function (): void {
|
||||
$result = $this->process(function (Organization $record): bool {
|
||||
|
||||
@@ -4,10 +4,33 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\OrganizationResource\Pages;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Filament\Resources\OrganizationResource;
|
||||
use App\Models\Organization;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateOrganization extends CreateRecord
|
||||
{
|
||||
protected static string $resource = OrganizationResource::class;
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
$data['personal_team'] = false;
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
protected function afterCreate(): void
|
||||
{
|
||||
/** @var Organization $organization */
|
||||
$organization = $this->record;
|
||||
|
||||
$user = $organization->owner;
|
||||
|
||||
$organization->users()->attach(
|
||||
$user, [
|
||||
'role' => Role::Owner->value,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class ListOrganizations extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
Actions\CreateAction::make()
|
||||
->icon('heroicon-s-plus'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\OrganizationResource\RelationManagers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Filament\Resources\OrganizationInvitationResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Service\InvitationService;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class InvitationsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'teamInvitations';
|
||||
|
||||
protected static ?string $title = 'Invitations';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
TextInput::make('email')
|
||||
->label('Email')
|
||||
->disabledOn(['edit'])
|
||||
->required(),
|
||||
Select::make('role')
|
||||
->options(Role::class)
|
||||
->label('Role')
|
||||
->rules([
|
||||
'required',
|
||||
'string',
|
||||
Rule::enum(Role::class)
|
||||
->except([Role::Owner, Role::Placeholder]),
|
||||
])
|
||||
->required(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('email')
|
||||
->modelLabel('Invitation')
|
||||
->pluralModelLabel('Invitations')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('email'),
|
||||
Tables\Columns\TextColumn::make('role'),
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\CreateAction::make()
|
||||
->icon('heroicon-s-plus')
|
||||
->using(function (array $data, string $model): Model {
|
||||
/** @var Organization $ownerRecord */
|
||||
$ownerRecord = $this->getOwnerRecord();
|
||||
|
||||
return app(InvitationService::class)
|
||||
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Action::make('view')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->url(fn (OrganizationInvitation $record): string => OrganizationInvitationResource::getUrl('view', [
|
||||
'record' => $record->getKey(),
|
||||
])),
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DeleteAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DetachBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,17 +5,24 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\OrganizationResource\RelationManagers;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\ApiException;
|
||||
use App\Filament\Resources\UserResource;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\MemberService;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Actions\AttachAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UsersRelationManager extends RelationManager
|
||||
{
|
||||
@@ -36,20 +43,40 @@ class UsersRelationManager extends RelationManager
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
/** @var Organization $organization */
|
||||
$organization = $this->getOwnerRecord();
|
||||
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('name'),
|
||||
Tables\Columns\TextColumn::make('role'),
|
||||
TextColumn::make('billable_rate')
|
||||
->money($this->getOwnerRecord()->currency ?? 'EUR', divideBy: 100),
|
||||
->money($organization->currency, divideBy: 100),
|
||||
])
|
||||
->headerActions([
|
||||
Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [
|
||||
$action->getRecordSelect(),
|
||||
Select::make('role')
|
||||
->options(Role::class),
|
||||
]),
|
||||
Tables\Actions\AttachAction::make()
|
||||
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
|
||||
->form(fn (AttachAction $action): array => [
|
||||
$action->getRecordSelect(),
|
||||
Select::make('role')
|
||||
->required()
|
||||
->options(Role::class)
|
||||
->rule([
|
||||
'required',
|
||||
'string',
|
||||
Rule::enum(Role::class)
|
||||
->except([Role::Owner, Role::Placeholder]),
|
||||
]),
|
||||
])
|
||||
->label('Add user')
|
||||
->modalHeading('Add user')
|
||||
->icon('heroicon-s-plus')
|
||||
->using(function (User $record, array $data): void {
|
||||
/** @var Organization $organization */
|
||||
$organization = $this->getOwnerRecord();
|
||||
app(MemberService::class)->addMember($record, $organization, Role::from($data['role']), true);
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Action::make('view')
|
||||
@@ -58,13 +85,55 @@ class UsersRelationManager extends RelationManager
|
||||
->url(fn (User $record): string => UserResource::getUrl('view', [
|
||||
'record' => $record->getKey(),
|
||||
])),
|
||||
Tables\Actions\EditAction::make(),
|
||||
Tables\Actions\DetachAction::make(),
|
||||
Tables\Actions\EditAction::make()
|
||||
->using(function (User $record, array $data): User {
|
||||
/** @var Organization $organization */
|
||||
$organization = $this->getOwnerRecord();
|
||||
/** @var Member $member */
|
||||
$member = $record->getRelation('membership');
|
||||
|
||||
if ($data['billable_rate'] !== $member->billable_rate) {
|
||||
$member->billable_rate = $data['billable_rate'];
|
||||
app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member);
|
||||
}
|
||||
|
||||
if ($data['role'] !== $member->role) {
|
||||
try {
|
||||
app(MemberService::class)->changeRole($member, $organization, Role::from($data['role']), true);
|
||||
} catch (ApiException $exception) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Update failed')
|
||||
->body($exception->getTranslatedMessage())
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
}
|
||||
$member->save();
|
||||
|
||||
return $record;
|
||||
}),
|
||||
Tables\Actions\DetachAction::make()
|
||||
->using(function (User $record): void {
|
||||
/** @var Organization $organization */
|
||||
$organization = $this->getOwnerRecord();
|
||||
$member = Member::query()
|
||||
->whereBelongsTo($record, 'user')
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->firstOrFail();
|
||||
try {
|
||||
app(MemberService::class)->removeMember($member, $organization);
|
||||
} catch (ApiException $exception) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Delete failed')
|
||||
->body($exception->getTranslatedMessage())
|
||||
->persistent()
|
||||
->send();
|
||||
}
|
||||
}),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DetachBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ class ProjectMemberResource extends Resource
|
||||
'nullable',
|
||||
'integer',
|
||||
'gt:0',
|
||||
'max:2147483647',
|
||||
])
|
||||
->numeric(),
|
||||
Forms\Components\Select::make('user_id')
|
||||
|
||||
@@ -15,7 +15,8 @@ class EditProjectMember extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\DeleteAction::make()
|
||||
->icon('heroicon-m-trash'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ class ListProjectMembers extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
Actions\CreateAction::make()
|
||||
->icon('heroicon-s-plus'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ class ProjectResource extends Resource
|
||||
'nullable',
|
||||
'integer',
|
||||
'gt:0',
|
||||
'max:2147483647',
|
||||
])
|
||||
->numeric(),
|
||||
Forms\Components\Select::make('organization_id')
|
||||
@@ -71,8 +72,13 @@ class ProjectResource extends Resource
|
||||
])
|
||||
->filters([
|
||||
SelectFilter::make('organization')
|
||||
->label('Organization')
|
||||
->relationship('organization', 'name')
|
||||
->searchable(),
|
||||
SelectFilter::make('organization_id')
|
||||
->label('Organization ID')
|
||||
->relationship('organization', 'id')
|
||||
->searchable(),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->actions([
|
||||
|
||||
@@ -15,7 +15,8 @@ class EditProject extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\DeleteAction::make()
|
||||
->icon('heroicon-m-trash'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user