mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
341 Commits
v0.0.1
...
feature/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ccc07c4235 | ||
|
|
453dbaac9e | ||
|
|
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 | ||
|
|
ee2f125062 | ||
|
|
fd8d596e9b | ||
|
|
555417dbbd | ||
|
|
7aab3d98fc | ||
|
|
1dc35f1f55 | ||
|
|
be50397775 | ||
|
|
e3b4cfd881 | ||
|
|
7fd5d25781 | ||
|
|
4c2748ff50 | ||
|
|
c69701aa66 | ||
|
|
c194785034 | ||
|
|
53e5805937 | ||
|
|
a8d82d0d2c | ||
|
|
8f0be6efce | ||
|
|
6593a8c24f | ||
|
|
0f32e42002 | ||
|
|
8ddce667cc | ||
|
|
726c2ee623 | ||
|
|
7decb095ee | ||
|
|
442da936d0 | ||
|
|
3a17ae83ae | ||
|
|
264b7c9b8d | ||
|
|
c3a7ef7585 | ||
|
|
de1accba4a | ||
|
|
364168debd | ||
|
|
75e739f6fb | ||
|
|
a69d1cb4c4 | ||
|
|
f21a2d4bdd | ||
|
|
512089ccbd | ||
|
|
313cee2db0 | ||
|
|
2184b3c835 | ||
|
|
7c26cee1ea | ||
|
|
ce82dddc6a | ||
|
|
099926f95c | ||
|
|
42da2c3397 | ||
|
|
62ac23cb1a | ||
|
|
c0c678ac0d | ||
|
|
c036b77331 | ||
|
|
7b467807d9 | ||
|
|
2e8b088c59 | ||
|
|
e69a419551 | ||
|
|
a10d0569af | ||
|
|
237b3832bb | ||
|
|
eefa7c8ca8 | ||
|
|
fc0a0615cb | ||
|
|
3a61d68dc1 | ||
|
|
0121195e75 | ||
|
|
0c054bdcf2 | ||
|
|
96f818cb04 | ||
|
|
31ca0419f5 | ||
|
|
78e35222f8 | ||
|
|
c5b854adb3 | ||
|
|
9f374c7716 | ||
|
|
ce8e503faa | ||
|
|
79f914d4b6 | ||
|
|
c4757ee8a9 | ||
|
|
c0212ec836 | ||
|
|
8f0c9afa1a | ||
|
|
8982bfac2b | ||
|
|
9ac1d19722 | ||
|
|
843e16c4c0 | ||
|
|
9a920bd4e9 | ||
|
|
bb8c944df5 | ||
|
|
e4c1363193 | ||
|
|
bd9cede081 | ||
|
|
92dde6a701 | ||
|
|
91cb6ab087 | ||
|
|
fadcd042c0 | ||
|
|
0eef5ffcfa | ||
|
|
90480f3bb8 | ||
|
|
86f5ea47bb | ||
|
|
8857befc6c | ||
|
|
f40ae91444 | ||
|
|
94940be02c | ||
|
|
f2f128e184 | ||
|
|
ffea3c6b68 | ||
|
|
1fdbfe77f0 | ||
|
|
7fb58ea341 | ||
|
|
d9244d1ab4 | ||
|
|
b0cdeb3e33 | ||
|
|
86555664c5 | ||
|
|
20f9b344f6 | ||
|
|
802d9558a3 | ||
|
|
474c0de3ac | ||
|
|
b1795392ad | ||
|
|
2692db2a86 | ||
|
|
ded58f8bd6 | ||
|
|
81e3ffd921 | ||
|
|
22363e1c89 | ||
|
|
d28269ebb0 | ||
|
|
3fc9d8b381 | ||
|
|
5bfd9e7dce | ||
|
|
ee6999af90 | ||
|
|
22420439d9 | ||
|
|
a065744d40 | ||
|
|
4a7db27a05 | ||
|
|
bae4265f70 | ||
|
|
a64ee87d19 | ||
|
|
c9311780ed | ||
|
|
4943baa236 | ||
|
|
4c977b5bf8 | ||
|
|
4e439010d1 |
29
.env.ci
29
.env.ci
@@ -6,12 +6,13 @@ APP_URL=http://localhost
|
||||
APP_FORCE_HTTPS=false
|
||||
SESSION_SECURE_COOKIE=false
|
||||
|
||||
# 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
|
||||
@@ -20,31 +21,21 @@ DB_TEST_PASSWORD=root
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
CACHE_DRIVER=file
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=sync
|
||||
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_MAILER=smtp
|
||||
MAIL_HOST=mailpit
|
||||
MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
# Mail
|
||||
MAIL_MAILER=log
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
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
|
||||
|
||||
# Services
|
||||
GOTENBERG_URL=http://0.0.0.0:3000
|
||||
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
|
||||
39
.env.example
39
.env.example
@@ -3,15 +3,16 @@ APP_ENV=local
|
||||
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
|
||||
APP_DEBUG=true
|
||||
APP_URL=https://solidtime.test
|
||||
|
||||
AUDITING_ENABLED=true
|
||||
SUPER_ADMINS=admin@example.com
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
# Logging
|
||||
LOG_CHANNEL=single
|
||||
LOG_DEPRECATIONS_CHANNEL=deprecation
|
||||
LOG_LEVEL=debug
|
||||
|
||||
# Database
|
||||
DB_CONNECTION=pgsql
|
||||
|
||||
DB_HOST=pgsql
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=laravel
|
||||
@@ -26,17 +27,11 @@ DB_TEST_PASSWORD=root
|
||||
|
||||
BROADCAST_DRIVER=log
|
||||
CACHE_DRIVER=file
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=sync
|
||||
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
|
||||
@@ -46,12 +41,6 @@ MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_REGION=us-east-1
|
||||
S3_BUCKET=
|
||||
S3_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
PUSHER_APP_ID=
|
||||
PUSHER_APP_KEY=
|
||||
PUSHER_APP_SECRET=
|
||||
@@ -60,6 +49,20 @@ PUSHER_PORT=443
|
||||
PUSHER_SCHEME=https
|
||||
PUSHER_APP_CLUSTER=mt1
|
||||
|
||||
# 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=local
|
||||
S3_URL=http://storage.solidtime.test/local
|
||||
S3_ENDPOINT=http://storage.solidtime.test
|
||||
S3_USE_PATH_STYLE_ENDPOINT=true
|
||||
|
||||
# Services
|
||||
GOTENBERG_URL=http://gotenberg:3000
|
||||
|
||||
VITE_HOST_NAME=vite.solidtime.test
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
|
||||
@@ -73,3 +76,5 @@ NETWORK_NAME=reverse-proxy-docker-traefik_routing
|
||||
|
||||
FORWARD_DB_PORT=5432
|
||||
FORWARD_WEB_PORT=8083
|
||||
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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=swoole
|
||||
OCTANE_SERVER=frankenphp
|
||||
PAGINATION_PER_PAGE_DEFAULT=500
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
|
||||
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
github: solidtime-io
|
||||
60
.github/workflows/build-private.yml
vendored
60
.github/workflows/build-private.yml
vendored
@@ -3,6 +3,8 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-private.yml'
|
||||
@@ -13,18 +15,60 @@ name: Build - Private
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
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,7 +111,7 @@ jobs:
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: mbstring, dom, fileinfo, pgsql, swoole
|
||||
extensions: mbstring, dom, fileinfo, pgsql
|
||||
|
||||
- name: "Install dependencies"
|
||||
uses: php-actions/composer@v6
|
||||
@@ -110,15 +154,21 @@ 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
|
||||
|
||||
- name: "Build and push"
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
build-args: |
|
||||
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
|
||||
|
||||
71
.github/workflows/build-public.yml
vendored
71
.github/workflows/build-public.yml
vendored
@@ -3,6 +3,8 @@ on:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- '*'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/build-public.yml'
|
||||
@@ -13,12 +15,59 @@ name: Build - Public
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
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: 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
|
||||
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: "Install dependencies"
|
||||
uses: php-actions/composer@v6
|
||||
@@ -44,27 +93,39 @@ jobs:
|
||||
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: "Docker meta"
|
||||
id: "meta"
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: solidtime/solidtime
|
||||
images: |
|
||||
solidtime/solidtime
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- 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"
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/prod/Dockerfile
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
1
.github/workflows/generate-api-docs.yml
vendored
1
.github/workflows/generate-api-docs.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
jobs:
|
||||
api_docs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
services:
|
||||
pgsql_test:
|
||||
|
||||
2
.github/workflows/npm-build.yml
vendored
2
.github/workflows/npm-build.yml
vendored
@@ -4,8 +4,8 @@ on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
|
||||
2
.github/workflows/npm-lint.yml
vendored
2
.github/workflows/npm-lint.yml
vendored
@@ -4,8 +4,8 @@ on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
|
||||
29
.github/workflows/npm-publish-api.yml
vendored
Normal file
29
.github/workflows/npm-publish-api.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Publish API package to NPM
|
||||
on:
|
||||
workflow_dispatch
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- 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 }}
|
||||
29
.github/workflows/npm-publish-ui.yml
vendored
Normal file
29
.github/workflows/npm-publish-ui.yml
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
name: Publish UI package to NPM
|
||||
on:
|
||||
workflow_dispatch
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- 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 }}
|
||||
2
.github/workflows/npm-typecheck.yml
vendored
2
.github/workflows/npm-typecheck.yml
vendored
@@ -4,8 +4,8 @@ on: [push]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
|
||||
1
.github/workflows/phpstan.yml
vendored
1
.github/workflows/phpstan.yml
vendored
@@ -3,6 +3,7 @@ on: push
|
||||
jobs:
|
||||
phpstan:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
|
||||
13
.github/workflows/phpunit.yml
vendored
13
.github/workflows/phpunit.yml
vendored
@@ -3,6 +3,7 @@ on: push
|
||||
jobs:
|
||||
phpunit:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
services:
|
||||
pgsql_test:
|
||||
@@ -19,7 +20,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
|
||||
@@ -54,7 +63,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.4.1
|
||||
uses: codecov/codecov-action@v4.5.0
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
slug: solidtime-io/solidtime
|
||||
|
||||
2
.github/workflows/pint.yml
vendored
2
.github/workflows/pint.yml
vendored
@@ -3,6 +3,8 @@ on: push
|
||||
jobs:
|
||||
pint:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
6
.github/workflows/playwright.yml
vendored
6
.github/workflows/playwright.yml
vendored
@@ -1,10 +1,10 @@
|
||||
name: Playwright Tests
|
||||
on:
|
||||
workflow_dispatch:
|
||||
on: [push]
|
||||
jobs:
|
||||
test:
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
services:
|
||||
mailpit:
|
||||
image: 'axllent/mailpit:latest'
|
||||
|
||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
/.phpunit.cache
|
||||
/node_modules
|
||||
node_modules
|
||||
dist
|
||||
/public/build
|
||||
/public/hot
|
||||
/public/storage
|
||||
@@ -33,3 +34,11 @@ yarn-error.log
|
||||
/k8s
|
||||
/_ide_helper.php
|
||||
/.phpstorm.meta.php
|
||||
/.rnd
|
||||
|
||||
/caddy
|
||||
/frankenphp
|
||||
/public/frankenphp-worker.php
|
||||
/data
|
||||
/config/caddy
|
||||
/config/composer
|
||||
|
||||
82
README.md
82
README.md
@@ -13,89 +13,25 @@ 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
|
||||
|
||||
@@ -104,6 +40,8 @@ Therefore, we do not currently accept any contributions, unless you are a member
|
||||
|
||||
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
|
||||
|
||||
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
|
||||
|
||||
## Security
|
||||
|
||||
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -18,6 +19,7 @@ use Illuminate\Validation\ValidationException;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
use Laravel\Jetstream\Jetstream;
|
||||
use Log;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
@@ -41,9 +43,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);
|
||||
}),
|
||||
@@ -55,21 +57,54 @@ class CreateNewUser implements CreatesNewUsers
|
||||
],
|
||||
])->validate();
|
||||
|
||||
$timezone = 'UTC';
|
||||
if (array_key_exists('timezone', $input) && is_string($input['timezone']) && app(TimezoneService::class)->isValid($input['timezone'])) {
|
||||
$timezone = $input['timezone'];
|
||||
$timezone = null;
|
||||
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
|
||||
if (app(TimezoneService::class)->isValid($input['timezone'])) {
|
||||
$timezone = $input['timezone'];
|
||||
} else {
|
||||
$timezone = app(TimezoneService::class)->mapLegacyTimezone($input['timezone']);
|
||||
if ($timezone === null) {
|
||||
Log::debug('Invalid timezone', ['timezone' => $input['timezone']]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$user = DB::transaction(function () use ($input, $timezone) {
|
||||
return tap(User::create([
|
||||
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
|
||||
|
||||
$startOfWeek = Weekday::Monday;
|
||||
$currency = null;
|
||||
if ($ipLookupResponse !== null) {
|
||||
$startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday;
|
||||
if ($timezone === null) {
|
||||
$timezone = $ipLookupResponse->timezone;
|
||||
}
|
||||
$currency = $ipLookupResponse->currency;
|
||||
}
|
||||
$user = null;
|
||||
$organization = null;
|
||||
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void {
|
||||
$user = User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => Hash::make($input['password']),
|
||||
'timezone' => $timezone,
|
||||
'week_start' => Weekday::Monday,
|
||||
]), function (User $user) {
|
||||
$this->createTeam($user);
|
||||
});
|
||||
'timezone' => $timezone ?? 'UTC',
|
||||
'week_start' => $startOfWeek,
|
||||
]);
|
||||
|
||||
$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);
|
||||
});
|
||||
|
||||
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];
|
||||
@@ -79,24 +114,4 @@ class CreateNewUser implements CreatesNewUsers
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a personal team for the user.
|
||||
*/
|
||||
protected function createTeam(User $user): void
|
||||
{
|
||||
$organization = new Organization();
|
||||
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
|
||||
$organization->personal_team = true;
|
||||
$organization->owner()->associate($user);
|
||||
$organization->save();
|
||||
|
||||
$organization->users()->attach(
|
||||
$user, [
|
||||
'role' => Role::Owner->value,
|
||||
]
|
||||
);
|
||||
|
||||
$user->ownedTeams()->save($organization);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ 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;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
|
||||
|
||||
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
@@ -24,11 +26,33 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
|
||||
public function update(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
|
||||
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
|
||||
'timezone' => ['required', 'timezone:all'],
|
||||
'week_start' => ['required', Rule::enum(Weekday::class)],
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
],
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
'max:255',
|
||||
UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', false);
|
||||
}),
|
||||
],
|
||||
'photo' => [
|
||||
'nullable',
|
||||
'mimes:jpg,jpeg,png',
|
||||
'max:1024',
|
||||
],
|
||||
'timezone' => [
|
||||
'required',
|
||||
'timezone:all',
|
||||
],
|
||||
'week_start' => [
|
||||
'required',
|
||||
Rule::enum(Weekday::class),
|
||||
],
|
||||
])->validateWithBag('updateProfileInformation');
|
||||
|
||||
if (isset($input['photo'])) {
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace App\Actions\Jetstream;
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\UserService;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
@@ -39,14 +38,10 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
|
||||
AddingTeamMember::dispatch($organization, $newOrganizationMember);
|
||||
|
||||
DB::transaction(function () use ($organization, $newOrganizationMember, $role) {
|
||||
DB::transaction(function () use ($organization, $newOrganizationMember, $role): void {
|
||||
$organization->users()->attach(
|
||||
$newOrganizationMember, ['role' => $role]
|
||||
);
|
||||
|
||||
if ($role === Role::Owner->value) {
|
||||
app(UserService::class)->changeOwnership($organization, $newOrganizationMember);
|
||||
}
|
||||
});
|
||||
|
||||
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
|
||||
@@ -76,15 +71,15 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
'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',
|
||||
'string',
|
||||
Rule::in([
|
||||
Role::Owner->value,
|
||||
Role::Admin->value,
|
||||
Role::Manager->value,
|
||||
Role::Employee->value,
|
||||
@@ -98,7 +93,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',
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 Illuminate\Auth\Access\AuthorizationException;
|
||||
@@ -12,7 +13,6 @@ 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,9 +33,7 @@ class CreateOrganization implements CreatesTeams
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
])->validateWithBag('createTeam');
|
||||
|
||||
AddingTeam::dispatch($user);
|
||||
|
||||
$organization = new Organization();
|
||||
$organization = new Organization;
|
||||
$organization->name = $input['name'];
|
||||
$organization->personal_team = false;
|
||||
$organization->owner()->associate($user);
|
||||
@@ -47,10 +45,12 @@ class CreateOrganization implements CreatesTeams
|
||||
]
|
||||
);
|
||||
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\DeletionService;
|
||||
use Laravel\Jetstream\Contracts\DeletesTeams;
|
||||
|
||||
class DeleteOrganization implements DeletesTeams
|
||||
@@ -12,8 +13,9 @@ class DeleteOrganization implements DeletesTeams
|
||||
/**
|
||||
* Delete the given team.
|
||||
*/
|
||||
public function delete(Organization $team): void
|
||||
public function delete(Organization $organization): void
|
||||
{
|
||||
$team->purge();
|
||||
/** @see ValidateOrganizationDeletion */
|
||||
app(DeletionService::class)->deleteOrganization($organization);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,51 +4,27 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Exceptions\Api\ApiException;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Jetstream\Contracts\DeletesTeams;
|
||||
use App\Service\DeletionService;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Contracts\DeletesUsers;
|
||||
|
||||
class DeleteUser implements DeletesUsers
|
||||
{
|
||||
/**
|
||||
* The team deleter implementation.
|
||||
*
|
||||
* @var \Laravel\Jetstream\Contracts\DeletesTeams
|
||||
*/
|
||||
protected $deletesTeams;
|
||||
|
||||
/**
|
||||
* Create a new action instance.
|
||||
*/
|
||||
public function __construct(DeletesTeams $deletesTeams)
|
||||
{
|
||||
$this->deletesTeams = $deletesTeams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the given user.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function delete(User $user): void
|
||||
{
|
||||
DB::transaction(function () use ($user) {
|
||||
$this->deleteTeams($user);
|
||||
$user->deleteProfilePhoto();
|
||||
$user->tokens->each->delete();
|
||||
$user->delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the teams and team associations attached to the user.
|
||||
*/
|
||||
protected function deleteTeams(User $user): void
|
||||
{
|
||||
$user->teams()->detach();
|
||||
|
||||
$user->ownedTeams->each(function (Organization $team) {
|
||||
$this->deletesTeams->delete($team);
|
||||
});
|
||||
try {
|
||||
app(DeletionService::class)->deleteUser($user);
|
||||
} catch (ApiException $exception) {
|
||||
throw ValidationException::withMessages([
|
||||
'password' => $exception->getTranslatedMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,103 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use Closure;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\In;
|
||||
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
|
||||
use Exception;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
use Laravel\Jetstream\Events\InvitingTeamMember;
|
||||
use Laravel\Jetstream\Mail\TeamInvitation;
|
||||
|
||||
class InviteOrganizationMember implements InvitesTeamMembers
|
||||
{
|
||||
/**
|
||||
* Invite a new team member to the given team.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
|
||||
{
|
||||
if (! app(PermissionStore::class)->has($organization, 'invitations:create')) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
$this->validate($organization, $email, $role);
|
||||
|
||||
InvitingTeamMember::dispatch($organization, $email, $role);
|
||||
|
||||
/** @var OrganizationInvitation $invitation */
|
||||
$invitation = $organization->teamInvitations()->create([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
]);
|
||||
|
||||
Mail::to($email)->send(new TeamInvitation($invitation));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the invite member operation.
|
||||
*/
|
||||
protected function validate(Organization $organization, string $email, ?string $role): void
|
||||
{
|
||||
Validator::make([
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
], $this->rules($organization))->after(
|
||||
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
|
||||
)->validateWithBag('addTeamMember');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules for inviting a team member.
|
||||
*
|
||||
* @return array<string, array<ValidationRule|Rule|string|In>>
|
||||
*/
|
||||
protected function rules(Organization $organization): array
|
||||
{
|
||||
return array_filter([
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) {
|
||||
/** @var Builder<OrganizationInvitation> $builder */
|
||||
return $builder->whereBelongsTo($organization, 'organization');
|
||||
}))->withMessage(__('This user has already been invited to the team.')),
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
Role::Owner->value,
|
||||
Role::Admin->value,
|
||||
Role::Manager->value,
|
||||
Role::Employee->value,
|
||||
]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the user is not already on the team.
|
||||
*/
|
||||
protected function ensureUserIsNotAlreadyOnTeam(Organization $organization, string $email): Closure
|
||||
{
|
||||
return function ($validator) use ($organization, $email) {
|
||||
$validator->errors()->addIf(
|
||||
$organization->hasRealUserWithEmail($email),
|
||||
'email',
|
||||
__('This user already belongs to the team.')
|
||||
);
|
||||
};
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,50 +4,21 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Exception;
|
||||
use Laravel\Jetstream\Contracts\RemovesTeamMembers;
|
||||
use Laravel\Jetstream\Events\TeamMemberRemoved;
|
||||
|
||||
class RemoveOrganizationMember implements RemovesTeamMembers
|
||||
{
|
||||
/**
|
||||
* Remove the team member from the given team.
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function remove(User $user, Organization $organization, User $teamMember): void
|
||||
{
|
||||
$this->authorize($user, $organization, $teamMember);
|
||||
|
||||
$this->ensureUserDoesNotOwnTeam($teamMember, $organization);
|
||||
|
||||
$organization->removeUser($teamMember);
|
||||
|
||||
TeamMemberRemoved::dispatch($organization, $teamMember);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize that the user can remove the team member.
|
||||
*/
|
||||
protected function authorize(User $user, Organization $organization, User $teamMember): void
|
||||
{
|
||||
if (! Gate::forUser($user)->check('removeTeamMember', $organization) &&
|
||||
$user->id !== $teamMember->id) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the currently authenticated user does not own the team.
|
||||
*/
|
||||
protected function ensureUserDoesNotOwnTeam(User $teamMember, Organization $organization): void
|
||||
{
|
||||
if ($teamMember->id === $organization->owner->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'team' => [__('You may not leave a team that you created.')],
|
||||
])->errorBag('removeTeamMember');
|
||||
}
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,63 +5,21 @@ declare(strict_types=1);
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\MovedToApiException;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use App\Service\UserService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Events\TeamMemberUpdated;
|
||||
use Exception;
|
||||
|
||||
class UpdateMemberRole
|
||||
{
|
||||
/**
|
||||
* Update the role for the given team member.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
|
||||
{
|
||||
if (! app(PermissionStore::class)->has($organization, 'members:change-role')) {
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
$user = User::where('id', '=', $userId)->firstOrFail();
|
||||
$member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail();
|
||||
if ($member->role === Role::Placeholder->value) {
|
||||
abort(403, 'Cannot update the role of a placeholder member.');
|
||||
}
|
||||
|
||||
Validator::make([
|
||||
'role' => $role,
|
||||
], [
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
Role::Owner->value,
|
||||
Role::Admin->value,
|
||||
Role::Manager->value,
|
||||
Role::Employee->value,
|
||||
]),
|
||||
],
|
||||
])->validate();
|
||||
|
||||
DB::transaction(function () use ($organization, $userId, $role, $user) {
|
||||
$organization->users()->updateExistingPivot($userId, [
|
||||
'role' => $role,
|
||||
]);
|
||||
|
||||
if ($role === Role::Owner->value) {
|
||||
app(UserService::class)->changeOwnership($organization, $user);
|
||||
}
|
||||
});
|
||||
|
||||
TeamMemberUpdated::dispatch($organization->fresh(), User::findOrFail($userId));
|
||||
throw new MovedToApiException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class UpdateOrganization implements UpdatesTeamNames
|
||||
'currency' => [
|
||||
'required',
|
||||
'string',
|
||||
new CurrencyRule(),
|
||||
new CurrencyRule,
|
||||
],
|
||||
])->validateWithBag('updateTeamName');
|
||||
|
||||
|
||||
28
app/Actions/Jetstream/ValidateOrganizationDeletion.php
Normal file
28
app/Actions/Jetstream/ValidateOrganizationDeletion.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
class ValidateOrganizationDeletion
|
||||
{
|
||||
/**
|
||||
* Validate that the team can be deleted by the given user.
|
||||
*
|
||||
* @param User $user Authenticated user
|
||||
* @param Organization $organization Organization to be deleted
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function validate(User $user, Organization $organization): void
|
||||
{
|
||||
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
}
|
||||
}
|
||||
59
app/Console/Commands/Admin/OrganizationDeleteCommand.php
Normal file
59
app/Console/Commands/Admin/OrganizationDeleteCommand.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands\Admin;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\DeletionService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class OrganizationDeleteCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'admin:organization:delete
|
||||
{ organization : The ID of the organization to delete }';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete a organization';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(DeletionService $deletionService): int
|
||||
{
|
||||
$organizationId = $this->argument('organization');
|
||||
|
||||
if (! Str::isUuid($organizationId)) {
|
||||
$this->error('Organization ID must be a valid UUID.');
|
||||
|
||||
return self::FAILURE;
|
||||
|
||||
}
|
||||
|
||||
/** @var Organization|null $organization */
|
||||
$organization = Organization::find($organizationId);
|
||||
if ($organization === null) {
|
||||
$this->error('Organization with ID '.$organizationId.' not found.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->info('Deleting organization with ID '.$organization->getKey());
|
||||
|
||||
$deletionService->deleteOrganization($organization);
|
||||
|
||||
$this->info('Organization with ID '.$organization->getKey().' has been deleted.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
59
app/Console/Commands/Admin/UserVerifyCommand.php
Normal file
59
app/Console/Commands/Admin/UserVerifyCommand.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?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::where('email', $email)->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,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;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use Illuminate\Encryption\Encrypter;
|
||||
use Illuminate\Support\Str;
|
||||
use phpseclib3\Crypt\RSA;
|
||||
|
||||
class SelfHostGenerateKeys extends Command
|
||||
class SelfHostGenerateKeysCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
@@ -18,6 +18,7 @@ class SelfHostGenerateKeys 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 SelfHostGenerateKeys 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 SelfHostGenerateKeys 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;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ class TestJobCommand extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'test:job';
|
||||
protected $signature = 'test:job {--fail}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -30,7 +30,9 @@ class TestJobCommand extends Command
|
||||
public function handle(): int
|
||||
{
|
||||
$user = User::firstOrFail();
|
||||
TestJob::dispatch($user, 'Test job message.');
|
||||
$fail = (bool) $this->option('fail');
|
||||
|
||||
TestJob::dispatch($user, 'Test job message.', $fail);
|
||||
|
||||
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,17 @@ 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('self-host:check-for-update')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
|
||||
->twiceDaily();
|
||||
|
||||
$schedule->command('self-host:telemetry')
|
||||
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
|
||||
->twiceDaily();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,4 @@ enum Role: string
|
||||
case Manager = 'manager';
|
||||
case Employee = 'employee';
|
||||
case Placeholder = 'placeholder';
|
||||
|
||||
}
|
||||
|
||||
@@ -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,17 @@ enum TimeEntryAggregationType: string
|
||||
case Task = 'task';
|
||||
case Client = 'client';
|
||||
case Billable = 'billable';
|
||||
case Description = 'description';
|
||||
|
||||
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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
20
app/Events/BeforeOrganizationDeletion.php
Normal file
20
app/Events/BeforeOrganizationDeletion.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Events;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class BeforeOrganizationDeletion
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public Organization $organization;
|
||||
|
||||
public function __construct(Organization $organization)
|
||||
{
|
||||
$this->organization = $organization;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ abstract class ApiException extends Exception
|
||||
{
|
||||
public const string KEY = 'api_exception';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(static::KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the exception into an HTTP response.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
|
||||
{
|
||||
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
|
||||
}
|
||||
10
app/Exceptions/Api/ChangingRoleToPlaceholderIsNotAllowed.php
Normal file
10
app/Exceptions/Api/ChangingRoleToPlaceholderIsNotAllowed.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
|
||||
{
|
||||
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class EntityStillInUseApiException extends ApiException
|
||||
|
||||
public function __construct(string $modelToDelete, string $modelInUse)
|
||||
{
|
||||
parent::__construct('', 0, null);
|
||||
parent::__construct();
|
||||
$this->modelToDelete = $modelToDelete;
|
||||
$this->modelInUse = $modelInUse;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
10
app/Exceptions/Api/OnlyOwnerCanChangeOwnership.php
Normal file
10
app/Exceptions/Api/OnlyOwnerCanChangeOwnership.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class OnlyOwnerCanChangeOwnership extends ApiException
|
||||
{
|
||||
public const string KEY = 'only_owner_can_change_ownership';
|
||||
}
|
||||
@@ -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/OrganizationNeedsAtLeastOneOwner.php
Normal file
10
app/Exceptions/Api/OrganizationNeedsAtLeastOneOwner.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class OrganizationNeedsAtLeastOneOwner extends ApiException
|
||||
{
|
||||
public const string KEY = 'organization_needs_at_least_one_owner';
|
||||
}
|
||||
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 UserIsAlreadyMemberOfOrganizationApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'user_is_already_member_of_organization';
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class Handler extends ExceptionHandler
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->reportable(function (Throwable $e) {
|
||||
$this->reportable(function (Throwable $e): void {
|
||||
//
|
||||
});
|
||||
}
|
||||
|
||||
15
app/Exceptions/MovedToApiException.php
Normal file
15
app/Exceptions/MovedToApiException.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
class MovedToApiException extends HttpException
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(403, 'Moved to API');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -47,37 +45,62 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
|
||||
return null;
|
||||
}
|
||||
|
||||
$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']);
|
||||
$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']);
|
||||
}
|
||||
|
||||
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\PrettyJson;
|
||||
|
||||
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(),
|
||||
PrettyJson::make('old_values'),
|
||||
PrettyJson::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;
|
||||
}
|
||||
115
app/Filament/Resources/FailedJobResource.php
Normal file
115
app/Filament/Resources/FailedJobResource.php
Normal file
@@ -0,0 +1,115 @@
|
||||
<?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\ViewAction;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Novadaemon\FilamentPrettyJson\PrettyJson;
|
||||
|
||||
/**
|
||||
* @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%;']),
|
||||
PrettyJson::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')
|
||||
->label('Retry')
|
||||
->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();
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
DeleteAction::make('Delete'),
|
||||
ViewAction::make('View'),
|
||||
Action::make('retry')
|
||||
->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,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\FailedJobResource\Pages;
|
||||
|
||||
use App\Filament\Resources\FailedJobResource;
|
||||
use App\Models\FailedJob;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Pages\Actions\Action;
|
||||
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')
|
||||
->label('Retry all failed Jobs')
|
||||
->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')
|
||||
->label('Delete all failed Jobs')
|
||||
->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;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace App\Filament\Resources;
|
||||
use App\Filament\Resources\OrganizationResource\Pages;
|
||||
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
|
||||
use App\Models\Organization;
|
||||
use App\Service\Export\ExportService;
|
||||
use App\Service\Import\Importers\ImporterProvider;
|
||||
use App\Service\Import\Importers\ImportException;
|
||||
use App\Service\Import\Importers\ReportDto;
|
||||
@@ -65,6 +66,12 @@ class OrganizationResource extends Resource
|
||||
Forms\Components\TextInput::make('billable_rate')
|
||||
->label('Billable rate (in Cents)')
|
||||
->nullable()
|
||||
->rules([
|
||||
'nullable',
|
||||
'integer',
|
||||
'gt:0',
|
||||
'max:2147483647',
|
||||
])
|
||||
->numeric(),
|
||||
Forms\Components\DateTimePicker::make('created_at')
|
||||
->label('Created At')
|
||||
@@ -105,9 +112,33 @@ class OrganizationResource extends Resource
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
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) {
|
||||
@@ -169,7 +200,6 @@ class OrganizationResource extends Resource
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\OrganizationResource\Actions;
|
||||
|
||||
use App\Exceptions\Api\ApiException;
|
||||
use App\Models\Organization;
|
||||
use App\Service\DeletionService;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Throwable;
|
||||
|
||||
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 {
|
||||
try {
|
||||
$deletionService = app(DeletionService::class);
|
||||
$deletionService->deleteOrganization($record);
|
||||
|
||||
return true;
|
||||
} catch (ApiException $exception) {
|
||||
$this->failureNotificationTitle($exception->getTranslatedMessage());
|
||||
report($exception);
|
||||
} catch (Throwable $exception) {
|
||||
$this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));
|
||||
report($exception);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (! $result) {
|
||||
$this->failure();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->success();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\OrganizationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OrganizationResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditOrganization extends EditRecord
|
||||
@@ -15,7 +14,7 @@ class EditOrganization extends EditRecord
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
OrganizationResource\Actions\DeleteOrganization::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\OrganizationResource\Pages;
|
||||
|
||||
use App\Filament\Resources\OrganizationResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
@@ -18,8 +17,6 @@ class ViewOrganization extends ViewRecord
|
||||
return [
|
||||
EditAction::make('edit')
|
||||
->icon('heroicon-s-pencil'),
|
||||
DeleteAction::make('delete')
|
||||
->icon('heroicon-s-trash'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
92
app/Filament/Resources/ProjectMemberResource.php
Normal file
92
app/Filament/Resources/ProjectMemberResource.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ProjectMemberResource\Pages;
|
||||
use App\Models\ProjectMember;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ProjectMemberResource extends Resource
|
||||
{
|
||||
protected static ?string $model = ProjectMember::class;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('billable_rate')
|
||||
->label('Billable rate (in Cents)')
|
||||
->nullable()
|
||||
->rules([
|
||||
'nullable',
|
||||
'integer',
|
||||
'gt:0',
|
||||
'max:2147483647',
|
||||
])
|
||||
->numeric(),
|
||||
Forms\Components\Select::make('user_id')
|
||||
->relationship('user', 'name')
|
||||
->required(),
|
||||
Forms\Components\Select::make('member_id')
|
||||
->relationship('member', 'id')
|
||||
->required(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('id')
|
||||
->label('ID'),
|
||||
Tables\Columns\TextColumn::make('billable_rate')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('project.name'),
|
||||
Tables\Columns\TextColumn::make('user.name'),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListProjectMembers::route('/'),
|
||||
'create' => Pages\CreateProjectMember::route('/create'),
|
||||
'edit' => Pages\EditProjectMember::route('/{record}/edit'),
|
||||
'view' => Pages\ViewProjectMembers::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\ProjectMemberResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProjectMemberResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateProjectMember extends CreateRecord
|
||||
{
|
||||
protected static string $resource = ProjectMemberResource::class;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\ProjectMemberResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProjectMemberResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditProjectMember extends EditRecord
|
||||
{
|
||||
protected static string $resource = ProjectMemberResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\ProjectMemberResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProjectMemberResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListProjectMembers extends ListRecords
|
||||
{
|
||||
protected static string $resource = ProjectMemberResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\ProjectMemberResource\Pages;
|
||||
|
||||
use App\Filament\Resources\ProjectMemberResource;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewProjectMembers extends ViewRecord
|
||||
{
|
||||
protected static string $resource = ProjectMemberResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
EditAction::make('edit')
|
||||
->icon('heroicon-s-pencil'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\ProjectResource\Pages;
|
||||
use App\Filament\Resources\ProjectResource\RelationManagers\ProjectMembersRelationManager;
|
||||
use App\Models\Project;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Components\ColorPicker;
|
||||
@@ -37,6 +38,16 @@ class ProjectResource extends Resource
|
||||
ColorPicker::make('color')
|
||||
->label('Color')
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('billable_rate')
|
||||
->label('Billable rate (in Cents)')
|
||||
->nullable()
|
||||
->rules([
|
||||
'nullable',
|
||||
'integer',
|
||||
'gt:0',
|
||||
'max:2147483647',
|
||||
])
|
||||
->numeric(),
|
||||
Forms\Components\Select::make('organization_id')
|
||||
->relationship(name: 'organization', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
@@ -78,7 +89,7 @@ class ProjectResource extends Resource
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
ProjectMembersRelationManager::make(),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\ProjectResource\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\ProjectMemberResource;
|
||||
use App\Models\ProjectMember;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Actions\Action;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class ProjectMembersRelationManager extends RelationManager
|
||||
{
|
||||
protected static ?string $title = 'Project Members';
|
||||
|
||||
protected static string $relationship = 'members';
|
||||
|
||||
public function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
]);
|
||||
}
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->recordTitleAttribute('name')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('user.name'),
|
||||
Tables\Columns\TextColumn::make('billable_rate')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
])
|
||||
->filters([
|
||||
//
|
||||
])
|
||||
->headerActions([
|
||||
])
|
||||
->actions([
|
||||
Action::make('view')
|
||||
->icon('heroicon-o-eye')
|
||||
->color('gray')
|
||||
->url(fn (ProjectMember $record): string => ProjectMemberResource::getUrl('view', [
|
||||
'record' => $record->getKey(),
|
||||
])),
|
||||
Action::make('edit')
|
||||
->icon('heroicon-o-pencil')
|
||||
->url(fn (ProjectMember $record): string => ProjectMemberResource::getUrl('edit', [
|
||||
'record' => $record->getKey(),
|
||||
]))
|
||||
->openUrlInNewTab(),
|
||||
])
|
||||
->bulkActions([
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class TagResource extends Resource
|
||||
@@ -58,7 +59,9 @@ class TagResource extends Resource
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
//
|
||||
SelectFilter::make('organization')
|
||||
->relationship('organization', 'name')
|
||||
->searchable(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\EditAction::make(),
|
||||
|
||||
@@ -49,7 +49,7 @@ class TimeEntryResource extends Resource
|
||||
->label('End')
|
||||
->nullable()
|
||||
->rules([
|
||||
'after:start',
|
||||
'after_or_equal:start',
|
||||
]),
|
||||
Select::make('user_id')
|
||||
->relationship(name: 'user', titleAttribute: 'email')
|
||||
|
||||
@@ -111,9 +111,18 @@ class UserResource extends Resource
|
||||
->filters([
|
||||
TernaryFilter::make('real_user')
|
||||
->queries(
|
||||
true: fn (Builder $query) => $query->where('is_placeholder', '=', false),
|
||||
false: fn (Builder $query) => $query->where('is_placeholder', '=', true),
|
||||
blank: fn (Builder $query) => $query,
|
||||
true: function (Builder $query): Builder {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', false);
|
||||
},
|
||||
false: function (Builder $query): Builder {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', true);
|
||||
},
|
||||
blank: function (Builder $query): Builder {
|
||||
/** @var Builder<User> $query */
|
||||
return $query;
|
||||
},
|
||||
)
|
||||
->label('Real User?'),
|
||||
TernaryFilter::make('email_verified')
|
||||
|
||||
46
app/Filament/Resources/UserResource/Actions/DeleteUser.php
Normal file
46
app/Filament/Resources/UserResource/Actions/DeleteUser.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Resources\UserResource\Actions;
|
||||
|
||||
use App\Exceptions\Api\ApiException;
|
||||
use App\Models\User;
|
||||
use App\Service\DeletionService;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Throwable;
|
||||
|
||||
class DeleteUser extends DeleteAction
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->icon('heroicon-m-trash');
|
||||
$this->action(function (): void {
|
||||
$result = $this->process(function (User $record): bool {
|
||||
try {
|
||||
$deletionService = app(DeletionService::class);
|
||||
$deletionService->deleteUser($record);
|
||||
|
||||
return true;
|
||||
} catch (ApiException $exception) {
|
||||
$this->failureNotificationTitle($exception->getTranslatedMessage());
|
||||
report($exception);
|
||||
} catch (Throwable $exception) {
|
||||
$this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));
|
||||
report($exception);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
if (! $result) {
|
||||
$this->failure();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->success();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
|
||||
|
||||
@@ -17,7 +16,7 @@ class EditUser extends EditRecord
|
||||
{
|
||||
return [
|
||||
Impersonate::make()->record($this->getRecord()),
|
||||
Actions\DeleteAction::make(),
|
||||
UserResource\Actions\DeleteUser::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\UserResource\Pages;
|
||||
|
||||
use App\Filament\Resources\UserResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
@@ -18,8 +17,6 @@ class ViewUser extends ViewRecord
|
||||
return [
|
||||
EditAction::make('edit')
|
||||
->icon('heroicon-s-pencil'),
|
||||
DeleteAction::make('delete')
|
||||
->icon('heroicon-s-trash'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
@@ -11,7 +12,9 @@ use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class ActiveUserOverview extends BaseWidget
|
||||
{
|
||||
protected static ?string $heading = 'A Registrations';
|
||||
protected static ?int $sort = 1;
|
||||
|
||||
protected ?string $heading = 'A Registrations';
|
||||
|
||||
protected function getCards(): array
|
||||
{
|
||||
@@ -19,7 +22,8 @@ class ActiveUserOverview extends BaseWidget
|
||||
$placeholderUserCount = User::query()->where('is_placeholder', '=', true)->count();
|
||||
$activeInLastWeek = User::query()
|
||||
->where('is_placeholder', '=', false)
|
||||
->whereHas('timeEntries', function (Builder $query) {
|
||||
->whereHas('timeEntries', function (Builder $query): void {
|
||||
/** @var Builder<TimeEntry> $query */
|
||||
$query->where('created_at', '>=', now()->subWeek())
|
||||
->orWhere('updated_at', '>=', now()->subWeek());
|
||||
})
|
||||
|
||||
38
app/Filament/Widgets/ServerOverview.php
Normal file
38
app/Filament/Widgets/ServerOverview.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use Filament\Widgets\Widget;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class ServerOverview extends Widget
|
||||
{
|
||||
protected static string $view = 'filament.widgets.server-overview';
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function getViewData(): array
|
||||
{
|
||||
/** @var string|null $currentVersion */
|
||||
$currentVersion = config('app.version');
|
||||
/** @var string|null $build */
|
||||
$build = config('app.build');
|
||||
$latestVersion = Cache::get('latest_version', null);
|
||||
|
||||
$needsUpdate = false;
|
||||
if ($latestVersion !== null && $currentVersion !== null && version_compare($latestVersion, $currentVersion) > 0) {
|
||||
$needsUpdate = true;
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $currentVersion,
|
||||
'build' => $build,
|
||||
'environment' => config('app.env'),
|
||||
'currentVersion' => $latestVersion,
|
||||
'needsUpdate' => $needsUpdate,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ class TimeEntriesCreated extends ChartWidget
|
||||
|
||||
public ?string $filter = 'week';
|
||||
|
||||
protected static ?int $sort = 3;
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
$filter = $this->filter;
|
||||
@@ -27,7 +29,9 @@ class TimeEntriesCreated extends ChartWidget
|
||||
} else {
|
||||
$start = now()->subWeek();
|
||||
}
|
||||
$trend = Trend::model(TimeEntry::class)
|
||||
$trend = Trend::query(
|
||||
TimeEntry::query()->where('is_imported', '=', false)
|
||||
)
|
||||
->between(
|
||||
start: $start,
|
||||
end: now(),
|
||||
|
||||
77
app/Filament/Widgets/TimeEntriesImported.php
Normal file
77
app/Filament/Widgets/TimeEntriesImported.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Widgets;
|
||||
|
||||
use App\Models\TimeEntry;
|
||||
use Filament\Widgets\ChartWidget;
|
||||
use Flowframe\Trend\Trend;
|
||||
use Flowframe\Trend\TrendValue;
|
||||
|
||||
class TimeEntriesImported extends ChartWidget
|
||||
{
|
||||
protected static ?string $heading = 'Time Entries Imported';
|
||||
|
||||
public ?string $filter = 'week';
|
||||
|
||||
protected static ?int $sort = 4;
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
$filter = $this->filter;
|
||||
if ($filter === 'week') {
|
||||
$start = now()->subWeek();
|
||||
} elseif ($filter === 'month') {
|
||||
$start = now()->subMonth();
|
||||
} elseif ($filter === 'year') {
|
||||
$start = now()->subYear();
|
||||
} else {
|
||||
$start = now()->subWeek();
|
||||
}
|
||||
$trend = Trend::query(
|
||||
TimeEntry::query()->where('is_imported', '=', true)
|
||||
)
|
||||
->between(
|
||||
start: $start,
|
||||
end: now(),
|
||||
)
|
||||
->perDay();
|
||||
|
||||
if ($filter === 'week') {
|
||||
$trend->perDay();
|
||||
} elseif ($filter === 'month') {
|
||||
$trend->perDay();
|
||||
} elseif ($filter === 'year') {
|
||||
$trend->perMonth();
|
||||
} else {
|
||||
$trend->perDay();
|
||||
}
|
||||
|
||||
$data = $trend->count();
|
||||
|
||||
return [
|
||||
'datasets' => [
|
||||
[
|
||||
'label' => self::$heading,
|
||||
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
|
||||
],
|
||||
],
|
||||
'labels' => $data->map(fn (TrendValue $value) => $value->date),
|
||||
];
|
||||
}
|
||||
|
||||
protected function getFilters(): ?array
|
||||
{
|
||||
return [
|
||||
'week' => 'Last week',
|
||||
'month' => 'Last month',
|
||||
'year' => 'Last year',
|
||||
];
|
||||
}
|
||||
|
||||
protected function getType(): string
|
||||
{
|
||||
return 'line';
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ class UserRegistrations extends ChartWidget
|
||||
|
||||
public ?string $filter = 'week';
|
||||
|
||||
protected static ?int $sort = 2;
|
||||
|
||||
protected function getData(): array
|
||||
{
|
||||
$filter = $this->filter;
|
||||
|
||||
@@ -5,14 +5,16 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Http\Requests\V1\Tag\TagStoreRequest;
|
||||
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
||||
use App\Http\Requests\V1\Client\ClientIndexRequest;
|
||||
use App\Http\Requests\V1\Client\ClientStoreRequest;
|
||||
use App\Http\Requests\V1\Client\ClientUpdateRequest;
|
||||
use App\Http\Resources\V1\Client\ClientCollection;
|
||||
use App\Http\Resources\V1\Client\ClientResource;
|
||||
use App\Models\Client;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class ClientController extends Controller
|
||||
{
|
||||
@@ -33,14 +35,22 @@ class ClientController extends Controller
|
||||
*
|
||||
* @operationId getClients
|
||||
*/
|
||||
public function index(Organization $organization): ClientCollection
|
||||
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'clients:view');
|
||||
|
||||
$clients = Client::query()
|
||||
$clientsQuery = Client::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
->orderBy('created_at', 'desc');
|
||||
|
||||
$filterArchived = $request->getFilterArchived();
|
||||
if ($filterArchived === 'true') {
|
||||
$clientsQuery->whereNotNull('archived_at');
|
||||
} elseif ($filterArchived === 'false') {
|
||||
$clientsQuery->whereNull('archived_at');
|
||||
}
|
||||
|
||||
$clients = $clientsQuery->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return new ClientCollection($clients);
|
||||
}
|
||||
@@ -52,11 +62,11 @@ class ClientController extends Controller
|
||||
*
|
||||
* @operationId createClient
|
||||
*/
|
||||
public function store(Organization $organization, TagStoreRequest $request): ClientResource
|
||||
public function store(Organization $organization, ClientStoreRequest $request): ClientResource
|
||||
{
|
||||
$this->checkPermission($organization, 'clients:create');
|
||||
|
||||
$client = new Client();
|
||||
$client = new Client;
|
||||
$client->name = $request->input('name');
|
||||
$client->organization()->associate($organization);
|
||||
$client->save();
|
||||
@@ -71,11 +81,14 @@ class ClientController extends Controller
|
||||
*
|
||||
* @operationId updateClient
|
||||
*/
|
||||
public function update(Organization $organization, Client $client, TagUpdateRequest $request): ClientResource
|
||||
public function update(Organization $organization, Client $client, ClientUpdateRequest $request): ClientResource
|
||||
{
|
||||
$this->checkPermission($organization, 'clients:update', $client);
|
||||
|
||||
$client->name = $request->input('name');
|
||||
if ($request->has('is_archived')) {
|
||||
$client->archived_at = $request->getIsArchived() ? Carbon::now() : null;
|
||||
}
|
||||
$client->save();
|
||||
|
||||
return new ClientResource($client);
|
||||
|
||||
@@ -4,20 +4,16 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\BillingContract;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class Controller extends \App\Http\Controllers\Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected PermissionStore $permissionStore,
|
||||
) {
|
||||
}
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
@@ -25,7 +21,7 @@ class Controller extends \App\Http\Controllers\Controller
|
||||
protected function checkPermission(Organization $organization, string $permission): void
|
||||
{
|
||||
if (! $this->permissionStore->has($organization, $permission)) {
|
||||
throw new AuthorizationException();
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +37,7 @@ class Controller extends \App\Http\Controllers\Controller
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new AuthorizationException();
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
protected function hasPermission(Organization $organization, string $permission): bool
|
||||
@@ -49,33 +45,8 @@ class Controller extends \App\Http\Controllers\Controller
|
||||
return $this->permissionStore->has($organization, $permission);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
protected function user(): User
|
||||
protected function canAccessPremiumFeatures(Organization $organization): bool
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = Auth::user();
|
||||
if ($user === null) {
|
||||
Log::error('This function should only be called in authenticated context');
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
protected function member(Organization $organization): Member
|
||||
{
|
||||
$user = $this->user();
|
||||
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
|
||||
if ($member === null) {
|
||||
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
|
||||
throw new AuthorizationException();
|
||||
}
|
||||
|
||||
return $member;
|
||||
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
|
||||
}
|
||||
}
|
||||
|
||||
38
app/Http/Controllers/Api/V1/ExportController.php
Normal file
38
app/Http/Controllers/Api/V1/ExportController.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Service\Export\ExportException;
|
||||
use App\Service\Export\ExportService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class ExportController extends Controller
|
||||
{
|
||||
/**
|
||||
* Export data of an organization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ExportException
|
||||
*
|
||||
* @operationId exportOrganization
|
||||
*/
|
||||
public function export(Organization $organization, ExportService $exportService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'export');
|
||||
|
||||
$filepath = $exportService->export($organization);
|
||||
$downloadUrl = Storage::disk(config('filesystems.private'))
|
||||
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
|
||||
|
||||
return new JsonResponse([
|
||||
'success' => true,
|
||||
'download_url' => $downloadUrl,
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class ImportController extends Controller
|
||||
|
||||
foreach ($importers as $key => $importerClass) {
|
||||
/** @var ImporterContract $importer */
|
||||
$importer = new $importerClass();
|
||||
$importer = new $importerClass;
|
||||
$importersResponse[] = [
|
||||
'key' => $key,
|
||||
'name' => $importer->getName(),
|
||||
|
||||
@@ -4,17 +4,18 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
|
||||
use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
|
||||
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
|
||||
use App\Http\Resources\V1\Invitation\InvitationCollection;
|
||||
use App\Http\Resources\V1\Invitation\InvitationResource;
|
||||
use App\Mail\OrganizationInvitationMail;
|
||||
use App\Models\Organization;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use App\Service\InvitationService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
use Laravel\Jetstream\Mail\TeamInvitation;
|
||||
|
||||
class InvitationController extends Controller
|
||||
{
|
||||
@@ -49,19 +50,18 @@ class InvitationController extends Controller
|
||||
* Invite a user to the organization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws UserIsAlreadyMemberOfOrganizationApiException
|
||||
*
|
||||
* @operationId invite
|
||||
*/
|
||||
public function store(Organization $organization, InvitationStoreRequest $request): JsonResponse
|
||||
public function store(Organization $organization, InvitationStoreRequest $request, InvitationService $invitationService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'invitations:create');
|
||||
|
||||
app(InvitesTeamMembers::class)->invite(
|
||||
$this->user(),
|
||||
$organization,
|
||||
$request->input('email'),
|
||||
$request->input('role')
|
||||
);
|
||||
$email = $request->getEmail();
|
||||
$role = $request->getRole();
|
||||
|
||||
$invitationService->inviteUser($organization, $email, $role);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
@@ -77,7 +77,8 @@ class InvitationController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'invitations:resend', $invitation);
|
||||
|
||||
Mail::to($invitation->email)->send(new TeamInvitation($invitation));
|
||||
Mail::to($invitation->email)
|
||||
->queue(new OrganizationInvitationMail($invitation));
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
@@ -5,23 +5,28 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Events\MemberMadeToPlaceholder;
|
||||
use App\Events\MemberRemoved;
|
||||
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
||||
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
||||
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
||||
use App\Exceptions\Api\UserNotPlaceholderApiException;
|
||||
use App\Http\Requests\V1\Member\MemberIndexRequest;
|
||||
use App\Http\Requests\V1\Member\MemberUpdateRequest;
|
||||
use App\Http\Resources\V1\Member\MemberCollection;
|
||||
use App\Http\Resources\V1\Member\MemberPivotResource;
|
||||
use App\Http\Resources\V1\Member\MemberResource;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\BillableRateService;
|
||||
use App\Service\InvitationService;
|
||||
use App\Service\MemberService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
|
||||
class MemberController extends Controller
|
||||
{
|
||||
@@ -36,7 +41,7 @@ class MemberController extends Controller
|
||||
/**
|
||||
* List all members of an organization
|
||||
*
|
||||
* @return MemberCollection<MemberPivotResource>>
|
||||
* @return MemberCollection<MemberResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
@@ -46,7 +51,9 @@ class MemberController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'members:view');
|
||||
|
||||
$members = $organization->users()
|
||||
$members = Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->with(['user'])
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return MemberCollection::make($members);
|
||||
@@ -56,15 +63,40 @@ class MemberController extends Controller
|
||||
* Update a member of the organization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws OrganizationNeedsAtLeastOneOwner
|
||||
* @throws OnlyOwnerCanChangeOwnership
|
||||
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
||||
*
|
||||
* @operationId updateMember
|
||||
*/
|
||||
public function update(Organization $organization, Member $member, MemberUpdateRequest $request): JsonResource
|
||||
public function update(Organization $organization, Member $member, MemberUpdateRequest $request, BillableRateService $billableRateService, MemberService $memberService): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'members:update', $member);
|
||||
|
||||
$member->billable_rate = $request->input('billable_rate');
|
||||
$member->role = $request->input('role');
|
||||
if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) {
|
||||
$member->billable_rate = $request->getBillableRate();
|
||||
|
||||
$billableRateService->updateTimeEntriesBillableRateForMember($member);
|
||||
}
|
||||
if ($request->has('role') && $member->role !== $request->getRole()->value) {
|
||||
$newRole = $request->getRole();
|
||||
$oldRole = Role::from($member->role);
|
||||
if ($oldRole === Role::Owner) {
|
||||
throw new OrganizationNeedsAtLeastOneOwner;
|
||||
}
|
||||
if ($newRole === Role::Placeholder) {
|
||||
throw new ChangingRoleToPlaceholderIsNotAllowed;
|
||||
}
|
||||
if ($newRole === Role::Owner) {
|
||||
if ($this->hasPermission($organization, 'members:change-ownership')) {
|
||||
$memberService->changeOwnership($organization, $member);
|
||||
} else {
|
||||
throw new OnlyOwnerCanChangeOwnership;
|
||||
}
|
||||
} else {
|
||||
$member->role = $request->getRole()->value;
|
||||
}
|
||||
}
|
||||
$member->save();
|
||||
|
||||
return new MemberResource($member);
|
||||
@@ -88,15 +120,36 @@ class MemberController extends Controller
|
||||
throw new EntityStillInUseApiException('member', 'project_member');
|
||||
}
|
||||
if ($member->role === Role::Owner->value) {
|
||||
throw new CanNotRemoveOwnerFromOrganization();
|
||||
throw new CanNotRemoveOwnerFromOrganization;
|
||||
}
|
||||
|
||||
$member->delete();
|
||||
MemberRemoved::dispatch($member, $organization);
|
||||
|
||||
return response()
|
||||
->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a member a placeholder member
|
||||
*
|
||||
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
|
||||
*/
|
||||
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'members:make-placeholder', $member);
|
||||
|
||||
if ($member->role === Role::Owner->value) {
|
||||
throw new CanNotRemoveOwnerFromOrganization;
|
||||
}
|
||||
|
||||
$memberService->makeMemberToPlaceholder($member);
|
||||
|
||||
MemberMadeToPlaceholder::dispatch($member, $organization);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite a placeholder member to become a real member of the organization
|
||||
*
|
||||
@@ -104,21 +157,16 @@ class MemberController extends Controller
|
||||
*
|
||||
* @operationId invitePlaceholder
|
||||
*/
|
||||
public function invitePlaceholder(Organization $organization, Member $member, Request $request): JsonResponse
|
||||
public function invitePlaceholder(Organization $organization, Member $member, InvitationService $invitationService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'members:invite-placeholder', $member);
|
||||
$user = $member->user;
|
||||
|
||||
if (! $user->is_placeholder) {
|
||||
throw new UserNotPlaceholderApiException();
|
||||
throw new UserNotPlaceholderApiException;
|
||||
}
|
||||
|
||||
app(InvitesTeamMembers::class)->invite(
|
||||
$this->user(),
|
||||
$organization,
|
||||
$user->email,
|
||||
Role::Employee->value,
|
||||
);
|
||||
$invitationService->inviteUser($organization, $user->email, Role::Employee);
|
||||
|
||||
return response()->json(null, 204);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
|
||||
use App\Http\Resources\V1\Organization\OrganizationResource;
|
||||
use App\Models\Organization;
|
||||
use App\Service\BillableRateService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
class OrganizationController extends Controller
|
||||
@@ -14,28 +16,42 @@ class OrganizationController extends Controller
|
||||
/**
|
||||
* Get organization
|
||||
*
|
||||
* @operationId getOrganization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function show(Organization $organization): OrganizationResource
|
||||
{
|
||||
$this->checkPermission($organization, 'organizations:view');
|
||||
|
||||
return new OrganizationResource($organization);
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
return new OrganizationResource($organization, $showBillableRate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update organization
|
||||
*
|
||||
* @operationId updateOrganization
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function update(Organization $organization, OrganizationUpdateRequest $request): OrganizationResource
|
||||
public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource
|
||||
{
|
||||
$this->checkPermission($organization, 'organizations:update');
|
||||
|
||||
$organization->name = $request->input('name');
|
||||
$organization->billable_rate = $request->input('billable_rate');
|
||||
$oldBillableRate = $organization->billable_rate;
|
||||
if ($request->has('employees_can_see_billable_rates')) {
|
||||
$organization->employees_can_see_billable_rates = $request->validated('employees_can_see_billable_rates');
|
||||
}
|
||||
$organization->billable_rate = $request->getBillableRate();
|
||||
$organization->save();
|
||||
|
||||
return new OrganizationResource($organization);
|
||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
|
||||
}
|
||||
|
||||
return new OrganizationResource($organization, true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Http\Requests\V1\Project\ProjectIndexRequest;
|
||||
use App\Http\Requests\V1\Project\ProjectStoreRequest;
|
||||
@@ -13,10 +14,12 @@ use App\Http\Resources\V1\Project\ProjectResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\User;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\BillableRateService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ProjectController extends Controller
|
||||
@@ -50,10 +53,18 @@ class ProjectController extends Controller
|
||||
if (! $canViewAllProjects) {
|
||||
$projectsQuery->visibleByEmployee($user);
|
||||
}
|
||||
$filterArchived = $request->getFilterArchived();
|
||||
if ($filterArchived === 'true') {
|
||||
$projectsQuery->whereNotNull('archived_at');
|
||||
} elseif ($filterArchived === 'false') {
|
||||
$projectsQuery->whereNull('archived_at');
|
||||
}
|
||||
|
||||
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return new ProjectCollection($projects);
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
return new ProjectCollection($projects, $showBillableRate);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,9 +78,12 @@ class ProjectController extends Controller
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:view', $project);
|
||||
|
||||
// Note: There is currently no need to check if a user is a member of the project,
|
||||
// since this is only relevant for users with the role "employee" and they can not access this endpoint.
|
||||
|
||||
$project->load('organization');
|
||||
|
||||
return new ProjectResource($project);
|
||||
return new ProjectResource($project, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,15 +96,20 @@ class ProjectController extends Controller
|
||||
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:create');
|
||||
$project = new Project();
|
||||
$project = new Project;
|
||||
$project->name = $request->input('name');
|
||||
$project->color = $request->input('color');
|
||||
$project->billable_rate = $request->input('billable_rate');
|
||||
$project->is_billable = (bool) $request->input('is_billable');
|
||||
$project->billable_rate = $request->getBillableRate();
|
||||
$project->client_id = $request->input('client_id');
|
||||
$project->is_public = $request->getIsPublic();
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$project->estimated_time = $request->getEstimatedTime();
|
||||
}
|
||||
$project->organization()->associate($organization);
|
||||
$project->save();
|
||||
|
||||
return new ProjectResource($project);
|
||||
return new ProjectResource($project, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,16 +119,41 @@ class ProjectController extends Controller
|
||||
*
|
||||
* @operationId updateProject
|
||||
*/
|
||||
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request): JsonResource
|
||||
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:update', $project);
|
||||
$project->name = $request->input('name');
|
||||
$project->color = $request->input('color');
|
||||
$project->billable_rate = $request->input('billable_rate');
|
||||
$project->client_id = $request->input('client_id');
|
||||
$project->is_billable = (bool) $request->input('is_billable');
|
||||
if ($request->has('is_archived')) {
|
||||
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
|
||||
}
|
||||
if ($request->has('is_public')) {
|
||||
$project->is_public = $request->boolean('is_public');
|
||||
}
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$project->estimated_time = $request->getEstimatedTime();
|
||||
}
|
||||
$oldBillableRate = $project->billable_rate;
|
||||
$clientIdChanged = false;
|
||||
$project->billable_rate = $request->getBillableRate();
|
||||
if ($project->client_id !== $request->input('client_id')) {
|
||||
$project->client_id = $request->input('client_id');
|
||||
$clientIdChanged = true;
|
||||
}
|
||||
$project->save();
|
||||
|
||||
return new ProjectResource($project);
|
||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
||||
}
|
||||
if ($clientIdChanged) {
|
||||
TimeEntry::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->whereBelongsTo($project, 'project')
|
||||
->update(['client_id' => $project->client_id]);
|
||||
}
|
||||
|
||||
return new ProjectResource($project, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,8 +174,8 @@ class ProjectController extends Controller
|
||||
throw new EntityStillInUseApiException('project', 'time_entry');
|
||||
}
|
||||
|
||||
DB::transaction(function () use (&$project) {
|
||||
$project->members->each(function (ProjectMember $member) {
|
||||
DB::transaction(function () use (&$project): void {
|
||||
$project->members->each(function (ProjectMember $member): void {
|
||||
$member->delete();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Service\BillableRateService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
@@ -58,25 +59,29 @@ class ProjectMemberController extends Controller
|
||||
*
|
||||
* @operationId createProjectMember
|
||||
*/
|
||||
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request): JsonResource
|
||||
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request, BillableRateService $billableRateService): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:create', $project);
|
||||
|
||||
$member = Member::findOrFail((string) $request->input('member_id'));
|
||||
if ($member->user->is_placeholder) {
|
||||
throw new InactiveUserCanNotBeUsedApiException();
|
||||
throw new InactiveUserCanNotBeUsedApiException;
|
||||
}
|
||||
if (ProjectMember::whereBelongsTo($project, 'project')->whereBelongsTo($member, 'member')->exists()) {
|
||||
throw new UserIsAlreadyMemberOfProjectApiException();
|
||||
throw new UserIsAlreadyMemberOfProjectApiException;
|
||||
}
|
||||
|
||||
$projectMember = new ProjectMember();
|
||||
$projectMember->billable_rate = $request->input('billable_rate');
|
||||
$projectMember = new ProjectMember;
|
||||
$projectMember->billable_rate = $request->getBillableRate();
|
||||
$projectMember->member()->associate($member);
|
||||
$projectMember->user()->associate($member->user);
|
||||
$projectMember->project()->associate($project);
|
||||
$projectMember->save();
|
||||
|
||||
if ($request->getBillableRate() !== null) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
||||
}
|
||||
|
||||
return new ProjectMemberResource($projectMember);
|
||||
}
|
||||
|
||||
@@ -87,12 +92,17 @@ class ProjectMemberController extends Controller
|
||||
*
|
||||
* @operationId updateProjectMember
|
||||
*/
|
||||
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request): JsonResource
|
||||
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
|
||||
$projectMember->billable_rate = $request->input('billable_rate');
|
||||
$oldBillableRate = $projectMember->billable_rate;
|
||||
$projectMember->billable_rate = $request->getBillableRate();
|
||||
$projectMember->save();
|
||||
|
||||
if ($oldBillableRate !== $request->getBillableRate()) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
|
||||
}
|
||||
|
||||
return new ProjectMemberResource($projectMember);
|
||||
}
|
||||
|
||||
@@ -103,12 +113,22 @@ class ProjectMemberController extends Controller
|
||||
*
|
||||
* @operationId deleteProjectMember
|
||||
*/
|
||||
public function destroy(Organization $organization, ProjectMember $projectMember): JsonResponse
|
||||
public function destroy(Organization $organization, ProjectMember $projectMember, BillableRateService $billableRateService): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:delete', projectMember: $projectMember);
|
||||
|
||||
$hadBillableRate = $projectMember->billable_rate !== null;
|
||||
$project = $projectMember->project;
|
||||
$member = $projectMember->member;
|
||||
|
||||
$projectMember->delete();
|
||||
|
||||
if ($hadBillableRate) {
|
||||
$billableRateService->updateTimeEntriesBillableRateForMember($member);
|
||||
$billableRateService->updateTimeEntriesBillableRateForProject($project);
|
||||
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
|
||||
}
|
||||
|
||||
return response()
|
||||
->json(null, 204);
|
||||
}
|
||||
|
||||
90
app/Http/Controllers/Api/V1/Public/ReportController.php
Normal file
90
app/Http/Controllers/Api/V1/Public/ReportController.php
Normal file
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Public;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Http\Controllers\Api\V1\Controller;
|
||||
use App\Http\Resources\V1\Report\DetailedWithDataReportResource;
|
||||
use App\Models\Report;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Service\Dto\ReportPropertiesDto;
|
||||
use App\Service\TimeEntryAggregationService;
|
||||
use App\Service\TimeEntryFilter;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ReportController extends Controller
|
||||
{
|
||||
/**
|
||||
* Get report by a share secret
|
||||
*
|
||||
* This endpoint is public and does not require authentication. The report must be public and not expired.
|
||||
* The report is considered expired if the `public_until` field is set and the date is in the past.
|
||||
* The report is considered public if the `is_public` field is set to `true`.
|
||||
*
|
||||
* @operationId getPublicReport
|
||||
*/
|
||||
public function show(Request $request, TimeEntryAggregationService $timeEntryAggregationService): DetailedWithDataReportResource
|
||||
{
|
||||
$shareSecret = $request->header('X-Api-Key');
|
||||
if (! is_string($shareSecret)) {
|
||||
throw new ModelNotFoundException;
|
||||
}
|
||||
|
||||
$report = Report::query()
|
||||
->with([
|
||||
'organization',
|
||||
])
|
||||
->where('share_secret', '=', $shareSecret)
|
||||
->where('is_public', '=', true)
|
||||
->where(function (Builder $builder): void {
|
||||
/** @var Builder<Report> $builder */
|
||||
$builder->whereNull('public_until')
|
||||
->orWhere('public_until', '>', now());
|
||||
})
|
||||
->firstOrFail();
|
||||
/** @var ReportPropertiesDto $properties */
|
||||
$properties = $report->properties;
|
||||
|
||||
$timeEntriesQuery = TimeEntry::query()
|
||||
->whereBelongsTo($report->organization, 'organization');
|
||||
|
||||
$filter = new TimeEntryFilter($timeEntriesQuery);
|
||||
$filter->addStart($properties->start);
|
||||
$filter->addEnd($properties->end);
|
||||
$filter->addActive($properties->active);
|
||||
$filter->addBillable($properties->billable);
|
||||
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
|
||||
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
|
||||
$filter->addTagIdsFilter($properties->tagIds?->toArray());
|
||||
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
|
||||
$filter->addClientIdsFilter($properties->clientIds?->toArray());
|
||||
$timeEntriesQuery = $filter->get();
|
||||
|
||||
$data = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||
$timeEntriesQuery->clone(),
|
||||
$report->properties->group,
|
||||
$report->properties->subGroup,
|
||||
$report->properties->timezone,
|
||||
$report->properties->weekStart,
|
||||
false,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
);
|
||||
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||
$timeEntriesQuery->clone(),
|
||||
TimeEntryAggregationType::fromInterval($report->properties->historyGroup),
|
||||
null,
|
||||
$report->properties->timezone,
|
||||
$report->properties->weekStart,
|
||||
true,
|
||||
$report->properties->start,
|
||||
$report->properties->end,
|
||||
);
|
||||
|
||||
return new DetailedWithDataReportResource($report, $data, $historyData);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user