Compare commits

..

145 Commits

Author SHA1 Message Date
Constantin Graf
45a60a926f Fix docker image 2025-02-07 13:50:56 -05:00
Constantin Graf
0a956fd9e7 Fixed user create in filament 2025-02-06 14:20:37 -05:00
Constantin Graf
09b168cddb Update composer dependencies - minor 2025-02-06 14:00:30 -05:00
Gregor Vostrak
31b9659f7e start time entry on click in recently tracked time entries dropdown 2025-02-06 18:36:16 +01:00
Gregor Vostrak
db7111da44 add recently tracked timeentries dropdown to timetracker 2025-02-06 18:36:16 +01:00
Gregor Vostrak
18ab1f714b update dependencies, update eslint config, update optional ts props types 2025-02-06 18:36:16 +01:00
Gregor Vostrak
00e2518196 fix TimeTrackerRangeSelector detection so it does not open the Dropdown again after pressing Escape 2025-02-06 18:36:16 +01:00
Gregor Vostrak
6f6e5fb4c3 fix time update test to respect new taborder logic 2025-02-06 18:36:16 +01:00
Gregor Vostrak
68228bccb2 fix enter submits in the time range dropdown 2025-02-06 18:36:16 +01:00
Gregor Vostrak
2dd80ba6cc fix focus state for dropdowns, fix taborder for timerange select in timetracker and timeentryrows 2025-02-06 18:36:16 +01:00
Gregor Vostrak
b783ea9ecd improve focus state styling 2025-02-06 18:36:16 +01:00
Constantin Graf
dce608e403 Add more tests; Add filter in filament resource; Added options for user create command 2025-02-06 12:22:19 -05:00
Constantin Graf
84c9cfe2f2 Fixed bugs causing incorrect computed attributes in imported data 2025-02-06 12:22:19 -05:00
Constantin Graf
f14bd6413a Add missing serve option to local filesystem disk 2025-02-06 12:22:19 -05:00
Constantin Graf
eb19199bc6 Updated composer dependencies 2025-02-06 12:22:19 -05:00
Constantin Graf
0252d984cb Added estimated time to clockify project import 2025-02-06 12:22:19 -05:00
Constantin Graf
18162b0ff5 Fixed timezones in unit tests 2025-02-06 12:22:19 -05:00
Constantin Graf
3dab7440dd Updated composer dependencies 2025-02-06 12:22:19 -05:00
Constantin Graf
713e12e54e Fixed reports in deletion service 2025-02-06 12:22:19 -05:00
Constantin Graf
fc0a840ded Deactivated registration 2025-02-06 12:22:19 -05:00
dependabot[bot]
28904b650e Bump aglipanci/laravel-pint-action from 2.4 to 2.5
Bumps [aglipanci/laravel-pint-action](https://github.com/aglipanci/laravel-pint-action) from 2.4 to 2.5.
- [Release notes](https://github.com/aglipanci/laravel-pint-action/releases)
- [Commits](https://github.com/aglipanci/laravel-pint-action/compare/2.4...2.5)

---
updated-dependencies:
- dependency-name: aglipanci/laravel-pint-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-06 12:02:07 -05:00
dependabot[bot]
1d34a77eb2 Bump codecov/codecov-action from 5.1.2 to 5.3.1
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.1.2 to 5.3.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.1.2...v5.3.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-02-06 11:34:37 -05:00
Constantin Graf
49e045809b Enhanced description for Clockify imports 2024-12-20 19:57:50 -05:00
Constantin Graf
e90fa8307f Fixed timezones in unit tests 2024-12-20 19:57:50 -05:00
dependabot[bot]
895540d0a9 Bump codecov/codecov-action from 4.5.0 to 5.1.2
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.5.0 to 5.1.2.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4.5.0...v5.1.2)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-20 19:29:52 -05:00
Constantin Graf
62270382dc Fixed import lock 2024-12-18 11:26:49 -05:00
Constantin Graf
29929467f6 Fixed overlapping labels in PDF report 2024-12-18 11:20:32 -05:00
Gregor Vostrak
02fe89dfdf Update README.md 2024-12-17 17:38:34 +01:00
Gregor Vostrak
03550a0ca6 add request free trial text to upgrade modal 2024-12-17 17:03:54 +01:00
Gregor Vostrak
2f1056dddb change report default to public 2024-12-17 15:21:23 +01:00
Gregor Vostrak
6e226cd743 hide report table for users that do not already have reports and cannot report new ones 2024-12-17 13:03:59 +01:00
Gregor Vostrak
19ed966504 fix icons alignment in billing upgrade buttons 2024-12-17 12:55:29 +01:00
Gregor Vostrak
33818f10b3 improve detailed report so that the table header has a border on the new page 2024-12-09 17:29:44 +01:00
Gregor Vostrak
ee9d818d75 add name of shared report to title attribute 2024-12-09 17:24:04 +01:00
Gregor Vostrak
e3d8457523 add week_start default for unauthenticated shared reports view 2024-12-09 17:11:56 +01:00
Gregor Vostrak
67e42a0a54 improve pdf index export to prevent overflows 2024-12-09 16:58:06 +01:00
Gregor Vostrak
fdbf88a9a6 fix selects inside of focus trap not working on click select 2024-12-09 16:33:57 +01:00
Gregor Vostrak
c4daca32c5 add modal focus trap & fix design bug in project billable section 2024-12-09 15:45:28 +01:00
Gregor Vostrak
4e10f9538f add export modal to prevent firefox popup blocking behaviour 2024-12-09 15:29:44 +01:00
Gregor Vostrak
959cad8f74 fix main chart label not cutting off for big numbers on the top 2024-12-09 12:57:25 +01:00
Gregor Vostrak
e308ca78b1 improve design for time entries index export 2024-12-09 12:57:25 +01:00
Gregor Vostrak
4281736a6d automatically set the project billable default in time entry create modal 2024-12-09 12:57:25 +01:00
Gregor Vostrak
9b0cf37bc7 improve aggregated pdf design 2024-12-09 12:57:25 +01:00
Constantin Graf
a4f3e014d9 Add debug flag to pdf export 2024-12-09 12:57:25 +01:00
Gregor Vostrak
32bce2f749 fix reporting descriptions for nested group 2024-12-09 12:57:25 +01:00
Gregor Vostrak
ae7f5a98e7 add Today option to Date Range Picker 2024-12-09 12:57:25 +01:00
Gregor Vostrak
e3f981aac2 add missing data to public shared reports, add premium restrictions, add pdf download 2024-12-09 12:57:25 +01:00
Constantin Graf
bcb298bd6d Updated dedoc/scramble composer dependency 2024-12-09 12:57:25 +01:00
Constantin Graf
620c4c97dc Updated PDF footer and added pie chart to aggregate report 2024-12-09 12:57:25 +01:00
Constantin Graf
05da595470 Add wait for report with chart 2024-12-09 12:57:25 +01:00
Constantin Graf
a4d8a02b80 Updated PDF reports 2024-12-09 12:57:25 +01:00
Constantin Graf
0860aa9d24 Added shareable reports 2024-12-09 12:57:25 +01:00
Gregor Vostrak
9c82efdf07 add reporting submenus to navbar 2024-12-09 12:57:25 +01:00
Gregor Vostrak
2560619c15 add shared reports section in the frontend 2024-12-09 12:57:25 +01:00
Constantin Graf
c03aad1abd Added shareable reports 2024-12-09 12:57:25 +01:00
Constantin Graf
0ee0175f04 Prevent stray requests in unit tests 2024-12-02 17:40:01 +01:00
Constantin Graf
0c1f06face Change default generate key env to single line 2024-12-02 15:00:29 +01:00
Gregor Vostrak
86d625b18a add discount banner 2024-11-25 13:21:35 +01:00
Constantin Graf
83e17d4a40 Updated composer dependencies 2024-11-16 16:18:06 +01:00
Gregor Vostrak
5b27853546 Add e2e test for live timer 2024-11-15 18:04:39 +01:00
Gregor Vostrak
f49f7b2c9b fix live timer after reload 2024-11-15 16:48:02 +01:00
Constantin Graf
9e77500d94 Extended healthcheck debug in debug mode 2024-11-15 13:17:33 +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
458 changed files with 22422 additions and 6469 deletions

23
.env.ci
View File

@@ -4,14 +4,16 @@ APP_KEY=
APP_DEBUG=true APP_DEBUG=true
APP_URL=http://localhost APP_URL=http://localhost
APP_FORCE_HTTPS=false APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true
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 +22,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

@@ -1,18 +1,21 @@
# Application
APP_NAME=solidtime APP_NAME=solidtime
APP_ENV=local APP_ENV=local
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk= 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
APP_ENABLE_REGISTRATION=true
SUPER_ADMINS=admin@example.com SUPER_ADMINS=admin@example.com
PAGINATION_PER_PAGE_DEFAULT=500
# 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
@@ -25,18 +28,20 @@ DB_TEST_DATABASE=laravel
DB_TEST_USERNAME=root DB_TEST_USERNAME=root
DB_TEST_PASSWORD=root DB_TEST_PASSWORD=root
BROADCAST_DRIVER=log # Broadcasting
BROADCAST_DRIVER=null
# Cache
CACHE_DRIVER=file CACHE_DRIVER=file
# Queue
QUEUE_CONNECTION=sync QUEUE_CONNECTION=sync
# Session
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
@@ -46,15 +51,7 @@ 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}"
PUSHER_APP_ID= # Filesystems
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
# Storage
FILESYSTEM_DISK=s3 FILESYSTEM_DISK=s3
PUBLIC_FILESYSTEM_DISK=s3 PUBLIC_FILESYSTEM_DISK=s3
S3_ACCESS_KEY_ID=sail S3_ACCESS_KEY_ID=sail
@@ -65,18 +62,14 @@ S3_URL=http://storage.solidtime.test/local
S3_ENDPOINT=http://storage.solidtime.test S3_ENDPOINT=http://storage.solidtime.test
S3_USE_PATH_STYLE_ENDPOINT=true 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_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# Local setup
NGINX_HOST_NAME=solidtime.test NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432 FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083 FORWARD_WEB_PORT=8083
PAGINATION_PER_PAGE_DEFAULT=500

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

View File

@@ -1,13 +0,0 @@
/* eslint-env node */
require("@rushstack/eslint-patch/modern-module-resolution")
module.exports = {
extends: ['plugin:vue/vue3-essential', '@vue/eslint-config-typescript/recommended', '@vue/eslint-config-prettier'],
rules: {
'vue/multi-word-component-names': 'off',
"@typescript-eslint/no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": "error",
},
plugins: ['unused-imports'],
}

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

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

View File

@@ -20,15 +20,55 @@ jobs:
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:

View File

@@ -1,90 +0,0 @@
on:
push:
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-public.yml'
- 'docker/prod/**'
workflow_dispatch:
name: Build - Public (Release)
jobs:
build:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
steps:
- name: "Check out code"
uses: actions/checkout@v4
- name: "Copy .env template for production"
run: cp .env.production .env
- name: "Install dependencies"
uses: php-actions/composer@v6
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
with:
command: install
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
php_version: 8.3
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Build"
run: npm run build
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: |
solidtime/solidtime
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Build and push"
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -3,6 +3,8 @@ on:
branches: branches:
- main - main
- develop - develop
tags:
- '*'
pull_request: pull_request:
paths: paths:
- '.github/workflows/build-public.yml' - '.github/workflows/build-public.yml'
@@ -12,7 +14,7 @@ on:
name: Build - Public name: Build - Public
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-22.04
permissions: permissions:
packages: write packages: write
contents: read contents: read
@@ -23,9 +25,49 @@ jobs:
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

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
@@ -55,7 +63,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov" - name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v4.5.0 uses: codecov/codecov-action@v5.3.1
with: with:
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime slug: solidtime-io/solidtime

View File

@@ -10,6 +10,6 @@ jobs:
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: "Check code style" - name: "Check code style"
uses: aglipanci/laravel-pint-action@2.4 uses: aglipanci/laravel-pint-action@2.5
with: with:
configPath: "pint.json" configPath: "pint.json"

View File

@@ -13,7 +13,7 @@ solidtime is a modern open-source time tracking application for Freelancers and
- Time tracking: Track your time with a modern and easy-to-use interface - Time tracking: Track your time with a modern and easy-to-use interface
- Projects: Create and manage projects and assign project members - Projects: Create and manage projects and assign project members
- Tasks: Create and manage tasks and assign tasks to project members - Tasks: Create and manage tasks and assign tasks to projects
- Clients: Create and manage clients and assign clients to projects - Clients: Create and manage clients and assign clients to projects
- Billable rates: Set billable rates for projects, project members, organization members and organizations - Billable rates: Set billable rates for projects, project members, organization members and organizations
- Multiple organizations: Create and manage multiple organizations with one account - Multiple organizations: Create and manage multiple organizations with one account
@@ -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

@@ -4,16 +4,14 @@ declare(strict_types=1);
namespace App\Actions\Fortify; namespace App\Actions\Fortify;
use App\Enums\Role;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Events\NewsletterRegistered; use App\Events\NewsletterRegistered;
use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use App\Service\UserService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -34,6 +32,12 @@ class CreateNewUser implements CreatesNewUsers
*/ */
public function create(array $input): User public function create(array $input): User
{ {
if (! config('app.enable_registration')) {
throw ValidationException::withMessages([
'email' => [__('Registration is disabled.')],
]);
}
Validator::make($input, [ Validator::make($input, [
'name' => [ 'name' => [
'required', 'required',
@@ -43,9 +47,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);
}), }),
@@ -81,30 +85,16 @@ class CreateNewUser implements CreatesNewUsers
$currency = $ipLookupResponse->currency; $currency = $ipLookupResponse->currency;
} }
$user = null; $user = null;
$organization = null; DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency): void {
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency) { $userService = app(UserService::class);
$user = User::create([ $user = $userService->createUser(
'name' => $input['name'], $input['name'],
'email' => $input['email'], $input['email'],
'password' => Hash::make($input['password']), $input['password'],
'timezone' => $timezone ?? 'UTC', $timezone ?? 'UTC',
'week_start' => $startOfWeek, $startOfWeek,
]); $currency ?? 'EUR',
$organization = new Organization;
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$organization->personal_team = true;
$organization->currency = $currency ?? 'EUR';
$organization->owner()->associate($user);
$organization->save();
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
); );
$user->ownedTeams()->save($organization);
}); });
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent']; $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

