Compare commits

...

138 Commits

Author SHA1 Message Date
Gregor Vostrak
5e3ac45ce0 make currency and createProject permission props in the create time entry modal, bump ui version 2024-11-13 13:04:12 +01:00
Constantin Graf
2cf9b3aa8f Fix force https for some reverse proxies 2024-11-12 21:50:26 +01:00
Constantin Graf
64b41e3018 Fix force https for some reverse proxies, Add url and path to debug endpoint 2024-11-12 19:03:36 +01:00
Gregor Vostrak
31014c1e29 fix type import api reference 2024-11-12 18:58:59 +01:00
Gregor Vostrak
d880717749 add TimeEntryCreateModal and MoreOptionsDropdown to ui package 2024-11-12 18:54:54 +01:00
Gregor Vostrak
df0f3b2680 patch new time entries into existing store when stores are refreshed on focus 2024-11-12 17:38:04 +01:00
Gregor Vostrak
4b0cb2e282 improve time picker parsing, fix nested escape listeners, change project member select 2024-11-12 16:07:51 +01:00
Gregor Vostrak
d5699da234 improve manual time entry modal, improve time picker, add human duration input 2024-11-12 16:07:51 +01:00
Constantin Graf
96f06bae1d Update README.md 2024-11-12 13:52:31 +01:00
Gregor Vostrak
e1243178fe Update README.md 2024-11-12 13:50:33 +01:00
Gregor Vostrak
cfbc98705a add bug report and feature request rules to the README 2024-11-12 13:48:04 +01:00
Gregor Vostrak
f0d6b234e5 add github sponsor information 2024-11-11 17:23:23 +01:00
Constantin Graf
4b622afcfc Change logic of tags_ids filter from AND to OR 2024-11-08 13:28:26 +01:00
Constantin Graf
45daeead61 Fix billable contract for self-hosting 2024-11-07 16:12:42 +01:00
Constantin Graf
95c1bcd4cb Change precheck order in migrations 2024-11-05 12:32:51 +01:00
Constantin Graf
3b3f593080 Fix foreign keys and deletion service 2024-11-05 12:09:04 +01:00
Constantin Graf
4224fdd57e Fixed report for query with no entries 2024-11-01 13:46:22 +01:00
Constantin Graf
f4cfeaa718 Fixed issue with daylight saving time in chart 2024-10-30 17:40:46 +01:00
Constantin Graf
04fcc1e3ae Fixed timezones in detailed export reports #2 2024-10-29 18:25:42 +01:00
Constantin Graf
f145e821a8 Fix incorrect grouping by billable in export report 2024-10-29 18:09:22 +01:00
Constantin Graf
eaaa83406d Fixed timezones in detailed export reports 2024-10-29 18:09:22 +01:00
Constantin Graf
9a60e2b911 Add tests for export endpoints 2024-10-29 17:20:21 +01:00
Gregor Vostrak
5a1e05374c disable pdf export button 2024-10-29 17:20:21 +01:00
Gregor Vostrak
ab4dbd64df add support for history_group and loading indicators to export buttons 2024-10-29 17:20:21 +01:00
Constantin Graf
8712cfb9dc Add report exports 2024-10-29 17:20:21 +01:00
Gregor Vostrak
7c1fe35754 add export buttons for aggregated export and pdf export 2024-10-29 17:20:21 +01:00
Constantin Graf
b0bcc4f330 Add pdf detailed report and placeholder for aggregate endpoint 2024-10-29 17:20:21 +01:00
Gregor Vostrak
5593d141ea automatically select project after create in time tracker component, fixes ST-457 2024-10-29 17:20:21 +01:00
Gregor Vostrak
d080b07e60 add Export download buttons 2024-10-29 17:20:21 +01:00
Constantin Graf
64535ceea6 Add report exports 2024-10-29 17:20:21 +01:00
Gregor Vostrak
e54df74d5d improve typing in solidtime ui package 2024-10-28 14:54:48 +01:00
Constantin Graf
27b40d863e Make email validation on registration stricter 2024-10-28 14:32:27 +01:00
Gregor Vostrak
b41d20839e improve empty state texts for employees 2024-10-28 14:24:40 +01:00
Gregor Vostrak
7acadda6d8 bump ui and api package versions 2024-10-28 14:14:50 +01:00
Gregor Vostrak
cd7573dcf1 hide create project buttons and modal depending on the permission 2024-10-28 14:14:50 +01:00
Gregor Vostrak
eb4debe481 move time entry mass updates to ui package and remove its dependencies 2024-10-28 14:14:50 +01:00
Constantin Graf
fd77e1e901 Fix logo for email client with no SVG support like Gmail 2024-10-28 12:21:57 +01:00
Constantin Graf
401cd4be0a Fixed setting multiple time entry description to an empty string 2024-10-22 16:45:21 +02:00
Gregor Vostrak
548307336a keep tags when starting a new time entry from a finished one, fixes ST-469 2024-10-22 13:27:30 +02:00
Constantin Graf
f534f90ca7 Fix force HTTPS config 2024-10-22 11:09:31 +02:00
Constantin Graf
0290013d19 Specify enclosure and escape for solidtime export and import 2024-10-15 13:35:37 +02:00
Constantin Graf
85f4a3049c Fixed escaping issues in importer 2024-10-15 12:57:45 +02:00
Constantin Graf
4c27f1a2de Fix bugs in computed attribute calculation 2024-10-15 12:57:45 +02:00
Constantin Graf
69d3ff4f7b Stricter validation for uuid and integer 2024-10-15 12:57:45 +02:00
Constantin Graf
2b1da883fb Fixed typo in console kernel 2024-10-11 13:10:09 +02:00
Gregor Vostrak
c291170d79 fix timing problem when updating multiple time entries, fixes #202 2024-10-09 17:35:22 +02:00
Constantin Graf
d9925d632e Fix api url 2024-10-09 17:34:08 +02:00
Gregor Vostrak
ddf11b394d do not load filament theme stylesheet in main application 2024-10-09 16:51:25 +02:00
Gregor Vostrak
129c132f97 make project and tags in mass updates resettable 2024-10-09 14:20:07 +02:00
Gregor Vostrak
26637e6f84 fix billable status update dropdown 2024-10-09 13:30:09 +02:00
Gregor Vostrak
612f40a4b0 fix unselecting bugs in time view 2024-10-09 13:26:51 +02:00
Gregor Vostrak
8f34fac0a6 add select all for time entry row heading 2024-10-09 03:01:34 +02:00
Gregor Vostrak
a374a52474 add select and deselect all on time and detailed reporting view 2024-10-09 01:48:23 +02:00
Gregor Vostrak
09586de2d5 clear selected time entries after mass delete in time vue 2024-10-09 01:00:12 +02:00
Gregor Vostrak
678d27c93a fix design inconsistencies between regular and aggregate row 2024-10-09 00:55:42 +02:00
Constantin Graf
7af1990935 Added fallback for local env to server overview widget 2024-10-08 21:31:35 +02:00
Constantin Graf
2372ee0622 Add update lookup and telemetry, Add version and build to app config 2024-10-08 21:31:35 +02:00
Gregor Vostrak
f147fb9725 add mass updates to time view 2024-10-08 21:28:23 +02:00
Constantin Graf
d5a4df738f Fix bug in time-entry.update-multiple; Add computed property for client_id 2024-10-08 19:19:08 +02:00
Gregor Vostrak
b3b84db004 fix wrong update on time range selector that causes duplicate time entry start requests, fixes ST-449 2024-10-08 18:16:06 +02:00
Gregor Vostrak
d3d3a98b08 change detailed reporting to use time entries mass delete endpoint 2024-10-08 13:26:27 +02:00
Gregor Vostrak
9f2ac70549 add mass delete time entries frontend, closes ST-450 2024-10-08 13:26:27 +02:00
Constantin Graf
071895791c Add endpoint to delete multiple time entries 2024-10-08 13:26:27 +02:00
Gregor Vostrak
9a50e144b3 improve time entry heading padding 2024-10-08 12:59:04 +02:00
Gregor Vostrak
a77b8a5ed2 add mass update to detailed reporting page 2024-10-08 12:59:04 +02:00
Constantin Graf
fcba96fbf6 Renamed skip to offset 2024-10-08 12:59:04 +02:00
Gregor Vostrak
d200de54a8 fix chart overflowing on some screen sizes 2024-10-08 12:59:04 +02:00
Constantin Graf
a882ec6ca0 Add skip and meta to resource in time entry endpoint 2024-10-08 12:59:04 +02:00
Gregor Vostrak
3ee7839ca9 add detailed reporting page 2024-10-08 12:59:04 +02:00
Gregor Vostrak
165391861a remove debug message 2024-10-01 22:59:59 +02:00
Gregor Vostrak
8d950c6d45 hide billable rate in projects table for employees when employees_can_see_billable_rates is disabled 2024-10-01 22:48:27 +02:00
Gregor Vostrak
6c7b1b3f21 add employees_can_see_billable_rates setting to organization settings 2024-10-01 22:48:27 +02:00
Constantin Graf
51cd919db6 Add organization setting employees_can_see_billable_rates 2024-10-01 22:48:27 +02:00
Constantin Graf
9d279d4980 Fix ARM image 2024-09-30 23:36:58 +02:00
Gregor Vostrak
32c7e55a15 add Upgrade Info Modal, fix hardcoded premium flag 2024-09-30 14:52:18 +02:00
Gregor Vostrak
084647c2a6 add project edit button to project show page and billing rate info, fixes ST-236 2024-09-30 14:19:47 +02:00
Gregor Vostrak
469f128604 fix project name column overflow on some screen sizes with long project names 2024-09-30 14:19:47 +02:00
Gregor Vostrak
c9c221de62 improve focus handling in time tracker component, improve focus-visible state for timetracker start and stop button 2024-09-30 14:19:47 +02:00
Gregor Vostrak
878bbd359d cleanup dayjs abstraction usage and useCurrentTimeEntry api for starting and stopping time entries 2024-09-30 14:19:47 +02:00
Gregor Vostrak
a6528102fe add estimated project and tasks frontend 2024-09-30 14:19:47 +02:00
Constantin Graf
bff766d363 Add spend_time to projects and tasks 2024-09-30 14:19:47 +02:00
Constantin Graf
2e8da98287 Added php-cs-fixer rule void_return 2024-09-30 14:19:47 +02:00
Constantin Graf
a820d8540f Added time estimates for projects and tasks, fixes ST-283 2024-09-30 14:19:47 +02:00
Constantin Graf
78ea8a673b Fixed timezone problem in unit tests 2024-09-30 11:02:11 +02:00
Gregor Vostrak
8b50f33cc9 chore: remove unnecessary startLiveTimer call in current time entry init 2024-09-26 01:02:34 +02:00
Gregor Vostrak
014bffe86d display the number of projects in a separate column in the clients table 2024-09-26 00:59:51 +02:00
Gregor Vostrak
2dbde63043 clear client name input on client create submit, fixes #189 2024-09-25 14:51:25 +02:00
Gregor Vostrak
876a41cb2a fix client page header design bug 2024-09-23 12:54:09 +02:00
Gregor Vostrak
1036502e49 remove wrong character from billing banner 2024-09-20 23:40:02 +02:00
Gregor Vostrak
5bf4dc79c2 hide explanation text for billing banner on mobile view 2024-09-20 12:59:09 +02:00
Constantin Graf
2592dd3b9e Fix local setup 2024-09-19 23:48:03 +02:00
Gregor Vostrak
05f240efc9 fix custom date picker update in reporting 2024-09-19 11:16:31 +02:00
Gregor Vostrak
d5b35ef420 improve billing banners on mobile 2024-09-17 22:32:43 +02:00
Gregor Vostrak
7e5374d5b1 add presets for date rage picker in reporting 2024-09-17 22:32:43 +02:00
Gregor Vostrak
36cdae523f fix bug where chart does not update project colors on data change 2024-09-17 22:32:43 +02:00
Gregor Vostrak
b2ad4b3785 add description grouping to reporting page (fixes ST-399), persist grouping selection in local storage 2024-09-17 22:32:43 +02:00
Constantin Graf
5e4270e3f5 Add time entry aggregation type “description” 2024-09-17 22:32:43 +02:00
Constantin Graf
d4e71e7c2c Lock import and increase timeout 2024-09-17 22:32:31 +02:00
Constantin Graf
5c6b32d5bb Deactivate auditing for time entries in importer 2024-09-16 21:50:01 +02:00
Constantin Graf
37400d239c Add command admin:user:verify 2024-09-13 17:59:10 +02:00
Constantin Graf
50902e7705 Renamed command admin:delete-organization to admin:organization:delete 2024-09-13 17:59:10 +02:00
Constantin Graf
498f29617e Add mapping for legacy timezones 2024-09-13 17:59:10 +02:00
Constantin Graf
61cc80dc6e Fixed export bug 2024-09-12 15:31:20 +02:00
Constantin Graf
0a0b7a03b4 Deactivate auditing for import and increase max_execution_time 2024-09-12 15:31:20 +02:00
Constantin Graf
cc10af0b97 Reduce overhead of health check endpoints 2024-09-12 15:31:20 +02:00
Constantin Graf
d3545b3c73 Allow time entries with less than one second duration 2024-09-12 15:31:20 +02:00
Gregor Vostrak
9e1413c15f unify and fix chart styles in dashboard and reporting view, fixes ST-356 2024-09-12 15:12:50 +02:00
Gregor Vostrak
ac85e778a4 fix error handling for organization export, fixes ST-426 2024-09-12 14:46:05 +02:00
Gregor Vostrak
9189910136 fix available roles filter, fixes ST-425 2024-09-12 14:41:23 +02:00
Gregor Vostrak
85315fc62f add client grouping and expandable project tasks to project task timetracker dropdown, fixes ST-253 2024-09-11 18:07:35 +02:00
Constantin Graf
91b56ae92f Fixed deprecation warning 2024-09-11 18:07:35 +02:00
Gregor Vostrak
845f0d19d8 add trial expiry day countdown to billing banner 2024-09-11 18:07:35 +02:00
Gregor Vostrak
d211e962f5 fix reporting multiselect dropdowns max height, fixes ST-414 2024-09-11 18:07:35 +02:00
Gregor Vostrak
f0705e1e4a fix sidebar navigation overflowing, add scrollbar only to nav items 2024-09-11 18:07:35 +02:00
Gregor Vostrak
b990387775 make No Project white in chart fixes ST-360 2024-09-11 18:07:35 +02:00
Gregor Vostrak
a4d6ba3cdb improve reporting chart, fix project table with long client name, fixes ST-414 2024-09-11 18:07:35 +02:00
Gregor Vostrak
3b41d90b07 fix layout bug in time view with small time entries, fixes ST-414 2024-09-11 18:07:35 +02:00
Gregor Vostrak
b391f47d1b fix scroll & jumping issues with task dropdown, fixes ST-395 2024-09-11 18:07:35 +02:00
Gregor Vostrak
19cc05140a add archiving for clients, fixes ST-279 2024-09-11 18:07:35 +02:00
Gregor Vostrak
5592d87cd5 fix e2e tests, filter requests to listen to correct time entry update request 2024-09-11 18:07:35 +02:00
Gregor Vostrak
b518187ecb Dashboard Data Refresh After creating a time entry, fixes ST-299 2024-09-11 18:07:35 +02:00
Gregor Vostrak
c09119af33 fix project member billable rate not shown correctly in modal, fixes ST-363 2024-09-11 18:07:35 +02:00
Constantin Graf
ceba49d054 Reverting phpstan update to prevent incorrect warnings 2024-09-11 18:07:35 +02:00
Constantin Graf
01dd13b947 Add getTrialUntil to BillingContract; Allow delete endpoints after blocking 2024-09-11 18:07:35 +02:00
Gregor Vostrak
83301d03ca respect billing permission in frontend, fix hiding of billing banners 2024-09-11 18:07:35 +02:00
Constantin Graf
4969fcba7e Add billing permission to owner 2024-09-11 18:07:35 +02:00
Gregor Vostrak
48b2bb436e show action blocked modal with instructions instead of small notification when server returns action blocked error 2024-09-11 18:07:35 +02:00
Gregor Vostrak
30ed47d3fb add trial banners and unblock member invite modal during trial 2024-09-11 18:07:35 +02:00
Gregor Vostrak
2bad9eaa3c chore: type OrganizationInvitation in DefaultImporter, new formatting rules 2024-09-11 18:07:35 +02:00
Constantin Graf
78b41ea0b7 Added reply to config 2024-09-11 18:07:35 +02:00
Constantin Graf
d8968399d6 Updated dependencies; Fixed codeformatting and phpstan 2024-09-11 18:07:35 +02:00
Constantin Graf
5b7df869ad Added trial and blocking to billing contract, fixed bug in running time tracker command 2024-09-11 18:07:35 +02:00
Constantin Graf
7c593f8f87 Enable auditing for unit testing 2024-09-11 17:58:29 +02:00
Gregor Vostrak
22b2933d85 open export downloads in the same window 2024-09-11 17:58:29 +02:00
Gregor Vostrak
6dd9d5bab0 add exporter in frontend, fixes ST-382 2024-09-11 17:58:29 +02:00
Constantin Graf
9a8945b0dc Add local setup for S3 2024-09-11 17:58:29 +02:00
Constantin Graf
fc614b796c Increaded timeout for ARM build 2024-09-10 19:40:57 +02:00
Constantin Graf
b031598f79 Added ARM build 2024-09-10 19:00:44 +02:00
382 changed files with 14657 additions and 3279 deletions

