Compare commits

...

74 Commits

Author SHA1 Message Date
Constantin Graf
0d1d9a4ee7 Suppress expected OAuth access denial reports 2026-08-31 11:39:30 +02:00
Constantin Graf
ba778bdddf Suppress reporting for expected API exceptions 2026-08-31 11:14:23 +02:00
Andrew Herron
28aba7b27a Stopped downloads opening a new tab now that it's guaranteed to be an attachment. 2026-08-28 17:30:27 +02:00
Andrew Herron
453d5ed066 Set content-disposition 'attachment' for both local and S3 downloads. Added a bunch of tests. Fixes #1148. 2026-08-28 17:30:27 +02:00
Gregor Vostrak
ed3ba0135b fix undefined table-state helper in projects search e2e test 2026-08-28 13:10:36 +02:00
Gregor Vostrak
fb7d945c50 polish projects search ui, fix empty states 2026-08-27 18:50:34 +02:00
AKolenda
9958e1c09c add search input to projects overview page
Filters the project table by name client-side, reusing the existing
filteredProjects computed and the dropdown search input styling.
Search is deliberately not persisted in the table state so a reload
never leaves the table silently filtered.
2026-08-27 18:50:34 +02:00
github-actions[bot]
f00d48f1c3 Update VOUCHED list
https://github.com/solidtime-io/solidtime/issues/1217#issuecomment-5442102245
2026-08-27 16:28:04 +00:00
Constantin Graf
7edbfd5d76 Updated extension billing to 0.0.6 2026-08-27 15:22:31 +02:00
Gregor Vostrak
1edb940557 fix empty-value sorting edge cases and simplify sort components 2026-08-26 18:17:34 +02:00
Gregor Vostrak
c9330e6cb8 fix semantic task table role 2026-08-26 18:17:34 +02:00
Gregor Vostrak
0e9f652d84 refactor table sorting to composables and shared SortableTableHeaderCell 2026-08-26 18:17:34 +02:00
Niklaus Bucher
d29f5706ce Add sorting to tasks table 2026-08-26 18:17:34 +02:00
Constantin Graf
b2849ec04a Fixed code formatting 2026-08-25 13:35:32 +02:00
Constantin Graf
bd69cf478c Bump billing extension to v0.0.5 2026-08-25 13:35:32 +02:00
Constantin Graf
5197135d00 Updated service extension 2026-08-25 13:35:32 +02:00
Constantin Graf
b09b8649b8 Fixed typo in import 2026-08-25 12:32:30 +02:00
Constantin Graf
99fafdb8d6 Add database indexes 2026-08-25 12:32:30 +02:00
Gregor Vostrak
b02e49c7e0 improve modal scrolling behaviour on mobile 2026-08-24 21:46:46 +02:00
Gregor Vostrak
8609635d74 fix modal overflow on smaller viewports with dynamic viewport height 2026-08-22 17:43:33 +02:00
github-actions[bot]
5a07f798b3 Update VOUCHED list
https://github.com/solidtime-io/solidtime/discussions/1188#discussioncomment-DC_kwDOLFnGEc4BFEiq
2026-08-21 13:22:28 +00:00
Gregor Vostrak
3af69830ee move api rate limit to app config, add fallback for empty env key 2026-08-20 17:40:33 +02:00
Darwin Correa
a050153bcd feat: make API rate limits configurable via env vars (#1204)
* feat: make API rate limits configurable via env vars
2026-08-20 17:22:24 +02:00
Gregor Vostrak
9d9731c7ce add formatting support for months grouping 2026-08-20 17:11:35 +02:00
Andrew Herron
dfe3206614 Add "Week" grouping option to reporting 2026-08-20 17:11:35 +02:00
Andrew Herron
97fd882878 Format date group labels in all aggregate exports 2026-08-20 17:11:35 +02:00
Andrew Herron
111e12df12 Add "Date" grouping option to reporting 2026-08-20 17:11:35 +02:00
Constantin Graf
de13c07855 Fixed return type in TrustHosts 2026-08-06 17:49:52 +02:00
Constantin Graf
29a2e994cd Fixed phpstan error in TrustHosts middleware 2026-08-06 17:06:34 +02:00
Constantin Graf
f6d886b218 Refactored TrustHostsTest; Added return types to TrustHosts 2026-08-06 17:01:46 +02:00
Gregor Vostrak
80d98b30a1 add custom error handling for host mismatch; ensure TrustHosts runs
before TrustProxies
2026-08-06 17:01:46 +02:00
Gregor Vostrak
32f2f1431b add TrustHosts middleware with exemption for healthchecks 2026-08-06 17:01:46 +02:00
Constantin Graf
8f6d584ee9 Fixed invoice tax rate 2026-07-30 21:16:06 +02:00
github-actions[bot]
1905cbf40c Update VOUCHED list
https://github.com/solidtime-io/solidtime/issues/1148#issuecomment-5123895106
2026-07-29 22:12:52 +00:00
github-actions[bot]
c8f668238e Update VOUCHED list
https://github.com/solidtime-io/solidtime/issues/1179#issuecomment-5123615992
2026-07-29 21:36:39 +00:00
Gregor Vostrak
bf11bacdee add vouch system requirement for PRs > 50 line changes 2026-07-29 23:22:32 +02:00
Gregor Vostrak
f3c6a0b8ae fix invitations not being respected during signup when email case differs 2026-07-28 17:07:53 +02:00
Gregor Vostrak
619c602571 cleanup and deduplicate breaks frontend tests 2026-07-28 16:52:36 +02:00
Gregor Vostrak
600daf44d9 add description/project labels to break placement modal for existing
time entries
2026-07-28 16:52:36 +02:00
Gregor Vostrak
a1d6c92806 insert breaks into work entries instead of carving them out; rollback on
failure system
2026-07-28 16:52:36 +02:00
Gregor Vostrak
ce1bd6a435 unify display values for work and break time totals 2026-07-28 16:52:36 +02:00
Gregor Vostrak
79d85227c2 bump ui to 0.0.22 and api to 0.0.7 2026-07-28 16:52:36 +02:00
Gregor Vostrak
885abf3333 promote breaks feature in sidebar banner 2026-07-28 16:52:36 +02:00
Gregor Vostrak
c0c8fee6be default time entry type filter to work for legacy reports 2026-07-28 16:52:36 +02:00
Gregor Vostrak
cbcd1e51f6 add break time entries and simplified time tracker ui 2026-07-28 16:52:36 +02:00
Constantin Graf
114a32536d Fixed update of member_id in time_entries.update and time_entries.updateMultiple
Removed usage of legacy user_id in TimeEntryController
2026-07-23 12:02:46 +02:00
Constantin Graf
ff8a0f065b Updated extension billing 2026-07-23 11:42:32 +02:00
Constantin Graf
44fd0ffb91 Fix .dockerignore 2026-07-23 11:40:19 +02:00
Constantin Graf
c07c62bfab Add extensions manifest 2026-07-21 16:39:27 +02:00
Constantin Graf
f599757761 Add .dockerignore file 2026-07-21 16:39:27 +02:00
dependabot[bot]
e54fd2460c Bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 16:38:33 +02:00
dependabot[bot]
e997a2b39f Bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-21 16:38:15 +02:00
Constantin Graf
f0de89b0e8 Prevent failed jobs for no longer existing projects and tasks 2026-07-11 16:00:34 +02:00
Constantin Graf
024283273a Updated email validation to stricter config 2026-07-11 16:00:34 +02:00
Candide
f01ab5fd9d Fix awkward wording for time tracker alert email
I just received this email today, and found the "you do that in solidtime" wording to be a bit awkward.
2026-07-11 15:57:33 +02:00
Gregor Vostrak
4fb18f343f allow missing client columns in clockify importer, fixes #1149 2026-07-09 16:16:25 +02:00
Gregor Vostrak
ba374c0371 add client name to projecttaskselect dropdown trigger 2026-07-09 13:53:08 +02:00
Gregor Vostrak
e302421765 make sure dropdown/combobox lists stay visible during close animation to
avoid layout shifts
2026-07-09 13:01:23 +02:00
Gregor Vostrak
c90c1ed8fa add stable secondary sorting based on id to the tests to avoid flakyness 2026-07-09 13:01:23 +02:00
Gregor Vostrak
75f5144b45 use pinned selection instead of fix items on dropdown open so async
loaded collections update the dropdown properly
2026-07-09 13:01:23 +02:00
Gregor Vostrak
53ecc58509 add validationa for project names in toggl importer 2026-07-09 13:01:23 +02:00
Gregor Vostrak
ac68091d3e add secondary name sorting as tie breaker in client and project tables 2026-07-09 13:01:23 +02:00
Gregor Vostrak
a97c02542b add unique id tiebreaker to paginated index endpoints to make pagination
stable (#1138)
2026-07-09 13:01:23 +02:00
Gregor Vostrak
a56942abcf Add virtualizer to ProjectDropdown, ClientDropdown and Reporting
Comboboxes; Remove redundant focus loop on Project/ClientDropdown
2026-07-09 13:01:23 +02:00
Gregor Vostrak
61a0a7db5a remove the measure row logic and rely on static values only for the
virtualizer and remove duplicated focus trap to avoid infinite loop in
project task dropdown
2026-07-09 13:01:23 +02:00
Gregor Vostrak
bc5af14a75 keep tasks visible when search term matches project or client name 2026-07-09 13:01:23 +02:00
Gregor Vostrak
95bfb45978 add virtualizer to ProjectTaskDropdown component to handle bigger
project lists
2026-07-09 13:01:23 +02:00
Gregor Vostrak
2207f676ee add pagination to client and project table 2026-07-09 13:01:23 +02:00
Gregor Vostrak
981bcbe091 add tests for tag match type (backend + e2e) 2026-06-26 14:53:37 +02:00
Gregor Vostrak
ccf3cec8c0 migrate tag match type buttons to reka-ui radio group for accessibility 2026-06-26 14:53:37 +02:00
Gregor Vostrak
23ea1500b0 Use an enum for tag match type 2026-06-26 14:53:37 +02:00
Gregor Vostrak
4bbde04e28 Rename tag_filter parameter to tag_match_type 2026-06-26 14:53:37 +02:00
Gregor Vostrak
caa36d2875 Fix not-contains tag filter dropping entries where tags is null 2026-06-26 14:53:37 +02:00
Beda Schmid
58d7b33366 Add filter to include/exclude tags
[Added]
- Introduced a tag filter feature allowing users to specify whether tags should be included or excluded in time entry filters. This supports 'contains' and 'not_contains' modes.
2026-06-26 14:53:37 +02:00
278 changed files with 13049 additions and 1547 deletions

54
.dockerignore Normal file
View File

@@ -0,0 +1,54 @@
.git
**/.git
.gitmodules
**/.gitmodules
.github
.DS_Store
.fleet
.idea
.vscode
*.log
npm-debug.log
yarn-error.log
k8s
docs
e2e
tests
docker-compose.yml
docker/local
.phpunit.cache
.phpunit.result.cache
coverage
test-results
playwright-report
blob-report
playwright/.cache
openapi.json
playwright
playwright.config.ts
vitest.config.ts
phpunit.xml
phpstan.neon
pint.json
eslint.config.mjs
tsconfig.json
jsconfig.json
postcss.config.js
tailwind.config.js
node_modules
extensions/*/node_modules
Homestead.json
Homestead.yaml
auth.json
.env.backup
.rnd
_ide_helper.php
.phpstorm.meta.php
storage/logs/*
storage/*.key

27
.github/VOUCHED.td vendored Normal file
View File

@@ -0,0 +1,27 @@
# Vouched contributors for solidtime.
#
# One handle per line, without the leading @, sorted alphabetically.
# Prefix a handle with - to denounce them, optionally followed by a reason.
# Format reference: https://github.com/mitchellh/vouch
#
# Maintainers do not need to edit this file by hand. Comment "vouch @user",
# "unvouch @user" or "denounce @user <reason>" on any issue, pull request or
# discussion and the vouch workflows will update this file.
#
# Collaborators with write access and bots are always allowed and do not need
# an entry here.
#
# Seeded 2026-07-25 from the authors of every merged pull request.
agross
akolenda
bufferhead-code
candideu
kasparrosin
korridor
nikbucher tasks table sorting
onatcer
shrootbuck
smilebeda
thespyder
utlark

View File

@@ -8,6 +8,8 @@ on:
pull_request: pull_request:
paths: paths:
- '.github/workflows/build-onpremise.yml' - '.github/workflows/build-onpremise.yml'
- '.dockerignore'
- 'extensions/manifest.json'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
@@ -35,7 +37,7 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -91,14 +93,23 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout invoicing extension" - name: "Checkout invoicing extension"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
repository: solidtime-io/extension-invoicing repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }} ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}

View File

@@ -8,6 +8,8 @@ on:
pull_request: pull_request:
paths: paths:
- '.github/workflows/build-private.yml' - '.github/workflows/build-private.yml'
- '.dockerignore'
- 'extensions/manifest.json'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -22,7 +24,7 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -68,14 +70,27 @@ jobs:
run: cat .env run: cat .env
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'
- name: "Read extension manifest"
id: extension-manifest
run: |
{
echo "billing_repository=$(jq -r '.Billing.repository' extensions/manifest.json)"
echo "billing_ref=$(jq -r '.Billing.ref' extensions/manifest.json)"
echo "services_repository=$(jq -r '.Services.repository' extensions/manifest.json)"
echo "services_ref=$(jq -r '.Services.ref' extensions/manifest.json)"
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension" - name: "Checkout billing extension"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
repository: solidtime-io/extension-billing repository: ${{ steps.extension-manifest.outputs.billing_repository }}
ref: ${{ steps.extension-manifest.outputs.billing_ref }}
path: extensions/Billing path: extensions/Billing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }} ssh-key: ${{ secrets.SSH_PRIVATE_KEY_BILLING_EXTENSION }}
@@ -93,9 +108,10 @@ jobs:
run: cd extensions/Billing && npm ci run: cd extensions/Billing && npm ci
- name: "Checkout services extension" - name: "Checkout services extension"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
repository: solidtime-io/extension-services repository: ${{ steps.extension-manifest.outputs.services_repository }}
ref: ${{ steps.extension-manifest.outputs.services_ref }}
path: extensions/Services path: extensions/Services
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }} ssh-key: ${{ secrets.SSH_PRIVATE_KEY_SERVICES_EXTENSION }}
@@ -111,9 +127,10 @@ jobs:
run: cd extensions/Services && npm ci run: cd extensions/Services && npm ci
- name: "Checkout invoicing extension" - name: "Checkout invoicing extension"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
repository: solidtime-io/extension-invoicing repository: ${{ steps.extension-manifest.outputs.invoicing_repository }}
ref: ${{ steps.extension-manifest.outputs.invoicing_ref }}
path: extensions/Invoicing path: extensions/Invoicing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }} ssh-key: ${{ secrets.SSH_PRIVATE_KEY_INVOICING_EXTENSION }}

View File

@@ -8,6 +8,7 @@ on:
pull_request: pull_request:
paths: paths:
- '.github/workflows/build-public.yml' - '.github/workflows/build-public.yml'
- '.dockerignore'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:
@@ -36,7 +37,7 @@ jobs:
steps: steps:
- name: "Check out code" - name: "Check out code"
uses: actions/checkout@v6 uses: actions/checkout@v7
with: with:
fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag fetch-depth: 0 # Required for WyriHaximus/github-action-get-previous-tag
@@ -92,7 +93,7 @@ jobs:
if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit if: steps.cache-vendor.outputs.cache-hit != 'true' # Skip if cache hit
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

View File

@@ -29,7 +29,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup PHP" - name: "Setup PHP"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2

View File

@@ -11,7 +11,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup PHP (for Ziggy)" - name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -24,7 +24,7 @@ jobs:
run: composer install -n --prefer-dist run: composer install -n --prefer-dist
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

View File

@@ -9,10 +9,10 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

View File

@@ -11,10 +11,10 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

View File

@@ -11,11 +11,11 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- name: Install root project dependencies - name: Install root project dependencies
run: npm ci run: npm ci
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'

View File

@@ -11,9 +11,9 @@ jobs:
id-token: write id-token: write
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
# Setup .npmrc file to publish to npm # Setup .npmrc file to publish to npm
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'
registry-url: 'https://registry.npmjs.org' registry-url: 'https://registry.npmjs.org'

View File

@@ -13,10 +13,10 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

View File

@@ -10,7 +10,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup PHP (for Ziggy)" - name: "Setup PHP (for Ziggy)"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -23,7 +23,7 @@ jobs:
run: composer install -n --prefer-dist run: composer install -n --prefer-dist
- name: "Use Node.js" - name: "Use Node.js"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup PHP" - name: "Setup PHP"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2

View File

@@ -36,7 +36,7 @@ jobs:
--health-retries 5 --health-retries 5
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup PHP" - name: "Setup PHP"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
@@ -48,7 +48,7 @@ jobs:
- name: "Run composer install" - name: "Run composer install"
run: composer install -n --prefer-dist run: composer install -n --prefer-dist
- uses: actions/setup-node@v6 - uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

View File

@@ -9,7 +9,7 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Check code style" - name: "Check code style"
uses: aglipanci/laravel-pint-action@2.6 uses: aglipanci/laravel-pint-action@2.6

View File

@@ -35,10 +35,10 @@ jobs:
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'
@@ -99,10 +99,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "Checkout code" - name: "Checkout code"
uses: actions/checkout@v6 uses: actions/checkout@v7
- name: "Setup node" - name: "Setup node"
uses: actions/setup-node@v6 uses: actions/setup-node@v7
with: with:
node-version: '20.x' node-version: '20.x'

75
.github/workflows/vouch-check-pr.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
name: Vouch (check PR)
on:
pull_request_target:
types: [opened, reopened, synchronize]
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: write
jobs:
check:
runs-on: ubuntu-latest
timeout-minutes: 5
if: >-
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/recheck'))
steps:
# Pull requests of 50 changed lines or fewer skip the vouch requirement.
- name: "Measure diff size"
id: size
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number || github.event.issue.number }}
# Changes to these files do not count towards the 50-line limit.
# One extended regex per line, matched against the whole repo-relative
# path, so use a leading .* to match a file in any directory.
IGNORED: |
package-lock\.json
composer\.lock
tests/.*
e2e/.*
.*\.(test|spec)\.(ts|js|vue)
run: |
set -euo pipefail
# An empty list yields "^()$", which matches no filename. grep exits
# 1 on an empty list, so swallow that rather than fail the step.
join() { { grep -vE '^[[:space:]]*$' || true; } | paste -sd'|' -; }
ignored="^($(join <<<"$IGNORED"))$"
total=$(gh api --paginate "repos/$REPO/pulls/$PR/files" \
--jq '.[] | [.filename, .additions + .deletions] | @tsv' |
awk -F'\t' -v ignored="$ignored" '
$1 ~ ignored { next }
{ n += $2 }
END { print n+0 }')
echo "total=$total" >> "$GITHUB_OUTPUT"
echo "Countable diff size: $total line(s)"
- name: "Small patch (denounced users still blocked)"
if: fromJSON(steps.size.outputs.total) <= 50
uses: mitchellh/vouch/action/check-pr@v1.5.0
with:
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
auto-close: true
require-vouch: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Full vouch required"
if: fromJSON(steps.size.outputs.total) > 50
uses: mitchellh/vouch/action/check-pr@v1.5.0
with:
pr-number: ${{ github.event.pull_request.number || github.event.issue.number }}
auto-close: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,33 @@
name: Vouch (manage by discussion)
# Same commands as vouch-manage-by-issue.yml, but for discussion comments.
on:
discussion_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
discussions: write
jobs:
manage:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Apply vouch command"
uses: mitchellh/vouch/action/manage-by-discussion@v1.5.0
with:
discussion-number: ${{ github.event.discussion.number }}
comment-node-id: ${{ github.event.comment.node_id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -0,0 +1,35 @@
name: Vouch (manage by issue)
# Maintainers comment "vouch @user", "unvouch @user" or "denounce @user <reason>"
# on any issue or pull request, and this workflow updates .github/VOUCHED.td.
on:
issue_comment:
types: [created]
concurrency:
group: vouch-manage
cancel-in-progress: false
permissions:
contents: write
issues: write
pull-requests: write
jobs:
manage:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: "Checkout code"
uses: actions/checkout@v7
- name: "Apply vouch command"
uses: mitchellh/vouch/action/manage-by-issue@v1.5.0
with:
issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -26,9 +26,10 @@ yarn-error.log
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/coverage /coverage
/extensions /extensions/*
!/extensions/.gitkeep !/extensions/.gitkeep
!/extensions/extensions_autoload.php !/extensions/extensions_autoload.php
!/extensions/manifest.json
/auth.json /auth.json
/modules_statuses.json /modules_statuses.json
/k8s /k8s

View File

@@ -12,6 +12,22 @@ In order to keep the issues of the repository clean we decided to only use them
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. 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.
### Vouched contributors
Pull requests from authors who are not vouched are closed automatically. This lets us keep up with the volume of AI slop pull requests without a maintainer having to triage every one of them by hand.
Your pull request is not affected if any of the following applies:
- You have write access to this repository.
- Someone with write access has vouched for you. The list lives in [.github/VOUCHED.td](.github/VOUCHED.td).
- Your pull request changes 50 lines or fewer. Test files and lockfiles do not count towards that number, so a small fix that comes with tests still qualifies.
To get vouched, open an issue or discussion before you start and explain how you intend to implement the change. We will discuss the approach with you, and only once we have agreed on the implementation does a maintainer comment `vouch @your-handle`, which puts you on the list from then on.
Being vouched only stops your pull requests from being closed automatically. [Only work on approved issues](#only-work-on-approved-issues) still applies to every pull request you send.
Contributors who abuse this are denounced, and their pull requests are closed regardless of size.
### Contributor License Agreement ### 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. 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.

View File

@@ -39,6 +39,8 @@ Please open an issue or start a discussion and wait for approval before submitti
**If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.** **If you submit an AI slop pull request (especially without following the proper procedure), you will be banned from future contributions to solidtime.**
To keep that manageable, pull requests from authors who are not vouched are closed automatically, unless they change 50 lines or fewer. To get vouched, open an issue or discussion first and explain how you intend to implement the change. Once we have agreed on the approach, we vouch for you. See [Vouched contributors](./CONTRIBUTING.md#vouched-contributors).
Please read the [CONTRIBUTING.md](./CONTRIBUTING.md) before sumbitting a Pull Request. 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. We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TagMatchType: string
{
use LaravelEnumHelper;
case Contains = 'contains';
case NotContains = 'not_contains';
}

View File

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

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
enum TimeEntryType: string
{
use LaravelEnumHelper;
case Work = 'work';
case Break = 'break';
}

View File

@@ -60,7 +60,6 @@ abstract class ApiException extends Exception
*/ */
public function report(): bool public function report(): bool
{ {
// TODO: temporary activated
return false; return false;
} }
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException class CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers extends ApiException
{ {
public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members'; public const string KEY = 'can_not_delete_user_who_is_owner_of_organization_with_multiple_members';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class CanNotRemoveOwnerFromOrganization extends ApiException class CanNotRemoveOwnerFromOrganization extends ApiException
{ {
public const string KEY = 'can_not_remove_owner_from_organization'; public const string KEY = 'can_not_remove_owner_from_organization';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
{ {
public const string KEY = 'changing_role_of_placeholder_is_not_allowed'; public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ChangingRoleToPlaceholderIsNotAllowed extends ApiException class ChangingRoleToPlaceholderIsNotAllowed extends ApiException
{ {
public const string KEY = 'changing_role_to_placeholder_is_not_allowed'; public const string KEY = 'changing_role_to_placeholder_is_not_allowed';
public function report(): bool
{
return true;
}
} }

View File

@@ -19,6 +19,16 @@ class EntityStillInUseApiException extends ApiException
public const string KEY = 'entity_still_in_use'; public const string KEY = 'entity_still_in_use';
/**
* Report the exception.
*
* @return bool true means the exception handler will not report it again
*/
public function report(): bool
{
return true;
}
/** /**
* Get the translated message for the exception. * Get the translated message for the exception.
*/ */

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class FeatureIsNotAvailableInFreePlanApiException extends ApiException class FeatureIsNotAvailableInFreePlanApiException extends ApiException
{ {
public const string KEY = 'feature_is_not_available_in_free_plan'; public const string KEY = 'feature_is_not_available_in_free_plan';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class InactiveUserCanNotBeUsedApiException extends ApiException class InactiveUserCanNotBeUsedApiException extends ApiException
{ {
public const string KEY = 'inactive_user_can_not_be_used'; public const string KEY = 'inactive_user_can_not_be_used';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class InvitationForTheEmailAlreadyExistsApiException extends ApiException class InvitationForTheEmailAlreadyExistsApiException extends ApiException
{ {
public const string KEY = 'invitation_for_the_email_already_exists'; public const string KEY = 'invitation_for_the_email_already_exists';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OnlyOwnerCanChangeOwnership extends ApiException class OnlyOwnerCanChangeOwnership extends ApiException
{ {
public const string KEY = 'only_owner_can_change_ownership'; public const string KEY = 'only_owner_can_change_ownership';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
{ {
public const string KEY = 'only_placeholders_can_be_merged_into_another_member'; public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException class OrganizationHasNoSubscriptionButMultipleMembersException extends ApiException
{ {
public const string KEY = 'organization_has_no_subscription_but_multiple_members'; public const string KEY = 'organization_has_no_subscription_but_multiple_members';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OrganizationNeedsAtLeastOneOwner extends ApiException class OrganizationNeedsAtLeastOneOwner extends ApiException
{ {
public const string KEY = 'organization_needs_at_least_one_owner'; public const string KEY = 'organization_needs_at_least_one_owner';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class OverlappingTimeEntryApiException extends ApiException class OverlappingTimeEntryApiException extends ApiException
{ {
public const string KEY = 'overlapping_time_entry'; public const string KEY = 'overlapping_time_entry';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
{ {
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception'; public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class TimeEntryCanNotBeRestartedApiException extends ApiException class TimeEntryCanNotBeRestartedApiException extends ApiException
{ {
public const string KEY = 'time_entry_can_not_be_restarted'; public const string KEY = 'time_entry_can_not_be_restarted';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,14 @@ namespace App\Exceptions\Api;
class TimeEntryStillRunningApiException extends ApiException class TimeEntryStillRunningApiException extends ApiException
{ {
public const string KEY = 'time_entry_still_running'; public const string KEY = 'time_entry_still_running';
/**
* Report the exception.
*
* @return bool true means the exception handler will not report it again
*/
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfOrganizationApiException extends ApiException class UserIsAlreadyMemberOfOrganizationApiException extends ApiException
{ {
public const string KEY = 'user_is_already_member_of_organization'; public const string KEY = 'user_is_already_member_of_organization';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserIsAlreadyMemberOfProjectApiException extends ApiException class UserIsAlreadyMemberOfProjectApiException extends ApiException
{ {
public const string KEY = 'user_is_already_member_of_project'; public const string KEY = 'user_is_already_member_of_project';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserNotPlaceholderApiException extends ApiException class UserNotPlaceholderApiException extends ApiException
{ {
public const string KEY = 'user_not_placeholder'; public const string KEY = 'user_not_placeholder';
public function report(): bool
{
return true;
}
} }

View File

@@ -7,4 +7,9 @@ namespace App\Exceptions\Api;
class UserResendEmailVerificationNoPendingEmailApiException extends ApiException class UserResendEmailVerificationNoPendingEmailApiException extends ApiException
{ {
public const string KEY = 'user_resend_email_verification_no_pending_email'; public const string KEY = 'user_resend_email_verification_no_pending_email';
public function report(): bool
{
return true;
}
} }

View File

@@ -6,7 +6,11 @@ namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use League\OAuth2\Server\Exception\OAuthServerException;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Throwable; use Throwable;
class Handler extends ExceptionHandler class Handler extends ExceptionHandler
@@ -30,6 +34,33 @@ class Handler extends ExceptionHandler
$this->reportable(function (Throwable $e): void { $this->reportable(function (Throwable $e): void {
// //
}); });
$this->dontReportWhen(fn (Throwable $e): bool => $e instanceof OAuthServerException
&& $e->getErrorType() === 'access_denied'
&& $e->getHttpStatusCode() === 401);
// A request on an untrusted host (see App\Http\Middleware\TrustHosts)
// otherwise renders as a bare "Bad request." 400. Show a message that
// says how to fix it instead. The framework has already converted the
// SuspiciousOperationException into a BadRequestHttpException by the time
// renderables run, so we match that and inspect the original.
$this->renderable(function (BadRequestHttpException $e, Request $request): ?Response {
$previous = $e->getPrevious();
if (! $previous instanceof SuspiciousOperationException
|| ! str_starts_with($previous->getMessage(), 'Untrusted Host')) {
return null; // any other bad request keeps the default response
}
$message = 'This hostname is not configured for this instance. '
.'Set APP_URL, or add the host to TRUSTED_HOSTS.';
if ($request->expectsJson()) {
return response()->json(['message' => $message], 400);
}
return response()->view('errors.untrusted-host', ['message' => $message], 400);
});
} }
public function render($request, Throwable $e): Response|RedirectResponse public function render($request, Throwable $e): Response|RedirectResponse

View File

@@ -66,7 +66,7 @@ class UserResource extends Resource
->ignore($record?->getKey()), ->ignore($record?->getKey()),
]) ])
->rule([ ->rule([
'email', 'email:rfc,strict',
]) ])
->maxLength(255), ->maxLength(255),
Forms\Components\Toggle::make('is_placeholder') Forms\Components\Toggle::make('is_placeholder')

View File

@@ -43,7 +43,8 @@ class ClientController extends Controller
$clientsQuery = Client::query() $clientsQuery = Client::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc'); ->orderBy('created_at', 'desc')
->orderBy('id');
if (! $canViewAllClients) { if (! $canViewAllClients) {
$clientsQuery->visibleByEmployee($user); $clientsQuery->visibleByEmployee($user);

View File

@@ -28,7 +28,9 @@ class ExportController extends Controller
$filepath = $exportService->export($organization); $filepath = $exportService->export($organization);
$downloadUrl = Storage::disk(config('filesystems.private')) $downloadUrl = Storage::disk(config('filesystems.private'))
->temporaryUrl($filepath, Carbon::now()->addMinutes(10)); ->temporaryUrl($filepath, Carbon::now()->addMinutes(10), [
'ResponseContentDisposition' => 'attachment; filename="'.basename($filepath).'"',
]);
return new JsonResponse([ return new JsonResponse([
'success' => true, 'success' => true,

View File

@@ -42,6 +42,7 @@ class InvitationController extends Controller
$invitations = $organization->organizationInvitations() $invitations = $organization->organizationInvitations()
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return InvitationCollection::make($invitations); return InvitationCollection::make($invitations);

View File

@@ -61,6 +61,7 @@ class MemberController extends Controller
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->with(['user']) ->with(['user'])
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return MemberCollection::make($members); return MemberCollection::make($members);

View File

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

View File

@@ -62,6 +62,7 @@ class ProjectController extends Controller
$projects = $projectsQuery $projects = $projectsQuery
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;

View File

@@ -49,6 +49,7 @@ class ProjectMemberController extends Controller
$projectMembers = ProjectMember::query() $projectMembers = ProjectMember::query()
->whereBelongsTo($project, 'project') ->whereBelongsTo($project, 'project')
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return new ProjectMemberCollection($projectMembers); return new ProjectMemberCollection($projectMembers);

View File

@@ -57,9 +57,10 @@ class ReportController extends Controller
$filter->addEnd($properties->end); $filter->addEnd($properties->end);
$filter->addActive($properties->active); $filter->addActive($properties->active);
$filter->addBillable($properties->billable); $filter->addBillable($properties->billable);
$filter->addType($properties->timeEntryType);
$filter->addMemberIdsFilter($properties->memberIds?->toArray()); $filter->addMemberIdsFilter($properties->memberIds?->toArray());
$filter->addProjectIdsFilter($properties->projectIds?->toArray()); $filter->addProjectIdsFilter($properties->projectIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray()); $filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType);
$filter->addTaskIdsFilter($properties->taskIds?->toArray()); $filter->addTaskIdsFilter($properties->taskIds?->toArray());
$filter->addClientIdsFilter($properties->clientIds?->toArray()); $filter->addClientIdsFilter($properties->clientIds?->toArray());
$timeEntriesQuery = $filter->get(); $timeEntriesQuery = $filter->get();

View File

@@ -47,6 +47,7 @@ class ReportController extends Controller
$reports = Report::query() $reports = Report::query()
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
@@ -96,6 +97,7 @@ class ReportController extends Controller
$properties->setClientIds($request->input('properties.client_ids', null)); $properties->setClientIds($request->input('properties.client_ids', null));
$properties->setProjectIds($request->input('properties.project_ids', null)); $properties->setProjectIds($request->input('properties.project_ids', null));
$properties->setTagIds($request->input('properties.tag_ids', null)); $properties->setTagIds($request->input('properties.tag_ids', null));
$properties->setTagMatchType($request->getPropertyTagMatchType());
$properties->setTaskIds($request->input('properties.task_ids', null)); $properties->setTaskIds($request->input('properties.task_ids', null));
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start; $properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
$timezone = $user->timezone; $timezone = $user->timezone;
@@ -110,6 +112,7 @@ class ReportController extends Controller
$properties->timezone = $timezone; $properties->timezone = $timezone;
$properties->roundingType = $request->getPropertyRoundingType(); $properties->roundingType = $request->getPropertyRoundingType();
$properties->roundingMinutes = $request->getPropertyRoundingMinutes(); $properties->roundingMinutes = $request->getPropertyRoundingMinutes();
$properties->timeEntryType = $request->getPropertyTimeEntryType();
$report->properties = $properties; $report->properties = $properties;
if ($isPublic) { if ($isPublic) {
$report->share_secret = $reportService->generateSecret(); $report->share_secret = $reportService->generateSecret();

View File

@@ -42,6 +42,7 @@ class TagController extends Controller
$tags = Tag::query() $tags = Tag::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return new TagCollection($tags); return new TagCollection($tags);

View File

@@ -84,6 +84,7 @@ class TaskController extends Controller
$tasks = $query $tasks = $query
->orderBy('created_at', 'desc') ->orderBy('created_at', 'desc')
->orderBy('id')
->paginate(config('app.pagination_per_page_default')); ->paginate(config('app.pagination_per_page_default'));
return new TaskCollection($tasks); return new TaskCollection($tasks);

View File

@@ -6,6 +6,7 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException; use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
use App\Exceptions\Api\OverlappingTimeEntryApiException; use App\Exceptions\Api\OverlappingTimeEntryApiException;
use App\Exceptions\Api\PdfRendererIsNotConfiguredException; use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
@@ -67,7 +68,7 @@ class TimeEntryController extends Controller
$query = TimeEntry::query() $query = TimeEntry::query()
->where('organization_id', $organization->getKey()) ->where('organization_id', $organization->getKey())
->where('user_id', $member->user_id) ->where('member_id', $member->getKey())
->when($exclude !== null, function (Builder $q) use ($exclude): void { ->when($exclude !== null, function (Builder $q) use ($exclude): void {
$q->where('id', '!=', $exclude->getKey()); $q->where('id', '!=', $exclude->getKey());
}) })
@@ -107,8 +108,8 @@ class TimeEntryController extends Controller
/** /**
* Get time entries in organization * Get time entries in organization
* *
* If you only need time entries for a specific user, you can filter by `user_id`. * If you only need time entries for a specific user, you can filter by `member_id`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter. * Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
* *
* @return TimeEntryCollection<TimeEntryResource> * @return TimeEntryCollection<TimeEntryResource>
* *
@@ -118,16 +119,17 @@ class TimeEntryController extends Controller
*/ */
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
{ {
/** @var Member|null $member */ $member = $this->member($organization);
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; /** @var Member|null $memberFilter */
if ($member !== null && $member->user_id === Auth::id()) { $memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
$this->checkPermission($organization, 'time-entries:view:own'); $this->checkPermission($organization, 'time-entries:view:own');
} else { } else {
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
} }
$canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization); $canAccessPremiumFeatures = $this->canAccessPremiumFeatures($organization);
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
$totalCount = $timeEntriesQuery->count(); $totalCount = $timeEntriesQuery->count();
@@ -158,7 +160,7 @@ class TimeEntryController extends Controller
if ($timeEntries->count() === 0) { if ($timeEntries->count() === 0) {
Log::warning('User has has more than '.$limit.' time entries on one date', [ Log::warning('User has has more than '.$limit.' time entries on one date', [
'date' => $lastDate->toDateString(), 'date' => $lastDate->toDateString(),
'user_id' => $request->input('user_id'), 'member_id' => $request->input('member_id'),
'auth_user_id' => Auth::id(), 'auth_user_id' => Auth::id(),
'limit' => $limit, 'limit' => $limit,
]); ]);
@@ -194,7 +196,8 @@ class TimeEntryController extends Controller
$timeEntriesQuery = TimeEntry::query() $timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization') ->whereBelongsTo($organization, 'organization')
->select($select) ->select($select)
->orderBy('start', 'desc'); ->orderBy('time_entries.start', 'desc')
->orderBy('time_entries.id');
$filter = new TimeEntryFilter($timeEntriesQuery); $filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start')); $filter->addStartFilter($request->input('start'));
@@ -203,10 +206,11 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member); $filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids')); $filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids')); $filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids')); $filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$filter->addTaskIdsFilter($request->input('task_ids')); $filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids')); $filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable')); $filter->addBillableFilter($request->input('billable'));
$filter->addTypeFilter($request->input('type'));
return $filter->get(); return $filter->get();
} }
@@ -220,9 +224,10 @@ class TimeEntryController extends Controller
*/ */
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{ {
/** @var Member|null $member */ $member = $this->member($organization);
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; /** @var Member|null $memberFilter */
if ($member !== null && $member->user_id === Auth::id()) { $memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
$this->checkPermission($organization, 'time-entries:view:own'); $this->checkPermission($organization, 'time-entries:view:own');
} else { } else {
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
@@ -239,7 +244,7 @@ class TimeEntryController extends Controller
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $memberFilter, $canAccessPremiumFeatures);
$timeEntriesQuery->with([ $timeEntriesQuery->with([
'task', 'task',
'client', 'client',
@@ -262,7 +267,7 @@ class TimeEntryController extends Controller
if ($viewFile === false) { if ($viewFile === false) {
throw new \LogicException('View file not found'); throw new \LogicException('View file not found');
} }
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries( $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery, $timeEntriesAggregateQuery,
null, null,
@@ -331,7 +336,9 @@ class TimeEntryController extends Controller
return response()->json([ return response()->json([
'download_url' => Storage::disk(config('filesystems.private')) 'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)), ->temporaryUrl($path, now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
]); ]);
} }
@@ -369,9 +376,10 @@ class TimeEntryController extends Controller
*/ */
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
{ {
/** @var Member|null $member */ $member = $this->member($organization);
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; /** @var Member|null $memberFilter */
if ($member !== null && $member->user_id === Auth::id()) { $memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
$this->checkPermission($organization, 'time-entries:view:own'); $this->checkPermission($organization, 'time-entries:view:own');
} else { } else {
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
@@ -382,7 +390,7 @@ class TimeEntryController extends Controller
$group1Type = $request->getGroup(); $group1Type = $request->getGroup();
$group2Type = $request->getSubGroup(); $group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -418,9 +426,10 @@ class TimeEntryController extends Controller
*/ */
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{ {
/** @var Member|null $member */ $member = $this->member($organization);
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; /** @var Member|null $memberFilter */
if ($member !== null && $member->user_id === Auth::id()) { $memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($memberFilter !== null && $memberFilter->getKey() === $member->getKey()) {
$this->checkPermission($organization, 'time-entries:view:own'); $this->checkPermission($organization, 'time-entries:view:own');
} else { } else {
$this->checkPermission($organization, 'time-entries:view:all'); $this->checkPermission($organization, 'time-entries:view:all');
@@ -436,7 +445,7 @@ class TimeEntryController extends Controller
$group = $request->getGroup(); $group = $request->getGroup();
$subGroup = $request->getSubGroup(); $subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -528,7 +537,7 @@ class TimeEntryController extends Controller
->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename); ->putFileAs($folderPath, new File($tempFolder->path($filenameTemp)), $filename);
} else { } else {
Excel::store( Excel::store(
new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate), new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate, $localizationService),
$path, $path,
config('filesystems.private'), config('filesystems.private'),
$format->getExportPackageType(), $format->getExportPackageType(),
@@ -540,7 +549,9 @@ class TimeEntryController extends Controller
return response()->json([ return response()->json([
'download_url' => Storage::disk(config('filesystems.private')) 'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)), ->temporaryUrl($path, now()->addMinutes(5), [
'ResponseContentDisposition' => 'attachment; filename="'.$filename.'"',
]),
]); ]);
} }
@@ -559,10 +570,11 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member); $filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids')); $filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids')); $filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids')); $filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$filter->addTaskIdsFilter($request->input('task_ids')); $filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids')); $filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable')); $filter->addBillableFilter($request->input('billable'));
$filter->addTypeFilter($request->input('type'));
return $filter->get(); return $filter->get();
} }
@@ -579,7 +591,7 @@ class TimeEntryController extends Controller
{ {
/** @var Member $member */ /** @var Member $member */
$member = Member::query()->findOrFail($request->input('member_id')); $member = Member::query()->findOrFail($request->input('member_id'));
if ($member->user_id === Auth::id()) { if ($member->getKey() === $this->member($organization)->getKey()) {
$this->checkPermission($organization, 'time-entries:create:own'); $this->checkPermission($organization, 'time-entries:create:own');
} else { } else {
$this->checkPermission($organization, 'time-entries:create:all'); $this->checkPermission($organization, 'time-entries:create:all');
@@ -626,9 +638,10 @@ class TimeEntryController extends Controller
*/ */
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
{ {
/** @var Member|null $member */ $member = $this->member($organization);
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; /** @var Member|null $newMember */
if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) { $newMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($timeEntry->member_id === $member->getKey() && ($newMember === null || $newMember->getKey() === $member->getKey())) {
$this->checkPermission($organization, 'time-entries:update:own', $timeEntry); $this->checkPermission($organization, 'time-entries:update:own', $timeEntry);
} else { } else {
$this->checkPermission($organization, 'time-entries:update:all', $timeEntry); $this->checkPermission($organization, 'time-entries:update:all', $timeEntry);
@@ -660,6 +673,10 @@ class TimeEntryController extends Controller
} }
$timeEntry->fill($request->validated()); $timeEntry->fill($request->validated());
if ($newMember !== null) {
$timeEntry->member()->associate($newMember);
$timeEntry->user()->associate($newMember->user);
}
$timeEntry->description = $request->input('description', $timeEntry->description) ?? ''; $timeEntry->description = $request->input('description', $timeEntry->description) ?? '';
$timeEntry->setComputedAttributeValue('billable_rate'); $timeEntry->setComputedAttributeValue('billable_rate');
$timeEntry->save(); $timeEntry->save();
@@ -689,6 +706,7 @@ class TimeEntryController extends Controller
*/ */
public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse public function updateMultiple(Organization $organization, TimeEntryUpdateMultipleRequest $request): JsonResponse
{ {
$member = $this->member($organization);
$this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']); $this->checkAnyPermission($organization, ['time-entries:update:all', 'time-entries:update:own']);
$canAccessAll = $this->hasPermission($organization, 'time-entries:update:all'); $canAccessAll = $this->hasPermission($organization, 'time-entries:update:all');
@@ -713,6 +731,9 @@ class TimeEntryController extends Controller
throw new AuthorizationException; throw new AuthorizationException;
} }
/** @var Member|null $newMember */
$newMember = isset($changes['member_id']) ? Member::query()->findOrFail($changes['member_id']) : null;
$project = null; $project = null;
$client = null; $client = null;
$overwriteClient = false; $overwriteClient = false;
@@ -739,16 +760,33 @@ class TimeEntryController extends Controller
continue; continue;
} }
if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) { if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) {
$error->push($id); $error->push($id);
continue; continue;
} }
// Changing time entries to Break entries is only allowed when breaks are enabled in the org settings
$resultingType = isset($changes['type']) ? TimeEntryType::from($changes['type']) : $timeEntry->type;
if ($resultingType === TimeEntryType::Break && $timeEntry->type !== TimeEntryType::Break && ! $organization->breaks_enabled) {
$error->push($id);
continue;
}
// Break entries can not be billable, have tags or belong to a project/task (see TimeEntry::booted)
if ($resultingType === TimeEntryType::Break && ($project !== null || $task !== null || $request->boolean('changes.billable') || count($changes['tags'] ?? []) > 0)) {
$error->push($id);
continue;
}
$oldProject = $timeEntry->project; $oldProject = $timeEntry->project;
$oldTask = $timeEntry->task; $oldTask = $timeEntry->task;
$timeEntry->fill($changes); $timeEntry->fill($changes);
if ($newMember !== null) {
$timeEntry->member()->associate($newMember);
$timeEntry->user_id = $newMember->user_id;
}
// If project is changed, but task is not, we remove the old task from the time entry // If project is changed, but task is not, we remove the old task from the time entry
if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) { if ($oldProject !== null && $project !== null && $oldProject->isNot($project) && $task === null) {
$timeEntry->task()->disassociate(); $timeEntry->task()->disassociate();
@@ -789,7 +827,8 @@ class TimeEntryController extends Controller
*/ */
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
{ {
if ($timeEntry->member->user_id === Auth::id()) { $member = $this->member($organization);
if ($timeEntry->member_id === $member->getKey()) {
$this->checkPermission($organization, 'time-entries:delete:own', $timeEntry); $this->checkPermission($organization, 'time-entries:delete:own', $timeEntry);
} else { } else {
$this->checkPermission($organization, 'time-entries:delete:all', $timeEntry); $this->checkPermission($organization, 'time-entries:delete:all', $timeEntry);
@@ -846,7 +885,7 @@ class TimeEntryController extends Controller
continue; continue;
} }
if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) { if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) {
$error->push($id); $error->push($id);
continue; continue;

View File

@@ -15,6 +15,7 @@ use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated; use App\Http\Middleware\RedirectIfAuthenticated;
use App\Http\Middleware\ShareInertiaData; use App\Http\Middleware\ShareInertiaData;
use App\Http\Middleware\TrimStrings; use App\Http\Middleware\TrimStrings;
use App\Http\Middleware\TrustHosts;
use App\Http\Middleware\TrustProxies; use App\Http\Middleware\TrustProxies;
use App\Http\Middleware\ValidateSignature; use App\Http\Middleware\ValidateSignature;
use App\Http\Middleware\VerifyCsrfToken; use App\Http\Middleware\VerifyCsrfToken;
@@ -47,6 +48,7 @@ class Kernel extends HttpKernel
*/ */
protected $middleware = [ protected $middleware = [
ForceHttps::class, ForceHttps::class,
TrustHosts::class,
TrustProxies::class, TrustProxies::class,
HandleCors::class, HandleCors::class,
PreventRequestsDuringMaintenance::class, PreventRequestsDuringMaintenance::class,

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware;
use Illuminate\Http\Middleware\TrustHosts as BaseTrustHosts;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
/**
* Rejects requests whose Host is not trusted, preventing Host-header poisoning of
* generated URLs (password reset, SSO callback, invitations). Trusted = the
* APP_URL host and its subdomains, plus TRUSTED_HOSTS (for multi-host access such
* as a Tailscale name). Health-check endpoints are exempt (probed by IP).
*/
class TrustHosts extends BaseTrustHosts
{
/**
* @return array<int, string|null>
*/
public function hosts(): array
{
/** @var array<int, string> $configured */
$configured = config('app.trusted_hosts', []);
$extra = array_map(function (string $host): string {
$host = trim($host);
// "*.example.com" matches any subdomain, not the apex.
if (str_starts_with($host, '*.')) {
return '^.+\.'.preg_quote(substr($host, 2), '#').'$';
}
return '^'.preg_quote($host, '#').'$';
}, $configured);
return array_merge([$this->allSubdomainsOfApplicationUrl()], $extra);
}
/**
* @param \Closure(Request): Response $next
*/
public function handle(Request $request, $next)
{
// Exempt health checks (probed by IP). Also reset the trusted hosts,
// since Octane leaks the static state across requests.
if ($request->is('health-check/*')) {
Request::setTrustedHosts([]);
return $next($request);
}
return parent::handle($request, $next);
}
}

View File

@@ -25,7 +25,7 @@ class InvitationStoreRequest extends BaseFormRequest
return [ return [
'email' => [ 'email' => [
'required', 'required',
'email', 'email:rfc,strict',
], ],
'role' => [ 'role' => [
'required', 'required',

View File

@@ -51,6 +51,9 @@ class OrganizationUpdateRequest extends BaseFormRequest
'prevent_overlapping_time_entries' => [ 'prevent_overlapping_time_entries' => [
'boolean', 'boolean',
], ],
'breaks_enabled' => [
'boolean',
],
'number_format' => [ 'number_format' => [
Rule::enum(NumberFormat::class), Rule::enum(NumberFormat::class),
], ],
@@ -125,4 +128,9 @@ class OrganizationUpdateRequest extends BaseFormRequest
{ {
return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null; return $this->has('prevent_overlapping_time_entries') ? $this->boolean('prevent_overlapping_time_entries') : null;
} }
public function getBreaksEnabled(): ?bool
{
return $this->has('breaks_enabled') ? $this->boolean('breaks_enabled') : null;
}
} }

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Report; namespace App\Http\Requests\V1\Report;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Organization; use App\Models\Organization;
@@ -124,6 +126,11 @@ class ReportStoreRequest extends BaseFormRequest
} }
}, },
], ],
'properties.tag_match_type' => [
'nullable',
'string',
Rule::enum(TagMatchType::class),
],
'properties.task_ids' => [ 'properties.task_ids' => [
'nullable', 'nullable',
'array', 'array',
@@ -171,6 +178,12 @@ class ReportStoreRequest extends BaseFormRequest
'numeric', 'numeric',
'integer', 'integer',
], ],
// Filter by time entry type
'properties.time_entry_type' => [
'nullable',
'string',
Rule::enum(TimeEntryType::class),
],
]; ];
} }
@@ -234,6 +247,15 @@ class ReportStoreRequest extends BaseFormRequest
return null; return null;
} }
public function getPropertyTimeEntryType(): ?TimeEntryType
{
if (! $this->has('properties.time_entry_type') || $this->input('properties.time_entry_type') === null) {
return null;
}
return TimeEntryType::from($this->input('properties.time_entry_type'));
}
public function getPropertyGroup(): TimeEntryAggregationType public function getPropertyGroup(): TimeEntryAggregationType
{ {
return TimeEntryAggregationType::from($this->input('properties.group')); return TimeEntryAggregationType::from($this->input('properties.group'));
@@ -249,6 +271,15 @@ class ReportStoreRequest extends BaseFormRequest
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group')); return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
} }
public function getPropertyTagMatchType(): ?TagMatchType
{
if (! $this->has('properties.tag_match_type') || $this->input('properties.tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->input('properties.tag_match_type'));
}
public function getPropertyRoundingType(): ?TimeEntryRoundingType public function getPropertyRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) { if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {

View File

@@ -5,9 +5,11 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -139,6 +141,10 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail); })->uuid()->validate($attribute, $value, $fail);
}, },
], ],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
'array', 'array',
@@ -178,6 +184,11 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
'string', 'string',
'in:true,false', 'in:true,false',
], ],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
'fill_gaps_in_time_groups' => [ 'fill_gaps_in_time_groups' => [
'string', 'string',
'in:true,false', 'in:true,false',
@@ -246,6 +257,15 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
return ExportFormat::from($this->validated('format')); return ExportFormat::from($this->validated('format'));
} }
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType public function getRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,8 +4,10 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -125,6 +127,10 @@ class TimeEntryAggregateRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail); })->uuid()->validate($attribute, $value, $fail);
}, },
], ],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
'array', 'array',
@@ -164,6 +170,11 @@ class TimeEntryAggregateRequest extends BaseFormRequest
'string', 'string',
'in:true,false', 'in:true,false',
], ],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
'fill_gaps_in_time_groups' => [ 'fill_gaps_in_time_groups' => [
'string', 'string',
'in:true,false', 'in:true,false',
@@ -208,6 +219,15 @@ class TimeEntryAggregateRequest extends BaseFormRequest
return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null; return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null;
} }
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType public function getRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -5,7 +5,9 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
@@ -110,6 +112,10 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
})->uuid()->validate($attribute, $value, $fail); })->uuid()->validate($attribute, $value, $fail);
}, },
], ],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
'array', 'array',
@@ -150,6 +156,11 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
'string', 'string',
'in:true,false', 'in:true,false',
], ],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
// Limit the number of returned time entries (default: 150) // Limit the number of returned time entries (default: 150)
'limit' => [ 'limit' => [
'integer', 'integer',
@@ -215,6 +226,15 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
return ExportFormat::from($this->validated('format')); return ExportFormat::from($this->validated('format'));
} }
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType public function getRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,7 +4,9 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
@@ -103,6 +105,10 @@ class TimeEntryIndexRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail); })->uuid()->validate($attribute, $value, $fail);
}, },
], ],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined // Filter by task IDs, task IDs are OR combined
'task_ids' => [ 'task_ids' => [
'array', 'array',
@@ -143,6 +149,11 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string', 'string',
'in:true,false', 'in:true,false',
], ],
// Filter by time entry type
'type' => [
'string',
Rule::enum(TimeEntryType::class),
],
// Limit the number of returned time entries (default: 150) // Limit the number of returned time entries (default: 150)
'limit' => [ 'limit' => [
'integer', 'integer',
@@ -190,6 +201,15 @@ class TimeEntryIndexRequest extends BaseFormRequest
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0; return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
} }
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType public function getRoundingType(): ?TimeEntryRoundingType
{ {
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
@@ -14,6 +15,7 @@ use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
@@ -24,7 +26,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, array<string|ValidationRule>> * @return array<string, array<string|\Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -42,6 +44,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'nullable', 'nullable',
'string', 'string',
'required_with:task_id', 'required_with:task_id',
'prohibited_if:type,break',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization'); $builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -60,6 +63,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'task_id' => [ 'task_id' => [
'nullable', 'nullable',
'string', 'string',
'prohibited_if:type,break',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
@@ -85,6 +89,16 @@ class TimeEntryStoreRequest extends BaseFormRequest
'billable' => [ 'billable' => [
'required', 'required',
'boolean', 'boolean',
'declined_if:type,break',
],
// Type of the time entry (work time or a break)
'type' => [
Rule::enum(TimeEntryType::class),
function (string $attribute, mixed $value, \Closure $fail): void {
if ($value === TimeEntryType::Break->value && ! $this->organization->breaks_enabled) {
$fail('Breaks are disabled for this organization.');
}
},
], ],
// Description of time entry // Description of time entry
'description' => [ 'description' => [
@@ -96,6 +110,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'tags' => [ 'tags' => [
'nullable', 'nullable',
'array', 'array',
'prohibited_if:type,break',
], ],
'tags.*' => [ 'tags.*' => [
ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Tag::class, null, function (Builder $builder): Builder {

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
@@ -14,6 +15,7 @@ use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
@@ -24,7 +26,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, array<string|ValidationRule>> * @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/ */
public function rules(): array public function rules(): array
{ {
@@ -54,6 +56,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'nullable', 'nullable',
'string', 'string',
'required_with:task_id', 'required_with:task_id',
'prohibited_if:changes.type,break',
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization'); $builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -72,6 +75,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.task_id' => [ 'changes.task_id' => [
'nullable', 'nullable',
'string', 'string',
'prohibited_if:changes.type,break',
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
@@ -84,7 +88,13 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
], ],
// Whether time entry is billable // Whether time entry is billable
'changes.billable' => [ 'changes.billable' => [
'sometimes',
'boolean', 'boolean',
'declined_if:changes.type,break',
],
// Type of the time entry (work time or a break)
'changes.type' => [
Rule::enum(TimeEntryType::class),
], ],
// Description of time entry // Description of time entry
'changes.description' => [ 'changes.description' => [
@@ -96,6 +106,7 @@ class TimeEntryUpdateMultipleRequest extends BaseFormRequest
'changes.tags' => [ 'changes.tags' => [
'nullable', 'nullable',
'array', 'array',
'prohibited_if:changes.type,break',
], ],
'changes.tags.*' => [ 'changes.tags.*' => [
'string', 'string',

View File

@@ -4,16 +4,21 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry; namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TimeEntryType;
use App\Http\Requests\V1\BaseFormRequest; use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
use App\Models\Tag; use App\Models\Tag;
use App\Models\Task; use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ConditionalRules;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\ProhibitedIf;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/** /**
@@ -24,10 +29,19 @@ class TimeEntryUpdateRequest extends BaseFormRequest
/** /**
* Get the validation rules that apply to the request. * Get the validation rules that apply to the request.
* *
* @return array<string, array<string|ValidationRule>> * @return array<string, array<string|\Closure|ValidationRule|\Illuminate\Contracts\Validation\Rule|ProhibitedIf|ConditionalRules>>
*/ */
public function rules(): array public function rules(): array
{ {
// Break restrictions need to apply based on the type the entry will have after the
// update, not only when the payload itself contains type=break.
$timeEntry = $this->route('timeEntry');
$timeEntry = $timeEntry instanceof TimeEntry ? $timeEntry : null;
$resultingType = $this->has('type')
? TimeEntryType::tryFrom((string) $this->input('type'))
: $timeEntry?->type;
$isBreak = $resultingType === TimeEntryType::Break;
return [ return [
// ID of the organization member that the time entry should belong to // ID of the organization member that the time entry should belong to
'member_id' => [ 'member_id' => [
@@ -42,6 +56,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'nullable', 'nullable',
'string', 'string',
'required_with:task_id', 'required_with:task_id',
Rule::prohibitedIf($isBreak),
ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */ /** @var Builder<Project> $builder */
$builder = $builder->whereBelongsTo($this->organization, 'organization'); $builder = $builder->whereBelongsTo($this->organization, 'organization');
@@ -60,6 +75,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'task_id' => [ 'task_id' => [
'nullable', 'nullable',
'string', 'string',
Rule::prohibitedIf($isBreak),
ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */ /** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
@@ -82,7 +98,22 @@ class TimeEntryUpdateRequest extends BaseFormRequest
], ],
// Whether time entry is billable // Whether time entry is billable
'billable' => [ 'billable' => [
'sometimes',
'boolean', 'boolean',
Rule::when($isBreak, ['declined']),
],
// Type of the time entry (work time or a break)
'type' => [
Rule::enum(TimeEntryType::class),
function (string $attribute, mixed $value, \Closure $fail) use ($timeEntry): void {
// While breaks are disabled, entries that already are breaks may stay
// breaks, but converting a work entry to a break is not allowed.
if ($value === TimeEntryType::Break->value
&& ! $this->organization->breaks_enabled
&& $timeEntry?->type !== TimeEntryType::Break) {
$fail('Breaks are disabled for this organization.');
}
},
], ],
// Description of time entry // Description of time entry
'description' => [ 'description' => [
@@ -94,6 +125,7 @@ class TimeEntryUpdateRequest extends BaseFormRequest
'tags' => [ 'tags' => [
'nullable', 'nullable',
'array', 'array',
Rule::prohibitedIf($isBreak),
], ],
'tags.*' => [ 'tags.*' => [
'string', 'string',

View File

@@ -41,7 +41,7 @@ class UserUpdateRequest extends BaseFormRequest
'max:255', 'max:255',
], ],
'email' => [ 'email' => [
'email', 'email:rfc,strict',
'max:255', 'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) { UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */ /** @var Builder<User> $query */

View File

@@ -57,6 +57,8 @@ class OrganizationResource extends BaseResource
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks, 'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */ /** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries, 'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
/** @var bool $breaks_enabled Whether members of the organization can track breaks */
'breaks_enabled' => $this->resource->breaks_enabled,
/** @var string $currency Currency code (ISO 4217) */ /** @var string $currency Currency code (ISO 4217) */
'currency' => $this->resource->currency, 'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */ /** @var string $currency_symbol Currency symbol */

View File

@@ -50,12 +50,16 @@ class DetailedReportResource extends BaseResource
'member_ids' => $this->resource->properties->memberIds?->toArray(), 'member_ids' => $this->resource->properties->memberIds?->toArray(),
/** @var bool|null $billable Filter by billable status */ /** @var bool|null $billable Filter by billable status */
'billable' => $this->resource->properties->billable, 'billable' => $this->resource->properties->billable,
/** @var string|null $time_entry_type Filter by time entry type */
'time_entry_type' => $this->resource->properties->timeEntryType?->value,
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */ /** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
'client_ids' => $this->resource->properties->clientIds?->toArray(), 'client_ids' => $this->resource->properties->clientIds?->toArray(),
/** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */ /** @var array<string>|null $project_ids Filter by project IDs, project IDs are OR combined */
'project_ids' => $this->resource->properties->projectIds?->toArray(), 'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */ /** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(), 'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var string|null $tag_match_type Tag match type */
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */ /** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(), 'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** @var string|null $rounding_type Rounding type for time entries */ /** @var string|null $rounding_type Rounding type for time entries */

View File

@@ -47,6 +47,8 @@ class TimeEntryResource extends BaseResource
'tags' => $this->resource->tags ?? [], 'tags' => $this->resource->tags ?? [],
/** @var bool $billable Whether time entry is billable */ /** @var bool $billable Whether time entry is billable */
'billable' => $this->resource->billable, 'billable' => $this->resource->billable,
/** @var string $type Type of the time entry (`work` time or a `break`) */
'type' => $this->resource->type->value,
]; ];
} }
} }

View File

@@ -20,6 +20,11 @@ class RecalculateSpentTimeForProject implements ShouldDispatchAfterCommit, Shoul
use Queueable; use Queueable;
use SerializesModels; use SerializesModels;
/**
* Delete the job if its models no longer exist.
*/
public bool $deleteWhenMissingModels = true;
public Project $project; public Project $project;
/** /**

View File

@@ -20,6 +20,11 @@ class RecalculateSpentTimeForTask implements ShouldDispatchAfterCommit, ShouldQu
use Queueable; use Queueable;
use SerializesModels; use SerializesModels;
/**
* Delete the job if its models no longer exist.
*/
public bool $deleteWhenMissingModels = true;
public Task $task; public Task $task;
/** /**

View File

@@ -34,6 +34,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property bool $employees_can_see_billable_rates * @property bool $employees_can_see_billable_rates
* @property bool $employees_can_manage_tasks * @property bool $employees_can_manage_tasks
* @property bool $prevent_overlapping_time_entries * @property bool $prevent_overlapping_time_entries
* @property bool $breaks_enabled
* @property User $owner * @property User $owner
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
@@ -70,6 +71,7 @@ class Organization extends Model implements AuditableContract
'employees_can_see_billable_rates' => 'boolean', 'employees_can_see_billable_rates' => 'boolean',
'employees_can_manage_tasks' => 'boolean', 'employees_can_manage_tasks' => 'boolean',
'prevent_overlapping_time_entries' => 'boolean', 'prevent_overlapping_time_entries' => 'boolean',
'breaks_enabled' => 'boolean',
'number_format' => NumberFormat::class, 'number_format' => NumberFormat::class,
'currency_format' => CurrencyFormat::class, 'currency_format' => CurrencyFormat::class,
'date_format' => DateFormat::class, 'date_format' => DateFormat::class,

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Enums\TimeEntryType;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use App\Service\BillableRateService; use App\Service\BillableRateService;
@@ -28,6 +29,7 @@ use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
* @property Carbon|null $end * @property Carbon|null $end
* @property int|null $billable_rate Billable rate per hour in cents * @property int|null $billable_rate Billable rate per hour in cents
* @property bool $billable * @property bool $billable
* @property TimeEntryType $type
* @property array<string> $tags * @property array<string> $tags
* @property string $user_id * @property string $user_id
* @property string $member_id * @property string $member_id
@@ -71,12 +73,20 @@ class TimeEntry extends Model implements AuditableContract
'start' => 'datetime', 'start' => 'datetime',
'end' => 'datetime', 'end' => 'datetime',
'billable' => 'bool', 'billable' => 'bool',
'type' => TimeEntryType::class,
'tags' => 'array', 'tags' => 'array',
'billable_rate' => 'int', 'billable_rate' => 'int',
'is_imported' => 'bool', 'is_imported' => 'bool',
'still_active_email_sent_at' => 'datetime', 'still_active_email_sent_at' => 'datetime',
]; ];
/**
* @var array<string, string>
*/
protected $attributes = [
'type' => 'work',
];
public const array SELECT_COLUMNS = [ public const array SELECT_COLUMNS = [
'id', 'id',
'description', 'description',
@@ -84,6 +94,7 @@ class TimeEntry extends Model implements AuditableContract
'end', 'end',
'billable_rate', 'billable_rate',
'billable', 'billable',
'type',
'user_id', 'user_id',
'organization_id', 'organization_id',
'project_id', 'project_id',
@@ -117,6 +128,21 @@ class TimeEntry extends Model implements AuditableContract
'billable_rate', 'billable_rate',
]; ];
protected static function booted(): void
{
// Break entries can never be billable, have tags or belong to a project/task.
static::saving(function (TimeEntry $timeEntry): void {
if ($timeEntry->type === TimeEntryType::Break) {
$timeEntry->billable = false;
$timeEntry->billable_rate = null;
$timeEntry->project_id = null;
$timeEntry->task_id = null;
$timeEntry->client_id = null;
$timeEntry->tags = [];
}
});
}
public function getBillableRateComputed(): ?int public function getBillableRateComputed(): ?int
{ {
return app(BillableRateService::class)->getBillableRateForTimeEntry($this); return app(BillableRateService::class)->getBillableRateForTimeEntry($this);
@@ -173,6 +199,16 @@ class TimeEntry extends Model implements AuditableContract
$builder->whereJsonContains('tags', $tag->getKey()); $builder->whereJsonContains('tags', $tag->getKey());
} }
/**
* Only work entries — breaks do not count toward tracked/billable time.
*
* @param Builder<TimeEntry> $builder
*/
public function scopeWorkTime(Builder $builder): void
{
$builder->where('type', '=', TimeEntryType::Work);
}
/** /**
* @return BelongsTo<User, $this> * @return BelongsTo<User, $this>
*/ */

View File

@@ -20,6 +20,7 @@ use App\Service\BillingContract;
use App\Service\IpLookup\IpLookupServiceContract; use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\IpLookup\NoIpLookupService; use App\Service\IpLookup\NoIpLookupService;
use App\Service\PermissionStore; use App\Service\PermissionStore;
use DateTimeInterface;
use Dedoc\Scramble\Scramble; use Dedoc\Scramble\Scramble;
use Dedoc\Scramble\Support\Generator\OpenApi; use Dedoc\Scramble\Support\Generator\OpenApi;
use Dedoc\Scramble\Support\Generator\SecurityScheme; use Dedoc\Scramble\Support\Generator\SecurityScheme;
@@ -29,8 +30,13 @@ use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Foundation\Application; use Illuminate\Foundation\Application;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\StreamedResponse;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@@ -98,6 +104,27 @@ class AppServiceProvider extends ServiceProvider
$this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class); $this->app->bind(IpLookupServiceContract::class, NoIpLookupService::class);
$this->app->bind(BillingContract::class); $this->app->bind(BillingContract::class);
// Storage
// The local driver ignores the ResponseContentDisposition option of temporaryUrl,
// so mirror it through the signed query parameters of the storage route.
$privateDisk = config('filesystems.private');
if (config('filesystems.disks.'.$privateDisk.'.driver') === 'local') {
$disk = Storage::disk($privateDisk);
$disk->serveUsing(function (Request $request, string $path, array $headers) use ($disk): StreamedResponse {
return $disk->response($path, null, $headers, $request->query('disposition', 'inline'));
});
$disk->buildTemporaryUrlsUsing(function (string $path, DateTimeInterface $expiration, array $options) use ($privateDisk): string {
$parameters = array_filter([
'path' => $path,
'disposition' => isset($options['ResponseContentDisposition'])
? Str::before($options['ResponseContentDisposition'], ';')
: null,
]);
return url(URL::temporarySignedRoute('storage.'.$privateDisk, $expiration, $parameters, absolute: false));
});
}
// Routing // Routing
Route::model('member', Member::class); Route::model('member', Member::class);
Route::model('invitation', OrganizationInvitation::class); Route::model('invitation', OrganizationInvitation::class);

View File

@@ -33,8 +33,8 @@ class RouteServiceProvider extends ServiceProvider
} }
return $request->user() return $request->user()
? Limit::perMinute(200)->by($request->user()->id) ? Limit::perMinute(config('app.api_rate_limit_authenticated_per_minute'))->by($request->user()->id)
: Limit::perMinute(60)->by($request->ip()); : Limit::perMinute(config('app.api_rate_limit_guest_per_minute'))->by($request->ip());
}); });
$this->routes(function (): void { $this->routes(function (): void {

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\TimeEntryType;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Project; use App\Models\Project;
@@ -154,6 +155,7 @@ class DashboardService
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey()) ->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey()) ->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->orderBy('date'); ->orderBy('date');
@@ -195,6 +197,7 @@ class DashboardService
->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->select(DB::raw('DATE('.$dateWithTimeZone.') as date, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey()) ->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey()) ->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy(DB::raw('DATE('.$dateWithTimeZone.')')) ->groupBy(DB::raw('DATE('.$dateWithTimeZone.')'))
->orderBy('date'); ->orderBy('date');
@@ -222,7 +225,8 @@ class DashboardService
$query = TimeEntry::query() $query = TimeEntry::query()
->select(DB::raw('round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->select(DB::raw('round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey()) ->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey()); ->where('organization_id', '=', $organization->getKey())
->workTime();
$query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone); $query = $this->constrainDateByPossibleDates($query, $possibleDays, $timezone);
/** @var Collection<int, object{aggregate: int}> $resultDb */ /** @var Collection<int, object{aggregate: int}> $resultDb */
@@ -290,6 +294,7 @@ class DashboardService
->select(DB::raw('project_id, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate')) ->select(DB::raw('project_id, round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate'))
->where('user_id', '=', $user->getKey()) ->where('user_id', '=', $user->getKey())
->where('organization_id', '=', $organization->getKey()) ->where('organization_id', '=', $organization->getKey())
->workTime()
->groupBy('project_id'); ->groupBy('project_id');
$query = $this->constrainDateByCurrentWeek($query, $timezone, $user->week_start); $query = $this->constrainDateByCurrentWeek($query, $timezone, $user->week_start);
@@ -433,7 +438,8 @@ class DashboardService
JOIN time_entries ON time_entries.start < time_ranges."end" JOIN time_entries ON time_entries.start < time_ranges."end"
AND coalesce(time_entries."end", :now::timestamp) > time_ranges.start AND coalesce(time_entries."end", :now::timestamp) > time_ranges.start
WHERE time_entries.user_id = :user_id and WHERE time_entries.user_id = :user_id and
time_entries.organization_id = :organization_id time_entries.organization_id = :organization_id and
time_entries.type = :work_type
GROUP BY time_ranges.start GROUP BY time_ranges.start
ORDER BY time_ranges.start ORDER BY time_ranges.start
', [ ', [
@@ -442,6 +448,7 @@ class DashboardService
'user_id' => $user->getKey(), 'user_id' => $user->getKey(),
'organization_id' => $organization->getKey(), 'organization_id' => $organization->getKey(),
'now' => Carbon::now()->toDateTimeString(), 'now' => Carbon::now()->toDateTimeString(),
'work_type' => TimeEntryType::Work->value,
]))->pluck('aggregate', 'start'); ]))->pluck('aggregate', 'start');
$response = []; $response = [];

View File

@@ -4,9 +4,11 @@ declare(strict_types=1);
namespace App\Service\Dto; namespace App\Service\Dto;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval; use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType; use App\Enums\TimeEntryRoundingType;
use App\Enums\TimeEntryType;
use App\Enums\Weekday; use App\Enums\Weekday;
use App\Service\TimeEntryFilter; use App\Service\TimeEntryFilter;
use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\Castable;
@@ -56,6 +58,8 @@ class ReportPropertiesDto implements Castable
*/ */
public ?Collection $tagIds = null; public ?Collection $tagIds = null;
public ?TagMatchType $tagMatchType = null;
/** /**
* @var Collection<int, string>|null * @var Collection<int, string>|null
*/ */
@@ -65,6 +69,8 @@ class ReportPropertiesDto implements Castable
public ?int $roundingMinutes = null; public ?int $roundingMinutes = null;
public ?TimeEntryType $timeEntryType = null;
/** /**
* Get the caster class to use when casting from / to this cast target. * Get the caster class to use when casting from / to this cast target.
* *
@@ -115,6 +121,7 @@ class ReportPropertiesDto implements Castable
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null; $dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null; $dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null; $dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null; $dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group); $dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup); $dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
@@ -125,6 +132,12 @@ class ReportPropertiesDto implements Castable
$dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null; $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 // 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; $dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null;
// Note: timeEntryType was added later, reports persisted before that are missing the value and default to "work"
if (property_exists($data, 'timeEntryType')) {
$dto->timeEntryType = $data->timeEntryType !== null ? TimeEntryType::from($data->timeEntryType) : null;
} else {
$dto->timeEntryType = TimeEntryType::Work;
}
return $dto; return $dto;
} }
@@ -144,6 +157,7 @@ class ReportPropertiesDto implements Castable
'clientIds' => $value->clientIds?->toArray(), 'clientIds' => $value->clientIds?->toArray(),
'projectIds' => $value->projectIds?->toArray(), 'projectIds' => $value->projectIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(), 'tagIds' => $value->tagIds?->toArray(),
'tagMatchType' => $value->tagMatchType?->value,
'taskIds' => $value->taskIds?->toArray(), 'taskIds' => $value->taskIds?->toArray(),
'group' => $value->group->value, 'group' => $value->group->value,
'subGroup' => $value->subGroup->value, 'subGroup' => $value->subGroup->value,
@@ -152,6 +166,7 @@ class ReportPropertiesDto implements Castable
'timezone' => $value->timezone, 'timezone' => $value->timezone,
'roundingType' => $value->roundingType?->value, 'roundingType' => $value->roundingType?->value,
'roundingMinutes' => $value->roundingMinutes, 'roundingMinutes' => $value->roundingMinutes,
'timeEntryType' => $value->timeEntryType?->value,
]; ];
$jsonString = json_encode($data); $jsonString = json_encode($data);
@@ -216,6 +231,11 @@ class ReportPropertiesDto implements Castable
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null; $this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
} }
public function setTagMatchType(?TagMatchType $tagMatchType): void
{
$this->tagMatchType = $tagMatchType;
}
/** /**
* @param array<mixed>|null $taskIds * @param array<mixed>|null $taskIds
*/ */

View File

@@ -107,6 +107,7 @@ class ExportService
'end', 'end',
'billable_rate', 'billable_rate',
'billable', 'billable',
'type',
'member_id', 'member_id',
'user_id', 'user_id',
'organization_id', 'organization_id',
@@ -131,6 +132,7 @@ class ExportService
$timeEntry->end?->toIso8601ZuluString() ?? '', $timeEntry->end?->toIso8601ZuluString() ?? '',
$timeEntry->billable_rate ?? '', $timeEntry->billable_rate ?? '',
$timeEntry->billable ? 'true' : 'false', $timeEntry->billable ? 'true' : 'false',
$timeEntry->type->value,
$timeEntry->member_id, $timeEntry->member_id,
$timeEntry->user_id, $timeEntry->user_id,
$timeEntry->organization_id, $timeEntry->organization_id,

View File

@@ -29,7 +29,8 @@ class ClockifyProjectsImporter extends DefaultImporter
$records = $reader->getRecords(); $records = $reader->getRecords();
foreach ($records as $record) { foreach ($records as $record) {
$clientId = null; $clientId = null;
if ($record['Client'] !== '') { // Newer Clockify exports no longer contain a "Client" column.
if (($record['Client'] ?? '') !== '') {
$clientId = $this->clientImportHelper->getKey([ $clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'], 'name' => $record['Client'],
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
@@ -45,7 +46,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'color' => $this->colorService->getRandomColor(), 'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes', 'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null, 'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null, 'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
]); ]);
} }
@@ -80,7 +81,6 @@ class ClockifyProjectsImporter extends DefaultImporter
{ {
$requiredFields = [ $requiredFields = [
'Project', 'Project',
'Client',
'Status', 'Status',
'Visibility', 'Visibility',
'Billability', 'Billability',

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\Import\Importers; namespace App\Service\Import\Importers;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Jobs\RecalculateSpentTimeForProject; use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask; use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry; use App\Models\TimeEntry;
@@ -71,8 +72,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'role' => Role::Placeholder->value, 'role' => Role::Placeholder->value,
]); ]);
$member = $this->memberImportHelper->getModelById($memberId); $member = $this->memberImportHelper->getModelById($memberId);
// Clockify allows a project/task/client/tags/billable on breaks, but those are
// meaningless for non-work time. Detect breaks up front and skip creating any of
// that so a break can't spawn an orphan project/tag or inflate the import counts.
$isBreak = isset($record['Type']) && strtolower($record['Type']) === 'break';
$clientId = null; $clientId = null;
if ($record['Client'] !== '') { if (! $isBreak && ($record['Client'] ?? '') !== '') {
$clientId = $this->clientImportHelper->getKey([ $clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'], 'name' => $record['Client'],
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
@@ -81,7 +86,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$projectId = null; $projectId = null;
$project = null; $project = null;
$projectMember = null; $projectMember = null;
if ($record['Project'] !== '') { if (! $isBreak && $record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([ $projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'], 'name' => $record['Project'],
'client_id' => $clientId, 'client_id' => $clientId,
@@ -97,7 +102,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]); ]);
} }
$taskId = null; $taskId = null;
if ($taskKey !== null && $record[$taskKey] !== '') { if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
$taskId = $this->taskImportHelper->getKey([ $taskId = $this->taskImportHelper->getKey([
'name' => $record[$taskKey], 'name' => $record[$taskKey],
'project_id' => $projectId, 'project_id' => $projectId,
@@ -123,7 +128,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
} }
$timeEntry->billable = $record['Billable'] === 'Yes'; $timeEntry->billable = $record['Billable'] === 'Yes';
} }
$timeEntry->tags = $this->getTags($record['Tags']); if ($isBreak) {
// Breaks can not be billable or belong to a project/task (already skipped above)
$timeEntry->type = TimeEntryType::Break;
$timeEntry->billable = false;
}
$timeEntry->tags = $isBreak ? [] : $this->getTags($record['Tags']);
$timeEntry->is_imported = true; $timeEntry->is_imported = true;
// Start // Start
@@ -215,7 +225,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
{ {
$requiredFields = [ $requiredFields = [
'Project', 'Project',
'Client',
'Description', 'Description',
'User', 'User',
'Group', 'Group',

View File

@@ -171,7 +171,7 @@ abstract class DefaultImporter implements ImporterContract
}, validate: [ }, validate: [
'email' => [ 'email' => [
'required', 'required',
'email', 'email:rfc,strict',
'max:255', 'max:255',
], ],
]); ]);

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\Import\Importers; namespace App\Service\Import\Importers;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Jobs\RecalculateSpentTimeForProject; use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask; use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry; use App\Models\TimeEntry;
@@ -255,6 +256,14 @@ class SolidtimeImporter extends DefaultImporter
throw new ImportException('Invalid billable value'); throw new ImportException('Invalid billable value');
} }
$timeEntry->billable = $timeEntryRow['billable'] === 'true'; $timeEntry->billable = $timeEntryRow['billable'] === 'true';
// The type column does not exist in old exports
if (($timeEntryRow['type'] ?? '') !== '') {
$type = TimeEntryType::tryFrom($timeEntryRow['type']);
if ($type === null) {
throw new ImportException('Invalid type value');
}
$timeEntry->type = $type;
}
$timeEntry->tags = $this->getTags($timeEntryRow['tags']); $timeEntry->tags = $this->getTags($timeEntryRow['tags']);
$timeEntry->is_imported = true; $timeEntry->is_imported = true;

View File

@@ -123,6 +123,7 @@ class TogglDataImporter extends DefaultImporter
} }
foreach ($projects as $project) { foreach ($projects as $project) {
$projectExternalId = $this->guardExternalIdentifier($project->id);
$clientId = null; $clientId = null;
if ($project->client_id !== null) { if ($project->client_id !== null) {
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id); $clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);
@@ -146,16 +147,16 @@ class TogglDataImporter extends DefaultImporter
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null, 'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
], (string) $project->id); ], (string) $project->id);
if (! file_exists($temporaryDirectory->path('projects_users/'.$project->id.'.json'))) { if (! file_exists($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'))) {
throw new ImportException('File "projects_users/'.$project->id.'.json" missing in ZIP'); throw new ImportException('File "projects_users/'.$projectExternalId.'.json" missing in ZIP');
} }
$projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$project->id.'.json')); $projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'));
if ($projectMembersFileContent === false) { if ($projectMembersFileContent === false) {
throw new ImportException('File "projects_users/'.$project->id.'.json" can not be opened'); throw new ImportException('File "projects_users/'.$projectExternalId.'.json" can not be opened');
} }
$projectMembers = json_decode($projectMembersFileContent); $projectMembers = json_decode($projectMembersFileContent);
if ($projectMembers === null) { if ($projectMembers === null) {
throw new ImportException('File "projects_users/'.$project->id.'.json" is empty'); throw new ImportException('File "projects_users/'.$projectExternalId.'.json" is empty');
} }
foreach ($projectMembers as $projectMember) { foreach ($projectMembers as $projectMember) {
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id); $userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
@@ -170,6 +171,7 @@ class TogglDataImporter extends DefaultImporter
} }
$projectIds = $this->projectImportHelper->getExternalIds(); $projectIds = $this->projectImportHelper->getExternalIds();
foreach ($projectIds as $projectIdExternal) { foreach ($projectIds as $projectIdExternal) {
$projectIdExternal = $this->guardExternalIdentifier($projectIdExternal);
if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) { if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) {
continue; continue;
} }
@@ -209,6 +211,30 @@ class TogglDataImporter extends DefaultImporter
} }
} }
/**
* Ensure an externally-sourced identifier can be safely used inside a
* filesystem path. The identifiers originate from the untrusted uploaded
* ZIP, and Spatie's TemporaryDirectory::path() auto-creates any missing
* parent directory of the resolved path, so an unfiltered "../" sequence
* would escape the import sandbox and create/probe arbitrary paths on the
* host (CWE-22). Toggl identifiers are numeric, so restricting them to a
* conservative allow-list rejects traversal without affecting real data.
*
* @throws ImportException
*/
private function guardExternalIdentifier(mixed $id): string
{
if (! is_string($id) && ! is_int($id)) {
throw new ImportException('Invalid identifier in import data');
}
$id = (string) $id;
if (preg_match('/^[A-Za-z0-9_-]+$/', $id) !== 1) {
throw new ImportException('Invalid identifier in import data');
}
return $id;
}
#[Override] #[Override]
public function getName(): string public function getName(): string
{ {

View File

@@ -23,6 +23,10 @@ class InvitationService
*/ */
public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation public function inviteUser(Organization $organization, string $email, Role $role, User $inviter): OrganizationInvitation
{ {
// Normalize the email so it matches how user emails are stored (see UserService::createUser),
// otherwise a mixed-case invite silently fails to link on registration.
$email = strtolower($email);
if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) { if (app(MemberService::class)->isEmailAlreadyMember($organization, $email)) {
throw new UserIsAlreadyMemberOfOrganizationApiException; throw new UserIsAlreadyMemberOfOrganizationApiException;
} }
@@ -55,7 +59,7 @@ class InvitationService
$organizations = new Collection; $organizations = new Collection;
$invitations = OrganizationInvitation::query() $invitations = OrganizationInvitation::query()
->where('email', $user->email) ->whereRaw('lower(email) = ?', [strtolower($user->email)])
->whereNotNull('accepted_at') ->whereNotNull('accepted_at')
->get(); ->get();

View File

@@ -8,12 +8,14 @@ use App\Enums\CurrencyFormat;
use App\Enums\DateFormat; use App\Enums\DateFormat;
use App\Enums\IntervalFormat; use App\Enums\IntervalFormat;
use App\Enums\NumberFormat; use App\Enums\NumberFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeFormat; use App\Enums\TimeFormat;
use App\Models\Organization; use App\Models\Organization;
use Brick\Math\BigDecimal; use Brick\Math\BigDecimal;
use Brick\Money\Money; use Brick\Money\Money;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
use Carbon\CarbonInterval; use Carbon\CarbonInterval;
use Illuminate\Support\Carbon;
class LocalizationService class LocalizationService
{ {
@@ -152,6 +154,38 @@ class LocalizationService
return $date->format($this->dateFormat->toCarbonFormat()); return $date->format($this->dateFormat->toCarbonFormat());
} }
/**
* Time group types have no server-side descriptor; their keys are ISO dates and are
* formatted here instead. A Week key is the first day of that week, so it renders as the
* range it covers. A Year key is already a bare year, so it is returned unchanged - it must
* not be parsed, Carbon reads a four digit string as a time of day.
*/
public function formatTimeGroupKey(?string $key, TimeEntryAggregationType $groupType): ?string
{
if ($key === null) {
return null;
}
if ($groupType === TimeEntryAggregationType::Day) {
return $this->formatDate(Carbon::parse($key));
}
if ($groupType === TimeEntryAggregationType::Week) {
$weekStart = Carbon::parse($key);
return $this->formatDate($weekStart).' - '.$this->formatDate($weekStart->copy()->addDays(6));
}
if ($groupType === TimeEntryAggregationType::Month) {
// Note: the leading "!" resets all fields the format does not name. Without it the
// day of the month is taken from today, and a day that the parsed month does not
// have overflows the date into the next month.
return Carbon::createFromFormat('!Y-m', $key)->format('F Y');
}
return $key;
}
public function setDateFormat(DateFormat $dateFormat): void public function setDateFormat(DateFormat $dateFormat): void
{ {
$this->dateFormat = $dateFormat; $this->dateFormat = $dateFormat;

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Service\ReportExport; namespace App\Service\ReportExport;
use App\Enums\TimeEntryType;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\IntervalService; use App\Service\IntervalService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -25,6 +26,7 @@ class TimeEntriesDetailedCsvExport extends CsvExport
'Duration', 'Duration',
'Duration (decimal)', 'Duration (decimal)',
'Billable', 'Billable',
'Break',
'Tags', 'Tags',
]; ];
@@ -58,6 +60,7 @@ class TimeEntriesDetailedCsvExport extends CsvExport
'Duration' => $duration !== null ? $interval->format($model->getDuration()) : null, 'Duration' => $duration !== null ? $interval->format($model->getDuration()) : null,
'Duration (decimal)' => $duration?->totalHours, 'Duration (decimal)' => $duration?->totalHours,
'Billable' => $model->billable ? 'Yes' : 'No', 'Billable' => $model->billable ? 'Yes' : 'No',
'Break' => $model->type === TimeEntryType::Break ? 'Yes' : 'No',
'Tags' => $model->tagsRelation->pluck('name')->implode(', '), 'Tags' => $model->tagsRelation->pluck('name')->implode(', '),
]; ];
} }