@@ -7,18 +7,16 @@ namespace App\Actions\Jetstream;
use App\Enums\Role; use App\Enums\Role;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\MemberService;
use Closure; use Closure;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In; use Illuminate\Validation\Rules\In;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Laravel\Jetstream\Contracts\AddsTeamMembers; use Laravel\Jetstream\Contracts\AddsTeamMembers;
use Laravel\Jetstream\Events\AddingTeamMember;
use Laravel\Jetstream\Events\TeamMemberAdded;
class AddOrganizationMember implements AddsTeamMembers class AddOrganizationMember implements AddsTeamMembers
{ {
@@ -36,15 +34,7 @@ class AddOrganizationMember implements AddsTeamMembers
->where('is_placeholder', '=', false) ->where('is_placeholder', '=', false)
->firstOrFail(); ->firstOrFail();
AddingTeamMember::dispatch($organization, $newOrganizationMember); app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
DB::transaction(function () use ($organization, $newOrganizationMember, $role) {
$organization->users()->attach(
$newOrganizationMember, ['role' => $role]
);
});
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
} }
/** /**
@@ -71,10 +61,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 */ /** @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',
@@ -93,7 +83,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

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Admin;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\User;
use App\Service\UserService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use LogicException;
class UserCreateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:user:create
{ name : The name of the user }
{ email : The email of the user }
{ --ask-for-password : Ask for the password, otherwise the command will generate a random one }
{ --verify-email : Verify the email address of the user }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a new user';
/**
* Execute the console command.
*/
public function handle(): int
{
$name = $this->argument('name');
$email = $this->argument('email');
$askForPassword = (bool) $this->option('ask-for-password');
$verifyEmail = (bool) $this->option('verify-email');
if (User::query()->where('email', $email)->where('is_placeholder', '=', false)->exists()) {
$this->error('User with email "'.$email.'" already exists.');
return self::FAILURE;
}
if ($askForPassword) {
$outputPassword = false;
$password = $this->secret('Enter the password');
} else {
$outputPassword = true;
$password = bin2hex(random_bytes(16));
}
$user = null;
DB::transaction(function () use (&$user, $name, $email, $password, $verifyEmail): void {
$user = app(UserService::class)->createUser(
$name,
$email,
$password,
'UTC',
Weekday::Monday,
'EUR',
$verifyEmail
);
});
/** @var Organization|null $organization */
$organization = $user->ownedTeams->first();
if ($organization === null) {
throw new LogicException('User does not have an organization');
}
$this->info('Created user "'.$name.'" ("'.$email.'")');
$this->line('ID: '.$user->getKey());
$this->line('Name: '.$name);
$this->line('Email: '.$email);
if ($outputPassword) {
$this->line('Password: '.$password);
}
$this->line('Timezone: '.$user->timezone);
$this->line('Week start: '.$user->week_start->value);
// Organization
$this->line('Currency: '.$organization->currency);
return self::SUCCESS;
}
}

