Compare commits

...

106 Commits

Author SHA1 Message Date
Gregor Vostrak
4b5aff20fc bump solidtime ui package version to 0.0.13 2025-11-19 17:00:11 +01:00
Gregor Vostrak
9e5aa77e41 fix display problems caused by minimum height of calendar events 2025-11-19 16:46:58 +01:00
Gregor Vostrak
0791a68283 add support for currently running time entry 2025-11-19 16:08:32 +01:00
Gregor Vostrak
e66679274d improve idle indicator colors, fix typescript issues 2025-11-19 13:37:33 +01:00
Gregor Vostrak
717fd35d76 add tooltips to idlestatus indicators 2025-11-18 13:58:30 +01:00
Gregor Vostrak
5a3a5995cc add activity status plugin to calendar 2025-11-17 14:20:04 +01:00
Gregor Vostrak
a8e6d28eab improve initial mount performance for groupedtimeentrytable by streaming in the rows
mounting the rows mounts lots of nested components which results in a delay on the initial mount.
2025-11-13 15:20:30 +01:00
Gregor Vostrak
9c9aeeab0f use container queries for time entry table 2025-11-13 12:24:28 +01:00
Gregor Vostrak
8a1253e101 make sure that CreateTimeEntry modal always starts with times that have 0 seconds 2025-11-12 18:19:27 +01:00
Gregor Vostrak
661fa25da1 prevent seconds update on timepicker when nothing else changes 2025-11-12 18:15:59 +01:00
Gregor Vostrak
d77048a7dd add tooltip component 2025-11-12 18:01:02 +01:00
Gregor Vostrak
4676af9b40 move css variables and tailwind theme config into ui package 2025-11-12 16:49:41 +01:00
Gregor Vostrak
18c8e62228 make sure that timepicker and calendar set seconds to 0 on update, fixes #968 2025-11-12 14:33:56 +01:00
Gregor Vostrak
e7703aef64 move button component to ui package 2025-11-12 14:24:54 +01:00
Gregor Vostrak
86d0497000 design fixes, improve component encapsulation 2025-11-06 14:20:12 +01:00
Gregor Vostrak
522f7d2bd2 move currency and cancreateproject permission to props to decouple TimeEntryCreateModal from web 2025-11-04 16:08:24 +01:00
Gregor Vostrak
2f807e4808 fix package build error dependencies 2025-11-04 15:48:14 +01:00
Gregor Vostrak
93d9db349b bump api and ui package versions 2025-11-04 15:15:26 +01:00
Gregor Vostrak
3417b60585 only run self-hosting update and telemetry scheduler when app_key is set 2025-11-04 13:35:12 +01:00
Constantin Graf
0f21fabd37 Spread self-hosting update and telemetry requests over the day 2025-11-03 20:24:52 +01:00
Gregor Vostrak
df00200464 load current member time entries in calendar, to be consistent with time view 2025-10-22 14:36:21 +02:00
Gregor Vostrak
3b41de7135 remove project default listener in timeentry edit modal 2025-10-22 13:55:06 +02:00
Gregor Vostrak
9fe0ea5a0f add support for HH:mm:ss format for input time fields 2025-10-22 13:54:14 +02:00
Gregor Vostrak
f8f708a664 add set end time functionality to timetracker component 2025-10-21 17:24:46 +02:00
Gregor Vostrak
c359259e45 fix TimeRangeSelector dropdown behaviour when clicking after other input was focused before 2025-10-21 13:50:30 +02:00
Gregor Vostrak
55d12aaae1 add discard option for running timer 2025-10-21 12:49:49 +02:00
Alexander Groß
9a1dd4861c Extend description to 5000 chars, closes #914 2025-10-21 12:36:32 +02:00
Gregor Vostrak
1e985b71ec move Client visibleByEmployee logic from controller to model 2025-10-21 12:22:17 +02:00
Alexander Groß
93d6a86f74 Show clients that are assigned to the employee, closes #893 2025-10-21 12:20:28 +02:00
Gregor Vostrak
19a206d57c add prevent_overlapping_time_entries setting to organization
when enabled users are blocked from creating or editing new time entries that are overlapping with other time entries
2025-10-13 14:23:41 +02:00
Gregor Vostrak
c0788c270b fix typescript openapi mapping types 2025-10-07 17:42:44 +02:00
Gregor Vostrak
7765056074 add tag grouping 2025-10-07 17:15:20 +02:00
Kaspar Rosin
639f5332e4 feat: add duplicate time entry fields 2025-10-07 17:10:22 +02:00
Gregor Vostrak
4a50145329 fix calendar header timezone issue 2025-10-06 19:30:58 +02:00
Gregor Vostrak
8aabffd1e7 fix race condition in UserTimezoneMismatchModal 2025-10-06 18:33:57 +02:00
Gregor Vostrak
b373427dc7 add feedback button in sidebar 2025-10-01 13:20:23 +02:00
Gregor Vostrak
d2a4d60441 clarify UserSettingsIcon Dropdown Profile Settings Item Description 2025-10-01 13:20:23 +02:00
Gregor Vostrak
c3305b3df6 remove bottom padding for toast container
This became redundant due to the floating feedback bubble removal
2025-10-01 13:20:23 +02:00
Gregor Vostrak
7584e59d0b improve focus states and keyboard navigation for organization switcher and user settings dropdown 2025-10-01 13:20:23 +02:00
Gregor Vostrak
d2f75cca6e update organization switcher to use shadcn dropdownmenu 2025-10-01 13:20:23 +02:00
Gregor Vostrak
250379d4bd change profile dropdown to shadcn, add feedback entry 2025-10-01 13:20:23 +02:00
Gregor Vostrak
7f89fd8ea1 fix overflow issues in short calendar events 2025-09-29 12:19:27 +02:00
Gregor Vostrak
0b45f3b473 change create bucket script to work with new minio client versions 2025-09-29 12:09:15 +02:00
Gregor Vostrak
9827a74ae2 lock caddy version to 2.10 to fix docker buiilds 2025-09-08 13:49:43 +02:00
Gregor Vostrak
3425847a44 make time entry create in calendar use minimal interval instead of 1h duration 2025-09-08 13:28:36 +02:00
Gregor Vostrak
47b778fab9 make sure that 0 duration entries are shown correctly in calendar 2025-09-08 13:28:36 +02:00
Gregor Vostrak
85d69f1f16 fix scroll overflow issue in calendar with banner 2025-09-08 13:28:36 +02:00
Gregor Vostrak
fca55fe0e1 improve calendar fetching behaviour to always include prev/next period 2025-09-08 13:28:36 +02:00
Gregor Vostrak
f19abb9db6 make calendar fetch time ranges respect user timezone 2025-09-08 13:28:36 +02:00
Gregor Vostrak
e3bd50ed6b improve contrast of calendar events 2025-09-08 13:28:36 +02:00
Gregor Vostrak
c582530899 add edit time entry dropdown option to timeentryrow 2025-09-08 13:28:36 +02:00
Gregor Vostrak
fb5185a32f fix card background active color contrast in light mode 2025-09-08 13:28:36 +02:00
Gregor Vostrak
0a0854f771 fix recently tracked time entries card placeholders 2025-09-08 13:28:36 +02:00
Gregor Vostrak
4e635cde83 add support for week_start and time_format in calendar
also rename them so that they do not conflict with the datepicker calendar component
2025-09-08 13:28:36 +02:00
Gregor Vostrak
9fa9522237 add calendar view 2025-09-08 13:28:36 +02:00
Gregor Vostrak
04c44097d0 fix duplicated borders in time and detailed reporting view 2025-09-08 13:28:36 +02:00
Gregor Vostrak
3d5a0cb974 add timezone mismatch modal 2025-09-08 13:28:36 +02:00
Constantin Graf
da98e0571c Add on premise build 2025-08-12 16:59:52 +02:00
Constantin Graf
f68f05d1aa Updated the PR template 2025-07-31 14:01:17 +02:00
Gregor Vostrak
8fdc4c1219 add contributing notice that you need to run the format command 2025-07-31 14:01:17 +02:00
Gregor Vostrak
93148299a9 add CONTRIBUTING.md 2025-07-31 14:01:17 +02:00
Constantin Graf
78d2ea1a25 Add API doc description for chart endpoints 2025-07-31 13:43:00 +02:00
Constantin Graf
14f559c4c2 Removed FORWARD_WEB_PORT from local setup 2025-07-31 13:42:37 +02:00
Gregor Vostrak
61fd2b1187 update font-face file names for font loading 2025-07-31 12:08:51 +02:00
Gregor Vostrak
9ea3c5dc29 fix font embeds #864 2025-07-31 11:53:32 +02:00
Gregor Vostrak
cb30487a21 add format check, update prettier rules, apply rules consistently 2025-07-31 11:53:00 +02:00
Constantin Graf
b11672732b Fixed modules service providers 2025-07-23 16:11:34 +02:00
Gregor Vostrak
97dcadc795 add frontend blocking for rounding for non-premium users 2025-07-23 16:09:36 +02:00
Constantin Graf
e7fa414c06 Restrict rounding to premium users 2025-07-23 16:09:36 +02:00
Gregor Vostrak
43073b5be2 fix design inconsistency in timeentryaggregaterow 2025-07-18 16:38:09 +02:00
Gregor Vostrak
9589c9106d e2e: make sure reporting tests do not check the dropdown values when verifying table results 2025-07-17 18:41:48 +02:00
Gregor Vostrak
8a0d2235a8 fix flakyness in e2e tests for reporting 2025-07-17 18:38:21 +02:00
Gregor Vostrak
38f38790d5 change font to inter, scale down fonts, improve rounding/filter elements 2025-07-17 18:38:21 +02:00
Gregor Vostrak
e3cfc155b8 add rounding frontend to reports, and support for shared reports 2025-07-17 18:38:21 +02:00
Constantin Graf
4b726635b2 Add rounding feature 2025-07-17 18:38:21 +02:00
Constantin Graf
e1185af281 Fixed failing tests because of legacy currency codes 2025-07-17 18:16:25 +02:00
Constantin Graf
f9c0d64f82 Add email notifications for expiring api tokens 2025-07-17 18:16:25 +02:00
Constantin Graf
3d58f570bd Fixed Laravel passport migrations 2025-07-17 11:47:34 +02:00
Constantin Graf
400bc434b9 Updated docker image 2025-07-17 11:47:34 +02:00
Constantin Graf
2ab28001be Updated dependencies; Major update laravel passport 2025-07-17 11:47:34 +02:00
Gregor Vostrak
62d2f4bf4e fix broken light mode on oauth page #842 2025-07-15 15:52:55 +02:00
Gregor Vostrak
3d4b20f7c8 make sure time entry information remains visible on mobile views 2025-07-08 18:22:18 +02:00
Gregor Vostrak
155ed62fcc add clearable option to calendardateinput, fix format, add paid_date 2025-07-08 18:22:18 +02:00
Gregor Vostrak
5daa6f2a25 fix last 7 days statistic labels 2025-07-08 18:22:18 +02:00
Constantin Graf
47aa65d959 Add checks for placeholder invitation; Fixed bug in member deletion 2025-07-08 16:49:05 +02:00
Gregor Vostrak
b0e638c28b fix daterange presets, fix e2e test 2025-06-30 12:54:22 +02:00
Gregor Vostrak
24b62d4643 add information about placeholders in delete modal 2025-06-30 12:54:22 +02:00
Gregor Vostrak
dd928508fd add delete modal for member delete with relations
allow admins to delete members
fix Dialog cloes on click outside of content
2025-06-30 12:54:22 +02:00
Constantin Graf
ead9cf2185 Add option to delete members with relations 2025-06-30 12:54:22 +02:00
Gregor Vostrak
7578beb271 fix css variables not updating correctly when system theme changes 2025-06-24 15:43:49 +02:00
Constantin Graf
dc21ac8352 Switch organization after accepting invitation 2025-06-10 11:23:53 +02:00
Constantin Graf
4de7868851 Add postgres version matrix to phpunit tests 2025-06-04 21:43:35 +02:00
dependabot[bot]
ffc016a1ec Bump codecov/codecov-action from 5.4.2 to 5.4.3
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.2 to 5.4.3.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.4.2...v5.4.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-22 18:32:13 +02:00
Constantin Graf
be69626970 Add permissions to all GitHub actions 2025-05-22 11:04:37 +02:00
Gregor Vostrak
f1dce88dab fix time zone issue in daterangepicker 2025-05-21 12:34:02 -07:00
Constantin Graf
15411ec0c8 Add resend verification email to filament resource 2025-05-19 11:50:40 +02:00
Constantin Graf
48f09421d0 Fixed time entries exports for employees #2 2025-05-16 15:14:22 +02:00
Constantin Graf
36caadeb14 Fixed time entries exports for employees 2025-05-16 13:20:23 +02:00
Gregor Vostrak
b4edcaa2dc hide shared reports create for employees, fix export request for employees 2025-05-16 13:20:23 +02:00
Constantin Graf
a3dda8b03c Fixed text for clockify import 2025-05-16 13:03:47 +02:00
Constantin Graf
d64f0c52be Fixed bugs in current organization; Add database consistency checks; Add foreign key 2025-05-16 13:03:47 +02:00
Gregor Vostrak
c80d51c2e1 fix sub_group empty type placeholders showing parent type in shared reports view 2025-05-15 13:34:27 +02:00
Gregor Vostrak
46dea00b34 fix user name not displayed correctly for employee users in reporting 2025-05-15 12:54:30 +02:00
Constantin Graf
16fed4a2b7 Add base request class with generic rule sets 2025-05-14 21:07:54 +02:00
Gregor Vostrak
9a2af2e743 respect organization time format settings in api tokens section 2025-05-14 16:21:37 +02:00
Gregor Vostrak
2e3a517502 improve positioning and overflow behaviour of dialogs 2025-05-14 16:03:32 +02:00
600 changed files with 14714 additions and 9379 deletions