View File

@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Service\ReportExport; namespace App\Service\ReportExport;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TimeEntryType;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use App\Service\LocalizationService; use App\Service\LocalizationService;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -106,6 +107,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
'Duration', 'Duration',
'Duration (decimal)', 'Duration (decimal)',
'Billable', 'Billable',
'Break',
'Tags', 'Tags',
]; ];
} }
@@ -130,6 +132,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
$duration !== null ? $this->localizationService->formatInterval($duration) : null, $duration !== null ? $this->localizationService->formatInterval($duration) : null,
$duration?->totalHours, $duration?->totalHours,
$model->billable ? 'Yes' : 'No', $model->billable ? 'Yes' : 'No',
$model->type === TimeEntryType::Break ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '), $model->tagsRelation->pluck('name')->implode(', '),
]; ];
} elseif ($this->exportFormat === ExportFormat::ODS) { } elseif ($this->exportFormat === ExportFormat::ODS) {
@@ -144,6 +147,7 @@ class TimeEntriesDetailedExport implements FromQuery, ShouldAutoSize, WithColumn
$duration !== null ? $this->localizationService->formatInterval($duration) : null, $duration !== null ? $this->localizationService->formatInterval($duration) : null,
$duration?->totalHours, $duration?->totalHours,
$model->billable ? 'Yes' : 'No', $model->billable ? 'Yes' : 'No',
$model->type === TimeEntryType::Break ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '), $model->tagsRelation->pluck('name')->implode(', '),
]; ];
} else { } else {

View File

@@ -6,6 +6,7 @@ namespace App\Service\ReportExport;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationType;
use App\Service\LocalizationService;
use Illuminate\View\View; use Illuminate\View\View;
use Maatwebsite\Excel\Concerns\Exportable; use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromView; use Maatwebsite\Excel\Concerns\FromView;
@@ -48,6 +49,8 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
private bool $showBillableRate; private bool $showBillableRate;
private LocalizationService $localization;
/** /**
* @param array{ * @param array{
* grouped_type: string|null, * grouped_type: string|null,
@@ -68,7 +71,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
* cost: int|null * cost: int|null
* } $data * } $data
*/ */
public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate) public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate, LocalizationService $localization)
{ {
$this->data = $data; $this->data = $data;
$this->exportFormat = $exportFormat; $this->exportFormat = $exportFormat;
@@ -76,6 +79,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
$this->group = $group; $this->group = $group;
$this->subGroup = $subGroup; $this->subGroup = $subGroup;
$this->showBillableRate = $showBillableRate; $this->showBillableRate = $showBillableRate;
$this->localization = $localization;
} }
public function view(): View public function view(): View
@@ -87,6 +91,7 @@ class TimeEntriesReportExport implements FromView, ShouldAutoSize, WithCustomCsv
'subGroup' => $this->subGroup, 'subGroup' => $this->subGroup,
'exportFormat' => $this->exportFormat, 'exportFormat' => $this->exportFormat,
'showBillableRate' => $this->showBillableRate, 'showBillableRate' => $this->showBillableRate,
'localization' => $this->localization,
]); ]);
} }

