Compare commits

..

8 Commits

Author SHA1 Message Date
Gregor Vostrak
bbaa0ae45b cleanup and deduplicate breaks frontend tests 2026-07-28 15:51:17 +02:00
Gregor Vostrak
40dc3842ce add description/project labels to break placement modal for existing
time entries
2026-07-28 15:21:58 +02:00
Gregor Vostrak
bbd7d286d9 insert breaks into work entries instead of carving them out; rollback on
failure system
2026-07-28 15:11:03 +02:00
Gregor Vostrak
f6311eddfd unify display values for work and break time totals 2026-07-27 15:48:05 +02:00
Gregor Vostrak
2639b62744 bump ui to 0.0.22 and api to 0.0.7 2026-07-24 18:09:21 +02:00
Gregor Vostrak
8a68673a48 promote breaks feature in sidebar banner 2026-07-24 16:44:45 +02:00
Gregor Vostrak
34f8eb0970 default time entry type filter to work for legacy reports 2026-07-24 15:36:51 +02:00
Gregor Vostrak
64ca1e9115 add break time entries and simplified time tracker ui 2026-07-21 18:54:05 +02:00
41 changed files with 101 additions and 1945 deletions

View File

@@ -1,7 +1,5 @@
.git .git
**/.git
.gitmodules .gitmodules
**/.gitmodules
.github .github
.DS_Store .DS_Store
.fleet .fleet
@@ -10,13 +8,6 @@
*.log *.log
npm-debug.log npm-debug.log
yarn-error.log yarn-error.log
k8s
docs
e2e
tests
docker-compose.yml
docker/local
.phpunit.cache .phpunit.cache
.phpunit.result.cache .phpunit.result.cache
@@ -25,18 +16,6 @@ test-results
playwright-report playwright-report
blob-report blob-report
playwright/.cache 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 node_modules
extensions/*/node_modules extensions/*/node_modules
@@ -51,4 +30,3 @@ _ide_helper.php
.phpstorm.meta.php .phpstorm.meta.php
storage/logs/* storage/logs/*
storage/*.key

26
.github/VOUCHED.td vendored
View File

@@ -1,26 +0,0 @@
# 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
bufferhead-code
candideu
kasparrosin
korridor
nikbucher tasks table sorting
onatcer
shrootbuck
smilebeda
thespyder
utlark

View File

@@ -8,7 +8,6 @@ on:
pull_request: pull_request:
paths: paths:
- '.github/workflows/build-onpremise.yml' - '.github/workflows/build-onpremise.yml'
- '.dockerignore'
- 'extensions/manifest.json' - 'extensions/manifest.json'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:

View File

@@ -8,7 +8,6 @@ on:
pull_request: pull_request:
paths: paths:
- '.github/workflows/build-private.yml' - '.github/workflows/build-private.yml'
- '.dockerignore'
- 'extensions/manifest.json' - 'extensions/manifest.json'
- 'docker/prod/**' - 'docker/prod/**'
workflow_dispatch: workflow_dispatch:

View File

@@ -8,7 +8,6 @@ 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:

View File

@@ -1,75 +0,0 @@
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

@@ -1,33 +0,0 @@
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

@@ -1,35 +0,0 @@
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 }}

View File

@@ -12,22 +12,6 @@ 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,8 +39,6 @@ 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

@@ -6,10 +6,7 @@ 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 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
@@ -33,29 +30,6 @@ class Handler extends ExceptionHandler
$this->reportable(function (Throwable $e): void { $this->reportable(function (Throwable $e): void {
// //
}); });
// 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

@@ -68,7 +68,7 @@ class TimeEntryController extends Controller
$query = TimeEntry::query() $query = TimeEntry::query()
->where('organization_id', $organization->getKey()) ->where('organization_id', $organization->getKey())
->where('member_id', $member->getKey()) ->where('user_id', $member->user_id)
->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());
}) })
@@ -108,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 `member_id`. * If you only need time entries for a specific user, you can filter by `user_id`.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter. * Users with the permission `time-entries:view:own` can only use this endpoint with their own user ID in the user_id filter.
* *
* @return TimeEntryCollection<TimeEntryResource> * @return TimeEntryCollection<TimeEntryResource>
* *
@@ -119,17 +119,16 @@ class TimeEntryController extends Controller
*/ */
public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource public function index(Organization $organization, TimeEntryIndexRequest $request): JsonResource
{ {
$member = $this->member($organization); /** @var Member|null $member */
/** @var Member|null $memberFilter */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) {
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, $memberFilter, $canAccessPremiumFeatures); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$totalCount = $timeEntriesQuery->count(); $totalCount = $timeEntriesQuery->count();
@@ -160,7 +159,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(),
'member_id' => $request->input('member_id'), 'user_id' => $request->input('user_id'),
'auth_user_id' => Auth::id(), 'auth_user_id' => Auth::id(),
'limit' => $limit, 'limit' => $limit,
]); ]);
@@ -224,10 +223,9 @@ class TimeEntryController extends Controller
*/ */
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{ {
$member = $this->member($organization); /** @var Member|null $member */
/** @var Member|null $memberFilter */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) {
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');
@@ -244,7 +242,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, $memberFilter, $canAccessPremiumFeatures); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member, $canAccessPremiumFeatures);
$timeEntriesQuery->with([ $timeEntriesQuery->with([
'task', 'task',
'client', 'client',
@@ -267,7 +265,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, $memberFilter); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries( $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
$timeEntriesAggregateQuery, $timeEntriesAggregateQuery,
null, null,
@@ -374,10 +372,9 @@ class TimeEntryController extends Controller
*/ */
public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array public function aggregate(Organization $organization, TimeEntryAggregateRequest $request, TimeEntryAggregationService $timeEntryAggregationService): array
{ {
$member = $this->member($organization); /** @var Member|null $member */
/** @var Member|null $memberFilter */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) {
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');
@@ -388,7 +385,7 @@ class TimeEntryController extends Controller
$group1Type = $request->getGroup(); $group1Type = $request->getGroup();
$group2Type = $request->getSubGroup(); $group2Type = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -424,10 +421,9 @@ class TimeEntryController extends Controller
*/ */
public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse public function aggregateExport(Organization $organization, TimeEntryAggregateExportRequest $request, TimeEntryAggregationService $timeEntryAggregationService): JsonResponse
{ {
$member = $this->member($organization); /** @var Member|null $member */
/** @var Member|null $memberFilter */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
$memberFilter = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($member !== null && $member->user_id === Auth::id()) {
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');
@@ -443,7 +439,7 @@ class TimeEntryController extends Controller
$group = $request->getGroup(); $group = $request->getGroup();
$subGroup = $request->getSubGroup(); $subGroup = $request->getSubGroup();
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $memberFilter); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
$roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null; $roundingType = $canAccessPremiumFeatures ? $request->getRoundingType() : null;
$roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null; $roundingMinutes = $canAccessPremiumFeatures ? $request->getRoundingMinutes() : null;
@@ -535,7 +531,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, $localizationService), new TimeEntriesReportExport($aggregatedData, $format, $currency, $group, $subGroup, $showBillableRate),
$path, $path,
config('filesystems.private'), config('filesystems.private'),
$format->getExportPackageType(), $format->getExportPackageType(),
@@ -587,7 +583,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->getKey() === $this->member($organization)->getKey()) { if ($member->user_id === Auth::id()) {
$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');
@@ -634,10 +630,9 @@ class TimeEntryController extends Controller
*/ */
public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource public function update(Organization $organization, TimeEntry $timeEntry, TimeEntryUpdateRequest $request): JsonResource
{ {
$member = $this->member($organization); /** @var Member|null $member */
/** @var Member|null $newMember */ $member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
$newMember = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null; if ($timeEntry->member->user_id === Auth::id() && ($member === null || $member->user_id === Auth::id())) {
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);
@@ -669,10 +664,6 @@ 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();
@@ -702,7 +693,6 @@ 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');
@@ -727,9 +717,6 @@ 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;
@@ -756,7 +743,7 @@ class TimeEntryController extends Controller
continue; continue;
} }
if (! $canAccessAll && $timeEntry->member_id !== $member->getKey()) { if (! $canAccessAll && $timeEntry->user_id !== Auth::id()) {
$error->push($id); $error->push($id);
continue; continue;
@@ -779,10 +766,6 @@ class TimeEntryController extends Controller
$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();
@@ -823,8 +806,7 @@ class TimeEntryController extends Controller
*/ */
public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse public function destroy(Organization $organization, TimeEntry $timeEntry): JsonResponse
{ {
$member = $this->member($organization); if ($timeEntry->member->user_id === Auth::id()) {
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);
@@ -881,7 +863,7 @@ class TimeEntryController extends Controller
continue; continue;
} }
if (! $canDeleteAll && $timeEntry->member_id !== $this->member($organization)->getKey()) { if (! $canDeleteAll && $timeEntry->user_id !== Auth::id()) {
$error->push($id); $error->push($id);
continue; continue;

View File

@@ -15,7 +15,6 @@ 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;
@@ -48,7 +47,6 @@ 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

@@ -1,56 +0,0 @@
<?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

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

View File

@@ -23,10 +23,6 @@ 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;
} }
@@ -59,7 +55,7 @@ class InvitationService
$organizations = new Collection; $organizations = new Collection;
$invitations = OrganizationInvitation::query() $invitations = OrganizationInvitation::query()
->whereRaw('lower(email) = ?', [strtolower($user->email)]) ->where('email', $user->email)
->whereNotNull('accepted_at') ->whereNotNull('accepted_at')
->get(); ->get();

View File

@@ -8,14 +8,12 @@ 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
{ {
@@ -154,38 +152,6 @@ 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

@@ -6,7 +6,6 @@ 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;
@@ -49,8 +48,6 @@ 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,
@@ -71,7 +68,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, LocalizationService $localization) public function __construct(array $data, ExportFormat $exportFormat, string $currency, TimeEntryAggregationType $group, TimeEntryAggregationType $subGroup, bool $showBillableRate)
{ {
$this->data = $data; $this->data = $data;
$this->exportFormat = $exportFormat; $this->exportFormat = $exportFormat;
@@ -79,7 +76,6 @@ 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
@@ -91,7 +87,6 @@ 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

@@ -75,27 +75,6 @@ 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),
@@ -158,21 +137,6 @@ 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

@@ -1,230 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use function Laravel\Prompts\info as consoleInfo;
return new class extends Migration
{
/**
* PostgreSQL cannot build or drop an index concurrently inside a transaction.
* Keeping this migration non-transactional prevents long write locks in production.
*
* @var bool
*/
public $withinTransaction = false;
/**
* Run the migrations.
*/
public function up(): void
{
// Tenant-scoped client pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'clients_organization_created_id_index', 'CREATE INDEX CONCURRENTLY clients_organization_created_id_index ON clients (organization_id, created_at DESC, id)');
// Tenant-scoped project pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'projects_organization_created_id_index', 'CREATE INDEX CONCURRENTLY projects_organization_created_id_index ON projects (organization_id, created_at DESC, id)');
// Speeds client relationship loads and the FK check when a client is deleted or its ID changes.
$this->runIndexOperation('create', 'projects_client_id_index', 'CREATE INDEX CONCURRENTLY projects_client_id_index ON projects (client_id)');
// Tenant-scoped task pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'tasks_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tasks_organization_created_id_index ON tasks (organization_id, created_at DESC, id)');
// Speeds project task lists and the FK check when a project is deleted or its ID changes.
$this->runIndexOperation('create', 'tasks_project_id_index', 'CREATE INDEX CONCURRENTLY tasks_project_id_index ON tasks (project_id)');
// Tenant-scoped tag pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'tags_organization_created_id_index', 'CREATE INDEX CONCURRENTLY tags_organization_created_id_index ON tags (organization_id, created_at DESC, id)');
// Tenant-scoped report pagination ordered by newest first; also covers the organization FK.
$this->runIndexOperation('create', 'reports_organization_created_id_index', 'CREATE INDEX CONCURRENTLY reports_organization_created_id_index ON reports (organization_id, created_at DESC, id)');
// Tenant-scoped member pagination; the existing (organization_id, user_id) unique index remains for membership lookup.
$this->runIndexOperation('create', 'members_organization_created_id_index', 'CREATE INDEX CONCURRENTLY members_organization_created_id_index ON members (organization_id, created_at DESC, id)');
// Supports reverse user-to-membership lookups and the FK check when a user is deleted or its ID changes.
$this->runIndexOperation('create', 'members_user_id_index', 'CREATE INDEX CONCURRENTLY members_user_id_index ON members (user_id)');
// The existing (project_id, user_id) unique index covers project_id, but member_id and legacy user_id need reverse indexes.
$this->runIndexOperation('create', 'project_members_member_id_index', 'CREATE INDEX CONCURRENTLY project_members_member_id_index ON project_members (member_id)');
$this->runIndexOperation('create', 'project_members_user_id_index', 'CREATE INDEX CONCURRENTLY project_members_user_id_index ON project_members (user_id)');
// Supports organization deletion/current-team cleanup and the FK check on users.current_team_id.
$this->runIndexOperation('create', 'users_current_team_id_index', 'CREATE INDEX CONCURRENTLY users_current_team_id_index ON users (current_team_id)');
// Filament loads the newest audits first; this avoids scanning and sorting the large append-only audit table.
$this->runIndexOperation('create', 'audits_created_at_index', 'CREATE INDEX CONCURRENTLY audits_created_at_index ON audits (created_at DESC)');
// Main tenant time-entry range/pagination path, including its start DESC, id ordering; also covers the organization FK.
$this->runIndexOperation('create', 'time_entries_organization_start_id_index', 'CREATE INDEX CONCURRENTLY time_entries_organization_start_id_index ON time_entries (organization_id, start DESC, id)');
// Filament lists time entries globally by creation time, so the tenant-prefixed index cannot provide this ordering.
$this->runIndexOperation('create', 'time_entries_created_at_index', 'CREATE INDEX CONCURRENTLY time_entries_created_at_index ON time_entries (created_at DESC)');
// Dashboard history is consistently filtered by user and organization, then bounded by start; user first also covers its FK.
$this->runIndexOperation('create', 'time_entries_user_organization_start_index', 'CREATE INDEX CONCURRENTLY time_entries_user_organization_start_index ON time_entries (user_id, organization_id, start)');
// Member timelines, overlap checks, and billable-rate updates start with member_id; also covers its FK.
$this->runIndexOperation('create', 'time_entries_member_start_index', 'CREATE INDEX CONCURRENTLY time_entries_member_start_index ON time_entries (member_id, start)');
// These relationship/filter indexes also prevent full scans for FK checks when parent rows change or are deleted.
$this->runIndexOperation('create', 'time_entries_project_id_index', 'CREATE INDEX CONCURRENTLY time_entries_project_id_index ON time_entries (project_id)');
$this->runIndexOperation('create', 'time_entries_task_id_index', 'CREATE INDEX CONCURRENTLY time_entries_task_id_index ON time_entries (task_id)');
$this->runIndexOperation('create', 'time_entries_client_id_index', 'CREATE INDEX CONCURRENTLY time_entries_client_id_index ON time_entries (client_id)');
// Active-timer checks touch only open entries, so a partial index stays small while serving the hot member_id lookup.
$this->runIndexOperation('create', 'time_entries_active_member_index', 'CREATE INDEX CONCURRENTLY time_entries_active_member_index ON time_entries (member_id) WHERE "end" IS NULL');
// whereJsonContains(tags, tag_id) compiles to JSONB containment, which is supported by a GIN index.
$this->runIndexOperation('create', 'time_entries_tags_gin_index', 'CREATE INDEX CONCURRENTLY time_entries_tags_gin_index ON time_entries USING GIN (tags)');
// Passport already indexes user_id; these indexes cover the other FK used during OAuth client deletion/update.
$this->runIndexOperation('create', 'oauth_access_tokens_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_access_tokens_client_id_index ON oauth_access_tokens (client_id)');
$this->runIndexOperation('create', 'oauth_auth_codes_client_id_index', 'CREATE INDEX CONCURRENTLY oauth_auth_codes_client_id_index ON oauth_auth_codes (client_id)');
// owner_id is already the leading column of oauth_clients_owner_id_owner_type_index.
$this->runIndexOperation('drop', 'oauth_clients_user_id_index', 'DROP INDEX CONCURRENTLY oauth_clients_user_id_index');
// Public report lookup starts with the unique share_secret index; no query filters only by this boolean.
$this->runIndexOperation('drop', 'reports_is_public_index', 'DROP INDEX CONCURRENTLY reports_is_public_index');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$indexes = [
'clients_organization_created_id_index',
'projects_organization_created_id_index',
'projects_client_id_index',
'tasks_organization_created_id_index',
'tasks_project_id_index',
'tags_organization_created_id_index',
'reports_organization_created_id_index',
'members_organization_created_id_index',
'members_user_id_index',
'project_members_member_id_index',
'project_members_user_id_index',
'users_current_team_id_index',
'audits_created_at_index',
'time_entries_organization_start_id_index',
'time_entries_created_at_index',
'time_entries_user_organization_start_index',
'time_entries_member_start_index',
'time_entries_project_id_index',
'time_entries_task_id_index',
'time_entries_client_id_index',
'time_entries_active_member_index',
'time_entries_tags_gin_index',
'oauth_access_tokens_client_id_index',
'oauth_auth_codes_client_id_index',
];
$concurrently = DB::transactionLevel() === 0 ? ' CONCURRENTLY' : '';
foreach ($indexes as $index) {
$this->runIndexOperation('drop', $index, 'DROP INDEX'.$concurrently.' IF EXISTS '.$index);
}
$this->runIndexOperation('create', 'oauth_clients_user_id_index', 'CREATE INDEX'.$concurrently.' oauth_clients_user_id_index ON oauth_clients (owner_id)');
$this->runIndexOperation('create', 'reports_is_public_index', 'CREATE INDEX'.$concurrently.' reports_is_public_index ON reports (is_public)');
}
private function runIndexOperation(string $operation, string $index, string $statement): void
{
$indexState = $this->indexState($index);
if ($operation === 'create' && $indexState === ['valid' => true, 'ready' => true]) {
$this->writeProgress(sprintf('Skipping index [%s] because it already exists and is valid', $index));
return;
}
if ($operation === 'drop' && $indexState === null) {
$this->writeProgress(sprintf('Skipping index [%s] because it does not exist', $index));
return;
}
if ($operation === 'create' && $indexState !== null) {
$this->writeProgress(sprintf(
'Index [%s] exists but is incomplete (valid=%s, ready=%s); dropping it before rebuilding',
$index,
$indexState['valid'] ? 'true' : 'false',
$indexState['ready'] ? 'true' : 'false',
));
$this->executeIndexStatement(
'drop incomplete',
$index,
(DB::transactionLevel() === 0 ? 'DROP INDEX CONCURRENTLY ' : 'DROP INDEX ').$this->quoteIdentifier($index),
);
}
$this->executeIndexStatement($operation, $index, $statement);
}
private function executeIndexStatement(string $operation, string $index, string $statement): void
{
$startedAt = microtime(true);
$this->writeProgress(sprintf('Starting to %s index [%s]', $operation, $index));
try {
DB::statement($statement);
} catch (Throwable $exception) {
$this->writeProgress(sprintf(
'Failed to %s index [%s] after %.2f seconds: %s',
$operation,
$index,
microtime(true) - $startedAt,
$exception->getMessage(),
));
throw $exception;
}
$this->writeProgress(sprintf(
'Finished %s index [%s] in %.2f seconds',
$operation === 'create' ? 'creating' : 'dropping',
$index,
microtime(true) - $startedAt,
));
}
/**
* @return array{valid: bool, ready: bool}|null
*/
private function indexState(string $index): ?array
{
$state = DB::selectOne(
<<<'SQL'
SELECT pg_index.indisvalid::int AS valid, pg_index.indisready::int AS ready
FROM pg_index
JOIN pg_class ON pg_class.oid = pg_index.indexrelid
JOIN pg_namespace ON pg_namespace.oid = pg_class.relnamespace
WHERE pg_namespace.nspname = current_schema()
AND pg_class.relname = ?
SQL,
[$index],
);
if ($state === null) {
return null;
}
return [
'valid' => (bool) $state->valid,
'ready' => (bool) $state->ready,
];
}
private function quoteIdentifier(string $identifier): string
{
return DB::connection()->getQueryGrammar()->wrap($identifier);
}
private function writeProgress(string $message): void
{
if (app()->runningUnitTests()) {
return;
}
$message = sprintf('[%s] %s', date(DATE_ATOM), $message);
Log::info($message);
consoleInfo($message);
}
};

View File

@@ -189,9 +189,7 @@ ENV WITH_HORIZON=false \
WITH_SCHEDULER=false \ WITH_SCHEDULER=false \
WITH_REVERB=false WITH_REVERB=false
COPY --link --chown=${WWWUSER}:${WWWUSER} . ./ COPY --link --chown=${WWWUSER}:${WWWUSER} . .
RUN test -z "$(find . -name .git -print -quit)"
#COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public #COPY --link --chown=${WWWUSER}:${WWWUSER} --from=build ${ROOT}/public public
RUN mkdir -p \ RUN mkdir -p \

View File

@@ -10,7 +10,6 @@ import {
createTimeEntryWithTagViaApi, createTimeEntryWithTagViaApi,
createTimeEntryWithBillableStatusViaApi, createTimeEntryWithBillableStatusViaApi,
createBareTimeEntryViaApi, createBareTimeEntryViaApi,
createTimeEntryOnDateViaApi,
createPublicProjectViaApi, createPublicProjectViaApi,
updateOrganizationSettingViaApi, updateOrganizationSettingViaApi,
} from './utils/api'; } from './utils/api';
@@ -842,127 +841,6 @@ test('test that setting group by to current sub group triggers sub group fallbac
await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible(); await expect(groupBySelects.filter({ hasText: 'Members' }).first()).toBeVisible();
}); });
test('test that group by date groups the report by day and formats the date labels with organization settings', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' });
await createTimeEntryViaApi(ctx, {
description: 'Entry for group by date',
duration: '1h',
});
// Go to reporting page
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
// Find the "Group by" selects within the reporting table
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
// Default state: group=Project
await groupBySelects.filter({ hasText: 'Project' }).first().click();
const [aggregateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=day') &&
response.status() === 200
),
page.getByRole('option', { name: 'Date', exact: true }).click(),
]);
// Verify the API request contains the correct group parameter
const requestUrl = new URL(aggregateResponse.url());
expect(requestUrl.searchParams.get('group')).toBe('day');
// The row label is rendered in the organization date format (D.M.YYYY)
await expect(
page.getByTestId('reporting_view').getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/)
).toBeVisible();
await expect(page.getByTestId('reporting_view').getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(
0
);
});
test('test that group by week requests week grouping and does not leak the raw group key', async ({
page,
ctx,
}) => {
await createTimeEntryViaApi(ctx, {
description: 'Entry for group by week',
duration: '1h',
});
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
await groupBySelects.filter({ hasText: 'Project' }).first().click();
const [aggregateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=week') &&
response.status() === 200
),
page.getByRole('option', { name: 'Week', exact: true }).click(),
]);
const requestUrl = new URL(aggregateResponse.url());
expect(requestUrl.searchParams.get('group')).toBe('week');
// The raw group key is the first day of the week and must not leak through.
await expect(page.getByTestId('reporting_view').getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(
0
);
});
test('test that group by week labels a week spanning new year with a range crossing the year', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { date_format: 'slash-separated-dd-mm-yyyy' });
for (const day of ['2025-12-22', '2025-12-29', '2026-01-05']) {
await createTimeEntryOnDateViaApi(ctx, {
date: new Date(`${day}T09:00:00Z`),
duration: '1h',
description: `Entry for ${day}`,
});
}
// The reporting page keeps its range in session storage, so seed a range spanning new year
// rather than driving the date picker.
await page.addInitScript(() => {
window.sessionStorage.setItem('reporting-start-date', '2025-12-15');
window.sessionStorage.setItem('reporting-end-date', '2026-01-15');
});
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
await groupBySelects.filter({ hasText: 'Project' }).first().click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=week') &&
response.status() === 200
),
page.getByRole('option', { name: 'Week', exact: true }).click(),
]);
const reportingView = page.getByTestId('reporting_view');
await expect(reportingView.getByText('22/12/2025 - 28/12/2025', { exact: true })).toBeVisible();
await expect(reportingView.getByText('29/12/2025 - 04/01/2026', { exact: true })).toBeVisible();
await expect(reportingView.getByText('05/01/2026 - 11/01/2026', { exact: true })).toBeVisible();
await expect(reportingView.getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(0);
});
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────
// Export Tests // Export Tests
// ────────────────────────────────────────────────── // ──────────────────────────────────────────────────