22
.env.ci
View File

@@ -6,12 +6,13 @@ APP_URL=http://localhost
APP_FORCE_HTTPS=false APP_FORCE_HTTPS=false
SESSION_SECURE_COOKIE=false SESSION_SECURE_COOKIE=false
# Logging
LOG_CHANNEL=stack LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug LOG_LEVEL=debug
# Database
DB_CONNECTION=pgsql_test DB_CONNECTION=pgsql_test
DB_TEST_HOST=127.0.0.1 DB_TEST_HOST=127.0.0.1
DB_TEST_PORT=5432 DB_TEST_PORT=5432
DB_TEST_DATABASE=laravel DB_TEST_DATABASE=laravel
@@ -20,26 +21,21 @@ DB_TEST_PASSWORD=root
BROADCAST_DRIVER=log BROADCAST_DRIVER=log
CACHE_DRIVER=file CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync QUEUE_CONNECTION=sync
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1 # Mail
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log MAIL_MAILER=log
MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}" MAIL_FROM_NAME="${APP_NAME}"
S3_ACCESS_KEY_ID= # Filesystems
S3_SECRET_ACCESS_KEY= FILESYSTEM_DISK=local
S3_REGION=us-east-1 PUBLIC_FILESYSTEM_DISK=public
S3_BUCKET=
S3_USE_PATH_STYLE_ENDPOINT=false # Services
GOTENBERG_URL=http://0.0.0.0:3000
PUSHER_APP_ID= PUSHER_APP_ID=
PUSHER_APP_KEY= PUSHER_APP_KEY=

View File

@@ -4,15 +4,15 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true APP_DEBUG=true
APP_URL=https://solidtime.test APP_URL=https://solidtime.test
AUDITING_ENABLED=true AUDITING_ENABLED=true
SUPER_ADMINS=admin@example.com SUPER_ADMINS=admin@example.com
# Logging
LOG_CHANNEL=single LOG_CHANNEL=single
LOG_DEPRECATIONS_CHANNEL=deprecation LOG_DEPRECATIONS_CHANNEL=deprecation
LOG_LEVEL=debug LOG_LEVEL=debug
# Database
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST=pgsql DB_HOST=pgsql
DB_PORT=5432 DB_PORT=5432
DB_DATABASE=laravel DB_DATABASE=laravel
@@ -27,17 +27,11 @@ DB_TEST_PASSWORD=root
BROADCAST_DRIVER=log BROADCAST_DRIVER=log
CACHE_DRIVER=file CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync QUEUE_CONNECTION=sync
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1 # Mail
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp MAIL_MAILER=smtp
MAIL_HOST=mailpit MAIL_HOST=mailpit
MAIL_PORT=1025 MAIL_PORT=1025
@@ -47,12 +41,6 @@ MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="no-reply@solidtime.test" MAIL_FROM_ADDRESS="no-reply@solidtime.test"
MAIL_FROM_NAME="${APP_NAME}" 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_ID=
PUSHER_APP_KEY= PUSHER_APP_KEY=
PUSHER_APP_SECRET= PUSHER_APP_SECRET=
@@ -61,6 +49,20 @@ PUSHER_PORT=443
PUSHER_SCHEME=https PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1 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_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"

View File

@@ -1,4 +1,6 @@
APP_NAME=solidtime APP_NAME=solidtime
APP_VERSION=0.0.0
APP_BUILD=0
VITE_APP_NAME=solidtime VITE_APP_NAME=solidtime
APP_ENV=production APP_ENV=production
APP_DEBUG=false APP_DEBUG=false

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: solidtime-io

View File

@@ -15,20 +15,60 @@ name: Build - Private
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 20
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v4 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" - name: "Use Node.js"
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: '20.x'
- name: "Copy .env template for production"
run: cp .env.production .env && cat .env
- name: "Checkout billing extension" - name: "Checkout billing extension"
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:
@@ -114,6 +154,9 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=sha,format=long type=sha,format=long
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -125,6 +168,7 @@ jobs:
DOCKER_FILES_BASE_PATH=docker/prod/ DOCKER_FILES_BASE_PATH=docker/prod/
file: docker/prod/Dockerfile file: docker/prod/Dockerfile
push: true push: true
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha

View File

@@ -15,14 +15,59 @@ name: Build - Public
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v4 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" - 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" - name: "Install dependencies"
uses: php-actions/composer@v6 uses: php-actions/composer@v6
@@ -48,18 +93,28 @@ jobs:
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} 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" - name: "Docker meta"
id: "meta" id: "meta"
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: solidtime/solidtime images: |
solidtime/solidtime
ghcr.io/${{ github.repository }}
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
type=semver,pattern={{version}} type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx" - name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -70,7 +125,7 @@ jobs:
file: docker/prod/Dockerfile file: docker/prod/Dockerfile
build-args: | build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/ DOCKER_FILES_BASE_PATH=docker/prod/
platforms: linux/amd64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}

View File

@@ -20,7 +20,15 @@ jobs:
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --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: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -28,6 +28,11 @@ We also have an examples repository [here](https://github.com/solidtime-io/self-
If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/) If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)
## Issues & Feature Requests
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 ## Contributing
This project is in a very early stage. The structure and APIs are still subject to change and not stable. This project is in a very early stage. The structure and APIs are still subject to change and not stable.
@@ -35,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. 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 ## Security
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file. Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.

View File