View File

@@ -35,7 +35,9 @@ class UserVerifyCommand extends Command
$this->info('Start verifying user with email "'.$email.'"'); $this->info('Start verifying user with email "'.$email.'"');
/** @var User|null $user */ /** @var User|null $user */
$user = User::where('email', $email)->first(); $user = User::query()->where('email', $email)
->where('is_placeholder', '=', false)
->first();
if ($user === null) { if ($user === null) {
$this->error('User with email "'.$email.'" not found.'); $this->error('User with email "'.$email.'" not found.');

View File

@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Report;
use App\Models\Report;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use LogicException;
class ReportSetExpiredToPrivateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'report:set-expired-to-private '.
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Makes public reports private if the public_until date has passed.';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->comment('Makes public reports private if the public_until date has passed...');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
}
$resetReports = 0;
Report::query()
->where('public_until', '<', Carbon::now())
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $reports) use ($dryRun, &$resetReports): void {
/** @var Collection<int, Report> $reports */
foreach ($reports as $report) {
$publicUntil = $report->public_until;
if ($publicUntil === null) {
throw new LogicException('public_until should not be null');
}
$this->info('Make report "'.$report->name.'" ('.$report->getKey().') private, expired: '.
$publicUntil->toIso8601ZuluString().' ('.$publicUntil->diffForHumans().')');
$resetReports++;
if (! $dryRun) {
$report->is_public = false;
$report->share_secret = null;
$report->save();
}
}
});
$this->comment('Finished setting '.$resetReports.' expired reports to private...');
return self::SUCCESS;
}
}

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

@@ -18,6 +18,7 @@ class SelfHostGenerateKeysCommand extends Command
*/ */
protected $signature = 'self-host:generate-keys protected $signature = 'self-host:generate-keys
{ --length=4096 : The length of the passport private key } { --length=4096 : The length of the passport private key }
{ --multi-line : Whether to output the keys in multiple lines }
{ --format=env : The format of the output (env, yaml) }'; { --format=env : The format of the output (env, yaml) }';
/** /**
@@ -34,6 +35,7 @@ class SelfHostGenerateKeysCommand extends Command
{ {
$format = $this->option('format'); $format = $this->option('format');
$key = RSA::createKey((int) $this->option('length')); $key = RSA::createKey((int) $this->option('length'));
$multiLine = (bool) $this->option('multi-line');
$publicKey = (string) $key->getPublicKey(); $publicKey = (string) $key->getPublicKey();
$privateKey = (string) $key; $privateKey = (string) $key;
@@ -41,12 +43,17 @@ class SelfHostGenerateKeysCommand extends Command
if ($format === 'env') { if ($format === 'env') {
$this->line('APP_KEY="'.$appKey.'"'); $this->line('APP_KEY="'.$appKey.'"');
$this->line('PASSPORT_PRIVATE_KEY="'.$privateKey.'"'); if ($multiLine) {
$this->line('PASSPORT_PUBLIC_KEY="'.$publicKey.'"'); $this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", "\n", $privateKey).'"');
$this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", "\n", $publicKey).'"');
} else {
$this->line('PASSPORT_PRIVATE_KEY="'.Str::replace("\r\n", '\n', $privateKey).'"');
$this->line('PASSPORT_PUBLIC_KEY="'.Str::replace("\r\n", '\n', $publicKey).'"');
}
} elseif ($format === 'yaml') { } elseif ($format === 'yaml') {
$this->line('APP_KEY: "'.$appKey.'"'); $this->line('APP_KEY: "'.$appKey.'"');
$this->line("PASSPORT_PRIVATE_KEY: |\n ".Str::replace("\n", "\n ", $privateKey)); $this->line("PASSPORT_PRIVATE_KEY: |\n ".Str::replace("\r\n", "\n ", $privateKey));
$this->line("PASSPORT_PUBLIC_KEY: |\n ".Str::replace("\n", "\n ", $publicKey)); $this->line("PASSPORT_PUBLIC_KEY: |\n ".Str::replace("\r\n", "\n ", $publicKey));
} else { } else {
$this->error('Invalid format'); $this->error('Invalid format');

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

@@ -54,7 +54,7 @@ class TimeEntrySendStillRunningMailsCommand extends Command
$query->where('is_placeholder', '=', false); $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';
@@ -17,6 +21,16 @@ enum TimeEntryAggregationType: string
case Billable = 'billable'; case Billable = 'billable';
case Description = 'description'; 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
{ {
return match ($this) { return match ($this) {

View File

@@ -4,10 +4,13 @@ declare(strict_types=1);
namespace App\Enums; namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
enum Weekday: string enum Weekday: string
{ {
use LaravelEnumHelper;
case Monday = 'monday'; case Monday = 'monday';
case Tuesday = 'tuesday'; case Tuesday = 'tuesday';
case Wednesday = 'wednesday'; case Wednesday = 'wednesday';

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 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

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

@@ -60,8 +60,13 @@ class ClientResource extends Resource
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->actions([ ->actions([
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make(),

View File

@@ -15,7 +15,8 @@ class EditClient extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make(), Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,7 +15,8 @@ class ListClients extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), Actions\CreateAction::make()
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Enums\Role;
use App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Models\OrganizationInvitation;
use App\Service\OrganizationInvitationService;
use Filament\Forms;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
class OrganizationInvitationResource extends Resource
{
protected static ?string $model = OrganizationInvitation::class;
protected static ?string $label = 'Invitations';
protected static ?string $navigationIcon = 'heroicon-o-user-plus';
protected static ?string $navigationGroup = 'Users';
protected static ?int $navigationSort = 9;
public static function form(Form $form): Form
{
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('email')
->label('Email')
->disabledOn(['edit'])
->required(),
Select::make('role')
->options(Role::class),
Forms\Components\Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->disabledOn(['edit'])
->required(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('organization.name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('email')
->sortable(),
Tables\Columns\TextColumn::make('role'),
Tables\Columns\TextColumn::make('created_at')
->label('Created At')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->label('Updated At')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('resend')
->label('Resend')
->action(function (Collection $records): void {
foreach ($records as $organizationInvite) {
app(OrganizationInvitationService::class)->resend($organizationInvite);
}
}),
]),
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListOrganizationInvitations::route('/'),
'edit' => Pages\EditOrganizationInvitation::route('/{record}/edit'),
'view' => Pages\ViewOrganizationInvitation::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Filament\Resources\OrganizationInvitationResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditOrganizationInvitation extends EditRecord
{
protected static string $resource = OrganizationInvitationResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Filament\Resources\OrganizationInvitationResource;
use Filament\Resources\Pages\ListRecords;
class ListOrganizationInvitations extends ListRecords
{
protected static string $resource = OrganizationInvitationResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Filament\Resources\OrganizationInvitationResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewOrganizationInvitation extends ViewRecord
{
protected static string $resource = OrganizationInvitationResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
];
}
}

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Filament\Resources\OrganizationResource\Pages; use App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager; use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
use App\Models\Organization; use App\Models\Organization;
use App\Service\DeletionService;
use App\Service\Export\ExportService; use App\Service\Export\ExportService;
use App\Service\Import\Importers\ImporterProvider; use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ImportException;
@@ -46,10 +48,13 @@ class OrganizationResource extends Resource
->maxLength(255), ->maxLength(255),
Forms\Components\Toggle::make('personal_team') Forms\Components\Toggle::make('personal_team')
->label('Is personal?') ->label('Is personal?')
->hiddenOn(['create'])
->required(), ->required(),
Forms\Components\Select::make('user_id') Forms\Components\Select::make('user_id')
->label('Owner')
->relationship(name: 'owner', titleAttribute: 'email') ->relationship(name: 'owner', titleAttribute: 'email')
->searchable(['name', 'email']) ->searchable(['name', 'email'])
->disabledOn(['edit'])
->required(), ->required(),
Forms\Components\Select::make('currency') Forms\Components\Select::make('currency')
->label('Currency') ->label('Currency')
@@ -62,6 +67,7 @@ class OrganizationResource extends Resource
return $select; return $select;
}) })
->required()
->searchable(), ->searchable(),
Forms\Components\TextInput::make('billable_rate') Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)') ->label('Billable rate (in Cents)')
@@ -70,13 +76,16 @@ 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')
->label('Created At') ->label('Created At')
->hiddenOn(['create'])
->disabled(), ->disabled(),
Forms\Components\DateTimePicker::make('updated_at') Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At') ->label('Updated At')
->hiddenOn(['create'])
->disabled(), ->disabled(),
]); ]);
} }
@@ -96,7 +105,7 @@ class OrganizationResource extends Resource
->sortable(), ->sortable(),
Tables\Columns\TextColumn::make('currency'), Tables\Columns\TextColumn::make('currency'),
TextColumn::make('billable_rate') TextColumn::make('billable_rate')
->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100), ->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
Tables\Columns\TextColumn::make('created_at') Tables\Columns\TextColumn::make('created_at')
->dateTime() ->dateTime()
->sortable(), ->sortable(),
@@ -111,6 +120,10 @@ class OrganizationResource extends Resource
]) ])
->actions([ ->actions([
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->using(function (Organization $record): void {
app(DeletionService::class)->deleteOrganization($record);
}),
Action::make('Export') Action::make('Export')
->icon('heroicon-o-arrow-down-tray') ->icon('heroicon-o-arrow-down-tray')
->action(function (Organization $record) { ->action(function (Organization $record) {
@@ -122,7 +135,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 +150,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) {
@@ -198,8 +211,6 @@ class OrganizationResource extends Resource
]), ]),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([
]),
]); ]);
} }
@@ -207,6 +218,7 @@ class OrganizationResource extends Resource
{ {
return [ return [
UsersRelationManager::class, UsersRelationManager::class,
InvitationsRelationManager::class,
]; ];
} }

View File

@@ -15,7 +15,6 @@ class DeleteOrganization extends DeleteAction
protected function setUp(): void protected function setUp(): void
{ {
parent::setUp(); parent::setUp();
// TODO: check why setting the icon is necessary
$this->icon('heroicon-m-trash'); $this->icon('heroicon-m-trash');
$this->action(function (): void { $this->action(function (): void {
$result = $this->process(function (Organization $record): bool { $result = $this->process(function (Organization $record): bool {

View File

@@ -4,10 +4,33 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages; namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Enums\Role;
use App\Filament\Resources\OrganizationResource; use App\Filament\Resources\OrganizationResource;
use App\Models\Organization;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
class CreateOrganization extends CreateRecord class CreateOrganization extends CreateRecord
{ {
protected static string $resource = OrganizationResource::class; protected static string $resource = OrganizationResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['personal_team'] = false;
return $data;
}
protected function afterCreate(): void
{
/** @var Organization $organization */
$organization = $this->record;
$user = $organization->owner;
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
}
} }