View File

@@ -80,8 +80,7 @@ GOTENBERG_URL=http://gotenberg:3000
# Local setup
NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083
FORWARD_DB_PORT=54329
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
#SAIL_XDEBUG_MODE=develop,debug,coverage

View File

@@ -1,8 +1,11 @@
<!--
This project is early stage. The structure and APIs are still subject to change and not stable.
Therefore, we do not currently accept any contributions, unless you are a member of the team.
## What does this PR do?
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.
-->
- Fixes #XXXX (GitHub issue number)
## Checklist (DO NOT REMOVE)
- [ ] I read the [contributing guide](https://github.com/solidtime-io/solidtime/blob/main/CONTRIBUTING.md)
- [ ] I signed the [Contributor License Agreement](https://cla-assistant.io/solidtime-io/solidtime).
- [ ] I commented my code, particularly in hard-to-understand areas

216
.github/workflows/build-onpremise.yml vendored Normal file
View File

@@ -0,0 +1,216 @@
on:
push:
branches:
- main
- develop
tags:
- '*'
pull_request:
paths:
- '.github/workflows/build-onpremise.yml'
- 'docker/prod/**'
workflow_dispatch:
permissions:
packages: write
contents: read
attestations: write
id-token: write
env:
DOCKER_REPO: registry.on-premise.solidtime.io/solidtime/solidtime
name: Build - On Premise
jobs:
build:
strategy:
matrix:
include:
- runs-on: "ubuntu-24.04-arm"
platform: "linux/arm64"
- runs-on: "ubuntu-24.04"
platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }}
timeout-minutes: 90
steps:
- name: "Check out code"
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
- name: "Get build"
id: release-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: release-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.release-version.outputs.app_version }}/g' .env
- name: "Add build to .env"
run: sed -i 's/APP_BUILD=0/APP_BUILD=${{ steps.release-build.outputs.build }}/g' .env
- name: "Output .env"
run: cat .env
- name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: mbstring, dom, fileinfo, pgsql
- name: "Install dependencies"
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Checkout invoicing extension"
uses: actions/checkout@v4
with:
repository: solidtime-io/extension-invoicing
path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}
- name: "Install composer dependencies in invoicing extension"
run: cd extensions/Invoicing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Install npm dependencies"
run: npm ci
- name: "Build"
run: npm run build
- name: "Prepare"
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: "Docker meta"
id: "meta"
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_REPO }}
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }}
- 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 by digest"
id: build
uses: docker/build-push-action@v6
with:
context: .
file: docker/prod/Dockerfile
build-args: |
DOCKER_FILES_BASE_PATH=docker/prod/
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,"name=${{ env.DOCKER_REPO }}",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha
cache-to: type=gha,mode=max
- name: "Export digest"
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: "Upload digest"
uses: actions/upload-artifact@v4
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
merge:
runs-on: ubuntu-latest
timeout-minutes: 90
needs:
- build
steps:
- name: "Download digests"
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: "Login to solidtime OnPremise Registry"
uses: docker/login-action@v3
with:
registry: registry.on-premise.solidtime.io
username: ${{ secrets.ONPREMISE_USERNAME }}
password: ${{ secrets.ONPREMISE_TOKEN }}
- name: "Set up Docker Buildx"
uses: docker/setup-buildx-action@v3
- name: "Docker meta"
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_REPO }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: "Create manifest list and push"
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKER_REPO }}@sha256:%s ' *)
- name: "Inspect image"
run: |
docker buildx imagetools inspect ${{ env.DOCKER_REPO }}:${{ steps.meta.outputs.version }}

View File

@@ -10,6 +10,8 @@ on:
- '.github/workflows/build-private.yml'
- 'docker/prod/**'
workflow_dispatch:
permissions:
contents: read
name: Build - Private
jobs:
@@ -17,6 +19,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: "Check out code"
uses: actions/checkout@v4

View File

@@ -11,6 +11,12 @@ on:
- 'docker/prod/**'
workflow_dispatch:
permissions:
packages: write
contents: read
attestations: write
id-token: write
env:
DOCKERHUB_REPO: solidtime/solidtime
GHCR_REPO: ghcr.io/solidtime-io/solidtime
@@ -26,11 +32,6 @@ jobs:
- runs-on: "ubuntu-24.04"
platform: "linux/amd64"
runs-on: ${{ matrix.runs-on }}
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
steps:
@@ -163,11 +164,6 @@ jobs:
merge:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
attestations: write
id-token: write
timeout-minutes: 90
needs:
- build

View File

@@ -3,6 +3,9 @@ on:
push:
branches:
- main
permissions:
contents: read
jobs:
api_docs:
runs-on: ubuntu-latest

View File

@@ -1,6 +1,8 @@
name: NPM Build
on: [push]
permissions:
contents: read
jobs:
build:

23
.github/workflows/npm-format-check.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: NPM Format Check
on: [push]
jobs:
format-check:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: "Checkout code"
uses: actions/checkout@v4
- name: "Use Node.js"
uses: actions/setup-node@v4
with:
node-version: '20.x'
- name: "Install npm dependencies"
run: npm ci
- name: "Check code formatting"
run: npm run format:check