@@ -43,9 +43,9 @@ class CreateNewUser implements CreatesNewUsers
'email' => [ 'email' => [
'required', 'required',
'string', 'string',
'email', 'email:rfc,strict',
'max:255', 'max:255',
new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder { UniqueEloquent::make(User::class, 'email', function (Builder $builder): Builder {
/** @var Builder<User> $builder */ /** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false); return $builder->where('is_placeholder', '=', false);
}), }),
@@ -62,7 +62,10 @@ class CreateNewUser implements CreatesNewUsers
if (app(TimezoneService::class)->isValid($input['timezone'])) { if (app(TimezoneService::class)->isValid($input['timezone'])) {
$timezone = $input['timezone']; $timezone = $input['timezone'];
} else { } else {
Log::debug('Invalid timezone', ['timezone' => $input['timezone']]); $timezone = app(TimezoneService::class)->mapLegacyTimezone($input['timezone']);
if ($timezone === null) {
Log::debug('Invalid timezone', ['timezone' => $input['timezone']]);
}
} }
} }
@@ -77,30 +80,31 @@ class CreateNewUser implements CreatesNewUsers
} }
$currency = $ipLookupResponse->currency; $currency = $ipLookupResponse->currency;
} }
$user = null;
$user = DB::transaction(function () use ($input, $timezone, $startOfWeek, $currency) { $organization = null;
return tap(User::create([ DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void {
$user = User::create([
'name' => $input['name'], 'name' => $input['name'],
'email' => $input['email'], 'email' => $input['email'],
'password' => Hash::make($input['password']), 'password' => Hash::make($input['password']),
'timezone' => $timezone ?? 'UTC', 'timezone' => $timezone ?? 'UTC',
'week_start' => $startOfWeek, 'week_start' => $startOfWeek,
]), function (User $user) use ($currency): void { ]);
$organization = new Organization();
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$organization->personal_team = true;
$organization->currency = $currency ?? 'EUR';
$organization->owner()->associate($user);
$organization->save();
$organization->users()->attach( $organization = new Organization;
$user, [ $organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
'role' => Role::Owner->value, $organization->personal_team = true;
] $organization->currency = $currency ?? 'EUR';
); $organization->owner()->associate($user);
$organization->save();
$user->ownedTeams()->save($organization); $organization->users()->attach(
}); $user, [
'role' => Role::Owner->value,
]
);
$user->ownedTeams()->save($organization);
}); });
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent']; $newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];

View File

@@ -35,7 +35,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
'required', 'required',
'email', 'email',
'max:255', 'max:255',
(new UniqueEloquent(User::class, 'email'))->ignore($user->id)->query(function (Builder $query) { UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */ /** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false); return $query->where('is_placeholder', '=', false);
}), }),

View File

@@ -38,7 +38,7 @@ class AddOrganizationMember implements AddsTeamMembers
AddingTeamMember::dispatch($organization, $newOrganizationMember); AddingTeamMember::dispatch($organization, $newOrganizationMember);
DB::transaction(function () use ($organization, $newOrganizationMember, $role) { DB::transaction(function () use ($organization, $newOrganizationMember, $role): void {
$organization->users()->attach( $organization->users()->attach(
$newOrganizationMember, ['role' => $role] $newOrganizationMember, ['role' => $role]
); );
@@ -71,9 +71,10 @@ class AddOrganizationMember implements AddsTeamMembers
'email' => [ 'email' => [
'required', 'required',
'email', '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); 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' => [ 'role' => [
'required', 'required',
@@ -92,7 +93,7 @@ class AddOrganizationMember implements AddsTeamMembers
*/ */
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
{ {
return function ($validator) use ($team, $email) { return function ($validator) use ($team, $email): void {
$validator->errors()->addIf( $validator->errors()->addIf(
$team->hasRealUserWithEmail($email), $team->hasRealUserWithEmail($email),
'email', 'email',

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Actions\Jetstream; namespace App\Actions\Jetstream;
use App\Enums\Role; use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@@ -12,7 +13,6 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\CreatesTeams; use Laravel\Jetstream\Contracts\CreatesTeams;
use Laravel\Jetstream\Events\AddingTeam;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
class CreateOrganization implements CreatesTeams class CreateOrganization implements CreatesTeams
@@ -33,9 +33,7 @@ class CreateOrganization implements CreatesTeams
'name' => ['required', 'string', 'max:255'], 'name' => ['required', 'string', 'max:255'],
])->validateWithBag('createTeam'); ])->validateWithBag('createTeam');
AddingTeam::dispatch($user); $organization = new Organization;
$organization = new Organization();
$organization->name = $input['name']; $organization->name = $input['name'];
$organization->personal_team = false; $organization->personal_team = false;
$organization->owner()->associate($user); $organization->owner()->associate($user);
@@ -47,10 +45,12 @@ class CreateOrganization implements CreatesTeams
] ]
); );
$user->ownedTeams()->save($organization);
$user->switchTeam($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; return $organization;
} }
} }

View File

@@ -19,6 +19,6 @@ class InviteOrganizationMember implements InvitesTeamMembers
*/ */
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
{ {
throw new MovedToApiException(); throw new MovedToApiException;
} }
} }

View File

@@ -19,6 +19,6 @@ class RemoveOrganizationMember implements RemovesTeamMembers
*/ */
public function remove(User $user, Organization $organization, User $teamMember): void public function remove(User $user, Organization $organization, User $teamMember): void
{ {
throw new MovedToApiException(); throw new MovedToApiException;
} }
} }

View File

@@ -20,6 +20,6 @@ class UpdateMemberRole
*/ */
public function update(User $actingUser, Organization $organization, string $userId, string $role): void public function update(User $actingUser, Organization $organization, string $userId, string $role): void
{ {
throw new MovedToApiException(); throw new MovedToApiException;
} }
} }

View File

@@ -36,7 +36,7 @@ class UpdateOrganization implements UpdatesTeamNames
'currency' => [ 'currency' => [
'required', 'required',
'string', 'string',
new CurrencyRule(), new CurrencyRule,
], ],
])->validateWithBag('updateTeamName'); ])->validateWithBag('updateTeamName');

View File

@@ -22,7 +22,7 @@ class ValidateOrganizationDeletion
public function validate(User $user, Organization $organization): void public function validate(User $user, Organization $organization): void
{ {
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) { if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
throw new AuthorizationException(); throw new AuthorizationException;
} }
} }
} }

View File

@@ -9,14 +9,14 @@ use App\Service\DeletionService;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Str; use Illuminate\Support\Str;
class DeleteOrganizationCommand extends Command class OrganizationDeleteCommand extends Command
{ {
/** /**
* The name and signature of the console command. * The name and signature of the console command.
* *
* @var string * @var string
*/ */
protected $signature = 'admin:delete-organization protected $signature = 'admin:organization:delete
{ organization : The ID of the organization to delete }'; { organization : The ID of the organization to delete }';
/** /**
@@ -24,7 +24,7 @@ class DeleteOrganizationCommand extends Command
* *
* @var string * @var string
*/ */
protected $description = 'Delete a organization.'; protected $description = 'Delete a organization';
/** /**
* Execute the console command. * Execute the console command.

View 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;
}
}

View File

@@ -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;
}
}

View 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;
}
}

View File

@@ -6,7 +6,9 @@ namespace App\Console\Commands\TimeEntry;
use App\Mail\TimeEntryStillRunningMail; use App\Mail\TimeEntryStillRunningMail;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Mail;
@@ -47,8 +49,12 @@ class TimeEntrySendStillRunningMailsCommand extends Command
->with([ ->with([
'user', 'user',
]) ])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
})
->orderBy('created_at', 'asc') ->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) { ->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
/** @var Collection<int, TimeEntry> $timeEntries */ /** @var Collection<int, TimeEntry> $timeEntries */
foreach ($timeEntries as $timeEntry) { foreach ($timeEntries as $timeEntry) {
$user = $timeEntry->user; $user = $timeEntry->user;

View File

@@ -17,6 +17,14 @@ class Kernel extends ConsoleKernel
$schedule->command('time-entry:send-still-running-mails') $schedule->command('time-entry:send-still-running-mails')
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails')) ->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
->everyTenMinutes(); ->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();
} }
/** /**

View 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,
};
}
}

View File

@@ -4,8 +4,12 @@ declare(strict_types=1);
namespace App\Enums; namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryAggregationType: string enum TimeEntryAggregationType: string
{ {
use LaravelEnumHelper;
case Day = 'day'; case Day = 'day';
case Week = 'week'; case Week = 'week';
case Month = 'month'; case Month = 'month';
@@ -15,6 +19,17 @@ enum TimeEntryAggregationType: string
case Task = 'task'; case Task = 'task';
case Client = 'client'; case Client = 'client';
case Billable = 'billable'; 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 public function toInterval(): ?TimeEntryAggregationTypeInterval
{ {

View 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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -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';
}

View File

@@ -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';
}

View 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';
}

View File

@@ -27,7 +27,7 @@ class Handler extends ExceptionHandler
*/ */
public function register(): void public function register(): void
{ {
$this->reportable(function (Throwable $e) { $this->reportable(function (Throwable $e): void {
// //
}); });
} }

View File

@@ -24,20 +24,20 @@ class ApiExceptionTypeToSchema extends ExceptionToResponseExtension
public function toResponse(Type $type): Response public function toResponse(Type $type): Response
{ {
$validationResponseBodyType = (new OpenApiTypes\ObjectType()) $validationResponseBodyType = (new OpenApiTypes\ObjectType)
->addProperty( ->addProperty(
'error', 'error',
(new OpenApiTypes\BooleanType()) (new OpenApiTypes\BooleanType)
->setDescription('Whether the response is an error.') ->setDescription('Whether the response is an error.')
) )
->addProperty( ->addProperty(
'key', 'key',
(new OpenApiTypes\StringType()) (new OpenApiTypes\StringType)
->setDescription('Error key.') ->setDescription('Error key.')
) )
->addProperty( ->addProperty(
'message', 'message',
(new OpenApiTypes\StringType()) (new OpenApiTypes\StringType)
->setDescription('Error message.') ->setDescription('Error message.')
) )
->setRequired(['error', 'key', 'message']); ->setRequired(['error', 'key', 'message']);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Extensions\Scramble; namespace App\Extensions\Scramble;
use App\Http\Resources\PaginatedResourceCollection; use App\Http\Resources\PaginatedResourceCollection;
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
use Dedoc\Scramble\Extensions\TypeToSchemaExtension; use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
use Dedoc\Scramble\Support\Generator\Response; use Dedoc\Scramble\Support\Generator\Response;
use Dedoc\Scramble\Support\Generator\Schema; use Dedoc\Scramble\Support\Generator\Schema;
@@ -44,39 +45,49 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
return null; return null;
} }
$type = new OpenApiObjectType; $newType = new OpenApiObjectType;
$type->addProperty('data', (new ArrayType())->setItems($collectingType)); $newType->addProperty('data', (new ArrayType)->setItems($collectingType));
$type->addProperty( if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) {
'links', $newType->addProperty(
(new OpenApiObjectType) 'meta',
->addProperty('first', (new StringType)->nullable(true)) (new OpenApiObjectType)
->addProperty('last', (new StringType)->nullable(true)) ->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->addProperty('prev', (new StringType)->nullable(true)) ->setRequired(['total'])
->addProperty('next', (new StringType)->nullable(true)) );
->setRequired(['first', 'last', 'prev', 'next']) $newType->setRequired(['data', 'meta']);
); } else {
$type->addProperty( $newType->addProperty(
'meta', 'links',
(new OpenApiObjectType) (new OpenApiObjectType)
->addProperty('current_page', new IntegerType) ->addProperty('first', (new StringType)->nullable(true))
->addProperty('from', (new IntegerType)->nullable(true)) ->addProperty('last', (new StringType)->nullable(true))
->addProperty('last_page', new IntegerType) ->addProperty('prev', (new StringType)->nullable(true))
->addProperty('links', (new ArrayType)->setItems( ->addProperty('next', (new StringType)->nullable(true))
(new OpenApiObjectType) ->setRequired(['first', 'last', 'prev', 'next'])
->addProperty('url', (new StringType)->nullable(true)) );
->addProperty('label', new StringType) $newType->addProperty(
->addProperty('active', new BooleanType) 'meta',
->setRequired(['url', 'label', 'active']) (new OpenApiObjectType)
)->setDescription('Generated paginator links.')) ->addProperty('current_page', new IntegerType)
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.')) ->addProperty('from', (new IntegerType)->nullable(true))
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.')) ->addProperty('last_page', new IntegerType)
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.')) ->addProperty('links', (new ArrayType)->setItems(
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.')) (new OpenApiObjectType)
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total']) ->addProperty('url', (new StringType)->nullable(true))
); ->addProperty('label', new StringType)
$type->setRequired(['data', 'links', 'meta']); ->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 $type; return $newType;
} }
/** /**

View File

@@ -70,6 +70,7 @@ class OrganizationResource extends Resource
'nullable', 'nullable',
'integer', 'integer',
'gt:0', 'gt:0',
'max:2147483647',
]) ])
->numeric(), ->numeric(),
Forms\Components\DateTimePicker::make('created_at') Forms\Components\DateTimePicker::make('created_at')
@@ -122,7 +123,7 @@ class OrganizationResource extends Resource
->persistent() ->persistent()
->send(); ->send();
return response()->streamDownload(function () use ($file) { return response()->streamDownload(function () use ($file): void {
echo Storage::disk(config('filesystems.private'))->get($file); echo Storage::disk(config('filesystems.private'))->get($file);
}, 'export.zip'); }, 'export.zip');
} catch (\Exception $exception) { } catch (\Exception $exception) {
@@ -137,7 +138,7 @@ class OrganizationResource extends Resource
}), }),
Action::make('Import') Action::make('Import')
->icon('heroicon-o-inbox-arrow-down') ->icon('heroicon-o-inbox-arrow-down')
->action(function (Organization $record, array $data) { ->action(function (Organization $record, array $data): void {
try { try {
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']); $file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
if ($file === null) { if ($file === null) {

View File

@@ -29,6 +29,7 @@ class ProjectMemberResource extends Resource
'nullable', 'nullable',
'integer', 'integer',
'gt:0', 'gt:0',
'max:2147483647',
]) ])
->numeric(), ->numeric(),
Forms\Components\Select::make('user_id') Forms\Components\Select::make('user_id')

View File

@@ -45,6 +45,7 @@ class ProjectResource extends Resource
'nullable', 'nullable',
'integer', 'integer',
'gt:0', 'gt:0',
'max:2147483647',
]) ])
->numeric(), ->numeric(),
Forms\Components\Select::make('organization_id') Forms\Components\Select::make('organization_id')

View File

@@ -49,7 +49,7 @@ class TimeEntryResource extends Resource
->label('End') ->label('End')
->nullable() ->nullable()
->rules([ ->rules([
'after:start', 'after_or_equal:start',
]), ]),
Select::make('user_id') Select::make('user_id')
->relationship(name: 'user', titleAttribute: 'email') ->relationship(name: 'user', titleAttribute: 'email')

View File

@@ -111,9 +111,18 @@ class UserResource extends Resource
->filters([ ->filters([
TernaryFilter::make('real_user') TernaryFilter::make('real_user')
->queries( ->queries(
true: fn (Builder $query) => $query->where('is_placeholder', '=', false), true: function (Builder $query): Builder {
false: fn (Builder $query) => $query->where('is_placeholder', '=', true), /** @var Builder<User> $query */
blank: fn (Builder $query) => $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?'), ->label('Real User?'),
TernaryFilter::make('email_verified') TernaryFilter::make('email_verified')

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Filament\Widgets; namespace App\Filament\Widgets;
use App\Models\TimeEntry;
use App\Models\User; use App\Models\User;
use Filament\Widgets\StatsOverviewWidget as BaseWidget; use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat; use Filament\Widgets\StatsOverviewWidget\Stat;
@@ -21,7 +22,8 @@ class ActiveUserOverview extends BaseWidget
$placeholderUserCount = User::query()->where('is_placeholder', '=', true)->count(); $placeholderUserCount = User::query()->where('is_placeholder', '=', true)->count();
$activeInLastWeek = User::query() $activeInLastWeek = User::query()
->where('is_placeholder', '=', false) ->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()) $query->where('created_at', '>=', now()->subWeek())
->orWhere('updated_at', '>=', now()->subWeek()); ->orWhere('updated_at', '>=', now()->subWeek());
}) })