View File

@@ -15,7 +15,8 @@ class ListOrganizations extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), Actions\CreateAction::make()
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\RelationManagers;
use App\Enums\Role;
use App\Filament\Resources\OrganizationInvitationResource;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Service\InvitationService;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class InvitationsRelationManager extends RelationManager
{
protected static string $relationship = 'teamInvitations';
protected static ?string $title = 'Invitations';
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('email')
->label('Email')
->disabledOn(['edit'])
->required(),
Select::make('role')
->options(Role::class)
->label('Role')
->rules([
'required',
'string',
Rule::enum(Role::class)
->except([Role::Owner, Role::Placeholder]),
])
->required(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('email')
->modelLabel('Invitation')
->pluralModelLabel('Invitations')
->columns([
Tables\Columns\TextColumn::make('email'),
Tables\Columns\TextColumn::make('role'),
])
->headerActions([
Tables\Actions\CreateAction::make()
->icon('heroicon-s-plus')
->using(function (array $data, string $model): Model {
/** @var Organization $ownerRecord */
$ownerRecord = $this->getOwnerRecord();
return app(InvitationService::class)
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));
}),
])
->actions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (OrganizationInvitation $record): string => OrganizationInvitationResource::getUrl('view', [
'record' => $record->getKey(),
])),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
]),
]);
}
}

View File

@@ -5,17 +5,24 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\RelationManagers; namespace App\Filament\Resources\OrganizationResource\RelationManagers;
use App\Enums\Role; use App\Enums\Role;
use App\Exceptions\Api\ApiException;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\BillableRateService;
use App\Service\MemberService;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Actions\Action; use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AttachAction; use Filament\Tables\Actions\AttachAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Validation\Rule;
class UsersRelationManager extends RelationManager class UsersRelationManager extends RelationManager
{ {
@@ -36,20 +43,40 @@ class UsersRelationManager extends RelationManager
public function table(Table $table): Table public function table(Table $table): Table
{ {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
return $table return $table
->recordTitleAttribute('name') ->recordTitleAttribute('name')
->columns([ ->columns([
Tables\Columns\TextColumn::make('name'), Tables\Columns\TextColumn::make('name'),
Tables\Columns\TextColumn::make('role'), Tables\Columns\TextColumn::make('role'),
TextColumn::make('billable_rate') TextColumn::make('billable_rate')
->money($this->getOwnerRecord()->currency ?? 'EUR', divideBy: 100), ->money($organization->currency, divideBy: 100),
]) ])
->headerActions([ ->headerActions([
Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [ Tables\Actions\AttachAction::make()
$action->getRecordSelect(), ->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
Select::make('role') ->form(fn (AttachAction $action): array => [
->options(Role::class), $action->getRecordSelect(),
]), Select::make('role')
->required()
->options(Role::class)
->rule([
'required',
'string',
Rule::enum(Role::class)
->except([Role::Owner, Role::Placeholder]),
]),
])
->label('Add user')
->modalHeading('Add user')
->icon('heroicon-s-plus')
->using(function (User $record, array $data): void {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
app(MemberService::class)->addMember($record, $organization, Role::from($data['role']), true);
}),
]) ])
->actions([ ->actions([
Action::make('view') Action::make('view')
@@ -58,13 +85,55 @@ class UsersRelationManager extends RelationManager
->url(fn (User $record): string => UserResource::getUrl('view', [ ->url(fn (User $record): string => UserResource::getUrl('view', [
'record' => $record->getKey(), 'record' => $record->getKey(),
])), ])),
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make()
Tables\Actions\DetachAction::make(), ->using(function (User $record, array $data): User {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
/** @var Member $member */
$member = $record->getRelation('membership');
if ($data['billable_rate'] !== $member->billable_rate) {
$member->billable_rate = $data['billable_rate'];
app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member);
}
if ($data['role'] !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $organization, Role::from($data['role']), true);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Update failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}
$member->save();
return $record;
}),
Tables\Actions\DetachAction::make()
->using(function (User $record): void {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
$member = Member::query()
->whereBelongsTo($record, 'user')
->whereBelongsTo($organization, 'organization')
->firstOrFail();
try {
app(MemberService::class)->removeMember($member, $organization);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Delete failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
]),
]); ]);
} }
} }

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

