Compare commits

..

122 Commits

Author SHA1 Message Date
Constantin Graf
ccc07c4235 Enhanced description for Clockify imports 2024-12-20 19:46:41 -05:00
Constantin Graf
453dbaac9e Fixed timezones in unit tests 2024-12-20 19:39:38 -05:00
Constantin Graf
62270382dc Fixed import lock 2024-12-18 11:26:49 -05:00
Constantin Graf
29929467f6 Fixed overlapping labels in PDF report 2024-12-18 11:20:32 -05:00
Gregor Vostrak
02fe89dfdf Update README.md 2024-12-17 17:38:34 +01:00
Gregor Vostrak
03550a0ca6 add request free trial text to upgrade modal 2024-12-17 17:03:54 +01:00
Gregor Vostrak
2f1056dddb change report default to public 2024-12-17 15:21:23 +01:00
Gregor Vostrak
6e226cd743 hide report table for users that do not already have reports and cannot report new ones 2024-12-17 13:03:59 +01:00
Gregor Vostrak
19ed966504 fix icons alignment in billing upgrade buttons 2024-12-17 12:55:29 +01:00
Gregor Vostrak
33818f10b3 improve detailed report so that the table header has a border on the new page 2024-12-09 17:29:44 +01:00
Gregor Vostrak
ee9d818d75 add name of shared report to title attribute 2024-12-09 17:24:04 +01:00
Gregor Vostrak
e3d8457523 add week_start default for unauthenticated shared reports view 2024-12-09 17:11:56 +01:00
Gregor Vostrak
67e42a0a54 improve pdf index export to prevent overflows 2024-12-09 16:58:06 +01:00
Gregor Vostrak
fdbf88a9a6 fix selects inside of focus trap not working on click select 2024-12-09 16:33:57 +01:00
Gregor Vostrak
c4daca32c5 add modal focus trap & fix design bug in project billable section 2024-12-09 15:45:28 +01:00
Gregor Vostrak
4e10f9538f add export modal to prevent firefox popup blocking behaviour 2024-12-09 15:29:44 +01:00
Gregor Vostrak
959cad8f74 fix main chart label not cutting off for big numbers on the top 2024-12-09 12:57:25 +01:00
Gregor Vostrak
e308ca78b1 improve design for time entries index export 2024-12-09 12:57:25 +01:00
Gregor Vostrak
4281736a6d automatically set the project billable default in time entry create modal 2024-12-09 12:57:25 +01:00
Gregor Vostrak
9b0cf37bc7 improve aggregated pdf design 2024-12-09 12:57:25 +01:00
Constantin Graf
a4f3e014d9 Add debug flag to pdf export 2024-12-09 12:57:25 +01:00
Gregor Vostrak
32bce2f749 fix reporting descriptions for nested group 2024-12-09 12:57:25 +01:00
Gregor Vostrak
ae7f5a98e7 add Today option to Date Range Picker 2024-12-09 12:57:25 +01:00
Gregor Vostrak
e3f981aac2 add missing data to public shared reports, add premium restrictions, add pdf download 2024-12-09 12:57:25 +01:00
Constantin Graf
bcb298bd6d Updated dedoc/scramble composer dependency 2024-12-09 12:57:25 +01:00
Constantin Graf
620c4c97dc Updated PDF footer and added pie chart to aggregate report 2024-12-09 12:57:25 +01:00
Constantin Graf
05da595470 Add wait for report with chart 2024-12-09 12:57:25 +01:00
Constantin Graf
a4d8a02b80 Updated PDF reports 2024-12-09 12:57:25 +01:00
Constantin Graf
0860aa9d24 Added shareable reports 2024-12-09 12:57:25 +01:00
Gregor Vostrak
9c82efdf07 add reporting submenus to navbar 2024-12-09 12:57:25 +01:00
Gregor Vostrak
2560619c15 add shared reports section in the frontend 2024-12-09 12:57:25 +01:00
Constantin Graf
c03aad1abd Added shareable reports 2024-12-09 12:57:25 +01:00
Constantin Graf
0ee0175f04 Prevent stray requests in unit tests 2024-12-02 17:40:01 +01:00
Constantin Graf
0c1f06face Change default generate key env to single line 2024-12-02 15:00:29 +01:00
Gregor Vostrak
86d625b18a add discount banner 2024-11-25 13:21:35 +01:00
Constantin Graf
83e17d4a40 Updated composer dependencies 2024-11-16 16:18:06 +01:00
Gregor Vostrak
5b27853546 Add e2e test for live timer 2024-11-15 18:04:39 +01:00
Gregor Vostrak
f49f7b2c9b fix live timer after reload 2024-11-15 16:48:02 +01:00
Constantin Graf
9e77500d94 Extended healthcheck debug in debug mode 2024-11-15 13:17:33 +01:00
Constantin Graf
2cf9b3aa8f Fix force https for some reverse proxies 2024-11-12 21:50:26 +01:00
Constantin Graf
64b41e3018 Fix force https for some reverse proxies, Add url and path to debug endpoint 2024-11-12 19:03:36 +01:00
Gregor Vostrak
31014c1e29 fix type import api reference 2024-11-12 18:58:59 +01:00
Gregor Vostrak
d880717749 add TimeEntryCreateModal and MoreOptionsDropdown to ui package 2024-11-12 18:54:54 +01:00
Gregor Vostrak
df0f3b2680 patch new time entries into existing store when stores are refreshed on focus 2024-11-12 17:38:04 +01:00
Gregor Vostrak
4b0cb2e282 improve time picker parsing, fix nested escape listeners, change project member select 2024-11-12 16:07:51 +01:00
Gregor Vostrak
d5699da234 improve manual time entry modal, improve time picker, add human duration input 2024-11-12 16:07:51 +01:00
Constantin Graf
96f06bae1d Update README.md 2024-11-12 13:52:31 +01:00
Gregor Vostrak
e1243178fe Update README.md 2024-11-12 13:50:33 +01:00
Gregor Vostrak
cfbc98705a add bug report and feature request rules to the README 2024-11-12 13:48:04 +01:00
Gregor Vostrak
f0d6b234e5 add github sponsor information 2024-11-11 17:23:23 +01:00
Constantin Graf
4b622afcfc Change logic of tags_ids filter from AND to OR 2024-11-08 13:28:26 +01:00
Constantin Graf
45daeead61 Fix billable contract for self-hosting 2024-11-07 16:12:42 +01:00
Constantin Graf
95c1bcd4cb Change precheck order in migrations 2024-11-05 12:32:51 +01:00
Constantin Graf
3b3f593080 Fix foreign keys and deletion service 2024-11-05 12:09:04 +01:00
Constantin Graf
4224fdd57e Fixed report for query with no entries 2024-11-01 13:46:22 +01:00
Constantin Graf
f4cfeaa718 Fixed issue with daylight saving time in chart 2024-10-30 17:40:46 +01:00
Constantin Graf
04fcc1e3ae Fixed timezones in detailed export reports #2 2024-10-29 18:25:42 +01:00
Constantin Graf
f145e821a8 Fix incorrect grouping by billable in export report 2024-10-29 18:09:22 +01:00
Constantin Graf
eaaa83406d Fixed timezones in detailed export reports 2024-10-29 18:09:22 +01:00
Constantin Graf
9a60e2b911 Add tests for export endpoints 2024-10-29 17:20:21 +01:00
Gregor Vostrak
5a1e05374c disable pdf export button 2024-10-29 17:20:21 +01:00
Gregor Vostrak
ab4dbd64df add support for history_group and loading indicators to export buttons 2024-10-29 17:20:21 +01:00
Constantin Graf
8712cfb9dc Add report exports 2024-10-29 17:20:21 +01:00
Gregor Vostrak
7c1fe35754 add export buttons for aggregated export and pdf export 2024-10-29 17:20:21 +01:00
Constantin Graf
b0bcc4f330 Add pdf detailed report and placeholder for aggregate endpoint 2024-10-29 17:20:21 +01:00
Gregor Vostrak
5593d141ea automatically select project after create in time tracker component, fixes ST-457 2024-10-29 17:20:21 +01:00
Gregor Vostrak
d080b07e60 add Export download buttons 2024-10-29 17:20:21 +01:00
Constantin Graf
64535ceea6 Add report exports 2024-10-29 17:20:21 +01:00
Gregor Vostrak
e54df74d5d improve typing in solidtime ui package 2024-10-28 14:54:48 +01:00
Constantin Graf
27b40d863e Make email validation on registration stricter 2024-10-28 14:32:27 +01:00
Gregor Vostrak
b41d20839e improve empty state texts for employees 2024-10-28 14:24:40 +01:00
Gregor Vostrak
7acadda6d8 bump ui and api package versions 2024-10-28 14:14:50 +01:00
Gregor Vostrak
cd7573dcf1 hide create project buttons and modal depending on the permission 2024-10-28 14:14:50 +01:00
Gregor Vostrak
eb4debe481 move time entry mass updates to ui package and remove its dependencies 2024-10-28 14:14:50 +01:00
Constantin Graf
fd77e1e901 Fix logo for email client with no SVG support like Gmail 2024-10-28 12:21:57 +01:00
Constantin Graf
401cd4be0a Fixed setting multiple time entry description to an empty string 2024-10-22 16:45:21 +02:00
Gregor Vostrak
548307336a keep tags when starting a new time entry from a finished one, fixes ST-469 2024-10-22 13:27:30 +02:00
Constantin Graf
f534f90ca7 Fix force HTTPS config 2024-10-22 11:09:31 +02:00
Constantin Graf
0290013d19 Specify enclosure and escape for solidtime export and import 2024-10-15 13:35:37 +02:00
Constantin Graf
85f4a3049c Fixed escaping issues in importer 2024-10-15 12:57:45 +02:00
Constantin Graf
4c27f1a2de Fix bugs in computed attribute calculation 2024-10-15 12:57:45 +02:00
Constantin Graf
69d3ff4f7b Stricter validation for uuid and integer 2024-10-15 12:57:45 +02:00
Constantin Graf
2b1da883fb Fixed typo in console kernel 2024-10-11 13:10:09 +02:00
Gregor Vostrak
c291170d79 fix timing problem when updating multiple time entries, fixes #202 2024-10-09 17:35:22 +02:00
Constantin Graf
d9925d632e Fix api url 2024-10-09 17:34:08 +02:00
Gregor Vostrak
ddf11b394d do not load filament theme stylesheet in main application 2024-10-09 16:51:25 +02:00
Gregor Vostrak
129c132f97 make project and tags in mass updates resettable 2024-10-09 14:20:07 +02:00
Gregor Vostrak
26637e6f84 fix billable status update dropdown 2024-10-09 13:30:09 +02:00
Gregor Vostrak
612f40a4b0 fix unselecting bugs in time view 2024-10-09 13:26:51 +02:00
Gregor Vostrak
8f34fac0a6 add select all for time entry row heading 2024-10-09 03:01:34 +02:00
Gregor Vostrak
a374a52474 add select and deselect all on time and detailed reporting view 2024-10-09 01:48:23 +02:00
Gregor Vostrak
09586de2d5 clear selected time entries after mass delete in time vue 2024-10-09 01:00:12 +02:00
Gregor Vostrak
678d27c93a fix design inconsistencies between regular and aggregate row 2024-10-09 00:55:42 +02:00
Constantin Graf
7af1990935 Added fallback for local env to server overview widget 2024-10-08 21:31:35 +02:00
Constantin Graf
2372ee0622 Add update lookup and telemetry, Add version and build to app config 2024-10-08 21:31:35 +02:00
Gregor Vostrak
f147fb9725 add mass updates to time view 2024-10-08 21:28:23 +02:00
Constantin Graf
d5a4df738f Fix bug in time-entry.update-multiple; Add computed property for client_id 2024-10-08 19:19:08 +02:00
Gregor Vostrak
b3b84db004 fix wrong update on time range selector that causes duplicate time entry start requests, fixes ST-449 2024-10-08 18:16:06 +02:00
Gregor Vostrak
d3d3a98b08 change detailed reporting to use time entries mass delete endpoint 2024-10-08 13:26:27 +02:00
Gregor Vostrak
9f2ac70549 add mass delete time entries frontend, closes ST-450 2024-10-08 13:26:27 +02:00
Constantin Graf
071895791c Add endpoint to delete multiple time entries 2024-10-08 13:26:27 +02:00
Gregor Vostrak
9a50e144b3 improve time entry heading padding 2024-10-08 12:59:04 +02:00
Gregor Vostrak
a77b8a5ed2 add mass update to detailed reporting page 2024-10-08 12:59:04 +02:00
Constantin Graf
fcba96fbf6 Renamed skip to offset 2024-10-08 12:59:04 +02:00
Gregor Vostrak
d200de54a8 fix chart overflowing on some screen sizes 2024-10-08 12:59:04 +02:00
Constantin Graf
a882ec6ca0 Add skip and meta to resource in time entry endpoint 2024-10-08 12:59:04 +02:00
Gregor Vostrak
3ee7839ca9 add detailed reporting page 2024-10-08 12:59:04 +02:00
Gregor Vostrak
165391861a remove debug message 2024-10-01 22:59:59 +02:00
Gregor Vostrak
8d950c6d45 hide billable rate in projects table for employees when employees_can_see_billable_rates is disabled 2024-10-01 22:48:27 +02:00
Gregor Vostrak
6c7b1b3f21 add employees_can_see_billable_rates setting to organization settings 2024-10-01 22:48:27 +02:00
Constantin Graf
51cd919db6 Add organization setting employees_can_see_billable_rates 2024-10-01 22:48:27 +02:00
Constantin Graf
9d279d4980 Fix ARM image 2024-09-30 23:36:58 +02:00
Gregor Vostrak
32c7e55a15 add Upgrade Info Modal, fix hardcoded premium flag 2024-09-30 14:52:18 +02:00
Gregor Vostrak
084647c2a6 add project edit button to project show page and billing rate info, fixes ST-236 2024-09-30 14:19:47 +02:00
Gregor Vostrak
469f128604 fix project name column overflow on some screen sizes with long project names 2024-09-30 14:19:47 +02:00
Gregor Vostrak
c9c221de62 improve focus handling in time tracker component, improve focus-visible state for timetracker start and stop button 2024-09-30 14:19:47 +02:00
Gregor Vostrak
878bbd359d cleanup dayjs abstraction usage and useCurrentTimeEntry api for starting and stopping time entries 2024-09-30 14:19:47 +02:00
Gregor Vostrak
a6528102fe add estimated project and tasks frontend 2024-09-30 14:19:47 +02:00
Constantin Graf
bff766d363 Add spend_time to projects and tasks 2024-09-30 14:19:47 +02:00
Constantin Graf
2e8da98287 Added php-cs-fixer rule void_return 2024-09-30 14:19:47 +02:00
Constantin Graf
a820d8540f Added time estimates for projects and tasks, fixes ST-283 2024-09-30 14:19:47 +02:00
Constantin Graf
78ea8a673b Fixed timezone problem in unit tests 2024-09-30 11:02:11 +02:00
347 changed files with 17170 additions and 2788 deletions