View 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,
];
}
}

View File

@@ -66,7 +66,7 @@ class ClientController extends Controller
{ {
$this->checkPermission($organization, 'clients:create'); $this->checkPermission($organization, 'clients:create');
$client = new Client(); $client = new Client;
$client->name = $request->input('name'); $client->name = $request->input('name');
$client->organization()->associate($organization); $client->organization()->associate($organization);
$client->save(); $client->save();

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Models\Organization; use App\Models\Organization;
use App\Service\BillingContract;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@@ -12,8 +13,7 @@ class Controller extends \App\Http\Controllers\Controller
{ {
public function __construct( public function __construct(
protected PermissionStore $permissionStore, protected PermissionStore $permissionStore,
) { ) {}
}
/** /**
* @throws AuthorizationException * @throws AuthorizationException
@@ -21,7 +21,7 @@ class Controller extends \App\Http\Controllers\Controller
protected function checkPermission(Organization $organization, string $permission): void protected function checkPermission(Organization $organization, string $permission): void
{ {
if (! $this->permissionStore->has($organization, $permission)) { if (! $this->permissionStore->has($organization, $permission)) {
throw new AuthorizationException(); throw new AuthorizationException;
} }
} }
@@ -37,11 +37,16 @@ class Controller extends \App\Http\Controllers\Controller
return; return;
} }
} }
throw new AuthorizationException(); throw new AuthorizationException;
} }
protected function hasPermission(Organization $organization, string $permission): bool protected function hasPermission(Organization $organization, string $permission): bool
{ {
return $this->permissionStore->has($organization, $permission); return $this->permissionStore->has($organization, $permission);
} }
protected function canAccessPremiumFeatures(Organization $organization): bool
{
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
}
} }

View File

@@ -35,7 +35,7 @@ class ImportController extends Controller
foreach ($importers as $key => $importerClass) { foreach ($importers as $key => $importerClass) {
/** @var ImporterContract $importer */ /** @var ImporterContract $importer */
$importer = new $importerClass(); $importer = new $importerClass;
$importersResponse[] = [ $importersResponse[] = [
'key' => $key, 'key' => $key,
'name' => $importer->getName(), 'name' => $importer->getName(),

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Role; use App\Enums\Role;
use App\Events\MemberMadeToPlaceholder;
use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization; use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed; use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
@@ -80,16 +82,16 @@ class MemberController extends Controller
$newRole = $request->getRole(); $newRole = $request->getRole();
$oldRole = Role::from($member->role); $oldRole = Role::from($member->role);
if ($oldRole === Role::Owner) { if ($oldRole === Role::Owner) {
throw new OrganizationNeedsAtLeastOneOwner(); throw new OrganizationNeedsAtLeastOneOwner;
} }
if ($newRole === Role::Placeholder) { if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed(); throw new ChangingRoleToPlaceholderIsNotAllowed;
} }
if ($newRole === Role::Owner) { if ($newRole === Role::Owner) {
if ($this->hasPermission($organization, 'members:change-ownership')) { if ($this->hasPermission($organization, 'members:change-ownership')) {
$memberService->changeOwnership($organization, $member); $memberService->changeOwnership($organization, $member);
} else { } else {
throw new OnlyOwnerCanChangeOwnership(); throw new OnlyOwnerCanChangeOwnership;
} }
} else { } else {
$member->role = $request->getRole()->value; $member->role = $request->getRole()->value;
@@ -118,15 +120,34 @@ class MemberController extends Controller
throw new EntityStillInUseApiException('member', 'project_member'); throw new EntityStillInUseApiException('member', 'project_member');
} }
if ($member->role === Role::Owner->value) { if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization(); throw new CanNotRemoveOwnerFromOrganization;
} }
$member->delete(); $member->delete();
MemberRemoved::dispatch($member, $organization);
return response() return response()
->json(null, 204); ->json(null, 204);
} }
/**
* @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 * Invite a placeholder member to become a real member of the organization
* *
@@ -140,7 +161,7 @@ class MemberController extends Controller
$user = $member->user; $user = $member->user;
if (! $user->is_placeholder) { if (! $user->is_placeholder) {
throw new UserNotPlaceholderApiException(); throw new UserNotPlaceholderApiException;
} }
$invitationService->inviteUser($organization, $user->email, Role::Employee); $invitationService->inviteUser($organization, $user->email, Role::Employee);

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest; use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
use App\Http\Resources\V1\Organization\OrganizationResource; use App\Http\Resources\V1\Organization\OrganizationResource;
use App\Models\Organization; use App\Models\Organization;
@@ -23,7 +24,9 @@ class OrganizationController extends Controller
{ {
$this->checkPermission($organization, 'organizations:view'); $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);
} }
/** /**
@@ -39,6 +42,9 @@ class OrganizationController extends Controller
$organization->name = $request->input('name'); $organization->name = $request->input('name');
$oldBillableRate = $organization->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->billable_rate = $request->getBillableRate();
$organization->save(); $organization->save();
@@ -46,6 +52,6 @@ class OrganizationController extends Controller
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization); $billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
} }
return new OrganizationResource($organization); return new OrganizationResource($organization, true);
} }
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Project\ProjectIndexRequest; use App\Http\Requests\V1\Project\ProjectIndexRequest;
use App\Http\Requests\V1\Project\ProjectStoreRequest; use App\Http\Requests\V1\Project\ProjectStoreRequest;
@@ -13,6 +14,7 @@ use App\Http\Resources\V1\Project\ProjectResource;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Service\BillableRateService; use App\Service\BillableRateService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -60,7 +62,9 @@ class ProjectController extends Controller
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default')); $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);
} }
/** /**
@@ -74,9 +78,12 @@ class ProjectController extends Controller
{ {
$this->checkPermission($organization, 'projects:view', $project); $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'); $project->load('organization');
return new ProjectResource($project); return new ProjectResource($project, true);
} }
/** /**
@@ -89,16 +96,19 @@ class ProjectController extends Controller
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
{ {
$this->checkPermission($organization, 'projects:create'); $this->checkPermission($organization, 'projects:create');
$project = new Project(); $project = new Project;
$project->name = $request->input('name'); $project->name = $request->input('name');
$project->color = $request->input('color'); $project->color = $request->input('color');
$project->is_billable = (bool) $request->input('is_billable'); $project->is_billable = (bool) $request->input('is_billable');
$project->billable_rate = $request->getBillableRate(); $project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id'); $project->client_id = $request->input('client_id');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}
$project->organization()->associate($organization); $project->organization()->associate($organization);
$project->save(); $project->save();
return new ProjectResource($project); return new ProjectResource($project, true);
} }
/** /**
@@ -117,16 +127,29 @@ class ProjectController extends Controller
if ($request->has('is_archived')) { if ($request->has('is_archived')) {
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null; $project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
} }
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}
$oldBillableRate = $project->billable_rate; $oldBillableRate = $project->billable_rate;
$clientIdChanged = false;
$project->billable_rate = $request->getBillableRate(); $project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id'); if ($project->client_id !== $request->input('client_id')) {
$project->client_id = $request->input('client_id');
$clientIdChanged = true;
}
$project->save(); $project->save();
if ($oldBillableRate !== $request->getBillableRate()) { if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForProject($project); $billableRateService->updateTimeEntriesBillableRateForProject($project);
} }
if ($clientIdChanged) {
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->whereBelongsTo($project, 'project')
->update(['client_id' => $project->client_id]);
}
return new ProjectResource($project); return new ProjectResource($project, true);
} }
/** /**
@@ -147,8 +170,8 @@ class ProjectController extends Controller
throw new EntityStillInUseApiException('project', 'time_entry'); throw new EntityStillInUseApiException('project', 'time_entry');
} }
DB::transaction(function () use (&$project) { DB::transaction(function () use (&$project): void {
$project->members->each(function (ProjectMember $member) { $project->members->each(function (ProjectMember $member): void {
$member->delete(); $member->delete();
}); });

View File

@@ -65,13 +65,13 @@ class ProjectMemberController extends Controller
$member = Member::findOrFail((string) $request->input('member_id')); $member = Member::findOrFail((string) $request->input('member_id'));
if ($member->user->is_placeholder) { if ($member->user->is_placeholder) {
throw new InactiveUserCanNotBeUsedApiException(); throw new InactiveUserCanNotBeUsedApiException;
} }
if (ProjectMember::whereBelongsTo($project, 'project')->whereBelongsTo($member, 'member')->exists()) { if (ProjectMember::whereBelongsTo($project, 'project')->whereBelongsTo($member, 'member')->exists()) {
throw new UserIsAlreadyMemberOfProjectApiException(); throw new UserIsAlreadyMemberOfProjectApiException;
} }
$projectMember = new ProjectMember(); $projectMember = new ProjectMember;
$projectMember->billable_rate = $request->getBillableRate(); $projectMember->billable_rate = $request->getBillableRate();
$projectMember->member()->associate($member); $projectMember->member()->associate($member);
$projectMember->user()->associate($member->user); $projectMember->user()->associate($member->user);

View File

@@ -57,7 +57,7 @@ class TagController extends Controller
{ {
$this->checkPermission($organization, 'tags:create'); $this->checkPermission($organization, 'tags:create');
$tag = new Tag(); $tag = new Tag;
$tag->name = $request->input('name'); $tag->name = $request->input('name');
$tag->organization()->associate($organization); $tag->organization()->associate($organization);
$tag->save(); $tag->save();

View File

@@ -76,9 +76,12 @@ class TaskController extends Controller
public function store(Organization $organization, TaskStoreRequest $request): JsonResource public function store(Organization $organization, TaskStoreRequest $request): JsonResource
{ {
$this->checkPermission($organization, 'tasks:create'); $this->checkPermission($organization, 'tasks:create');
$task = new Task(); $task = new Task;
$task->name = $request->input('name'); $task->name = $request->input('name');
$task->project_id = $request->input('project_id'); $task->project_id = $request->input('project_id');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$task->estimated_time = $request->getEstimatedTime();
}
$task->organization()->associate($organization); $task->organization()->associate($organization);
$task->save(); $task->save();
@@ -96,6 +99,9 @@ class TaskController extends Controller
{ {
$this->checkPermission($organization, 'tasks:update', $task); $this->checkPermission($organization, 'tasks:update', $task);
$task->name = $request->input('name'); $task->name = $request->input('name');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$task->estimated_time = $request->getEstimatedTime();
}
if ($request->has('is_done')) { if ($request->has('is_done')) {
$task->done_at = $request->getIsDone() ? Carbon::now() : null; $task->done_at = $request->getIsDone() ? Carbon::now() : null;
} }

View File

@@ -4,28 +4,51 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryDestroyMultipleRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest; use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection; use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
use App\Http\Resources\V1\TimeEntry\TimeEntryResource; use App\Http\Resources\V1\TimeEntry\TimeEntryResource;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\ReportExport\TimeEntriesReportExport;
use App\Service\TimeEntryAggregationService; use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter; use App\Service\TimeEntryFilter;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Gotenberg\Exceptions\GotenbergApiErrored;
use Gotenberg\Exceptions\NoOutputFileInResponse;
use Gotenberg\Gotenberg;
use Gotenberg\Stream;
use GuzzleHttp\Client;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller class TimeEntryController extends Controller
{ {
@@ -38,11 +61,13 @@ class TimeEntryController extends Controller
} }
/** /**
* Get all time entries in organization * Get time entries in organization
* *
* If you only need time entries for a specific user, you can filter by `user_id`. * If you only need time entries for a specific user, you can filter by `user_id`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter. * Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
* *
* @return TimeEntryCollection<TimeEntryResource>
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId getTimeEntries * @operationId getTimeEntries
@@ -57,27 +82,16 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
} }
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
->whereBelongsTo($organization, 'organization')
->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery); $totalCount = $timeEntriesQuery->count();
$filter->addStartFilter($request->input('start'));
$filter->addEndFilter($request->input('end'));
$filter->addActiveFilter($request->input('active'));
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
$limit = $request->has('limit') ? (int) $request->input('limit', 100) : 100; $limit = $request->getLimit();
if ($limit > 1000) { if ($limit > 1000) {
$limit = 1000; $limit = 1000;
} }
$timeEntriesQuery->limit($limit); $timeEntriesQuery->limit($limit);
$timeEntriesQuery->skip($request->getOffset());
$timeEntries = $timeEntriesQuery->get(); $timeEntries = $timeEntriesQuery->get();
@@ -111,7 +125,121 @@ class TimeEntryController extends Controller
} }
} }
return new TimeEntryCollection($timeEntries); return (new TimeEntryCollection($timeEntries))
->additional([
'meta' => [
'total' => $totalCount,
],
]);
}
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
{
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start'));
$filter->addEndFilter($request->input('end'));
$filter->addActiveFilter($request->input('active'));
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
return $filter->get();
}
/**
* Export time entries in organization
*
* @throws AuthorizationException|PdfRendererIsNotConfiguredException|FeatureIsNotAvailableInFreePlanApiException
*
* @operationId exportTimeEntries
*/
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request): JsonResponse
{
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$user = $this->user();
$timezone = $user->timezone;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([
'task',
'client',
'project',
'user',
'tagsRelation',
]);
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports';
$path = $folderPath.'/'.$filename;
if ($format === ExportFormat::CSV) {
$export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $folderPath, $filename, $timeEntriesQuery, 1000, $timezone);
$export->export();
} elseif ($format === ExportFormat::PDF) {
if (config('services.gotenberg.url') === null) {
throw new PdfRendererIsNotConfiguredException;
}
$viewFile = file_get_contents(resource_path('views/reports/time-entry-index.blade.php'));
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$html = Blade::render($viewFile, ['timeEntries' => $timeEntriesQuery->get()]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
$client = new Client([
'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [
config('services.gotenberg.basic_auth_username'),
config('services.gotenberg.basic_auth_password'),
] : null,
]);
$request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf()
->pdfa('PDF/A-3b')
->paperSize('8.27', '11.7') // A4
->footer(Stream::string('footer', $footerHtml))
->html(Stream::string('body', $html));
$tempFolder = TemporaryDirectory::make();
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);
Storage::disk(config('filesystems.private'))
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesDetailedExport($timeEntriesQuery, $format, $timezone),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
[
'visibility' => 'private',
]
);
}
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
]);
} }
/** /**
@@ -146,7 +274,7 @@ class TimeEntryController extends Controller
* *
* @throws AuthorizationException * @throws AuthorizationException
*/ */
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $aggregationService): array public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
{ {
/** @var Member|null $member */ /** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
@@ -155,7 +283,146 @@ class TimeEntryController extends Controller
} else { } else {
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
} }
$user = $this->user();
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
$group1Type,
$group2Type,
$user->timezone,
$user->week_start,
$request->getFillGapsInTimeGroups(),
$request->getStart(),
$request->getEnd()
);
return [
'data' => $aggregatedData,
];
}
/**
* Export aggregated time entries in organization
*
* @operationId exportAggregatedTimeEntries
*
* @throws AuthorizationException
* @throws PdfRendererIsNotConfiguredException
* @throws GotenbergApiErrored
* @throws NoOutputFileInResponse
* @throws FeatureIsNotAvailableInFreePlanApiException
*/
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$user = $this->user();
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesAggregateQuery->clone(),
$group,
$subGroup,
$user->timezone,
$user->week_start,
false,
$request->getStart(),
$request->getEnd()
);
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(),
$request->getHistoryGroup(),
null,
$user->timezone,
$user->week_start,
true,
$request->getStart(),
$request->getEnd()
);
$currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$folderPath = 'exports';
$path = $folderPath.'/'.$filename;
if ($format === ExportFormat::PDF) {
if (config('services.gotenberg.url') === null) {
throw new PdfRendererIsNotConfiguredException;
}
$client = new Client([
'auth' => config('services.gotenberg.basic_auth_username') !== null && config('services.gotenberg.basic_auth_password') !== null ? [
config('services.gotenberg.basic_auth_username'),
config('services.gotenberg.basic_auth_password'),
] : null,
]);
$viewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate-index.blade.php'));
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$html = Blade::render($viewFile, [
'aggregatedData' => $aggregatedData,
'dataHistoryChart' => $dataHistoryChart,
'currency' => $currency,
'group' => $group,
'subGroup' => $subGroup,
'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone),
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
$request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf()
->pdfa('PDF/A-3b')
->paperSize('8.27', '11.7') // A4
->footer(Stream::string('footer', $footerHtml))
->html(Stream::string('body', $html));
$tempFolder = TemporaryDirectory::make();
$filenameTemp = Gotenberg::save($request, $tempFolder->path(), $client);
Storage::disk(config('filesystems.private'))
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
[
'visibility' => 'private',
]
);
}
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
]);
}
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
{
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization'); ->whereBelongsTo($organization, 'organization');
@@ -170,27 +437,8 @@ class TimeEntryController extends Controller
$filter->addTaskIdsFilter($request->input('task_ids')); $filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids')); $filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable')); $filter->addBillableFilter($request->input('billable'));
$timeEntriesQuery = $filter->get();
$user = $this->user(); return $filter->get();
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$aggregatedData = $aggregationService->getAggregatedTimeEntries(
$timeEntriesQuery,
$group1Type,
$group2Type,
$user->timezone,
$user->week_start,
$request->getFillGapsInTimeGroups(),
$request->getStart(),
$request->getEnd()
);
return [
'data' => $aggregatedData,
];
} }
/** /**
@@ -212,12 +460,21 @@ class TimeEntryController extends Controller
} }
if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) { if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) {
throw new TimeEntryStillRunningApiException(); throw new TimeEntryStillRunningApiException;
} }
$client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null; $project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
$client = $project?->client;
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
$timeEntry = new TimeEntry(); if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
$timeEntry = new TimeEntry;
$timeEntry->fill($request->validated()); $timeEntry->fill($request->validated());
$timeEntry->client()->associate($client); $timeEntry->client()->associate($client);
$timeEntry->user_id = $member->user_id; $timeEntry->user_id = $member->user_id;
@@ -247,19 +504,41 @@ class TimeEntryController extends Controller
} }
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) { if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
throw new TimeEntryCanNotBeRestartedApiException(); throw new TimeEntryCanNotBeRestartedApiException;
} }
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;
$project = null;
if ($request->has('project_id')) { if ($request->has('project_id')) {
$client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null; $project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
$client = $project?->client;
$timeEntry->client()->associate($client); $timeEntry->client()->associate($client);
} }
$task = null;
if ($request->has('task_id')) {
$task = $request->input('task_id') !== null ? Task::findOrFail((string) $request->input('task_id')) : null;
}
$timeEntry->fill($request->validated()); $timeEntry->fill($request->validated());
$timeEntry->description = $request->input('description', $timeEntry->description) ?? ''; $timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
$timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save(); $timeEntry->save();
if ($oldProject !== null) {
RecalculateSpentTimeForProject::dispatch($oldProject);
}
if ($oldTask !== null) {
RecalculateSpentTimeForTask::dispatch($oldTask);
}
if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {
RecalculateSpentTimeForTask::dispatch($task);
}
return new TimeEntryResource($timeEntry); return new TimeEntryResource($timeEntry);
} }
@@ -279,24 +558,39 @@ class TimeEntryController extends Controller
$timeEntries = TimeEntry::query() $timeEntries = TimeEntry::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->with([
'project',
'task',
])
->whereIn('id', $ids) ->whereIn('id', $ids)
->get(); ->get();
$changes = $request->validated('changes'); $changes = $request->validated('changes');
if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) { if ($request->has('changes.description')) {
throw new AuthorizationException(); $changes['description'] = $request->input('changes.description') ?? '';
} }
if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) {
throw new AuthorizationException;
}
$project = null;
$client = null; $client = null;
$overwriteClient = false; $overwriteClient = false;
if ($request->has('changes.project_id')) { if ($request->has('changes.project_id')) {
$client = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id'))->client : null; $project = $request->input('changes.project_id') !== null ? Project::findOrFail((string) $request->input('changes.project_id')) : null;
$client = $project?->client;
$overwriteClient = true; $overwriteClient = true;
} }
$success = new Collection(); $task = null;
$error = new Collection(); if ($request->has('changes.task_id')) {
$task = $request->input('changes.task_id') !== null ? Task::findOrFail((string) $request->input('changes.task_id')) : null;
}
$success = new Collection;
$error = new Collection;
foreach ($ids as $id) { foreach ($ids as $id) {
/** @var TimeEntry|null $timeEntry */ /** @var TimeEntry|null $timeEntry */
@@ -313,12 +607,32 @@ class TimeEntryController extends Controller
continue; continue;
} }
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;
$timeEntry->fill($changes); $timeEntry->fill($changes);
// If project is changed, but task is not, we remove the old task from the time entry
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
$timeEntry->task()->disassociate();
}
if ($overwriteClient) { if ($overwriteClient) {
$timeEntry->client()->associate($client); $timeEntry->client()->associate($client);
} }
$timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save(); $timeEntry->save();
if ($oldTask !== null) {
RecalculateSpentTimeForTask::dispatch($oldTask);
}
if ($oldProject !== null) {
RecalculateSpentTimeForProject::dispatch($oldProject);
}
if ($project !== null && ($oldProject === null || $project->isNot($oldProject))) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null && ($oldTask === null || $task->isNot($oldTask))) {
RecalculateSpentTimeForTask::dispatch($task);
}
$success->push($id); $success->push($id);
} }
@@ -343,9 +657,81 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry); $this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
} }
$project = $timeEntry->project;
$task = $timeEntry->task;
$timeEntry->delete(); $timeEntry->delete();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
return response() return response()
->json(null, 204); ->json(null, 204);
} }
/**
* Delete multiple time entries
*
* @throws AuthorizationException
*
* @operationId deleteTimeEntries
*/
public function destroyMultiple(Organization $organization, TimeEntryDestroyMultipleRequest $request): JsonResponse
{
$this->checkAnyPermission($organization, ['time-entries:delete:all', 'time-entries:delete:own']);
$canDeleteAll = $this->hasPermission($organization, 'time-entries:delete:all');
$ids = $request->validated('ids');
$timeEntries = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->with([
'project',
'task',
])
->whereIn('id', $ids)
->get();
$success = new Collection;
$error = new Collection;
foreach ($ids as $id) {
/** @var TimeEntry|null $timeEntry */
$timeEntry = $timeEntries->firstWhere('id', $id);
if ($timeEntry === null) {
// Note: ID wrong or time entry in different organization
$error->push($id);
continue;
}
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
$error->push($id);
continue;
}
$project = $timeEntry->project;
$task = $timeEntry->task;
$timeEntry->delete();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
$success->push($id);
}
return response()->json([
'success' => $success->toArray(),
'error' => $error->toArray(),
]);
}
} }