@@ -15,7 +15,8 @@ class EditProjectMember extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make(), Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,7 +15,8 @@ class ListProjectMembers extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), Actions\CreateAction::make()
->icon('heroicon-s-plus'),
]; ];
} }
} }

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')
@@ -71,8 +72,13 @@ class ProjectResource extends Resource
]) ])
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->actions([ ->actions([

View File

@@ -15,7 +15,8 @@ class EditProject extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make(), Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,7 +15,8 @@ class ListProjects extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), Actions\CreateAction::make()
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ReportResource\Pages;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
use Filament\Forms;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Novadaemon\FilamentPrettyJson\PrettyJson;
class ReportResource extends Resource
{
protected static ?string $model = Report::class;
protected static ?string $navigationIcon = 'heroicon-o-document-chart-bar';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 7;
public static function form(Form $form): Form
{
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('description')
->label('Description')
->nullable()
->maxLength(255),
Toggle::make('is_public')
->label('Is public?')
->required(),
DateTimePicker::make('public_until')
->label('Public until')
->nullable(),
Forms\Components\Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->disabled()
->required(),
Forms\Components\TextInput::make('share_secret')
->label('Share Secret')
->nullable(),
PrettyJson::make('properties')
->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string {
return $record->getRawOriginal('properties');
})
->disabled(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('description')
->searchable()
->sortable(),
ToggleColumn::make('is_public')
->label('Is public?')
->sortable(),
TextColumn::make('organization.name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name')
->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
])
->actions([
Action::make('public-view')
->label('Public')
->icon('heroicon-o-eye')
->color('gray')
->hidden(fn (Report $record): bool => $record->getShareableLink() === null)
->url(fn (Report $record): string => $record->getShareableLink(), true),
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListReports::route('/'),
'edit' => Pages\EditReport::route('/{record}/edit'),
'view' => Pages\ViewReport::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReportResource\Pages;
use App\Filament\Resources\ReportResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditReport extends EditRecord
{
protected static string $resource = ReportResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReportResource\Pages;
use App\Filament\Resources\ReportResource;
use Filament\Resources\Pages\ListRecords;
class ListReports extends ListRecords
{
protected static string $resource = ReportResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReportResource\Pages;
use App\Filament\Resources\ReportResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewReport extends ViewRecord
{
protected static string $resource = ReportResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
];
}
}

View File

@@ -60,8 +60,13 @@ class TagResource extends Resource
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->actions([ ->actions([
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make(),

View File

@@ -15,7 +15,8 @@ class EditTag extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make(), Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,7 +15,8 @@ class ListTags extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), Actions\CreateAction::make()
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -61,8 +61,13 @@ class TaskResource extends Resource
]) ])
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->actions([ ->actions([

View File

@@ -15,7 +15,8 @@ class EditTask extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make(), Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,7 +15,8 @@ class ListTasks extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), Actions\CreateAction::make()
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -92,8 +92,13 @@ class TimeEntryResource extends Resource
]) ])
->filters([ ->filters([
SelectFilter::make('organization') SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name') ->relationship('organization', 'name')
->searchable(), ->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
]) ])
->defaultSort('created_at', 'desc') ->defaultSort('created_at', 'desc')
->actions([ ->actions([

View File

@@ -15,7 +15,8 @@ class EditTimeEntry extends EditRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\DeleteAction::make(), Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
]; ];
} }
} }

View File

@@ -15,7 +15,8 @@ class ListTimeEntries extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), Actions\CreateAction::make()
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -5,21 +5,27 @@ declare(strict_types=1);
namespace App\Filament\Resources; namespace App\Filament\Resources;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Exceptions\Api\ApiException;
use App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager; use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager;
use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager; use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager;
use App\Models\User; use App\Models\User;
use App\Service\DeletionService;
use App\Service\TimezoneService; use App\Service\TimezoneService;
use Brick\Money\ISOCurrencyProvider;
use Exception; use Exception;
use Filament\Forms; use Filament\Forms;
use Filament\Forms\Components\TextInput; use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource; use Filament\Resources\Resource;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter; use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table; use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use STS\FilamentImpersonate\Tables\Actions\Impersonate; use STS\FilamentImpersonate\Tables\Actions\Impersonate;
class UserResource extends Resource class UserResource extends Resource
@@ -34,6 +40,8 @@ class UserResource extends Resource
public static function form(Form $form): Form public static function form(Form $form): Form
{ {
/** @var User|null $record */
$record = $form->getRecord();
return $form return $form
->columns(1) ->columns(1)
->schema([ ->schema([
@@ -50,12 +58,25 @@ class UserResource extends Resource
Forms\Components\TextInput::make('email') Forms\Components\TextInput::make('email')
->label('Email') ->label('Email')
->required() ->required()
->rules($record?->is_placeholder ? [] : [
UniqueEloquent::make(User::class, 'email')
->ignore($record?->getKey()),
])
->rule([
'email',
])
->maxLength(255), ->maxLength(255),
Forms\Components\Toggle::make('is_placeholder') Forms\Components\Toggle::make('is_placeholder')
->label('Is Placeholder'), ->label('Is Placeholder?')
->hiddenOn(['create'])
->disabledOn(['edit']),
Forms\Components\DateTimePicker::make('email_verified_at') Forms\Components\DateTimePicker::make('email_verified_at')
->label('Email Verified At') ->label('Email Verified At')
->hiddenOn(['create'])
->nullable(), ->nullable(),
Forms\Components\Toggle::make('is_email_verified')
->label('Email Verified?')
->visibleOn(['create']),
Forms\Components\Select::make('timezone') Forms\Components\Select::make('timezone')
->label('Timezone') ->label('Timezone')
->options(fn (): array => app(TimezoneService::class)->getSelectOptions()) ->options(fn (): array => app(TimezoneService::class)->getSelectOptions())
@@ -67,15 +88,39 @@ class UserResource extends Resource
->required(), ->required(),
TextInput::make('password') TextInput::make('password')
->password() ->password()
->label('Password')
->dehydrateStateUsing(fn ($state) => Hash::make($state)) ->dehydrateStateUsing(fn ($state) => Hash::make($state))
->dehydrated(fn ($state) => filled($state)) ->dehydrated(fn ($state) => filled($state))
->hiddenOn(['create'])
->required(fn (string $context): bool => $context === 'create') ->required(fn (string $context): bool => $context === 'create')
->maxLength(255), ->maxLength(255),
TextInput::make('password_create')
->password()
->label('Password')
->visibleOn(['create'])
->required(fn (string $context): bool => $context === 'create')
->maxLength(255),
Forms\Components\Select::make('currency')
->label('Currency (Personal Organization)')
->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = [];
foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';
}
return $select;
})
->required()
->visibleOn(['create'])
->searchable(),
Forms\Components\DateTimePicker::make('created_at') Forms\Components\DateTimePicker::make('created_at')
->label('Created At') ->label('Created At')
->hiddenOn(['create'])
->disabled(), ->disabled(),
Forms\Components\DateTimePicker::make('updated_at') Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At') ->label('Updated At')
->hiddenOn(['create'])
->disabled(), ->disabled(),
]); ]);
} }
@@ -145,11 +190,22 @@ class UserResource extends Resource
} }
}), }),
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->hidden(fn (User $record) => $record->is(Auth::user()))
->using(function (User $record): void {
try {
app(DeletionService::class)->deleteUser($record);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Delete failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]); ]);
} }

View File

@@ -4,24 +4,29 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages; namespace App\Filament\Resources\UserResource\Pages;
use App\Enums\Weekday;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use App\Models\Organization;
use App\Models\User; use App\Models\User;
use App\Service\UserService;
use Filament\Resources\Pages\CreateRecord; use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord class CreateUser extends CreateRecord
{ {
protected static string $resource = UserResource::class; protected static string $resource = UserResource::class;
protected function afterCreate(): void protected function handleRecordCreation(array $data): User
{ {
/** @var User $user */ $userService = app(UserService::class);
$user = $this->record; $user = $userService->createUser(
$data['name'],
$data['email'],
$data['password_create'],
$data['timezone'],
Weekday::from($data['week_start']),
$data['currency'],
(bool) $data['is_email_verified']
);
$user->ownedTeams()->save(Organization::forceCreate([ return $user;
'user_id' => $user->id,
'name' => explode(' ', $user->name, 2)[0]."'s Organization",
'personal_team' => true,
]));
} }
} }

