Compare commits

...

263 Commits

Author SHA1 Message Date
Constantin Graf
0290013d19 Specify enclosure and escape for solidtime export and import 2024-10-15 13:35:37 +02:00
Constantin Graf
85f4a3049c Fixed escaping issues in importer 2024-10-15 12:57:45 +02:00
Constantin Graf
4c27f1a2de Fix bugs in computed attribute calculation 2024-10-15 12:57:45 +02:00
Constantin Graf
69d3ff4f7b Stricter validation for uuid and integer 2024-10-15 12:57:45 +02:00
Constantin Graf
2b1da883fb Fixed typo in console kernel 2024-10-11 13:10:09 +02:00
Gregor Vostrak
c291170d79 fix timing problem when updating multiple time entries, fixes #202 2024-10-09 17:35:22 +02:00
Constantin Graf
d9925d632e Fix api url 2024-10-09 17:34:08 +02:00
Gregor Vostrak
ddf11b394d do not load filament theme stylesheet in main application 2024-10-09 16:51:25 +02:00
Gregor Vostrak
129c132f97 make project and tags in mass updates resettable 2024-10-09 14:20:07 +02:00
Gregor Vostrak
26637e6f84 fix billable status update dropdown 2024-10-09 13:30:09 +02:00
Gregor Vostrak
612f40a4b0 fix unselecting bugs in time view 2024-10-09 13:26:51 +02:00
Gregor Vostrak
8f34fac0a6 add select all for time entry row heading 2024-10-09 03:01:34 +02:00
Gregor Vostrak
a374a52474 add select and deselect all on time and detailed reporting view 2024-10-09 01:48:23 +02:00
Gregor Vostrak
09586de2d5 clear selected time entries after mass delete in time vue 2024-10-09 01:00:12 +02:00
Gregor Vostrak
678d27c93a fix design inconsistencies between regular and aggregate row 2024-10-09 00:55:42 +02:00
Constantin Graf
7af1990935 Added fallback for local env to server overview widget 2024-10-08 21:31:35 +02:00
Constantin Graf
2372ee0622 Add update lookup and telemetry, Add version and build to app config 2024-10-08 21:31:35 +02:00
Gregor Vostrak
f147fb9725 add mass updates to time view 2024-10-08 21:28:23 +02:00
Constantin Graf
d5a4df738f Fix bug in time-entry.update-multiple; Add computed property for client_id 2024-10-08 19:19:08 +02:00
Gregor Vostrak
b3b84db004 fix wrong update on time range selector that causes duplicate time entry start requests, fixes ST-449 2024-10-08 18:16:06 +02:00
Gregor Vostrak
d3d3a98b08 change detailed reporting to use time entries mass delete endpoint 2024-10-08 13:26:27 +02:00
Gregor Vostrak
9f2ac70549 add mass delete time entries frontend, closes ST-450 2024-10-08 13:26:27 +02:00
Constantin Graf
071895791c Add endpoint to delete multiple time entries 2024-10-08 13:26:27 +02:00
Gregor Vostrak
9a50e144b3 improve time entry heading padding 2024-10-08 12:59:04 +02:00
Gregor Vostrak
a77b8a5ed2 add mass update to detailed reporting page 2024-10-08 12:59:04 +02:00
Constantin Graf
fcba96fbf6 Renamed skip to offset 2024-10-08 12:59:04 +02:00
Gregor Vostrak
d200de54a8 fix chart overflowing on some screen sizes 2024-10-08 12:59:04 +02:00
Constantin Graf
a882ec6ca0 Add skip and meta to resource in time entry endpoint 2024-10-08 12:59:04 +02:00
Gregor Vostrak
3ee7839ca9 add detailed reporting page 2024-10-08 12:59:04 +02:00
Gregor Vostrak
165391861a remove debug message 2024-10-01 22:59:59 +02:00
Gregor Vostrak
8d950c6d45 hide billable rate in projects table for employees when employees_can_see_billable_rates is disabled 2024-10-01 22:48:27 +02:00
Gregor Vostrak
6c7b1b3f21 add employees_can_see_billable_rates setting to organization settings 2024-10-01 22:48:27 +02:00
Constantin Graf
51cd919db6 Add organization setting employees_can_see_billable_rates 2024-10-01 22:48:27 +02:00
Constantin Graf
9d279d4980 Fix ARM image 2024-09-30 23:36:58 +02:00
Gregor Vostrak
32c7e55a15 add Upgrade Info Modal, fix hardcoded premium flag 2024-09-30 14:52:18 +02:00
Gregor Vostrak
084647c2a6 add project edit button to project show page and billing rate info, fixes ST-236 2024-09-30 14:19:47 +02:00
Gregor Vostrak
469f128604 fix project name column overflow on some screen sizes with long project names 2024-09-30 14:19:47 +02:00
Gregor Vostrak
c9c221de62 improve focus handling in time tracker component, improve focus-visible state for timetracker start and stop button 2024-09-30 14:19:47 +02:00
Gregor Vostrak
878bbd359d cleanup dayjs abstraction usage and useCurrentTimeEntry api for starting and stopping time entries 2024-09-30 14:19:47 +02:00
Gregor Vostrak
a6528102fe add estimated project and tasks frontend 2024-09-30 14:19:47 +02:00
Constantin Graf
bff766d363 Add spend_time to projects and tasks 2024-09-30 14:19:47 +02:00
Constantin Graf
2e8da98287 Added php-cs-fixer rule void_return 2024-09-30 14:19:47 +02:00
Constantin Graf
a820d8540f Added time estimates for projects and tasks, fixes ST-283 2024-09-30 14:19:47 +02:00
Constantin Graf
78ea8a673b Fixed timezone problem in unit tests 2024-09-30 11:02:11 +02:00
Gregor Vostrak
8b50f33cc9 chore: remove unnecessary startLiveTimer call in current time entry init 2024-09-26 01:02:34 +02:00
Gregor Vostrak
014bffe86d display the number of projects in a separate column in the clients table 2024-09-26 00:59:51 +02:00
Gregor Vostrak
2dbde63043 clear client name input on client create submit, fixes #189 2024-09-25 14:51:25 +02:00
Gregor Vostrak
876a41cb2a fix client page header design bug 2024-09-23 12:54:09 +02:00
Gregor Vostrak
1036502e49 remove wrong character from billing banner 2024-09-20 23:40:02 +02:00
Gregor Vostrak
5bf4dc79c2 hide explanation text for billing banner on mobile view 2024-09-20 12:59:09 +02:00
Constantin Graf
2592dd3b9e Fix local setup 2024-09-19 23:48:03 +02:00
Gregor Vostrak
05f240efc9 fix custom date picker update in reporting 2024-09-19 11:16:31 +02:00
Gregor Vostrak
d5b35ef420 improve billing banners on mobile 2024-09-17 22:32:43 +02:00
Gregor Vostrak
7e5374d5b1 add presets for date rage picker in reporting 2024-09-17 22:32:43 +02:00
Gregor Vostrak
36cdae523f fix bug where chart does not update project colors on data change 2024-09-17 22:32:43 +02:00
Gregor Vostrak
b2ad4b3785 add description grouping to reporting page (fixes ST-399), persist grouping selection in local storage 2024-09-17 22:32:43 +02:00
Constantin Graf
5e4270e3f5 Add time entry aggregation type “description” 2024-09-17 22:32:43 +02:00
Constantin Graf
d4e71e7c2c Lock import and increase timeout 2024-09-17 22:32:31 +02:00
Constantin Graf
5c6b32d5bb Deactivate auditing for time entries in importer 2024-09-16 21:50:01 +02:00
Constantin Graf
37400d239c Add command admin:user:verify 2024-09-13 17:59:10 +02:00
Constantin Graf
50902e7705 Renamed command admin:delete-organization to admin:organization:delete 2024-09-13 17:59:10 +02:00
Constantin Graf
498f29617e Add mapping for legacy timezones 2024-09-13 17:59:10 +02:00
Constantin Graf
61cc80dc6e Fixed export bug 2024-09-12 15:31:20 +02:00
Constantin Graf
0a0b7a03b4 Deactivate auditing for import and increase max_execution_time 2024-09-12 15:31:20 +02:00
Constantin Graf
cc10af0b97 Reduce overhead of health check endpoints 2024-09-12 15:31:20 +02:00
Constantin Graf
d3545b3c73 Allow time entries with less than one second duration 2024-09-12 15:31:20 +02:00
Gregor Vostrak
9e1413c15f unify and fix chart styles in dashboard and reporting view, fixes ST-356 2024-09-12 15:12:50 +02:00
Gregor Vostrak
ac85e778a4 fix error handling for organization export, fixes ST-426 2024-09-12 14:46:05 +02:00
Gregor Vostrak
9189910136 fix available roles filter, fixes ST-425 2024-09-12 14:41:23 +02:00
Gregor Vostrak
85315fc62f add client grouping and expandable project tasks to project task timetracker dropdown, fixes ST-253 2024-09-11 18:07:35 +02:00
Constantin Graf
91b56ae92f Fixed deprecation warning 2024-09-11 18:07:35 +02:00
Gregor Vostrak
845f0d19d8 add trial expiry day countdown to billing banner 2024-09-11 18:07:35 +02:00
Gregor Vostrak
d211e962f5 fix reporting multiselect dropdowns max height, fixes ST-414 2024-09-11 18:07:35 +02:00
Gregor Vostrak
f0705e1e4a fix sidebar navigation overflowing, add scrollbar only to nav items 2024-09-11 18:07:35 +02:00
Gregor Vostrak
b990387775 make No Project white in chart fixes ST-360 2024-09-11 18:07:35 +02:00
Gregor Vostrak
a4d6ba3cdb improve reporting chart, fix project table with long client name, fixes ST-414 2024-09-11 18:07:35 +02:00
Gregor Vostrak
3b41d90b07 fix layout bug in time view with small time entries, fixes ST-414 2024-09-11 18:07:35 +02:00
Gregor Vostrak
b391f47d1b fix scroll & jumping issues with task dropdown, fixes ST-395 2024-09-11 18:07:35 +02:00
Gregor Vostrak
19cc05140a add archiving for clients, fixes ST-279 2024-09-11 18:07:35 +02:00
Gregor Vostrak
5592d87cd5 fix e2e tests, filter requests to listen to correct time entry update request 2024-09-11 18:07:35 +02:00
Gregor Vostrak
b518187ecb Dashboard Data Refresh After creating a time entry, fixes ST-299 2024-09-11 18:07:35 +02:00
Gregor Vostrak
c09119af33 fix project member billable rate not shown correctly in modal, fixes ST-363 2024-09-11 18:07:35 +02:00
Constantin Graf
ceba49d054 Reverting phpstan update to prevent incorrect warnings 2024-09-11 18:07:35 +02:00
Constantin Graf
01dd13b947 Add getTrialUntil to BillingContract; Allow delete endpoints after blocking 2024-09-11 18:07:35 +02:00
Gregor Vostrak
83301d03ca respect billing permission in frontend, fix hiding of billing banners 2024-09-11 18:07:35 +02:00
Constantin Graf
4969fcba7e Add billing permission to owner 2024-09-11 18:07:35 +02:00
Gregor Vostrak
48b2bb436e show action blocked modal with instructions instead of small notification when server returns action blocked error 2024-09-11 18:07:35 +02:00
Gregor Vostrak
30ed47d3fb add trial banners and unblock member invite modal during trial 2024-09-11 18:07:35 +02:00
Gregor Vostrak
2bad9eaa3c chore: type OrganizationInvitation in DefaultImporter, new formatting rules 2024-09-11 18:07:35 +02:00
Constantin Graf
78b41ea0b7 Added reply to config 2024-09-11 18:07:35 +02:00
Constantin Graf
d8968399d6 Updated dependencies; Fixed codeformatting and phpstan 2024-09-11 18:07:35 +02:00
Constantin Graf
5b7df869ad Added trial and blocking to billing contract, fixed bug in running time tracker command 2024-09-11 18:07:35 +02:00
Constantin Graf
7c593f8f87 Enable auditing for unit testing 2024-09-11 17:58:29 +02:00
Gregor Vostrak
22b2933d85 open export downloads in the same window 2024-09-11 17:58:29 +02:00
Gregor Vostrak
6dd9d5bab0 add exporter in frontend, fixes ST-382 2024-09-11 17:58:29 +02:00
Constantin Graf
9a8945b0dc Add local setup for S3 2024-09-11 17:58:29 +02:00
Constantin Graf
fc614b796c Increaded timeout for ARM build 2024-09-10 19:40:57 +02:00
Constantin Graf
b031598f79 Added ARM build 2024-09-10 19:00:44 +02:00
Constantin Graf
07823291ae Removed default healthcheck in prod Dockerfile 2024-09-05 13:07:01 +02:00
Gregor Vostrak
75012ea020 Update README.md 2024-09-04 17:37:08 +02:00
Gregor Vostrak
49de8d0900 remove dev setup instructions from the readme and link self-hosting 2024-09-04 17:28:44 +02:00
Constantin Graf
156d2ff1a0 Add auditing 2024-09-03 14:26:01 +02:00
Constantin Graf
a01e1d6b0b Add billable rate calculation to creation and deletion of project members 2024-09-03 13:10:43 +02:00
Constantin Graf
9df91f4e4a Fix billiable rate in updateMultiple time entries (ST-396) 2024-09-03 13:09:09 +02:00
Gregor Vostrak
e538fec7c7 improve sidebar scrollbars for firefox 2024-08-29 14:55:45 +02:00
Gregor Vostrak
aee5ea456e fix overflow issue 2024-08-29 14:55:45 +02:00
Gregor Vostrak
2c0ab5e15a add update notification to sidebar, fix aborted requests on navigate 2024-08-29 14:55:45 +02:00
Constantin Graf
0245eccaeb Fixed broken test 2024-08-27 21:31:09 +02:00
Constantin Graf
ee77de04ef Added export endpoint and solidtime import; Enhanced toggl import 2024-08-27 21:31:09 +02:00
Gregor Vostrak
056a63e193 fix desktop version update urls 2024-08-27 18:52:09 +02:00
Gregor Vostrak
024d841024 add desktop versions infos, make package publish actions only run on manual trigger 2024-08-27 17:47:22 +02:00
Gregor Vostrak
597f9ce802 fix time entry aggregate mass delete function 2024-08-27 17:47:22 +02:00
Gregor Vostrak
18ac9acc2a chore: bump api package version 2024-08-27 17:47:22 +02:00
Gregor Vostrak
f6d9dfa6bb expose createApiClient method in api package to public 2024-08-27 17:47:22 +02:00
Gregor Vostrak
64d422f5f7 force publish ui package 2024-08-27 17:47:22 +02:00
Gregor Vostrak
b3b8b9fba9 fix formatting of github action files workflow_dispatch 2024-08-27 17:47:22 +02:00
Gregor Vostrak
e981d6bc01 chore: bump ui package version 2024-08-27 17:47:22 +02:00
Gregor Vostrak
859833452f add daily duration to header, fix dropdown overflows, add time dropdown to duration select 2024-08-27 17:47:22 +02:00
Gregor Vostrak
33d139e3aa add mass updates to time entry aggregate rows, make package actions run on manual dispatch 2024-08-27 17:47:22 +02:00
Gregor Vostrak
0c05ad240d install root dependencies for building api package 2024-08-27 17:47:22 +02:00
Gregor Vostrak
4ad68b4f4e change github action checkout path to prevent dependencies being loaded from the parent 2024-08-27 17:47:22 +02:00
Gregor Vostrak
249b1b5820 cleanup and fix formatting for utils and packages 2024-08-27 17:47:22 +02:00
Gregor Vostrak
1328692faf fix ui exports, change api package bunder to vite, fix type exports 2024-08-27 17:47:22 +02:00
Gregor Vostrak
35c65d3bf0 move MainContainer Component to ui package and fix types 2024-08-27 17:47:22 +02:00
Gregor Vostrak
c3cad88949 add TimeEntryGroupedTable to exported components 2024-08-27 17:47:22 +02:00
Gregor Vostrak
f4d4ea8b98 explicitly define exported components 2024-08-27 17:47:22 +02:00
Gregor Vostrak
05ece9b0ee clean up ui package dev dependencies 2024-08-27 17:47:22 +02:00
Gregor Vostrak
571054b816 install root project dependencies for building ui package 2024-08-27 17:47:22 +02:00
Gregor Vostrak
f014137623 move multiselect components, week start and timezon functions to ui package 2024-08-27 17:47:22 +02:00
Gregor Vostrak
b2d327e8b1 add heroicons and move all ui package dependencies to peerDependencies 2024-08-27 17:47:22 +02:00
Gregor Vostrak
c6ee2b5131 add missing dayjs dependency to ui package 2024-08-27 17:47:22 +02:00
Gregor Vostrak
b689784701 add repository fields to package.json of api and ui packages 2024-08-27 17:47:22 +02:00
Gregor Vostrak
b375cba5f7 fix working directory in github actions for ui and api packages 2024-08-27 17:47:22 +02:00
Gregor Vostrak
635954f81d move ui and api to seperate packages and add npm actions for them 2024-08-27 17:47:22 +02:00
Constantin Graf
b7c9aa6f28 ST-370: Fixed error when sending unknown fields in request 2024-08-23 16:53:00 +02:00
Gregor Vostrak
87b114a32a fix formatting for hours 2024-08-20 23:38:36 +02:00
Gregor Vostrak
00e095ec4b fix token invalidation detection 2024-08-20 16:27:52 +02:00
Gregor Vostrak
b741105cfa only update the current time entry when the description was actually changed, not on all blur 2024-08-08 17:36:20 +02:00
Gregor Vostrak
16203ec748 fix hiding of existing members in the member create modal 2024-08-08 17:36:20 +02:00
Gregor Vostrak
06a35cb447 disable zodios request/response validation in runtime and use server errors instead 2024-08-08 16:11:14 +02:00
Gregor Vostrak
7c1b828ad3 fix vite config for authorization page 2024-08-05 16:49:24 +02:00
Gregor Vostrak
ea90b0acb2 add custom passport authorize page 2024-08-05 16:49:24 +02:00
Gregor Vostrak
10cc5cf42a seperate project types, make tag dropdown location configurable, update api client 2024-08-05 16:49:24 +02:00
Constantin Graf
04bb8e50a7 Renamed user member endpoint and removed pagination 2024-08-05 16:49:24 +02:00
Gregor Vostrak
6aef8856f5 fix wrong secondarybutton import 2024-08-05 16:49:24 +02:00
Gregor Vostrak
06fef6e40f refactor timetracker to seperate data and ui logic 2024-08-05 16:49:24 +02:00
Constantin Graf
a9c874e540 Added pagination config for filament 2024-08-05 16:49:24 +02:00
Constantin Graf
21207a4058 Added me endpoints 2024-08-05 16:49:24 +02:00
Constantin Graf
0e7dec2f40 Updated scramble 2024-08-05 16:49:24 +02:00
Gregor Vostrak
99c652a61b refactor required time entry emits to props 2024-08-05 16:49:24 +02:00
Gregor Vostrak
1e4f0afa67 use prop function createTag instead of event to make sure it is handled by the parent 2024-08-05 16:49:24 +02:00
Gregor Vostrak
655723db49 refactor tag components and tagCreate events, change global week_start and timezone settings, fix pie charts 2024-08-05 16:49:24 +02:00
Gregor Vostrak
10d8540e6c refactor to common MoreOptionsDropdown component for shared ui 2024-08-05 16:49:24 +02:00
Gregor Vostrak
cbdbcef9eb move time entries grouped table to its own component 2024-08-05 16:49:24 +02:00
Gregor Vostrak
a519c119d4 refactor time entry and projecttaskdropdown components to not rely on pinia stores 2024-08-05 16:49:24 +02:00
Gregor Vostrak
375cee7589 fix select behaviour in project member dropdown, fixes ST-308 2024-08-05 16:49:24 +02:00
Constantin Graf
ba07616111 Added storage link to docker image 2024-07-24 13:54:54 +02:00
Constantin Graf
63323d86c3 Added tests for FailedJobResource and renamed to singular 2024-07-18 13:27:46 +02:00
Constantin Graf
8db0a7d25e Added mail to inform users about still running time entries 2024-07-18 13:27:46 +02:00
Constantin Graf
855db81104 Added failed jobs to admin panel 2024-07-18 13:27:46 +02:00
Constantin Graf
055d93f7a3 Published mail layout and added logo 2024-07-18 13:27:46 +02:00
Constantin Graf
ee2f125062 Fixed typo 2024-07-15 21:41:37 +02:00
Constantin Graf
fd8d596e9b Moved invitation from jetstream to API; Deactived moved jetstream features 2024-07-15 17:35:10 +02:00
Constantin Graf
555417dbbd Added tests for billable rate in time entries endpoint 2024-07-15 17:34:56 +02:00
Gregor Vostrak
7aab3d98fc remove billable_rate_update_time_entries flag and always update all time entries 2024-07-15 17:34:56 +02:00
Constantin Graf
1dc35f1f55 Removed option to update billable rate without updating time entries 2024-07-15 17:34:56 +02:00
Gregor Vostrak
be50397775 refactor billableratemodal to use a common component for shared logic 2024-07-08 17:22:48 +02:00
Gregor Vostrak
e3b4cfd881 add billable rate updates for time entries in the past to projects and project members, fixes ST-304 2024-07-08 17:22:48 +02:00
Constantin Graf
7fd5d25781 Fixed failed jobs table 2024-07-03 17:14:35 +02:00
Constantin Graf
4c2748ff50 Added tests of extension to phpunit config 2024-07-03 15:05:00 +02:00
Gregor Vostrak
c69701aa66 add ability to change role of a user 2024-07-03 14:21:00 +02:00
Gregor Vostrak
c194785034 hide more options in members table if no options are avaliable, fixes ST-129 2024-07-03 14:08:26 +02:00
Gregor Vostrak
53e5805937 fix type, fixes ST-301 2024-07-03 12:55:11 +02:00
Gregor Vostrak
a8d82d0d2c remove owner from invite member select, fix modal not closing bug 2024-07-03 12:53:52 +02:00
Gregor Vostrak
8f0be6efce respect has_subscription property in frontend for displaying the member add popup 2024-07-02 17:17:27 +02:00
Gregor Vostrak
6593a8c24f add support for archiving projects and marking tasks as done 2024-07-02 17:01:12 +02:00
Constantin Graf
0f32e42002 Fixed typo 2024-07-01 19:15:57 +02:00
Constantin Graf
8ddce667cc Added billing information to inertia data 2024-07-01 18:34:06 +02:00
Gregor Vostrak
726c2ee623 fix members test 2024-07-01 17:28:19 +02:00
Constantin Graf
7decb095ee Fixed static code analyser and added unit tests for ip lookup 2024-07-01 17:25:20 +02:00
Gregor Vostrak
442da936d0 Merge branch 'feature/member_features' of github.com:solidtime-io/solidtime into feature/update_billable_rate
# Conflicts:
#	e2e/members.spec.ts
#	e2e/organization.spec.ts
2024-07-01 17:15:08 +02:00
Constantin Graf
3a17ae83ae Member update endpoint can now change ownership 2024-07-01 17:06:44 +02:00
Gregor Vostrak
264b7c9b8d add billable rate time entries update support for existing time entries (member & organization) 2024-07-01 17:06:44 +02:00
Constantin Graf
c3a7ef7585 Fixed api docs 2024-07-01 17:06:44 +02:00
Constantin Graf
de1accba4a Added ip lookup on registration, fixes ST-245 2024-07-01 17:06:44 +02:00
Constantin Graf
364168debd Add ability to set task to done, fixes ST-244 2024-07-01 17:06:44 +02:00
Constantin Graf
75e739f6fb Changed billable_rate_update_time_entries to real boolean 2024-07-01 17:06:44 +02:00
Constantin Graf
a69d1cb4c4 Added ability to archive projects and clients, fixes ST-37 2024-07-01 17:06:44 +02:00
Constantin Graf
f21a2d4bdd Fix unhandled error on jetstream page with non-UUID id, fixes ST-274 2024-07-01 17:06:44 +02:00
Constantin Graf
512089ccbd Make name fields in projects, tasks, clients and tags unique; fixes ST-265 2024-07-01 17:06:44 +02:00
Constantin Graf
313cee2db0 Restrict roles available to invitation and member.update, fixes ST-264 2024-07-01 17:06:44 +02:00
Constantin Graf
2184b3c835 Add ability to update billable rate of existing time entries 2024-07-01 17:06:44 +02:00
Constantin Graf
7c26cee1ea Added PHPUnit annotations 2024-07-01 17:06:44 +02:00
Gregor Vostrak
ce82dddc6a change invite tests to use members section instead of organization setting 2024-07-01 17:03:47 +02:00
Gregor Vostrak
099926f95c change member invite to api route, add resend invitation mail, add delete invitation, fixes ST-87 2024-07-01 17:03:47 +02:00
Constantin Graf
42da2c3397 Set timeout for all GitHub actions 2024-07-01 12:09:30 +02:00
Constantin Graf
62ac23cb1a Fixed tests after adding schema dumps for test database 2024-07-01 12:08:53 +02:00
Constantin Graf
c0c678ac0d Use schema dump only for phpunit test runs 2024-06-30 19:42:10 +02:00
Constantin Graf
c036b77331 Added frankenphp local setup files to .gitignore 2024-06-30 19:41:27 +02:00
Constantin Graf
7b467807d9 Moved from swoole to frankenphp 2024-06-27 16:39:45 +02:00
Gregor Vostrak
2e8b088c59 improve project edit modal: fix enter submit on billable input and add labels 2024-06-24 18:32:43 +02:00
Gregor Vostrak
e69a419551 change cookie session default name to solidtime_session 2024-06-24 18:28:37 +02:00
Gregor Vostrak
a10d0569af fix token refresh on window focus, deactivate webkit playwright tests 2024-06-24 18:23:43 +02:00
Gregor Vostrak
237b3832bb use log driver for mailing in ci pipeline 2024-06-24 18:23:43 +02:00
Gregor Vostrak
eefa7c8ca8 fix focus & click behaviour of time range selector and task project dropdown modal 2024-06-24 18:23:43 +02:00
Gregor Vostrak
fc0a0615cb reenable playwright github action 2024-06-24 18:23:43 +02:00
Gregor Vostrak
3a61d68dc1 rename state change in useCurrentTimeEntry 2024-06-18 18:30:29 +02:00
Gregor Vostrak
0121195e75 focus on description after starting time tracker, ST-254 2024-06-18 18:28:45 +02:00
Gregor Vostrak
0c054bdcf2 improve focus handling for time entry create modal and update end date if start date is after end, fixes ST-250 2024-06-18 17:58:26 +02:00
Gregor Vostrak
96f818cb04 update minor dependencies, update playwright image 2024-06-18 17:29:09 +02:00
Constantin Graf
31ca0419f5 Updated composer dependencies; Changed dependency nwidart/laravel-modules to original repository 2024-06-18 17:01:57 +02:00
dependabot[bot]
78e35222f8 Bump docker/build-push-action from 5 to 6
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-18 16:45:22 +02:00
dependabot[bot]
c5b854adb3 Bump braces in the npm_and_yarn group across 1 directory
Bumps the npm_and_yarn group with 1 update in the / directory: [braces](https://github.com/micromatch/braces).


Updates `braces` from 3.0.2 to 3.0.3
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
  dependency-group: npm_and_yarn
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-18 16:44:59 +02:00
Gregor Vostrak
9f374c7716 improve time picker focus handling and number input, fixes ST-251 2024-06-18 15:48:46 +02:00
Gregor Vostrak
ce8e503faa refresh stores on window focus, fixes ST-262 2024-06-18 13:47:00 +02:00
Gregor Vostrak
79f914d4b6 add partial patches to time entries store after time entry updates to avoid inconsistencies , fixes ST-259 2024-06-18 13:13:39 +02:00
Gregor Vostrak
c4757ee8a9 validate if date is valid before updating the value to prevent invalid dates sent to the server, fixes ST-255 2024-06-18 13:03:37 +02:00
Gregor Vostrak
c0212ec836 make activity graph chart resize on window resize, fixes ST-261 2024-06-18 12:57:07 +02:00
Gregor Vostrak
8f0c9afa1a remove user profile link from signup flow, fixes ST-260 2024-06-18 12:52:25 +02:00
Constantin Graf
8982bfac2b Fixed bug in user delete feature 2024-06-17 12:50:39 +02:00
Gregor Vostrak
9ac1d19722 change reporting default back 2024-06-13 21:12:41 +02:00
dependabot[bot]
843e16c4c0 Bump codecov/codecov-action from 4.4.1 to 4.5.0
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.4.1 to 4.5.0.
- [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.4.1...v4.5.0)

---
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>
2024-06-13 16:26:56 +02:00
Constantin Graf
9a920bd4e9 Fixed migration 2024-06-13 16:26:44 +02:00
Constantin Graf
bb8c944df5 Added telescope migration for local setup 2024-06-13 16:26:44 +02:00
Constantin Graf
e4c1363193 Moved local setup to octane with swoole 2024-06-13 16:26:44 +02:00
Constantin Graf
bd9cede081 Prevent and remove zero values for billable rates 2024-06-13 16:26:44 +02:00
Gregor Vostrak
92dde6a701 fix timezone issues related to time entries spanning over a day 2024-06-12 17:06:10 +02:00
Gregor Vostrak
91cb6ab087 add formatting to importer descriptions, change reporting group limit for months 2024-06-10 18:01:32 +02:00
Gregor Vostrak
fadcd042c0 bg card color improvements 2024-06-10 17:34:09 +02:00
Constantin Graf
0eef5ffcfa Performance optimization for import 2024-06-10 17:33:07 +02:00
Constantin Graf
90480f3bb8 Updated composer dependencies minor 2024-06-10 17:33:07 +02:00
Constantin Graf
86f5ea47bb Added user and organization deletion system; Added coverage annotations 2024-06-09 13:58:46 +02:00
Gregor Vostrak
8857befc6c add loading indicator to import (ST-149), change card divider color 2024-06-07 14:18:27 +02:00
Constantin Graf
f40ae91444 Fixed timezone in time entries importer (tests) 2024-06-06 18:46:27 +02:00
Constantin Graf
94940be02c Fixed timezone in time entries importer 2024-06-06 18:35:19 +02:00
Gregor Vostrak
f2f128e184 remove misplaced create tag button from import screen 2024-06-06 18:30:04 +02:00
Gregor Vostrak
ffea3c6b68 fix time picker messing up the date when number exceeds 24 hours 2024-06-06 17:41:00 +02:00
Constantin Graf
1fdbfe77f0 Build images for all tags 2024-06-06 17:30:45 +02:00
Gregor Vostrak
7fb58ea341 add is_billable support for create / update project and in timetracker, fixes ST-217 2024-06-06 17:19:41 +02:00
Constantin Graf
d9244d1ab4 Fixed bug in user profile update validation that did not ignore placeholder users 2024-06-06 17:19:41 +02:00
Constantin Graf
b0cdeb3e33 Fixed test case and travelTo function in test cases 2024-06-06 17:19:41 +02:00
Constantin Graf
86555664c5 Added billable flag to projects 2024-06-06 17:19:41 +02:00
Constantin Graf
20f9b344f6 Added is_imported flag to time entries 2024-06-06 17:19:41 +02:00
Constantin Graf
802d9558a3 Fixed endOfWeek bug 2024-06-06 17:19:41 +02:00
Gregor Vostrak
474c0de3ac add update member billable rate , fixes ST-241 2024-06-05 18:36:00 +02:00
Gregor Vostrak
b1795392ad add date selector to time entries, fixes ST-134 2024-06-05 18:36:00 +02:00
Gregor Vostrak
2692db2a86 fix timetracker timepicker when no timer is started yet 2024-06-05 18:36:00 +02:00
Gregor Vostrak
ded58f8bd6 add edit modal for tasks and clients, fixes ST-233 2024-06-05 18:36:00 +02:00
Gregor Vostrak
81e3ffd921 add project add button to dropdown and set fixed height, fixes ST-170 2024-06-05 18:36:00 +02:00
Gregor Vostrak
22363e1c89 move from variable font to static fonts to fix safari rendering problems, fixes ST-230 2024-06-05 18:36:00 +02:00
Gregor Vostrak
d28269ebb0 add project member edit modal, fixes ST-119 2024-06-05 18:36:00 +02:00
Gregor Vostrak
3fc9d8b381 fix feedback bubble overlays elements, fixes ST-172 2024-06-05 18:36:00 +02:00
Gregor Vostrak
5bfd9e7dce add time picker to timetracker, fixes ST-180 2024-06-05 18:36:00 +02:00
Gregor Vostrak
ee6999af90 fix billable rate input field, fixes ST-203 2024-06-05 18:36:00 +02:00
Gregor Vostrak
22420439d9 fix placeholder alignment in time view 2024-06-05 18:36:00 +02:00
Gregor Vostrak
a065744d40 indent design bug fixes on time view, fixes ST-208 2024-06-05 18:36:00 +02:00
Gregor Vostrak
4a7db27a05 reload stores after new team is created and data imported 2024-06-05 18:36:00 +02:00
Gregor Vostrak
bae4265f70 improve error handling for 401 requests and fetch 2024-06-05 18:36:00 +02:00
Gregor Vostrak
a64ee87d19 improve sidebar 2024-06-05 18:36:00 +02:00
Gregor Vostrak
c9311780ed restrict group and subgroup so they are always different 2024-06-05 18:36:00 +02:00
Gregor Vostrak
4943baa236 add reporting client aggregation 2024-06-05 18:36:00 +02:00
Gregor Vostrak
4c977b5bf8 add client_ids to reporting filters 2024-06-05 18:36:00 +02:00
Gregor Vostrak
4e439010d1 hide admin submenu title for non-admins 2024-06-05 18:36:00 +02:00
654 changed files with 34671 additions and 7621 deletions

View File

@@ -31,12 +31,7 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mailpit
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_MAILER=log
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"

View File

@@ -3,11 +3,12 @@ APP_ENV=local
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true
APP_URL=https://solidtime.test
AUDITING_ENABLED=true
SUPER_ADMINS=admin@example.com
LOG_CHANNEL=stack
LOG_DEPRECATIONS_CHANNEL=null
LOG_CHANNEL=single
LOG_DEPRECATIONS_CHANNEL=deprecation
LOG_LEVEL=debug
DB_CONNECTION=pgsql
@@ -26,7 +27,6 @@ DB_TEST_PASSWORD=root
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
SESSION_DRIVER=database
SESSION_LIFETIME=120
@@ -46,12 +46,6 @@ MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
MAIL_FROM_NAME="${APP_NAME}"
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_REGION=us-east-1
S3_BUCKET=
S3_USE_PATH_STYLE_ENDPOINT=false
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
@@ -60,6 +54,17 @@ PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
# Storage
FILESYSTEM_DISK=s3
PUBLIC_FILESYSTEM_DISK=s3
S3_ACCESS_KEY_ID=sail
S3_SECRET_ACCESS_KEY=password
S3_REGION=us-east-1
S3_BUCKET=local
S3_URL=http://storage.solidtime.test/local
S3_ENDPOINT=http://storage.solidtime.test
S3_USE_PATH_STYLE_ENDPOINT=true
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
@@ -73,3 +78,5 @@ NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083
PAGINATION_PER_PAGE_DEFAULT=500

View File

@@ -1,10 +1,12 @@
APP_NAME=solidtime
APP_VERSION=0.0.0
APP_BUILD=0
VITE_APP_NAME=solidtime
APP_ENV=production
APP_DEBUG=false
APP_FORCE_HTTPS=true
SESSION_SECURE_COOKIE=true
OCTANE_SERVER=swoole
OCTANE_SERVER=frankenphp
PAGINATION_PER_PAGE_DEFAULT=500
LOG_CHANNEL=stack

View File

@@ -3,6 +3,8 @@ on:
branches:
- main
- develop
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-private.yml'
@@ -13,18 +15,60 @@ name: Build - Private
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: "Check out code"
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
prefix: "v"
- name: "Get version"
id: version
run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
else
echo "ERROR: No previous tag found";
exit 1;
fi
else
version=$(echo "${{ github.ref }}" | cut -c 12-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
fi
- name: "Copy .env template for production"
run: |
cp .env.production .env
rm .env.production .env.ci .env.example
- name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
- name: "Output .env"
run: cat .env
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Copy .env template for production"
run: cp .env.production .env && cat .env
- name: "Checkout billing extension"
uses: actions/checkout@v4
with:
@@ -67,7 +111,7 @@ jobs:
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql, swoole
extensions: mbstring, dom, fileinfo, pgsql
- name: "Install dependencies"
uses: php-actions/composer@v6
@@ -110,15 +154,21 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=sha,format=long
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Build and push"
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
file: docker/prod/Dockerfile
push: true
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha

View File

@@ -3,6 +3,8 @@ on:
branches:
- main
- develop
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-public.yml'
@@ -13,12 +15,59 @@ name: Build - Public
jobs:
build:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
steps:
- name: "Check out code"
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
prefix: "v"
- name: "Get version"
id: version
run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
else
echo "ERROR: No previous tag found";
exit 1;
fi
else
version=$(echo "${{ github.ref }}" | cut -c 12-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
fi
- name: "Copy .env template for production"
run: cp .env.production .env
run: |
cp .env.production .env
rm .env.production .env.ci .env.example
- name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
- name: "Output .env"
run: cat .env
- name: "Install dependencies"
uses: php-actions/composer@v6
@@ -44,27 +93,39 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: solidtime/solidtime
images: |
solidtime/solidtime
ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v3
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Build and push"
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
platforms: linux/amd64
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -6,6 +6,7 @@ on:
jobs:
api_docs:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
pgsql_test:

View File

@@ -4,8 +4,8 @@ on: [push]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"

View File

@@ -4,8 +4,8 @@ on: [push]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"

29
.github/workflows/npm-publish-api.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Publish API package to NPM
on:
workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
# Setup .npmrc file to publish to npm
- name: Install root project dependencies
run: npm ci
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Install dependencies
run: npm ci
working-directory: ./resources/js/packages/api
- name: Build package
run: npm run build
working-directory: ./resources/js/packages/api
- name: Publish Package
run: npm publish --provenance --access public
working-directory: ./resources/js/packages/api
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

29
.github/workflows/npm-publish-ui.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Publish UI package to NPM
on:
workflow_dispatch
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Install root project dependencies
run: npm ci
- name: Install package dependencies
run: npm ci
working-directory: ./resources/js/packages/ui
- name: Build package
run: npm run build
working-directory: ./resources/js/packages/ui
- name: Publish Package
run: npm publish --provenance --access public
working-directory: ./resources/js/packages/ui
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -4,8 +4,8 @@ on: [push]
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"

View File

@@ -3,6 +3,7 @@ on: push
jobs:
phpstan:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"

View File

@@ -3,6 +3,7 @@ on: push
jobs:
phpunit:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
pgsql_test:
@@ -54,7 +55,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v4.4.1
uses: codecov/codecov-action@v4.5.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -3,6 +3,8 @@ on: push
jobs:
pint:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"
uses: actions/checkout@v4

View File

@@ -1,10 +1,10 @@
name: Playwright Tests
on:
workflow_dispatch:
on: [push]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
timeout-minutes: 60
services:
mailpit:
image: 'axllent/mailpit:latest'

11
.gitignore vendored
View File

@@ -1,5 +1,6 @@
/.phpunit.cache
/node_modules
node_modules
dist
/public/build
/public/hot
/public/storage
@@ -33,3 +34,11 @@ yarn-error.log
/k8s
/_ide_helper.php
/.phpstorm.meta.php
/.rnd
/caddy
/frankenphp
/public/frankenphp-worker.php
/data
/config/caddy
/config/composer

View File

@@ -20,82 +20,13 @@ solidtime is a modern open-source time tracking application for Freelancers and
- Roles and permissions: Create and manage organizations
- Import: Import your time tracking data from other time tracking applications (Supported: Toggl, Clockify, Timeentry CSV)
## Local setup for development
## Self Hosting
**System requirements**
* Docker
If you are looking into self-hosting solidtime, you can find the guides [here](https://docs.solidtime.io/self-hosting/intro)
First you need to download or clone the repository f.e. with `git@github.com:solidtime-io/solidtime.git`.
We also have an examples repository [here](https://github.com/solidtime-io/self-hosting-examples)
After that, execute the following commands **inside the project folder**:
```bash
docker run --rm \
--pull=always \
-v "$(pwd)":/opt \
-w /opt \
laravelsail/php83-composer:latest \
bash -c "composer install --ignore-platform-reqs"
cp .env.example .env
./vendor/bin/sail up -d
./vendor/bin/sail artisan key:generate
./vendor/bin/sail artisan migrate:fresh --seed
./vendor/bin/sail php artisan passport:install
./vendor/bin/sail npm install
./vendor/bin/sail npm run build
```
Make sure to set the APP_PORT and VITE_PORT inside your `.env` file to a port that is not already used by your system.
By default the application will run on [localhost:8083](http://localhost:8083/)
### Setup with Reverse Proxy
**Additional System Requirements**
* Traefik 2 Reverse-Proxy (https://github.com/korridor/reverse-proxy-docker-traefik)
Add the following entry to your `/etc/hosts`
```
127.0.0.1 solidtime.test
127.0.0.1 playwright.solidtime.test
127.0.0.1 vite.solidtime.test
127.0.0.1 mail.solidtime.test
```
### Running E2E Tests
`./vendor/bin/sail up -d ` will automatically start a Playwright UI server that you can access at `https://playwright.solidtime.test`.
Make sure that you use HTTPS otherwise the resources will not be loaded correctly.
### Recording E2E Tests
To record E2E tests, you need to install and execute playwright locally (outside the Docker container) using:
```bash
npx playwright install
npx playwright codegen solidtime.test
```
### E2E Troubleshooting
If E2E tests are not working at all, make sure you do not have the Vite server running and just run `npm run build` to update the version.
If the E2E tests are not working consistently and fail with a timeout during the authentication, you might want to delete the `test-results/.auth` directory to force new test accounts to be created.
### Generate ZOD Client
The Zodius HTTP client is generated using the following command:
```bash
npm run zod:generate
```
If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)
## Contributing

View File

@@ -9,6 +9,7 @@ use App\Enums\Weekday;
use App\Events\NewsletterRegistered;
use App\Models\Organization;
use App\Models\User;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\TimezoneService;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
@@ -18,6 +19,7 @@ use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Jetstream\Jetstream;
use Log;
class CreateNewUser implements CreatesNewUsers
{
@@ -43,7 +45,7 @@ class CreateNewUser implements CreatesNewUsers
'string',
'email',
'max:255',
new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder {
UniqueEloquent::make(User::class, 'email', function (Builder $builder): Builder {
/** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false);
}),
@@ -55,21 +57,54 @@ class CreateNewUser implements CreatesNewUsers
],
])->validate();
$timezone = 'UTC';
if (array_key_exists('timezone', $input) && is_string($input['timezone']) && app(TimezoneService::class)->isValid($input['timezone'])) {
$timezone = $input['timezone'];
$timezone = null;
if (array_key_exists('timezone', $input) && is_string($input['timezone'])) {
if (app(TimezoneService::class)->isValid($input['timezone'])) {
$timezone = $input['timezone'];
} else {
$timezone = app(TimezoneService::class)->mapLegacyTimezone($input['timezone']);
if ($timezone === null) {
Log::debug('Invalid timezone', ['timezone' => $input['timezone']]);
}
}
}
$user = DB::transaction(function () use ($input, $timezone) {
return tap(User::create([
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup(request()->ip());
$startOfWeek = Weekday::Monday;
$currency = null;
if ($ipLookupResponse !== null) {
$startOfWeek = $ipLookupResponse->startOfWeek ?? Weekday::Monday;
if ($timezone === null) {
$timezone = $ipLookupResponse->timezone;
}
$currency = $ipLookupResponse->currency;
}
$user = null;
$organization = null;
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
'timezone' => $timezone,
'week_start' => Weekday::Monday,
]), function (User $user) {
$this->createTeam($user);
});
'timezone' => $timezone ?? 'UTC',
'week_start' => $startOfWeek,
]);
$organization = new Organization;
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$organization->personal_team = true;
$organization->currency = $currency ?? 'EUR';
$organization->owner()->associate($user);
$organization->save();
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
$user->ownedTeams()->save($organization);
});
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];
@@ -79,24 +114,4 @@ class CreateNewUser implements CreatesNewUsers
return $user;
}
/**
* Create a personal team for the user.
*/
protected function createTeam(User $user): void
{
$organization = new Organization();
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$organization->personal_team = true;
$organization->owner()->associate($user);
$organization->save();
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
$user->ownedTeams()->save($organization);
}
}

View File

@@ -7,9 +7,11 @@ namespace App\Actions\Fortify;
use App\Enums\Weekday;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\UpdatesUserProfileInformation;
class UpdateUserProfileInformation implements UpdatesUserProfileInformation
@@ -24,11 +26,33 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
public function update(User $user, array $input): void
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($user->id)],
'photo' => ['nullable', 'mimes:jpg,jpeg,png', 'max:1024'],
'timezone' => ['required', 'timezone:all'],
'week_start' => ['required', Rule::enum(Weekday::class)],
'name' => [
'required',
'string',
'max:255',
],
'email' => [
'required',
'email',
'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false);
}),
],
'photo' => [
'nullable',
'mimes:jpg,jpeg,png',
'max:1024',
],
'timezone' => [
'required',
'timezone:all',
],
'week_start' => [
'required',
Rule::enum(Weekday::class),
],
])->validateWithBag('updateProfileInformation');
if (isset($input['photo'])) {

View File

@@ -7,7 +7,6 @@ namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Models\Organization;
use App\Models\User;
use App\Service\UserService;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
@@ -39,14 +38,10 @@ class AddOrganizationMember implements AddsTeamMembers
AddingTeamMember::dispatch($organization, $newOrganizationMember);
DB::transaction(function () use ($organization, $newOrganizationMember, $role) {
DB::transaction(function () use ($organization, $newOrganizationMember, $role): void {
$organization->users()->attach(
$newOrganizationMember, ['role' => $role]
);
if ($role === Role::Owner->value) {
app(UserService::class)->changeOwnership($organization, $newOrganizationMember);
}
});
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
@@ -76,15 +71,15 @@ class AddOrganizationMember implements AddsTeamMembers
'email' => [
'required',
'email',
(new ExistsEloquent(User::class, 'email', function (Builder $builder) {
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
/** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false);
}))->withMessage(__('We were unable to find a registered user with this email address.')),
})->withMessage(__('We were unable to find a registered user with this email address.')),
],
'role' => [
'required',
'string',
Rule::in([
Role::Owner->value,
Role::Admin->value,
Role::Manager->value,
Role::Employee->value,
@@ -98,7 +93,7 @@ class AddOrganizationMember implements AddsTeamMembers
*/
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
{
return function ($validator) use ($team, $email) {
return function ($validator) use ($team, $email): void {
$validator->errors()->addIf(
$team->hasRealUserWithEmail($email),
'email',

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
@@ -12,7 +13,6 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\CreatesTeams;
use Laravel\Jetstream\Events\AddingTeam;
use Laravel\Jetstream\Jetstream;
class CreateOrganization implements CreatesTeams
@@ -33,9 +33,7 @@ class CreateOrganization implements CreatesTeams
'name' => ['required', 'string', 'max:255'],
])->validateWithBag('createTeam');
AddingTeam::dispatch($user);
$organization = new Organization();
$organization = new Organization;
$organization->name = $input['name'];
$organization->personal_team = false;
$organization->owner()->associate($user);
@@ -47,10 +45,12 @@ class CreateOrganization implements CreatesTeams
]
);
$user->ownedTeams()->save($organization);
$user->switchTeam($organization);
// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
$organization = $organization->refresh();
AfterCreateOrganization::dispatch($organization);
return $organization;
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Service\DeletionService;
use Laravel\Jetstream\Contracts\DeletesTeams;
class DeleteOrganization implements DeletesTeams
@@ -12,8 +13,9 @@ class DeleteOrganization implements DeletesTeams
/**
* Delete the given team.
*/
public function delete(Organization $team): void
public function delete(Organization $organization): void
{
$team->purge();
/** @see ValidateOrganizationDeletion */
app(DeletionService::class)->deleteOrganization($organization);
}
}

View File

@@ -4,51 +4,27 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Exceptions\Api\ApiException;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Laravel\Jetstream\Contracts\DeletesTeams;
use App\Service\DeletionService;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Contracts\DeletesUsers;
class DeleteUser implements DeletesUsers
{
/**
* The team deleter implementation.
*
* @var \Laravel\Jetstream\Contracts\DeletesTeams
*/
protected $deletesTeams;
/**
* Create a new action instance.
*/
public function __construct(DeletesTeams $deletesTeams)
{
$this->deletesTeams = $deletesTeams;
}
/**
* Delete the given user.
*
* @throws ValidationException
*/
public function delete(User $user): void
{
DB::transaction(function () use ($user) {
$this->deleteTeams($user);
$user->deleteProfilePhoto();
$user->tokens->each->delete();
$user->delete();
});
}
/**
* Delete the teams and team associations attached to the user.
*/
protected function deleteTeams(User $user): void
{
$user->teams()->detach();
$user->ownedTeams->each(function (Organization $team) {
$this->deletesTeams->delete($team);
});
try {
app(DeletionService::class)->deleteUser($user);
} catch (ApiException $exception) {
throw ValidationException::withMessages([
'password' => $exception->getTranslatedMessage(),
]);
}
}
}

View File

@@ -4,103 +4,21 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Exceptions\MovedToApiException;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Service\PermissionStore;
use Closure;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Exception;
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
use Laravel\Jetstream\Events\InvitingTeamMember;
use Laravel\Jetstream\Mail\TeamInvitation;
class InviteOrganizationMember implements InvitesTeamMembers
{
/**
* Invite a new team member to the given team.
*
* @throws AuthorizationException
* @throws Exception
*/
public function invite(User $user, Organization $organization, string $email, ?string $role = null): void
{
if (! app(PermissionStore::class)->has($organization, 'invitations:create')) {
throw new AuthorizationException();
}
$this->validate($organization, $email, $role);
InvitingTeamMember::dispatch($organization, $email, $role);
/** @var OrganizationInvitation $invitation */
$invitation = $organization->teamInvitations()->create([
'email' => $email,
'role' => $role,
]);
Mail::to($email)->send(new TeamInvitation($invitation));
}
/**
* Validate the invite member operation.
*/
protected function validate(Organization $organization, string $email, ?string $role): void
{
Validator::make([
'email' => $email,
'role' => $role,
], $this->rules($organization))->after(
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
)->validateWithBag('addTeamMember');
}
/**
* Get the validation rules for inviting a team member.
*
* @return array<string, array<ValidationRule|Rule|string|In>>
*/
protected function rules(Organization $organization): array
{
return array_filter([
'email' => [
'required',
'email',
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) {
/** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($organization, 'organization');
}))->withMessage(__('This user has already been invited to the team.')),
],
'role' => [
'required',
'string',
Rule::in([
Role::Owner->value,
Role::Admin->value,
Role::Manager->value,
Role::Employee->value,
]),
],
]);
}
/**
* Ensure that the user is not already on the team.
*/
protected function ensureUserIsNotAlreadyOnTeam(Organization $organization, string $email): Closure
{
return function ($validator) use ($organization, $email) {
$validator->errors()->addIf(
$organization->hasRealUserWithEmail($email),
'email',
__('This user already belongs to the team.')
);
};
throw new MovedToApiException;
}
}

View File

@@ -4,50 +4,21 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Exceptions\MovedToApiException;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Gate;
use Illuminate\Validation\ValidationException;
use Exception;
use Laravel\Jetstream\Contracts\RemovesTeamMembers;
use Laravel\Jetstream\Events\TeamMemberRemoved;
class RemoveOrganizationMember implements RemovesTeamMembers
{
/**
* Remove the team member from the given team.
*
* @throws Exception
*/
public function remove(User $user, Organization $organization, User $teamMember): void
{
$this->authorize($user, $organization, $teamMember);
$this->ensureUserDoesNotOwnTeam($teamMember, $organization);
$organization->removeUser($teamMember);
TeamMemberRemoved::dispatch($organization, $teamMember);
}
/**
* Authorize that the user can remove the team member.
*/
protected function authorize(User $user, Organization $organization, User $teamMember): void
{
if (! Gate::forUser($user)->check('removeTeamMember', $organization) &&
$user->id !== $teamMember->id) {
throw new AuthorizationException;
}
}
/**
* Ensure that the currently authenticated user does not own the team.
*/
protected function ensureUserDoesNotOwnTeam(User $teamMember, Organization $organization): void
{
if ($teamMember->id === $organization->owner->id) {
throw ValidationException::withMessages([
'team' => [__('You may not leave a team that you created.')],
])->errorBag('removeTeamMember');
}
throw new MovedToApiException;
}
}

View File

@@ -5,63 +5,21 @@ declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Exceptions\MovedToApiException;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Service\PermissionStore;
use App\Service\UserService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Laravel\Jetstream\Events\TeamMemberUpdated;
use Exception;
class UpdateMemberRole
{
/**
* Update the role for the given team member.
*
* @throws AuthorizationException
* @throws ValidationException
* @throws Exception
*/
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
{
if (! app(PermissionStore::class)->has($organization, 'members:change-role')) {
throw new AuthorizationException();
}
$user = User::where('id', '=', $userId)->firstOrFail();
$member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail();
if ($member->role === Role::Placeholder->value) {
abort(403, 'Cannot update the role of a placeholder member.');
}
Validator::make([
'role' => $role,
], [
'role' => [
'required',
'string',
Rule::in([
Role::Owner->value,
Role::Admin->value,
Role::Manager->value,
Role::Employee->value,
]),
],
])->validate();
DB::transaction(function () use ($organization, $userId, $role, $user) {
$organization->users()->updateExistingPivot($userId, [
'role' => $role,
]);
if ($role === Role::Owner->value) {
app(UserService::class)->changeOwnership($organization, $user);
}
});
TeamMemberUpdated::dispatch($organization->fresh(), User::findOrFail($userId));
throw new MovedToApiException;
}
}

View File

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

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Actions\Jetstream;
use App\Models\Organization;
use App\Models\User;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
class ValidateOrganizationDeletion
{
/**
* Validate that the team can be deleted by the given user.
*
* @param User $user Authenticated user
* @param Organization $organization Organization to be deleted
*
* @throws AuthorizationException
*/
public function validate(User $user, Organization $organization): void
{
if (! app(PermissionStore::class)->userHas($organization, $user, 'organizations:delete')) {
throw new AuthorizationException;
}
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Admin;
use App\Models\Organization;
use App\Service\DeletionService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
class OrganizationDeleteCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:organization:delete
{ organization : The ID of the organization to delete }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete a organization';
/**
* Execute the console command.
*/
public function handle(DeletionService $deletionService): int
{
$organizationId = $this->argument('organization');
if (! Str::isUuid($organizationId)) {
$this->error('Organization ID must be a valid UUID.');
return self::FAILURE;
}
/** @var Organization|null $organization */
$organization = Organization::find($organizationId);
if ($organization === null) {
$this->error('Organization with ID '.$organizationId.' not found.');
return self::FAILURE;
}
$this->info('Deleting organization with ID '.$organization->getKey());
$deletionService->deleteOrganization($organization);
$this->info('Organization with ID '.$organization->getKey().' has been deleted.');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Admin;
use App\Models\User;
use Illuminate\Auth\Events\Verified;
use Illuminate\Console\Command;
class UserVerifyCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:user:verify
{ email : The email of the user to verify }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Verify the email address of an user';
/**
* Execute the console command.
*/
public function handle(): int
{
$email = $this->argument('email');
$this->info('Start verifying user with email "'.$email.'"');
/** @var User|null $user */
$user = User::where('email', $email)->first();
if ($user === null) {
$this->error('User with email "'.$email.'" not found.');
return self::FAILURE;
}
if ($user->hasVerifiedEmail()) {
$this->info('User with email "'.$email.'" already verified.');
return self::FAILURE;
}
$user->markEmailAsVerified();
event(new Verified($user));
$this->info('User with email "'.$email.'" has been verified.');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\SelfHost;
use App\Service\ApiService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
class SelfHostCheckForUpdateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'self-host:check-for-update';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Execute the console command.
*/
public function handle(): int
{
$apiService = app(ApiService::class);
$latestVersion = $apiService->checkForUpdate();
if ($latestVersion === null) {
$this->error('Failed to check for update, check the logs for more information.');
return self::FAILURE;
}
// Note: Cache for 13 hours, because the command runs twice daily (every 12 hours).
Cache::put('latest_version', $latestVersion, 60 * 60 * 12);
return self::SUCCESS;
}
}

View File

@@ -9,7 +9,7 @@ use Illuminate\Encryption\Encrypter;
use Illuminate\Support\Str;
use phpseclib3\Crypt\RSA;
class SelfHostGenerateKeys extends Command
class SelfHostGenerateKeysCommand extends Command
{
/**
* The name and signature of the console command.

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

@@ -15,7 +15,7 @@ class TestJobCommand extends Command
*
* @var string
*/
protected $signature = 'test:job';
protected $signature = 'test:job {--fail}';
/**
* The console command description.
@@ -30,7 +30,9 @@ class TestJobCommand extends Command
public function handle(): int
{
$user = User::firstOrFail();
TestJob::dispatch($user, 'Test job message.');
$fail = (bool) $this->option('fail');
TestJob::dispatch($user, 'Test job message.', $fail);
return self::SUCCESS;
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\TimeEntry;
use App\Mail\TimeEntryStillRunningMail;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
class TimeEntrySendStillRunningMailsCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'time-entry:send-still-running-mails '.
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sends emails to users who have running time entries for more than 8 hours.';
/**
* Execute the console command.
*/
public function handle(): int
{
$this->comment('Sending still running time entry emails...');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');
}
$sentMails = 0;
TimeEntry::query()
->whereNull('end')
->where('start', '<', now()->subHours(8))
->whereNull('still_active_email_sent_at')
->with([
'user',
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
})
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
/** @var Collection<int, TimeEntry> $timeEntries */
foreach ($timeEntries as $timeEntry) {
$user = $timeEntry->user;
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') for time entry '.$timeEntry->getKey());
$sentMails++;
if (! $dryRun) {
Mail::to($user->email)
->queue(new TimeEntryStillRunningMail($timeEntry, $user));
$timeEntry->still_active_email_sent_at = Carbon::now();
$timeEntry->save();
}
}
});
$this->comment('Finished sending '.$sentMails.' still running time entry emails...');
return self::SUCCESS;
}
}

View File

@@ -14,7 +14,17 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
$schedule->command('time-entry:send-still-running-mails')
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
->everyTenMinutes();
$schedule->command('self-host:check-for-update')
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
->twiceDaily();
$schedule->command('self-host:telemetry')
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDaily();
}
/**

View File

@@ -11,5 +11,4 @@ enum Role: string
case Manager = 'manager';
case Employee = 'employee';
case Placeholder = 'placeholder';
}

View File

@@ -15,6 +15,7 @@ enum TimeEntryAggregationType: string
case Task = 'task';
case Client = 'client';
case Billable = 'billable';
case Description = 'description';
public function toInterval(): ?TimeEntryAggregationTypeInterval
{

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Organization;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
/**
* This event is fired after an organization has been created.
* This event does NOT fire when an organization is created as part of a registration.
*/
class AfterCreateOrganization
{
use Dispatchable;
use SerializesModels;
public Organization $organization;
public function __construct(Organization $organization)
{
$this->organization = $organization;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Organization;
use Illuminate\Foundation\Events\Dispatchable;
class BeforeOrganizationDeletion
{
use Dispatchable;
public Organization $organization;
public function __construct(Organization $organization)
{
$this->organization = $organization;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Foundation\Events\Dispatchable;
class MemberMadeToPlaceholder
{
use Dispatchable;
public Organization $organization;
public Member $member;
public function __construct(Member $member, Organization $organization)
{
$this->member = $member;
$this->organization = $organization;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Events;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Foundation\Events\Dispatchable;
class MemberRemoved
{
use Dispatchable;
public Organization $organization;
public Member $member;
public function __construct(Member $member, Organization $organization)
{
$this->member = $member;
$this->organization = $organization;
}
}

View File

@@ -13,6 +13,11 @@ abstract class ApiException extends Exception
{
public const string KEY = 'api_exception';
public function __construct()
{
parent::__construct(static::KEY);
}
/**
* Render the exception into an HTTP response.
*/

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
{
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
{
public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
}

View File

@@ -12,7 +12,7 @@ class EntityStillInUseApiException extends ApiException
public function __construct(string $modelToDelete, string $modelInUse)
{
parent::__construct('', 0, null);
parent::__construct();
$this->modelToDelete = $modelToDelete;
$this->modelInUse = $modelInUse;
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class OnlyOwnerCanChangeOwnership extends ApiException
{
public const string KEY = 'only_owner_can_change_ownership';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
{
public const string KEY = 'organization_has_no_subscription_but_multiple_members';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class OrganizationNeedsAtLeastOneOwner extends ApiException
{
public const string KEY = 'organization_needs_at_least_one_owner';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
{
public const string KEY = 'user_is_already_member_of_organization';
}

View File

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

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Symfony\Component\HttpKernel\Exception\HttpException;
class MovedToApiException extends HttpException
{
public function __construct()
{
parent::__construct(403, 'Moved to API');
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Extensions\Auditing\Resolvers;
use Illuminate\Support\Facades\Request;
use OwenIt\Auditing\Contracts\Auditable;
use OwenIt\Auditing\Contracts\Resolver;
class CustomIpAddressResolver implements Resolver
{
private static function anonymizeIpAddress(string $ipAddress): string
{
/** @source https://stackoverflow.com/a/48777412 */
return preg_replace(
['/\.\d*$/', '/[\da-f]*:[\da-f]*$/'],
['.0', '0:0'],
$ipAddress
);
}
public static function resolve(Auditable $auditable): string
{
$ip = $auditable->preloadedResolverData['ip_address'] ?? Request::ip();
if ($ip !== null) {
$ip = self::anonymizeIpAddress($ip);
}
return $ip;
}
}

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Extensions\Scramble;
use App\Http\Resources\PaginatedResourceCollection;
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
use Dedoc\Scramble\Support\Generator\Response;
use Dedoc\Scramble\Support\Generator\Schema;
@@ -27,13 +28,10 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
&& $type->isInstanceOf(PaginatedResourceCollection::class);
}
/**
* @param Generic $type
*/
public function toResponse(Type $type): ?Response
public function toSchema(Type $type): ?OpenApiObjectType
{
/** @var Type|null $collectingClassType */
$collectingClassType = $type->templateTypes[0];
$collectingClassType = $type->templateTypes[0] ?? null;
if (! $collectingClassType instanceof ObjectType) {
return null;
@@ -47,37 +45,62 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
return null;
}
$type = new OpenApiObjectType;
$type->addProperty('data', (new ArrayType())->setItems($collectingType));
$type->addProperty(
'links',
(new OpenApiObjectType)
->addProperty('first', (new StringType)->nullable(true))
->addProperty('last', (new StringType)->nullable(true))
->addProperty('prev', (new StringType)->nullable(true))
->addProperty('next', (new StringType)->nullable(true))
->setRequired(['first', 'last', 'prev', 'next'])
);
$type->addProperty(
'meta',
(new OpenApiObjectType)
->addProperty('current_page', new IntegerType)
->addProperty('from', (new IntegerType)->nullable(true))
->addProperty('last_page', new IntegerType)
->addProperty('links', (new ArrayType)->setItems(
(new OpenApiObjectType)
->addProperty('url', (new StringType)->nullable(true))
->addProperty('label', new StringType)
->addProperty('active', new BooleanType)
->setRequired(['url', 'label', 'active'])
)->setDescription('Generated paginator links.'))
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
);
$type->setRequired(['data', 'links', 'meta']);
$newType = new OpenApiObjectType;
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));
if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) {
$newType->addProperty(
'meta',
(new OpenApiObjectType)
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->setRequired(['total'])
);
$newType->setRequired(['data', 'meta']);
} else {
$newType->addProperty(
'links',
(new OpenApiObjectType)
->addProperty('first', (new StringType)->nullable(true))
->addProperty('last', (new StringType)->nullable(true))
->addProperty('prev', (new StringType)->nullable(true))
->addProperty('next', (new StringType)->nullable(true))
->setRequired(['first', 'last', 'prev', 'next'])
);
$newType->addProperty(
'meta',
(new OpenApiObjectType)
->addProperty('current_page', new IntegerType)
->addProperty('from', (new IntegerType)->nullable(true))
->addProperty('last_page', new IntegerType)
->addProperty('links', (new ArrayType)->setItems(
(new OpenApiObjectType)
->addProperty('url', (new StringType)->nullable(true))
->addProperty('label', new StringType)
->addProperty('active', new BooleanType)
->setRequired(['url', 'label', 'active'])
)->setDescription('Generated paginator links.'))
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
);
$newType->setRequired(['data', 'links', 'meta']);
}
return $newType;
}
/**
* @param Generic $type
*/
public function toResponse(Type $type): ?Response
{
/** @var ObjectType|null $collectingClassType */
$collectingClassType = $type->templateTypes[0] ?? null;
if (! $collectingClassType instanceof ObjectType) {
return null;
}
$type = $this->toSchema($type);
return Response::make(200)
->description('Paginated set of `'.$this->components->uniqueSchemaName($collectingClassType->name).'`')

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\AuditResource\Pages;
use App\Models\Audit;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\PrettyJson;
class AuditResource extends Resource
{
protected static ?string $model = Audit::class;
protected static ?string $navigationIcon = 'heroicon-o-archive-box';
protected static ?string $navigationGroup = 'System';
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('user_type')
->maxLength(255),
Forms\Components\TextInput::make('user_id'),
Forms\Components\TextInput::make('event')
->required()
->maxLength(255),
Forms\Components\TextInput::make('auditable_type')
->required()
->maxLength(255),
Forms\Components\TextInput::make('auditable_id')
->required(),
PrettyJson::make('old_values'),
PrettyJson::make('new_values'),
Forms\Components\Textarea::make('url'),
Forms\Components\TextInput::make('ip_address'),
Forms\Components\TextInput::make('user_agent')
->maxLength(1023),
Forms\Components\TextInput::make('tags')
->maxLength(255),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('event'),
Tables\Columns\TextColumn::make('auditable_type'),
Tables\Columns\TextColumn::make('auditable_id'),
IconColumn::make('was_command')
->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))
->boolean(),
Tables\Columns\TextColumn::make('created_at')
->sortable()
->dateTime(),
Tables\Columns\TextColumn::make('updated_at')
->sortable()
->dateTime(),
])
->filters([
//
])
->actions([
Tables\Actions\ViewAction::make(),
])
->bulkActions([
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListAudits::route('/'),
'create' => Pages\CreateAudit::route('/create'),
'view' => Pages\ViewAudit::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAudit extends CreateRecord
{
protected static string $resource = AuditResource::class;
}

View File

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

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ViewRecord;
class ViewAudit extends ViewRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\FailedJobResource\Pages\ListFailedJobs;
use App\Filament\Resources\FailedJobResource\Pages\ViewFailedJobs;
use App\Models\FailedJob;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Artisan;
use Novadaemon\FilamentPrettyJson\PrettyJson;
/**
* @source https://gitlab.com/amvisor/filament-failed-jobs
*/
class FailedJobResource extends Resource
{
protected static ?string $model = FailedJob::class;
protected static ?string $navigationIcon = 'heroicon-o-exclamation-circle';
protected static ?string $navigationGroup = 'System';
public static function getNavigationBadge(): ?string
{
return (string) FailedJob::query()->count();
}
public static function form(Form $form): Form
{
return $form
->schema([
TextInput::make('uuid')->disabled()->columnSpan(4),
TextInput::make('failed_at')->disabled(),
TextInput::make('id')->disabled(),
TextInput::make('connection')->disabled(),
TextInput::make('queue')->disabled(),
// make text a little bit smaller because often a complete Stack Trace is shown:
TextArea::make('exception')->disabled()->columnSpan(4)->extraInputAttributes(['style' => 'font-size: 80%;']),
PrettyJson::make('payload')->disabled()->columnSpan(4),
])->columns(4);
}
public static function table(Table $table): Table
{
return $table
->defaultSort('id', 'desc')
->columns([
TextColumn::make('id')->sortable()->searchable()->toggleable(),
TextColumn::make('failed_at')->sortable()->searchable(false)->toggleable(),
TextColumn::make('exception')
->sortable()
->searchable()
->toggleable()
->wrap()
->limit(200)
->tooltip(fn (FailedJob $record) => "{$record->failed_at} UUID: {$record->uuid}; Connection: {$record->connection}; Queue: {$record->queue};"),
TextColumn::make('uuid')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('connection')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
TextColumn::make('queue')->sortable()->searchable()->toggleable(isToggledHiddenByDefault: true),
])
->filters([])
->bulkActions([
BulkAction::make('retry')
->label('Retry')
->requiresConfirmation()
->action(function (Collection $records): void {
/** @var FailedJob $record */
foreach ($records as $record) {
Artisan::call("queue:retry {$record->uuid}");
}
Notification::make()
->title("{$records->count()} jobs have been pushed back onto the queue.")
->success()
->send();
}),
])
->actions([
DeleteAction::make('Delete'),
ViewAction::make('View'),
Action::make('retry')
->label('Retry')
->requiresConfirmation()
->action(function (FailedJob $record): void {
Artisan::call("queue:retry {$record->uuid}");
Notification::make()
->title("The job with uuid '{$record->uuid}' has been pushed back onto the queue.")
->success()
->send();
}),
]);
}
public static function getPages(): array
{
return [
'index' => ListFailedJobs::route('/'),
'view' => ViewFailedJobs::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\FailedJobResource\Pages;
use App\Filament\Resources\FailedJobResource;
use App\Models\FailedJob;
use Filament\Notifications\Notification;
use Filament\Pages\Actions\Action;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;
class ListFailedJobs extends ListRecords
{
protected static string $resource = FailedJobResource::class;
public function getHeaderActions(): array
{
return [
Action::make('retry_all')
->label('Retry all failed Jobs')
->requiresConfirmation()
->action(function (): void {
Artisan::call('queue:retry all');
Notification::make()
->title('All failed jobs have been pushed back onto the queue.')
->success()
->send();
}),
Action::make('delete_all')
->label('Delete all failed Jobs')
->requiresConfirmation()
->color('danger')
->action(function (): void {
FailedJob::truncate();
Notification::make()
->title('All failed jobs have been removed.')
->success()
->send();
}),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\FailedJobResource\Pages;
use App\Filament\Resources\FailedJobResource;
use Filament\Resources\Pages\ViewRecord;
class ViewFailedJobs extends ViewRecord
{
protected static string $resource = FailedJobResource::class;
}

View File

@@ -7,6 +7,7 @@ namespace App\Filament\Resources;
use App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
use App\Models\Organization;
use App\Service\Export\ExportService;
use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto;
@@ -65,6 +66,12 @@ class OrganizationResource extends Resource
Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
'nullable',
'integer',
'gt:0',
'max:2147483647',
])
->numeric(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
@@ -105,9 +112,33 @@ class OrganizationResource extends Resource
])
->actions([
Tables\Actions\EditAction::make(),
Action::make('Export')
->icon('heroicon-o-arrow-down-tray')
->action(function (Organization $record) {
try {
$file = app(ExportService::class)->export($record);
Notification::make()
->title('Export successful')
->success()
->persistent()
->send();
return response()->streamDownload(function () use ($file): void {
echo Storage::disk(config('filesystems.private'))->get($file);
}, 'export.zip');
} catch (\Exception $exception) {
report($exception);
Notification::make()
->title('Export failed')
->danger()
->body('Message: '.$exception->getMessage())
->persistent()
->send();
}
}),
Action::make('Import')
->icon('heroicon-o-inbox-arrow-down')
->action(function (Organization $record, array $data) {
->action(function (Organization $record, array $data): void {
try {
$file = Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']);
if ($file === null) {
@@ -169,7 +200,6 @@ class OrganizationResource extends Resource
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Actions;
use App\Exceptions\Api\ApiException;
use App\Models\Organization;
use App\Service\DeletionService;
use Filament\Actions\DeleteAction;
use Throwable;
class DeleteOrganization extends DeleteAction
{
protected function setUp(): void
{
parent::setUp();
// TODO: check why setting the icon is necessary
$this->icon('heroicon-m-trash');
$this->action(function (): void {
$result = $this->process(function (Organization $record): bool {
try {
$deletionService = app(DeletionService::class);
$deletionService->deleteOrganization($record);
return true;
} catch (ApiException $exception) {
$this->failureNotificationTitle($exception->getTranslatedMessage());
report($exception);
} catch (Throwable $exception) {
$this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));
report($exception);
}
return false;
});
if (! $result) {
$this->failure();
return;
}
$this->success();
});
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditOrganization extends EditRecord
@@ -15,7 +14,7 @@ class EditOrganization extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
OrganizationResource\Actions\DeleteOrganization::make(),
];
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
@@ -18,8 +17,6 @@ class ViewOrganization extends ViewRecord
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
DeleteAction::make('delete')
->icon('heroicon-s-trash'),
];
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ProjectMemberResource\Pages;
use App\Models\ProjectMember;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
class ProjectMemberResource extends Resource
{
protected static ?string $model = ProjectMember::class;
protected static bool $shouldRegisterNavigation = false;
public static function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
'nullable',
'integer',
'gt:0',
'max:2147483647',
])
->numeric(),
Forms\Components\Select::make('user_id')
->relationship('user', 'name')
->required(),
Forms\Components\Select::make('member_id')
->relationship('member', 'id')
->required(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('id')
->label('ID'),
Tables\Columns\TextColumn::make('billable_rate')
->numeric()
->sortable(),
Tables\Columns\TextColumn::make('project.name'),
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
public static function getRelations(): array
{
return [
//
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListProjectMembers::route('/'),
'create' => Pages\CreateProjectMember::route('/create'),
'edit' => Pages\EditProjectMember::route('/{record}/edit'),
'view' => Pages\ViewProjectMembers::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ProjectMemberResource\Pages;
use App\Filament\Resources\ProjectMemberResource;
use Filament\Resources\Pages\CreateRecord;
class CreateProjectMember extends CreateRecord
{
protected static string $resource = ProjectMemberResource::class;
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ProjectMemberResource\Pages;
use App\Filament\Resources\ProjectMemberResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditProjectMember extends EditRecord
{
protected static string $resource = ProjectMemberResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
];
}
}

View File

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

View File

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

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ProjectResource\Pages;
use App\Filament\Resources\ProjectResource\RelationManagers\ProjectMembersRelationManager;
use App\Models\Project;
use Filament\Forms;
use Filament\Forms\Components\ColorPicker;
@@ -37,6 +38,16 @@ class ProjectResource extends Resource
ColorPicker::make('color')
->label('Color')
->required(),
Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->rules([
'nullable',
'integer',
'gt:0',
'max:2147483647',
])
->numeric(),
Forms\Components\Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
@@ -78,7 +89,7 @@ class ProjectResource extends Resource
public static function getRelations(): array
{
return [
//
ProjectMembersRelationManager::make(),
];
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ProjectResource\RelationManagers;
use App\Filament\Resources\ProjectMemberResource;
use App\Models\ProjectMember;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
class ProjectMembersRelationManager extends RelationManager
{
protected static ?string $title = 'Project Members';
protected static string $relationship = 'members';
public function form(Form $form): Form
{
return $form
->schema([
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('user.name'),
Tables\Columns\TextColumn::make('billable_rate')
->numeric()
->sortable(),
])
->filters([
//
])
->headerActions([
])
->actions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (ProjectMember $record): string => ProjectMemberResource::getUrl('view', [
'record' => $record->getKey(),
])),
Action::make('edit')
->icon('heroicon-o-pencil')
->url(fn (ProjectMember $record): string => ProjectMemberResource::getUrl('edit', [
'record' => $record->getKey(),
]))
->openUrlInNewTab(),
])
->bulkActions([
]);
}
}

View File

@@ -11,6 +11,7 @@ use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class TagResource extends Resource
@@ -58,7 +59,9 @@ class TagResource extends Resource
])
->defaultSort('created_at', 'desc')
->filters([
//
SelectFilter::make('organization')
->relationship('organization', 'name')
->searchable(),
])
->actions([
Tables\Actions\EditAction::make(),

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Actions;
use App\Exceptions\Api\ApiException;
use App\Models\User;
use App\Service\DeletionService;
use Filament\Actions\DeleteAction;
use Throwable;
class DeleteUser extends DeleteAction
{
protected function setUp(): void
{
parent::setUp();
$this->icon('heroicon-m-trash');
$this->action(function (): void {
$result = $this->process(function (User $record): bool {
try {
$deletionService = app(DeletionService::class);
$deletionService->deleteUser($record);
return true;
} catch (ApiException $exception) {
$this->failureNotificationTitle($exception->getTranslatedMessage());
report($exception);
} catch (Throwable $exception) {
$this->failureNotificationTitle(__('exceptions.unknown_error_in_admin_panel'));
report($exception);
}
return false;
});
if (! $result) {
$this->failure();
return;
}
$this->success();
});
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
@@ -17,7 +16,7 @@ class EditUser extends EditRecord
{
return [
Impersonate::make()->record($this->getRecord()),
Actions\DeleteAction::make(),
UserResource\Actions\DeleteUser::make(),
];
}
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions\DeleteAction;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
@@ -18,8 +17,6 @@ class ViewUser extends ViewRecord
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
DeleteAction::make('delete')
->icon('heroicon-s-trash'),
];
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Models\TimeEntry;
use App\Models\User;
use Filament\Widgets\StatsOverviewWidget as BaseWidget;
use Filament\Widgets\StatsOverviewWidget\Stat;
@@ -11,6 +12,8 @@ use Illuminate\Database\Eloquent\Builder;
class ActiveUserOverview extends BaseWidget
{
protected static ?int $sort = 1;
protected static ?string $heading = 'A Registrations';
protected function getCards(): array
@@ -19,7 +22,8 @@ class ActiveUserOverview extends BaseWidget
$placeholderUserCount = User::query()->where('is_placeholder', '=', true)->count();
$activeInLastWeek = User::query()
->where('is_placeholder', '=', false)
->whereHas('timeEntries', function (Builder $query) {
->whereHas('timeEntries', function (Builder $query): void {
/** @var Builder<TimeEntry> $query */
$query->where('created_at', '>=', now()->subWeek())
->orWhere('updated_at', '>=', now()->subWeek());
})

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

@@ -15,6 +15,8 @@ class TimeEntriesCreated extends ChartWidget
public ?string $filter = 'week';
protected static ?int $sort = 3;
protected function getData(): array
{
$filter = $this->filter;
@@ -27,7 +29,9 @@ class TimeEntriesCreated extends ChartWidget
} else {
$start = now()->subWeek();
}
$trend = Trend::model(TimeEntry::class)
$trend = Trend::query(
TimeEntry::query()->where('is_imported', '=', false)
)
->between(
start: $start,
end: now(),

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use App\Models\TimeEntry;
use Filament\Widgets\ChartWidget;
use Flowframe\Trend\Trend;
use Flowframe\Trend\TrendValue;
class TimeEntriesImported extends ChartWidget
{
protected static ?string $heading = 'Time Entries Imported';
public ?string $filter = 'week';
protected static ?int $sort = 4;
protected function getData(): array
{
$filter = $this->filter;
if ($filter === 'week') {
$start = now()->subWeek();
} elseif ($filter === 'month') {
$start = now()->subMonth();
} elseif ($filter === 'year') {
$start = now()->subYear();
} else {
$start = now()->subWeek();
}
$trend = Trend::query(
TimeEntry::query()->where('is_imported', '=', true)
)
->between(
start: $start,
end: now(),
)
->perDay();
if ($filter === 'week') {
$trend->perDay();
} elseif ($filter === 'month') {
$trend->perDay();
} elseif ($filter === 'year') {
$trend->perMonth();
} else {
$trend->perDay();
}
$data = $trend->count();
return [
'datasets' => [
[
'label' => self::$heading,
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
],
],
'labels' => $data->map(fn (TrendValue $value) => $value->date),
];
}
protected function getFilters(): ?array
{
return [
'week' => 'Last week',
'month' => 'Last month',
'year' => 'Last year',
];
}
protected function getType(): string
{
return 'line';
}
}

View File

@@ -15,6 +15,8 @@ class UserRegistrations extends ChartWidget
public ?string $filter = 'week';
protected static ?int $sort = 2;
protected function getData(): array
{
$filter = $this->filter;

View File

@@ -5,14 +5,16 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Tag\TagStoreRequest;
use App\Http\Requests\V1\Tag\TagUpdateRequest;
use App\Http\Requests\V1\Client\ClientIndexRequest;
use App\Http\Requests\V1\Client\ClientStoreRequest;
use App\Http\Requests\V1\Client\ClientUpdateRequest;
use App\Http\Resources\V1\Client\ClientCollection;
use App\Http\Resources\V1\Client\ClientResource;
use App\Models\Client;
use App\Models\Organization;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
class ClientController extends Controller
{
@@ -33,14 +35,22 @@ class ClientController extends Controller
*
* @operationId getClients
*/
public function index(Organization $organization): ClientCollection
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
{
$this->checkPermission($organization, 'clients:view');
$clients = Client::query()
$clientsQuery = Client::query()
->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc')
->paginate(config('app.pagination_per_page_default'));
->orderBy('created_at', 'desc');
$filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') {
$clientsQuery->whereNotNull('archived_at');
} elseif ($filterArchived === 'false') {
$clientsQuery->whereNull('archived_at');
}
$clients = $clientsQuery->paginate(config('app.pagination_per_page_default'));
return new ClientCollection($clients);
}
@@ -52,11 +62,11 @@ class ClientController extends Controller
*
* @operationId createClient
*/
public function store(Organization $organization, TagStoreRequest $request): ClientResource
public function store(Organization $organization, ClientStoreRequest $request): ClientResource
{
$this->checkPermission($organization, 'clients:create');
$client = new Client();
$client = new Client;
$client->name = $request->input('name');
$client->organization()->associate($organization);
$client->save();
@@ -71,11 +81,14 @@ class ClientController extends Controller
*
* @operationId updateClient
*/
public function update(Organization $organization, Client $client, TagUpdateRequest $request): ClientResource
public function update(Organization $organization, Client $client, ClientUpdateRequest $request): ClientResource
{
$this->checkPermission($organization, 'clients:update', $client);
$client->name = $request->input('name');
if ($request->has('is_archived')) {
$client->archived_at = $request->getIsArchived() ? Carbon::now() : null;
}
$client->save();
return new ClientResource($client);

View File

@@ -4,20 +4,16 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Service\BillingContract;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class Controller extends \App\Http\Controllers\Controller
{
public function __construct(
protected PermissionStore $permissionStore,
) {
}
) {}
/**
* @throws AuthorizationException
@@ -25,7 +21,7 @@ class Controller extends \App\Http\Controllers\Controller
protected function checkPermission(Organization $organization, string $permission): void
{
if (! $this->permissionStore->has($organization, $permission)) {
throw new AuthorizationException();
throw new AuthorizationException;
}
}
@@ -41,7 +37,7 @@ class Controller extends \App\Http\Controllers\Controller
return;
}
}
throw new AuthorizationException();
throw new AuthorizationException;
}
protected function hasPermission(Organization $organization, string $permission): bool
@@ -49,33 +45,8 @@ class Controller extends \App\Http\Controllers\Controller
return $this->permissionStore->has($organization, $permission);
}
/**
* @throws AuthorizationException
*/
protected function user(): User
protected function canAccessPremiumFeatures(Organization $organization): bool
{
/** @var User|null $user */
$user = Auth::user();
if ($user === null) {
Log::error('This function should only be called in authenticated context');
throw new AuthorizationException();
}
return $user;
}
/**
* @throws AuthorizationException
*/
protected function member(Organization $organization): Member
{
$user = $this->user();
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
throw new AuthorizationException();
}
return $member;
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
}
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Models\Organization;
use App\Service\Export\ExportException;
use App\Service\Export\ExportService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
class ExportController extends Controller
{
/**
* Export data of an organization
*
* @throws AuthorizationException
* @throws ExportException
*
* @operationId exportOrganization
*/
public function export(Organization $organization, ExportService $exportService): JsonResponse
{
$this->checkPermission($organization, 'export');
$filepath = $exportService->export($organization);
$downloadUrl = Storage::disk(config('filesystems.private'))
->temporaryUrl($filepath, Carbon::now()->addMinutes(10));
return new JsonResponse([
'success' => true,
'download_url' => $downloadUrl,
], 200);
}
}

View File

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

View File

@@ -4,17 +4,18 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
use App\Http\Resources\V1\Invitation\InvitationCollection;
use App\Http\Resources\V1\Invitation\InvitationResource;
use App\Mail\OrganizationInvitationMail;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Service\InvitationService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Mail;
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
use Laravel\Jetstream\Mail\TeamInvitation;
class InvitationController extends Controller
{
@@ -49,19 +50,18 @@ class InvitationController extends Controller
* Invite a user to the organization
*
* @throws AuthorizationException
* @throws UserIsAlreadyMemberOfOrganizationApiException
*
* @operationId invite
*/
public function store(Organization $organization, InvitationStoreRequest $request): JsonResponse
public function store(Organization $organization, InvitationStoreRequest $request, InvitationService $invitationService): JsonResponse
{
$this->checkPermission($organization, 'invitations:create');
app(InvitesTeamMembers::class)->invite(
$this->user(),
$organization,
$request->input('email'),
$request->input('role')
);
$email = $request->getEmail();
$role = $request->getRole();
$invitationService->inviteUser($organization, $email, $role);
return response()->json(null, 204);
}
@@ -77,7 +77,8 @@ class InvitationController extends Controller
{
$this->checkPermission($organization, 'invitations:resend', $invitation);
Mail::to($invitation->email)->send(new TeamInvitation($invitation));
Mail::to($invitation->email)
->queue(new OrganizationInvitationMail($invitation));
return response()->json(null, 204);
}

View File

@@ -5,23 +5,28 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\MemberMadeToPlaceholder;
use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest;
use App\Http\Resources\V1\Member\MemberCollection;
use App\Http\Resources\V1\Member\MemberPivotResource;
use App\Http\Resources\V1\Member\MemberResource;
use App\Models\Member;
use App\Models\Organization;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Service\BillableRateService;
use App\Service\InvitationService;
use App\Service\MemberService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
class MemberController extends Controller
{
@@ -36,7 +41,7 @@ class MemberController extends Controller
/**
* List all members of an organization
*
* @return MemberCollection<MemberPivotResource>>
* @return MemberCollection<MemberResource>
*
* @throws AuthorizationException
*
@@ -46,7 +51,9 @@ class MemberController extends Controller
{
$this->checkPermission($organization, 'members:view');
$members = $organization->users()
$members = Member::query()
->whereBelongsTo($organization, 'organization')
->with(['user'])
->paginate(config('app.pagination_per_page_default'));
return MemberCollection::make($members);
@@ -56,15 +63,40 @@ class MemberController extends Controller
* Update a member of the organization
*
* @throws AuthorizationException
* @throws OrganizationNeedsAtLeastOneOwner
* @throws OnlyOwnerCanChangeOwnership
* @throws ChangingRoleToPlaceholderIsNotAllowed
*
* @operationId updateMember
*/
public function update(Organization $organization, Member $member, MemberUpdateRequest $request): JsonResource
public function update(Organization $organization, Member $member, MemberUpdateRequest $request, BillableRateService $billableRateService, MemberService $memberService): JsonResource
{
$this->checkPermission($organization, 'members:update', $member);
$member->billable_rate = $request->input('billable_rate');
$member->role = $request->input('role');
if ($request->has('billable_rate') && $member->billable_rate !== $request->getBillableRate()) {
$member->billable_rate = $request->getBillableRate();
$billableRateService->updateTimeEntriesBillableRateForMember($member);
}
if ($request->has('role') && $member->role !== $request->getRole()->value) {
$newRole = $request->getRole();
$oldRole = Role::from($member->role);
if ($oldRole === Role::Owner) {
throw new OrganizationNeedsAtLeastOneOwner;
}
if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed;
}
if ($newRole === Role::Owner) {
if ($this->hasPermission($organization, 'members:change-ownership')) {
$memberService->changeOwnership($organization, $member);
} else {
throw new OnlyOwnerCanChangeOwnership;
}
} else {
$member->role = $request->getRole()->value;
}
}
$member->save();
return new MemberResource($member);
@@ -88,15 +120,34 @@ class MemberController extends Controller
throw new EntityStillInUseApiException('member', 'project_member');
}
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization();
throw new CanNotRemoveOwnerFromOrganization;
}
$member->delete();
MemberRemoved::dispatch($member, $organization);
return response()
->json(null, 204);
}
/**
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
*/
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{
$this->checkPermission($organization, 'members:make-placeholder', $member);
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization;
}
$memberService->makeMemberToPlaceholder($member);
MemberMadeToPlaceholder::dispatch($member, $organization);
return response()->json(null, 204);
}
/**
* Invite a placeholder member to become a real member of the organization
*
@@ -104,21 +155,16 @@ class MemberController extends Controller
*
* @operationId invitePlaceholder
*/
public function invitePlaceholder(Organization $organization, Member $member, Request $request): JsonResponse
public function invitePlaceholder(Organization $organization, Member $member, InvitationService $invitationService): JsonResponse
{
$this->checkPermission($organization, 'members:invite-placeholder', $member);
$user = $member->user;
if (! $user->is_placeholder) {
throw new UserNotPlaceholderApiException();
throw new UserNotPlaceholderApiException;
}
app(InvitesTeamMembers::class)->invite(
$this->user(),
$organization,
$user->email,
Role::Employee->value,
);
$invitationService->inviteUser($organization, $user->email, Role::Employee);
return response()->json(null, 204);
}

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
use App\Http\Resources\V1\Organization\OrganizationResource;
use App\Models\Organization;
use App\Service\BillableRateService;
use Illuminate\Auth\Access\AuthorizationException;
class OrganizationController extends Controller
@@ -14,28 +16,42 @@ class OrganizationController extends Controller
/**
* Get organization
*
* @operationId getOrganization
*
* @throws AuthorizationException
*/
public function show(Organization $organization): OrganizationResource
{
$this->checkPermission($organization, 'organizations:view');
return new OrganizationResource($organization);
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return new OrganizationResource($organization, $showBillableRate);
}
/**
* Update organization
*
* @operationId updateOrganization
*
* @throws AuthorizationException
*/
public function update(Organization $organization, OrganizationUpdateRequest $request): OrganizationResource
public function update(Organization $organization, OrganizationUpdateRequest $request, BillableRateService $billableRateService): OrganizationResource
{
$this->checkPermission($organization, 'organizations:update');
$organization->name = $request->input('name');
$organization->billable_rate = $request->input('billable_rate');
$oldBillableRate = $organization->billable_rate;
if ($request->has('employees_can_see_billable_rates')) {
$organization->employees_can_see_billable_rates = $request->validated('employees_can_see_billable_rates');
}
$organization->billable_rate = $request->getBillableRate();
$organization->save();
return new OrganizationResource($organization);
if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
}
return new OrganizationResource($organization, true);
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Project\ProjectIndexRequest;
use App\Http\Requests\V1\Project\ProjectStoreRequest;
@@ -13,10 +14,12 @@ use App\Http\Resources\V1\Project\ProjectResource;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\User;
use App\Models\TimeEntry;
use App\Service\BillableRateService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
class ProjectController extends Controller
@@ -50,10 +53,18 @@ class ProjectController extends Controller
if (! $canViewAllProjects) {
$projectsQuery->visibleByEmployee($user);
}
$filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') {
$projectsQuery->whereNotNull('archived_at');
} elseif ($filterArchived === 'false') {
$projectsQuery->whereNull('archived_at');
}
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
return new ProjectCollection($projects);
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return new ProjectCollection($projects, $showBillableRate);
}
/**
@@ -67,9 +78,12 @@ class ProjectController extends Controller
{
$this->checkPermission($organization, 'projects:view', $project);
// Note: There is currently no need to check if a user is a member of the project,
// since this is only relevant for users with the role "employee" and they can not access this endpoint.
$project->load('organization');
return new ProjectResource($project);
return new ProjectResource($project, true);
}
/**
@@ -82,15 +96,19 @@ class ProjectController extends Controller
public function store(Organization $organization, ProjectStoreRequest $request): JsonResource
{
$this->checkPermission($organization, 'projects:create');
$project = new Project();
$project = new Project;
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->billable_rate = $request->input('billable_rate');
$project->is_billable = (bool) $request->input('is_billable');
$project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}
$project->organization()->associate($organization);
$project->save();
return new ProjectResource($project);
return new ProjectResource($project, true);
}
/**
@@ -100,16 +118,38 @@ class ProjectController extends Controller
*
* @operationId updateProject
*/
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request): JsonResource
public function update(Organization $organization, Project $project, ProjectUpdateRequest $request, BillableRateService $billableRateService): JsonResource
{
$this->checkPermission($organization, 'projects:update', $project);
$project->name = $request->input('name');
$project->color = $request->input('color');
$project->billable_rate = $request->input('billable_rate');
$project->client_id = $request->input('client_id');
$project->is_billable = (bool) $request->input('is_billable');
if ($request->has('is_archived')) {
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
}
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}
$oldBillableRate = $project->billable_rate;
$clientIdChanged = false;
$project->billable_rate = $request->getBillableRate();
if ($project->client_id !== $request->input('client_id')) {
$project->client_id = $request->input('client_id');
$clientIdChanged = true;
}
$project->save();
return new ProjectResource($project);
if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForProject($project);
}
if ($clientIdChanged) {
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->whereBelongsTo($project, 'project')
->update(['client_id' => $project->client_id]);
}
return new ProjectResource($project, true);
}
/**
@@ -130,8 +170,8 @@ class ProjectController extends Controller
throw new EntityStillInUseApiException('project', 'time_entry');
}
DB::transaction(function () use (&$project) {
$project->members->each(function (ProjectMember $member) {
DB::transaction(function () use (&$project): void {
$project->members->each(function (ProjectMember $member): void {
$member->delete();
});

View File

@@ -14,6 +14,7 @@ use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Service\BillableRateService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
@@ -58,25 +59,29 @@ class ProjectMemberController extends Controller
*
* @operationId createProjectMember
*/
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request): JsonResource
public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request, BillableRateService $billableRateService): JsonResource
{
$this->checkPermission($organization, 'project-members:create', $project);
$member = Member::findOrFail((string) $request->input('member_id'));
if ($member->user->is_placeholder) {
throw new InactiveUserCanNotBeUsedApiException();
throw new InactiveUserCanNotBeUsedApiException;
}
if (ProjectMember::whereBelongsTo($project, 'project')->whereBelongsTo($member, 'member')->exists()) {
throw new UserIsAlreadyMemberOfProjectApiException();
throw new UserIsAlreadyMemberOfProjectApiException;
}
$projectMember = new ProjectMember();
$projectMember->billable_rate = $request->input('billable_rate');
$projectMember = new ProjectMember;
$projectMember->billable_rate = $request->getBillableRate();
$projectMember->member()->associate($member);
$projectMember->user()->associate($member->user);
$projectMember->project()->associate($project);
$projectMember->save();
if ($request->getBillableRate() !== null) {
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
}
return new ProjectMemberResource($projectMember);
}
@@ -87,12 +92,17 @@ class ProjectMemberController extends Controller
*
* @operationId updateProjectMember
*/
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request): JsonResource
public function update(Organization $organization, ProjectMember $projectMember, ProjectMemberUpdateRequest $request, BillableRateService $billableRateService): JsonResource
{
$this->checkPermission($organization, 'project-members:update', projectMember: $projectMember);
$projectMember->billable_rate = $request->input('billable_rate');
$oldBillableRate = $projectMember->billable_rate;
$projectMember->billable_rate = $request->getBillableRate();
$projectMember->save();
if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForProjectMember($projectMember);
}
return new ProjectMemberResource($projectMember);
}
@@ -103,12 +113,22 @@ class ProjectMemberController extends Controller
*
* @operationId deleteProjectMember
*/
public function destroy(Organization $organization, ProjectMember $projectMember): JsonResponse
public function destroy(Organization $organization, ProjectMember $projectMember, BillableRateService $billableRateService): JsonResponse
{
$this->checkPermission($organization, 'project-members:delete', projectMember: $projectMember);
$hadBillableRate = $projectMember->billable_rate !== null;
$project = $projectMember->project;
$member = $projectMember->member;
$projectMember->delete();
if ($hadBillableRate) {
$billableRateService->updateTimeEntriesBillableRateForMember($member);
$billableRateService->updateTimeEntriesBillableRateForProject($project);
$billableRateService->updateTimeEntriesBillableRateForOrganization($organization);
}
return response()
->json(null, 204);
}

View File

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

View File

@@ -15,6 +15,7 @@ use App\Models\Task;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
class TaskController extends Controller
{
@@ -53,6 +54,12 @@ class TaskController extends Controller
if (! $canViewAllTasks) {
$query->visibleByEmployee($user);
}
$doneFilter = $request->getFilterDone();
if ($doneFilter === 'true') {
$query->whereNotNull('done_at');
} elseif ($doneFilter === 'false') {
$query->whereNull('done_at');
}
$tasks = $query->paginate(config('app.pagination_per_page_default'));
@@ -69,9 +76,12 @@ class TaskController extends Controller
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
{
$this->checkPermission($organization, 'tasks:create');
$task = new Task();
$task = new Task;
$task->name = $request->input('name');
$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->save();
@@ -89,6 +99,12 @@ class TaskController extends Controller
{
$this->checkPermission($organization, 'tasks:update', $task);
$task->name = $request->input('name');
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$task->estimated_time = $request->getEstimatedTime();
}
if ($request->has('is_done')) {
$task->done_at = $request->getIsDone() ? Carbon::now() : null;
}
$task->save();
return new TaskResource($task);

View File

@@ -7,15 +7,19 @@ namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryDestroyMultipleRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
use App\Http\Resources\V1\TimeEntry\TimeEntryResource;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter;
@@ -43,6 +47,8 @@ class TimeEntryController extends Controller
* 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.
*
* @return TimeEntryCollection<TimeEntryResource>
*
* @throws AuthorizationException
*
* @operationId getTimeEntries
@@ -73,11 +79,14 @@ class TimeEntryController extends Controller
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
$limit = $request->has('limit') ? (int) $request->input('limit', 100) : 100;
$totalCount = $timeEntriesQuery->count();
$limit = $request->getLimit();
if ($limit > 1000) {
$limit = 1000;
}
$timeEntriesQuery->limit($limit);
$timeEntriesQuery->skip($request->getOffset());
$timeEntries = $timeEntriesQuery->get();
@@ -111,7 +120,12 @@ class TimeEntryController extends Controller
}
}
return new TimeEntryCollection($timeEntries);
return (new TimeEntryCollection($timeEntries))
->additional([
'meta' => [
'total' => $totalCount,
],
]);
}
/**
@@ -212,12 +226,21 @@ class TimeEntryController extends Controller
}
if ($request->input('end') === null && TimeEntry::query()->whereBelongsTo($member, 'member')->where('end', null)->exists()) {
throw new TimeEntryStillRunningApiException();
throw new TimeEntryStillRunningApiException;
}
$client = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id'))->client : null;
$project = $request->input('project_id') !== null ? Project::findOrFail((string) $request->input('project_id')) : null;
$client = $project?->client;
$task = $request->input('task_id') !== null ? $project->tasks()->findOrFail((string) $request->input('task_id')) : null;
$timeEntry = new TimeEntry();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
$timeEntry = new TimeEntry;
$timeEntry->fill($request->validated());
$timeEntry->client()->associate($client);
$timeEntry->user_id = $member->user_id;
@@ -247,22 +270,49 @@ class TimeEntryController extends Controller
}
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
throw new TimeEntryCanNotBeRestartedApiException();
throw new TimeEntryCanNotBeRestartedApiException;
}
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;
$project = null;
if ($request->has('project_id')) {
$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);
}
$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->description = $request->input('description', $timeEntry->description) ?? '';
$timeEntry->setComputedAttributeValue('billable_rate');
$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);
}
/**
* Update multiple time entries
*
* @operationId updateMultipleTimeEntries
*
* @throws AuthorizationException
*/
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
@@ -270,30 +320,42 @@ class TimeEntryController extends Controller
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
$ids = $request->input('ids');
$ids = $request->validated('ids');
$timeEntries = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->with([
'project',
'task',
])
->whereIn('id', $ids)
->get();
$changes = $request->input('changes');
$changes = $request->validated('changes');
if (isset($changes['member_id']) && ! $canAccessAll && $this->member($organization)->getKey() !== $changes['member_id']) {
throw new AuthorizationException();
throw new AuthorizationException;
}
$project = null;
$client = null;
$overwriteClient = false;
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;
}
$success = new Collection();
$error = new Collection();
$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;
$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
@@ -307,11 +369,32 @@ class TimeEntryController extends Controller
continue;
}
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;
$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) {
$timeEntry->client()->associate($client);
}
$timeEntry->setComputedAttributeValue('billable_rate');
$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);
}
@@ -336,9 +419,81 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
}
$project = $timeEntry->project;
$task = $timeEntry->task;
$timeEntry->delete();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
return response()
->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

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\V1\User\UserResource;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Resources\Json\JsonResource;
class UserController extends Controller
{
/**
* Get the current user
*
* This endpoint is independent of organization.
*
* @operationId getMe
*
* @throws AuthorizationException
*/
public function me(): JsonResource
{
$user = $this->user();
return new UserResource($user);
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Resources\V1\Member\PersonalMembershipCollection;
use App\Models\Member;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Resources\Json\JsonResource;
class UserMembershipController extends Controller
{
/**
* Get the memberships of the current user
*
* This endpoint is independent of organization.
*
* @operationId getMyMemberships
*
* @return PersonalMembershipCollection
*
* @throws AuthorizationException
*/
public function myMemberships(): JsonResource
{
$user = $this->user();
$members = Member::query()
->whereBelongsTo($user, 'user')
->with(['organization'])
->get();
return new PersonalMembershipCollection($members);
}
}

View File

@@ -4,11 +4,63 @@ declare(strict_types=1);
namespace App\Http\Controllers;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
use AuthorizesRequests;
use ValidatesRequests;
/**
* @throws AuthorizationException
*/
protected function user(): User
{
/** @var User|null $user */
$user = Auth::user();
if ($user === null) {
Log::error('This function should only be called in authenticated context');
throw new AuthorizationException;
}
return $user;
}
/**
* @throws AuthorizationException
*/
protected function member(Organization $organization): Member
{
$user = $this->user();
/** @var Member|null $member */
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
throw new AuthorizationException;
}
return $member;
}
/**
* @throws AuthorizationException
*/
protected function currentOrganization(): Organization
{
$user = $this->user();
$organization = $user->currentTeam;
if ($organization === null) {
$organization = $user->organizations()->first();
}
return $organization;
}
}

View File

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

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