View File

@@ -1,6 +1,8 @@
name: NPM Lint
on: [push]
permissions:
contents: read
jobs:
build:

View File

@@ -1,6 +1,8 @@
name: Publish API package to NPM
on:
workflow_dispatch
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest

View File

@@ -1,6 +1,8 @@
name: Publish UI package to NPM
on:
workflow_dispatch
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest

View File

@@ -1,7 +1,8 @@
name: NPM Typecheck
on: [push]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest

View File

@@ -1,5 +1,7 @@
name: Static code analysis (PHPStan)
on: push
permissions:
contents: read
jobs:
phpstan:
runs-on: ubuntu-latest

View File

@@ -1,13 +1,18 @@
name: PHPUnit Tests
on: push
permissions:
contents: read
jobs:
phpunit:
runs-on: ubuntu-latest
timeout-minutes: 10
strategy:
matrix:
postgres_version: [ 15, 16, 17 ]
services:
pgsql_test:
image: postgres:15
image: postgres:${{ matrix.postgres_version }}
env:
PGPASSWORD: 'root'
POSTGRES_DB: 'laravel'
@@ -63,7 +68,7 @@ jobs:
run: php artisan test --stop-on-failure --coverage-text --coverage-clover=coverage.xml
- name: "Upload coverage reports to Codecov"
uses: codecov/codecov-action@v5.4.2
uses: codecov/codecov-action@v5.4.3
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: solidtime-io/solidtime

View File

@@ -1,5 +1,7 @@
name: PHP Linting
on: push
permissions:
contents: read
jobs:
pint:
runs-on: ubuntu-latest

View File

@@ -1,5 +1,7 @@
name: Playwright Tests
on: [push]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest

27
.prettierignore Normal file
View File

@@ -0,0 +1,27 @@
# Ignore build outputs
node_modules/
vendor/
storage/
bootstrap/cache/
public/build/
public/hot/
# Ignore lock files
package-lock.json
composer.lock
# Ignore generated files
*.min.js
*.min.css
# Ignore test results
test-results/
playwright-report/
# Ignore IDE files
.idea/
.vscode/
# Ignore OS files
.DS_Store
Thumbs.db

View File

@@ -3,5 +3,6 @@
"tabWidth": 4,
"singleQuote": true,
"bracketSameLine": true,
"quoteProps": "preserve"
"quoteProps": "preserve",
"printWidth": 100
}

81
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,81 @@
# Contributing to solidtime
Contributions are greatly apprecited, please make sure to read the rules and vision for solidtime before contributing.
## Rules
### Issues for Bugs, Discussions for Feature requests
In order to keep the issues of the repository clean we decided to only use them for bugs. Feature Requests and enhancement are handled in discussions. This also helps us to see which feature requests are popular as they can be upvoted.
### Only work on approved issues
To respect your time and help us manage contributions effectively, please open an issue or start a discussion and wait for approval before submitting a pull request (PR). This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
### Contributor License Agreement
You'll also notice that weve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Dont worry - the process is quick and only takes a few clicks.
We want to be transparent about why we require the CLA and what it means for your contributions and the codebase. Thats why weve written a few paragraphs below outlining our plans and vision for solidtime in the **Vision** part of this document.
### Prevent Duplicate Work
Before you submit a new PR, make sure that none exists already. If you plan to work on an issue, make sure to let us and others know by commenting on the issue/discussion.
### Give context
Tell us what you thinking was behind the decisions you made while drafting the PR. Treat the PR itself as documentation for everyone who wants to go back and understand why certain decisions were made.
### Summarize your PR
Please make sure to include a short summary at the top of your PR to make it easy for us to quickly check what the PR is about, without looking at the code changes.
### Use Github Keywords and Auto-Link Issues
Use phrases like "Closes #123" or "Fixes #123" in the PR description to link the PR with the issue that you are adressing.
### Mention what you tested and how
Explain how you tested and validated the implementation.
### Keep Naming consistent
Look at existing code patterns and use naming conventions that already exist in the code base.
### Testing
We have an exhaustive test-suite of PHPUnit (Backend) and Playwright (Frontend) testing. Whereever applicable please make sure to write add tests to the codebase.
### Linting & Formatting
Make sure to run linting and formatting commands before you commit the changes.
For backend changes:
```
composer fix
composer analyse
```
For frontend changes:
```
npm run lint:fix
npm run format
```
## Vision
We started solidtime to provide an open infrastructure solution for time tracking—one that empowers teams and individuals to fully own their data, instead of depending on proprietary platforms. We believe infrastructure software should be open, accessible, and built to last. However, competing with established market leaders in this space requires long-term financial sustainability.
solidtime is licensed under the AGPL, which we believe is the best available license to strike a balance between openness and financial viability. The AGPL gives us, as the copyright holders, certain exclusive rights that we plan to leverage to fund development. To ensure we retain those rights across the entire codebase, we've put a CLA in place that contributors must sign before submitting code.
One of solidtimes key advantages is that it's built to be self-hostable. This makes it a great solution for organizations like governments, healthcare providers, and enterprises that are required to keep data on their own infrastructure due to regulations or internal policies. These organizations may need custom licenses, integrations, or modifications that aren't suitable for the open-source version. To support them, we offer relicensed versions of solidtime along with support plans.
Well also provide proprietary extensions for solidtime. These will be available to enterprise customers with support plans, but also to individual users or teams who dont need support, at much more accessible price points. For companies running solidtime on their own infrastructure, this is the easiest way to support the project while gaining additional functionality. While we plan to make it easier to build custom extensions in the future, our current APIs are still highly experimental.
Finally - and perhaps most importantly - we offer a hosted SaaS version called solidtime Cloud, for users who cant or dont want to run the software themselves. This version includes proprietary extensions, always runs the latest commit, and includes monitoring and billing features available exclusively on this hosted instance. We expect solidtime Cloud to play a critical role in funding the project long-term.
Having full control over the source codes licensing also gives us the ability to change the license of the main project in the future. That said, we have no plans to do so and would only consider it in extreme cases - for example, if a malicious actor were to directly compete with our hosted service in a way that threatens the sustainability of the project, the legal interpretation of AGPL changes in a way that would make it unreasonable to use for certain companies, or a new similar license gains wide-spread adoption. Regardless, solidtime will always remain free to self-host for individuals and companies who use it as part of their work, and all previous releases will remain licensed under AGPL.
If you are using the open-source version of solidtime and want to support us, the best way to do so is to spread the word.

View File