View File

@@ -15,7 +15,8 @@ class ListUsers extends ListRecords
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Actions\CreateAction::make(), Actions\CreateAction::make()
->icon('heroicon-s-plus'),
]; ];
} }
} }

View File

@@ -7,6 +7,7 @@ namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource; use App\Filament\Resources\UserResource;
use Filament\Actions\EditAction; use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord; use Filament\Resources\Pages\ViewRecord;
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
class ViewUser extends ViewRecord class ViewUser extends ViewRecord
{ {
@@ -15,6 +16,7 @@ class ViewUser extends ViewRecord
protected function getHeaderActions(): array protected function getHeaderActions(): array
{ {
return [ return [
Impersonate::make()->record($this->getRecord()),
EditAction::make('edit') EditAction::make('edit')
->icon('heroicon-s-pencil'), ->icon('heroicon-s-pencil'),
]; ];

View File

@@ -5,15 +5,18 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\RelationManagers; namespace App\Filament\Resources\UserResource\RelationManagers;
use App\Enums\Role; use App\Enums\Role;
use App\Exceptions\Api\ApiException;
use App\Filament\Resources\OrganizationResource; use App\Filament\Resources\OrganizationResource;
use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\User;
use App\Service\MemberService;
use Filament\Forms\Components\Select; use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form; use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager; use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables; use Filament\Tables;
use Filament\Tables\Actions\Action; use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AttachAction;
use Filament\Tables\Columns\TextColumn; use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table; use Filament\Tables\Table;
@@ -27,10 +30,6 @@ class OrganizationsRelationManager extends RelationManager
->schema([ ->schema([
Select::make('role') Select::make('role')
->options(Role::class), ->options(Role::class),
TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->numeric(),
]); ]);
} }
@@ -41,15 +40,11 @@ class OrganizationsRelationManager extends RelationManager
->columns([ ->columns([
TextColumn::make('name'), TextColumn::make('name'),
TextColumn::make('role'), TextColumn::make('role'),
TextColumn::make('billable_rate') TextColumn::make('membership.billable_rate')
->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100), ->label('Billable rate')
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
]) ])
->headerActions([ ->headerActions([
Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [
$action->getRecordSelect(),
Select::make('role')
->options(Role::class),
]),
]) ])
->actions([ ->actions([
Action::make('view') Action::make('view')
@@ -58,13 +53,48 @@ class OrganizationsRelationManager extends RelationManager
->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [ ->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [
'record' => $record->getKey(), 'record' => $record->getKey(),
])), ])),
Tables\Actions\EditAction::make(), Tables\Actions\EditAction::make()
Tables\Actions\DetachAction::make(), ->using(function (Organization $record, array $data): Organization {
/** @var Member $member */
$member = $record->getRelation('membership');
if ($data['role'] !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $record, Role::from($data['role']), true);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Update failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}
$member->save();
return $record;
}),
Tables\Actions\DetachAction::make()
->using(function (Organization $record): void {
/** @var User $user */
$user = $this->getOwnerRecord();
$member = Member::query()
->whereBelongsTo($user, 'user')
->whereBelongsTo($record, 'organization')
->firstOrFail();
try {
app(MemberService::class)->removeMember($member, $record);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Delete failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}),
]) ])
->bulkActions([ ->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
]),
]); ]);
} }
} }

View File