View File

@@ -28,7 +28,7 @@ class Controller extends BaseController
$user = Auth::user(); $user = Auth::user();
if ($user === null) { if ($user === null) {
Log::error('This function should only be called in authenticated context'); Log::error('This function should only be called in authenticated context');
throw new AuthorizationException(); throw new AuthorizationException;
} }
return $user; return $user;
@@ -44,7 +44,7 @@ class Controller extends BaseController
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first(); $member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) { if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization'); Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
throw new AuthorizationException(); throw new AuthorizationException;
} }
return $member; return $member;

View File

@@ -4,6 +4,4 @@ declare(strict_types=1);
namespace App\Http\Controllers\Web; namespace App\Http\Controllers\Web;
abstract class Controller extends \App\Http\Controllers\Controller abstract class Controller extends \App\Http\Controllers\Controller {}
{
}

View File

@@ -48,6 +48,8 @@ class HealthCheckController extends Controller
return response() return response()
->json([ ->json([
'ip_address' => $ipAddress, 'ip_address' => $ipAddress,
'url' => $request->url(),
'path' => $request->path(),
'hostname' => $hostname, 'hostname' => $hostname,
'timestamp' => Carbon::now()->timestamp, 'timestamp' => Carbon::now()->timestamp,
'date_time_utc' => Carbon::now('UTC')->toDateTimeString(), 'date_time_utc' => Carbon::now('UTC')->toDateTimeString(),

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http; namespace App\Http;
use App\Http\Middleware\CheckOrganizationBlocked;
use App\Http\Middleware\ForceJsonResponse; use App\Http\Middleware\ForceJsonResponse;
use Illuminate\Foundation\Http\Kernel as HttpKernel; use Illuminate\Foundation\Http\Kernel as HttpKernel;
@@ -50,6 +51,9 @@ class Kernel extends HttpKernel
\Illuminate\Routing\Middleware\SubstituteBindings::class, \Illuminate\Routing\Middleware\SubstituteBindings::class,
ForceJsonResponse::class, ForceJsonResponse::class,
], ],
'health-check' => [
],
]; ];
/** /**
@@ -71,5 +75,6 @@ class Kernel extends HttpKernel
'signed' => \App\Http\Middleware\ValidateSignature::class, 'signed' => \App\Http\Middleware\ValidateSignature::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class, 'verified' => \App\Http\Middleware\EnsureEmailIsVerified::class,
'check-organization-blocked' => CheckOrganizationBlocked::class,
]; ];
} }

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
use App\Models\Organization;
use App\Service\BillingContract;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckOrganizationBlocked
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*
* @throws OrganizationHasNoSubscriptionButMultipleMembersException
*/
public function handle(Request $request, Closure $next): Response
{
$organization = $request->route('organization');
if (! ($organization instanceof Organization)) {
throw new \LogicException('The organization must be loaded before this middleware.');
}
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
if ($billing->isBlocked($organization)) {
throw new OrganizationHasNoSubscriptionButMultipleMembersException;
}
return $next($request);
}
}