@@ -35,10 +35,9 @@ If you have a **feature request**, please [**create a discussion**](https://gith
## Contributing
This project is in a very early stage. The structure and APIs are still subject to change and not stable.
Therefore, we do not currently accept any contributions, unless you are a member of the team.
Please open an issue or start a discussion and wait for approval before submitting a pull request. This does not apply to tiny fixes or changes however, please keep in mind that we might not merge PRs for various reasons.
As soon as we feel comfortable enough that the application structure is stable enough, we will open up the project for contributions.
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

View File

@@ -26,7 +26,7 @@ class CreateNewUser implements CreatesNewUsers
/**
* Create a newly registered user.
*
* @param array<string, string> $input
* @param array<string, mixed> $input
*
* @throws ValidationException
*/

View File

@@ -6,7 +6,6 @@ namespace App\Actions\Fortify;
use App\Enums\Weekday;
use App\Models\User;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
@@ -59,8 +58,7 @@ class UpdateUserProfileInformation implements UpdatesUserProfileInformation
$user->updateProfilePhoto($input['photo']);
}
if ($input['email'] !== $user->email &&
$user instanceof MustVerifyEmail) {
if ($input['email'] !== $user->email) {
$user->forceFill([
'name' => $input['name'],
'email' => $input['email'],

View File

@@ -57,7 +57,7 @@ class AddOrganizationMember implements AddsTeamMembers
*/
protected function rules(): array
{
return array_filter([
return [
'email' => [
'required',
'email',
@@ -75,7 +75,7 @@ class AddOrganizationMember implements AddsTeamMembers
Role::Employee->value,
]),
],
]);
];
}
/**

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Auth;
use App\Mail\AuthApiTokenExpirationReminderMail;
use App\Mail\AuthApiTokenExpiredMail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Mail;
class AuthSendReminderForExpiringApiTokensCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'auth:send-mails-expiring-api-tokens '.
' { --dry-run : Do not actually send emails or save anything to the database, just output what would happen }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Sends emails about expiring API tokens, one week before and when they expired.';
/**
* Execute the console command.
*/
public function handle(): int
{
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->comment('Running in dry-run mode. No emails will be sent and nothing will be saved to the database.');
}
$this->comment('Sending reminder emails about expiring API tokens...');
$sentMails = 0;
Token::query()
->where('expires_at', '<=', Carbon::now()->addDays(7))
->whereNull('reminder_sent_at')
->with([
'client',
'user',
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
})
->isApiToken(true)
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
/** @var Collection<int, Token> $tokens */
foreach ($tokens as $token) {
$user = $token->user;
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') reminding about API token '.$token->getKey());
$sentMails++;
if (! $dryRun) {
Mail::to($user->email)
->queue(new AuthApiTokenExpirationReminderMail($token, $user));
$token->reminder_sent_at = Carbon::now();
$token->save();
}
}
});
$this->comment('Finished sending '.$sentMails.' expiring API token emails...');
$this->comment('Sent emails about expired API tokens');
$sentMails = 0;
Token::query()
->where('expires_at', '<=', Carbon::now())
->whereNull('expired_info_sent_at')
->with([
'client',
'user',
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false);
})
->isApiToken(true)
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $tokens) use ($dryRun, &$sentMails): void {
/** @var Collection<int, Token> $tokens */
foreach ($tokens as $token) {
$user = $token->user;
$this->info('Start sending email to user "'.$user->email.'" ('.$user->getKey().') about expired API token '.$token->getKey());
$sentMails++;
if (! $dryRun) {
Mail::to($user->email)
->queue(new AuthApiTokenExpiredMail($token, $user));
$token->expired_info_sent_at = Carbon::now();
$token->save();
}
}
});
$this->comment('Finished sending '.$sentMails.' expired API token emails...');
return self::SUCCESS;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\SelfHost;
use Illuminate\Console\Command;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class SelfHostDatabaseConsistency extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'self-host:database-consistency';
/**
* The console command description.
*
* @var string
*/
protected $description = '';
/**
* Execute the console command.
*/
public function handle(): int
{
$hadAProblem = false;
// Task need to be part of project in time entries
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('tasks', 'time_entries.task_id', '=', 'tasks.id')
->where('tasks.project_id', '!=', DB::raw('time_entries.project_id'))
->get();
$this->logProblems($problems, 'Time entries have a task that does not belong to the project of the time entry', $hadAProblem);
// Client id is the client id of the project
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->join('projects', 'time_entries.project_id', '=', 'projects.id')
->where(DB::raw('coalesce(projects.client_id::varchar, \'\')'), '!=', DB::raw('coalesce(time_entries.client_id::varchar, \'\')'))
->get();
$this->logProblems($problems, 'Time entries have a client that does not match the client of the project', $hadAProblem);
// Client id can only be not null if the project id is not null
$problems = DB::table('time_entries')
->select(['time_entries.id as id'])
->whereNotNull('client_id')
->whereNull('project_id')
->get();
$this->logProblems($problems, 'Time entries have a client but no project', $hadAProblem);
// Every user needs to be a member of at least one organization
$problems = DB::table('users')
->select(['users.id as id'])
->leftJoin('members', 'users.id', '=', 'members.user_id')
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Users are not member of any organization', $hadAProblem);
// Every organization needs at least an owner
$problems = DB::table('organizations')
->select(['organizations.id as id'])
->leftJoin('members', function (JoinClause $join): void {
$join->on('organizations.id', '=', 'members.organization_id')
->where('members.role', '=', 'owner');
})
->whereNull('members.id')
->get();
$this->logProblems($problems, 'Organizations without an owner', $hadAProblem);
// Every member can only have one running time entry
$problems = DB::table('time_entries')
->select(['user_id as id'])
->whereNull('end')
->groupBy('user_id')
->havingRaw('count(*) > 1')
->get(['user_id', DB::raw('count(*) as count')]);
$this->logProblems($problems, 'Users with more than one running time entry', $hadAProblem);
// Users have a current organization that they are not a member of
$problems = DB::table('users')
->select(['users.id as id'])
->whereNotNull('current_team_id')
->whereNotIn('current_team_id', function (Builder $query): void {
$query->select('organization_id')
->from('members')
->whereColumn('members.user_id', 'users.id');
})->get();
$this->logProblems($problems, 'Users have a current organization that they are not a member of', $hadAProblem);
return $hadAProblem ? self::FAILURE : self::SUCCESS;
}
/**
* @param Collection<int, \stdClass> $problems
*/
private function logProblems(Collection $problems, string $message, bool &$hadAProblem): void
{
$message = 'Consistency problem: '.$message;
if ($problems->isNotEmpty()) {
$ids = $problems->pluck('id');
$hadAProblem = true;
Log::error($message, [
'ids' => $ids,
]);
$error = $message;
foreach ($ids as $id) {
$error .= "\n - ".$id;
}
$this->error($error);
}
}
}

View File

@@ -18,13 +18,35 @@ class Kernel extends ConsoleKernel
->when(fn (): bool => config('scheduling.tasks.time_entry_send_still_running_mails'))
->everyTenMinutes();
$schedule->command('self-host:check-for-update')
->when(fn (): bool => config('scheduling.tasks.self_hosting_check_for_update'))
->twiceDaily();
$schedule->command('auth:send-mails-expiring-api-tokens')
->when(fn (): bool => config('scheduling.tasks.auth_send_mails_expiring_api_tokens'))
->everyTenMinutes();
$schedule->command('self-host:telemetry')
->when(fn (): bool => config('scheduling.tasks.self_hosting_telemetry'))
->twiceDaily();
if (config('app.key') && (config('scheduling.tasks.self_hosting_check_for_update') || config('scheduling.tasks.self_hosting_telemetry'))) {
// Convert string to a stable integer for seeding
/** @var int $seed Take the first 8 hex chars → 32-bit int */
$seed = hexdec(substr(hash('md5', config('app.key')), 0, 8));
$seed = abs($seed); // Ensure it's positive
mt_srand($seed);
$firstHour = mt_rand(0, 23);
$secondHour = ($firstHour + 12) % 24;
$minuteOffset = mt_rand(0, 59);
mt_srand(null); // Reset the random number generator
if (config('scheduling.tasks.self_hosting_check_for_update')) {
$schedule->command('self-host:check-for-update')
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
}
if (config('scheduling.tasks.self_hosting_telemetry')) {
$schedule->command('self-host:telemetry')
->twiceDailyAt($firstHour, $secondHour, $minuteOffset);
}
}
$schedule->command('self-host:database-consistency')
->when(fn (): bool => config('scheduling.tasks.self_hosting_database_consistency'))
->everySixHours();
}
/**

View File

@@ -20,6 +20,7 @@ enum TimeEntryAggregationType: string
case Client = 'client';
case Billable = 'billable';
case Description = 'description';
case Tag = 'tag';
public static function fromInterval(TimeEntryAggregationTypeInterval $timeEntryAggregationTypeInterval): TimeEntryAggregationType
{

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryRoundingType: string
{
use LaravelEnumHelper;
case Up = 'up';
case Down = 'down';
case Nearest = 'nearest';
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederAfterSeed
{
use Dispatchable;
public function __construct() {}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Events;
use Illuminate\Foundation\Events\Dispatchable;
class DatabaseSeederBeforeDelete
{
use Dispatchable;
public function __construct() {}
}

View File

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

View File

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

View File

@@ -41,9 +41,7 @@ class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
return null;
}
if (! ($collectingType = $this->openApiTransformer->transform($collectingClassType))) {
return null;
}
$collectingType = $this->openApiTransformer->transform($collectingClassType);
$newType = new OpenApiObjectType;
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));

View File

@@ -15,6 +15,7 @@ use Filament\Resources\Resource;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\BulkAction;
use Filament\Tables\Actions\DeleteAction;
use Filament\Tables\Actions\DeleteBulkAction;
use Filament\Tables\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -75,7 +76,8 @@ class FailedJobResource extends Resource
->filters([])
->bulkActions([
BulkAction::make('retry')
->label('Retry')
->icon('heroicon-o-arrow-path')
->label('Retry selected')
->requiresConfirmation()
->action(function (Collection $records): void {
/** @var FailedJob $record */
@@ -87,11 +89,13 @@ class FailedJobResource extends Resource
->success()
->send();
}),
DeleteBulkAction::make(),
])
->actions([
DeleteAction::make('Delete'),
ViewAction::make('View'),
DeleteAction::make(),
ViewAction::make(),
Action::make('retry')
->icon('heroicon-o-arrow-path')
->label('Retry')
->requiresConfirmation()
->action(function (FailedJob $record): void {
@@ -109,7 +113,6 @@ class FailedJobResource extends Resource
return [
'index' => ListFailedJobs::route('/'),
'view' => ViewFailedJobs::route('/{record}'),
];
}
}

View File

@@ -6,8 +6,8 @@ namespace App\Filament\Resources\FailedJobResource\Pages;
use App\Filament\Resources\FailedJobResource;
use App\Models\FailedJob;
use Filament\Actions\Action;
use Filament\Notifications\Notification;
use Filament\Pages\Actions\Action;
use Filament\Resources\Pages\ListRecords;
use Illuminate\Support\Facades\Artisan;
@@ -19,7 +19,8 @@ class ListFailedJobs extends ListRecords
{
return [
Action::make('retry_all')
->label('Retry all failed Jobs')
->icon('heroicon-o-arrow-path')
->label('Retry all')
->requiresConfirmation()
->action(function (): void {
Artisan::call('queue:retry all');
@@ -30,7 +31,8 @@ class ListFailedJobs extends ListRecords
}),
Action::make('delete_all')
->label('Delete all failed Jobs')
->icon('heroicon-o-trash')
->label('Delete all')
->requiresConfirmation()
->color('danger')
->action(function (): void {

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\TokenResource\Pages;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use Filament\Forms;
use Filament\Forms\Form;
@@ -40,7 +39,7 @@ class TokenResource extends Resource
->label('Name')
->required()
->maxLength(255),
Forms\Components\Select::make('user_id')
Forms\Components\Select::make('owner_id')
->label('User')
->relationship(name: 'user', titleAttribute: 'name')
->searchable(['name'])
@@ -79,10 +78,12 @@ class TokenResource extends Resource
Tables\Columns\TextColumn::make('client.name')
->searchable()
->sortable(),
Tables\Columns\IconColumn::make('client.personal_access_client')
Tables\Columns\IconColumn::make('personal_access_client')
->state(function (Token $token): bool {
return in_array('personal_access', $token->client->grant_types ?? [], true);
})
->boolean()
->label('API token?')
->sortable(),
->label('API token?'),
Tables\Columns\IconColumn::make('revoked')
->boolean()
->label('Revoked?')
@@ -104,17 +105,11 @@ class TokenResource extends Resource
->queries(
true: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->where('personal_access_client', true);
});
return $query->isApiToken();
},
false: function (Builder $query) {
/** @var Builder<Token> $query */
return $query->whereHas('client', function (Builder $query) {
/** @var Builder<Client> $query */
return $query->where('personal_access_client', false);
});
return $query->isApiToken(false);
},
blank: function (Builder $query) {
/** @var Builder<Token> $query */

View File

@@ -23,6 +23,7 @@ use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -207,6 +208,14 @@ class UserResource extends Resource
}),
])
->bulkActions([
Tables\Actions\BulkAction::make('Resend verification email')
->icon('heroicon-o-paper-airplane')
->action(function (Collection $records): void {
foreach ($records as $user) {
/** @var User $user */
$user->sendEmailVerificationNotification();
}
}),
]);
}