@@ -14,7 +14,7 @@ class ActiveUserOverview extends BaseWidget
{ {
protected static ?int $sort = 1; protected static ?int $sort = 1;
protected static ?string $heading = 'A Registrations'; protected ?string $heading = 'A Registrations';
protected function getCards(): array protected function getCards(): array
{ {

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

@@ -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;
@@ -43,4 +44,9 @@ class Controller extends \App\Http\Controllers\Controller
{ {
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

@@ -9,13 +9,12 @@ use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest; use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
use App\Http\Resources\V1\Invitation\InvitationCollection; use App\Http\Resources\V1\Invitation\InvitationCollection;
use App\Http\Resources\V1\Invitation\InvitationResource; use App\Http\Resources\V1\Invitation\InvitationResource;
use App\Mail\OrganizationInvitationMail;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Service\InvitationService; use App\Service\InvitationService;
use App\Service\OrganizationInvitationService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Mail;
class InvitationController extends Controller class InvitationController extends Controller
{ {
@@ -73,12 +72,11 @@ class InvitationController extends Controller
* *
* @operationId resendInvitationEmail * @operationId resendInvitationEmail
*/ */
public function resend(Organization $organization, OrganizationInvitation $invitation): JsonResponse public function resend(Organization $organization, OrganizationInvitation $invitation, OrganizationInvitationService $organizationInvitationService): JsonResponse
{ {
$this->checkPermission($organization, 'invitations:resend', $invitation); $this->checkPermission($organization, 'invitations:resend', $invitation);
Mail::to($invitation->email) $organizationInvitationService->resend($invitation);
->queue(new OrganizationInvitationMail($invitation));
return response()->json(null, 204); return response()->json(null, 204);
} }

View File

@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\Role; use App\Enums\Role;
use App\Events\MemberMadeToPlaceholder; 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;
@@ -19,8 +18,6 @@ use App\Http\Resources\V1\Member\MemberCollection;
use App\Http\Resources\V1\Member\MemberResource; use App\Http\Resources\V1\Member\MemberResource;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Service\BillableRateService; use App\Service\BillableRateService;
use App\Service\InvitationService; use App\Service\InvitationService;
use App\Service\MemberService; use App\Service\MemberService;
@@ -80,22 +77,8 @@ class MemberController extends Controller
} }
if ($request->has('role') && $member->role !== $request->getRole()->value) { if ($request->has('role') && $member->role !== $request->getRole()->value) {
$newRole = $request->getRole(); $newRole = $request->getRole();
$oldRole = Role::from($member->role); $allowOwnerChange = $this->hasPermission($organization, 'members:change-ownership');
if ($oldRole === Role::Owner) { $memberService->changeRole($member, $organization, $newRole, $allowOwnerChange);
throw new OrganizationNeedsAtLeastOneOwner;
}
if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed;
}
if ($newRole === Role::Owner) {
if ($this->hasPermission($organization, 'members:change-ownership')) {
$memberService->changeOwnership($organization, $member);
} else {
throw new OnlyOwnerCanChangeOwnership;
}
} else {
$member->role = $request->getRole()->value;
}
} }
$member->save(); $member->save();
@@ -109,28 +92,19 @@ class MemberController extends Controller
* *
* @operationId removeMember * @operationId removeMember
*/ */
public function destroy(Organization $organization, Member $member): JsonResponse public function destroy(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{ {
$this->checkPermission($organization, 'members:delete', $member); $this->checkPermission($organization, 'members:delete', $member);
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) { $memberService->removeMember($member, $organization);
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization;
}
$member->delete();
MemberRemoved::dispatch($member, $organization);
return response() return response()
->json(null, 204); ->json(null, 204);
} }
/** /**
* Make a member a placeholder member
*
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization * @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
*/ */
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse

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);
} }
/** /**
@@ -95,10 +102,14 @@ class ProjectController extends Controller
$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');
$project->is_public = $request->getIsPublic();
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 +128,32 @@ 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 ($request->has('is_public')) {
$project->is_public = $request->boolean('is_public');
}
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}
$oldBillableRate = $project->billable_rate; $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 +174,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

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1\Public;
use App\Enums\TimeEntryAggregationType;
use App\Http\Controllers\Api\V1\Controller;
use App\Http\Resources\V1\Report\DetailedWithDataReportResource;
use App\Models\Report;
use App\Models\TimeEntry;
use App\Service\Dto\ReportPropertiesDto;
use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
class ReportController extends Controller
{
/**
* Get report by a share secret
*
* This endpoint is public and does not require authentication. The report must be public and not expired.
* The report is considered expired if the `public_until` field is set and the date is in the past.
* The report is considered public if the `is_public` field is set to `true`.
*
* @operationId getPublicReport
*/
public function show(Request $request, TimeEntryAggregationService $timeEntryAggregationService): DetailedWithDataReportResource
{
$shareSecret = $request->header('X-Api-Key');
if (! is_string($shareSecret)) {
throw new ModelNotFoundException;
}
$report = Report::query()
->with([
'organization',
])
->where('share_secret', '=', $shareSecret)
->where('is_public', '=', true)
->where(function (Builder $builder): void {
/** @var Builder<Report> $builder */
$builder->whereNull('public_until')
->orWhere('public_until', '>', now());
})
->firstOrFail();
/** @var ReportPropertiesDto $properties */
$properties = $report->properties;
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($report->organization, 'organization');
$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStart($properties->start);
$filter->addEnd($properties->end);
$filter->addActive($properties->active);
$filter->addBillable($properties->billable);
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray());
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
$filter->addClientIdsFilter($properties->clientIds?->toArray());
$timeEntriesQuery = $filter->get();
$data = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(),
$report->properties->group,
$report->properties->subGroup,
$report->properties->timezone,
$report->properties->weekStart,
false,
$report->properties->start,
$report->properties->end,
);
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(),
TimeEntryAggregationType::fromInterval($report->properties->historyGroup),
null,
$report->properties->timezone,
$report->properties->weekStart,
true,
$report->properties->start,
$report->properties->end,
);
return new DetailedWithDataReportResource($report, $data, $historyData);
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Weekday;
use App\Http\Requests\V1\Report\ReportStoreRequest;
use App\Http\Requests\V1\Report\ReportUpdateRequest;
use App\Http\Resources\V1\Report\DetailedReportResource;
use App\Http\Resources\V1\Report\ReportCollection;
use App\Http\Resources\V1\Report\ReportResource;
use App\Models\Organization;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
use App\Service\ReportService;
use App\Service\TimezoneService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class ReportController extends Controller
{
/**
* @throws AuthorizationException
*/
protected function checkPermission(Organization $organization, string $permission, ?Report $report = null): void
{
parent::checkPermission($organization, $permission);
if ($report !== null && $report->organization_id !== $organization->id) {
throw new AuthorizationException('Report does not belong to organization');
}
}
/**
* Get reports
*
* @return ReportCollection<ReportResource>
*
* @throws AuthorizationException
*
* @operationId getReports
*/
public function index(Organization $organization): ReportCollection
{
$this->checkPermission($organization, 'reports:view');
$reports = Report::query()
->orderBy('created_at', 'desc')
->whereBelongsTo($organization, 'organization')
->paginate(config('app.pagination_per_page_default'));
return new ReportCollection($reports);
}
/**
* Get report
*
* @throws AuthorizationException
*
* @operationId getReport
*/
public function show(Organization $organization, Report $report): DetailedReportResource
{
$this->checkPermission($organization, 'reports:view', $report);
return new DetailedReportResource($report);
}
/**
* Create report
*
* @throws AuthorizationException
*
* @operationId createReport
*/
public function store(Organization $organization, ReportStoreRequest $request, TimezoneService $timezoneService, ReportService $reportService): DetailedReportResource
{
$this->checkPermission($organization, 'reports:create');
$user = $this->user();
$report = new Report;
$report->name = $request->getName();
$report->description = $request->getDescription();
$isPublic = $request->getIsPublic();
$report->is_public = $isPublic;
$properties = new ReportPropertiesDto;
$properties->group = $request->getPropertyGroup();
$properties->subGroup = $request->getPropertySubGroup();
$properties->historyGroup = $request->getPropertyHistoryGroup();
$properties->start = $request->getPropertyStart();
$properties->end = $request->getPropertyEnd();
$properties->active = $request->getPropertyActive();
$properties->setMemberIds($request->input('properties.member_ids', null));
$properties->billable = $request->getPropertyBillable();
$properties->setClientIds($request->input('properties.client_ids', null));
$properties->setProjectIds($request->input('properties.project_ids', null));
$properties->setTagIds($request->input('properties.tag_ids', null));
$properties->setTaskIds($request->input('properties.task_ids', null));
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
$timezone = $user->timezone;
if ($request->has('properties.timezone')) {
if ($timezoneService->isValid($request->input('properties.timezone'))) {
$timezone = $request->input('properties.timezone');
}
if ($timezoneService->mapLegacyTimezone($request->input('properties.timezone')) !== null) {
$timezone = $timezoneService->mapLegacyTimezone($request->input('properties.timezone'));
}
}
$properties->timezone = $timezone;
$report->properties = $properties;
if ($isPublic) {
$report->share_secret = $reportService->generateSecret();
$report->public_until = $request->getPublicUntil();
} else {
$report->share_secret = null;
$report->public_until = null;
}
$report->organization()->associate($organization);
$report->save();
return new DetailedReportResource($report);
}
/**
* Update report
*
* @throws AuthorizationException
*
* @operationId updateReport
*/
public function update(Organization $organization, Report $report, ReportUpdateRequest $request, ReportService $reportService): DetailedReportResource
{
$this->checkPermission($organization, 'reports:update', $report);
if ($request->has('name')) {
$report->name = $request->getName();
}
if ($request->has('description')) {
$report->description = $request->getDescription();
}
if ($request->has('is_public') && $request->getIsPublic() !== $report->is_public) {
$isPublic = $request->getIsPublic();
$report->is_public = $isPublic;
if ($isPublic) {
$report->share_secret = $reportService->generateSecret();
$report->public_until = $request->getPublicUntil();
} else {
$report->share_secret = null;
$report->public_until = null;
}
}
$report->save();
return new DetailedReportResource($report);
}
/**
* Delete report
*
* @throws AuthorizationException
*
* @operationId deleteReport
*/
public function destroy(Organization $organization, Report $report): JsonResponse
{
$this->checkPermission($organization, 'reports:delete', $report);
$report->delete();
return response()->json(null, 204);
}
}

View File