View File

@@ -16,7 +16,6 @@ import {
createTimeEntryWithBillableStatusViaApi, createTimeEntryWithBillableStatusViaApi,
createTagViaApi, createTagViaApi,
createReportViaApi, createReportViaApi,
updateOrganizationSettingViaApi,
} from './utils/api'; } from './utils/api';
import { import {
goToReporting, goToReporting,
@@ -69,42 +68,6 @@ test('test that saving a report creates a shared report and its shareable link s
await expect(page.getByText('Total')).toBeVisible(); await expect(page.getByText('Total')).toBeVisible();
}); });
test('test that a shared report grouped by date shows date labels formatted by the organization setting', async ({
page,
ctx,
}) => {
const reportName = 'DateGroupReport ' + Math.floor(Math.random() * 10000);
await updateOrganizationSettingViaApi(ctx, { date_format: 'point-separated-d-m-yyyy' });
await createTimeEntryViaApi(ctx, {
description: 'Entry for date grouping',
duration: '1h',
});
await goToReporting(page);
// Switch the grouping to "Date"
const groupBySelects = page.locator('[data-testid="reporting_view"]').getByRole('combobox');
await groupBySelects.filter({ hasText: 'Project' }).first().click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('group=day') &&
response.status() === 200
),
page.getByRole('option', { name: 'Date', exact: true }).click(),
]);
const { shareableLink } = await saveAsSharedReport(page, reportName);
// Verify row labels are formatted correctly
await page.goto(shareableLink);
await expect(page.getByText('Total')).toBeVisible();
await expect(page.getByText(/^\d{1,2}\.\d{1,2}\.\d{4}$/)).toBeVisible();
await expect(page.getByText(/^\d{4}-\d{2}-\d{2}$/)).toHaveCount(0);
});
test('test that shared report with invalid secret shows no data', async ({ page }) => { test('test that shared report with invalid secret shows no data', async ({ page }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value'); await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value');
await expect(page.getByText('No time entries found').first()).toBeVisible(); await expect(page.getByText('No time entries found').first()).toBeVisible();

View File

@@ -1,14 +1,14 @@
{ {
"Billing": { "Billing": {
"repository": "solidtime-io/extension-billing", "repository": "solidtime-io/extension-billing",
"ref": "v0.0.5" "ref": "v0.0.1"
}, },
"Services": { "Services": {
"repository": "solidtime-io/extension-services", "repository": "solidtime-io/extension-services",
"ref": "v0.0.2" "ref": "v0.0.1"
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.2" "ref": "v0.0.1"
} }
} }

View File

@@ -240,8 +240,7 @@ const groupedPieChartData = computed(() => {
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
const name = getNameForReportingRowEntry( const name = getNameForReportingRowEntry(
entry.key, entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null, aggregatedTableTimeEntries.value?.grouped_type ?? null
organization?.value?.date_format
); );
let color = getRandomColorWithSeed(entry.key ?? 'none'); let color = getRandomColorWithSeed(entry.key ?? 'none');
if ( if (
@@ -256,7 +255,11 @@ const groupedPieChartData = computed(() => {
} }
return { return {
value: entry.seconds, value: entry.seconds,
name: name ?? '', name:
getNameForReportingRowEntry(
entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null
) ?? '',
color: color, color: color,
}; };
}) ?? [] }) ?? []
@@ -266,25 +269,18 @@ const groupedPieChartData = computed(() => {
const tableData = computed(() => { const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return { return {
key: entry.key,
seconds: entry.seconds, seconds: entry.seconds,
cost: entry.cost, cost: entry.cost,
description: getNameForReportingRowEntry( description: getNameForReportingRowEntry(
entry.key, entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null, aggregatedTableTimeEntries.value?.grouped_type ?? null
organization?.value?.date_format
), ),
grouped_data: grouped_data:
entry.grouped_data?.map((el) => { entry.grouped_data?.map((el) => {
return { return {
key: el.key,
seconds: el.seconds, seconds: el.seconds,
cost: el.cost, cost: el.cost,
description: getNameForReportingRowEntry( description: getNameForReportingRowEntry(el.key, entry.grouped_type),
el.key,
entry.grouped_type,
organization?.value?.date_format
),
}; };
}) ?? [], }) ?? [],
}; };
@@ -425,8 +421,9 @@ const tableData = computed(() => {
"> ">
<ReportingRow <ReportingRow
v-for="entry in tableData" v-for="entry in tableData"
:key="entry.key ?? 'none'" :key="entry.description ?? 'none'"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:type="aggregatedTableTimeEntries.grouped_type"
:show-cost="showBillableRate" :show-cost="showBillableRate"
:entry="entry"></ReportingRow> :entry="entry"></ReportingRow>
<div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]"> <div class="contents [&>*]:transition text-text-tertiary [&>*]:h-[50px]">

View File

@@ -11,7 +11,6 @@ type AggregatedGroupedData = GroupedData & {
}; };
type GroupedData = { type GroupedData = {
key: string | null;
seconds: number; seconds: number;
cost: number | null; cost: number | null;
description: string | null | undefined; description: string | null | undefined;
@@ -73,7 +72,7 @@ const organization = inject<ComputedRef<Organization>>('organization');
:style="`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`"> :style="`grid-template-columns: 1fr 150px ${showCost ? '150px' : ''}`">
<ReportingRow <ReportingRow
v-for="subEntry in entry.grouped_data" v-for="subEntry in entry.grouped_data"
:key="subEntry.key ?? 'none'" :key="subEntry.description ?? 'none'"
:currency="props.currency" :currency="props.currency"
:show-cost="showCost" :show-cost="showCost"
indent indent

View File

@@ -95,24 +95,20 @@ const tableData = computed(() => {
return ( return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return { return {
key: entry.key,
seconds: entry.seconds, seconds: entry.seconds,
cost: entry.cost, cost: entry.cost,
description: getNameForReportingRowEntry( description: getNameForReportingRowEntry(
entry.key, entry.key,
aggregatedTableTimeEntries.value?.grouped_type ?? null, aggregatedTableTimeEntries.value?.grouped_type ?? null
organization?.value?.date_format
), ),
grouped_data: grouped_data:
entry.grouped_data?.map((el) => { entry.grouped_data?.map((el) => {
return { return {
key: el.key,
seconds: el.seconds, seconds: el.seconds,
cost: el.cost, cost: el.cost,
description: getNameForReportingRowEntry( description: getNameForReportingRowEntry(
el.key, el.key,
entry.grouped_type ?? null, entry.grouped_type ?? null
organization?.value?.date_format
), ),
}; };
}) ?? [], }) ?? [],
@@ -168,7 +164,7 @@ const showBillableRate = computed(() => {
"> ">
<ReportingRow <ReportingRow
v-for="entry in tableData" v-for="entry in tableData"
:key="entry.key ?? 'none'" :key="entry.description ?? 'none'"
:currency="getOrganizationCurrencyString()" :currency="getOrganizationCurrencyString()"
:show-cost="showBillableRate" :show-cost="showBillableRate"
:entry="entry"></ReportingRow> :entry="entry"></ReportingRow>

View File

@@ -116,43 +116,25 @@ const subGroup = computed(() => {
} }
return 'project'; return 'project';
}); });
const { emptyPlaceholder, getNameForReportingRowEntry } = useReportingStore(); const { emptyPlaceholder } = useReportingStore();
/**
* The public report endpoint has no descriptor for time group types, so their labels are
* derived from the raw group key.
*/
function resolveLabel(
description: string | null | undefined,
key: string | null | undefined,
groupedType: string
) {
if (description !== null && description !== undefined) {
return description;
}
return (
getNameForReportingRowEntry(key ?? null, groupedType, reportDateFormat.value) ??
emptyPlaceholder[groupedType] ??
''
);
}
const groupedPieChartData = computed(() => { const groupedPieChartData = computed(() => {
return ( return (
aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
const groupedType = aggregatedTableTimeEntries.value?.grouped_type ?? 'project'; if (entry.description === null) {
const name = resolveLabel(entry.description, entry.key, groupedType);
if (name === emptyPlaceholder[groupedType]) {
return { return {
value: entry.seconds, value: entry.seconds,
name: name, name:
emptyPlaceholder[
aggregatedTableTimeEntries.value?.grouped_type ?? 'project'
] ?? '',
color: '#CCCCCC', color: '#CCCCCC',
}; };
} }
return { return {
value: entry.seconds, value: entry.seconds,
name: name, name: entry.description,
color: entry.color ?? getRandomColorWithSeed(name), color: entry.color ?? getRandomColorWithSeed(entry.description ?? 'none'),
}; };
}) ?? [] }) ?? []
); );
@@ -161,25 +143,21 @@ const groupedPieChartData = computed(() => {
const tableData = computed(() => { const tableData = computed(() => {
return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => { return aggregatedTableTimeEntries.value?.grouped_data?.map((entry) => {
return { return {
key: entry.key,
seconds: entry.seconds, seconds: entry.seconds,
cost: entry.cost, cost: entry.cost,
description: resolveLabel( description:
entry.description, entry.description ??
entry.key, emptyPlaceholder[aggregatedTableTimeEntries.value?.grouped_type ?? 'project'] ??
aggregatedTableTimeEntries.value?.grouped_type ?? 'project' '',
),
grouped_data: grouped_data:
entry.grouped_data?.map((el) => { entry.grouped_data?.map((el) => {
return { return {
key: el.key,
seconds: el.seconds, seconds: el.seconds,
cost: el.cost, cost: el.cost,
description: resolveLabel( description:
el.description, el.description ??
el.key, emptyPlaceholder[entry.grouped_type ?? 'project'] ??
entry.grouped_type ?? 'project' '',
),
}; };
}) ?? [], }) ?? [],
}; };
@@ -241,7 +219,7 @@ onMounted(async () => {
"> ">
<ReportingRow <ReportingRow
v-for="entry in tableData" v-for="entry in tableData"
:key="entry.key ?? 'none'" :key="entry.description ?? 'none'"
:currency="reportCurrency" :currency="reportCurrency"
:currency-format="reportCurrencyFormat" :currency-format="reportCurrencyFormat"
:show-cost="true" :show-cost="true"

View File

@@ -198,7 +198,7 @@ const showResultModal = ref(false);
for="file-upload" for="file-upload"
class="relative cursor-pointer rounded-md font-semibold text-text-primary focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 focus-within:ring-offset-gray-900 hover:text-indigo-500"> class="relative cursor-pointer rounded-md font-semibold text-text-primary focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-600 focus-within:ring-offset-2 focus-within:ring-offset-gray-900 hover:text-indigo-500">
<span v-if="files">{{ filenames }}</span> <span v-if="files">{{ filenames }}</span>
<span v-else>Upload the Export file</span> <span v-else>Upload a Toggl/Clockify Export</span>
<input <input
id="file-upload" id="file-upload"
ref="importFile" ref="importFile"

View File

@@ -25,21 +25,25 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits);
<template> <template>
<DialogPortal> <DialogPortal>
<DialogOverlay <DialogOverlay
class="fixed top-0 left-0 z-50 w-screen h-screen [height:100dvh] backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"> class="fixed inset-0 z-50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0">
<div class="absolute inset-0 bg-default-background opacity-30" /> <div class="absolute inset-0 bg-default-background opacity-30" />
<div
class="absolute inset-0 overflow-y-auto overscroll-contain flex items-start justify-center px-2">
<DialogContent
v-bind="forwarded"
:class="
cn(
'my-3 md:my-14 xl:my-24 bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
props.class
)
">
<slot />
</DialogContent>
</div>
</DialogOverlay> </DialogOverlay>
<div
:class="
cn(
'fixed top-0 left-0 z-50 pointer-events-none w-screen h-screen flex items-start px-2 pt-3 md:pt-14 xl:pt-24 justify-center overflow-auto'
)
">
<DialogContent
v-bind="forwarded"
:class="
cn(
'pointer-events-auto bg-default-background grid w-full max-w-lg border border-border-tertiary shadow-lg duration-200 rounded-lg outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
props.class
)
">
<slot />
</DialogContent>
</div>
</DialogPortal> </DialogPortal>
</template> </template>

View File

@@ -1,10 +1,5 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import { import { formatHumanReadableDuration, formatReportingDuration } from './time';
formatHumanReadableDuration,
formatMonth,
formatReportingDuration,
formatWeekRange,
} from './time';
const seconds = 14 * 3600 + 45 * 60 + 6; // 14h 45m 06s const seconds = 14 * 3600 + 45 * 60 + 6; // 14h 45m 06s
@@ -47,27 +42,3 @@ describe('formatReportingDuration', () => {
); );
}); });
}); });
describe('formatWeekRange', () => {
test('renders the six days following the given first day of the week', () => {
expect(formatWeekRange('2026-07-27', 'slash-separated-dd-mm-yyyy')).toBe(
'27/07/2026 - 02/08/2026'
);
});
test('spans a month boundary', () => {
expect(formatWeekRange('2026-07-27')).toBe('27.7.2026 - 2.8.2026');
});
test('spans a year boundary', () => {
expect(formatWeekRange('2025-12-29', 'slash-separated-dd-mm-yyyy')).toBe(
'29/12/2025 - 04/01/2026'
);
});
});
describe('formatMonth', () => {
test('renders the name of the month that the key covers', () => {
expect(formatMonth('2001-02')).toBe('February 2001');
});
});

View File

@@ -236,24 +236,6 @@ export function formatWeek(date: string | null): string {
return 'Week ' + getDayJsInstance()(date).week(); return 'Week ' + getDayJsInstance()(date).week();
} }
/*
* Returns the range covered by the week starting on the given day.
* @param date - first day of a week, in the format of 'YYYY-MM-DD'
*/
export function formatWeekRange(date: string, format?: DateFormat): string {
const end = getDayJsInstance()(date).add(6, 'day').format('YYYY-MM-DD');
return `${formatDate(date, format)} - ${formatDate(end, format)}`;
}
/*
* Returns the month that the given key falls in. There is no `DateFormat` variant for a
* month, so this is not affected by the organization date format.
* @param date - a month, in the format of 'YYYY-MM'
*/
export function formatMonth(date: string): string {
return getDayJsInstance()(date).format('MMMM YYYY');
}
/* /*
* Returns a human readable date format. * Returns a human readable date format.
* @param date - date in the format of 'YYYY-MM-DD' * @param date - date in the format of 'YYYY-MM-DD'

View File

@@ -7,20 +7,9 @@ import { useTasksQuery } from '@/utils/useTasksQuery';
import { useClientsQuery } from '@/utils/useClientsQuery'; import { useClientsQuery } from '@/utils/useClientsQuery';
import { useTagsQuery } from '@/utils/useTagsQuery'; import { useTagsQuery } from '@/utils/useTagsQuery';
import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid'; import { CheckCircleIcon, UserCircleIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { import { DocumentTextIcon, FolderIcon } from '@heroicons/vue/16/solid';
CalendarDaysIcon,
CalendarIcon,
DocumentTextIcon,
FolderIcon,
} from '@heroicons/vue/16/solid';
import { Coffee } from '@lucide/vue'; import { Coffee } from '@lucide/vue';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue'; import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import {
type DateFormat,
formatDate,
formatMonth,
formatWeekRange,
} from '@/packages/ui/src/utils/time';
export type GroupingOption = export type GroupingOption =
| 'project' | 'project'
@@ -30,11 +19,7 @@ export type GroupingOption =
| 'client' | 'client'
| 'description' | 'description'
| 'tag' | 'tag'
| 'type' | 'type';
| 'day'
| 'week'
| 'month'
| 'year';
export const useReportingStore = defineStore('reporting', () => { export const useReportingStore = defineStore('reporting', () => {
// Cache query composables to avoid creating new subscriptions on every call // Cache query composables to avoid creating new subscriptions on every call
@@ -55,11 +40,7 @@ export const useReportingStore = defineStore('reporting', () => {
type: 'Work time', type: 'Work time',
} as Record<string, string>; } as Record<string, string>;
function getNameForReportingRowEntry( function getNameForReportingRowEntry(key: string | null, type: string | null) {
key: string | null,
type: string | null,
dateFormat?: DateFormat
) {
if (type === null) { if (type === null) {
return null; return null;
} }
@@ -95,16 +76,6 @@ export const useReportingStore = defineStore('reporting', () => {
if (type === 'type') { if (type === 'type') {
return key === 'break' ? 'Break' : 'Work time'; return key === 'break' ? 'Break' : 'Work time';
} }
if (type === 'day') {
return formatDate(key, dateFormat);
}
if (type === 'week') {
return formatWeekRange(key, dateFormat);
}
if (type === 'month') {
return formatMonth(key);
}
// A `year` key is already a bare year, so it falls through unchanged.
return key; return key;
} }
@@ -153,16 +124,6 @@ export const useReportingStore = defineStore('reporting', () => {
value: 'tag', value: 'tag',
icon: DocumentTextIcon, icon: DocumentTextIcon,
}, },
{
label: 'Date',
value: 'day',
icon: CalendarIcon,
},
{
label: 'Week',
value: 'week',
icon: CalendarDaysIcon,
},
]; ];
return { return {

View File

@@ -1,49 +0,0 @@
{{-- Self-contained on purpose: this page is rendered for a request on an
untrusted host, so it must not call url()/route()/asset(), which would
re-trigger Host validation and throw again. --}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Untrusted host</title>
<style>
html, body { height: 100%; margin: 0; }
body {
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
color: #1f2937;
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
.card {
max-width: 32rem;
margin: 1.5rem;
padding: 2rem;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
h1 { margin: 0 0 0.75rem; font-size: 1.25rem; }
p { margin: 0; line-height: 1.6; color: #4b5563; }
code {
padding: 0.1rem 0.35rem;
background: #f3f4f6;
border-radius: 0.25rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
}
</style>
</head>
<body>
<div class="card">
<h1>Untrusted host</h1>
<p>
This hostname is not configured for this instance. Set
<code>APP_URL</code>, or add the host to <code>TRUSTED_HOSTS</code>.
</p>
</div>
</body>
</html>

View File

@@ -194,7 +194,7 @@
@if($group->is(\App\Enums\TimeEntryAggregationType::Billable)) @if($group->is(\App\Enums\TimeEntryAggregationType::Billable))
{{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }} {{ $group1Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
@else @else
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? 'No '.Str::lower($group->description()) }} {{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
@endif @endif
</span> </span>
</td> </td>
@@ -239,7 +239,7 @@
<span style="color: #a1a1aa;"> <span style="color: #a1a1aa;">
{{ $group->description() }}: {{ $group->description() }}:
</span> </span>
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? 'No '.Str::lower($group->description()) }} {{ $group1Entry['description'] ?? $group1Entry['key'] ?? 'No '.Str::lower($group->description()) }}
@endif @endif
</h2> </h2>
@@ -278,7 +278,7 @@
@if($subGroup->is(\App\Enums\TimeEntryAggregationType::Billable)) @if($subGroup->is(\App\Enums\TimeEntryAggregationType::Billable))
{{ $group2Entry['key'] === '1' ? 'Billable' : 'Non-billable' }} {{ $group2Entry['key'] === '1' ? 'Billable' : 'Non-billable' }}
@else @else
{{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }} {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
@endif @endif
</td> </td>
<td> <td>
@@ -318,7 +318,7 @@
series: [ series: [
{ {
data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group, $localization): object { data: {!! json_encode(collect($aggregatedData['grouped_data'])->map(function (array $data) use (&$colorService, $group): object {
$color = $data['color']; $color = $data['color'];
if ($color === null) { if ($color === null) {
$color = $colorService->getRandomColor($data['key']); $color = $colorService->getRandomColor($data['key']);
@@ -328,7 +328,7 @@
} }
return (object)[ return (object)[
'value' => $data['seconds'], 'value' => $data['seconds'],
'name' => $data['description'] ?? $localization->formatTimeGroupKey($data['key'], $group) ?? 'No '.Str::lower($group->description()), 'name' => $data['description'] ?? $data['key'] ?? 'No '.Str::lower($group->description()),
'color' => $color, 'color' => $color,
'itemStyle' => (object) [ 'itemStyle' => (object) [
'color' => $color, 'color' => $color,

View File

@@ -44,7 +44,7 @@
</td> </td>
@else @else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}"> <td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? '-' }} {{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}
</td> </td>
@endif @endif
@if ($subGroup === TimeEntryAggregationType::Billable) @if ($subGroup === TimeEntryAggregationType::Billable)
@@ -53,7 +53,7 @@
</td> </td>
@else @else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}"> <td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }} {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
</td> </td>
@endif @endif
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}"> <td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
@@ -74,7 +74,7 @@
</td> </td>
@else @else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}"> <td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group1Entry['description'] ?? $localization->formatTimeGroupKey($group1Entry['key'], $group) ?? '-' }} {{ $group1Entry['description'] ?? $group1Entry['key'] ?? '-' }}
</td> </td>
@endif @endif
@if ($subGroup === TimeEntryAggregationType::Billable) @if ($subGroup === TimeEntryAggregationType::Billable)
@@ -83,7 +83,7 @@
</td> </td>
@else @else
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}"> <td style="border: 1px solid black;" data-type="{{ DataType::TYPE_STRING }}">
{{ $group2Entry['description'] ?? $localization->formatTimeGroupKey($group2Entry['key'], $subGroup) ?? '-' }} {{ $group2Entry['description'] ?? $group2Entry['key'] ?? '-' }}
</td> </td>
@endif @endif
<td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}" <td style="border: 1px solid black;" data-type="{{ DataType::TYPE_NUMERIC }}"

View File

@@ -380,40 +380,6 @@ class RegistrationTest extends TestCaseWithDatabase
$this->assertSame($user->organization->id, $organizations->first()->id); $this->assertSame($user->organization->id, $organizations->first()->id);
} }
public function test_registration_joins_invited_organization_even_if_invitation_email_casing_differs(): void
{
// Arrange: invitation stored with a different casing than the registration email
$user = $this->createUserWithPermission();
OrganizationInvitation::factory()
->forOrganization($user->organization)
->role(Role::Employee)
->accepted()
->create([
'email' => 'Invited.User@example.com',
]);
// Act
$response = $this->post('/register', [
'name' => 'Invited User',
'email' => 'invited.user@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => true,
]);
// Assert: joined the inviting organization, no extra personal organization, invitation consumed
$this->assertAuthenticated();
$response->assertRedirect(RouteServiceProvider::HOME);
$newUser = User::where('email', 'invited.user@example.com')->first();
$this->assertNotNull($newUser);
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'email' => 'Invited.User@example.com',
]);
$organizations = $newUser->organizations;
$this->assertCount(1, $organizations);
$this->assertSame($user->organization->id, $organizations->first()->id);
}
public function test_registration_logs_and_skips_accepted_invitation_with_invalid_role(): void public function test_registration_logs_and_skips_accepted_invitation_with_invalid_role(): void
{ {
// Arrange // Arrange

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1; namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\DateFormat;
use App\Enums\ExportFormat; use App\Enums\ExportFormat;
use App\Enums\Role; use App\Enums\Role;
use App\Enums\TagMatchType; use App\Enums\TagMatchType;
@@ -17,7 +16,6 @@ use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask; use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
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;
@@ -94,30 +92,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonPath('data.0.id', $timeEntry->getKey()); $response->assertJsonPath('data.0.id', $timeEntry->getKey());
} }
public function test_index_endpoint_filters_by_member_id_instead_of_legacy_user_id(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$legacyUser = User::factory()->create();
$timeEntry = TimeEntry::factory()->forMember($data->member)->create([
'user_id' => $legacyUser->getKey(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'member_id' => $data->member->getKey(),
]));
// Assert
$this->assertResponseCode($response, 200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $timeEntry->getKey());
}
public function test_index_endpoint_fails_if_user_filter_is_from_different_organization(): void public function test_index_endpoint_fails_if_user_filter_is_from_different_organization(): void
{ {
// Arrange // Arrange
@@ -152,10 +126,7 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
Passport::actingAs($data->user); Passport::actingAs($data->user);
// Act // Act
$response = $this->getJson(route('api.v1.time-entries.index', [ $response = $this->getJson(route('api.v1.time-entries.index', [$data->organization->getKey(), 'user_id' => $user->getKey()]));
$data->organization->getKey(),
'member_id' => $member->getKey(),
]));
// Assert // Assert
$this->assertResponseCode($response, 200); $this->assertResponseCode($response, 200);
@@ -1643,212 +1614,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$this->assertResponseCode($response, 200); $this->assertResponseCode($response, 200);
} }
public function test_aggregate_export_endpoints_can_create_a_pdf_report_grouped_by_date(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$client = Client::factory()->forOrganization($data->organization)->create();
$project = Project::factory()->forOrganization($data->organization)->forClient($client)->create();
$timeEntry1 = TimeEntry::factory()->forOrganization($data->organization)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
$timeEntry2 = TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create();
Passport::actingAs($data->user);
$this->actAsOrganizationWithSubscription();
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::Day,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Day,
'start' => Carbon::now()->startOfYear()->toIso8601ZuluString(),
'end' => Carbon::now()->endOfYear()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_pdf_renders_date_group_labels_in_organization_date_format(): void
{
// Arrange
$this->travelTo(Carbon::create(2024, 3, 15, 12, 0, 0, 'UTC'));
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
// Note: the organization factory randomizes the date format, so pin it
$data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]);
$project = Project::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)
->startWithDuration(Carbon::now()->subDay(), 3600)->create();
Passport::actingAs($data->user);
$this->actAsOrganizationWithSubscription();
// Act
// Note: debug=true returns the rendered HTML instead of handing it to the PDF renderer.
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::Day,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Day,
'start' => Carbon::now()->subDays(7)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'debug' => 'true',
]));
// Assert
$this->assertResponseCode($response, 200);
$html = $response->json('html');
$this->assertIsString($html);
$this->assertStringContainsString('14/03/2024', $html);
$this->assertStringNotContainsString('2024-03-14', $html);
}
public function test_aggregate_export_csv_renders_date_group_labels_in_organization_date_format(): void
{
// Arrange
$this->travelTo(Carbon::create(2024, 3, 15, 12, 0, 0, 'UTC'));
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
// Note: the organization factory randomizes the date format, so pin it
$data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]);
$project = Project::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)
->startWithDuration(Carbon::now()->subDay(), 3600)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$data->organization->getKey(),
'format' => ExportFormat::CSV,
'group' => TimeEntryAggregationType::Day,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Day,
'start' => Carbon::now()->subDays(7)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
$disk = Storage::disk(config('filesystems.private'));
$files = $disk->files('exports');
$this->assertCount(1, $files);
$csv = $disk->get($files[0]);
$this->assertIsString($csv);
$this->assertStringContainsString('14/03/2024', $csv);
$this->assertStringNotContainsString('2024-03-14', $csv);
}
/**
* @return array{0: Organization, 1: Member, 2: User}
*/
private function createWeeksSpanningNewYear(): array
{
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
// Note: the organization factory randomizes the date format, so pin it
$data->organization->update(['date_format' => DateFormat::SlashSeparatedDDMMYYYY]);
$project = Project::factory()->forOrganization($data->organization)->create();
// Note: the user factory pins the week start to Monday, so these land in predictable buckets
foreach (['2025-12-22', '2025-12-29', '2026-01-05'] as $day) {
TimeEntry::factory()->forOrganization($data->organization)->forProject($project)->forMember($data->member)
->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 3600)->create();
}
return [$data->organization, $data->member, $data->user];
}
public function test_aggregate_export_csv_labels_a_week_group_with_its_date_range(): void
{
// Arrange
$this->travelTo(Carbon::create(2026, 1, 15, 12, 0, 0, 'UTC'));
[$organization, , $user] = $this->createWeeksSpanningNewYear();
Passport::actingAs($user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$organization->getKey(),
'format' => ExportFormat::CSV,
'group' => TimeEntryAggregationType::Week,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Week,
'start' => Carbon::parse('2025-12-15 00:00:00', 'UTC')->toIso8601ZuluString(),
'end' => Carbon::parse('2026-01-15 23:59:59', 'UTC')->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
$disk = Storage::disk(config('filesystems.private'));
$files = $disk->files('exports');
$this->assertCount(1, $files);
$csv = $disk->get($files[0]);
$this->assertIsString($csv);
$this->assertStringContainsString('22/12/2025 - 28/12/2025', $csv);
$this->assertStringContainsString('29/12/2025 - 04/01/2026', $csv);
$this->assertStringContainsString('05/01/2026 - 11/01/2026', $csv);
$this->assertStringNotContainsString('2025-12-29', $csv);
$this->assertStringNotContainsString('2025-12-22', $csv);
}
public function test_aggregate_export_endpoints_can_create_a_pdf_report_grouped_by_week_spanning_new_year(): void
{
// Arrange
$this->travelTo(Carbon::create(2026, 1, 15, 12, 0, 0, 'UTC'));
[$organization, , $user] = $this->createWeeksSpanningNewYear();
Passport::actingAs($user);
$this->actAsOrganizationWithSubscription();
// Act
// Note: a week 1 label carries a comma and reaches the echarts series in a <script> tag
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::Week,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Week,
'start' => Carbon::parse('2025-12-15 00:00:00', 'UTC')->toIso8601ZuluString(),
'end' => Carbon::parse('2026-01-15 23:59:59', 'UTC')->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 200);
}
public function test_aggregate_export_pdf_labels_a_week_group_with_its_date_range(): void
{
// Arrange
$this->travelTo(Carbon::create(2026, 1, 15, 12, 0, 0, 'UTC'));
[$organization, , $user] = $this->createWeeksSpanningNewYear();
Passport::actingAs($user);
$this->actAsOrganizationWithSubscription();
// Act
// Note: debug=true returns the rendered HTML instead of handing it to the PDF renderer.
$response = $this->getJson(route('api.v1.time-entries.aggregate-export', [
$organization->getKey(),
'format' => ExportFormat::PDF,
'group' => TimeEntryAggregationType::Week,
'sub_group' => TimeEntryAggregationType::Project,
'history_group' => TimeEntryAggregationTypeInterval::Week,
'start' => Carbon::parse('2025-12-15 00:00:00', 'UTC')->toIso8601ZuluString(),
'end' => Carbon::parse('2026-01-15 23:59:59', 'UTC')->toIso8601ZuluString(),
'debug' => 'true',
]));
// Assert
$this->assertResponseCode($response, 200);
$html = $response->json('html');
$this->assertIsString($html);
$this->assertStringContainsString('22/12/2025 - 28/12/2025', $html);
$this->assertStringContainsString('29/12/2025 - 04/01/2026', $html);
$this->assertStringNotContainsString('2025-12-29', $html);
}
public function test_index_export_endpoint_with_client_ids_filter_returns_filtered_entries(): void public function test_index_export_endpoint_with_client_ids_filter_returns_filtered_entries(): void
{ {
// Arrange // Arrange
@@ -2007,29 +1772,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_aggregate_endpoint_filters_by_member_id_instead_of_legacy_user_id(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:own',
]);
$legacyUser = User::factory()->create();
TimeEntry::factory()->forMember($data->member)->startWithDuration(Carbon::now(), 100)->create([
'user_id' => $legacyUser->getKey(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
$data->organization->getKey(),
'member_id' => $data->member->getKey(),
]));
// Assert
$response->assertSuccessful();
$response->assertJsonPath('data.seconds', 100);
}
public function test_aggregate_endpoint_groups_by_two_groups(): void public function test_aggregate_endpoint_groups_by_two_groups(): void
{ {
// Arrange // Arrange
@@ -3077,32 +2819,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_update_endpoint_updates_user_id_when_member_id_changes(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:all',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
$timeEntry = TimeEntry::factory()->forMember($data->member)->create();
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.time-entries.update', [$data->organization->getKey(), $timeEntry->getKey()]), [
'member_id' => $otherMember->getKey(),
]);
// Assert
$response->assertValid();
$this->assertResponseCode($response, 200);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry->getKey(),
'member_id' => $otherMember->getKey(),
'user_id' => $otherUser->getKey(),
]);
}
public function test_update_endpoint_can_update_project_and_automatically_set_client(): void public function test_update_endpoint_can_update_project_and_automatically_set_client(): void
{ {
// Arrange // Arrange
@@ -3439,40 +3155,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_destroy_multiple_uses_member_id_for_own_permission_checks(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:delete:own',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
$timeEntry = TimeEntry::factory()->forMember($otherMember)->create([
'user_id' => $data->user->getKey(),
]);
Passport::actingAs($data->user);
// Act
$response = $this->deleteJson(route('api.v1.time-entries.destroy-multiple', [$data->organization->getKey()]), [
'ids' => [
$timeEntry->getKey(),
],
]);
// Assert
$response->assertValid();
$this->assertResponseCode($response, 200);
$response->assertExactJson([
'success' => [],
'error' => [
$timeEntry->getKey(),
],
]);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry->getKey(),
]);
}
public function test_destroy_multiple_deletes_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void public function test_destroy_multiple_deletes_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void
{ {
// Arrange // Arrange
@@ -3884,46 +3566,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_update_multiple_uses_member_id_for_own_permission_checks(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:own',
'projects:view:all',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
$timeEntry = TimeEntry::factory()->forMember($otherMember)->create([
'user_id' => $data->user->getKey(),
]);
$timeEntriesFake = TimeEntry::factory()->forOrganization($data->organization)->make();
Passport::actingAs($data->user);
// Act
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
'ids' => [
$timeEntry->getKey(),
],
'changes' => [
'description' => $timeEntriesFake->description,
],
]);
// Assert
$response->assertValid();
$this->assertResponseCode($response, 200);
$response->assertExactJson([
'success' => [],
'error' => [
$timeEntry->getKey(),
],
]);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry->getKey(),
'description' => $timeEntry->description,
]);
}
public function test_update_multiple_updates_sets_description_to_empty_if_the_client_sends_null(): void public function test_update_multiple_updates_sets_description_to_empty_if_the_client_sends_null(): void
{ {
// Arrange // Arrange
@@ -3970,51 +3612,6 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
]); ]);
} }
public function test_update_multiple_updates_user_id_when_member_id_changes(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:update:all',
]);
$otherUser = User::factory()->create();
$otherMember = Member::factory()->forOrganization($data->organization)->forUser($otherUser)->role(Role::Employee)->create();
$timeEntry1 = TimeEntry::factory()->forMember($data->member)->create();
$timeEntry2 = TimeEntry::factory()->forMember($data->member)->create();
Passport::actingAs($data->user);
// Act
$response = $this->patchJson(route('api.v1.time-entries.update-multiple', [$data->organization->getKey()]), [
'ids' => [
$timeEntry1->getKey(),
$timeEntry2->getKey(),
],
'changes' => [
'member_id' => $otherMember->getKey(),
],
]);
// Assert
$response->assertValid();
$response->assertStatus(200);
$response->assertExactJson([
'success' => [
$timeEntry1->getKey(),
$timeEntry2->getKey(),
],
'error' => [],
]);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry1->getKey(),
'member_id' => $otherMember->getKey(),
'user_id' => $otherUser->getKey(),
]);
$this->assertDatabaseHas(TimeEntry::class, [
'id' => $timeEntry2->getKey(),
'member_id' => $otherMember->getKey(),
'user_id' => $otherUser->getKey(),
]);
}
public function test_update_multiple_updates_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void public function test_update_multiple_updates_all_time_entries_and_fails_for_time_entries_of_other_users_and_and_other_organizations_with_all_time_entries_permission(): void
{ {
// Arrange // Arrange

View File

@@ -1,271 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Middleware;
use App\Http\Middleware\TrustHosts;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Http\Request;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response;
use Tests\TestCase;
#[CoversClass(TrustHosts::class)]
class TrustHostsTest extends TestCase
{
private const string CANONICAL = 'https://app.example.com';
protected function setUp(): void
{
parent::setUp();
config(['app.url' => self::CANONICAL]);
}
protected function tearDown(): void
{
Request::setTrustedHosts([]); // don't leak static state between tests
parent::tearDown();
}
/**
* The real middleware, with only the environment gate forced on (it
* self-exempts in the testing environment).
*/
private function middleware(): TrustHosts
{
return new class($this->app) extends TrustHosts
{
protected function shouldSpecifyTrustedHosts(): bool
{
return true;
}
};
}
private function accepts(Request $request): bool
{
$this->middleware()->handle($request, fn (Request $request): Response => new Response('passed'));
try {
dump($request->getHost());
return true;
} catch (\Throwable) {
return false;
}
}
public function test_canonical_host_is_accepted(): void
{
// Arrange
$request = Request::create(self::CANONICAL.'/login');
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertTrue($accepted);
}
public function test_subdomain_of_canonical_host_is_accepted(): void
{
// Arrange
$request = Request::create('https://team.app.example.com/login');
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertTrue($accepted);
}
public function test_declared_trusted_host_is_accepted(): void
{
// Arrange
config(['app.trusted_hosts' => ['box.tailnet.ts.net']]);
$request = Request::create('https://box.tailnet.ts.net/login');
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertTrue($accepted);
}
public function test_wildcard_trusted_host_matches_subdomains_only(): void
{
// Arrange
config(['app.trusted_hosts' => ['*.example.net']]);
$subdomainRequest = Request::create('https://foo.example.net/login');
$nestedSubdomainRequest = Request::create('https://a.b.example.net/login');
$apexRequest = Request::create('https://example.net/login');
$suffixInjectionRequest = Request::create('https://example.net.evil.com/login');
// Act
$subdomainAccepted = $this->accepts($subdomainRequest);
$nestedSubdomainAccepted = $this->accepts($nestedSubdomainRequest);
$apexAccepted = $this->accepts($apexRequest);
$suffixInjectionAccepted = $this->accepts($suffixInjectionRequest);
// Assert
$this->assertTrue($subdomainAccepted);
$this->assertTrue($nestedSubdomainAccepted);
$this->assertFalse($apexAccepted);
$this->assertFalse($suffixInjectionAccepted);
}
public function test_multiple_trusted_hosts_are_all_accepted(): void
{
// Arrange
config(['app.trusted_hosts' => [
'box.tailnet.ts.net',
'solidtime.internal',
'*.preview.example.com',
]]);
$tailnetRequest = Request::create('https://box.tailnet.ts.net/login');
$internalRequest = Request::create('https://solidtime.internal/login');
$previewRequest = Request::create('https://pr-42.preview.example.com/login');
$unlistedRequest = Request::create('https://evil.example.com/login');
// Act
$tailnetAccepted = $this->accepts($tailnetRequest);
$internalAccepted = $this->accepts($internalRequest);
$previewAccepted = $this->accepts($previewRequest);
$unlistedAccepted = $this->accepts($unlistedRequest);
// Assert
$this->assertTrue($tailnetAccepted);
$this->assertTrue($internalAccepted);
$this->assertTrue($previewAccepted);
$this->assertFalse($unlistedAccepted);
}
public function test_poisoned_host_is_rejected(): void
{
// Arrange
$request = Request::create('https://evil.example.com/login');
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertFalse($accepted);
}
public function test_poisoned_x_forwarded_host_is_rejected(): void
{
// Arrange
$request = Request::create(self::CANONICAL.'/login');
$request->headers->set('X-Forwarded-Host', 'evil.example.com');
$request->setTrustedProxies(
['0.0.0.0/0', '2000::/3'],
Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT
);
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertFalse($accepted);
}
public function test_forwarded_host_from_trusted_proxy_is_accepted(): void
{
// Arrange
$request = Request::create('https://evil.example.com/login');
$request->headers->set('X-Forwarded-Host', 'app.example.com');
$request->setTrustedProxies(
['0.0.0.0/0', '2000::/3'],
Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT
);
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertTrue($accepted);
}
public function test_forwarded_host_from_non_trusted_proxy_is_rejected_if_host_is_allowed(): void
{
// Arrange
$request = Request::create('https://evil.example.com/login');
$request->headers->set('X-Forwarded-Host', 'app.example.com');
$request->setTrustedProxies(
['1.2.3.4/32'], // Not a trusted proxy
Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PROTO | Request::HEADER_X_FORWARDED_PORT
);
// Act
$accepted = $this->accepts($request);
// Assert
$this->assertFalse($accepted);
}
public function test_health_check_endpoint_bypasses_host_validation(): void
{
// Arrange
$internalIpRequest = Request::create('https://0.0.0.0/health-check/up');
$localhostRequest = Request::create('http://localhost/health-check/up');
// Act
$internalIpAccepted = $this->accepts($internalIpRequest);
$localhostAccepted = $this->accepts($localhostRequest);
// Assert
$this->assertTrue($internalIpAccepted);
$this->assertTrue($localhostAccepted);
}
public function test_health_check_endpoint_clears_state_before_other_middleware_reads_the_host(): void
{
// Arrange
Request::setTrustedHosts(['^app\.example\.com$']);
// Act
$response = $this->get(self::CANONICAL.'/health-check/up', ['Host' => '0.0.0.0']);
// Assert
$response->assertSuccessful()
->assertExactJson(['success' => true]);
}
public function test_untrusted_host_renders_a_helpful_error(): void
{
// Arrange
$handler = app(ExceptionHandler::class);
$exception = new SuspiciousOperationException('Untrusted Host "evil.example.com".');
$request = Request::create('https://evil.example.com/login');
// Act
$response = $handler->render($request, $exception);
// Assert
$this->assertSame(400, $response->getStatusCode());
$this->assertStringContainsString('TRUSTED_HOSTS', (string) $response->getContent());
}
public function test_untrusted_host_returns_json_for_api_clients(): void
{
// Arrange
$handler = app(ExceptionHandler::class);
$exception = new SuspiciousOperationException('Untrusted Host "evil.example.com".');
$request = Request::create('https://evil.example.com/api/v1/users');
$request->headers->set('Accept', 'application/json');
// Act
$response = $handler->render($request, $exception);
// Assert
$this->assertSame(400, $response->getStatusCode());
$this->assertJson((string) $response->getContent());
$this->assertStringContainsString('TRUSTED_HOSTS', (string) $response->getContent());
}
}

View File

@@ -8,7 +8,6 @@ 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\Service\LocalizationService; use App\Service\LocalizationService;
use Brick\Money\Currency; use Brick\Money\Currency;
@@ -304,87 +303,4 @@ class LocalizationServiceTest extends TestCaseWithDatabase
// Assert // Assert
$this->assertSame('14:09', $formatted); $this->assertSame('14:09', $formatted);
} }
public function test_format_time_group_key_formats_a_day_key_with_the_date_format(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act
$formatted = $this->localizationService->formatTimeGroupKey('2001-02-03', TimeEntryAggregationType::Day);
// Assert
$this->assertSame('03/02/2001', $formatted);
}
public function test_format_time_group_key_returns_null_for_a_null_key(): void
{
// Act
$formatted = $this->localizationService->formatTimeGroupKey(null, TimeEntryAggregationType::Day);
// Assert
$this->assertNull($formatted);
}
public function test_format_time_group_key_formats_a_week_key_as_the_range_it_covers(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act
$formatted = $this->localizationService->formatTimeGroupKey('2026-07-27', TimeEntryAggregationType::Week);
// Assert
$this->assertSame('27/07/2026 - 02/08/2026', $formatted);
}
public function test_format_time_group_key_formats_a_week_range_from_the_weekday_of_its_own_key(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act
$sundayStart = $this->localizationService->formatTimeGroupKey('2026-07-26', TimeEntryAggregationType::Week);
$mondayStart = $this->localizationService->formatTimeGroupKey('2026-07-20', TimeEntryAggregationType::Week);
// Assert
$this->assertSame('26/07/2026 - 01/08/2026', $sundayStart);
$this->assertSame('20/07/2026 - 26/07/2026', $mondayStart);
}
public function test_format_time_group_key_formats_a_week_range_spanning_new_year(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act
$formatted = $this->localizationService->formatTimeGroupKey('2025-12-29', TimeEntryAggregationType::Week);
// Assert
$this->assertSame('29/12/2025 - 04/01/2026', $formatted);
}
public function test_format_time_group_key_formats_a_month_key_and_does_not_change_year_and_entity_group_types(): void
{
// Arrange
$this->localizationService->setDateFormat(DateFormat::SlashSeparatedDDMMYYYY);
// Act & Assert
$this->assertSame('February 2001', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month));
$this->assertSame('2001', $this->localizationService->formatTimeGroupKey('2001', TimeEntryAggregationType::Year));
$this->assertSame('some-uuid', $this->localizationService->formatTimeGroupKey('some-uuid', TimeEntryAggregationType::Project));
}
public function test_format_time_group_key_formats_a_month_key_independently_of_the_current_day_of_month(): void
{
// Arrange
// Note: the current day of the month must not leak into the parsed month. The 31st does
// not exist in April, so a leaked day overflows the date into the following month.
$this->travelTo(Carbon::create(2026, 8, 31, 12, 0, 0, 'UTC'));
// Act & Assert
$this->assertSame('February 2001', $this->localizationService->formatTimeGroupKey('2001-02', TimeEntryAggregationType::Month));
$this->assertSame('April 2026', $this->localizationService->formatTimeGroupKey('2026-04', TimeEntryAggregationType::Month));
$this->assertSame('December 2026', $this->localizationService->formatTimeGroupKey('2026-12', TimeEntryAggregationType::Month));
}
} }