View File

@@ -8,9 +8,12 @@ use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
use App\Http\Requests\V1\ApiToken\ApiTokenStoreRequest;
use App\Http\Resources\V1\ApiToken\ApiTokenCollection;
use App\Http\Resources\V1\ApiToken\ApiTokenWithAccessTokenResource;
use App\Models\Passport\Client;
use App\Models\Passport\Token;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Str;
class ApiTokenController extends Controller
{
@@ -28,7 +31,10 @@ class ApiTokenController extends Controller
$user = $this->user();
$tokens = $user->tokens()
->where('client_id', '=', config('passport.personal_access_client.id'))
->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
})
->get();
return new ApiTokenCollection($tokens);
@@ -48,15 +54,21 @@ class ApiTokenController extends Controller
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
try {
$token = $user->createToken($request->getName(), ['*']);
/** @var Token $tokenModel */
$tokenModel = $token->getToken();
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
} catch (\RuntimeException $exception) {
report($exception);
if (Str::contains($exception->getMessage(), ['Personal access client not found'])) {
throw new PersonalAccessClientIsNotConfiguredException;
}
throw $exception;
}
$token = $user->createToken($request->getName(), ['*']);
/** @var Token $tokenModel */
$tokenModel = $token->token;
return new ApiTokenWithAccessTokenResource($tokenModel, $token->accessToken);
}
/**
@@ -71,13 +83,10 @@ class ApiTokenController extends Controller
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user');
}
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) {
throw new AuthorizationException('API token is not a personal access token');
}
@@ -97,13 +106,10 @@ class ApiTokenController extends Controller
{
$user = $this->user();
if (config('passport.personal_access_client.id') === null || config('passport.personal_access_client.secret') === null) {
throw new PersonalAccessClientIsNotConfiguredException;
}
if ($apiToken->user_id !== $user->getKey()) {
throw new AuthorizationException('API token does not belong to user');
}
if ($apiToken->client_id !== config('passport.personal_access_client.id')) {
if (! ($apiToken->client?->hasGrantType('personal_access') ?? false)) {
throw new AuthorizationException('API token is not a personal access token');
}

View File

@@ -14,6 +14,8 @@ use Illuminate\Http\JsonResponse;
class ChartController extends Controller
{
/**
* Get chart data for the weekly project overview.
*
* @throws AuthorizationException
*
* @operationId weeklyProjectOverview
@@ -31,6 +33,8 @@ class ChartController extends Controller
}
/**
* Get chart data for the latest tasks.
*
* @throws AuthorizationException
*
* @operationId latestTasks
@@ -48,6 +52,8 @@ class ChartController extends Controller
}
/**
* Get chart data for the last seven days.
*
* @throws AuthorizationException
*
* @operationId lastSevenDays
@@ -65,6 +71,8 @@ class ChartController extends Controller
}
/**
* Get chart data for the latest team activity.
*
* @throws AuthorizationException
*
* @operationId latestTeamActivity
@@ -81,6 +89,8 @@ class ChartController extends Controller
}
/**
* Get chart data for daily tracked hours.
*
* @throws AuthorizationException
*
* @operationId dailyTrackedHours
@@ -98,6 +108,8 @@ class ChartController extends Controller
}
/**
* Get chart data for total weekly time.
*
* @throws AuthorizationException
*
* @operationId totalWeeklyTime
@@ -115,6 +127,8 @@ class ChartController extends Controller
}
/**
* Get chart data for total weekly billable time.
*
* @throws AuthorizationException
*
* @operationId totalWeeklyBillableTime
@@ -132,6 +146,8 @@ class ChartController extends Controller
}
/**
* Get chart data for total weekly billable amount.
*
* @throws AuthorizationException
*
* @operationId totalWeeklyBillableAmount
@@ -154,6 +170,8 @@ class ChartController extends Controller
}
/**
* Get chart data for weekly history.
*
* @throws AuthorizationException
*
* @operationId weeklyHistory

View File

@@ -38,11 +38,17 @@ class ClientController extends Controller
public function index(Organization $organization, ClientIndexRequest $request): ClientCollection
{
$this->checkPermission($organization, 'clients:view');
$canViewAllClients = $this->hasPermission($organization, 'clients:view:all');
$user = $this->user();
$clientsQuery = Client::query()
->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc');
if (! $canViewAllClients) {
$clientsQuery->visibleByEmployee($user);
}
$filterArchived = $request->getFilterArchived();
if ($filterArchived === 'true') {
$clientsQuery->whereNotNull('archived_at');

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
@@ -50,6 +51,7 @@ class InvitationController extends Controller
*
* @throws AuthorizationException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws InvitationForTheEmailAlreadyExistsApiException
*
* @operationId invite
*/

View File

@@ -10,12 +10,14 @@ use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\InvitationForTheEmailAlreadyExistsApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\Member\MemberDestroyRequest;
use App\Http\Requests\V1\Member\MemberIndexRequest;
use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
use App\Http\Requests\V1\Member\MemberUpdateRequest;
@@ -100,11 +102,13 @@ class MemberController extends Controller
*
* @operationId removeMember
*/
public function destroy(Organization $organization, Member $member, MemberService $memberService): JsonResponse
public function destroy(MemberDestroyRequest $request, Organization $organization, Member $member, MemberService $memberService): JsonResponse
{
$this->checkPermission($organization, 'members:delete', $member);
$memberService->removeMember($member, $organization);
$deleteRelated = $request->getDeleteRelated();
$memberService->removeMember($member, $organization, $deleteRelated);
return response()
->json(null, 204);
@@ -170,6 +174,7 @@ class MemberController extends Controller
* @throws UserNotPlaceholderApiException
* @throws UserIsAlreadyMemberOfOrganizationApiException
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
* @throws InvitationForTheEmailAlreadyExistsApiException
*
* @operationId invitePlaceholder
*/

View File

@@ -61,6 +61,9 @@ class OrganizationController extends Controller
if ($request->getTimeFormat() !== null) {
$organization->time_format = $request->getTimeFormat();
}
if ($request->getPreventOverlappingTimeEntries() !== null) {
$organization->prevent_overlapping_time_entries = $request->getPreventOverlappingTimeEntries();
}
$hasBillableRate = $request->has('billable_rate');
if ($hasBillableRate) {
$oldBillableRate = $organization->billable_rate;

View File

@@ -73,7 +73,9 @@ class ReportController extends Controller
false,
$report->properties->start,
$report->properties->end,
true
true,
$report->properties->roundingType,
$report->properties->roundingMinutes,
);
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesQuery->clone(),
@@ -84,7 +86,9 @@ class ReportController extends Controller
true,
$report->properties->start,
$report->properties->end,
true
true,
$report->properties->roundingType,
$report->properties->roundingMinutes,
);
return new DetailedWithDataReportResource($report, $data, $historyData);

View File