View File

@@ -353,6 +353,13 @@ class TimeEntryAggregationService
'color' => null, 'color' => null,
]; ];
} }
} elseif ($type === TimeEntryAggregationType::Type) {
foreach ($keys as $key) {
$descriptorMap[$key] = [
'description' => $key === 'break' ? 'Break' : 'Work time',
'color' => null,
];
}
} elseif ($type === TimeEntryAggregationType::Tag) { } elseif ($type === TimeEntryAggregationType::Tag) {
$tags = Tag::query() $tags = Tag::query()
->whereIn('id', $keys) ->whereIn('id', $keys)
@@ -504,6 +511,8 @@ class TimeEntryAggregationService
return 'client_id'; return 'client_id';
} elseif ($group === TimeEntryAggregationType::Billable) { } elseif ($group === TimeEntryAggregationType::Billable) {
return 'billable'; return 'billable';
} elseif ($group === TimeEntryAggregationType::Type) {
return 'type';
} elseif ($group === TimeEntryAggregationType::Description) { } elseif ($group === TimeEntryAggregationType::Description) {
return 'description'; return 'description';
} elseif ($group === TimeEntryAggregationType::Tag) { } elseif ($group === TimeEntryAggregationType::Tag) {

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Service; namespace App\Service;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryType;
use App\Models\Member; use App\Models\Member;
use App\Models\TimeEntry; use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -143,6 +145,32 @@ class TimeEntryFilter
return $this; return $this;
} }
public function addTypeFilter(?string $type): self
{
if ($type === null) {
return $this;
}
$typeEnum = TimeEntryType::tryFrom($type);
if ($typeEnum === null) {
Log::warning('Invalid type filter value', ['value' => $type]);
return $this;
}
$this->addType($typeEnum);
return $this;
}
public function addType(?TimeEntryType $type): self
{
if ($type === null) {
return $this;
}
$this->builder->where('type', '=', $type->value);
return $this;
}
/** /**
* @param array<string>|null $clientIds * @param array<string>|null $clientIds
*/ */
@@ -192,15 +220,21 @@ class TimeEntryFilter
/** /**
* @param array<string>|null $tagIds * @param array<string>|null $tagIds
*/ */
public function addTagIdsFilter(?array $tagIds): self public function addTagIdsFilter(?array $tagIds, ?TagMatchType $tagMatchType = TagMatchType::Contains): self
{ {
if ($tagIds === null) { if ($tagIds === null) {
return $this; return $this;
} }
$tagMatchType ??= TagMatchType::Contains;
$includeNone = in_array(self::NONE_VALUE, $tagIds, true); $includeNone = in_array(self::NONE_VALUE, $tagIds, true);
$tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE)); $tagIds = array_values(array_filter($tagIds, fn (string $id): bool => $id !== self::NONE_VALUE));
// An empty selection (no tag IDs and not filtering for "none") is no constraint, so apply nothing.
// This also prevents the not-contains branch from collapsing into "only entries with null tags".
if (count($tagIds) === 0 && ! $includeNone) {
return $this;
}
$this->builder->where(function (Builder $builder) use ($tagIds, $includeNone): void { $tagCondition = function (Builder $builder) use ($tagIds, $includeNone): void {
foreach ($tagIds as $tagId) { foreach ($tagIds as $tagId) {
$builder->orWhereJsonContains('tags', $tagId); $builder->orWhereJsonContains('tags', $tagId);
} }
@@ -209,7 +243,18 @@ class TimeEntryFilter
$query->whereJsonLength('tags', 0)->orWhereNull('tags'); $query->whereJsonLength('tags', 0)->orWhereNull('tags');
}); });
} }
}); };
if ($tagMatchType === TagMatchType::NotContains) {
$this->builder->where(function (Builder $builder) use ($tagCondition, $includeNone): void {
$builder->whereNot($tagCondition);
if (! $includeNone) {
$builder->orWhereNull('tags');
}
});
} else {
$this->builder->where($tagCondition);
}
return $this; return $this;
} }