22
.env.ci
View File

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

View File

@@ -4,15 +4,15 @@ APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true APP_DEBUG=true
APP_URL=https://solidtime.test APP_URL=https://solidtime.test
AUDITING_ENABLED=true AUDITING_ENABLED=true
SUPER_ADMINS=admin@example.com SUPER_ADMINS=admin@example.com
# Logging
LOG_CHANNEL=single LOG_CHANNEL=single
LOG_DEPRECATIONS_CHANNEL=deprecation LOG_DEPRECATIONS_CHANNEL=deprecation
LOG_LEVEL=debug LOG_LEVEL=debug
# Database
DB_CONNECTION=pgsql DB_CONNECTION=pgsql
DB_HOST=pgsql DB_HOST=pgsql
DB_PORT=5432 DB_PORT=5432
DB_DATABASE=laravel DB_DATABASE=laravel
@@ -31,12 +31,7 @@ QUEUE_CONNECTION=sync
SESSION_DRIVER=database SESSION_DRIVER=database
SESSION_LIFETIME=120 SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1 # Mail
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp MAIL_MAILER=smtp
MAIL_HOST=mailpit MAIL_HOST=mailpit
MAIL_PORT=1025 MAIL_PORT=1025
@@ -54,7 +49,7 @@ PUSHER_PORT=443
PUSHER_SCHEME=https PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1 PUSHER_APP_CLUSTER=mt1
# Storage # Filesystems
FILESYSTEM_DISK=s3 FILESYSTEM_DISK=s3
PUBLIC_FILESYSTEM_DISK=s3 PUBLIC_FILESYSTEM_DISK=s3
S3_ACCESS_KEY_ID=sail S3_ACCESS_KEY_ID=sail
@@ -65,6 +60,9 @@ S3_URL=http://storage.solidtime.test/local
S3_ENDPOINT=http://storage.solidtime.test S3_ENDPOINT=http://storage.solidtime.test
S3_USE_PATH_STYLE_ENDPOINT=true S3_USE_PATH_STYLE_ENDPOINT=true
# Services
GOTENBERG_URL=http://gotenberg:3000
VITE_HOST_NAME=vite.solidtime.test VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}" VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"

View File

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

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

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

View File