@@ -79,6 +79,9 @@ class TaskController extends Controller
$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,149 @@ 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, 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');
}
$debug = $request->getDebug();
$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 && ! $debug) {
throw new PdfRendererIsNotConfiguredException;
}
$viewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf.blade.php'));
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesQuery->clone()->reorder()->withOnly([]),
null,
null,
$user->timezone,
$user->week_start,
false,
null,
null
);
$html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(),
'aggregatedData' => $aggregatedData,
'timezone' => $timezone,
'currency' => $organization->currency,
'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone),
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
if ($debug) {
return response()->json([
'html' => $html,
'footer_html' => $footerHtml,
]);
}
$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()
->assets(
Stream::path(resource_path('pdf/Outfit-VariableFont_wght.ttf'), 'outfit.ttf'),
)
->margins(0.39, 0.78, 0.39, 0.39)
->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 +302,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 +311,158 @@ 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;
}
$debug = $request->getDebug();
$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 && ! $debug) {
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/pdf.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),
'debug' => $debug,
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
if ($footerViewFile === false) {
throw new \LogicException('View file not found');
}
$footerHtml = Blade::render($footerViewFile);
if ($debug) {
return response()->json([
'html' => $html,
'footer_html' => $footerHtml,
]);
}
$request = Gotenberg::chromium(config('services.gotenberg.url'))
->pdf()
->waitForExpression("window.status === 'ready'")
->margins(0.39, 0.78, 0.39, 0.39)
->paperSize('8.27', '11.7') // A4
->footer(Stream::string('footer', $footerHtml))
->assets(Stream::path(resource_path('pdf/echarts.min.js'), 'echarts.min.js'),
Stream::path(resource_path('pdf/Outfit-VariableFont_wght.ttf'), 'outfit.ttf'),
)
->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 +477,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,
];
} }
/** /**
@@ -215,7 +503,16 @@ class TimeEntryController extends Controller
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;
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
$timeEntry = new TimeEntry; $timeEntry = new TimeEntry;
$timeEntry->fill($request->validated()); $timeEntry->fill($request->validated());
@@ -250,16 +547,38 @@ class TimeEntryController extends Controller
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,22 +598,37 @@ 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 ($request->has('changes.description')) {
$changes['description'] = $request->input('changes.description') ?? '';
}
if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) { if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) {
throw new AuthorizationException; 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;
} }
$task = null;
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; $success = new Collection;
$error = new Collection; $error = new Collection;
@@ -313,12 +647,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 +697,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

@@ -45,16 +45,34 @@ class HealthCheckController extends Controller
$dbTimezone = DB::select('show timezone;'); $dbTimezone = DB::select('show timezone;');
$response = [
'ip_address' => $ipAddress,
'url' => $request->url(),
'path' => $request->path(),
'hostname' => $hostname,
'timestamp' => Carbon::now()->timestamp,
'date_time_utc' => Carbon::now('UTC')->toDateTimeString(),
'date_time_app' => Carbon::now()->toDateTimeString(),
'timezone' => $dbTimezone[0]->TimeZone,
'secure' => $secure,
'is_trusted_proxy' => $isTrustedProxy,
];
if (app()->hasDebugModeEnabled()) {
$response['app_debug'] = true;
$response['app_url'] = config('app.url');
$response['app_env'] = app()->environment();
$response['app_timezone'] = config('app.timezone');
$response['app_force_https'] = config('app.force_https');
$response['trusted_proxies'] = config('trustedproxy.proxies');
$headers = $request->headers->all();
if (isset($headers['cookie'])) {
$headers['cookie'] = '***';
}
$response['headers'] = $headers;
}
return response() return response()
->json([ ->json($response);
'ip_address' => $ipAddress,
'hostname' => $hostname,
'timestamp' => Carbon::now()->timestamp,
'date_time_utc' => Carbon::now('UTC')->toDateTimeString(),
'date_time_app' => Carbon::now()->toDateTimeString(),
'timezone' => $dbTimezone[0]->TimeZone,
'secure' => $secure,
'is_trusted_proxy' => $isTrustedProxy,
]);
} }
} }

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,10 +32,10 @@ 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',
@@ -51,21 +51,46 @@ 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',
],
// Whether the project is public
'is_public' => [
'boolean',
], ],
]; ];
} }
public function getIsPublic(): bool
{
return $this->has('is_public') && $this->boolean('is_public');
}
public function getBillableRate(): ?int public function getBillableRate(): ?int
{ {
$input = $this->input('billable_rate'); $input = $this->input('billable_rate');
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,10 +32,10 @@ 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',
@@ -50,17 +50,28 @@ class ProjectUpdateRequest extends FormRequest
'is_archived' => [ 'is_archived' => [
'boolean', 'boolean',
], ],
'is_public' => [
'boolean',
],
'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 +89,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

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
use App\Models\Organization;
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class ReportStoreRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|LegacyValidationRule>>
*/
public function rules(): array
{
return [
'name' => [
'required',
'string',
'max:255',
],
'description' => [
'nullable',
'string',
],
'is_public' => [
'required',
'boolean',
],
// After this date the report will be automatically set to private (is_public=false) (ISO 8601 format, UTC timezone)
'public_until' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',
'after:now',
],
'properties' => [
'required',
'array',
],
'properties.start' => [
'required',
'date_format:Y-m-d\TH:i:s\Z',
],
'properties.end' => [
'required',
'date_format:Y-m-d\TH:i:s\Z',
],
'properties.active' => [
'nullable',
'boolean',
],
'properties.member_ids' => [
'nullable',
'array',
],
'properties.member_ids.*' => [
'string',
'uuid',
],
'properties.billable' => [
'nullable',
'boolean',
],
'properties.client_ids' => [
'nullable',
'array',
],
'properties.client_ids.*' => [
'string',
'uuid',
],
// Filter by project IDs, project IDs are OR combined
'properties.project_ids' => [
'nullable',
'array',
],
'properties.project_ids.*' => [
'string',
'uuid',
],
// Filter by tag IDs, tag IDs are OR combined
'properties.tag_ids' => [
'nullable',
'array',
],
'properties.tag_ids.*' => [
'string',
'uuid',
],
'properties.task_ids' => [
'nullable',
'array',
],
'properties.task_ids.*' => [
'string',
'uuid',
],
'properties.group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
'properties.sub_group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
'properties.history_group' => [
'required',
Rule::enum(TimeEntryAggregationTypeInterval::class),
],
'properties.week_start' => [
'nullable',
Rule::enum(Weekday::class),
],
'properties.timezone' => [
'nullable',
'timezone:all',
],
];
}
public function getName(): string
{
return (string) $this->input('name');
}
public function getDescription(): ?string
{
return $this->input('description');
}
public function getIsPublic(): bool
{
return (bool) $this->input('is_public');
}
public function getPublicUntil(): ?Carbon
{
$publicUntil = $this->input('public_until');
return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil);
}
public function getPropertyStart(): Carbon
{
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.start'));
if ($start === null) {
throw new \LogicException('Start date validation is not working');
}
return $start;
}
public function getPropertyEnd(): Carbon
{
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('properties.end'));
if ($end === null) {
throw new \LogicException('End date validation is not working');
}
return $end;
}
public function getPropertyActive(): ?bool
{
if ($this->has('properties.active') && $this->input('properties.active') !== null) {
return (bool) $this->input('properties.active');
}
return null;
}
public function getPropertyBillable(): ?bool
{
if ($this->has('properties.billable') && $this->input('properties.billable') !== null) {
return (bool) $this->input('properties.billable');
}
return null;
}
public function getPropertyGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('properties.group'));
}
public function getPropertySubGroup(): TimeEntryAggregationType
{
return TimeEntryAggregationType::from($this->input('properties.sub_group'));
}
public function getPropertyHistoryGroup(): TimeEntryAggregationTypeInterval
{
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Carbon;
/**
* @property Organization $organization Organization from model binding
*/
class ReportUpdateRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'name' => [
'string',
'max:255',
],
'description' => [
'nullable',
'string',
],
'is_public' => [
'boolean',
],
'public_until' => [
'nullable',
'date_format:Y-m-d\TH:i:s\Z',
'after:now',
],
];
}
public function getName(): string
{
return (string) $this->input('name');
}
public function getDescription(): ?string
{
return $this->input('description');
}
public function getIsPublic(): bool
{
return (bool) $this->input('is_public');
}
public function getPublicUntil(): ?Carbon
{
$publicUntil = $this->input('public_until');
return $publicUntil === null ? null : Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $publicUntil);
}
}

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,214 @@
<?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 [
// Data format of the export
'format' => [
'required',
'string',
Rule::enum(ExportFormat::class),
],
// Type of first grouping
'group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
// Type of second grouping
'sub_group' => [
'required',
Rule::enum(TimeEntryAggregationType::class),
],
// Type of grouping of the historic aggregation (time chart)
'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',
],
'debug' => [
'string',
'in:true,false',
],
];
}
public function getDebug(): bool
{
return $this->input('debug') === 'true';
}
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
{
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
if ($start === null) {
throw new \LogicException('Start date validation is not working');
}
return $start;
}
public function getEnd(): Carbon
{
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
if ($end === null) {
throw new \LogicException('End date validation is not working');
}
return $end;
}
public function getFormatValue(): ExportFormat
{
return ExportFormat::from($this->validated('format'));
}
}

View File

@@ -32,12 +32,13 @@ class TimeEntryAggregateRequest extends FormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// Type of first grouping
'group' => [ 'group' => [
'nullable', 'nullable',
'required_with:group_2', 'required_with:sub_group',
Rule::enum(TimeEntryAggregationType::class), Rule::enum(TimeEntryAggregationType::class),
], ],
// Type of second grouping
'sub_group' => [ 'sub_group' => [
'nullable', 'nullable',
Rule::enum(TimeEntryAggregationType::class), Rule::enum(TimeEntryAggregationType::class),
@@ -45,11 +46,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 +58,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 +79,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 +91,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 +115,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,173 @@
<?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\Support\Carbon;
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' => [
'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',
],
// 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',
],
'debug' => [
'string',
'in:true,false',
],
];
}
public function getDebug(): bool
{
return $this->input('debug', 'false') === 'true';
}
public function getStart(): Carbon
{
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('start'), 'UTC');
if ($start === null) {
throw new \LogicException('Start date validation is not working');
}
return $start;
}
public function getEnd(): Carbon
{
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC');
if ($end === null) {
throw new \LogicException('End date validation is not working');
}
return $end;
}
public function getOnlyFullDates(): bool
{
return $this->input('only_full_dates', 'false') === 'true';
}
public function getFormatValue(): ExportFormat
{
return ExportFormat::from($this->validated('format'));
}
}

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