View File

@@ -40,18 +40,19 @@ class HandleInertiaRequests extends Middleware
public function share(Request $request): array public function share(Request $request): array
{ {
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing'); $hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$billing = null;
if ($hasBilling) { /** @var BillingContract $billing */
/** @var BillingContract $billing */ $billing = app(BillingContract::class);
$billing = app(BillingContract::class);
}
$currentOrganization = $request->user()?->currentTeam; $currentOrganization = $request->user()?->currentTeam;
return array_merge(parent::share($request), [ return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling, 'has_billing_extension' => $hasBilling,
'billing' => $billing !== null ? [ 'billing' => $billing !== null && $currentOrganization !== null ? [
'has_subscription' => $currentOrganization !== null ? $billing->hasSubscription($currentOrganization) : null, 'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization),
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),
'is_blocked' => $billing->isBlocked($currentOrganization),
] : null, ] : null,
'flash' => [ 'flash' => [
'message' => fn () => $request->session()->get('message'), 'message' => fn () => $request->session()->get('message'),

View File

@@ -20,6 +20,7 @@ class ClientIndexRequest extends FormRequest
'page' => [ 'page' => [
'integer', 'integer',
'min:1', 'min:1',
'max:2147483647',
], ],
'archived' => [ 'archived' => [
'string', 'string',

View File

@@ -29,10 +29,10 @@ class ClientStoreRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
(new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder { UniqueEloquent::make(Client::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}))->withCustomTranslation('validation.client_name_already_exists'), })->withCustomTranslation('validation.client_name_already_exists'),
], ],
]; ];
} }

View File

@@ -31,10 +31,10 @@ class ClientUpdateRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
(new UniqueEloquent(Client::class, 'name', function (Builder $builder): Builder { UniqueEloquent::make(Client::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}))->ignore($this->client?->getKey())->withCustomTranslation('validation.client_name_already_exists'), })->ignore($this->client?->getKey())->withCustomTranslation('validation.client_name_already_exists'),
], ],
'is_archived' => [ 'is_archived' => [
'boolean', 'boolean',

View File

@@ -29,10 +29,10 @@ class InvitationStoreRequest extends FormRequest
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder): Builder { UniqueEloquent::make(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
/** @var Builder<OrganizationInvitation> $builder */ /** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}))->withCustomTranslation('validation.invitation_already_exists'), })->withCustomTranslation('validation.invitation_already_exists'),
], ],
'role' => [ 'role' => [
'required', 'required',

View File

@@ -31,6 +31,7 @@ class MemberUpdateRequest extends FormRequest
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
'max:2147483647',
], ],
]; ];
} }

View File

@@ -30,6 +30,10 @@ class OrganizationUpdateRequest extends FormRequest
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
'max:2147483647',
],
'employees_can_see_billable_rates' => [
'boolean',
], ],
]; ];
} }

View File

@@ -20,6 +20,7 @@ class ProjectIndexRequest extends FormRequest
'page' => [ 'page' => [
'integer', 'integer',
'min:1', 'min:1',
'max:2147483647',
], ],
'archived' => [ 'archived' => [
'string', 'string',

View File

@@ -32,16 +32,16 @@ class ProjectStoreRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
(new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder { UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}))->withCustomTranslation('validation.project_name_already_exists'), })->withCustomTranslation('validation.project_name_already_exists'),
], ],
'color' => [ 'color' => [
'required', 'required',
'string', 'string',
'max:255', 'max:255',
new ColorRule(), new ColorRule,
], ],
'is_billable' => [ 'is_billable' => [
'required', 'required',
@@ -51,13 +51,22 @@ class ProjectStoreRequest extends FormRequest
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
'max:2147483647',
], ],
// ID of the client
'client_id' => [ 'client_id' => [
'nullable', 'nullable',
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
],
// Estimated time in seconds
'estimated_time' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
], ],
]; ];
} }
@@ -68,4 +77,11 @@ class ProjectStoreRequest extends FormRequest
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
} }
public function getEstimatedTime(): ?int
{
$input = $this->input('estimated_time');
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
}
} }

View File

@@ -32,16 +32,16 @@ class ProjectUpdateRequest extends FormRequest
'required', 'required',
'string', 'string',
'max:255', 'max:255',
(new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder { UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}))->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'), })->ignore($this->project?->getKey())->withCustomTranslation('validation.project_name_already_exists'),
], ],
'color' => [ 'color' => [
'required', 'required',
'string', 'string',
'max:255', 'max:255',
new ColorRule(), new ColorRule,
], ],
'is_billable' => [ 'is_billable' => [
'required', 'required',
@@ -52,15 +52,23 @@ class ProjectUpdateRequest extends FormRequest
], ],
'client_id' => [ 'client_id' => [
'nullable', 'nullable',
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
'billable_rate' => [ 'billable_rate' => [
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
'max:2147483647',
],
// Estimated time in seconds
'estimated_time' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
], ],
]; ];
} }
@@ -78,4 +86,11 @@ class ProjectUpdateRequest extends FormRequest
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
} }
public function getEstimatedTime(): ?int
{
$input = $this->input('estimated_time');
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
}
} }

View File

@@ -26,16 +26,16 @@ class ProjectMemberStoreRequest extends FormRequest
return [ return [
'member_id' => [ 'member_id' => [
'required', 'required',
'uuid', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
'billable_rate' => [ 'billable_rate' => [
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
'max:2147483647',
], ],
]; ];
} }

View File

@@ -25,6 +25,7 @@ class ProjectMemberUpdateRequest extends FormRequest
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
'max:2147483647',
], ],
]; ];
} }

View File

@@ -29,10 +29,10 @@ class TagStoreRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
(new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder { UniqueEloquent::make(Tag::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}))->withCustomTranslation('validation.tag_name_already_exists'), })->withCustomTranslation('validation.tag_name_already_exists'),
], ],
]; ];
} }

View File

@@ -30,10 +30,10 @@ class TagUpdateRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
(new UniqueEloquent(Tag::class, 'name', function (Builder $builder): Builder { UniqueEloquent::make(Tag::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}))->ignore($this->tag?->getKey())->withCustomTranslation('validation.tag_name_already_exists'), })->ignore($this->tag?->getKey())->withCustomTranslation('validation.tag_name_already_exists'),
], ],
]; ];
} }

View File

@@ -27,8 +27,7 @@ class TaskIndexRequest extends FormRequest
{ {
return [ return [
'project_id' => [ 'project_id' => [
'uuid', ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization'); $builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -37,7 +36,7 @@ class TaskIndexRequest extends FormRequest
} }
return $builder; return $builder;
}), })->uuid(),
], ],
'done' => [ 'done' => [
'string', 'string',

View File

@@ -31,18 +31,32 @@ class TaskStoreRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
(new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder { UniqueEloquent::make(Task::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->where('project_id', '=', $this->input('project_id')); return $builder->where('project_id', '=', $this->input('project_id'));
}))->withCustomTranslation('validation.task_name_already_exists'), })->withCustomTranslation('validation.task_name_already_exists'),
], ],
'project_id' => [ 'project_id' => [
'required', 'required',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
],
// Estimated time in seconds
'estimated_time' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
], ],
]; ];
} }
public function getEstimatedTime(): ?int
{
$input = $this->input('estimated_time');
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
}
} }

View File

@@ -30,14 +30,21 @@ class TaskUpdateRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
(new UniqueEloquent(Task::class, 'name', function (Builder $builder): Builder { UniqueEloquent::make(Task::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->where('project_id', '=', $this->task->project_id); return $builder->where('project_id', '=', $this->task->project_id);
}))->ignore($this->task?->getKey())->withCustomTranslation('validation.task_name_already_exists'), })->ignore($this->task?->getKey())->withCustomTranslation('validation.task_name_already_exists'),
], ],
'is_done' => [ 'is_done' => [
'boolean', 'boolean',
], ],
// Estimated time in seconds
'estimated_time' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
]; ];
} }
@@ -47,4 +54,11 @@ class TaskUpdateRequest extends FormRequest
return $this->boolean('is_done'); return $this->boolean('is_done');
} }
public function getEstimatedTime(): ?int
{
$input = $this->input('estimated_time');
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
}
} }

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryAggregateExportRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
'format' => [
'required',
'string',
Rule::enum(ExportFormat::class),
],
'group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
'sub_group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
'history_group' => [
'required',
'nullable',
Rule::enum(TimeEntryAggregationTypeInterval::class),
],
// Filter by member ID
'member_id' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
'member_ids' => [
'array',
'min:1',
],
'member_ids.*' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by user ID
'user_id' => [
'string',
ExistsEloquent::make(User::class, null, function (Builder $builder): Builder {
/** @var Builder<User> $builder */
return $builder->belongsToOrganization($this->organization);
})->uuid(),
],
// Filter by project IDs, project IDs are OR combined
'project_ids' => [
'array',
'min:1',
],
'project_ids.*' => [
'string',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by client IDs, client IDs are OR combined
'client_ids' => [
'array',
'min:1',
],
'client_ids.*' => [
'string',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [
'array',
'min:1',
],
'tag_ids.*' => [
'string',
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
'min:1',
],
'task_ids.*' => [
'string',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [
'required',
'string',
'date_format:Y-m-d\TH:i:s\Z',
'before:end',
],
// Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'end' => [
'required',
'string',
'date_format:Y-m-d\TH:i:s\Z',
],
// Filter by active status (active means has no end date, is still running)
'active' => [
'string',
'in:true,false',
],
// Filter by billable status
'billable' => [
'string',
'in:true,false',
],
'fill_gaps_in_time_groups' => [
'string',
'in:true,false',
],
];
}
public function getGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('group'));
}
public function getSubGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('sub_group'));
}
public function getHistoryGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::fromInterval(TimeEntryAggregationTypeInterval::from($this->input('history_group')));
}
public function getStart(): Carbon
{
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
}
public function getEnd(): Carbon
{
return Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
}
public function getFormatValue(): ExportFormat
{
return ExportFormat::from($this->validated('format'));
}
}