View File

@@ -75,6 +75,27 @@ return [
'url' => env('APP_URL', 'http://localhost'), 'url' => env('APP_URL', 'http://localhost'),
/*
|--------------------------------------------------------------------------
| Trusted Hosts
|--------------------------------------------------------------------------
|
| Additional hostnames (besides the APP_URL host and its subdomains) that
| the application is allowed to respond on. This is needed for multi-host
| setups, e.g. reaching the instance over both a public domain and a
| Tailscale name. A request arriving on any host that is neither APP_URL
| (nor a subdomain of it) nor listed here is rejected, which prevents
| Host-header poisoning of password reset and other out-of-band links.
|
| See App\Http\Middleware\TrustHosts.
|
*/
'trusted_hosts' => array_values(array_filter(array_map(
'trim',
explode(',', (string) env('TRUSTED_HOSTS', ''))
))),
'asset_url' => env('ASSET_URL'), 'asset_url' => env('ASSET_URL'),
'force_https' => (bool) env('APP_FORCE_HTTPS', false), 'force_https' => (bool) env('APP_FORCE_HTTPS', false),
@@ -137,6 +158,21 @@ return [
'pagination_per_page_default' => (int) env('PAGINATION_PER_PAGE_DEFAULT', 15), 'pagination_per_page_default' => (int) env('PAGINATION_PER_PAGE_DEFAULT', 15),
/*
|--------------------------------------------------------------------------
| API Rate Limiting
|--------------------------------------------------------------------------
|
| The number of API requests allowed per minute, counted per user for
| authenticated requests and per IP address for guest requests. These
| limits are only enforced when the application runs in production.
|
*/
'api_rate_limit_authenticated_per_minute' => (int) (env('API_RATE_LIMIT_AUTH_PER_MINUTE') ?: 200),
'api_rate_limit_guest_per_minute' => (int) (env('API_RATE_LIMIT_GUEST_PER_MINUTE') ?: 60),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| Encryption Key | Encryption Key

View File

@@ -33,6 +33,7 @@ class OrganizationFactory extends Factory
'user_id' => User::factory(), 'user_id' => User::factory(),
'personal_team' => true, 'personal_team' => true,
'employees_can_see_billable_rates' => false, 'employees_can_see_billable_rates' => false,
'breaks_enabled' => false,
'number_format' => $this->faker->randomElement(NumberFormat::values()), 'number_format' => $this->faker->randomElement(NumberFormat::values()),
'currency_format' => $this->faker->randomElement(CurrencyFormat::values()), 'currency_format' => $this->faker->randomElement(CurrencyFormat::values()),
'date_format' => $this->faker->randomElement(DateFormat::values()), 'date_format' => $this->faker->randomElement(DateFormat::values()),
@@ -55,6 +56,13 @@ class OrganizationFactory extends Factory
]); ]);
} }
public function withBreaksEnabled(): self
{
return $this->state(fn (array $attributes) => [
'breaks_enabled' => true,
]);
}
public function withOwner(?User $owner = null): self public function withOwner(?User $owner = null): self
{ {
return $this->state(fn (array $attributes) => [ return $this->state(fn (array $attributes) => [

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