View File

@@ -1302,111 +1302,4 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
], ],
], $result); ], $result);
} }
public function test_aggregate_time_entries_by_week_keys_each_group_by_the_first_day_of_the_week(): void
{
// Arrange
// 2026-07-20 is a Monday, 2026-07-26 is the Sunday of that week.
foreach (['2026-07-20', '2026-07-26', '2026-07-27'] as $day) {
TimeEntry::factory()->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 10)->create();
}
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Week,
null,
'UTC',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert
$this->assertSame([
'seconds' => 30,
'cost' => 0,
'grouped_type' => 'week',
'grouped_data' => [
[
'key' => '2026-07-20',
'seconds' => 20,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
[
'key' => '2026-07-27',
'seconds' => 10,
'cost' => 0,
'grouped_type' => null,
'grouped_data' => null,
],
],
], $result);
}
public function test_aggregate_time_entries_by_week_aligns_the_group_key_to_the_organization_week_start(): void
{
// Arrange
// With a Sunday week start the Sunday entry belongs to the following week instead.
foreach (['2026-07-20', '2026-07-26', '2026-07-27'] as $day) {
TimeEntry::factory()->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 10)->create();
}
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Week,
null,
'UTC',
Weekday::Sunday,
false,
null,
null,
true,
null,
null
);
// Assert
$this->assertSame(['2026-07-19', '2026-07-26'], array_column($result['grouped_data'], 'key'));
$this->assertSame([10, 20], array_column($result['grouped_data'], 'seconds'));
}
public function test_aggregate_time_entries_by_week_keys_a_week_spanning_new_year_by_its_start_in_the_earlier_year(): void
{
// Arrange
// Note: 2025-12-29 is the Monday of the week containing 1 January 2026
foreach (['2025-12-29', '2025-12-31', '2026-01-01', '2026-01-05'] as $day) {
TimeEntry::factory()->startWithDuration(Carbon::parse($day.' 09:00:00', 'UTC'), 10)->create();
}
$query = TimeEntry::query();
// Act
$result = $this->service->getAggregatedTimeEntries(
$query,
TimeEntryAggregationType::Week,
null,
'UTC',
Weekday::Monday,
false,
null,
null,
true,
null,
null
);
// Assert
$this->assertSame(['2025-12-29', '2026-01-05'], array_column($result['grouped_data'], 'key'));
// Three of the four entries fall in the week that straddles new year.
$this->assertSame([30, 10], array_column($result['grouped_data'], 'seconds'));
}
} }