View File

@@ -45,11 +45,10 @@ class TimeEntryAggregateRequest extends FormRequest
// Filter by member ID // Filter by member ID
'member_id' => [ 'member_id' => [
'string', 'string',
'uuid', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
'member_ids' => [ 'member_ids' => [
@@ -58,21 +57,19 @@ class TimeEntryAggregateRequest extends FormRequest
], ],
'member_ids.*' => [ 'member_ids.*' => [
'string', 'string',
'uuid', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// Filter by user ID // Filter by user ID
'user_id' => [ 'user_id' => [
'string', 'string',
'uuid', ExistsEloquent::make(User::class, null, function (Builder $builder): Builder {
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
/** @var Builder<User> $builder */ /** @var Builder<User> $builder */
return $builder->belongsToOrganization($this->organization); return $builder->belongsToOrganization($this->organization);
}), })->uuid(),
], ],
// Filter by project IDs, project IDs are OR combined // Filter by project IDs, project IDs are OR combined
'project_ids' => [ 'project_ids' => [
@@ -81,11 +78,10 @@ class TimeEntryAggregateRequest extends FormRequest
], ],
'project_ids.*' => [ 'project_ids.*' => [
'string', 'string',
'uuid', ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// Filter by client IDs, client IDs are OR combined // Filter by client IDs, client IDs are OR combined
'client_ids' => [ 'client_ids' => [
@@ -94,24 +90,22 @@ class TimeEntryAggregateRequest extends FormRequest
], ],
'client_ids.*' => [ 'client_ids.*' => [
'string', 'string',
'uuid', ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// Filter by tag IDs, tag IDs are AND combined // Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [ 'tag_ids' => [
'array', 'array',
'min:1', 'min:1',
], ],
'tag_ids.*' => [ 'tag_ids.*' => [
'string', 'string',
'uuid', ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
@@ -120,10 +114,9 @@ class TimeEntryAggregateRequest extends FormRequest
], ],
'task_ids.*' => [ 'task_ids.*' => [
'string', 'string',
'uuid', ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [ 'start' => [

View File

@@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryDestroyMultipleRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'ids' => [
'required',
'array',
],
'ids.*' => [
'string',
'uuid',
],
];
}
}

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
'format' => [
'required',
'string',
Rule::enum(ExportFormat::class),
],
// Filter by member ID
'member_id' => [
'string',
'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
'member_ids' => [
'array',
'min:1',
],
'member_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by project IDs, project IDs are OR combined
'project_ids' => [
'array',
'min:1',
],
'project_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [
'array',
'min:1',
],
'tag_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
'min:1',
],
'task_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [
'nullable',
'string',
'date_format:Y-m-d\TH:i:s\Z',
'before:end',
],
// Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'end' => [
'nullable',
'string',
'date_format:Y-m-d\TH:i:s\Z',
],
// Filter by active status (active means has no end date, is still running)
'active' => [
'string',
'in:true,false',
],
// Filter by billable status
'billable' => [
'string',
'in:true,false',
],
// Limit the number of returned time entries (default: 150)
'limit' => [
'integer',
'min:1',
'max:500',
],
// Filter makes sure that only time entries of a whole date are returned
'only_full_dates' => [
'string',
'in:true,false',
],
];
}
public function getOnlyFullDates(): bool
{
return $this->input('only_full_dates', 'false') === 'true';
}
public function getFormatValue(): ExportFormat
{
return ExportFormat::from($this->validated('format'));
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -30,11 +31,10 @@ class TimeEntryIndexRequest extends FormRequest
// Filter by member ID // Filter by member ID
'member_id' => [ 'member_id' => [
'string', 'string',
'uuid', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter // Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
'member_ids' => [ 'member_ids' => [
@@ -43,11 +43,22 @@ class TimeEntryIndexRequest extends FormRequest
], ],
'member_ids.*' => [ 'member_ids.*' => [
'string', 'string',
'uuid', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
],
// Filter by client IDs, client IDs are OR combined
'client_ids' => [
'array',
'min:1',
],
'client_ids.*' => [
'string',
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
], ],
// Filter by project IDs, project IDs are OR combined // Filter by project IDs, project IDs are OR combined
'project_ids' => [ 'project_ids' => [
@@ -56,24 +67,22 @@ class TimeEntryIndexRequest extends FormRequest
], ],
'project_ids.*' => [ 'project_ids.*' => [
'string', 'string',
'uuid', ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// Filter by tag IDs, tag IDs are AND combined // Filter by tag IDs, tag IDs are OR combined
'tag_ids' => [ 'tag_ids' => [
'array', 'array',
'min:1', 'min:1',
], ],
'tag_ids.*' => [ 'tag_ids.*' => [
'string', 'string',
'uuid', ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
@@ -82,11 +91,10 @@ class TimeEntryIndexRequest extends FormRequest
], ],
'task_ids.*' => [ 'task_ids.*' => [
'string', 'string',
'uuid', ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z) // Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [ 'start' => [
@@ -117,6 +125,12 @@ class TimeEntryIndexRequest extends FormRequest
'min:1', 'min:1',
'max:500', 'max:500',
], ],
// Skip the first n time entries (default: 0)
'offset' => [
'integer',
'min:0',
'max:2147483647',
],
// Filter makes sure that only time entries of a whole date are returned // Filter makes sure that only time entries of a whole date are returned
'only_full_dates' => [ 'only_full_dates' => [
'string', 'string',
@@ -129,4 +143,14 @@ class TimeEntryIndexRequest extends FormRequest
{ {
return $this->input('only_full_dates', 'false') === 'true'; return $this->input('only_full_dates', 'false') === 'true';
} }
public function getLimit(): int
{
return $this->has('limit') ? (int) $this->validated('limit', 100) : 100;
}
public function getOffset(): int
{
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
}
} }

View File

@@ -31,36 +31,33 @@ class TimeEntryStoreRequest extends FormRequest
'member_id' => [ 'member_id' => [
'required', 'required',
'string', 'string',
'uuid', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
'project_id' => [ 'project_id' => [
'nullable', 'nullable',
'string', 'string',
'uuid',
'required_with:task_id', 'required_with:task_id',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// ID of the task that the time entry should belong to // ID of the task that the time entry should belong to
'task_id' => [ 'task_id' => [
'nullable', 'nullable',
'string', 'string',
'uuid', ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization') return $builder->whereBelongsTo($this->organization, 'organization')
->where('project_id', $this->input('project_id')); ->where('project_id', $this->input('project_id'));
}))->withMessage(__('validation.task_belongs_to_project')), })->uuid()->withMessage(__('validation.task_belongs_to_project')),
], ],
// Start of time entry (ISO 8601 format, UTC timezone) // Start of time entry (ISO 8601 format, UTC timezone)
'start' => [ 'start' => [
@@ -71,7 +68,7 @@ class TimeEntryStoreRequest extends FormRequest
'end' => [ 'end' => [
'nullable', 'nullable',
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
'after:start', 'after_or_equal:start',
], ],
// Whether time entry is billable // Whether time entry is billable
'billable' => [ 'billable' => [
@@ -90,12 +87,10 @@ class TimeEntryStoreRequest extends FormRequest
'array', 'array',
], ],
'tags.*' => [ 'tags.*' => [
'string', ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
]; ];
} }

View File

@@ -42,37 +42,34 @@ class TimeEntryUpdateMultipleRequest extends FormRequest
// ID of the organization member that the time entry should belong to // ID of the organization member that the time entry should belong to
'changes.member_id' => [ 'changes.member_id' => [
'string', 'string',
'uuid', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// ID of the project that the time entry should belong to // ID of the project that the time entry should belong to
'changes.project_id' => [ 'changes.project_id' => [
'nullable', 'nullable',
'string', 'string',
'uuid',
'required_with:task_id', 'required_with:task_id',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// ID of the task that the time entry should belong to // ID of the task that the time entry should belong to
'changes.task_id' => [ 'changes.task_id' => [
'nullable', 'nullable',
'string', 'string',
'uuid', ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization') return $builder->whereBelongsTo($this->organization, 'organization')
->where('project_id', $this->input('changes.project_id')); ->where('project_id', $this->input('changes.project_id'));
}))->withMessage(__('validation.task_belongs_to_project')), })->uuid()->withMessage(__('validation.task_belongs_to_project')),
], ],
// Whether time entry is billable // Whether time entry is billable
'changes.billable' => [ 'changes.billable' => [
@@ -91,11 +88,10 @@ class TimeEntryUpdateMultipleRequest extends FormRequest
], ],
'changes.tags.*' => [ 'changes.tags.*' => [
'string', 'string',
'uuid', ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
]; ];
} }

View File