@@ -107,6 +107,8 @@ class ReportController extends Controller
}
}
$properties->timezone = $timezone;
$properties->roundingType = $request->getPropertyRoundingType();
$properties->roundingMinutes = $request->getPropertyRoundingMinutes();
$report->properties = $properties;
if ($isPublic) {
$report->share_secret = $reportService->generateSecret();

View File

@@ -7,6 +7,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
@@ -33,6 +34,7 @@ use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\ReportExport\TimeEntriesReportExport;
use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter;
use App\Service\TimeEntryService;
use App\Service\TimezoneService;
use Gotenberg\Exceptions\GotenbergApiErrored;
use Gotenberg\Exceptions\NoOutputFileInResponse;
@@ -44,9 +46,11 @@ use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\File;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
@@ -54,6 +58,43 @@ use Spatie\TemporaryDirectory\TemporaryDirectory;
class TimeEntryController extends Controller
{
private function assertNoOverlap(Organization $organization, Member $member, \Illuminate\Support\Carbon $start, ?\Illuminate\Support\Carbon $end, ?TimeEntry $exclude = null): void
{
if (! $organization->prevent_overlapping_time_entries) {
return;
}
$query = TimeEntry::query()
->where('organization_id', $organization->getKey())
->where('user_id', $member->user_id)
->when($exclude !== null, function (Builder $q) use ($exclude): void {
$q->where('id', '!=', $exclude->getKey());
})
->where(function (Builder $q) use ($start, $end): void {
$q->where(function (Builder $q2) use ($start): void {
$q2->where('end', '>', $start)
->where('start', '<', $start);
});
if ($end !== null) {
$q->orWhere(function (Builder $q4) use ($end): void {
$q4->where('start', '<', $end)
->where('end', '>', $end);
});
// Check if the new entry completely surrounds an existing entry
$q->orWhere(function (Builder $q6) use ($start, $end): void {
$q6->where('start', '>=', $start)
->where('end', '<=', $end);
});
}
});
if ($query->exists()) {
throw new OverlappingTimeEntryApiException;
}
}
protected function checkPermission(Organization $organization, string $permission, ?TimeEntry $timeEntry = null): void
{
parent::checkPermission($organization, $permission);
@@ -84,7 +125,8 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all');
}
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$totalCount = $timeEntriesQuery->count();
@@ -138,10 +180,19 @@ class TimeEntryController extends Controller
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member, bool $canAccessPremiumFeatures): Builder
{
$select = TimeEntry::SELECT_COLUMNS;
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
if ($roundingType !== null && $roundingMinutes !== null) {
$select = array_diff($select, ['start', 'end']);
$select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes).' as start');
$select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes).' as end');
}
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->select($select)
->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery);
@@ -175,16 +226,19 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$debug = $request->getDebug();
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
if ($format === ExportFormat::PDF && ! $canAccessPremiumFeatures) {
throw new FeatureIsNotAvailableInFreePlanApiException;
}
$user = $this->user();
$timezone = $user->timezone;
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery->with([
'task',
'client',
@@ -207,8 +261,9 @@ class TimeEntryController extends Controller
if ($viewFile === false) {
throw new \LogicException('View file not found');
}
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesQuery->clone()->reorder()->withOnly([]),
$timeEntriesAggregateQuery,
null,
null,
$user->timezone,
@@ -216,7 +271,9 @@ class TimeEntryController extends Controller
false,
null,
null,
$showBillableRate
$showBillableRate,
$roundingType,
$roundingMinutes,
);
$html = Blade::render($viewFile, [
'timeEntries' => $timeEntriesQuery->get(),
@@ -226,6 +283,7 @@ class TimeEntryController extends Controller
'start' => $request->getStart()->timezone($timezone),
'end' => $request->getEnd()->timezone($timezone),
'localization' => $localizationService,
'showBillableRate' => $showBillableRate,
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-index/pdf-footer.blade.php'));
if ($footerViewFile === false) {
@@ -317,12 +375,15 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$user = $this->user();
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
$group1Type = $request->getGroup();
$group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery,
@@ -333,7 +394,9 @@ class TimeEntryController extends Controller
$request->getFillGapsInTimeGroups(),
$request->getStart(),
$request->getEnd(),
$showBillableRate
$showBillableRate,
$roundingType,
$roundingMinutes
);
return [
@@ -361,6 +424,7 @@ class TimeEntryController extends Controller
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$format = $request->getFormatValue();
if ($format === ExportFormat::PDF && ! $this->canAccessPremiumFeatures($organization)) {
throw new FeatureIsNotAvailableInFreePlanApiException;
@@ -372,6 +436,8 @@ class TimeEntryController extends Controller
$group = $request->getGroup();
$subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
$timeEntriesAggregateQuery->clone(),
@@ -382,7 +448,9 @@ class TimeEntryController extends Controller
false,
$request->getStart(),
$request->getEnd(),
$showBillableRate
$showBillableRate,
$roundingType,
$roundingMinutes
);
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery->clone(),
@@ -393,7 +461,9 @@ class TimeEntryController extends Controller
true,
$request->getStart(),
$request->getEnd(),
$showBillableRate
$showBillableRate,
$roundingType,
$roundingMinutes
);
$currency = $organization->currency;
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
@@ -428,6 +498,7 @@ class TimeEntryController extends Controller
'end' => $request->getEnd()->timezone($timezone),
'debug' => $debug,
'localization' => $localizationService,
'showBillableRate' => $showBillableRate,
]);
$footerViewFile = file_get_contents(resource_path('views/reports/time-entry-aggregate/pdf-footer.blade.php'));
if ($footerViewFile === false) {
@@ -456,7 +527,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else {
Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup),
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
@@ -475,7 +546,7 @@ class TimeEntryController extends Controller
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder
private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
{
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization');
@@ -517,17 +588,15 @@ class TimeEntryController extends Controller
throw new TimeEntryStillRunningApiException;
}
// Overlap check for create
$start = Carbon::parse($request->input('start'));
$end = $request->input('end') !== null ? Carbon::parse($request->input('end')) : null;
$this->assertNoOverlap($organization, $member, $start, $end);
$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->fill($request->validated());
$timeEntry->client()->associate($client);
@@ -537,6 +606,13 @@ class TimeEntryController extends Controller
$timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save();
if ($project !== null) {
RecalculateSpentTimeForProject::dispatch($project);
}
if ($task !== null) {
RecalculateSpentTimeForTask::dispatch($task);
}
return new TimeEntryResource($timeEntry);
}
@@ -561,6 +637,13 @@ class TimeEntryController extends Controller
throw new TimeEntryCanNotBeRestartedApiException;
}
// Overlap check for update (exclude current)
/** @var Member $effectiveMember */
$effectiveMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : $timeEntry->member;
$effectiveStart = $request->has('start') ? Carbon::parse($request->input('start')) : $timeEntry->start;
$effectiveEnd = $request->has('end') ? ($request->input('end') !== null ? Carbon::parse($request->input('end')) : null) : $timeEntry->end;
$this->assertNoOverlap($organization, $effectiveMember, $effectiveStart, $effectiveEnd, $timeEntry);
$oldProject = $timeEntry->project;
$oldTask = $timeEntry->task;

View File

