mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Compare commits
241 Commits
feature/ex
...
feature/do
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
595132ec2c | ||
|
|
0659cb3993 | ||
|
|
e8fc6fd77e | ||
|
|
a04185921d | ||
|
|
b73aa543fd | ||
|
|
2d6f9e514f | ||
|
|
f8e668790b | ||
|
|
77a5e979c6 | ||
|
|
353a579850 | ||
|
|
bd44a2b376 | ||
|
|
277dbaf6eb | ||
|
|
1cf33ddb3f | ||
|
|
84cd0d572d | ||
|
|
f37b86f377 | ||
|
|
1e7364fc4b | ||
|
|
8cbc9838c9 | ||
|
|
71c8992e31 | ||
|
|
53d91b65d6 | ||
|
|
0c88a10eb5 | ||
|
|
dd7b23958a | ||
|
|
1eb066f5aa | ||
|
|
b1287c6a0a | ||
|
|
815abb5980 | ||
|
|
e2f859be27 | ||
|
|
3d26fcaefe | ||
|
|
1e73a90f9d | ||
|
|
0f8f906e5c | ||
|
|
797fddf638 | ||
|
|
d07294ae7c | ||
|
|
1f49940805 | ||
|
|
6be6a48e0d | ||
|
|
b94a04dca0 | ||
|
|
bd3b8f265f | ||
|
|
c19a0f9acc | ||
|
|
5c6d84dc38 | ||
|
|
5c67709746 | ||
|
|
a2b0828c54 | ||
|
|
b94872b07b | ||
|
|
12bbbf64e9 | ||
|
|
c07ac4b0e4 | ||
|
|
a58566d002 | ||
|
|
57ed6036e6 | ||
|
|
ef7569b63b | ||
|
|
19c789b78e | ||
|
|
49548037b3 | ||
|
|
97df779d1e | ||
|
|
a1d5563fc4 | ||
|
|
c94ca804f8 | ||
|
|
189682cfaf | ||
|
|
8d16503541 | ||
|
|
e43ce477b8 | ||
|
|
5646aedb25 | ||
|
|
2b46e568e0 | ||
|
|
89a4a1962a | ||
|
|
c581ad8854 | ||
|
|
bce6cb9395 | ||
|
|
1cdae98ed9 | ||
|
|
02f6436fd0 | ||
|
|
452acca942 | ||
|
|
192c8c3b88 | ||
|
|
6218ffceb5 | ||
|
|
ba32be0543 | ||
|
|
bd817db06f | ||
|
|
97f4bce676 | ||
|
|
6962b668fb | ||
|
|
be8091296c | ||
|
|
84c4750c9b | ||
|
|
f582adab0d | ||
|
|
c60cff04ce | ||
|
|
cae41e4b4f | ||
|
|
8973be9dab | ||
|
|
2a0b8d31e6 | ||
|
|
d2f3fe411a | ||
|
|
f880f9f730 | ||
|
|
556bbedeca | ||
|
|
eed638d0aa | ||
|
|
864f41bda6 | ||
|
|
26524c5f40 | ||
|
|
cf98fabe0a | ||
|
|
88c0c334e9 | ||
|
|
0fc325363d | ||
|
|
1afc16573a | ||
|
|
147514a606 | ||
|
|
435522b502 | ||
|
|
f1d001e03e | ||
|
|
7f145cf1c2 | ||
|
|
b579ed1075 | ||
|
|
ed2b7476ae | ||
|
|
8107c6a208 | ||
|
|
6dc517e07d | ||
|
|
2c60d04ba4 | ||
|
|
2c222f3f67 | ||
|
|
c5c1a7af13 | ||
|
|
22cf7cf74d | ||
|
|
cfbfbd4b6a | ||
|
|
6629482a0e | ||
|
|
38457cae4d | ||
|
|
0e63ecb520 | ||
|
|
6f207a4926 | ||
|
|
052424a581 | ||
|
|
b258717211 | ||
|
|
685cc29282 | ||
|
|
c78c681ec4 | ||
|
|
2d9f33387e | ||
|
|
b68d68a2a2 | ||
|
|
a9e03f3b29 | ||
|
|
474b294a18 | ||
|
|
334a98016f | ||
|
|
8be55359ce | ||
|
|
e45662c715 | ||
|
|
f3217baed1 | ||
|
|
562ee234a8 | ||
|
|
15e61e9789 | ||
|
|
125f6f062f | ||
|
|
f75a19bccd | ||
|
|
c17d87b710 | ||
|
|
a154293348 | ||
|
|
9832c688fe | ||
|
|
6804eb098d | ||
|
|
531443f0df | ||
|
|
bd2d57dfd1 | ||
|
|
73c92fad47 | ||
|
|
537a023ab9 | ||
|
|
28fc324c6a | ||
|
|
9379c191be | ||
|
|
ff06d4d2f3 | ||
|
|
7efb7e6071 | ||
|
|
b2af9c6bf1 | ||
|
|
73b4d66386 | ||
|
|
cb7baef0ba | ||
|
|
dd75a80df7 | ||
|
|
bc562bf76f | ||
|
|
756b423295 | ||
|
|
3707f2469c | ||
|
|
c6c1434430 | ||
|
|
70b78e41c3 | ||
|
|
8c16302f17 | ||
|
|
bfc369794e | ||
|
|
3c2ea0e645 | ||
|
|
b0d28f2f6d | ||
|
|
6555bca5f1 | ||
|
|
81d9561656 | ||
|
|
0a6bde8bc6 | ||
|
|
51af3db305 | ||
|
|
f242ce48b5 | ||
|
|
19064cdc3d | ||
|
|
5a05ee35e0 | ||
|
|
00d9d1488e | ||
|
|
9bbbfdfafe | ||
|
|
d27f023e16 | ||
|
|
db57055941 | ||
|
|
743c64909a | ||
|
|
de97d15925 | ||
|
|
0691fe10ef | ||
|
|
513b2048ee | ||
|
|
3acf9b8b07 | ||
|
|
814d539fb0 | ||
|
|
7a51fca2f9 | ||
|
|
280032ee02 | ||
|
|
b1bb7245b0 | ||
|
|
6f37ad500a | ||
|
|
500ccd5719 | ||
|
|
bacd6f4222 | ||
|
|
022caf59ee | ||
|
|
f955ab3135 | ||
|
|
5b491b0da2 | ||
|
|
249ab67ac8 | ||
|
|
1bd2c28b37 | ||
|
|
33ac994cc0 | ||
|
|
8d3ee58bed | ||
|
|
8a2c260533 | ||
|
|
95ab1699c4 | ||
|
|
306a081a3d | ||
|
|
878ac4ab81 | ||
|
|
947550d639 | ||
|
|
09fb5aa48e | ||
|
|
9b9371e5a5 | ||
|
|
0648437478 | ||
|
|
8ba04eca0c | ||
|
|
8a2f35de0c | ||
|
|
b7dafb0892 | ||
|
|
6eca0c2c76 | ||
|
|
3417b60585 | ||
|
|
0f21fabd37 | ||
|
|
df00200464 | ||
|
|
3b41de7135 | ||
|
|
9fe0ea5a0f | ||
|
|
f8f708a664 | ||
|
|
c359259e45 | ||
|
|
55d12aaae1 | ||
|
|
9a1dd4861c | ||
|
|
1e985b71ec | ||
|
|
93d6a86f74 | ||
|
|
19a206d57c | ||
|
|
c0788c270b | ||
|
|
7765056074 | ||
|
|
639f5332e4 | ||
|
|
4a50145329 | ||
|
|
8aabffd1e7 | ||
|
|
b373427dc7 | ||
|
|
d2a4d60441 | ||
|
|
c3305b3df6 | ||
|
|
7584e59d0b | ||
|
|
d2f75cca6e | ||
|
|
250379d4bd | ||
|
|
7f89fd8ea1 | ||
|
|
0b45f3b473 | ||
|
|
9827a74ae2 | ||
|
|
3425847a44 | ||
|
|
47b778fab9 | ||
|
|
85d69f1f16 | ||
|
|
fca55fe0e1 | ||
|
|
f19abb9db6 | ||
|
|
e3bd50ed6b | ||
|
|
c582530899 | ||
|
|
fb5185a32f | ||
|
|
0a0854f771 | ||
|
|
4e635cde83 | ||
|
|
9fa9522237 | ||
|
|
04c44097d0 | ||
|
|
3d5a0cb974 | ||
|
|
da98e0571c | ||
|
|
f68f05d1aa | ||
|
|
8fdc4c1219 | ||
|
|
93148299a9 | ||
|
|
78d2ea1a25 | ||
|
|
14f559c4c2 | ||
|
|
61fd2b1187 | ||
|
|
9ea3c5dc29 | ||
|
|
cb30487a21 | ||
|
|
b11672732b | ||
|
|
97dcadc795 | ||
|
|
e7fa414c06 | ||
|
|
43073b5be2 | ||
|
|
9589c9106d | ||
|
|
8a0d2235a8 | ||
|
|
38f38790d5 | ||
|
|
e3cfc155b8 | ||
|
|
4b726635b2 | ||
|
|
e1185af281 | ||
|
|
f9c0d64f82 |
12
.env.ci
12
.env.ci
@@ -34,7 +34,12 @@ SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
|
||||
# Mail
|
||||
MAIL_MAILER=log
|
||||
MAIL_MAILER=smtp
|
||||
MAIL_HOST=localhost
|
||||
MAIL_PORT=1025
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_ENCRYPTION=null
|
||||
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
|
||||
MAIL_FROM_NAME="solidtime"
|
||||
MAIL_REPLY_TO_ADDRESS="hello@solidtime.test"
|
||||
@@ -55,4 +60,7 @@ AUDITING_ENABLED=true
|
||||
TELESCOPE_ENABLED=false
|
||||
|
||||
# Services
|
||||
GOTENBERG_URL=http://0.0.0.0:3000
|
||||
GOTENBERG_URL=http://localhost:3000
|
||||
|
||||
# Octane
|
||||
OCTANE_SERVER=frankenphp
|
||||
|
||||
@@ -77,11 +77,13 @@ TELESCOPE_ENABLED=false
|
||||
# Services
|
||||
GOTENBERG_URL=http://gotenberg:3000
|
||||
|
||||
# Octane
|
||||
OCTANE_SERVER=frankenphp
|
||||
|
||||
# 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
|
||||
|
||||
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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
216
.github/workflows/build-onpremise.yml
vendored
Normal 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 }}
|
||||
258
.github/workflows/image-smoke-test.yml
vendored
Normal file
258
.github/workflows/image-smoke-test.yml
vendored
Normal file
@@ -0,0 +1,258 @@
|
||||
name: Image Smoke Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'docker/prod/**'
|
||||
- '.github/workflows/image-smoke-test.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: Smoke (${{ matrix.mode }})
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
mode:
|
||||
- default
|
||||
- puid-pgid
|
||||
- openshift
|
||||
- drop-never
|
||||
- diagnostic
|
||||
- puid-mismatch-warning
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Copy .env template
|
||||
run: |
|
||||
cp .env.production .env
|
||||
rm .env.production .env.ci .env.example
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
extensions: mbstring, dom, fileinfo, pgsql
|
||||
|
||||
- name: Composer install
|
||||
run: composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: NPM ci
|
||||
run: npm ci
|
||||
|
||||
- name: NPM build
|
||||
run: npm run build
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build smoke image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/prod/Dockerfile
|
||||
build-args: |
|
||||
DOCKER_FILES_BASE_PATH=docker/prod/
|
||||
load: true
|
||||
tags: solidtime-smoke:test
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: "Smoke: default (image config + fresh deploy with empty bind mounts)"
|
||||
if: matrix.mode == 'default'
|
||||
run: |
|
||||
echo "[smoke] image's default USER is root (entrypoint needs root to drop privs)"
|
||||
user=$(docker inspect --format '{{.Config.User}}' solidtime-smoke:test)
|
||||
if [ "$user" != "root" ]; then
|
||||
echo "Expected 'root', got '$user'. The Dockerfile must end with USER root so the entrypoint can chown/usermod and drop privileges."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[smoke] storage tree is group-0 owned (OpenShift / arbitrary-UID compat)"
|
||||
group=$(docker run --rm --entrypoint stat solidtime-smoke:test -c '%g' /var/www/html/storage)
|
||||
if [ "$group" != "0" ]; then
|
||||
echo "Expected group 0, got '$group'. The Dockerfile must chgrp -R 0 storage bootstrap/cache so arbitrary-UID containers can write."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p test-storage test-cache
|
||||
docker run --rm \
|
||||
-v "$(pwd)/test-storage:/var/www/html/storage" \
|
||||
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
|
||||
solidtime-smoke:test \
|
||||
sh -c '
|
||||
set -e
|
||||
echo "[smoke] framework subdirs exist"
|
||||
test -d /var/www/html/storage/framework/cache/data
|
||||
test -d /var/www/html/storage/framework/sessions
|
||||
test -d /var/www/html/storage/framework/views
|
||||
test -d /var/www/html/storage/framework/testing
|
||||
test -d /var/www/html/storage/logs
|
||||
test -d /var/www/html/storage/app/public
|
||||
test -d /var/www/html/storage/app/private
|
||||
test -d /var/www/html/bootstrap/cache
|
||||
echo "[smoke] storage is writable"
|
||||
touch /var/www/html/storage/framework/cache/data/test-file
|
||||
echo "[smoke] running as octane (UID 1000)"
|
||||
[ "$(id -u)" = "1000" ]
|
||||
echo "[smoke] PASS"
|
||||
'
|
||||
|
||||
- name: "Smoke: PUID/PGID remap"
|
||||
if: matrix.mode == 'puid-pgid'
|
||||
run: |
|
||||
mkdir -p test-storage test-cache
|
||||
sudo chown -R 1501:1501 test-storage test-cache
|
||||
docker run --rm \
|
||||
-e PUID=1501 -e PGID=1501 \
|
||||
-v "$(pwd)/test-storage:/var/www/html/storage" \
|
||||
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
|
||||
solidtime-smoke:test \
|
||||
sh -c '
|
||||
set -e
|
||||
echo "[smoke] running as remapped UID/GID 1501"
|
||||
[ "$(id -u)" = "1501" ]
|
||||
[ "$(id -g)" = "1501" ]
|
||||
echo "[smoke] storage is writable as 1501"
|
||||
touch /var/www/html/storage/framework/cache/data/test-file
|
||||
echo "[smoke] PASS"
|
||||
'
|
||||
|
||||
- name: "Smoke: OpenShift / arbitrary UID + group 0"
|
||||
if: matrix.mode == 'openshift'
|
||||
run: |
|
||||
mkdir -p test-storage test-cache
|
||||
sudo chown -R 2000:0 test-storage test-cache
|
||||
sudo chmod -R g+rwX test-storage test-cache
|
||||
docker run --rm --user 2000:0 \
|
||||
-v "$(pwd)/test-storage:/var/www/html/storage" \
|
||||
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
|
||||
solidtime-smoke:test \
|
||||
sh -c '
|
||||
set -e
|
||||
echo "[smoke] running as arbitrary UID 2000, group 0"
|
||||
[ "$(id -u)" = "2000" ]
|
||||
[ "$(id -g)" = "0" ]
|
||||
echo "[smoke] storage is writable via group 0"
|
||||
touch /var/www/html/storage/framework/cache/data/test-file
|
||||
echo "[smoke] PASS"
|
||||
'
|
||||
|
||||
- name: "Smoke: SOLIDTIME_DROP_PRIVILEGES=never (run as root)"
|
||||
if: matrix.mode == 'drop-never'
|
||||
run: |
|
||||
mkdir -p test-storage test-cache
|
||||
docker run --rm \
|
||||
-e SOLIDTIME_DROP_PRIVILEGES=never \
|
||||
-v "$(pwd)/test-storage:/var/www/html/storage" \
|
||||
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
|
||||
solidtime-smoke:test \
|
||||
sh -c '
|
||||
set -e
|
||||
echo "[smoke] running as root (privilege drop disabled)"
|
||||
[ "$(id -u)" = "0" ]
|
||||
echo "[smoke] bootstrap still ran"
|
||||
test -d /var/www/html/storage/framework/cache/data
|
||||
echo "[smoke] storage writable as root"
|
||||
touch /var/www/html/storage/framework/cache/data/test-file
|
||||
echo "[smoke] PASS"
|
||||
'
|
||||
|
||||
- name: "Smoke: PUID set + started non-root prints a warning but continues"
|
||||
if: matrix.mode == 'puid-mismatch-warning'
|
||||
run: |
|
||||
mkdir -p test-storage test-cache
|
||||
sudo chown -R 1500:1500 test-storage test-cache
|
||||
|
||||
set +e
|
||||
docker run --rm \
|
||||
--user 1500:1500 \
|
||||
-e PUID=1500 -e PGID=1500 \
|
||||
-v "$(pwd)/test-storage:/var/www/html/storage" \
|
||||
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache" \
|
||||
solidtime-smoke:test \
|
||||
sh -c '
|
||||
set -e
|
||||
echo "[smoke] running as 1500 (user: directive wins)"
|
||||
[ "$(id -u)" = "1500" ]
|
||||
echo "[smoke] storage is writable as 1500"
|
||||
touch /var/www/html/storage/framework/cache/data/test-file
|
||||
echo "[smoke] container completed successfully"
|
||||
' \
|
||||
>stdout.log 2>stderr.log
|
||||
exit_code=$?
|
||||
set -e
|
||||
|
||||
echo "[smoke] exit code: $exit_code"
|
||||
echo "--- stderr ---"
|
||||
cat stderr.log
|
||||
echo "--- end stderr ---"
|
||||
|
||||
if [ "$exit_code" -ne 0 ]; then
|
||||
echo "Expected the entrypoint to continue (warning is non-fatal)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for needle in "PUID/PGID is set but the container started as UID" "remove any 'user:' directive" "Continuing as UID"; do
|
||||
if ! grep -q "$needle" stderr.log; then
|
||||
echo "Missing warning fragment: $needle"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "[smoke] PASS"
|
||||
|
||||
- name: "Smoke: diagnostic error path (read-only storage mount)"
|
||||
if: matrix.mode == 'diagnostic'
|
||||
run: |
|
||||
# Pre-create the full storage tree on the host so the entrypoint's
|
||||
# bootstrap_storage_tree() is a no-op (mkdir -p on existing dirs
|
||||
# returns 0 even on a read-only mount). The write test then fires
|
||||
# against the RO mount and triggers our diagnostic.
|
||||
mkdir -p test-storage/framework/cache/data \
|
||||
test-storage/framework/sessions \
|
||||
test-storage/framework/views \
|
||||
test-storage/framework/testing \
|
||||
test-storage/logs \
|
||||
test-storage/app/public \
|
||||
test-storage/app/private \
|
||||
test-cache
|
||||
|
||||
set +e
|
||||
docker run --rm \
|
||||
-v "$(pwd)/test-storage:/var/www/html/storage:ro" \
|
||||
-v "$(pwd)/test-cache:/var/www/html/bootstrap/cache:ro" \
|
||||
solidtime-smoke:test \
|
||||
true \
|
||||
>stdout.log 2>stderr.log
|
||||
exit_code=$?
|
||||
set -e
|
||||
|
||||
echo "[smoke] exit code: $exit_code"
|
||||
echo "--- stderr ---"
|
||||
cat stderr.log
|
||||
echo "--- end stderr ---"
|
||||
|
||||
if [ "$exit_code" -eq 0 ]; then
|
||||
echo "Expected the entrypoint to exit non-zero on an unwritable storage mount."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for needle in "not writable" "PUID=" "permissions"; do
|
||||
if ! grep -q "$needle" stderr.log; then
|
||||
echo "Missing diagnostic fragment: $needle"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "[smoke] PASS"
|
||||
23
.github/workflows/npm-format-check.yml
vendored
Normal file
23
.github/workflows/npm-format-check.yml
vendored
Normal 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
|
||||
63
.github/workflows/playwright.yml
vendored
63
.github/workflows/playwright.yml
vendored
@@ -6,10 +6,18 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shardTotal: [8]
|
||||
|
||||
services:
|
||||
mailpit:
|
||||
image: 'axllent/mailpit:latest'
|
||||
ports:
|
||||
- 1025:1025
|
||||
- 8025:8025
|
||||
pgsql_test:
|
||||
image: postgres:15
|
||||
env:
|
||||
@@ -57,22 +65,63 @@ jobs:
|
||||
- name: "Build Frontend"
|
||||
run: npm run build
|
||||
|
||||
- name: "Run Laravel Server"
|
||||
run: php artisan serve > /dev/null 2>&1 &
|
||||
- name: "Install FrankenPHP"
|
||||
run: |
|
||||
ARCH="$(uname -m)"
|
||||
curl -fsSL "https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-${ARCH}" -o /usr/local/bin/frankenphp
|
||||
chmod +x /usr/local/bin/frankenphp
|
||||
|
||||
- name: "Run Laravel Octane Server"
|
||||
run: php artisan octane:start --server=frankenphp --host=127.0.0.1 --port=8000 --workers=4 --max-requests=500 > /dev/null 2>&1 &
|
||||
env:
|
||||
OCTANE_SERVER: frankenphp
|
||||
|
||||
- name: "Install Playwright Browsers"
|
||||
run: npx playwright install --with-deps
|
||||
|
||||
- name: "Run Playwright tests"
|
||||
run: npx playwright test
|
||||
run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
env:
|
||||
PLAYWRIGHT_BASE_URL: 'http://127.0.0.1:8000'
|
||||
MAILPIT_BASE_URL: 'http://localhost:8025'
|
||||
|
||||
- name: "Upload test results"
|
||||
- name: "Upload blob report"
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results/
|
||||
retention-days: 30
|
||||
name: blob-report-${{ matrix.shardIndex }}
|
||||
path: blob-report/
|
||||
retention-days: 7
|
||||
|
||||
merge-reports:
|
||||
if: always()
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: "Setup node"
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
|
||||
- name: "Install dependencies"
|
||||
run: npm ci
|
||||
|
||||
- name: "Download blob reports"
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: all-blob-reports
|
||||
pattern: blob-report-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: "Merge reports"
|
||||
run: npx playwright merge-reports --reporter html ./all-blob-reports
|
||||
|
||||
- name: "Upload merged HTML report"
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
27
.prettierignore
Normal file
27
.prettierignore
Normal 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
|
||||
@@ -3,5 +3,6 @@
|
||||
"tabWidth": 4,
|
||||
"singleQuote": true,
|
||||
"bracketSameLine": true,
|
||||
"quoteProps": "preserve"
|
||||
"quoteProps": "preserve",
|
||||
"printWidth": 100
|
||||
}
|
||||
|
||||
81
CONTRIBUTING.md
Normal file
81
CONTRIBUTING.md
Normal 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 we’ve set up a [Contributor License Agreement (CLA)](https://cla-assistant.io/solidtime-io/solidtime), which must be signed before any PR can be merged. Don’t 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. That’s why we’ve 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 solidtime’s 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.
|
||||
|
||||
We’ll also provide proprietary extensions for solidtime. These will be available to enterprise customers with support plans, but also to individual users or teams who don’t 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 can’t or don’t 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 code’s 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.
|
||||
@@ -1,4 +1,4 @@
|
||||
# solidtime - The modern Open-Source Time Tracker
|
||||
# solidtime - The modern Open-Source TimeTracker
|
||||
|
||||
[](https://github.com/solidtime-io/solidtime/blob/main/LICENSE.md)
|
||||
[](https://codecov.io/gh/solidtime-io/solidtime)
|
||||
@@ -35,10 +35,11 @@ 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.
|
||||
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -18,13 +18,31 @@ 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'))
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
16
app/Enums/TimeEntryRoundingType.php
Normal file
16
app/Enums/TimeEntryRoundingType.php
Normal 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';
|
||||
}
|
||||
10
app/Exceptions/Api/OverlappingTimeEntryApiException.php
Normal file
10
app/Exceptions/Api/OverlappingTimeEntryApiException.php
Normal 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';
|
||||
}
|
||||
@@ -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}'),
|
||||
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\TimeEntryResource\Pages;
|
||||
use App\Models\Member;
|
||||
use App\Models\TimeEntry;
|
||||
use Filament\Forms\Components\DateTimePicker;
|
||||
use Filament\Forms\Components\Select;
|
||||
@@ -16,6 +17,7 @@ use Filament\Tables;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class TimeEntryResource extends Resource
|
||||
{
|
||||
@@ -51,15 +53,23 @@ class TimeEntryResource extends Resource
|
||||
->rules([
|
||||
'after_or_equal:start',
|
||||
]),
|
||||
Select::make('user_id')
|
||||
->relationship(name: 'user', titleAttribute: 'email')
|
||||
->searchable(['name', 'email'])
|
||||
Select::make('member_id')
|
||||
->relationship(
|
||||
name: 'member',
|
||||
titleAttribute: 'id',
|
||||
modifyQueryUsing: fn (Builder $query) => $query->with(['user', 'organization'])
|
||||
)
|
||||
->getOptionLabelFromRecordUsing(fn (Member $record): string => $record->user->email.' ('.$record->organization->name.')')
|
||||
->searchable()
|
||||
->required(),
|
||||
Select::make('project_id')
|
||||
->relationship(name: 'project', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
->nullable(),
|
||||
// TODO
|
||||
Select::make('task_id')
|
||||
->relationship(name: 'task', titleAttribute: 'name')
|
||||
->searchable(['name'])
|
||||
->nullable(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,28 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TimeEntryResource;
|
||||
use App\Models\Member;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateTimeEntry extends CreateRecord
|
||||
{
|
||||
protected static string $resource = TimeEntryResource::class;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
if (isset($data['member_id'])) {
|
||||
/** @var Member|null $member */
|
||||
$member = Member::query()->find($data['member_id']);
|
||||
if ($member !== null) {
|
||||
$data['user_id'] = $member->user_id;
|
||||
$data['organization_id'] = $member->organization_id;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Filament\Resources\TimeEntryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\TimeEntryResource;
|
||||
use App\Models\Member;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
@@ -19,4 +20,22 @@ class EditTimeEntry extends EditRecord
|
||||
->icon('heroicon-m-trash'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
if (isset($data['member_id'])) {
|
||||
/** @var Member|null $member */
|
||||
$member = Member::query()->find($data['member_id']);
|
||||
if ($member !== null) {
|
||||
$data['user_id'] = $member->user_id;
|
||||
$data['organization_id'] = $member->organization_id;
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -106,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->whereJsonContains('grant_types', 'personal_access');
|
||||
});
|
||||
return $query->isApiToken();
|
||||
},
|
||||
false: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
return $query->whereHas('client', function (Builder $query) {
|
||||
/** @var Builder<Client> $query */
|
||||
return $query->whereJsonDoesntContain('grant_types', 'personal_access');
|
||||
});
|
||||
return $query->isApiToken(false);
|
||||
},
|
||||
blank: function (Builder $query) {
|
||||
/** @var Builder<Token> $query */
|
||||
|
||||
@@ -35,6 +35,7 @@ class ApiTokenController extends Controller
|
||||
/** @var Builder<Client> $query */
|
||||
$query->whereJsonContains('grant_types', 'personal_access');
|
||||
})
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
|
||||
return new ApiTokenCollection($tokens);
|
||||
|
||||
@@ -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
|
||||
@@ -92,12 +102,14 @@ class ChartController extends Controller
|
||||
$this->checkPermission($organization, 'charts:view:own');
|
||||
$user = $this->user();
|
||||
|
||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 60);
|
||||
$dailyTrackedHours = $dashboardService->getDailyTrackedHours($user, $organization, 100);
|
||||
|
||||
return response()->json($dailyTrackedHours);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -41,6 +41,7 @@ class InvitationController extends Controller
|
||||
$this->checkPermission($organization, 'invitations:view');
|
||||
|
||||
$invitations = $organization->teamInvitations()
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return InvitationCollection::make($invitations);
|
||||
|
||||
@@ -60,6 +60,7 @@ class MemberController extends Controller
|
||||
$members = Member::query()
|
||||
->whereBelongsTo($organization, 'organization')
|
||||
->with(['user'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return MemberCollection::make($members);
|
||||
|
||||
@@ -46,6 +46,9 @@ class OrganizationController extends Controller
|
||||
if ($request->getEmployeesCanSeeBillableRates() !== null) {
|
||||
$organization->employees_can_see_billable_rates = $request->getEmployeesCanSeeBillableRates();
|
||||
}
|
||||
if ($request->getEmployeesCanManageTasks() !== null) {
|
||||
$organization->employees_can_manage_tasks = $request->getEmployeesCanManageTasks();
|
||||
}
|
||||
if ($request->getNumberFormat() !== null) {
|
||||
$organization->number_format = $request->getNumberFormat();
|
||||
}
|
||||
@@ -61,6 +64,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;
|
||||
|
||||
@@ -60,7 +60,9 @@ class ProjectController extends Controller
|
||||
$projectsQuery->whereNull('archived_at');
|
||||
}
|
||||
|
||||
$projects = $projectsQuery->paginate(config('app.pagination_per_page_default'));
|
||||
$projects = $projectsQuery
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
||||
|
||||
@@ -76,7 +78,7 @@ class ProjectController extends Controller
|
||||
*/
|
||||
public function show(Organization $organization, Project $project): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'projects:view', $project);
|
||||
$this->checkPermission($organization, 'projects:view:all', $project);
|
||||
|
||||
// Note: There is currently no need to check if a user is a member of the project,
|
||||
// since this is only relevant for users with the role "employee" and they can not access this endpoint.
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
|
||||
use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException;
|
||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberIndexRequest;
|
||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest;
|
||||
use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest;
|
||||
use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection;
|
||||
@@ -41,12 +42,13 @@ class ProjectMemberController extends Controller
|
||||
*
|
||||
* @operationId getProjectMembers
|
||||
*/
|
||||
public function index(Organization $organization, Project $project): ProjectMemberCollection
|
||||
public function index(Organization $organization, Project $project, ProjectMemberIndexRequest $request): ProjectMemberCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'project-members:view', $project);
|
||||
|
||||
$projectMembers = ProjectMember::query()
|
||||
->whereBelongsTo($project, 'project')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return new ProjectMemberCollection($projectMembers);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Enums\Weekday;
|
||||
use App\Http\Requests\V1\Report\ReportIndexRequest;
|
||||
use App\Http\Requests\V1\Report\ReportStoreRequest;
|
||||
use App\Http\Requests\V1\Report\ReportUpdateRequest;
|
||||
use App\Http\Resources\V1\Report\DetailedReportResource;
|
||||
@@ -40,7 +41,7 @@ class ReportController extends Controller
|
||||
*
|
||||
* @operationId getReports
|
||||
*/
|
||||
public function index(Organization $organization): ReportCollection
|
||||
public function index(Organization $organization, ReportIndexRequest $request): ReportCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'reports:view');
|
||||
|
||||
@@ -107,6 +108,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();
|
||||
@@ -148,6 +151,9 @@ class ReportController extends Controller
|
||||
$report->share_secret = null;
|
||||
$report->public_until = null;
|
||||
}
|
||||
} elseif ($report->is_public && $request->has('public_until')) {
|
||||
// Allow updating expiration date on already-public reports
|
||||
$report->public_until = $request->getPublicUntil();
|
||||
}
|
||||
$report->save();
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Exceptions\Api\EntityStillInUseApiException;
|
||||
use App\Http\Requests\V1\Tag\TagIndexRequest;
|
||||
use App\Http\Requests\V1\Tag\TagStoreRequest;
|
||||
use App\Http\Requests\V1\Tag\TagUpdateRequest;
|
||||
use App\Http\Resources\V1\Tag\TagCollection;
|
||||
@@ -34,7 +35,7 @@ class TagController extends Controller
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function index(Organization $organization): TagCollection
|
||||
public function index(Organization $organization, TagIndexRequest $request): TagCollection
|
||||
{
|
||||
$this->checkPermission($organization, 'tags:view');
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Http\Requests\V1\Task\TaskUpdateRequest;
|
||||
use App\Http\Resources\V1\Task\TaskCollection;
|
||||
use App\Http\Resources\V1\Task\TaskResource;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Task;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -27,6 +28,26 @@ class TaskController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check scoped permission and verify user has access to the project
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
private function checkScopedPermissionForProject(Organization $organization, Project $project, string $permission): void
|
||||
{
|
||||
$this->checkPermission($organization, $permission);
|
||||
|
||||
$user = $this->user();
|
||||
$hasAccess = Project::query()
|
||||
->where('id', $project->id)
|
||||
->visibleByEmployee($user)
|
||||
->exists();
|
||||
|
||||
if (! $hasAccess) {
|
||||
throw new AuthorizationException('You do not have permission to '.$permission.' in this project.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tasks
|
||||
*
|
||||
@@ -61,7 +82,9 @@ class TaskController extends Controller
|
||||
$query->whereNull('done_at');
|
||||
}
|
||||
|
||||
$tasks = $query->paginate(config('app.pagination_per_page_default'));
|
||||
$tasks = $query
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(config('app.pagination_per_page_default'));
|
||||
|
||||
return new TaskCollection($tasks);
|
||||
}
|
||||
@@ -75,7 +98,15 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function store(Organization $organization, TaskStoreRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:create');
|
||||
/** @var Project $project */
|
||||
$project = Project::query()->findOrFail($request->input('project_id'));
|
||||
|
||||
if ($this->hasPermission($organization, 'tasks:create:all')) {
|
||||
$this->checkPermission($organization, 'tasks:create:all');
|
||||
} else {
|
||||
$this->checkScopedPermissionForProject($organization, $project, 'tasks:create');
|
||||
}
|
||||
|
||||
$task = new Task;
|
||||
$task->name = $request->input('name');
|
||||
$task->project_id = $request->input('project_id');
|
||||
@@ -97,7 +128,17 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function update(Organization $organization, Task $task, TaskUpdateRequest $request): JsonResource
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:update', $task);
|
||||
// Check task belongs to organization
|
||||
if ($task->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Task does not belong to organization');
|
||||
}
|
||||
|
||||
if ($this->hasPermission($organization, 'tasks:update:all')) {
|
||||
$this->checkPermission($organization, 'tasks:update:all');
|
||||
} else {
|
||||
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:update');
|
||||
}
|
||||
|
||||
$task->name = $request->input('name');
|
||||
if ($this->canAccessPremiumFeatures($organization) && $request->has('estimated_time')) {
|
||||
$task->estimated_time = $request->getEstimatedTime();
|
||||
@@ -119,7 +160,16 @@ class TaskController extends Controller
|
||||
*/
|
||||
public function destroy(Organization $organization, Task $task): JsonResponse
|
||||
{
|
||||
$this->checkPermission($organization, 'tasks:delete', $task);
|
||||
// Check task belongs to organization
|
||||
if ($task->organization_id !== $organization->id) {
|
||||
throw new AuthorizationException('Task does not belong to organization');
|
||||
}
|
||||
|
||||
if ($this->hasPermission($organization, 'tasks:delete:all')) {
|
||||
$this->checkPermission($organization, 'tasks:delete:all');
|
||||
} else {
|
||||
$this->checkScopedPermissionForProject($organization, $task->project, 'tasks:delete');
|
||||
}
|
||||
|
||||
if ($task->timeEntries()->exists()) {
|
||||
throw new EntityStillInUseApiException('task', 'time_entry');
|
||||
|
||||
@@ -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,16 +46,56 @@ 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 Illuminate\Support\Str;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
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 +126,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 +181,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 +227,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',
|
||||
@@ -192,7 +247,7 @@ class TimeEntryController extends Controller
|
||||
'user',
|
||||
'tagsRelation',
|
||||
]);
|
||||
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
||||
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension();
|
||||
$folderPath = 'exports';
|
||||
$path = $folderPath.'/'.$filename;
|
||||
$localizationService = LocalizationService::forOrganization($organization);
|
||||
@@ -207,8 +262,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 +272,9 @@ class TimeEntryController extends Controller
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes,
|
||||
);
|
||||
$html = Blade::render($viewFile, [
|
||||
'timeEntries' => $timeEntriesQuery->get(),
|
||||
@@ -318,12 +376,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,
|
||||
@@ -334,7 +395,9 @@ class TimeEntryController extends Controller
|
||||
$request->getFillGapsInTimeGroups(),
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
);
|
||||
|
||||
return [
|
||||
@@ -362,6 +425,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;
|
||||
@@ -373,6 +437,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(),
|
||||
@@ -383,7 +449,9 @@ class TimeEntryController extends Controller
|
||||
false,
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
);
|
||||
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||
$timeEntriesAggregateQuery->clone(),
|
||||
@@ -394,13 +462,15 @@ class TimeEntryController extends Controller
|
||||
true,
|
||||
$request->getStart(),
|
||||
$request->getEnd(),
|
||||
$showBillableRate
|
||||
$showBillableRate,
|
||||
$roundingType,
|
||||
$roundingMinutes
|
||||
);
|
||||
$currency = $organization->currency;
|
||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||
$localizationService = LocalizationService::forOrganization($organization);
|
||||
|
||||
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
|
||||
$filename = 'time-entries-report-'.now()->format('Y-m-d_H-i-s').'-'.Str::uuid().'.'.$format->getFileExtension();
|
||||
$folderPath = 'exports';
|
||||
$path = $folderPath.'/'.$filename;
|
||||
|
||||
@@ -477,7 +547,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');
|
||||
@@ -519,17 +589,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);
|
||||
@@ -539,6 +607,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);
|
||||
}
|
||||
|
||||
@@ -554,15 +629,22 @@ class TimeEntryController extends Controller
|
||||
/** @var Member|null $member */
|
||||
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
|
||||
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
|
||||
$this->checkPermission($organization, 'time-entries:update:own');
|
||||
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
|
||||
} else {
|
||||
$this->checkPermission($organization, 'time-entries:update:all');
|
||||
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
|
||||
}
|
||||
|
||||
if ($timeEntry->end !== null && $request->has('end') && $request->input('end') === null) {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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,6 +51,7 @@ class HandleInertiaRequests extends Middleware
|
||||
return array_merge(parent::share($request), [
|
||||
'has_billing_extension' => $hasBilling,
|
||||
'has_invoicing_extension' => $hasInvoicing,
|
||||
'has_services_extension' => $hasServices,
|
||||
'billing' => $currentOrganization !== null ? [
|
||||
'has_subscription' => $billing->hasSubscription($currentOrganization),
|
||||
'has_trial' => $billing->hasTrial($currentOrganization),
|
||||
|
||||
@@ -21,6 +21,11 @@ class InvitationIndexRequest extends BaseFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ class MemberIndexRequest extends BaseFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
||||
'employees_can_see_billable_rates' => [
|
||||
'boolean',
|
||||
],
|
||||
'employees_can_manage_tasks' => [
|
||||
'boolean',
|
||||
],
|
||||
'prevent_overlapping_time_entries' => [
|
||||
'boolean',
|
||||
],
|
||||
'number_format' => [
|
||||
Rule::enum(NumberFormat::class),
|
||||
],
|
||||
@@ -98,4 +104,14 @@ class OrganizationUpdateRequest extends BaseFormRequest
|
||||
{
|
||||
return $this->has('employees_can_see_billable_rates') ? $this->boolean('employees_can_see_billable_rates') : null;
|
||||
}
|
||||
|
||||
public function getEmployeesCanManageTasks(): ?bool
|
||||
{
|
||||
return $this->has('employees_can_manage_tasks') ? $this->boolean('employees_can_manage_tasks') : null;
|
||||
}
|
||||
|
||||
public function getPreventOverlappingTimeEntries(): ?bool
|
||||
{
|
||||
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\ProjectMember;
|
||||
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class ProjectMemberIndexRequest extends BaseFormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
27
app/Http/Requests/V1/Report/ReportIndexRequest.php
Normal file
27
app/Http/Requests/V1/Report/ReportIndexRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Report;
|
||||
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class ReportIndexRequest extends BaseFormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,15 @@ 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 App\Service\TimeEntryFilter;
|
||||
use Illuminate\Contracts\Validation\Rule as LegacyValidationRule;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
@@ -22,7 +25,7 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|LegacyValidationRule>>
|
||||
* @return array<string, array<string|ValidationRule|LegacyValidationRule|\Closure>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -80,7 +83,14 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
],
|
||||
'properties.client_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
if (! Str::isUuid($value)) {
|
||||
$fail('The '.$attribute.' must be a valid UUID.');
|
||||
}
|
||||
},
|
||||
],
|
||||
// Filter by project IDs, project IDs are OR combined
|
||||
'properties.project_ids' => [
|
||||
@@ -89,7 +99,14 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
],
|
||||
'properties.project_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
if (! Str::isUuid($value)) {
|
||||
$fail('The '.$attribute.' must be a valid UUID.');
|
||||
}
|
||||
},
|
||||
],
|
||||
// Filter by tag IDs, tag IDs are OR combined
|
||||
'properties.tag_ids' => [
|
||||
@@ -98,7 +115,14 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
],
|
||||
'properties.tag_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
if (! Str::isUuid($value)) {
|
||||
$fail('The '.$attribute.' must be a valid UUID.');
|
||||
}
|
||||
},
|
||||
],
|
||||
'properties.task_ids' => [
|
||||
'nullable',
|
||||
@@ -106,7 +130,14 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
],
|
||||
'properties.task_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
if (! Str::isUuid($value)) {
|
||||
$fail('The '.$attribute.' must be a valid UUID.');
|
||||
}
|
||||
},
|
||||
],
|
||||
'properties.group' => [
|
||||
'required',
|
||||
@@ -128,6 +159,18 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
'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 +248,22 @@ class ReportStoreRequest extends BaseFormRequest
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
27
app/Http/Requests/V1/Tag/TagIndexRequest.php
Normal file
27
app/Http/Requests/V1/Tag/TagIndexRequest.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Tag;
|
||||
|
||||
use App\Http\Requests\V1\BaseFormRequest;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
|
||||
class TagIndexRequest extends BaseFormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:2147483647',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ class TaskIndexRequest extends BaseFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'page' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
'max:2147483647',
|
||||
],
|
||||
'project_id' => [
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
@@ -15,6 +16,7 @@ use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use App\Service\TimeEntryFilter;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -29,7 +31,7 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -93,10 +95,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
],
|
||||
'project_ids.*' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter by client IDs, client IDs are OR combined
|
||||
'client_ids' => [
|
||||
@@ -105,10 +112,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
],
|
||||
'client_ids.*' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter by tag IDs, tag IDs are OR combined
|
||||
'tag_ids' => [
|
||||
@@ -117,10 +129,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
],
|
||||
'tag_ids.*' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
@@ -129,9 +146,14 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
],
|
||||
'task_ids.*' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||
'start' => [
|
||||
@@ -164,6 +186,18 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
'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 +245,22 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -13,6 +14,7 @@ use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\User;
|
||||
use App\Service\TimeEntryFilter;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -27,7 +29,7 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -79,10 +81,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
],
|
||||
'project_ids.*' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter by client IDs, client IDs are OR combined
|
||||
'client_ids' => [
|
||||
@@ -91,10 +98,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
],
|
||||
'client_ids.*' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter by tag IDs, tag IDs are OR combined
|
||||
'tag_ids' => [
|
||||
@@ -103,10 +115,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
],
|
||||
'tag_ids.*' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
@@ -115,9 +132,14 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
],
|
||||
'task_ids.*' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||
'start' => [
|
||||
@@ -146,6 +168,18 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
'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 +207,22 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@ declare(strict_types=1);
|
||||
namespace App\Http\Requests\V1\TimeEntry;
|
||||
|
||||
use App\Enums\ExportFormat;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Models\Client;
|
||||
use App\Models\Member;
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Service\TimeEntryFilter;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
@@ -24,7 +27,7 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule|\Closure>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -56,6 +59,23 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
],
|
||||
// Filter by client IDs, client IDs are OR combined
|
||||
'client_ids' => [
|
||||
'array',
|
||||
'min:1',
|
||||
],
|
||||
'client_ids.*' => [
|
||||
'string',
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter by project IDs, project IDs are OR combined
|
||||
'project_ids' => [
|
||||
'array',
|
||||
@@ -63,11 +83,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
],
|
||||
'project_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter by tag IDs, tag IDs are OR combined
|
||||
'tag_ids' => [
|
||||
@@ -76,11 +100,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
],
|
||||
'tag_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
@@ -89,11 +117,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
||||
],
|
||||
'task_ids.*' => [
|
||||
'string',
|
||||
'uuid',
|
||||
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
}),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||
'start' => [
|
||||
@@ -133,6 +165,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 +214,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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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;
|
||||
@@ -11,8 +12,11 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Service\TimeEntryFilter;
|
||||
use Illuminate\Contracts\Validation\Rule as RuleContract;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
@@ -23,7 +27,7 @@ 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|\Closure>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
@@ -55,10 +59,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
],
|
||||
'client_ids.*' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Client::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Client> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter by project IDs, project IDs are OR combined
|
||||
'project_ids' => [
|
||||
@@ -67,10 +76,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
],
|
||||
'project_ids.*' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter by tag IDs, tag IDs are OR combined
|
||||
'tag_ids' => [
|
||||
@@ -79,10 +93,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
],
|
||||
'tag_ids.*' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Tag> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter by task IDs, task IDs are OR combined
|
||||
'task_ids' => [
|
||||
@@ -91,10 +110,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
],
|
||||
'task_ids.*' => [
|
||||
'string',
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid(),
|
||||
function (string $attribute, mixed $value, \Closure $fail): void {
|
||||
if ($value === TimeEntryFilter::NONE_VALUE) {
|
||||
return;
|
||||
}
|
||||
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Task> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
})->uuid()->validate($attribute, $value, $fail);
|
||||
},
|
||||
],
|
||||
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
|
||||
'start' => [
|
||||
@@ -136,6 +160,18 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
'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 +189,22 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
{
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
@@ -42,7 +44,16 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
||||
'required_with:task_id',
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||
|
||||
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||
$permissionStore = app(PermissionStore::class);
|
||||
if (! $permissionStore->has($this->organization, 'time-entries:create:all')
|
||||
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||
$builder = $builder->visibleByEmployee(Auth::user());
|
||||
}
|
||||
|
||||
return $builder;
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the task that the time entry should belong to
|
||||
@@ -79,7 +90,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:500',
|
||||
'max:5000',
|
||||
],
|
||||
// List of tag IDs
|
||||
'tags' => [
|
||||
|
||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
@@ -54,7 +56,16 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
||||
'required_with:task_id',
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||
|
||||
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||
$permissionStore = app(PermissionStore::class);
|
||||
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
|
||||
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||
$builder = $builder->visibleByEmployee(Auth::user());
|
||||
}
|
||||
|
||||
return $builder;
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the task that the time entry should belong to
|
||||
@@ -79,7 +90,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
|
||||
'changes.description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:500',
|
||||
'max:5000',
|
||||
],
|
||||
// List of tag IDs
|
||||
'changes.tags' => [
|
||||
|
||||
@@ -10,8 +10,10 @@ use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Service\PermissionStore;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||
|
||||
/**
|
||||
@@ -42,7 +44,16 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
||||
'required_with:task_id',
|
||||
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
|
||||
/** @var Builder<Project> $builder */
|
||||
return $builder->whereBelongsTo($this->organization, 'organization');
|
||||
$builder = $builder->whereBelongsTo($this->organization, 'organization');
|
||||
|
||||
// If user doesn't have 'all' permission for time entries or projects, only allow access to public projects or projects they're a member of
|
||||
$permissionStore = app(PermissionStore::class);
|
||||
if (! $permissionStore->has($this->organization, 'time-entries:update:all')
|
||||
&& ! $permissionStore->has($this->organization, 'projects:view:all')) {
|
||||
$builder = $builder->visibleByEmployee(Auth::user());
|
||||
}
|
||||
|
||||
return $builder;
|
||||
})->uuid(),
|
||||
],
|
||||
// ID of the task that the time entry should belong to
|
||||
@@ -77,7 +88,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
|
||||
'description' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'max:500',
|
||||
'max:5000',
|
||||
],
|
||||
// List of tag IDs
|
||||
'tags' => [
|
||||
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Client;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class ClientCollection extends ResourceCollection
|
||||
class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -53,6 +53,10 @@ 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 $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
||||
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
|
||||
/** @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 */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -4,9 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Tag;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class TagCollection extends ResourceCollection
|
||||
class TagCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
44
app/Mail/AuthApiTokenExpirationReminderMail.php
Normal file
44
app/Mail/AuthApiTokenExpirationReminderMail.php
Normal 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!'));
|
||||
}
|
||||
}
|
||||
44
app/Mail/AuthApiTokenExpiredMail.php
Normal file
44
app/Mail/AuthApiTokenExpiredMail.php
Normal 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!'));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -62,6 +63,18 @@ class Client extends Model implements AuditableContract
|
||||
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>
|
||||
*/
|
||||
|
||||
@@ -35,6 +35,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
|
||||
* @property int|null $billable_rate
|
||||
* @property string $user_id
|
||||
* @property bool $employees_can_see_billable_rates
|
||||
* @property bool $employees_can_manage_tasks
|
||||
* @property User $owner
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
@@ -70,6 +71,8 @@ class Organization extends JetstreamTeam implements AuditableContract
|
||||
'personal_team' => 'boolean',
|
||||
'currency' => 'string',
|
||||
'employees_can_see_billable_rates' => 'boolean',
|
||||
'employees_can_manage_tasks' => 'boolean',
|
||||
'prevent_overlapping_time_entries' => 'boolean',
|
||||
'number_format' => NumberFormat::class,
|
||||
'currency_format' => CurrencyFormat::class,
|
||||
'date_format' => DateFormat::class,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -18,11 +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
|
||||
{
|
||||
@@ -52,4 +57,40 @@ class Token extends PassportToken
|
||||
{
|
||||
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');
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,26 @@ class TimeEntry extends Model implements AuditableContract
|
||||
'still_active_email_sent_at' => 'datetime',
|
||||
];
|
||||
|
||||
public const array SELECT_COLUMNS = [
|
||||
'id',
|
||||
'description',
|
||||
'start',
|
||||
'end',
|
||||
'billable_rate',
|
||||
'billable',
|
||||
'user_id',
|
||||
'organization_id',
|
||||
'project_id',
|
||||
'task_id',
|
||||
'tags',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'member_id',
|
||||
'client_id',
|
||||
'is_imported',
|
||||
'still_active_email_sent_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that are computed. (f.e. for performance reasons)
|
||||
* These attributes can be regenerated at any time.
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace App\Policies;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
@@ -58,7 +59,7 @@ class OrganizationPolicy
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->ownsTeam($organization);
|
||||
return app(PermissionStore::class)->userHas($organization, $user, 'organizations:update');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -94,8 +94,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tasks:view',
|
||||
'tasks:view:all',
|
||||
'tasks:create',
|
||||
'tasks:create:all',
|
||||
'tasks:update',
|
||||
'tasks:update:all',
|
||||
'tasks:delete',
|
||||
'tasks:delete:all',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
@@ -109,6 +112,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
@@ -157,8 +161,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tasks:view',
|
||||
'tasks:view:all',
|
||||
'tasks:create',
|
||||
'tasks:create:all',
|
||||
'tasks:update',
|
||||
'tasks:update:all',
|
||||
'tasks:delete',
|
||||
'tasks:delete:all',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
@@ -172,6 +179,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
@@ -217,8 +225,11 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tasks:view',
|
||||
'tasks:view:all',
|
||||
'tasks:create',
|
||||
'tasks:create:all',
|
||||
'tasks:update',
|
||||
'tasks:update:all',
|
||||
'tasks:delete',
|
||||
'tasks:delete:all',
|
||||
'time-entries:view:all',
|
||||
'time-entries:create:all',
|
||||
'time-entries:update:all',
|
||||
@@ -232,6 +243,7 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'tags:update',
|
||||
'tags:delete',
|
||||
'clients:view',
|
||||
'clients:view:all',
|
||||
'clients:create',
|
||||
'clients:update',
|
||||
'clients:delete',
|
||||
@@ -256,12 +268,13 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'projects:view',
|
||||
'tags:view',
|
||||
'tasks:view',
|
||||
'clients:view',
|
||||
'time-entries:view:own',
|
||||
'time-entries:create:own',
|
||||
'time-entries:update:own',
|
||||
'time-entries:delete:own',
|
||||
'organizations:view',
|
||||
])->description('Employees have the ability to read, create, and update their own time entries and they can see the projects that they are members of.');
|
||||
])->description('Employees have the ability to read, create, and update their own time entries, they can see the projects that they are members of and the clients they are assigned to.');
|
||||
|
||||
Jetstream::role(Role::Placeholder->value, 'Placeholder', [
|
||||
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Brick\Money\ISOCurrencyProvider;
|
||||
use Brick\Money\Money;
|
||||
|
||||
class CurrencyService
|
||||
@@ -374,4 +375,12 @@ class CurrencyService
|
||||
|
||||
return $currencyCode;
|
||||
}
|
||||
|
||||
public function getRandomCurrencyCode(): string
|
||||
{
|
||||
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
|
||||
$currencyCodes = array_keys($currencies);
|
||||
|
||||
return $currencyCodes[array_rand($currencyCodes)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +266,8 @@ class DashboardService
|
||||
) as aggregate'))
|
||||
->where('billable', '=', true)
|
||||
->whereNotNull('billable_rate')
|
||||
->where('user_id', '=', $user->id);
|
||||
->where('user_id', '=', $user->getKey())
|
||||
->where('organization_id', '=', $organization->getKey());
|
||||
|
||||
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
|
||||
/** @var Collection<int, object{aggregate: int}> $resultDb */
|
||||
|
||||
@@ -6,7 +6,9 @@ namespace App\Service\Dto;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Service\TimeEntryFilter;
|
||||
use Illuminate\Contracts\Database\Eloquent\Castable;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -59,6 +61,10 @@ class ReportPropertiesDto implements Castable
|
||||
*/
|
||||
public ?Collection $taskIds = null;
|
||||
|
||||
public ?TimeEntryRoundingType $roundingType = null;
|
||||
|
||||
public ?int $roundingMinutes = null;
|
||||
|
||||
/**
|
||||
* Get the caster class to use when casting from / to this cast target.
|
||||
*
|
||||
@@ -115,6 +121,10 @@ class ReportPropertiesDto implements Castable
|
||||
$dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
||||
$dto->weekStart = Weekday::from($data->weekStart);
|
||||
$dto->timezone = $data->timezone;
|
||||
// Note: roundingType was added later so it is possible that the value is missing in persisted reports in the DB
|
||||
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null;
|
||||
// Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB
|
||||
$dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
|
||||
|
||||
return $dto;
|
||||
}
|
||||
@@ -140,6 +150,8 @@ class ReportPropertiesDto implements Castable
|
||||
'historyGroup' => $value->historyGroup->value,
|
||||
'weekStart' => $value->weekStart->value,
|
||||
'timezone' => $value->timezone,
|
||||
'roundingType' => $value->roundingType?->value,
|
||||
'roundingMinutes' => $value->roundingMinutes,
|
||||
];
|
||||
|
||||
$jsonString = json_encode($data);
|
||||
@@ -163,7 +175,7 @@ class ReportPropertiesDto implements Castable
|
||||
if (! is_string($id)) {
|
||||
throw new \InvalidArgumentException('The given ID is not a string');
|
||||
}
|
||||
if (! Str::isUuid($id)) {
|
||||
if ($id !== TimeEntryFilter::NONE_VALUE && ! Str::isUuid($id)) {
|
||||
throw new \InvalidArgumentException('The given ID is not a valid UUID');
|
||||
}
|
||||
$collection->push($id);
|
||||
|
||||
@@ -167,7 +167,7 @@ class ExportService
|
||||
$client->id,
|
||||
$client->name,
|
||||
$client->organization_id,
|
||||
$client->archived_at ?? '',
|
||||
$client->archived_at?->toIso8601ZuluString() ?? '',
|
||||
$client->created_at?->toIso8601ZuluString() ?? '',
|
||||
$client->updated_at?->toIso8601ZuluString() ?? '',
|
||||
]);
|
||||
|
||||
@@ -112,7 +112,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
if (strlen($record['Description']) > 500) {
|
||||
if (strlen($record['Description']) > 5000) {
|
||||
throw new ImportException('Time entry description is too long');
|
||||
}
|
||||
$timeEntry->description = $record['Description'];
|
||||
|
||||
@@ -107,7 +107,7 @@ class HarvestTimeEntriesImporter extends DefaultImporter
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
if (strlen($record['Notes']) > 500) {
|
||||
if (strlen($record['Notes']) > 5000) {
|
||||
throw new ImportException('Time entry note is too long');
|
||||
}
|
||||
$timeEntry->description = $record['Notes'];
|
||||
|
||||
@@ -247,7 +247,7 @@ class SolidtimeImporter extends DefaultImporter
|
||||
$timeEntry->project_id = $projectId;
|
||||
$timeEntry->client_id = $clientId;
|
||||
$timeEntry->organization_id = $this->organization->id;
|
||||
if (strlen($timeEntryRow['description']) > 500) {
|
||||
if (strlen($timeEntryRow['description']) > 5000) {
|
||||
throw new ImportException('Time entry description is too long');
|
||||
}
|
||||
$timeEntry->description = $timeEntryRow['description'];
|
||||
|
||||
@@ -96,6 +96,30 @@ class LocalizationService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a duration for reporting contexts (PDF reports, places that display duration
|
||||
* directly next to cost). Promotes the verbose `Hh Mm` format to the compact `HH:MM:SS`
|
||||
* so totals stay narrow and reconcile with cost, which is always computed to the second.
|
||||
*/
|
||||
public function formatIntervalForReporting(CarbonInterval $interval): string
|
||||
{
|
||||
$promoted = [
|
||||
IntervalFormat::HoursMinutes,
|
||||
IntervalFormat::HoursMinutesColonSeparated,
|
||||
];
|
||||
if (! in_array($this->intervalFormat, $promoted, true)) {
|
||||
return $this->formatInterval($interval);
|
||||
}
|
||||
|
||||
$previous = $this->intervalFormat;
|
||||
$this->intervalFormat = IntervalFormat::HoursMinutesSecondsColonSeparated;
|
||||
try {
|
||||
return $this->formatInterval($interval);
|
||||
} finally {
|
||||
$this->intervalFormat = $previous;
|
||||
}
|
||||
}
|
||||
|
||||
public function formatCurrency(Money $money): string
|
||||
{
|
||||
$currencyService = app(CurrencyService::class);
|
||||
|
||||
@@ -196,6 +196,7 @@ class MemberService
|
||||
|
||||
$placeholderUser = $user->replicate();
|
||||
$placeholderUser->is_placeholder = true;
|
||||
$placeholderUser->current_team_id = $member->organization_id;
|
||||
$placeholderUser->save();
|
||||
|
||||
$member->user()->associate($placeholderUser);
|
||||
|
||||
@@ -71,7 +71,19 @@ class PermissionStore
|
||||
/** @var Role|null $roleObj */
|
||||
$roleObj = Jetstream::findRole($role);
|
||||
|
||||
return $roleObj->permissions ?? [];
|
||||
$permissions = $roleObj->permissions ?? [];
|
||||
|
||||
// If the organization allows employees to manage tasks and the user is an employee,
|
||||
// add the task management permissions for accessible projects
|
||||
if ($role === \App\Enums\Role::Employee->value && $organization->employees_can_manage_tasks) {
|
||||
$permissions = array_merge($permissions, [
|
||||
'tasks:create',
|
||||
'tasks:update',
|
||||
'tasks:delete',
|
||||
]);
|
||||
}
|
||||
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,9 +6,11 @@ namespace App\Service;
|
||||
|
||||
use App\Enums\TimeEntryAggregationType;
|
||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use App\Enums\Weekday;
|
||||
use App\Models\Client;
|
||||
use App\Models\Project;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Task;
|
||||
use App\Models\TimeEntry;
|
||||
use App\Models\User;
|
||||
@@ -16,6 +18,7 @@ use Carbon\CarbonTimeZone;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TimeEntryAggregationService
|
||||
@@ -41,12 +44,24 @@ class TimeEntryAggregationService
|
||||
* cost: int|null
|
||||
* }
|
||||
*/
|
||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
||||
public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
||||
{
|
||||
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||
/** @var Builder<TimeEntry> $baseTotalsQuery */
|
||||
$baseTotalsQuery = $timeEntriesQuery->clone();
|
||||
$group1Select = null;
|
||||
$group2Select = null;
|
||||
$groupBy = null;
|
||||
// If any grouping is by tag, expand rows per tag and ensure a NULL row for entries without tags
|
||||
if (($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag)) {
|
||||
$timeEntriesQuery->crossJoin(DB::raw(
|
||||
"LATERAL (\n".
|
||||
" SELECT jsonb_array_elements_text(coalesce(tags, '[]'::jsonb)) AS tag\n".
|
||||
" UNION ALL\n".
|
||||
" SELECT ''::text AS tag WHERE coalesce(jsonb_array_length(tags), 0) = 0\n".
|
||||
') AS tag(tag)'
|
||||
));
|
||||
}
|
||||
if ($group1Type !== null) {
|
||||
$group1Select = $this->getGroupByQuery($group1Type, $timezone, $startOfWeek);
|
||||
$groupBy = ['group_1'];
|
||||
@@ -56,15 +71,14 @@ class TimeEntryAggregationService
|
||||
}
|
||||
}
|
||||
|
||||
$startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes);
|
||||
$endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes);
|
||||
|
||||
$timeEntriesQuery->selectRaw(
|
||||
($group1Select !== null ? $group1Select.' as group_1,' : '').
|
||||
($group2Select !== null ? $group2Select.' as group_2,' : '').
|
||||
' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'.
|
||||
' round(
|
||||
sum(
|
||||
extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60)
|
||||
)
|
||||
) as cost'
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||
);
|
||||
if ($groupBy !== null) {
|
||||
$timeEntriesQuery->groupBy($groupBy);
|
||||
@@ -84,6 +98,26 @@ class TimeEntryAggregationService
|
||||
$group1Response = [];
|
||||
$group1ResponseSum = 0;
|
||||
$group1ResponseCost = 0;
|
||||
// If Tag is subgroup, prepare base totals per primary group without tag expansion
|
||||
$baseTotalsPerGroup1Map = [];
|
||||
if ($group2Type === TimeEntryAggregationType::Tag) {
|
||||
$baseTotalsPerGroup1Query = $baseTotalsQuery->clone();
|
||||
$baseTotalsPerGroup1 = $baseTotalsPerGroup1Query
|
||||
->selectRaw(
|
||||
$group1Select.' as group_1,'.
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||
)
|
||||
->groupBy('group_1')
|
||||
->get();
|
||||
foreach ($baseTotalsPerGroup1 as $row) {
|
||||
/** @var object{group_1: mixed, aggregate: int|null, cost: int|null} $row */
|
||||
$baseTotalsPerGroup1Map[(string) ($row->group_1 ?? '')] = [
|
||||
'aggregate' => (int) ($row->aggregate ?? 0),
|
||||
'cost' => (int) ($row->cost ?? 0),
|
||||
];
|
||||
}
|
||||
}
|
||||
foreach ($groupedAggregates as $group1 => $group1Aggregates) {
|
||||
/** @var string|int $group1 */
|
||||
$group2Response = [];
|
||||
@@ -103,6 +137,14 @@ class TimeEntryAggregationService
|
||||
$group2ResponseSum += (int) $aggregate->get(0)->aggregate;
|
||||
$group2ResponseCost += (int) $aggregate->get(0)->cost;
|
||||
}
|
||||
// Override primary group totals when Tag is subgroup to avoid double counting
|
||||
if ($group2Type === TimeEntryAggregationType::Tag) {
|
||||
$keyForMap = (string) $group1;
|
||||
if (array_key_exists($keyForMap, $baseTotalsPerGroup1Map)) {
|
||||
$group2ResponseSum = $baseTotalsPerGroup1Map[$keyForMap]['aggregate'];
|
||||
$group2ResponseCost = $baseTotalsPerGroup1Map[$keyForMap]['cost'];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
/** @var Collection<int, object{aggregate: int, cost: int}> $group1Aggregates */
|
||||
$group2ResponseSum = (int) $group1Aggregates->get(0)->aggregate;
|
||||
@@ -121,6 +163,23 @@ class TimeEntryAggregationService
|
||||
$group1ResponseCost += $group2ResponseCost;
|
||||
}
|
||||
|
||||
// If Tag is selected in any grouping, compute overall totals from base (non-tag-expanded) query to avoid double counting
|
||||
$hasTagGrouping = ($group1Type === TimeEntryAggregationType::Tag) || ($group2Type === TimeEntryAggregationType::Tag);
|
||||
if ($hasTagGrouping) {
|
||||
// Reset selects and ordering on the cloned base query
|
||||
$baseTotals = $baseTotalsQuery
|
||||
->selectRaw(
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||
)
|
||||
->first();
|
||||
if ($baseTotals !== null) {
|
||||
/** @var object{aggregate: int|null, cost: int|null} $baseTotals */
|
||||
$group1ResponseSum = (int) ($baseTotals->aggregate ?? 0);
|
||||
$group1ResponseCost = (int) ($baseTotals->cost ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
if ($fillGapsInTimeGroupsIsPossible) {
|
||||
$group1Response = $this->fillGapsInTimeGroups($group1Response, $group1Type, $group2Type, $timezone, $startOfWeek, $start, $end);
|
||||
}
|
||||
@@ -164,9 +223,9 @@ class TimeEntryAggregationService
|
||||
* cost: int|null
|
||||
* }
|
||||
*/
|
||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array
|
||||
public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array
|
||||
{
|
||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate);
|
||||
$aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes);
|
||||
|
||||
$keysGroup1 = [];
|
||||
$keysGroup2 = [];
|
||||
@@ -294,6 +353,17 @@ class TimeEntryAggregationService
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
} elseif ($type === TimeEntryAggregationType::Tag) {
|
||||
$tags = Tag::query()
|
||||
->whereIn('id', $keys)
|
||||
->select('id', 'name')
|
||||
->get();
|
||||
foreach ($tags as $tag) {
|
||||
$descriptorMap[$tag->id] = [
|
||||
'description' => $tag->name,
|
||||
'color' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $descriptorMap;
|
||||
@@ -436,6 +506,8 @@ class TimeEntryAggregationService
|
||||
return 'billable';
|
||||
} elseif ($group === TimeEntryAggregationType::Description) {
|
||||
return 'description';
|
||||
} elseif ($group === TimeEntryAggregationType::Tag) {
|
||||
return 'tag';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
class TimeEntryFilter
|
||||
{
|
||||
public const string NONE_VALUE = 'none';
|
||||
|
||||
/**
|
||||
* @var Builder<TimeEntry>
|
||||
*/
|
||||
@@ -149,7 +151,17 @@ class TimeEntryFilter
|
||||
if ($clientIds === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->whereIn('client_id', $clientIds);
|
||||
$includeNone = in_array(self::NONE_VALUE, $clientIds, true);
|
||||
$clientIds = array_values(array_filter($clientIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||
|
||||
$this->builder->where(function (Builder $builder) use ($clientIds, $includeNone): void {
|
||||
if (count($clientIds) > 0) {
|
||||
$builder->whereIn('client_id', $clientIds);
|
||||
}
|
||||
if ($includeNone) {
|
||||
$builder->orWhereNull('client_id');
|
||||
}
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -162,7 +174,17 @@ class TimeEntryFilter
|
||||
if ($projectIds === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->whereIn('project_id', $projectIds);
|
||||
$includeNone = in_array(self::NONE_VALUE, $projectIds, true);
|
||||
$projectIds = array_values(array_filter($projectIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||
|
||||
$this->builder->where(function (Builder $builder) use ($projectIds, $includeNone): void {
|
||||
if (count($projectIds) > 0) {
|
||||
$builder->whereIn('project_id', $projectIds);
|
||||
}
|
||||
if ($includeNone) {
|
||||
$builder->orWhereNull('project_id');
|
||||
}
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
@@ -175,10 +197,18 @@ class TimeEntryFilter
|
||||
if ($tagIds === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->where(function (Builder $builder) use ($tagIds): void {
|
||||
$includeNone = in_array(self::NONE_VALUE, $tagIds, true);
|
||||
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||
|
||||
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void {
|
||||
foreach ($tagIds as $tagId) {
|
||||
$builder->orWhereJsonContains('tags', $tagId);
|
||||
}
|
||||
if ($includeNone) {
|
||||
$builder->orWhere(function (Builder $query): void {
|
||||
$query->whereJsonLength('tags', 0)->orWhereNull('tags');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return $this;
|
||||
@@ -192,7 +222,17 @@ class TimeEntryFilter
|
||||
if ($taskIds === null) {
|
||||
return $this;
|
||||
}
|
||||
$this->builder->whereIn('task_id', $taskIds);
|
||||
$includeNone = in_array(self::NONE_VALUE, $taskIds, true);
|
||||
$taskIds = array_values(array_filter($taskIds, fn (string $id): bool => $id !== self::NONE_VALUE));
|
||||
|
||||
$this->builder->where(function (Builder $builder) use ($taskIds, $includeNone): void {
|
||||
if (count($taskIds) > 0) {
|
||||
$builder->whereIn('task_id', $taskIds);
|
||||
}
|
||||
if ($includeNone) {
|
||||
$builder->orWhereNull('task_id');
|
||||
}
|
||||
});
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
47
app/Service/TimeEntryService.php
Normal file
47
app/Service/TimeEntryService.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Enums\TimeEntryRoundingType;
|
||||
use Illuminate\Support\Carbon;
|
||||
use LogicException;
|
||||
|
||||
class TimeEntryService
|
||||
{
|
||||
public function getStartSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
|
||||
{
|
||||
if ($roundingType === null || $roundingMinutes === null) {
|
||||
return 'start';
|
||||
}
|
||||
if ($roundingMinutes < 1) {
|
||||
throw new LogicException('Rounding minutes must be greater than 0');
|
||||
}
|
||||
|
||||
return 'date_bin(\'1 minutes\', start, TIMESTAMP \'1970-01-01\')';
|
||||
}
|
||||
|
||||
public function getEndSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
|
||||
{
|
||||
if ($roundingType === null || $roundingMinutes === null) {
|
||||
return 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
||||
}
|
||||
if ($roundingMinutes < 1) {
|
||||
throw new LogicException('Rounding minutes must be greater than 0');
|
||||
}
|
||||
$end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')';
|
||||
$start = $this->getStartSelectRawForRounding($roundingType, $roundingMinutes);
|
||||
if ($roundingType === TimeEntryRoundingType::Down) {
|
||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.')';
|
||||
} elseif ($roundingType === TimeEntryRoundingType::Up) {
|
||||
// If end is already on a boundary, keep it; otherwise round up to next boundary
|
||||
return 'CASE WHEN '.$end.' = date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$start.') '.
|
||||
'THEN '.$end.' '.
|
||||
'ELSE date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$start.') '.
|
||||
'END';
|
||||
} elseif ($roundingType === TimeEntryRoundingType::Nearest) {
|
||||
return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$start.')';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,7 +118,8 @@
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": [
|
||||
"laravel/telescope"
|
||||
"laravel/telescope",
|
||||
"nwidart/laravel-modules"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
2066
composer.lock
generated
2066
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use Illuminate\Support\Facades\Facade;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Nwidart\Modules\LaravelModulesServiceProvider;
|
||||
|
||||
return [
|
||||
|
||||
@@ -197,6 +198,7 @@ return [
|
||||
App\Providers\FortifyServiceProvider::class,
|
||||
App\Providers\JetstreamServiceProvider::class,
|
||||
// Warning: Do not add TelescopeServiceProvider here since it is already conditionally registered in AppServiceProvider
|
||||
LaravelModulesServiceProvider::class,
|
||||
])->toArray(),
|
||||
|
||||
/*
|
||||
|
||||
@@ -6,6 +6,7 @@ return [
|
||||
|
||||
'tasks' => [
|
||||
'time_entry_send_still_running_mails' => (bool) env('SCHEDULING_TASK_TIME_ENTRY_SEND_STILL_RUNNING_MAILS', true),
|
||||
'auth_send_mails_expiring_api_tokens' => (bool) env('SCHEDULING_TASK_AUTH_SEND_MAILS_EXPIRING_API_TOKENS', true),
|
||||
'self_hosting_check_for_update' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_CHECK_FOR_UPDATE', true),
|
||||
'self_hosting_telemetry' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_TELEMETRY', true),
|
||||
'self_hosting_database_consistency' => (bool) env('SCHEDULING_TASK_SELF_HOSTING_DATABASE_CONSISTENCY', false),
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Enums\NumberFormat;
|
||||
use App\Enums\TimeFormat;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\CurrencyService;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
@@ -27,7 +28,7 @@ class OrganizationFactory extends Factory
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->unique()->company(),
|
||||
'currency' => $this->faker->currencyCode(),
|
||||
'currency' => app(CurrencyService::class)->getRandomCurrencyCode(),
|
||||
'billable_rate' => null,
|
||||
'user_id' => User::factory(),
|
||||
'personal_team' => true,
|
||||
|
||||
@@ -36,6 +36,22 @@ class ClientFactory extends BaseClientFactory
|
||||
];
|
||||
}
|
||||
|
||||
public function desktopClient(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'name' => 'Desktop',
|
||||
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'authorization_code', 'implicit'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function apiClient(): self
|
||||
{
|
||||
return $this->state(fn (array $attributes) => [
|
||||
'name' => 'API',
|
||||
'grant_types' => ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token', 'client_credentials', 'personal_access'],
|
||||
]);
|
||||
}
|
||||
|
||||
public function personalAccessClient(): self
|
||||
{
|
||||
return $this->state(function (array $attributes) {
|
||||
|
||||
@@ -31,6 +31,8 @@ class TokenFactory extends Factory
|
||||
'created_at' => $this->faker->dateTime,
|
||||
'updated_at' => $this->faker->dateTime,
|
||||
'expires_at' => $this->faker->dateTime,
|
||||
'reminder_sent_at' => null,
|
||||
'expired_info_sent_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -153,6 +153,16 @@ class TimeEntryFactory extends Factory
|
||||
});
|
||||
}
|
||||
|
||||
public function endWithDuration(Carbon $end, int $durationInSeconds): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($end, $durationInSeconds): array {
|
||||
return [
|
||||
'start' => $end->copy()->utc()->subSeconds($durationInSeconds),
|
||||
'end' => $end->copy()->utc(),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function start(Carbon $start): self
|
||||
{
|
||||
return $this->state(function (array $attributes) use ($start): array {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
|
||||
$table->dateTime('reminder_sent_at')->nullable();
|
||||
$table->dateTime('expired_info_sent_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('oauth_access_tokens', function (Blueprint $table): void {
|
||||
$table->dropColumn('reminder_sent_at');
|
||||
$table->dropColumn('expired_info_sent_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('organizations', function (Blueprint $table): void {
|
||||
$table->boolean('prevent_overlapping_time_entries')->default(false)->after('employees_can_see_billable_rates');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('organizations', function (Blueprint $table): void {
|
||||
$table->dropColumn('prevent_overlapping_time_entries');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->string('description', 5000)->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('time_entries', function (Blueprint $table): void {
|
||||
$table->string('description', 500)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('organizations', function (Blueprint $table): void {
|
||||
$table->boolean('employees_can_manage_tasks')->default(false)->after('employees_can_see_billable_rates');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('organizations', function (Blueprint $table): void {
|
||||
$table->dropColumn('employees_can_manage_tasks');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -435,7 +435,7 @@ CREATE TABLE public.tasks (
|
||||
|
||||
CREATE TABLE public.time_entries (
|
||||
id uuid NOT NULL,
|
||||
description character varying(500) NOT NULL,
|
||||
description character varying(5000) NOT NULL,
|
||||
start timestamp(0) without time zone NOT NULL,
|
||||
"end" timestamp(0) without time zone,
|
||||
billable_rate integer,
|
||||
|
||||
@@ -5,8 +5,6 @@ services:
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
WWWGROUP: '${WWWGROUP}'
|
||||
ports:
|
||||
- '${FORWARD_WEB_PORT:-8083}:80'
|
||||
image: sail-8.3/app
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
@@ -109,7 +107,7 @@ services:
|
||||
- sail
|
||||
- reverse-proxy
|
||||
playwright:
|
||||
image: mcr.microsoft.com/playwright:v1.51.1-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.58.1-jammy
|
||||
command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0']
|
||||
working_dir: /src
|
||||
extra_hosts:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# Source: https://helgesver.re/articles/laravel-sail-create-minio-bucket-automatically
|
||||
|
||||
/usr/bin/mc config host add local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
|
||||
/usr/bin/mc alias set local ${S3_ENDPOINT} ${S3_ACCESS_KEY_ID} ${S3_SECRET_ACCESS_KEY};
|
||||
/usr/bin/mc rm -r --force local/${S3_BUCKET};
|
||||
/usr/bin/mc mb --ignore-existing local/${S3_BUCKET};
|
||||
/usr/bin/mc anonymous set public local/${S3_BUCKET};
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN CGO_ENABLED=1 \
|
||||
XCADDY_GO_BUILD_FLAGS="-ldflags='-w -s' -tags=nobadger,nomysql,nopgx" \
|
||||
CGO_CFLAGS=$(php-config --includes) \
|
||||
CGO_LDFLAGS="$(php-config --ldflags) $(php-config --libs)" \
|
||||
xcaddy build \
|
||||
xcaddy build v2.10.0 \
|
||||
--output /usr/local/bin/frankenphp \
|
||||
--with github.com/dunglas/frankenphp=./ \
|
||||
--with github.com/dunglas/frankenphp/caddy=./caddy/ \
|
||||
@@ -68,6 +68,7 @@ RUN apt-get update; \
|
||||
wget \
|
||||
vim \
|
||||
git \
|
||||
gosu \
|
||||
ncdu \
|
||||
procps \
|
||||
unzip \
|
||||
@@ -193,9 +194,20 @@ COPY --link --chown=${WWWUSER}:${WWWUSER} . .
|
||||
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
|
||||
|
||||
RUN mkdir -p \
|
||||
storage/framework/{sessions,views,cache,testing} \
|
||||
storage/framework/{sessions,views,cache/data,testing} \
|
||||
storage/logs \
|
||||
bootstrap/cache && chmod -R a+rw storage
|
||||
storage/app/public \
|
||||
storage/app/private \
|
||||
bootstrap/cache && \
|
||||
ln -s ../storage/app/public public/storage && \
|
||||
chmod -R a+rw storage bootstrap/cache
|
||||
|
||||
# OpenShift / arbitrary-UID compatibility: group 0 (root group) gets read+write+execute
|
||||
# on writable paths. Any UID can run the container if it joins the root group.
|
||||
# https://docs.openshift.com/container-platform/latest/openshift_images/create-images.html
|
||||
USER root
|
||||
RUN chgrp -R 0 storage bootstrap/cache && \
|
||||
chmod -R g+rwX storage bootstrap/cache
|
||||
|
||||
#RUN composer install \
|
||||
# --classmap-authoritative \
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
[program:octane]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan octane:frankenphp --host=0.0.0.0 --port=8000 --admin-port=2019 --caddyfile=%(ENV_ROOT)s/docker/prod/deployment/octane/FrankenPHP/Caddyfile
|
||||
user = %(ENV_USER)s
|
||||
priority = 1
|
||||
autostart = true
|
||||
autorestart = true
|
||||
@@ -14,7 +13,6 @@ stderr_logfile_maxbytes = 0
|
||||
[program:horizon]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan horizon
|
||||
user = %(ENV_USER)s
|
||||
priority = 3
|
||||
autostart = %(ENV_WITH_HORIZON)s
|
||||
autorestart = true
|
||||
@@ -27,7 +25,6 @@ stopwaitsecs = 3600
|
||||
[program:scheduler]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = supercronic -overlapping /etc/supercronic/laravel
|
||||
user = %(ENV_USER)s
|
||||
autostart = %(ENV_WITH_SCHEDULER)s
|
||||
autorestart = true
|
||||
stdout_logfile = %(ENV_ROOT)s/storage/logs/scheduler.log
|
||||
@@ -38,7 +35,6 @@ stderr_logfile_maxbytes = 200MB
|
||||
[program:clear-scheduler-cache]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user = %(ENV_USER)s
|
||||
autostart = %(ENV_WITH_SCHEDULER)s
|
||||
autorestart = false
|
||||
startsecs = 0
|
||||
@@ -51,7 +47,6 @@ stderr_logfile_maxbytes = 200MB
|
||||
[program:reverb]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan reverb:start
|
||||
user = %(ENV_USER)s
|
||||
priority = 2
|
||||
autostart = %(ENV_WITH_REVERB)s
|
||||
autorestart = true
|
||||
|
||||
@@ -1,6 +1,240 @@
|
||||
#!/usr/bin/env sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# ============================================================================
|
||||
# Solidtime container entrypoint.
|
||||
#
|
||||
# Layout:
|
||||
# 1. Storage tree bootstrap (idempotent, runs as any user)
|
||||
# 2. UID/GID remap + chown (root only, controlled by PUID/PGID env vars)
|
||||
# 3. Pre-flight write test (fails fast with a diagnosis message)
|
||||
# 4. Privilege drop via gosu, then re-exec self as APP_USER
|
||||
# 5. Original CONTAINER_MODE routing (runs as APP_USER)
|
||||
#
|
||||
# Env vars:
|
||||
# PUID, PGID UID/GID for the application user. Defaults 1000:1000.
|
||||
# Only takes effect when the container starts as root
|
||||
# (which is the image's default — if you set a
|
||||
# `user:` directive in compose, PUID/PGID are ignored
|
||||
# and a startup warning is printed).
|
||||
# SOLIDTIME_DROP_PRIVILEGES auto (default) | never
|
||||
# auto: if started as root, drop privileges to APP_USER; otherwise just exec.
|
||||
# never: never drop privileges. Run as whatever UID/GID was started.
|
||||
# ============================================================================
|
||||
|
||||
APP_USER="octane"
|
||||
APP_PATH="${ROOT:-/var/www/html}"
|
||||
STORAGE_PATH="${APP_PATH}/storage"
|
||||
CACHE_PATH="${APP_PATH}/bootstrap/cache"
|
||||
DEFAULT_UID=1000
|
||||
DEFAULT_GID=1000
|
||||
TARGET_UID="${PUID:-${DEFAULT_UID}}"
|
||||
TARGET_GID="${PGID:-${DEFAULT_GID}}"
|
||||
DROP_PRIVS="${SOLIDTIME_DROP_PRIVILEGES:-auto}"
|
||||
WRITABLE_PATHS=(
|
||||
"${STORAGE_PATH}/framework/cache/data"
|
||||
"${STORAGE_PATH}/framework/sessions"
|
||||
"${STORAGE_PATH}/framework/views"
|
||||
"${STORAGE_PATH}/framework/testing"
|
||||
"${STORAGE_PATH}/logs"
|
||||
"${STORAGE_PATH}/app/public"
|
||||
"${STORAGE_PATH}/app/private"
|
||||
"${CACHE_PATH}"
|
||||
)
|
||||
|
||||
case "${DROP_PRIVS}" in
|
||||
never) SHOULD_DROP=0 ;;
|
||||
auto)
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
SHOULD_DROP=1
|
||||
else
|
||||
SHOULD_DROP=0
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "[entrypoint] ERROR: invalid SOLIDTIME_DROP_PRIVILEGES='${DROP_PRIVS}'" >&2
|
||||
echo "[entrypoint] Valid values: auto (default), never" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Warn if PUID/PGID are set but the container started non-root. PUID/PGID only
|
||||
# take effect during the drop-privileges flow, which requires starting as root.
|
||||
# A common cause is leaving `user:` in the compose file alongside PUID env vars.
|
||||
if { [ -n "${PUID}" ] || [ -n "${PGID}" ]; } \
|
||||
&& [ "$(id -u)" != "0" ] \
|
||||
&& [ "${SOLIDTIME_PRIVILEGES_DROPPED:-0}" != "1" ]; then
|
||||
cat >&2 <<EOF
|
||||
[entrypoint] WARNING: PUID/PGID is set but the container started as UID $(id -u) (not root).
|
||||
[entrypoint] WARNING: PUID/PGID only apply when the entrypoint runs as root and drops privileges.
|
||||
[entrypoint] WARNING:
|
||||
[entrypoint] WARNING: To use PUID/PGID: remove any 'user:' directive from your compose file.
|
||||
[entrypoint] WARNING: To run as a fixed UID: remove PUID/PGID from your env.
|
||||
[entrypoint] WARNING:
|
||||
[entrypoint] WARNING: Continuing as UID $(id -u). See:
|
||||
[entrypoint] WARNING: https://docs.solidtime.io/self-hosting/guides/permissions
|
||||
|
||||
EOF
|
||||
fi
|
||||
|
||||
bootstrap_storage_tree() {
|
||||
mkdir -p "${WRITABLE_PATHS[@]}" 2>/dev/null || return 1
|
||||
}
|
||||
|
||||
# Proactive warning when the existing storage directory is owned by a non-default
|
||||
# UID (typical on NAS systems where host users aren't UID 1000) and PUID/PGID
|
||||
# aren't set. Without this nudge, the chown step silently re-owns the files to
|
||||
# 1000:1000 and the user only discovers the mismatch later when host-side tools
|
||||
# (backup, file browser, rsync) show unfamiliar ownership.
|
||||
maybe_warn_ownership_mismatch() {
|
||||
[ "${SHOULD_DROP}" = "1" ] || return 0
|
||||
[ -n "${PUID}" ] && return 0
|
||||
[ -n "${PGID}" ] && return 0
|
||||
[ -d "${STORAGE_PATH}" ] || return 0
|
||||
|
||||
local owner_uid owner_gid
|
||||
owner_uid="$(stat -c '%u' "${STORAGE_PATH}" 2>/dev/null)" || return 0
|
||||
owner_gid="$(stat -c '%g' "${STORAGE_PATH}" 2>/dev/null)" || return 0
|
||||
|
||||
# Root-owned: probably freshly created by the entrypoint, will be chowned shortly.
|
||||
[ "${owner_uid}" = "0" ] && return 0
|
||||
# Already the target: nothing to warn about.
|
||||
[ "${owner_uid}" = "${TARGET_UID}" ] && return 0
|
||||
|
||||
cat >&2 <<EOF
|
||||
[entrypoint] NOTE: ${STORAGE_PATH} is owned by UID ${owner_uid}:${owner_gid},
|
||||
[entrypoint] but the container is starting as UID ${TARGET_UID}:${TARGET_GID}.
|
||||
[entrypoint] Files will be chowned to ${TARGET_UID}:${TARGET_GID} and may
|
||||
[entrypoint] appear with an unfamiliar owner on the host.
|
||||
[entrypoint]
|
||||
[entrypoint] If you want the container to write as UID ${owner_uid} (common
|
||||
[entrypoint] on Synology / TrueNAS / Unraid where host users aren't UID
|
||||
[entrypoint] 1000), set in your env and restart:
|
||||
[entrypoint]
|
||||
[entrypoint] PUID=${owner_uid}
|
||||
[entrypoint] PGID=${owner_gid}
|
||||
[entrypoint]
|
||||
[entrypoint] More: https://docs.solidtime.io/self-hosting/guides/permissions
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
print_write_test_failure() {
|
||||
local owner
|
||||
owner="$(stat -c '%u:%g' "${STORAGE_PATH}" 2>/dev/null || echo unknown)"
|
||||
local runtime_uid
|
||||
local runtime_gid
|
||||
if [ "$(id -u)" = "0" ] && [ "${SHOULD_DROP}" = "1" ]; then
|
||||
runtime_uid="${TARGET_UID}"
|
||||
runtime_gid="${TARGET_GID}"
|
||||
else
|
||||
runtime_uid="$(id -u)"
|
||||
runtime_gid="$(id -g)"
|
||||
fi
|
||||
local owner_uid
|
||||
owner_uid="$(stat -c '%u' "${STORAGE_PATH}" 2>/dev/null || echo 1000)"
|
||||
local owner_gid
|
||||
owner_gid="$(stat -c '%g' "${STORAGE_PATH}" 2>/dev/null || echo 1000)"
|
||||
cat >&2 <<EOF
|
||||
|
||||
============================================================
|
||||
ERROR: Solidtime writable directories are not writable.
|
||||
|
||||
Diagnosis:
|
||||
Container will run as: UID ${runtime_uid}, GID ${runtime_gid}
|
||||
Storage directory owner: ${owner}
|
||||
|
||||
Likely cause: a bind-mounted host directory is owned by a different
|
||||
user than the container's application user.
|
||||
|
||||
Fix on the host:
|
||||
|
||||
sudo chown -R ${runtime_uid}:${runtime_gid} <your-bind-mount-path>
|
||||
|
||||
Or set PUID/PGID to match the host directory owner:
|
||||
|
||||
PUID=${owner_uid}
|
||||
PGID=${owner_gid}
|
||||
|
||||
To run intentionally as root, set:
|
||||
|
||||
SOLIDTIME_DROP_PRIVILEGES=never
|
||||
|
||||
For more help: https://docs.solidtime.io/self-hosting/guides/permissions
|
||||
============================================================
|
||||
|
||||
EOF
|
||||
}
|
||||
|
||||
write_test_as_user() {
|
||||
local user="$1"
|
||||
local script='
|
||||
set -e
|
||||
for dir in "$@"; do
|
||||
test_file="${dir}/.solidtime-write-test"
|
||||
touch "${test_file}"
|
||||
rm -f "${test_file}"
|
||||
done
|
||||
'
|
||||
if [ -n "${user}" ]; then
|
||||
gosu "${user}" sh -c "${script}" sh "${WRITABLE_PATHS[@]}" 2>/dev/null
|
||||
else
|
||||
sh -c "${script}" sh "${WRITABLE_PATHS[@]}" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Root preamble: bootstrap, remap, chown, write-test, then drop and re-exec.
|
||||
# ----------------------------------------------------------------------------
|
||||
if [ "$(id -u)" = "0" ]; then
|
||||
if ! bootstrap_storage_tree; then
|
||||
echo "[entrypoint] ERROR: failed to create storage subdirectories at ${STORAGE_PATH}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "${SHOULD_DROP}" = "1" ]; then
|
||||
maybe_warn_ownership_mismatch
|
||||
if [ "${TARGET_UID}" != "${DEFAULT_UID}" ] || [ "${TARGET_GID}" != "${DEFAULT_GID}" ]; then
|
||||
echo "[entrypoint] Remapping ${APP_USER} to ${TARGET_UID}:${TARGET_GID}"
|
||||
groupmod -o -g "${TARGET_GID}" "${APP_USER}"
|
||||
usermod -o -u "${TARGET_UID}" "${APP_USER}"
|
||||
fi
|
||||
# Idempotent chown: only fix entries whose owner or group is wrong.
|
||||
# On large storage volumes (lots of user uploads) this is dramatically
|
||||
# faster than a blanket `chown -R` every restart. Pattern borrowed from
|
||||
# docker-library/postgres and linuxserver.io's baseimage.
|
||||
find "${STORAGE_PATH}" "${CACHE_PATH}" \
|
||||
\( ! -user "${TARGET_UID}" -o ! -group "${TARGET_GID}" \) \
|
||||
-exec chown "${TARGET_UID}:${TARGET_GID}" {} + 2>/dev/null || true
|
||||
|
||||
if ! write_test_as_user "${APP_USER}"; then
|
||||
print_write_test_failure
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec gosu "${APP_USER}" env SOLIDTIME_PRIVILEGES_DROPPED=1 "$0" "$@"
|
||||
fi
|
||||
|
||||
if ! write_test_as_user ""; then
|
||||
print_write_test_failure
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
if ! bootstrap_storage_tree; then
|
||||
echo "[entrypoint] WARNING: could not create some storage subdirectories at ${STORAGE_PATH} (will continue if existing tree is writable)" >&2
|
||||
fi
|
||||
if ! write_test_as_user ""; then
|
||||
print_write_test_failure
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Application: runs as APP_USER (or whatever non-root UID was started).
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
unset SOLIDTIME_PRIVILEGES_DROPPED
|
||||
|
||||
container_mode=${CONTAINER_MODE:-"http"}
|
||||
octane_server=${OCTANE_SERVER}
|
||||
auto_db_migrate=${AUTO_DB_MIGRATE:-false}
|
||||
@@ -8,14 +242,16 @@ auto_db_migrate=${AUTO_DB_MIGRATE:-false}
|
||||
initialStuff() {
|
||||
echo "Container mode: $container_mode"
|
||||
|
||||
if [ ${auto_db_migrate} = "true" ]; then
|
||||
if [ "${auto_db_migrate}" = "true" ]; then
|
||||
echo "Auto database migration enabled."
|
||||
php artisan migrate --isolated --force
|
||||
fi
|
||||
|
||||
php artisan storage:link; \
|
||||
php artisan optimize:clear; \
|
||||
php artisan optimize;
|
||||
if [ ! -L "${APP_PATH}/public/storage" ]; then
|
||||
php artisan storage:link
|
||||
fi
|
||||
php artisan optimize:clear
|
||||
php artisan optimize
|
||||
}
|
||||
|
||||
if [ "$1" != "" ]; then
|
||||
@@ -23,11 +259,11 @@ if [ "$1" != "" ]; then
|
||||
elif [ "${container_mode}" = "http" ]; then
|
||||
initialStuff
|
||||
echo "Octane Server: $octane_server"
|
||||
if [ "${octane_server}" = "frankenphp" ]; then
|
||||
if [ "${octane_server}" = "frankenphp" ]; then
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.frankenphp.conf
|
||||
elif [ "${octane_server}" = "swoole" ]; then
|
||||
elif [ "${octane_server}" = "swoole" ]; then
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.swoole.conf
|
||||
elif [ "${octane_server}" = "roadrunner" ]; then
|
||||
elif [ "${octane_server}" = "roadrunner" ]; then
|
||||
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.roadrunner.conf
|
||||
else
|
||||
echo "Invalid Octane server supplied."
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
[supervisord]
|
||||
nodaemon = true
|
||||
user = %(ENV_USER)s
|
||||
logfile = /var/log/supervisor/supervisord.log
|
||||
pidfile = /var/run/supervisord.pid
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
[program:horizon]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan horizon
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stdout_logfile = /dev/stdout
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
[program:reverb]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan reverb:start
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stdout_logfile = /dev/stdout
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
[program:scheduler]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = supercronic -overlapping /etc/supercronic/laravel
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stdout_logfile = /dev/stdout
|
||||
@@ -12,7 +11,6 @@ stderr_logfile_maxbytes = 0
|
||||
[program:clear-scheduler-cache]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = php %(ENV_ROOT)s/artisan schedule:clear-cache
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = false
|
||||
startsecs = 0
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
[program:worker]
|
||||
process_name = %(program_name)s_%(process_num)s
|
||||
command = %(ENV_WORKER_COMMAND)s
|
||||
user = %(ENV_USER)s
|
||||
autostart = true
|
||||
autorestart = true
|
||||
stdout_logfile = /dev/stdout
|
||||
|
||||
189
e2e/auth.spec.ts
189
e2e/auth.spec.ts
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
|
||||
import { getPasswordResetUrl } from './utils/mailpit';
|
||||
|
||||
async function registerNewUser(page, email, password) {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||
@@ -35,14 +36,198 @@ test('can register and delete account', async ({ page }) => {
|
||||
await registerNewUser(page, email, password);
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/user/profile');
|
||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await page.getByPlaceholder('Password').fill(password);
|
||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Delete Account' }).click();
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password').fill(password);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await expect(page.getByRole('paragraph')).toContainText(
|
||||
await expect(page.getByRole('alert')).toContainText(
|
||||
'These credentials do not match our records.'
|
||||
);
|
||||
});
|
||||
|
||||
test('shows error for invalid email on forgot password', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||
|
||||
// Request password reset with non-existent email
|
||||
await page.getByLabel('Email').fill('nonexistent@example.com');
|
||||
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||
|
||||
// Should show error message
|
||||
await expect(page.getByText("We can't find a user with that email address.")).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows browser validation for invalid email format on forgot password', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||
|
||||
// Request password reset with invalid email format
|
||||
const emailInput = page.getByLabel('Email');
|
||||
await emailInput.fill('notanemail');
|
||||
|
||||
// Check for browser validation - the input should be invalid
|
||||
const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => !el.validity.valid);
|
||||
expect(isInvalid).toBe(true);
|
||||
});
|
||||
|
||||
test('shows browser validation for empty email on forgot password', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||
|
||||
// The email input is required, so it should be invalid when empty
|
||||
const emailInput = page.getByLabel('Email');
|
||||
|
||||
// Check for browser validation - the input should be invalid because it's required and empty
|
||||
const isInvalid = await emailInput.evaluate((el: HTMLInputElement) => el.validity.valueMissing);
|
||||
expect(isInvalid).toBe(true);
|
||||
});
|
||||
|
||||
test('can reset password via email link', async ({ page, request }) => {
|
||||
// First register a new user
|
||||
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||
const originalPassword = 'suchagreatpassword123';
|
||||
const newPassword = 'mynewsecurepassword456';
|
||||
await registerNewUser(page, email, originalPassword);
|
||||
|
||||
// Log out
|
||||
await page.getByTestId('current_user_button').click();
|
||||
await page.getByText('Log Out').click();
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||
|
||||
// Request password reset
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
||||
|
||||
// Get password reset URL from email
|
||||
const resetUrl = await getPasswordResetUrl(request, email);
|
||||
|
||||
// Navigate to reset page
|
||||
await page.goto(resetUrl);
|
||||
|
||||
// Fill in new password
|
||||
await page.getByLabel('Password', { exact: true }).fill(newPassword);
|
||||
await page.getByLabel('Confirm Password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
|
||||
// Should redirect to login page after successful reset
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||
|
||||
// Try logging in with new password
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password').fill(newPassword);
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
await expect(page.getByTestId('dashboard_view')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows validation error for password mismatch on reset', async ({ page, request }) => {
|
||||
// First register a new user
|
||||
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||
const originalPassword = 'suchagreatpassword123';
|
||||
await registerNewUser(page, email, originalPassword);
|
||||
|
||||
// Log out
|
||||
await page.getByTestId('current_user_button').click();
|
||||
await page.getByText('Log Out').click();
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||
|
||||
// Request password reset
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
||||
|
||||
// Get password reset URL from email
|
||||
const resetUrl = await getPasswordResetUrl(request, email);
|
||||
|
||||
// Navigate to reset page
|
||||
await page.goto(resetUrl);
|
||||
|
||||
// Fill in mismatched passwords
|
||||
await page.getByLabel('Password', { exact: true }).fill('newpassword123');
|
||||
await page.getByLabel('Confirm Password').fill('differentpassword456');
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
|
||||
// Should show validation error
|
||||
await expect(page.getByText('The password field confirmation does not match.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows validation error for short password on reset', async ({ page, request }) => {
|
||||
// First register a new user
|
||||
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||
const originalPassword = 'suchagreatpassword123';
|
||||
await registerNewUser(page, email, originalPassword);
|
||||
|
||||
// Log out
|
||||
await page.getByTestId('current_user_button').click();
|
||||
await page.getByText('Log Out').click();
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||
|
||||
// Request password reset
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/forgot-password');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByRole('button', { name: 'Email Password Reset Link' }).click();
|
||||
await expect(page.getByText('We have emailed your password reset link.')).toBeVisible();
|
||||
|
||||
// Get password reset URL from email
|
||||
const resetUrl = await getPasswordResetUrl(request, email);
|
||||
|
||||
// Navigate to reset page
|
||||
await page.goto(resetUrl);
|
||||
|
||||
// Fill in short password
|
||||
await page.getByLabel('Password', { exact: true }).fill('short');
|
||||
await page.getByLabel('Confirm Password').fill('short');
|
||||
await page.getByRole('button', { name: 'Reset Password' }).click();
|
||||
|
||||
// Should show validation error about minimum length
|
||||
await expect(page.getByText('must be at least')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows error for invalid login credentials', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/login');
|
||||
await page.getByLabel('Email').fill('nonexistent@example.com');
|
||||
await page.getByLabel('Password').fill('wrongpassword123');
|
||||
await page.getByRole('button', { name: 'Log in' }).click();
|
||||
|
||||
await expect(page.getByText('These credentials do not match our records.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows error when registering with existing email', async ({ page }) => {
|
||||
const email = `john+${Math.round(Math.random() * 10000)}@doe.com`;
|
||||
const password = 'suchagreatpassword123';
|
||||
|
||||
// Register first user
|
||||
await registerNewUser(page, email, password);
|
||||
|
||||
// Log out
|
||||
await page.getByTestId('current_user_button').click();
|
||||
await page.getByText('Log Out').click();
|
||||
await page.waitForURL(PLAYWRIGHT_BASE_URL + '/login');
|
||||
|
||||
// Try to register with the same email
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||
await page.getByLabel('Name').fill('Another User');
|
||||
await page.getByLabel('Email').fill(email);
|
||||
await page.getByLabel('Password', { exact: true }).fill(password);
|
||||
await page.getByLabel('Confirm Password').fill(password);
|
||||
await page.getByLabel('I agree to the Terms of').click();
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
// Should show error about email already taken
|
||||
await expect(page.getByText('The resource already exists.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows validation error for weak password on registration', async ({ page }) => {
|
||||
await page.goto(PLAYWRIGHT_BASE_URL + '/register');
|
||||
await page.getByLabel('Name').fill('Weak Password User');
|
||||
await page.getByLabel('Email').fill(`weak+${Math.round(Math.random() * 10000)}@test.com`);
|
||||
await page.getByLabel('Password', { exact: true }).fill('short');
|
||||
await page.getByLabel('Confirm Password').fill('short');
|
||||
await page.getByLabel('I agree to the Terms of').click();
|
||||
await page.getByRole('button', { name: 'Register' }).click();
|
||||
|
||||
await expect(page.getByText('must be at least')).toBeVisible();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user