@@ -20,15 +20,55 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
prefix: "v"
- name: "Get version"
id: version
run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
else
echo "ERROR: No previous tag found";
exit 1;
fi
else
version=$(echo "${{ github.ref }}" | cut -c 12-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
fi
- name: "Copy .env template for production"
run: |
cp .env.production .env
rm .env.production .env.ci .env.example
- name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
- name: "Output .env"
run: cat .env
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: '20.x' node-version: '20.x'
- name: "Copy .env template for production"
run: cp .env.production .env && cat .env
- name: "Checkout billing extension" - name: "Checkout billing extension"
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:

View File

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

View File

@@ -3,6 +3,8 @@ on:
branches: branches:
- main - main
- develop - develop
tags:
- '*'
pull_request: pull_request:
paths: paths:
- '.github/workflows/build-public.yml' - '.github/workflows/build-public.yml'
@@ -23,9 +25,49 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: build
run: echo "build=$(git rev-parse --short=8 HEAD)" >> "$GITHUB_OUTPUT"
- name: "Get Previous tag (normal push)"
id: previoustag
if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
uses: "WyriHaximus/github-action-get-previous-tag@v1"
with:
prefix: "v"
- name: "Get version"
id: version
run: |
if ${{ !startsWith(github.ref, 'refs/tags/v') }}; then
if ${{ startsWith(steps.previoustag.outputs.tag, 'v') }}; then
version=$(echo "${{ steps.previoustag.outputs.tag }}" | cut -c 2-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
else
echo "ERROR: No previous tag found";
exit 1;
fi
else
version=$(echo "${{ github.ref }}" | cut -c 12-)
echo "app_version=${version}" >> "$GITHUB_OUTPUT"
fi
- name: "Copy .env template for production" - name: "Copy .env template for production"
run: cp .env.production .env run: |
cp .env.production .env
rm .env.production .env.ci .env.example
- name: "Add version to .env"
run: sed -i 's/APP_VERSION=0.0.0/APP_VERSION=${{ steps.version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.build.outputs.build }}/g' .env
- name: "Output .env"
run: cat .env
- name: "Install dependencies" - name: "Install dependencies"
uses: php-actions/composer@v6 uses: php-actions/composer@v6

View File

@@ -20,7 +20,15 @@ jobs:
--health-interval 10s --health-interval 10s
--health-timeout 5s --health-timeout 5s
--health-retries 5 --health-retries 5
gotenberg:
image: gotenberg/gotenberg:8
ports:
- 3000:3000
options: >-
--health-cmd "curl --silent --fail http://localhost:3000/health"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v4 uses: actions/checkout@v4

View File

@@ -13,7 +13,7 @@ solidtime is a modern open-source time tracking application for Freelancers and
- Time tracking: Track your time with a modern and easy-to-use interface - Time tracking: Track your time with a modern and easy-to-use interface
- Projects: Create and manage projects and assign project members - Projects: Create and manage projects and assign project members
- Tasks: Create and manage tasks and assign tasks to project members - Tasks: Create and manage tasks and assign tasks to projects
- Clients: Create and manage clients and assign clients to projects - Clients: Create and manage clients and assign clients to projects
- Billable rates: Set billable rates for projects, project members, organization members and organizations - Billable rates: Set billable rates for projects, project members, organization members and organizations
- Multiple organizations: Create and manage multiple organizations with one account - Multiple organizations: Create and manage multiple organizations with one account
@@ -28,6 +28,11 @@ We also have an examples repository [here](https://github.com/solidtime-io/self-
If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/) If you do not want to self-host solidtime or try it out you can sign up for [solidtime cloud](https://www.solidtime.io/)
## Issues & Feature Requests
If you find any **bugs in solidtime**, please feel free to [**open an issue**](https://github.com/solidtime-io/solidtime/issues/new) in this repository, with instructions on how to reproduce the bug.
If you have a **feature request**, please [**create a discussion**](https://github.com/solidtime-io/solidtime/discussions/new?category=feature-requests) in this repository.
## Contributing ## Contributing
This project is in a very early stage. The structure and APIs are still subject to change and not stable. This project is in a very early stage. The structure and APIs are still subject to change and not stable.
@@ -35,6 +40,8 @@ Therefore, we do not currently accept any contributions, unless you are a member
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions. As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
## Security ## Security
Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file. Looking to report a vulnerability? Please refer our [SECURITY.md](./SECURITY.md) file.

View File

@@ -43,9 +43,9 @@ class CreateNewUser implements CreatesNewUsers
'email' => [ 'email' => [
'required', 'required',
'string', 'string',
'email', 'email:rfc,strict',
'max:255', 'max:255',
new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder { UniqueEloquent::make(User::class, 'email', function (Builder $builder): Builder {
/** @var Builder<User> $builder */ /** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false); return $builder->where('is_placeholder', '=', false);
}), }),
@@ -82,7 +82,7 @@ class CreateNewUser implements CreatesNewUsers
} }
$user = null; $user = null;
$organization = null; $organization = null;
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency) { DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void {
$user = User::create([ $user = User::create([
'name' => $input['name'], 'name' => $input['name'],
'email' => $input['email'], 'email' => $input['email'],

View File

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

View File

@@ -38,7 +38,7 @@ class AddOrganizationMember implements AddsTeamMembers
AddingTeamMember::dispatch($organization, $newOrganizationMember); AddingTeamMember::dispatch($organization, $newOrganizationMember);
DB::transaction(function () use ($organization, $newOrganizationMember, $role) { DB::transaction(function () use ($organization, $newOrganizationMember, $role): void {
$organization->users()->attach( $organization->users()->attach(
$newOrganizationMember, ['role' => $role] $newOrganizationMember, ['role' => $role]
); );
@@ -71,10 +71,10 @@ class AddOrganizationMember implements AddsTeamMembers
'email' => [ 'email' => [
'required', 'required',
'email', 'email',
(new ExistsEloquent(User::class, 'email', function (Builder $builder) { ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
/** @var Builder<User> $builder */ /** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false); return $builder->where('is_placeholder', '=', false);
}))->withMessage(__('We were unable to find a registered user with this email address.')), })->withMessage(__('We were unable to find a registered user with this email address.')),
], ],
'role' => [ 'role' => [
'required', 'required',
@@ -93,7 +93,7 @@ class AddOrganizationMember implements AddsTeamMembers
*/ */
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
{ {
return function ($validator) use ($team, $email) { return function ($validator) use ($team, $email): void {
$validator->errors()->addIf( $validator->errors()->addIf(
$team->hasRealUserWithEmail($email), $team->hasRealUserWithEmail($email),
'email', 'email',

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\SelfHost;
use App\Service\ApiService;
use Illuminate\Console\Command;
class SelfHostTelemetryCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'self-host:telemetry';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Execute the console command.
*/
public function handle(): int
{
$apiService = app(ApiService::class);
$success = $apiService->telemetry();
if (! $success) {
$this->error('Failed to send telemetry data, check the logs for more information.');
return self::FAILURE;
}
return self::SUCCESS;
}
}

View File

@@ -54,7 +54,7 @@ class TimeEntrySendStillRunningMailsCommand extends Command
$query->where('is_placeholder', '=', false); $query->where('is_placeholder', '=', false);
}) })
->orderBy('created_at', 'asc') ->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails) { ->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {
/** @var Collection<int, TimeEntry> $timeEntries */ /** @var Collection<int, TimeEntry> $timeEntries */
foreach ($timeEntries as $timeEntry) { foreach ($timeEntries as $timeEntry) {
$user = $timeEntry->user; $user = $timeEntry->user;

View File

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

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Maatwebsite\Excel\Excel;
enum ExportFormat: string
{
case CSV = 'csv';
case PDF = 'pdf';
case XLSX = 'xlsx';
case ODS = 'ods';
public function getFileExtension(): string
{
return match ($this) {
self::CSV => 'csv',
self::PDF => 'pdf',
self::XLSX => 'xlsx',
self::ODS => 'ods',
};
}
public function getExportPackageType(): string
{
return match ($this) {
self::CSV => Excel::CSV,
self::PDF => Excel::MPDF,
self::XLSX => Excel::XLSX,
self::ODS => Excel::ODS,
};
}
}

View File

@@ -4,8 +4,12 @@ declare(strict_types=1);
namespace App\Enums; namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryAggregationType: string enum TimeEntryAggregationType: string
{ {
use LaravelEnumHelper;
case Day = 'day'; case Day = 'day';
case Week = 'week'; case Week = 'week';
case Month = 'month'; case Month = 'month';
@@ -17,6 +21,16 @@ enum TimeEntryAggregationType: string
case Billable = 'billable'; case Billable = 'billable';
case Description = 'description'; case Description = 'description';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{
return match ($timeEntryAggregationTypeInterval) {
TimeEntryAggregationTypeInterval::Day => TimeEntryAggregationType::Day,
TimeEntryAggregationTypeInterval::Week => TimeEntryAggregationType::Week,
TimeEntryAggregationTypeInterval::Month => TimeEntryAggregationType::Month,
TimeEntryAggregationTypeInterval::Year => TimeEntryAggregationType::Year,
};
}
public function toInterval(): ?TimeEntryAggregationTypeInterval public function toInterval(): ?TimeEntryAggregationTypeInterval
{ {
return match ($this) { return match ($this) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Filament\Widgets;
use Filament\Widgets\Widget;
use Illuminate\Support\Facades\Cache;
class ServerOverview extends Widget
{
protected static string $view = 'filament.widgets.server-overview';
/**
* @return array<string, mixed>
*/
protected function getViewData(): array
{
/** @var string|null $currentVersion */
$currentVersion = config('app.version');
/** @var string|null $build */
$build = config('app.build');
$latestVersion = Cache::get('latest_version', null);
$needsUpdate = false;
if ($latestVersion !== null && $currentVersion !== null && version_compare($latestVersion, $currentVersion) > 0) {
$needsUpdate = true;
}
return [
'version' => $currentVersion,
'build' => $build,
'environment' => config('app.env'),
'currentVersion' => $latestVersion,
'needsUpdate' => $needsUpdate,
];
}
}

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Models\Organization; use App\Models\Organization;
use App\Service\BillingContract;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@@ -43,4 +44,9 @@ class Controller extends \App\Http\Controllers\Controller
{ {
return $this->permissionStore->has($organization, $permission); return $this->permissionStore->has($organization, $permission);
} }
protected function canAccessPremiumFeatures(Organization $organization): bool
{
return app(BillingContract::class)->hasSubscription($organization) || app(BillingContract::class)->hasTrial($organization);
}
} }

View File

@@ -131,6 +131,8 @@ class MemberController extends Controller
} }
/** /**
* Make a member a placeholder member
*
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization * @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
*/ */
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse

View File

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

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1; namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Exceptions\Api\EntityStillInUseApiException; use App\Exceptions\Api\EntityStillInUseApiException;
use App\Http\Requests\V1\Project\ProjectIndexRequest; use App\Http\Requests\V1\Project\ProjectIndexRequest;
use App\Http\Requests\V1\Project\ProjectStoreRequest; use App\Http\Requests\V1\Project\ProjectStoreRequest;
@@ -13,6 +14,7 @@ use App\Http\Resources\V1\Project\ProjectResource;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Service\BillableRateService; use App\Service\BillableRateService;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -60,7 +62,9 @@ class ProjectController extends Controller
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default')); $projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
return new ProjectCollection($projects); $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
return new ProjectCollection($projects, $showBillableRate);
} }
/** /**
@@ -74,9 +78,12 @@ class ProjectController extends Controller
{ {
$this->checkPermission($organization, 'projects:view', $project); $this->checkPermission($organization, 'projects:view', $project);
// Note: There is currently no need to check if a user is a member of the project,
// since this is only relevant for users with the role "employee" and they can not access this endpoint.
$project->load('organization'); $project->load('organization');
return new ProjectResource($project); return new ProjectResource($project, true);
} }
/** /**
@@ -95,10 +102,14 @@ class ProjectController extends Controller
$project->is_billable = (bool) $request->input('is_billable'); $project->is_billable = (bool) $request->input('is_billable');
$project->billable_rate = $request->getBillableRate(); $project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id'); $project->client_id = $request->input('client_id');
$project->is_public = $request->getIsPublic();
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}
$project->organization()->associate($organization); $project->organization()->associate($organization);
$project->save(); $project->save();
return new ProjectResource($project); return new ProjectResource($project, true);
} }
/** /**
@@ -117,16 +128,32 @@ class ProjectController extends Controller
if ($request->has('is_archived')) { if ($request->has('is_archived')) {
$project->archived_at = $request->getIsArchived() ? Carbon::now() : null; $project->archived_at = $request->getIsArchived() ? Carbon::now() : null;
} }
if ($request->has('is_public')) {
$project->is_public = $request->boolean('is_public');
}
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
$project->estimated_time = $request->getEstimatedTime();
}
$oldBillableRate = $project->billable_rate; $oldBillableRate = $project->billable_rate;
$clientIdChanged = false;
$project->billable_rate = $request->getBillableRate(); $project->billable_rate = $request->getBillableRate();
$project->client_id = $request->input('client_id'); if ($project->client_id !== $request->input('client_id')) {
$project->client_id = $request->input('client_id');
$clientIdChanged = true;
}
$project->save(); $project->save();
if ($oldBillableRate !== $request->getBillableRate()) { if ($oldBillableRate !== $request->getBillableRate()) {
$billableRateService->updateTimeEntriesBillableRateForProject($project); $billableRateService->updateTimeEntriesBillableRateForProject($project);
} }
if ($clientIdChanged) {
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->whereBelongsTo($project, 'project')
->update(['client_id' => $project->client_id]);
}
return new ProjectResource($project); return new ProjectResource($project, true);
} }
/** /**
@@ -147,8 +174,8 @@ class ProjectController extends Controller
throw new EntityStillInUseApiException('project', 'time_entry'); throw new EntityStillInUseApiException('project', 'time_entry');
} }
DB::transaction(function () use (&$project) { DB::transaction(function () use (&$project): void {
$project->members->each(function (ProjectMember $member) { $project->members->each(function (ProjectMember $member): void {
$member->delete(); $member->delete();
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,10 +32,10 @@ class ProjectStoreRequest extends FormRequest
'string', 'string',
'min:1', 'min:1',
'max:255', 'max:255',
(new UniqueEloquent(Project::class, 'name', function (Builder $builder): Builder { UniqueEloquent::make(Project::class, 'name', function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}))->withCustomTranslation('validation.project_name_already_exists'), })->withCustomTranslation('validation.project_name_already_exists'),
], ],
'color' => [ 'color' => [
'required', 'required',
@@ -51,21 +51,46 @@ class ProjectStoreRequest extends FormRequest
'nullable', 'nullable',
'integer', 'integer',
'min:0', 'min:0',
'max:2147483647',
], ],
// ID of the client
'client_id' => [ 'client_id' => [
'nullable', 'nullable',
new ExistsEloquent(Client::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
/** @var Builder<Client> $builder */ /** @var Builder<Client> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
}), })->uuid(),
],
// Estimated time in seconds
'estimated_time' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
// Whether the project is public
'is_public' => [
'boolean',
], ],
]; ];
} }
public function getIsPublic(): bool
{
return $this->has('is_public') && $this->boolean('is_public');
}
public function getBillableRate(): ?int public function getBillableRate(): ?int
{ {
$input = $this->input('billable_rate'); $input = $this->input('billable_rate');
return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null; return $input !== null && $input !== 0 ? (int) $this->input('billable_rate') : null;
} }
public function getEstimatedTime(): ?int
{
$input = $this->input('estimated_time');
return $input !== null && $input !== 0 ? (int) $this->input('estimated_time') : null;
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,8 @@ class PersonalMembershipResource extends BaseResource
'id' => $this->resource->organization->id, 'id' => $this->resource->organization->id,
/** @var string $name Name of organization */ /** @var string $name Name of organization */
'name' => $this->resource->organization->name, 'name' => $this->resource->organization->name,
/** @var string $currency Currency code (ISO 4217) of organization */
'currency' => $this->resource->organization->currency,
], ],
/** @var string $role Role */ /** @var string $role Role */
'role' => $this->resource->role, 'role' => $this->resource->role,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Http\Resources\V1\BaseResource;
use App\Models\Report;
use Illuminate\Http\Request;
/**
* @property Report $resource
*/
class DetailedReportResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string, string|bool|int|null|array<int, string>>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the report */
'id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string|null $email Description */
'description' => $this->resource->description,
/** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(),
'properties' => [
/** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value,
/** @var string $sub_group Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value,
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */
'start' => $this->resource->properties->start->toIso8601ZuluString(),
/** @var string $end End date of the report */
'end' => $this->resource->properties->end->toIso8601ZuluString(),
/** @var bool|null $active Whether the report is active */
'active' => $this->resource->properties->active,
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
'member_ids' => $this->resource->properties->memberIds?->toArray(),
/** @var bool|null $billable Filter by billable status */
'billable' => $this->resource->properties->billable,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->properties->clientIds?->toArray(),
/** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */
'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(),
],
/** @var string $created_at Date when the report was created */
'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
/** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(),
];
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Http\Resources\V1\BaseResource;
use App\Models\Report;
use Illuminate\Http\Request;
/**
* @property Report $resource
*
* @phpstan-type Data array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* }
*/
class DetailedWithDataReportResource extends BaseResource
{
/**
* @var Data
*/
private array $data;
/**
* @var Data
*/
private array $historyData;
/**
* @param Data $data
* @param Data $historyData
*/
public function __construct(Report $resource, array $data, array $historyData)
{
parent::__construct($resource);
$this->data = $data;
$this->historyData = $historyData;
}
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|Data|array<string, string|bool|int|null|array<int, string>>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string|null $email Description */
'description' => $this->resource->description,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->organization->currency,
'properties' => [
/** @var string $group Type of first grouping */
'group' => $this->resource->properties->group->value,
/** @var string $sub_group Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value,
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */
'start' => $this->resource->properties->start->toIso8601ZuluString(),
/** @var string $end End date of the report */
'end' => $this->resource->properties->end->toIso8601ZuluString(),
],
/** @var array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* color: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* } $data Aggregated data
*/
'data' => $this->data,
/** @var array{
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: string|null,
* grouped_data: null|array<array{
* key: string|null,
* description: string|null,
* seconds: int,
* cost: int,
* grouped_type: null,
* grouped_data: null
* }>
* }>,
* seconds: int,
* cost: int
* } $history_data Historic aggregated data
*/
'history_data' => $this->historyData,
];
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ReportCollection extends ResourceCollection implements PaginatedResourceCollection
{
/**
* The resource that this resource collects.
*
* @var string
*/
public $collects = ReportResource::class;
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Http\Resources\V1\BaseResource;
use App\Models\Report;
use Illuminate\Http\Request;
/**
* @property Report $resource
*/
class ReportResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string>>
*/
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the report */
'id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string|null $email Description */
'description' => $this->resource->description,
/** @var bool $is_public Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->resource->public_until?->toIso8601ZuluString(),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(),
/** @var string $created_at Date when the report was created */
'created_at' => $this->resource->created_at?->toIso8601ZuluString(),
/** @var string $updated_at Date when the report was last updated */
'updated_at' => $this->resource->updated_at?->toIso8601ZuluString(),
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,6 +29,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $currency * @property string $currency
* @property int|null $billable_rate * @property int|null $billable_rate
* @property string $user_id * @property string $user_id
* @property bool $employees_can_see_billable_rates
* @property User $owner * @property User $owner
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
@@ -58,6 +59,7 @@ class Organization extends JetstreamTeam implements AuditableContract
'name' => 'string', 'name' => 'string',
'personal_team' => 'boolean', 'personal_team' => 'boolean',
'currency' => 'string', 'currency' => 'string',
'employees_can_see_billable_rates' => 'boolean',
]; ];
/** /**

View File

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

64
app/Models/Report.php Normal file
View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\HasUuids;
use App\Service\Dto\ReportPropertiesDto;
use Database\Factories\ReportFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
/**
* @property string $id
* @property string $name
* @property string|null $description
* @property string $organization_id
* @property bool $is_public
* @property Carbon|null $public_until
* @property string|null $share_secret
* @property ReportPropertiesDto $properties
* @property-read Organization $organization
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*
* @method static ReportFactory factory()
*/
class Report extends Model
{
/** @use HasFactory<ReportFactory> */
use HasFactory;
use HasUuids;
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'is_public' => 'bool',
'public_until' => 'datetime',
'properties' => ReportPropertiesDto::class,
];
public function getShareableLink(): ?string
{
if ($this->is_public && $this->share_secret !== null) {
return route('shared-report').'#'.$this->share_secret;
}
return null;
}
/**
* @return BelongsTo<Organization, Report>
*/
public function organization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'organization_id');
}
}

View File

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

View File

@@ -15,6 +15,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Korridor\LaravelComputedAttributes\ComputedAttributes;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -23,6 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $project_id * @property string $project_id
* @property string $organization_id * @property string $organization_id
* @property Carbon|null $done_at * @property Carbon|null $done_at
* @property int|null $estimated_time
* @property int $spent_time
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read Project $project * @property-read Project $project
@@ -34,6 +38,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
*/ */
class Task extends Model implements AuditableContract class Task extends Model implements AuditableContract
{ {
use ComputedAttributes;
use CustomAuditable; use CustomAuditable;
/** @use HasFactory<TaskFactory> */ /** @use HasFactory<TaskFactory> */
@@ -48,9 +53,72 @@ class Task extends Model implements AuditableContract
*/ */
protected $casts = [ protected $casts = [
'name' => 'string', 'name' => 'string',
'estimated_time' => 'integer',
'done_at' => 'datetime', 'done_at' => 'datetime',
]; ];
/**
* The attributes that are computed. (f.e. for performance reasons)
* These attributes can be regenerated at any time.
*
* @var string[]
*/
protected array $computed = [
'spent_time',
];
/**
* Attributes to exclude from the Audit.
*
* @var array<string>
*/
protected array $auditExclude = [
'spent_time',
];
public function getSpentTimeComputed(): ?int
{
if ($this->hasAttribute('spent_time_computed')) {
return $this->attributes['spent_time_computed'] === null ? 0 : (int) $this->attributes['spent_time_computed'];
} else {
/** @var object{ spent_time: string } $result */
$result = $this->timeEntries()
->whereNotNull('end')
->selectRaw('sum(extract(epoch from ("end" - start))) as spent_time')
->first();
return (int) $result->spent_time;
}
}
/**
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
*
* @param Builder<Task> $builder
* @param array<string> $attributes Attributes that will be generated.
* @return Builder<Task>
*/
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
{
if (in_array('spent_time', $attributes, true)) {
$builder->withAggregate('timeEntries as spent_time_computed', DB::raw('extract(epoch from ("end" - start))'), 'sum');
}
return $builder;
}
/**
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
*
* @param Builder<Task> $builder
* @param array<string> $attributes Attributes that will be validated.
* @return Builder<Task>
*/
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
{
return $this->scopeComputedAttributesGenerate($builder, $attributes);
}
/** /**
* @return BelongsTo<Project, Task> * @return BelongsTo<Project, Task>
*/ */

View File

@@ -10,12 +10,16 @@ use App\Service\BillableRateService;
use Carbon\CarbonInterval; use Carbon\CarbonInterval;
use Database\Factories\TimeEntryFactory; use Database\Factories\TimeEntryFactory;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use Korridor\LaravelComputedAttributes\ComputedAttributes; use Korridor\LaravelComputedAttributes\ComputedAttributes;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
/** /**
* @property string $id * @property string $id
@@ -41,6 +45,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property-read Client|null $client * @property-read Client|null $client
* @property string|null $task_id * @property string|null $task_id
* @property-read Task|null $task * @property-read Task|null $task
* @property-read Collection<Tag> $tagsRelation
* *
* @method Builder<TimeEntry> hasTag(Tag $tag) * @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory() * @method static TimeEntryFactory factory()
@@ -53,6 +58,7 @@ class TimeEntry extends Model implements AuditableContract
/** @use HasFactory<TimeEntryFactory> */ /** @use HasFactory<TimeEntryFactory> */
use HasFactory; use HasFactory;
use HasJsonRelationships;
use HasUuids; use HasUuids;
/** /**
@@ -79,6 +85,16 @@ class TimeEntry extends Model implements AuditableContract
*/ */
protected array $computed = [ protected array $computed = [
'billable_rate', 'billable_rate',
'client_id',
];
/**
* Attributes to exclude from the Audit.
*
* @var array<string>
*/
protected array $auditExclude = [
'billable_rate',
]; ];
public function getBillableRateComputed(): ?int public function getBillableRateComputed(): ?int
@@ -86,6 +102,44 @@ class TimeEntry extends Model implements AuditableContract
return app(BillableRateService::class)->getBillableRateForTimeEntry($this); return app(BillableRateService::class)->getBillableRateForTimeEntry($this);
} }
public function getClientIdComputed(): ?string
{
return $this->project_id === null || $this->project === null ? null : $this->project->client_id;
}
/**
* This scope will be applied during the computed property generation with artisan computed-attributes:generate.
*
* @param Builder<TimeEntry> $builder
* @param array<string> $attributes Attributes that will be generated.
* @return Builder<TimeEntry>
*/
public function scopeComputedAttributesGenerate(Builder $builder, array $attributes): Builder
{
if (in_array('client_id', $attributes, true)) {
$builder->with([
'project' => function (Relation $builder): void {
/** @var Builder<Project> $builder */
$builder->select('id', 'client_id');
},
]);
}
return $builder;
}
/**
* This scope will be applied during the computed property validation with artisan computed-attributes:validate.
*
* @param Builder<TimeEntry> $builder
* @param array<string> $attributes Attributes that will be validated.
* @return Builder<TimeEntry>
*/
public function scopeComputedAttributesValidate(Builder $builder, array $attributes): Builder
{
return $this->scopeComputedAttributesGenerate($builder, $attributes);
}
public function getDuration(): ?CarbonInterval public function getDuration(): ?CarbonInterval
{ {
return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end); return $this->end === null ? null : $this->start->diffAsCarbonInterval($this->end);
@@ -148,4 +202,14 @@ class TimeEntry extends Model implements AuditableContract
{ {
return $this->belongsTo(Client::class, 'client_id'); return $this->belongsTo(Client::class, 'client_id');
} }
/**
* Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.
*
* @return BelongsToJson<Tag, $this>
*/
public function tagsRelation(): BelongsToJson
{
return $this->belongsToJson(Tag::class, 'tags');
}
} }

View File

@@ -25,7 +25,9 @@ use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\TwoFactorAuthenticatable; use Laravel\Fortify\TwoFactorAuthenticatable;
use Laravel\Jetstream\HasProfilePhoto; use Laravel\Jetstream\HasProfilePhoto;
use Laravel\Jetstream\HasTeams; use Laravel\Jetstream\HasTeams;
use Laravel\Passport\AuthCode;
use Laravel\Passport\HasApiTokens; use Laravel\Passport\HasApiTokens;
use Laravel\Passport\Token;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract; use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
/** /**
@@ -178,6 +180,22 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
return $this->hasMany(ProjectMember::class, 'user_id'); return $this->hasMany(ProjectMember::class, 'user_id');
} }
/**
* @return HasMany<Token>
*/
public function accessTokens(): HasMany
{
return $this->hasMany(Token::class);
}
/**
* @return HasMany<AuthCode>
*/
public function authCodes(): HasMany
{
return $this->hasMany(AuthCode::class);
}
/** /**
* @param Builder<User> $builder * @param Builder<User> $builder
*/ */

View File

@@ -28,7 +28,6 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\URL; use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
@@ -81,19 +80,20 @@ class AppServiceProvider extends ServiceProvider
}); });
// Scramble // Scramble
Scramble::extendOpenApi(function (OpenApi $openApi) { Scramble::extendOpenApi(function (OpenApi $openApi): void {
$openApi->secure( $openApi->secure(
SecurityScheme::oauth2() SecurityScheme::oauth2()
->flow('authorizationCode', function (OAuthFlow $flow) { ->flow('authorizationCode', function (OAuthFlow $flow): void {
$flow $flow
->authorizationUrl('https://solidtime.test/oauth/authorize'); ->authorizationUrl('https://solidtime.test/oauth/authorize');
}) })
); );
}); });
if (config('app.force_https', false) || App::isProduction()) { if (config('app.force_https', false)) {
URL::forceScheme('https'); URL::forceScheme('https');
request()->server->set('HTTPS', request()->header('X-Forwarded-Proto', 'https') === 'https' ? 'on' : 'off'); request()->server->set('HTTPS', 'on');
request()->headers->set('X-Forwarded-Proto', 'https');
} }
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore { $this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Providers\Filament; namespace App\Providers\Filament;
use App\Filament\Widgets\ActiveUserOverview; use App\Filament\Widgets\ActiveUserOverview;
use App\Filament\Widgets\ServerOverview;
use App\Filament\Widgets\TimeEntriesCreated; use App\Filament\Widgets\TimeEntriesCreated;
use App\Filament\Widgets\TimeEntriesImported; use App\Filament\Widgets\TimeEntriesImported;
use App\Filament\Widgets\UserRegistrations; use App\Filament\Widgets\UserRegistrations;
@@ -44,11 +45,13 @@ class AdminPanelProvider extends PanelProvider
]) ])
->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets') ->discoverWidgets(in: app_path('Filament/Widgets'), for: 'App\\Filament\\Widgets')
->widgets([ ->widgets([
ServerOverview::class,
ActiveUserOverview::class, ActiveUserOverview::class,
UserRegistrations::class, UserRegistrations::class,
TimeEntriesCreated::class, TimeEntriesCreated::class,
TimeEntriesImported::class, TimeEntriesImported::class,
]) ])
->viteTheme('resources/css/filament/admin/theme.css')
->plugins([ ->plugins([
EnvironmentIndicatorPlugin::make() EnvironmentIndicatorPlugin::make()
->color(fn () => match (App::environment()) { ->color(fn () => match (App::environment()) {

View File

@@ -126,6 +126,10 @@ class JetstreamServiceProvider extends ServiceProvider
'members:update', 'members:update',
'members:delete', 'members:delete',
'billing', 'billing',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
])->description('Owner users can perform any action. There is only one owner per organization.'); ])->description('Owner users can perform any action. There is only one owner per organization.');
Jetstream::role(Role::Admin->value, 'Administrator', [ Jetstream::role(Role::Admin->value, 'Administrator', [
@@ -170,6 +174,10 @@ class JetstreamServiceProvider extends ServiceProvider
'members:view', 'members:view',
'members:update', 'members:update',
'members:invite-placeholder', 'members:invite-placeholder',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
])->description('Administrator users can perform any action, except accessing the billing dashboard.'); ])->description('Administrator users can perform any action, except accessing the billing dashboard.');
Jetstream::role(Role::Manager->value, 'Manager', [ Jetstream::role(Role::Manager->value, 'Manager', [
@@ -206,6 +214,10 @@ class JetstreamServiceProvider extends ServiceProvider
'organizations:view', 'organizations:view',
'invitations:view', 'invitations:view',
'members:view', 'members:view',
'reports:view',
'reports:create',
'reports:update',
'reports:delete',
])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).'); ])->description('Managers have full access to all projects, time entries, ect. but cannot manage the organization (add/remove member, edit the organization, ect.).');
Jetstream::role(Role::Employee->value, 'Employee', [ Jetstream::role(Role::Employee->value, 'Employee', [

View File

@@ -37,9 +37,9 @@ class RouteServiceProvider extends ServiceProvider
: Limit::perMinute(60)->by($request->ip()); : Limit::perMinute(60)->by($request->ip());
}); });
$this->routes(function () { $this->routes(function (): void {
Route::middleware('health-check') Route::middleware('health-check')
->group(function () { ->group(function (): void {
Route::get('health-check/up', [HealthCheckController::class, 'up']); Route::get('health-check/up', [HealthCheckController::class, 'up']);
Route::get('health-check/debug', [HealthCheckController::class, 'debug']); Route::get('health-check/debug', [HealthCheckController::class, 'debug']);
}); });

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Models\Audit;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use Exception;
use Illuminate\Support\Facades\Http;
use Log;
class ApiService
{
private const string API_URL = 'https://app.solidtime.io/api/v1';
public function checkForUpdate(): ?string
{
try {
$response = Http::asJson()
->timeout(3)
->connectTimeout(2)
->post(self::API_URL.'/ping/version', [
'version' => config('app.version'),
'build' => config('app.build'),
'url' => config('app.url'),
]);
if ($response->status() === 200 && isset($response->json()['version']) && is_string($response->json()['version'])) {
return $response->json()['version'];
} else {
Log::warning('Failed to check for update', [
'status' => $response->status(),
'body' => $response->body(),
]);
return null;
}
} catch (\Throwable $e) {
Log::warning('Failed to check for update', [
'message' => $e->getMessage(),
]);
return null;
}
}
public function telemetry(): bool
{
try {
$response = Http::asJson()
->timeout(3)
->connectTimeout(2)
->post(self::API_URL.'/ping/telemetry', [
'version' => config('app.version'),
'build' => config('app.build'),
'url' => config('app.url'),
// telemetry data
'user_count' => User::count(),
'organization_count' => Organization::count(),
'audit_count' => Audit::count(),
'project_count' => Project::count(),
'project_member_count' => ProjectMember::count(),
'client_count' => Client::count(),
'task_count' => Task::count(),
'time_entry_count' => TimeEntry::count(),
]);
if ($response->status() === 200) {
return true;
} else {
Log::warning('Failed send telemetry data', [
'status' => $response->status(),
'body' => $response->body(),
]);
return false;
}
} catch (Exception $e) {
Log::warning('Failed send telemetry data', [
'message' => $e->getMessage(),
]);
return false;
}
}
}

View File

@@ -28,9 +28,9 @@ class BillableRateService
->where('billable', '=', true) ->where('billable', '=', true)
->where('organization_id', '=', $project->organization_id) ->where('organization_id', '=', $project->organization_id)
->whereBelongsTo($project, 'project') ->whereBelongsTo($project, 'project')
->whereDoesntHave('member', function (Builder $query) use ($project) { ->whereDoesntHave('member', function (Builder $query) use ($project): void {
/** @var Builder<Member> $query */ /** @var Builder<Member> $query */
$query->whereHas('projectMembers', function (Builder $query) use ($project) { $query->whereHas('projectMembers', function (Builder $query) use ($project): void {
/** @var Builder<ProjectMember> $query */ /** @var Builder<ProjectMember> $query */
$query->whereBelongsTo($project, 'project') $query->whereBelongsTo($project, 'project')
->whereNotNull('billable_rate'); ->whereNotNull('billable_rate');
@@ -62,7 +62,7 @@ class BillableRateService
TimeEntry::query() TimeEntry::query()
->where('billable', '=', true) ->where('billable', '=', true)
->where('organization_id', '=', $organization->getKey()) ->where('organization_id', '=', $organization->getKey())
->whereDoesntHave('member', function (Builder $builder) { ->whereDoesntHave('member', function (Builder $builder): void {
/** @var Builder<Member> $builder */ /** @var Builder<Member> $builder */
$builder->whereNotNull('billable_rate'); $builder->whereNotNull('billable_rate');
}) })

View File

@@ -22,7 +22,7 @@ class BillingContract
*/ */
public function hasSubscription(Organization $organization): bool public function hasSubscription(Organization $organization): bool
{ {
return false; return true;
} }
/** /**

View File

@@ -33,8 +33,12 @@ class ColorService
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/'; private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
public function getRandomColor(): string public function getRandomColor(?string $seed = null): string
{ {
if ($seed !== null) {
srand(crc32($seed));
}
return self::COLORS[array_rand(self::COLORS)]; return self::COLORS[array_rand(self::COLORS)];
} }

View File

@@ -47,22 +47,24 @@ class DashboardService
{ {
$result = []; $result = [];
$windowSize = 24 / $windows; $windowSize = 24 / $windows;
$end = Carbon::now($timeZone)->endOfDay()->subHours(3)->utc()->toDateTimeString(); $end = Carbon::now($timeZone)->startOfDay()->addDay()->subHours(3)->utc()->toDateTimeString();
$start = Carbon::now($timeZone)->subDays($days)->startOfDay()->utc()->toDateTimeString(); $start = Carbon::now($timeZone)->subDays($days)->startOfDay()->utc()->toDateTimeString();
$date = Carbon::now($timeZone)->startOfDay(); $date = Carbon::now($timeZone)->startOfDay();
$dateUtc = Carbon::now($timeZone)->startOfDay()->utc();
for ($i = 0; $i < $days; $i++) { for ($i = 0; $i < $days; $i++) {
$dateString = $date->format('Y-m-d'); $dateString = $date->format('Y-m-d');
$tempDate = $date->copy(); $tempDate = $dateUtc->copy();
$start = $tempDate->copy()->utc()->toDateTimeString(); $start = $tempDate->copy()->utc()->toDateTimeString();
$tempWindows = []; $tempWindows = [];
for ($j = 0; $j < $windows; $j++) { for ($j = 0; $j < $windows; $j++) {
$tempWindow = $tempDate->utc()->toDateTimeString(); $tempWindow = $tempDate->toDateTimeString();
$tempWindows[] = $tempWindow; $tempWindows[] = $tempWindow;
$tempDate->addHours($windowSize); $tempDate->addHours($windowSize);
} }
$result[$dateString] = $tempWindows; $result[$dateString] = $tempWindows;
$date->subDay(); $date->subDay();
$dateUtc->subDay();
} }
return [ return [

View File

@@ -35,7 +35,7 @@ class DeletionService
public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void public function deleteOrganization(Organization $organization, bool $inTransaction = true, ?User $ignoreUser = null): void
{ {
if ($inTransaction) { if ($inTransaction) {
DB::transaction(function () use ($organization) { DB::transaction(function () use ($organization): void {
$this->deleteOrganization($organization, false); $this->deleteOrganization($organization, false);
}); });
@@ -123,7 +123,7 @@ class DeletionService
public function deleteUser(User $user, bool $inTransaction = true): void public function deleteUser(User $user, bool $inTransaction = true): void
{ {
if ($inTransaction) { if ($inTransaction) {
DB::transaction(function () use ($user) { DB::transaction(function () use ($user): void {
$this->deleteUser($user, false); $this->deleteUser($user, false);
}); });
@@ -144,6 +144,7 @@ class DeletionService
->get(); ->get();
foreach ($members as $member) { foreach ($members as $member) {
/** @var Member $member */
if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) { if ($member->role === Role::Owner->value && $member->organization->users()->count() > 1) {
throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; throw new CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
} }
@@ -154,10 +155,13 @@ class DeletionService
if ($member->role === Role::Owner->value) { if ($member->role === Role::Owner->value) {
$this->deleteOrganization($member->organization, false, $user); $this->deleteOrganization($member->organization, false, $user);
} else { } else {
$this->memberService->makeMemberToPlaceholder($member); $this->memberService->makeMemberToPlaceholder($member, false);
} }
} }
$user->accessTokens()->delete();
$user->authCodes()->delete();
// Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last // Note: Since the deletion of the profile photo is not reversible via a database rollback this needs to be done last
$user->deleteProfilePhoto(); $user->deleteProfilePhoto();

View File

@@ -0,0 +1,217 @@
<?php
declare(strict_types=1);
namespace App\Service\Dto;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
use Illuminate\Contracts\Database\Eloquent\Castable;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class ReportPropertiesDto implements Castable
{
public TimeEntryAggregationType $group;
public TimeEntryAggregationType $subGroup;
public TimeEntryAggregationTypeInterval $historyGroup;
public Weekday $weekStart;
public string $timezone;
public Carbon $start;
public Carbon $end;
public ?bool $active = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $memberIds = null;
public ?bool $billable = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $clientIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $projectIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $tagIds = null;
/**
* @var Collection<int, string>|null
*/
public ?Collection $taskIds = null;
/**
* Get the caster class to use when casting from / to this cast target.
*
* @param array<string, mixed> $arguments
* @return CastsAttributes<ReportPropertiesDto, ReportPropertiesDto>
*/
public static function castUsing(array $arguments): CastsAttributes
{
return new class implements CastsAttributes
{
private const array REQUIRED_PROPERTIES = [
'group',
'subGroup',
'historyGroup',
'weekStart',
'timezone',
'start',
'end',
'active',
'memberIds',
'billable',
'clientIds',
'projectIds',
'tagIds',
'taskIds',
];
public function get(Model $model, string $key, mixed $value, array $attributes): ReportPropertiesDto
{
if (! is_string($value)) {
throw new \InvalidArgumentException('The given value is not a string');
}
$data = json_decode($value, false);
if ($data === null) {
throw new \InvalidArgumentException('The given value is not a JSON string');
}
foreach (self::REQUIRED_PROPERTIES as $property) {
if (! property_exists($data, $property)) {
throw new \InvalidArgumentException('The given JSON string does not contain the required property "'.$property.'"');
}
}
$dto = new ReportPropertiesDto;
$dto->end = $data->end !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->end) : null;
$dto->start = $data->start !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $data->start) : null;
$dto->active = $data->active;
$dto->memberIds = $data->memberIds !== null ? ReportPropertiesDto::idArrayToCollection($data->memberIds) : null;
$dto->billable = $data->billable;
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
$dto->weekStart = Weekday::from($data->weekStart);
$dto->timezone = $data->timezone;
return $dto;
}
/**
* @param ReportPropertiesDto $value
*/
public function set(Model $model, string $key, mixed $value, array $attributes): string
{
if (! ($value instanceof ReportPropertiesDto)) {
throw new \InvalidArgumentException('The given value is not an instance of ReportPropertiesDto');
}
$data = (object) [
'end' => $value->end->toIso8601ZuluString(),
'start' => $value->start->toIso8601ZuluString(),
'active' => $value->active,
'memberIds' => $value->memberIds?->toArray(),
'billable' => $value->billable,
'clientIds' => $value->clientIds?->toArray(),
'projectIds' => $value->projectIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(),
'taskIds' => $value->taskIds?->toArray(),
'group' => $value->group->value,
'subGroup' => $value->subGroup->value,
'historyGroup' => $value->historyGroup->value,
'weekStart' => $value->weekStart->value,
'timezone' => $value->timezone,
];
$jsonString = json_encode($data);
if ($jsonString === false) {
throw new \InvalidArgumentException('Could not encode the given data to a JSON string');
}
return $jsonString;
}
};
}
/**
* @param array<mixed> $ids
* @return Collection<int, string>
*/
public static function idArrayToCollection(array $ids): Collection
{
$collection = new Collection;
foreach ($ids as $id) {
if (! is_string($id)) {
throw new \InvalidArgumentException('The given ID is not a string');
}
if (! Str::isUuid($id)) {
throw new \InvalidArgumentException('The given ID is not a valid UUID');
}
$collection->push($id);
}
return $collection;
}
/**
* @param array<mixed>|null $memberIds
*/
public function setMemberIds(?array $memberIds): void
{
$this->memberIds = $memberIds !== null ? ReportPropertiesDto::idArrayToCollection($memberIds) : null;
}
/**
* @param array<mixed>|null $clientIds
*/
public function setClientIds(?array $clientIds): void
{
$this->clientIds = $clientIds !== null ? ReportPropertiesDto::idArrayToCollection($clientIds) : null;
}
/**
* @param array<mixed>|null $projectIds
*/
public function setProjectIds(?array $projectIds): void
{
$this->projectIds = $projectIds !== null ? ReportPropertiesDto::idArrayToCollection($projectIds) : null;
}
/**
* @param array<mixed>|null $tagIds
*/
public function setTagIds(?array $tagIds): void
{
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
}
/**
* @param array<mixed>|null $taskIds
*/
public function setTaskIds(?array $taskIds): void
{
$this->taskIds = $taskIds !== null ? ReportPropertiesDto::idArrayToCollection($taskIds) : null;
}
}

View File

@@ -47,6 +47,9 @@ class ExportService
// Organizations // Organizations
try { try {
$writer = Writer::createFromPath($temporaryDirectory->path('organizations.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('organizations.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'name', 'name',
@@ -66,6 +69,9 @@ class ExportService
// Organization invitations // Organization invitations
$writer = Writer::createFromPath($temporaryDirectory->path('organization_invitations.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('organization_invitations.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'email', 'email',
@@ -91,6 +97,9 @@ class ExportService
// Time entries // Time entries
$writer = Writer::createFromPath($temporaryDirectory->path('time_entries.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('time_entries.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'description', 'description',
@@ -139,6 +148,9 @@ class ExportService
// Clients // Clients
$writer = Writer::createFromPath($temporaryDirectory->path('clients.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('clients.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'name', 'name',
@@ -164,6 +176,9 @@ class ExportService
// Projects // Projects
$writer = Writer::createFromPath($temporaryDirectory->path('projects.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('projects.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'name', 'name',
@@ -199,6 +214,9 @@ class ExportService
// Project members // Project members
$writer = Writer::createFromPath($temporaryDirectory->path('project_members.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('project_members.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'billable_rate', 'billable_rate',
@@ -226,6 +244,9 @@ class ExportService
// Members // Members
$writer = Writer::createFromPath($temporaryDirectory->path('members.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('members.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'user_id', 'user_id',
@@ -260,6 +281,9 @@ class ExportService
// Tasks // Tasks
$writer = Writer::createFromPath($temporaryDirectory->path('tasks.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('tasks.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'name', 'name',
@@ -287,6 +311,9 @@ class ExportService
// Tags // Tags
$writer = Writer::createFromPath($temporaryDirectory->path('tags.csv'), 'w+'); $writer = Writer::createFromPath($temporaryDirectory->path('tags.csv'), 'w+');
$writer->setDelimiter(',');
$writer->setEnclosure('"');
$writer->setEscape('');
$writer->insertOne([ $writer->insertOne([
'id', 'id',
'name', 'name',

View File

@@ -31,10 +31,13 @@ class ImportService
$lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1); $lock = Cache::lock('import:'.$organization->getKey(), config('octane.max_execution_time', 60) + 1);
if ($lock->get()) { if ($lock->get()) {
DB::transaction(function () use (&$importer, &$data, &$timezone) { try {
$importer->importData($data, $timezone); DB::transaction(function () use (&$importer, &$data, &$timezone): void {
}); $importer->importData($data, $timezone);
$lock->release(); });
} finally {
$lock->release();
}
} else { } else {
throw new ImportException('Import is already in progress'); throw new ImportException('Import is already in progress');
} }

View File

@@ -47,6 +47,8 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$reader = Reader::createFromString($data); $reader = Reader::createFromString($data);
$reader->setHeaderOffset(0); $reader->setHeaderOffset(0);
$reader->setDelimiter(','); $reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader(); $header = $reader->getHeader();
$this->validateHeader($header); $this->validateHeader($header);
$records = $reader->getRecords(); $records = $reader->getRecords();

View File

@@ -112,8 +112,9 @@ abstract class DefaultImporter implements ImporterContract
'billable_rate' => [ 'billable_rate' => [
'nullable', 'nullable',
'integer', 'integer',
'max:2147483647',
], ],
], beforeSave: function (Project $project) { ], beforeSave: function (Project $project): void {
if ($project->billable_rate === 0) { if ($project->billable_rate === 0) {
$project->billable_rate = null; $project->billable_rate = null;
} }
@@ -125,8 +126,9 @@ abstract class DefaultImporter implements ImporterContract
'billable_rate' => [ 'billable_rate' => [
'nullable', 'nullable',
'integer', 'integer',
'max:2147483647',
], ],
], beforeSave: function (ProjectMember $projectMember) { ], beforeSave: function (ProjectMember $projectMember): void {
if ($projectMember->billable_rate === 0) { if ($projectMember->billable_rate === 0) {
$projectMember->billable_rate = null; $projectMember->billable_rate = null;
} }

View File

@@ -60,6 +60,8 @@ class SolidtimeImporter extends DefaultImporter
$clientsReader = Reader::createFromPath($temporaryDirectory->path('clients.csv')); $clientsReader = Reader::createFromPath($temporaryDirectory->path('clients.csv'));
$clientsReader->setHeaderOffset(0); $clientsReader->setHeaderOffset(0);
$clientsReader->setDelimiter(','); $clientsReader->setDelimiter(',');
$clientsReader->setEnclosure('"');
$clientsReader->setEscape('');
if (! file_exists($temporaryDirectory->path('members.csv'))) { if (! file_exists($temporaryDirectory->path('members.csv'))) {
throw new ImportException('File "members.csv" missing in ZIP'); throw new ImportException('File "members.csv" missing in ZIP');
@@ -67,6 +69,8 @@ class SolidtimeImporter extends DefaultImporter
$membersReader = Reader::createFromPath($temporaryDirectory->path('members.csv')); $membersReader = Reader::createFromPath($temporaryDirectory->path('members.csv'));
$membersReader->setHeaderOffset(0); $membersReader->setHeaderOffset(0);
$membersReader->setDelimiter(','); $membersReader->setDelimiter(',');
$membersReader->setEnclosure('"');
$membersReader->setEscape('');
if (! file_exists($temporaryDirectory->path('organization_invitations.csv'))) { if (! file_exists($temporaryDirectory->path('organization_invitations.csv'))) {
throw new ImportException('File "organization_invitations.csv" missing in ZIP'); throw new ImportException('File "organization_invitations.csv" missing in ZIP');
@@ -74,6 +78,8 @@ class SolidtimeImporter extends DefaultImporter
$organizationInvitationsReader = Reader::createFromPath($temporaryDirectory->path('organization_invitations.csv')); $organizationInvitationsReader = Reader::createFromPath($temporaryDirectory->path('organization_invitations.csv'));
$organizationInvitationsReader->setHeaderOffset(0); $organizationInvitationsReader->setHeaderOffset(0);
$organizationInvitationsReader->setDelimiter(','); $organizationInvitationsReader->setDelimiter(',');
$organizationInvitationsReader->setEnclosure('"');
$organizationInvitationsReader->setEscape('');
if (! file_exists($temporaryDirectory->path('project_members.csv'))) { if (! file_exists($temporaryDirectory->path('project_members.csv'))) {
throw new ImportException('File "project_members.csv" missing in ZIP'); throw new ImportException('File "project_members.csv" missing in ZIP');
@@ -81,6 +87,8 @@ class SolidtimeImporter extends DefaultImporter
$projectMembersReader = Reader::createFromPath($temporaryDirectory->path('project_members.csv')); $projectMembersReader = Reader::createFromPath($temporaryDirectory->path('project_members.csv'));
$projectMembersReader->setHeaderOffset(0); $projectMembersReader->setHeaderOffset(0);
$projectMembersReader->setDelimiter(','); $projectMembersReader->setDelimiter(',');
$projectMembersReader->setEnclosure('"');
$projectMembersReader->setEscape('');
if (! file_exists($temporaryDirectory->path('projects.csv'))) { if (! file_exists($temporaryDirectory->path('projects.csv'))) {
throw new ImportException('File "projects.csv" missing in ZIP'); throw new ImportException('File "projects.csv" missing in ZIP');
@@ -88,6 +96,8 @@ class SolidtimeImporter extends DefaultImporter
$projectsReader = Reader::createFromPath($temporaryDirectory->path('projects.csv')); $projectsReader = Reader::createFromPath($temporaryDirectory->path('projects.csv'));
$projectsReader->setHeaderOffset(0); $projectsReader->setHeaderOffset(0);
$projectsReader->setDelimiter(','); $projectsReader->setDelimiter(',');
$projectsReader->setEnclosure('"');
$projectsReader->setEscape('');
if (! file_exists($temporaryDirectory->path('tags.csv'))) { if (! file_exists($temporaryDirectory->path('tags.csv'))) {
throw new ImportException('File "tags.csv" missing in ZIP'); throw new ImportException('File "tags.csv" missing in ZIP');
@@ -95,6 +105,8 @@ class SolidtimeImporter extends DefaultImporter
$tagsReader = Reader::createFromPath($temporaryDirectory->path('tags.csv')); $tagsReader = Reader::createFromPath($temporaryDirectory->path('tags.csv'));
$tagsReader->setHeaderOffset(0); $tagsReader->setHeaderOffset(0);
$tagsReader->setDelimiter(','); $tagsReader->setDelimiter(',');
$tagsReader->setEnclosure('"');
$tagsReader->setEscape('');
if (! file_exists($temporaryDirectory->path('tasks.csv'))) { if (! file_exists($temporaryDirectory->path('tasks.csv'))) {
throw new ImportException('File "tasks.csv" missing in ZIP'); throw new ImportException('File "tasks.csv" missing in ZIP');
@@ -102,6 +114,8 @@ class SolidtimeImporter extends DefaultImporter
$tasksReader = Reader::createFromPath($temporaryDirectory->path('tasks.csv')); $tasksReader = Reader::createFromPath($temporaryDirectory->path('tasks.csv'));
$tasksReader->setHeaderOffset(0); $tasksReader->setHeaderOffset(0);
$tasksReader->setDelimiter(','); $tasksReader->setDelimiter(',');
$tasksReader->setEnclosure('"');
$tasksReader->setEscape('');
if (! file_exists($temporaryDirectory->path('time_entries.csv'))) { if (! file_exists($temporaryDirectory->path('time_entries.csv'))) {
throw new ImportException('File "time_entries.csv" missing in ZIP'); throw new ImportException('File "time_entries.csv" missing in ZIP');
@@ -109,6 +123,8 @@ class SolidtimeImporter extends DefaultImporter
$timeEntriesReader = Reader::createFromPath($temporaryDirectory->path('time_entries.csv')); $timeEntriesReader = Reader::createFromPath($temporaryDirectory->path('time_entries.csv'));
$timeEntriesReader->setHeaderOffset(0); $timeEntriesReader->setHeaderOffset(0);
$timeEntriesReader->setDelimiter(','); $timeEntriesReader->setDelimiter(',');
$timeEntriesReader->setEnclosure('"');
$timeEntriesReader->setEscape('');
foreach ($clientsReader as $client) { foreach ($clientsReader as $client) {
$this->clientImportHelper->getKey([ $this->clientImportHelper->getKey([

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