@@ -30,37 +30,34 @@ class TimeEntryUpdateRequest extends FormRequest
// ID of the organization member that the time entry should belong to // ID of the organization member that the time entry should belong to
'member_id' => [ 'member_id' => [
'string', 'string',
'uuid', ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// ID of the project that the time entry should belong to // ID of the project that the time entry should belong to
'project_id' => [ 'project_id' => [
'nullable', 'nullable',
'string', 'string',
'uuid',
'required_with:task_id', 'required_with:task_id',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
// ID of the task that the time entry should belong to // ID of the task that the time entry should belong to
'task_id' => [ 'task_id' => [
'nullable', 'nullable',
'string', 'string',
'uuid', ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
(new ExistsEloquent(Task::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization') return $builder->whereBelongsTo($this->organization, 'organization')
->where('project_id', $this->input('project_id')); ->where('project_id', $this->input('project_id'));
}))->withMessage(__('validation.task_belongs_to_project')), })->uuid()->withMessage(__('validation.task_belongs_to_project')),
], ],
// Start of time entry (ISO 8601 format, UTC timezone) // Start of time entry (ISO 8601 format, UTC timezone)
'start' => [ 'start' => [
@@ -70,7 +67,7 @@ class TimeEntryUpdateRequest extends FormRequest
'end' => [ 'end' => [
'nullable', 'nullable',
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
'after:start', 'after_or_equal:start',
], ],
// Whether time entry is billable // Whether time entry is billable
'billable' => [ 'billable' => [
@@ -89,11 +86,10 @@ class TimeEntryUpdateRequest extends FormRequest
], ],
'tags.*' => [ 'tags.*' => [
'string', 'string',
'uuid', ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */ /** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
], ],
]; ];
} }

View File

@@ -4,6 +4,4 @@ declare(strict_types=1);
namespace App\Http\Resources; namespace App\Http\Resources;
interface PaginatedResourceCollection interface PaginatedResourceCollection {}
{
}

View File

@@ -13,6 +13,20 @@ use Illuminate\Http\Request;
*/ */
class OrganizationResource extends BaseResource class OrganizationResource extends BaseResource
{ {
private bool $showBillableRate;
/**
* Create a new resource instance.
*
* @return void
*/
public function __construct(Organization $resource, bool $showBillableRate)
{
parent::__construct($resource);
$this->showBillableRate = $showBillableRate;
}
/** /**
* Transform the resource into an array. * Transform the resource into an array.
* *
@@ -28,7 +42,9 @@ class OrganizationResource extends BaseResource
/** @var bool $color Personal organizations automatically created after registration */ /** @var bool $color Personal organizations automatically created after registration */
'is_personal' => $this->resource->personal_team, 'is_personal' => $this->resource->personal_team,
/** @var int|null $billable_rate Billable rate in cents per hour */ /** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate, 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
]; ];
} }
} }

View File

@@ -5,14 +5,39 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Project; namespace App\Http\Resources\V1\Project;
use App\Http\Resources\PaginatedResourceCollection; use App\Http\Resources\PaginatedResourceCollection;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
private bool $showBillableRates;
/** /**
* The resource that this resource collects. * @param LengthAwarePaginator<Project> $resource
*
* @var string
*/ */
public $collects = ProjectResource::class; public function __construct($resource, bool $showBillableRates)
{
parent::__construct($resource);
$this->showBillableRates = $showBillableRates;
}
protected function collects(): ?string
{
return null;
}
/**
* Transform the resource collection into an array.
*
* @return array<array<string, string|bool|int|null>>
*/
public function toArray(Request $request): array
{
return $this->collection->map(function (Project $project) use ($request): array {
return (new ProjectResource($project, $this->showBillableRates))
->toArray($request);
})->all();
}
} }

View File

@@ -13,6 +13,15 @@ use Illuminate\Http\Request;
*/ */
class ProjectResource extends BaseResource class ProjectResource extends BaseResource
{ {
private bool $showBillableRate;
public function __construct(Project $resource, bool $showBillableRate)
{
parent::__construct($resource);
$this->showBillableRate = $showBillableRate;
}
/** /**
* Transform the resource into an array. * Transform the resource into an array.
* *
@@ -32,9 +41,13 @@ class ProjectResource extends BaseResource
/** @var bool $is_archived Whether the client is archived */ /** @var bool $is_archived Whether the client is archived */
'is_archived' => $this->resource->is_archived, 'is_archived' => $this->resource->is_archived,
/** @var int|null $billable_rate Billable rate in cents per hour */ /** @var int|null $billable_rate Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate, 'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @var bool $is_billable Project time entries billable default */ /** @var bool $is_billable Project time entries billable default */
'is_billable' => $this->resource->is_billable, 'is_billable' => $this->resource->is_billable,
/** @var int|null $estimated_time Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time,
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
'spent_time' => $this->resource->spent_time,
]; ];
} }
} }

View File

@@ -30,6 +30,10 @@ class TaskResource extends BaseResource
'is_done' => $this->resource->is_done, 'is_done' => $this->resource->is_done,
/** @var string $project_id ID of the project */ /** @var string $project_id ID of the project */
'project_id' => $this->resource->project_id, 'project_id' => $this->resource->project_id,
/** @var int|null $estimated_time Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time,
/** @var int $spent_time Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
'spent_time' => $this->resource->spent_time,
/** @var string $created_at When the tag was created */ /** @var string $created_at When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at), 'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at When the tag was last updated */ /** @var string $updated_at When the tag was last updated */

View File

@@ -4,9 +4,10 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\TimeEntry; namespace App\Http\Resources\V1\TimeEntry;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Http\Resources\Json\ResourceCollection;
class TimeEntryCollection extends ResourceCollection class TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection
{ {
/** /**
* The resource that this resource collects. * The resource that this resource collects.

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Project;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RecalculateSpentTimeForProject implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public Project $project;
/**
* Create a new job instance.
*/
public function __construct(Project $project)
{
$this->project = $project;
}
/**
* Execute the job.
*
* @throws Exception
*/
public function handle(): void
{
$this->project->setComputedAttributeValue('spent_time');
if ($this->project->isDirty()) {
$this->project->save();
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Jobs;
use App\Models\Task;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RecalculateSpentTimeForTask implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public Task $task;
/**
* Create a new job instance.
*/
public function __construct(Task $task)
{
$this->task = $task;
}
/**
* Execute the job.
*
* @throws Exception
*/
public function handle(): void
{
$this->task->setComputedAttributeValue('spent_time');
if ($this->task->isDirty()) {
$this->task->save();
}
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Listeners; namespace App\Listeners;
use App\Models\Member; use App\Models\Member;
use App\Models\User;
use App\Service\UserService; use App\Service\UserService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Laravel\Jetstream\Events\TeamMemberAdded; use Laravel\Jetstream\Events\TeamMemberAdded;
@@ -19,7 +20,8 @@ class RemovePlaceholder
/** @var UserService $userService */ /** @var UserService $userService */
$userService = app(UserService::class); $userService = app(UserService::class);
$placeholders = Member::query() $placeholders = Member::query()
->whereHas('user', function (Builder $query) use ($event) { ->whereHas('user', function (Builder $query) use ($event): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', true) $query->where('is_placeholder', '=', true)
->where('email', '=', $event->user->email); ->where('email', '=', $event->user->email);
}) })

View File

@@ -29,5 +29,6 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
*/ */
class Audit extends PackageAuditModel class Audit extends PackageAuditModel
{ {
/** @use HasFactory<AuditFactory> */
use HasFactory; use HasFactory;
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\ClientFactory; use Database\Factories\ClientFactory;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
@@ -12,7 +13,6 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -29,8 +29,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/ */
class Client extends Model implements AuditableContract class Client extends Model implements AuditableContract
{ {
use Auditable; use CustomAuditable;
/** @use HasFactory<ClientFactory> */
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;
/** /**
@@ -40,6 +43,7 @@ class Client extends Model implements AuditableContract
*/ */
protected $casts = [ protected $casts = [
'name' => 'string', 'name' => 'string',
'archived_at' => 'datetime',
]; ];
/** /**

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
use OwenIt\Auditing\Auditable;
trait CustomAuditable
{
use Auditable;
/**
* @var array<string>|null
*/
protected ?array $auditEvents = null;
public function disableAuditing(): void
{
$this->auditEvents = [];
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use Database\Factories\FailedJobFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -16,6 +17,7 @@ use Illuminate\Support\Carbon;
*/ */
class FailedJob extends Model class FailedJob extends Model
{ {
/** @use HasFactory<FailedJobFactory> */
use HasFactory; use HasFactory;
/** /**

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\MemberFactory; use Database\Factories\MemberFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -11,7 +12,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Laravel\Jetstream\Membership as JetstreamMembership; use Laravel\Jetstream\Membership as JetstreamMembership;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -29,8 +29,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/ */
class Member extends JetstreamMembership implements AuditableContract class Member extends JetstreamMembership implements AuditableContract
{ {
use Auditable; use CustomAuditable;
/** @use HasFactory<MemberFactory> */
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;
/** /**

View File

@@ -4,11 +4,13 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\OrganizationFactory; use Database\Factories\OrganizationFactory;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
@@ -18,7 +20,6 @@ use Laravel\Jetstream\Events\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated; use Laravel\Jetstream\Events\TeamUpdated;
use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Team as JetstreamTeam; use Laravel\Jetstream\Team as JetstreamTeam;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -28,6 +29,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $currency * @property string $currency
* @property int|null $billable_rate * @property int|null $billable_rate
* @property string $user_id * @property string $user_id
* @property bool $employees_can_see_billable_rates
* @property User $owner * @property User $owner
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
@@ -41,8 +43,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/ */
class Organization extends JetstreamTeam implements AuditableContract class Organization extends JetstreamTeam implements AuditableContract
{ {
use Auditable; use CustomAuditable;
/** @use HasFactory<OrganizationFactory> */
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;
/** /**
@@ -54,6 +59,7 @@ class Organization extends JetstreamTeam implements AuditableContract
'name' => 'string', 'name' => 'string',
'personal_team' => 'boolean', 'personal_team' => 'boolean',
'currency' => 'string', 'currency' => 'string',
'employees_can_see_billable_rates' => 'boolean',
]; ];
/** /**
@@ -110,7 +116,7 @@ class Organization extends JetstreamTeam implements AuditableContract
*/ */
public function users(): BelongsToMany public function users(): BelongsToMany
{ {
return $this->belongsToMany(Jetstream::userModel(), Jetstream::membershipModel()) return $this->belongsToMany(User::class, Member::class)
->withPivot([ ->withPivot([
'id', 'id',
'role', 'role',
@@ -120,6 +126,24 @@ class Organization extends JetstreamTeam implements AuditableContract
->as('membership'); ->as('membership');
} }
/**
* Get the owner of the team.
*
* @return BelongsTo<User, Organization>
*/
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* @return HasMany<Member>
*/
public function members(): HasMany
{
return $this->hasMany(Member::class);
}
/** /**
* @return BelongsToMany<User> * @return BelongsToMany<User>
*/ */

View File

@@ -4,14 +4,13 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\OrganizationInvitationFactory; use Database\Factories\OrganizationInvitationFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation; use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -27,8 +26,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/ */
class OrganizationInvitation extends JetstreamTeamInvitation implements AuditableContract class OrganizationInvitation extends JetstreamTeamInvitation implements AuditableContract
{ {
use Auditable; use CustomAuditable;
/** @use HasFactory<OrganizationInvitationFactory> */
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;
/** /**
@@ -55,7 +57,7 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
*/ */
public function organization(): BelongsTo public function organization(): BelongsTo
{ {
return $this->belongsTo(Jetstream::teamModel(), 'organization_id'); return $this->belongsTo(Organization::class, 'organization_id');
} }
/** /**
@@ -65,6 +67,6 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
*/ */
public function team(): BelongsTo public function team(): BelongsTo
{ {
return $this->belongsTo(Jetstream::teamModel(), 'organization_id'); return $this->belongsTo(Organization::class, 'organization_id');
} }
} }

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\ProjectFactory; use Database\Factories\ProjectFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -14,7 +15,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable; use Illuminate\Support\Facades\DB;
use Korridor\LaravelComputedAttributes\ComputedAttributes;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -27,6 +29,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property bool $is_public * @property bool $is_public
* @property bool $is_billable * @property bool $is_billable
* @property-read bool $is_archived * @property-read bool $is_archived
* @property int|null $estimated_time
* @property int $spent_time
* @property Carbon|null $archived_at * @property Carbon|null $archived_at
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
@@ -40,8 +44,12 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/ */
class Project extends Model implements AuditableContract class Project extends Model implements AuditableContract
{ {
use Auditable; use ComputedAttributes;
use CustomAuditable;
/** @use HasFactory<ProjectFactory> */
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;
/** /**
@@ -52,6 +60,9 @@ class Project extends Model implements AuditableContract
protected $casts = [ protected $casts = [
'name' => 'string', 'name' => 'string',
'color' => 'string', 'color' => 'string',
'archived_at' => 'datetime',
'estimated_time' => 'integer',
'spent_time' => 'integer',
]; ];
/** /**
@@ -63,6 +74,68 @@ class Project extends Model implements AuditableContract
'is_billable' => false, 'is_billable' => false,
]; ];
/**
* The attributes that are computed. (f.e. for performance reasons)
* These attributes can be regenerated at any time.
*
* @var string[]
*/
protected array $computed = [
'spent_time',
];
/**
* Attributes to exclude from the Audit.
*
* @var array<string>
*/
protected array $auditExclude = [
'spent_time',
];
public function getSpentTimeComputed(): ?int
{
if ($this->hasAttribute('spent_time_computed')) {
return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed'];
} else {
/** @var object{ spent_time: string } $result */
$result = $this->timeEntries()
->whereNotNull('end')
->selectRaw('sum(extract(epoch from ("end" - start))) as spent_time')
->first();
return (int) $result->spent_time;
}
}
/**
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
*
* @param Builder<Project> $builder
* @param array<string> $attributes Attributes that will be generated.
* @return Builder<Project>
*/
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
{
if (in_array('spent_time', $attributes, true)) {
$builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from ("end" - start))'), 'sum');
}
return $builder;
}
/**
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
*
* @param Builder<Project> $builder
* @param array<string> $attributes Attributes that will be validated.
* @return Builder<Project>
*/
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
{
return $this->scopeComputedAttributesGenerate($builder, $attributes);
}
/** /**
* @return BelongsTo<Organization, Project> * @return BelongsTo<Organization, Project>
*/ */

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\ProjectMemberFactory; use Database\Factories\ProjectMemberFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -11,7 +12,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -31,8 +31,11 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/ */
class ProjectMember extends Model implements AuditableContract class ProjectMember extends Model implements AuditableContract
{ {
use Auditable; use CustomAuditable;
/** @use HasFactory<ProjectMemberFactory> */
use HasFactory; use HasFactory;
use HasUuids; use HasUuids;
/** /**

View File

@@ -4,14 +4,17 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\TagFactory; use Database\Factories\TagFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Auditable;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
/** /**
* @property string $id * @property string $id
@@ -19,14 +22,19 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $organization_id * @property string $organization_id
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Collection<TimeEntry> $timeEntries
* @property-read Organization $organization * @property-read Organization $organization
* *
* @method static TagFactory factory() * @method static TagFactory factory()
*/ */
class Tag extends Model implements AuditableContract class Tag extends Model implements AuditableContract
{ {
use Auditable; use CustomAuditable;
/** @use HasFactory<TagFactory> */
use HasFactory; use HasFactory;
use HasJsonRelationships;
use HasUuids; use HasUuids;
/** /**
@@ -45,4 +53,14 @@ class Tag extends Model implements AuditableContract
{ {
return $this->belongsTo(Organization::class, 'organization_id'); return $this->belongsTo(Organization::class, 'organization_id');
} }
/**
* Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.
*
* @return HasManyJson<TimeEntry, $this>
*/
public function timeEntries(): HasManyJson
{
return $this->hasManyJson(TimeEntry::class, 'tags');
}
} }

Some files were not shown because too many files have changed in this diff Show More