@@ -43,7 +43,10 @@ class Controller extends BaseController
/** @var Member|null $member */
$member = Member::query()->whereBelongsTo($organization, 'organization')->whereBelongsTo($user, 'user')->first();
if ($member === null) {
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization');
Log::error('This function should only be called in authenticated context after checking the user is a member of the organization', [
'user' => $user->getKey(),
'organization' => $organization->getKey(),
]);
throw new AuthorizationException;
}

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Middleware;
use Closure;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Redirect;
use Illuminate\Support\Facades\URL;
@@ -20,8 +19,7 @@ class EnsureEmailIsVerified
{
if (! app()->isLocal()) {
if ($request->user() === null ||
($request->user() instanceof MustVerifyEmail &&
! $request->user()->hasVerifiedEmail())) {
(! $request->user()->hasVerifiedEmail())) {
return $request->expectsJson()
? abort(403, 'Your email address is not verified.')
: Redirect::guest(URL::route($redirectToRoute ?: 'verification.notice'));

View File

@@ -41,6 +41,7 @@ class HandleInertiaRequests extends Middleware
{
$hasBilling = Module::has('Billing') && Module::isEnabled('Billing');
$hasInvoicing = Module::has('Invoicing') && Module::isEnabled('Invoicing');
$hasServices = Module::has('Services') && Module::isEnabled('Services');
/** @var BillingContract $billing */
$billing = app(BillingContract::class);
@@ -50,7 +51,8 @@ class HandleInertiaRequests extends Middleware
return array_merge(parent::share($request), [
'has_billing_extension' => $hasBilling,
'has_invoicing_extension' => $hasInvoicing,
'billing' => $billing !== null && $currentOrganization !== null ? [
'has_services_extension' => $hasServices,
'billing' => $currentOrganization !== null ? [
'has_subscription' => $billing->hasSubscription($currentOrganization),
'has_trial' => $billing->hasTrial($currentOrganization),
'trial_until' => $billing->getTrialUntil($currentOrganization)?->toIso8601ZuluString(),

View File

@@ -26,7 +26,7 @@ class ShareInertiaData
{
/** @var PermissionStore $permissions */
$permissions = app(PermissionStore::class);
Inertia::share(array_filter([
Inertia::share([
'jetstream' => function () use ($request) {
/** @var User|null $user */
$user = $request->user();
@@ -101,7 +101,7 @@ class ShareInertiaData
return [$key => $bag->messages()];
})->all();
},
]));
]);
return $next($request);
}

View File

@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ApiToken;
use Illuminate\Foundation\Http\FormRequest;
use App\Http\Requests\V1\BaseFormRequest;
class ApiTokenStoreRequest extends FormRequest
class ApiTokenStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1;
use Illuminate\Foundation\Http\FormRequest;
class BaseFormRequest extends FormRequest
{
/**
* @return list<string>
*/
protected function moneyRules(bool $bigInt = false): array
{
$rules = [
'integer',
'min:0',
];
if ($bigInt) {
$rules[] = 'max:9223372036854775807';
} else {
$rules[] = 'max:2147483647';
}
return $rules;
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ClientIndexRequest extends FormRequest
class ClientIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class ClientStoreRequest extends FormRequest
class ClientStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Client;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Client|null $client Client from model binding
*/
class ClientUpdateRequest extends FormRequest
class ClientUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Import;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ImportRequest extends FormRequest
class ImportRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Invitation;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization
*/
class InvitationIndexRequest extends FormRequest
class InvitationIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -5,18 +5,15 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Invitation;
use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization
*/
class InvitationStoreRequest extends FormRequest
class InvitationStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -29,10 +26,6 @@ class InvitationStoreRequest extends FormRequest
'email' => [
'required',
'email',
UniqueEloquent::make(OrganizationInvitation::class, 'email', function (Builder $builder): Builder {
/** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
})->withCustomTranslation('validation.invitation_already_exists'),
],
'role' => [
'required',

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
/**
* @property Organization $organization
*/
class MemberDestroyRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'delete_related' => [
'string',
'in:true,false',
],
];
}
public function getDeleteRelated(): bool
{
return $this->input('delete_related', 'false') === 'true';
}
}

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization
*/
class MemberIndexRequest extends FormRequest
class MemberIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class MemberMergeIntoRequest extends FormRequest
class MemberMergeIntoRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -5,15 +5,15 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Member;
use App\Enums\Role;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization
*/
class MemberUpdateRequest extends FormRequest
class MemberUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -27,12 +27,12 @@ class MemberUpdateRequest extends FormRequest
'string',
Rule::enum(Role::class),
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
];
}

View File

@@ -9,14 +9,14 @@ use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeFormat;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* @property Organization $organization Organization from model binding
*/
class OrganizationUpdateRequest extends FormRequest
class OrganizationUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -30,15 +30,18 @@ class OrganizationUpdateRequest extends FormRequest
'string',
'max:255',
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
'employees_can_see_billable_rates' => [
'boolean',
],
'prevent_overlapping_time_entries' => [
'boolean',
],
'number_format' => [
Rule::enum(NumberFormat::class),
],
@@ -98,4 +101,9 @@ class OrganizationUpdateRequest extends FormRequest
{
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
}
public function getPreventOverlappingTimeEntries(): ?bool
{
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
}
}

View File

@@ -4,10 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use App\Http\Requests\V1\BaseFormRequest;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ProjectIndexRequest extends FormRequest
class ProjectIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -18,7 +18,7 @@ use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class ProjectStoreRequest extends FormRequest
class ProjectStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -55,12 +55,12 @@ class ProjectStoreRequest extends FormRequest
'required',
'boolean',
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
// ID of the client
'client_id' => [
'present',

View File

@@ -4,13 +4,13 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Project;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
@@ -19,7 +19,7 @@ use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
* @property Organization $organization Organization from model binding
* @property Project|null $project Project from model binding
*/
class ProjectUpdateRequest extends FormRequest
class ProjectUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -68,12 +68,11 @@ class ProjectUpdateRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
'billable_rate' => [
'billable_rate' => array_merge([
'nullable',
'integer',
'min:0',
'max:2147483647',
],
$this->moneyRules()
),
// Estimated time in seconds
'estimated_time' => [
'nullable',

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class ProjectMemberStoreRequest extends FormRequest
class ProjectMemberStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -31,12 +31,12 @@ class ProjectMemberStoreRequest extends FormRequest
return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(),
],
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
];
}

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\ProjectMember;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization Organization from model binding
*/
class ProjectMemberUpdateRequest extends FormRequest
class ProjectMemberUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -21,12 +21,12 @@ class ProjectMemberUpdateRequest extends FormRequest
public function rules(): array
{
return [
'billable_rate' => [
'nullable',
'integer',
'min:0',
'max:2147483647',
],
'billable_rate' => array_merge(
[
'nullable',
],
$this->moneyRules()
),
];
}

View File

@@ -6,18 +6,19 @@ namespace App\Http\Requests\V1\Report;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest;
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
class ReportStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -128,6 +129,18 @@ class ReportStoreRequest extends FormRequest
'nullable',
'timezone:all',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'properties.rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'properties.rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -205,4 +218,22 @@ class ReportStoreRequest extends FormRequest
{
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
}
public function getPropertyRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->input('properties.rounding_type'));
}
public function getPropertyRoundingMinutes(): ?int
{
if (! $this->has('properties.rounding_minutes') || $this->input('properties.rounding_minutes') === null) {
return null;
}
return (int) $this->input('properties.rounding_minutes');
}
}

View File

@@ -4,15 +4,15 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Http\Requests\V1\BaseFormRequest;
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
class ReportUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,17 +4,17 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TagStoreRequest extends FormRequest
class TagStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Tag;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Tag;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Tag|null $tag Tag from model binding
*/
class TagUpdateRequest extends FormRequest
class TagUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,19 +4,19 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Project;
use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TaskIndexRequest extends FormRequest
class TaskIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,19 +4,19 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TaskStoreRequest extends FormRequest
class TaskStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -4,18 +4,18 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Task;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
/**
* @property Organization $organization Organization from model binding
* @property Task|null $task Task from model binding
*/
class TaskUpdateRequest extends FormRequest
class TaskUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -7,6 +7,8 @@ namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
@@ -16,7 +18,6 @@ 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;
@@ -24,7 +25,7 @@ use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryAggregateExportRequest extends FormRequest
class TimeEntryAggregateExportRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -164,6 +165,18 @@ class TimeEntryAggregateExportRequest extends FormRequest
'string',
'in:true,false',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -211,4 +224,22 @@ class TimeEntryAggregateExportRequest extends FormRequest
{
return ExportFormat::from($this->validated('format'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
@@ -14,7 +16,6 @@ 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;
@@ -22,7 +23,7 @@ use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryAggregateRequest extends FormRequest
class TimeEntryAggregateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -146,6 +147,18 @@ class TimeEntryAggregateRequest extends FormRequest
'string',
'in:true,false',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -173,4 +186,22 @@ class TimeEntryAggregateRequest extends FormRequest
{
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
}

View File

@@ -4,14 +4,14 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
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
class TimeEntryDestroyMultipleRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TimeEntryRoundingType;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -133,6 +134,18 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
'string',
'in:true,false',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -170,4 +183,22 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{
return ExportFormat::from($this->validated('format'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
}

View File

@@ -4,26 +4,29 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
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 Illuminate\Contracts\Validation\Rule as RuleContract;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryIndexRequest extends FormRequest
class TimeEntryIndexRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
* @return array<string, array<string|ValidationRule|RuleContract>>
*/
public function rules(): array
{
@@ -136,6 +139,18 @@ class TimeEntryIndexRequest extends FormRequest
'string',
'in:true,false',
],
// Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null.
'rounding_type' => [
'nullable',
'string',
Rule::enum(TimeEntryRoundingType::class),
],
// Defines the length of the interval that the time entry rounding rounds to.
'rounding_minutes' => [
'nullable',
'numeric',
'integer',
],
];
}
@@ -153,4 +168,22 @@ class TimeEntryIndexRequest extends FormRequest
{
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {
return null;
}
return TimeEntryRoundingType::from($this->validated('rounding_type'));
}
public function getRoundingMinutes(): ?int
{
if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) {
return null;
}
return (int) $this->validated('rounding_minutes');
}
}

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -11,13 +12,12 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryStoreRequest extends FormRequest
class TimeEntryStoreRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -79,7 +79,7 @@ class TimeEntryStoreRequest extends FormRequest
'description' => [
'nullable',
'string',
'max:500',
'max:5000',
],
// List of tag IDs
'tags' => [

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -11,13 +12,12 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryUpdateMultipleRequest extends FormRequest
class TimeEntryUpdateMultipleRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -79,7 +79,7 @@ class TimeEntryUpdateMultipleRequest extends FormRequest
'changes.description' => [
'nullable',
'string',
'max:500',
'max:5000',
],
// List of tag IDs
'changes.tags' => [

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -11,13 +12,12 @@ use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization Organization from model binding
*/
class TimeEntryUpdateRequest extends FormRequest
class TimeEntryUpdateRequest extends BaseFormRequest
{
/**
* Get the validation rules that apply to the request.
@@ -77,7 +77,7 @@ class TimeEntryUpdateRequest extends FormRequest
'description' => [
'nullable',
'string',
'max:500',
'max:5000',
],
// List of tag IDs
'tags' => [

View File

@@ -53,6 +53,8 @@ class OrganizationResource extends BaseResource
'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 bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */

View File

@@ -8,15 +8,11 @@ use App\Http\Resources\PaginatedResourceCollection;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
{
private bool $showBillableRates;
/**
* @param LengthAwarePaginator<Project> $resource
*/
public function __construct($resource, bool $showBillableRates)
{
parent::__construct($resource);

View File

@@ -58,6 +58,10 @@ class DetailedReportResource extends BaseResource
'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|null $rounding_type Rounding type for time entries */
'rounding_type' => $this->resource->properties->roundingType?->value,
/** @var int|null $rounding_minutes Rounding minutes for time entries */
'rounding_minutes' => $this->resource->properties->roundingMinutes,
],
/** @var string $created_at Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at),

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class AuthApiTokenExpirationReminderMail extends Mailable
{
use Queueable, SerializesModels;
public Token $token;
public User $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(Token $token, User $user)
{
$this->token = $token;
$this->user = $user;
}
/**
* Build the message.
*/
public function build(): self
{
return $this->markdown('emails.auth-api-expiration-reminder', [
'profileUrl' => URL::to('user/profile'),
'tokenName' => $this->token->name,
])
->subject(__('Your API token will expire in 7 days!'));
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace App\Mail;
use App\Models\Passport\Token;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\URL;
class AuthApiTokenExpiredMail extends Mailable
{
use Queueable, SerializesModels;
public Token $token;
public User $user;
/**
* Create a new message instance.
*
* @return void
*/
public function __construct(Token $token, User $user)
{
$this->token = $token;
$this->user = $user;
}
/**
* Build the message.
*/
public function build(): self
{
return $this->markdown('emails.auth-api-token-expired', [
'profileUrl' => URL::to('user/profile'),
'tokenName' => $this->token->name,
])
->subject(__('Your API token has expired!'));
}
}

View File

@@ -16,8 +16,8 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
* @property string $event
* @property string $auditable_type
* @property string $auditable_id
* @property array|null $old_values
* @property array|null $new_values
* @property array<string, mixed>|null $old_values
* @property array<string, mixed>|null $new_values
* @property string|null $url
* @property string|null $ip_address
* @property string|null $user_agent

View File

@@ -7,6 +7,7 @@ namespace App\Models;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\ClientFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -47,7 +48,7 @@ class Client extends Model implements AuditableContract
];
/**
* @return BelongsTo<Organization, Client>
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{
@@ -55,13 +56,25 @@ class Client extends Model implements AuditableContract
}
/**
* @return HasMany<Project>
* @return HasMany<Project, $this>
*/
public function projects(): HasMany
{
return $this->hasMany(Project::class, 'client_id');
}
/**
* @param Builder<Client> $builder
* @return Builder<Client>
*/
public function scopeVisibleByEmployee(Builder $builder, User $user): Builder
{
return $builder->whereHas('projects', function (Builder $builder) use ($user): Builder {
/** @var Builder<Project> $builder */
return $builder->visibleByEmployee($user);
});
}
/**
* @return Attribute<bool, never>
*/

View File

@@ -25,8 +25,8 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property Carbon|null $updated_at
* @property-read Organization $organization
* @property-read User $user
* @property-read Collection<ProjectMember> $projectMembers
* @property-read Collection<TimeEntry> $timeEntries
* @property-read Collection<int, ProjectMember> $projectMembers
* @property-read Collection<int, TimeEntry> $timeEntries
*
* @method static MemberFactory factory()
*/
@@ -47,7 +47,7 @@ class Member extends JetstreamMembership implements AuditableContract
protected $table = 'members';
/**
* @return BelongsTo<User, Member>
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
@@ -55,7 +55,7 @@ class Member extends JetstreamMembership implements AuditableContract
}
/**
* @return BelongsTo<Organization, Member>
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{
@@ -63,7 +63,7 @@ class Member extends JetstreamMembership implements AuditableContract
}
/**
* @return HasMany<TimeEntry>
* @return HasMany<TimeEntry, $this>
*/
public function timeEntries(): HasMany
{
@@ -71,7 +71,7 @@ class Member extends JetstreamMembership implements AuditableContract
}
/**
* @return HasMany<ProjectMember>
* @return HasMany<ProjectMember, $this>
*/
public function projectMembers(): HasMany
{

View File

@@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use Laravel\Jetstream\Events\TeamCreated;
@@ -47,7 +48,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property IntervalFormat $interval_format
* @property TimeFormat $time_format
*
* @method HasMany<OrganizationInvitation> teamInvitations()
* @method HasMany<OrganizationInvitation, $this> teamInvitations()
* @method static OrganizationFactory factory()
*/
class Organization extends JetstreamTeam implements AuditableContract
@@ -69,6 +70,7 @@ class Organization extends JetstreamTeam implements AuditableContract
'personal_team' => 'boolean',
'currency' => 'string',
'employees_can_see_billable_rates' => 'boolean',
'prevent_overlapping_time_entries' => 'boolean',
'number_format' => NumberFormat::class,
'currency_format' => CurrencyFormat::class,
'date_format' => DateFormat::class,
@@ -79,7 +81,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
* @var list<string>
*/
protected $fillable = [
'name',
@@ -125,7 +127,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/**
* Get all the users that belong to the team.
*
* @return BelongsToMany<User>
* @return BelongsToMany<User, $this, Pivot, 'membership'>
*/
public function users(): BelongsToMany
{
@@ -142,7 +144,7 @@ class Organization extends JetstreamTeam implements AuditableContract
/**
* Get the owner of the team.
*
* @return BelongsTo<User, Organization>
* @return BelongsTo<User, $this>
*/
public function owner(): BelongsTo
{
@@ -150,7 +152,7 @@ class Organization extends JetstreamTeam implements AuditableContract
}
/**
* @return HasMany<Member>
* @return HasMany<Member, $this>
*/
public function members(): HasMany
{
@@ -158,7 +160,7 @@ class Organization extends JetstreamTeam implements AuditableContract
}
/**
* @return BelongsToMany<User>
* @return BelongsToMany<User, $this, Pivot, 'membership'>
*/
public function realUsers(): BelongsToMany
{

View File

@@ -53,7 +53,7 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
/**
* Get the organization that the invitation belongs to.
*
* @return BelongsTo<Organization, OrganizationInvitation>
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{
@@ -63,7 +63,7 @@ class OrganizationInvitation extends JetstreamTeamInvitation implements Auditabl
/**
* Get the organization that the invitation belongs to.
*
* @return BelongsTo<Organization, OrganizationInvitation>
* @return BelongsTo<Organization, $this>
*/
public function team(): BelongsTo
{

View File

@@ -4,6 +4,26 @@ declare(strict_types=1);
namespace App\Models\Passport;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use Laravel\Passport\AuthCode as PassportAuthCode;
class AuthCode extends PassportAuthCode {}
/**
* @property string $id
* @property string $user_id
* @property string $client_id
* @property string|null $scopes
* @property bool $revoked
* @property Carbon $expires_at
*/
class AuthCode extends PassportAuthCode
{
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@@ -5,22 +5,36 @@ declare(strict_types=1);
namespace App\Models\Passport;
use Database\Factories\Passport\ClientFactory;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Support\Carbon;
use Laravel\Passport\Client as PassportClient;
/**
* @property string $id
* @property string|null $user_id
* @property string|null $owner_id
* @property string|null $owner_type
* @property string $name
* @property string|null $secret
* @property string|null $provider
* @property string $redirect
* @property bool $personal_access_client
* @property bool $password_client
* @property array<string> $grant_types
* @property array<string> $redirect_uris
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property bool $revoked
*/
class Client extends PassportClient
{
/** @use HasFactory<ClientFactory> */
use HasFactory;
/**
* Create a new factory instance for the model.
*
* @return ClientFactory
*/
protected static function newFactory(): Factory
{
return ClientFactory::new();
}
}

View File

@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Models\Passport;
use Laravel\Passport\PersonalAccessClient as PassportPersonalAccessClient;
class PersonalAccessClient extends PassportPersonalAccessClient {}

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Models\Passport;
use App\Models\User;
use Database\Factories\Passport\TokenFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
@@ -17,9 +19,15 @@ use Laravel\Passport\Token as PassportToken;
* @property null|string $name
* @property array<string> $scopes
* @property bool $revoked
* @property Carbon|null $reminder_sent_at
* @property Carbon|null $expired_info_sent_at
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property Carbon|null $expires_at
* @property-read Client|null $client
* @property-read User|null $user
*
* @method Builder<Token> isApiToken(bool $isApiToken = true)
*/
class Token extends PassportToken
{
@@ -29,10 +37,60 @@ class Token extends PassportToken
/**
* Get the client that the token belongs to.
*
* @return BelongsTo<Client, Token>
* @return BelongsTo<Client, $this>
*/
// @phpstan-ignore method.childReturnType
public function client(): BelongsTo
{
return $this->belongsTo(Client::class, 'client_id', 'id');
}
/**
* Get the user that the token belongs to.
*
* @deprecated Will be removed in a future Laravel version.
*
* @return BelongsTo<User, $this>
*/
// @phpstan-ignore method.childReturnType
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'scopes' => 'array',
'revoked' => 'bool',
'expires_at' => 'datetime',
'reminder_sent_at' => 'datetime',
'expired_info_sent_at' => 'datetime',
];
}
/**
* @param Builder<static> $query
* @return Builder<static>
*/
public function scopeIsApiToken(Builder $query, bool $isApiToken = true): Builder
{
if ($isApiToken) {
return $query->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonContains('grant_types', 'personal_access');
});
} else {
return $query->whereHas('client', function (Builder $query): void {
/** @var Builder<Client> $query */
$query->whereJsonDoesntContain('grant_types', 'personal_access');
});
}
}
}

View File

@@ -137,7 +137,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return BelongsTo<Organization, Project>
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{
@@ -145,7 +145,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return BelongsTo<Client, Project>
* @return BelongsTo<Client, $this>
*/
public function client(): BelongsTo
{
@@ -153,7 +153,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return HasMany<ProjectMember>
* @return HasMany<ProjectMember, $this>
*/
public function members(): HasMany
{
@@ -161,7 +161,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return HasMany<Task>
* @return HasMany<Task, $this>
*/
public function tasks(): HasMany
{
@@ -169,7 +169,7 @@ class Project extends Model implements AuditableContract
}
/**
* @return HasMany<TimeEntry>
* @return HasMany<TimeEntry, $this>
*/
public function timeEntries(): HasMany
{

View File

@@ -48,7 +48,7 @@ class ProjectMember extends Model implements AuditableContract
];
/**
* @return BelongsTo<Project, ProjectMember>
* @return BelongsTo<Project, $this>
*/
public function project(): BelongsTo
{
@@ -58,7 +58,7 @@ class ProjectMember extends Model implements AuditableContract
/**
* @deprecated Use member relationship instead
*
* @return BelongsTo<User, ProjectMember>
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
@@ -66,7 +66,7 @@ class ProjectMember extends Model implements AuditableContract
}
/**
* @return BelongsTo<Member, ProjectMember>
* @return BelongsTo<Member, $this>
*/
public function member(): BelongsTo
{

View File

@@ -55,7 +55,7 @@ class Report extends Model
}
/**
* @return BelongsTo<Organization, Report>
* @return BelongsTo<Organization, $this>
*/
public function organization(): BelongsTo
{

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