Compare commits

..

1 Commits

Author SHA1 Message Date
Constantin Graf
819dfd5037 Allow Clockify import without Billable column 2026-06-24 12:11:46 +02:00
270 changed files with 1548 additions and 13159 deletions

View File

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

27
.github/VOUCHED.td vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 }}

3
.gitignore vendored
View File

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

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.
### 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
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.**
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.
We do accept contributions in the [documentation repository](https://github.com/solidtime-io/docs) f.e. to add new self-hosting guides.

View File

@@ -51,8 +51,7 @@ class TimeEntrySendStillRunningMailsCommand extends Command
])
->whereHas('user', function (Builder $query): void {
/** @var Builder<User> $query */
$query->where('is_placeholder', '=', false)
->where('send_time_entry_still_running_email', '=', true);
$query->where('is_placeholder', '=', false);
})
->orderBy('created_at', 'asc')
->chunk(500, function (Collection $timeEntries) use ($dryRun, &$sentMails): void {

View File

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

View File

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

View File

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

View File

@@ -6,10 +6,7 @@ namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Throwable;
class Handler extends ExceptionHandler
@@ -33,29 +30,6 @@ class Handler extends ExceptionHandler
$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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -124,10 +124,6 @@ class UserController extends Controller
$user->week_start = $request->getWeekStart();
}
if ($request->getSendTimeEntryStillRunningEmail() !== null) {
$user->send_time_entry_still_running_email = $request->getSendTimeEntryStillRunningEmail();
}
$user->save();
if ($emailToVerify !== null) {

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,7 +41,7 @@ class UserUpdateRequest extends BaseFormRequest
'max:255',
],
'email' => [
'email:rfc,strict',
'email',
'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
/** @var Builder<User> $query */
@@ -58,9 +58,6 @@ class UserUpdateRequest extends BaseFormRequest
'week_start' => [
Rule::enum(Weekday::class),
],
'send_time_entry_still_running_email' => [
'boolean',
],
];
}
@@ -84,13 +81,6 @@ class UserUpdateRequest extends BaseFormRequest
return $this->has('week_start') ? Weekday::from($this->input('week_start')) : null;
}
public function getSendTimeEntryStillRunningEmail(): ?bool
{
return $this->has('send_time_entry_still_running_email')
? $this->boolean('send_time_entry_still_running_email')
: null;
}
public function hasPhotoKey(): bool
{
return $this->has('photo');

View File

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

View File

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

View File

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

View File

@@ -36,8 +36,6 @@ class UserResource extends BaseResource
'timezone' => $this->resource->timezone,
/** @var Weekday $week_start Starting day of the week */
'week_start' => $this->resource->week_start->value,
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
];
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -42,7 +42,6 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property string $timezone
* @property bool $is_placeholder
* @property Weekday $week_start
* @property bool $send_time_entry_still_running_email
* @property string|null $profile_photo_path
* @property-read Organization|null $currentOrganization
* @property-read string $profile_photo_url
@@ -109,7 +108,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
'is_admin' => 'boolean',
'is_placeholder' => 'boolean',
'week_start' => Weekday::class,
'send_time_entry_still_running_email' => 'boolean',
];
/**
@@ -119,7 +117,6 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
*/
protected $attributes = [
'week_start' => Weekday::Monday,
'send_time_entry_still_running_email' => true,
];
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
@@ -25,12 +24,10 @@ class ClockifyProjectsImporter extends DefaultImporter
$header = $reader->getHeader();
$this->validateHeader($header);
$billableRateKey = $this->getBillableRateKey($header);
$tasksKey = $this->getTasksKey($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$clientId = null;
// Newer Clockify exports no longer contain a "Client" column.
if (($record['Client'] ?? '') !== '') {
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
@@ -46,13 +43,12 @@ class ClockifyProjectsImporter extends DefaultImporter
'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => isset($record['Estimated (h)']) && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
'archived_at' => $record['Status'] === 'Archived' ? Carbon::now() : null,
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
]);
}
if ($tasksKey !== null && $record[$tasksKey] !== '') {
$tasks = explode(', ', $record[$tasksKey]);
if ($record['Task'] !== '') {
$tasks = explode(', ', $record['Task']);
foreach ($tasks as $task) {
$this->taskImportHelper->getKey([
'name' => $task,
@@ -81,36 +77,17 @@ class ClockifyProjectsImporter extends DefaultImporter
{
$requiredFields = [
'Project',
'Client',
'Status',
'Visibility',
'Billability',
'Task',
];
foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
// Clockify names the tasks column "Task", "Tasks" or "Activities" depending on the export; accept any.
if ($this->getTasksKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Tasks');
}
}
/**
* Clockify names the tasks column differently depending on the export
* version: "Task" (older), "Tasks" (newer) or "Activities".
*
* @param array<string> $header
*/
private function getTasksKey(array $header): ?string
{
foreach (['Tasks', 'Task', 'Activities'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
}
/**

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Enums\TimeEntryType;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
@@ -55,7 +54,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$taskKey = $this->getTaskKey($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([
@@ -72,12 +70,8 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
// Clockify allows a project/task/client/tags/billable on breaks, but those are
// meaningless for non-work time. Detect breaks up front and skip creating any of
// that so a break can't spawn an orphan project/tag or inflate the import counts.
$isBreak = isset($record['Type']) && strtolower($record['Type']) === 'break';
$clientId = null;
if (! $isBreak && ($record['Client'] ?? '') !== '') {
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
@@ -86,7 +80,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$projectId = null;
$project = null;
$projectMember = null;
if (! $isBreak && $record['Project'] !== '') {
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'client_id' => $clientId,
@@ -102,9 +96,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]);
}
$taskId = null;
if (! $isBreak && $taskKey !== null && $record[$taskKey] !== '') {
if ($record['Task'] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record[$taskKey],
'name' => $record['Task'],
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
@@ -128,12 +122,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
}
$timeEntry->billable = $record['Billable'] === 'Yes';
}
if ($isBreak) {
// Breaks can not be billable or belong to a project/task (already skipped above)
$timeEntry->type = TimeEntryType::Break;
$timeEntry->billable = false;
}
$timeEntry->tags = $isBreak ? [] : $this->getTags($record['Tags']);
$timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true;
// Start
@@ -225,7 +214,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
{
$requiredFields = [
'Project',
'Client',
'Description',
'Task',
'User',
'Group',
'Email',
@@ -240,26 +231,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
// Clockify names the task column "Task" or "Activity" depending on the export; accept either.
if ($this->getTaskKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Task');
}
}
/**
* Clockify names the task column "Task" or "Activity" depending on the export version.
*
* @param array<string> $header
*/
private function getTaskKey(array $header): ?string
{
foreach (['Task', 'Activity'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
}
#[\Override]

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,14 +8,12 @@ use App\Enums\CurrencyFormat;
use App\Enums\DateFormat;
use App\Enums\IntervalFormat;
use App\Enums\NumberFormat;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeFormat;
use App\Models\Organization;
use Brick\Math\BigDecimal;
use Brick\Money\Money;
use Carbon\CarbonInterface;
use Carbon\CarbonInterval;
use Illuminate\Support\Carbon;
class LocalizationService
{
@@ -154,38 +152,6 @@ class LocalizationService
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
{
$this->dateFormat = $dateFormat;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -75,27 +75,6 @@ return [
'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'),
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
@@ -158,21 +137,6 @@ return [
'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

View File

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

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Database\Factories;
use App\Enums\TimeEntryType;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
@@ -34,7 +33,6 @@ class TimeEntryFactory extends Factory
'start' => $start,
'end' => $this->faker->dateTimeBetween($start, 'now'),
'billable' => $this->faker->boolean(),
'type' => TimeEntryType::Work,
'is_imported' => false,
'tags' => [],
'user_id' => User::factory(),
@@ -46,18 +44,6 @@ class TimeEntryFactory extends Factory
];
}
public function isBreak(): self
{
return $this->state(function (array $attributes): array {
return [
'type' => TimeEntryType::Break,
'billable' => false,
'project_id' => null,
'task_id' => null,
];
});
}
public function notBillable(): self
{
return $this->state(function (array $attributes): array {

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('time_entries', function (Blueprint $table): void {
$table->string('type')->default('work');
});
}
public function down(): void
{
Schema::table('time_entries', function (Blueprint $table): void {
$table->dropColumn('type');
});
}
};

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->boolean('breaks_enabled')->default(false)->after('prevent_overlapping_time_entries');
});
}
public function down(): void
{
Schema::table('organizations', function (Blueprint $table): void {
$table->dropColumn('breaks_enabled');
});
}
};

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

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->boolean('send_time_entry_still_running_email')->default(true)->after('week_start');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('send_time_entry_still_running_email');
});
}
};

View File

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

View File

@@ -1,278 +0,0 @@
import { expect, test } from '../playwright/fixtures';
import { PLAYWRIGHT_BASE_URL } from '../playwright/config';
import type { Page } from '@playwright/test';
import {
assertThatTimerHasStarted,
assertThatTimerIsStopped,
newTimeEntryResponse,
startOrStopTimerWithButton,
stoppedTimeEntryResponse,
} from './utils/currentTimeEntry';
import { createTimeEntryViaApi, updateOrganizationSettingViaApi } from './utils/api';
async function goToDashboard(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
}
function visibleBreakButton(page: Page) {
return page.getByRole('button', { name: 'Take a break' }).locator('visible=true').first();
}
// Breaks are disabled by default for new organizations, so enable them for the break flows.
// The tests that assert the disabled behaviour turn them back off explicitly.
test.beforeEach(async ({ ctx }) => {
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
});
test('test that switching to a break stops the work timer and starts a break entry', async ({
page,
}) => {
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await page.getByTestId('time_entry_description').fill('Work before break');
await Promise.all([
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
page.getByTestId('time_entry_description').press('Enter'),
]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
// Switch to break: stops the work entry and starts a break entry
await Promise.all([
newTimeEntryResponse(page, { description: '', type: 'break' }),
visibleBreakButton(page).click(),
]);
await expect(page.getByText('On break')).toBeVisible();
// The break bar offers a one-click resume that stops the break and restores
// the interrupted work context
await page.waitForTimeout(1500);
const resumeButton = page.getByRole('button', { name: 'Resume "Work before break"' });
await expect(resumeButton).toBeVisible();
await Promise.all([
stoppedTimeEntryResponse(page, { type: 'break' }),
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
resumeButton.click(),
]);
await assertThatTimerHasStarted(page);
await expect(page.getByTestId('time_entry_description')).toHaveValue('Work before break');
// Cleanup: stop the running entry
await Promise.all([
stoppedTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that stopping a break returns to an idle tracker where a fresh entry starts normally', async ({
page,
}) => {
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await page.getByTestId('time_entry_description').fill('Work before break');
await Promise.all([
newTimeEntryResponse(page, { description: 'Work before break', type: 'work' }),
page.getByTestId('time_entry_description').press('Enter'),
]);
await assertThatTimerHasStarted(page);
await page.waitForTimeout(1500);
// Switch to a break
await Promise.all([
newTimeEntryResponse(page, { description: '', type: 'break' }),
visibleBreakButton(page).click(),
]);
await expect(page.getByText('On break')).toBeVisible();
// Stopping the break just ends it — no modal, the tracker returns to the
// empty idle input with focus so typing starts a fresh entry
await page.waitForTimeout(1500);
await Promise.all([
stoppedTimeEntryResponse(page, { type: 'break' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
await expect(page.getByTestId('time_entry_description')).toHaveValue('');
await expect(page.getByTestId('time_entry_description')).toBeFocused();
// A fresh entry is the normal start flow: type + Enter
await page.getByTestId('time_entry_description').fill('Fresh after break');
await Promise.all([
newTimeEntryResponse(page, { description: 'Fresh after break', type: 'work' }),
page.getByTestId('time_entry_description').press('Enter'),
]);
await assertThatTimerHasStarted(page);
// Cleanup: stop the running entry
await Promise.all([
stoppedTimeEntryResponse(page, { description: 'Fresh after break', type: 'work' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that the more options dropdown can start a break directly', async ({ page }) => {
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
// Start a break straight from the more options dropdown (no create modal)
await page.getByRole('button', { name: 'Time entry actions' }).click();
await Promise.all([
newTimeEntryResponse(page, { description: '', type: 'break' }),
page.getByRole('menuitem', { name: 'Start Break' }).click(),
]);
await expect(page.getByText('On break')).toBeVisible();
// Without interrupted work there is nothing to resume, so no resume button is offered
await expect(page.getByRole('button', { name: /^Resume/ })).toHaveCount(0);
// Cleanup: stop the break
await page.waitForTimeout(1500);
await Promise.all([
stoppedTimeEntryResponse(page, { type: 'break' }),
startOrStopTimerWithButton(page),
]);
await assertThatTimerIsStopped(page);
});
test('test that disabling breaks hides every break-creation entry point', async ({ page, ctx }) => {
// Breaks disabled for the organization (delivered to the client via the organization endpoint)
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: false });
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Regular work' });
// Calendar: the empty-slot context menu offers "Create Time Entry" but no "Add Break",
// and the edit modal drops the work-time/break type selector
await page.goto(PLAYWRIGHT_BASE_URL + '/calendar');
await expect(page.locator('.fc')).toBeVisible();
const event = page.locator('.fc-event').filter({ hasText: 'Regular work' }).first();
await event.scrollIntoViewIfNeeded();
await expect(event).toBeVisible();
const box = await event.boundingBox();
expect(box).not.toBeNull();
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height + 40, { button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Create Time Entry' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Add Break' })).toHaveCount(0);
await page.keyboard.press('Escape');
await event.click({ button: 'right' });
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(
page.getByRole('dialog').getByRole('combobox').filter({ hasText: 'Work time' })
).toHaveCount(0);
await page.keyboard.press('Escape');
// Timesheet: no break row is shown
await page.goto(PLAYWRIGHT_BASE_URL + '/timesheet');
await expect(page.getByRole('button', { name: 'Add row' }).first()).toBeVisible();
await expect(page.getByText('Break', { exact: true })).toHaveCount(0);
// Dashboard tracker: no "Start Break" in the more options dropdown
await goToDashboard(page);
await expect(page.getByTestId('time_entry_description')).toBeEditable();
await page.getByRole('button', { name: 'Time entry actions' }).click();
await expect(page.getByRole('menuitem', { name: 'Switch to simple mode' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'Start Break' })).toHaveCount(0);
});
// The employee fixture registers a second user and accepts an invitation via Mailpit,
// which does not fit into the default per-test timeout.
test.describe('Org-level breaks setting', () => {
test.describe.configure({ timeout: 60000 });
test('test that the org-level breaks setting is respected for employees', async ({
ctx,
employee,
}) => {
const employeePage = employee.page;
// Breaks enabled (via beforeEach): the employee sees "Start Break" in the more options dropdown
await employeePage.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employeePage.getByTestId('dashboard_view')).toBeVisible();
await employeePage.getByRole('button', { name: 'Time entry actions' }).click();
await expect(
employeePage.getByRole('menuitem', { name: 'Switch to simple mode' })
).toBeVisible();
await expect(employeePage.getByRole('menuitem', { name: 'Start Break' })).toBeVisible();
await employeePage.keyboard.press('Escape');
// The owner disables breaks for the whole organization
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: false });
// The employee reloads: "Start Break" is gone from the dropdown
await employeePage.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await expect(employeePage.getByTestId('dashboard_view')).toBeVisible();
await employeePage.getByRole('button', { name: 'Time entry actions' }).click();
await expect(
employeePage.getByRole('menuitem', { name: 'Switch to simple mode' })
).toBeVisible();
await expect(employeePage.getByRole('menuitem', { name: 'Start Break' })).toHaveCount(0);
await employeePage.keyboard.press('Escape');
// With an active timer the break (coffee) button is not shown either
await employeePage.getByTestId('time_entry_description').fill('Employee work');
await Promise.all([
newTimeEntryResponse(employeePage, { description: 'Employee work', type: 'work' }),
employeePage.getByTestId('time_entry_description').press('Enter'),
]);
await assertThatTimerHasStarted(employeePage);
await expect(employeePage.getByRole('button', { name: 'Take a break' })).toHaveCount(0);
// Cleanup: stop the running entry
await Promise.all([
stoppedTimeEntryResponse(employeePage, { description: 'Employee work', type: 'work' }),
startOrStopTimerWithButton(employeePage),
]);
await assertThatTimerIsStopped(employeePage);
});
});
test('test that mass update warns about selected breaks and reports skipped entries instead of success', async ({
page,
ctx,
}) => {
// One work entry and one break: a billable mass update applies to the work
// entry but the server skips the break entirely — the UI must say so.
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Mass update work entry' });
await createTimeEntryViaApi(ctx, { duration: '30min', type: 'break' });
await page.goto(PLAYWRIGHT_BASE_URL + '/time');
await expect(page.locator('[data-testid="time_entry_row"]')).toHaveCount(2);
await page.getByLabel('Select All').click();
await expect(page.getByText('2 selected')).toBeVisible();
await page.getByRole('button', { name: 'Edit' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// No warning while the changeset is compatible with breaks
await expect(page.getByTestId('mass_update_break_warning')).not.toBeVisible();
// Making the entries billable is break-incompatible → warning appears
await page
.getByRole('dialog')
.getByRole('combobox')
.filter({ hasText: 'Set billable status' })
.click();
await page.getByRole('option', { name: 'Billable', exact: true }).click();
await expect(page.getByTestId('mass_update_break_warning')).toBeVisible();
await expect(page.getByTestId('mass_update_break_warning')).toContainText('skipped entirely');
// Submit: the work entry updates, the break is skipped, and the toast
// reports the skip instead of claiming success for all entries
const [massUpdateResponse] = await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'PATCH' &&
response.status() === 200
),
page.getByRole('button', { name: 'Update Time Entries' }).click(),
]);
const massUpdateBody = await massUpdateResponse.json();
expect(massUpdateBody.success.length).toBe(1);
expect(massUpdateBody.error.length).toBe(1);
await expect(page.getByText('1 of 2 time entries was skipped')).toBeVisible();
});

View File

@@ -2874,54 +2874,3 @@ test.describe('Daily Total After Create', () => {
}).toPass({ timeout: 5000 });
});
});
test('test that calendar context menu can add a break that fills the gap between two entries', async ({
page,
ctx,
}) => {
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
// Two work entries today (09:00-10:00 and 11:00-12:00 UTC) with a one hour gap
const today = new Date().toISOString().slice(0, 10);
const gapStart = `${today}T10:00:00Z`;
const gapEnd = `${today}T11:00:00Z`;
await createTimeEntryWithTimestampsViaApi(ctx, {
start: `${today}T09:00:00Z`,
end: gapStart,
description: 'Gap work A',
});
await createTimeEntryWithTimestampsViaApi(ctx, {
start: gapEnd,
end: `${today}T12:00:00Z`,
description: 'Gap work B',
});
await goToCalendar(page);
const eventA = page.locator('.fc-event').filter({ hasText: 'Gap work A' }).first();
await eventA.scrollIntoViewIfNeeded();
await expect(eventA).toBeVisible();
// Right-click just below entry A (inside the gap, in the same day column)
const box = await eventA.boundingBox();
expect(box).not.toBeNull();
await page.mouse.click(box!.x + box!.width / 2, box!.y + box!.height + 15, {
button: 'right',
});
await expect(page.getByRole('menu')).toBeVisible();
await page.getByRole('menuitem', { name: 'Add Break' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
// The break is prefilled to fill the gap exactly
const [createResponse] = await Promise.all([
page.waitForResponse(
async (response) =>
response.url().includes('/time-entries') &&
response.request().method() === 'POST' &&
response.status() === 201 &&
(await response.json()).data.type === 'break'
),
page.getByRole('button', { name: 'Add Break' }).click(),
]);
const body = await createResponse.json();
expect(body.data.start).toBe(gapStart);
expect(body.data.end).toBe(gapEnd);
});

View File

@@ -8,7 +8,7 @@ import {
createProjectViaApi,
createPublicProjectViaApi,
} from './utils/api';
import { clearTableState, getTableRowNames } from './utils/table';
import { getTableRowNames } from './utils/table';
async function goToClientsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/clients');
@@ -210,12 +210,18 @@ test('test that client context menu delete deletes the client', async ({ page, c
// Sorting Tests
// =============================================
async function clearClientTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('client-table-state');
});
}
test('test that sorting clients by name and status works', async ({ page, ctx }) => {
await createClientViaApi(ctx, { name: 'AAA SortClient' });
await createClientViaApi(ctx, { name: 'ZZZ SortClient' });
await goToClientsOverview(page);
await clearTableState(page, 'client-table-state');
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
@@ -240,14 +246,14 @@ test('test that sorting clients by name and status works', async ({ page, ctx })
test('test that sorting clients by project count works', async ({ page, ctx }) => {
const clientWithMany = await createClientViaApi(ctx, { name: 'ManyProjects Client' });
await createClientViaApi(ctx, { name: 'NoProjects Client' });
const clientWithNone = await createClientViaApi(ctx, { name: 'NoProjects Client' });
// Create projects for the first client
await createProjectViaApi(ctx, { name: 'Proj1', client_id: clientWithMany.id });
await createProjectViaApi(ctx, { name: 'Proj2', client_id: clientWithMany.id });
await goToClientsOverview(page);
await clearTableState(page, 'client-table-state');
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
@@ -268,7 +274,7 @@ test('test that sorting clients by project count works', async ({ page, ctx }) =
test('test that client sort state persists after page reload', async ({ page }) => {
await goToClientsOverview(page);
await clearTableState(page, 'client-table-state');
await clearClientTableState(page);
await page.reload();
const table = page.getByTestId('client_table');
@@ -368,119 +374,3 @@ test.describe('Employee Clients Restrictions', () => {
await expect(employee.page.getByText(clientName)).toBeVisible({ timeout: 10000 });
});
});
// ──────────────────────────────────────────────────
// Pagination Tests
// ──────────────────────────────────────────────────
test.describe('Clients Pagination', () => {
test.describe.configure({ timeout: 30000 });
test('test that client table paginates when there are more than 15 clients', async ({
page,
ctx,
}) => {
// Create 17 clients with zero-padded names so alphabetical sort is predictable.
// Page size is 15 → page 1 shows indices 00–14, page 2 shows 15–16.
const seed = Math.floor(Math.random() * 100000);
const prefix = `PaginationClient ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToClientsOverview(page);
await clearTableState(page, 'client-table-state');
await page.reload();
// Default sort is name asc; first 15 clients (00–14) on page 1.
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
// Client 15 should be on page 2, not visible on page 1.
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Exactly 15 data rows mounted on page 1.
await expect(page.getByRole('row')).toHaveCount(15);
// Navigation to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByText(prefix + '00')).not.toBeVisible();
// Page 2 contains the remaining 2 clients.
await expect(page.getByRole('row')).toHaveCount(2);
// Back to page 1 via Previous Page.
await page.getByRole('button', { name: 'Previous Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// First / Last page jumps.
await page.getByRole('button', { name: 'Last Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await page.getByRole('button', { name: 'First Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Direct page-number button navigation + selected state.
await page.getByRole('button', { name: 'Page 2' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
'aria-current',
'page'
);
});
test('test that client pagination is not shown when there are 15 or fewer clients', async ({
page,
ctx,
}) => {
await Promise.all(
Array.from({ length: 10 }, (_, i) =>
createClientViaApi(ctx, {
name: `FewClient ${Math.floor(Math.random() * 100000)} ${i}`,
})
)
);
await goToClientsOverview(page);
await clearTableState(page, 'client-table-state');
await page.reload();
await expect(page.getByTestId('client_table')).toBeVisible();
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
});
test('test that changing the sort resets client pagination to page 1', async ({
page,
ctx,
}) => {
const seed = Math.floor(Math.random() * 100000);
const prefix = `SortPagClient ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createClientViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToClientsOverview(page);
await clearTableState(page, 'client-table-state');
await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
// Go to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Sort by name descending.
const table = page.getByTestId('client_table');
const nameHeader = table.getByText('Name').first();
await nameHeader.click();
// Pagination reset to page 1; desc order → 16, 15 visible, 00 on page 2.
await expect(page.getByText(prefix + '16')).toBeVisible();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByText(prefix + '00')).not.toBeVisible();
});
});

View File

@@ -11,7 +11,7 @@ import {
updateMemberBillableRateViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
import { clearTableState, getTableRowNames } from './utils/table';
import { getTableRowNames } from './utils/table';
// Tests that invite + accept members need more time
test.describe.configure({ timeout: 45000 });
@@ -779,18 +779,20 @@ test('test that accepted invitation disappears from invitations tab', async ({ p
// Sorting Tests
// =============================================
// Helper to clear localStorage before tests that check sorting
async function clearMemberTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('member-table-state');
});
}
test('test that sorting members by name, role, and status works', async ({ page, ctx }) => {
// Create two placeholder members with names that sort predictably around "John Doe".
// Seeded alphabetically a second apart: created_at only has second precision and
// same-second rows fall back to a random UUID order. The spacing is what makes the
// API order (created_at desc: ZZZ, AAA, John) deterministic, so the tie-break
// assertions below are testing the tie-break rather than a coin flip.
// Create two placeholder members with names that sort predictably around "John Doe"
await createPlaceholderMemberViaImportApi(ctx, 'AAA SortFirst');
await page.waitForTimeout(1100);
await createPlaceholderMemberViaImportApi(ctx, 'ZZZ SortLast');
await goToMembersPage(page);
await clearTableState(page, 'member-table-state');
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');
@@ -812,24 +814,20 @@ test('test that sorting members by name, role, and status works', async ({ page,
const ownerIdx = names.indexOf('John Doe');
const placeholderIdx = names.indexOf('AAA SortFirst');
expect(ownerIdx).toBeLessThan(placeholderIdx);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
await roleHeader.click(); // desc: Placeholder first
names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
// -- Status sorting --
const statusHeader = table.getByText('Status').first();
await statusHeader.click(); // asc: Active(0) < Inactive(1)
names = await getTableRowNames(table);
expect(names.indexOf('John Doe')).toBeLessThan(names.indexOf('AAA SortFirst'));
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
await statusHeader.click(); // desc: Inactive first
names = await getTableRowNames(table);
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('John Doe'));
expect(names.indexOf('AAA SortFirst')).toBeLessThan(names.indexOf('ZZZ SortLast'));
// -- Email: just verify sort indicator appears --
const emailHeader = table.getByText('Email').first();
@@ -839,7 +837,7 @@ test('test that sorting members by name, role, and status works', async ({ page,
test('test that member sort state persists after page reload', async ({ page }) => {
await goToMembersPage(page);
await clearTableState(page, 'member-table-state');
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');
@@ -877,7 +875,7 @@ test('test that sorting members by billable rate works', async ({ page, ctx }) =
await updateMemberBillableRateViaApi(ctx, lowRateMember!.id, 5000);
await goToMembersPage(page);
await clearTableState(page, 'member-table-state');
await clearMemberTableState(page);
await page.reload();
const table = page.getByTestId('member_table');

View File

@@ -4,7 +4,7 @@ import { PLAYWRIGHT_BASE_URL, TEST_USER_PASSWORD } from '../playwright/config';
async function goToOrganizationSettings(page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/dashboard');
await page.locator('[data-testid="organization_switcher"]:visible').click();
await page.getByRole('menuitem', { name: 'Organization Settings' }).click();
await page.getByText('Organization Settings').click();
}
async function createTimeEntry(page, duration: string) {

View File

@@ -20,12 +20,6 @@ function profileInformationForm(page: Page) {
.locator('xpath=ancestor::*[descendant::form][1]');
}
function notificationSettingsForm(page: Page) {
return page
.getByRole('heading', { name: 'Notifications', exact: true })
.locator('xpath=ancestor::*[descendant::form][1]');
}
async function saveProfileForm(page: Page): Promise<void> {
const form = profileInformationForm(page);
await form.getByRole('button', { name: 'Save' }).click();
@@ -56,22 +50,6 @@ test('week-start change persists across reload', async ({ page }) => {
await expect(page.getByLabel('Start of the week')).toHaveValue('sunday');
});
test('still-running email notification setting persists across reload', async ({ page }) => {
await goToProfilePage(page);
const form = notificationSettingsForm(page);
const checkbox = form.getByLabel('Still-running time entry reminders');
await expect(checkbox).toBeChecked();
await checkbox.uncheck();
await form.getByRole('button', { name: 'Save' }).click();
await expect(form.getByText('Saved.', { exact: true })).toBeVisible();
await page.reload();
await expect(
notificationSettingsForm(page).getByLabel('Still-running time entry reminders')
).not.toBeChecked();
});
test('profile photo can be uploaded, persists across reload, and can be removed', async ({
page,
}) => {

View File

@@ -13,12 +13,18 @@ import {
archiveProjectViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
import { clearTableState, getSeededRowOrder } from './utils/table';
async function goToProjectsOverview(page: Page) {
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
}
// Helper to clear localStorage before tests that check persistence
async function clearProjectTableState(page: Page) {
await page.evaluate(() => {
localStorage.removeItem('project-table-state');
});
}
// Create new project via modal
test('test that creating and deleting a new project via the modal works', async ({ page }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
@@ -78,7 +84,7 @@ test('test that archiving and unarchiving projects works', async ({ page, ctx })
await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await clearProjectTableState(page);
await page.reload();
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
@@ -111,43 +117,6 @@ test('test that archiving and unarchiving projects works', async ({ page, ctx })
await expect(page.getByText(newProjectName)).toBeVisible();
});
test('test that the client can be changed in the edit project modal', async ({ page, ctx }) => {
const projectName = 'Edit Client Project ' + Math.floor(1 + Math.random() * 100000);
const clientName = 'Assigned Client ' + Math.floor(1 + Math.random() * 100000);
await createProjectViaApi(ctx, { name: projectName });
const client = await createClientViaApi(ctx, { name: clientName });
await page.goto(PLAYWRIGHT_BASE_URL + '/projects');
await expect(page.getByText(projectName)).toBeVisible({ timeout: 10000 });
// Open the project's Edit modal.
await page.getByRole('row').first().getByRole('button').click();
await page.getByRole('menuitem').getByText('Edit').first().click();
await expect(page.getByRole('dialog')).toBeVisible();
// Open the client dropdown (currently "No Client"), confirm it focuses, and pick the client.
await page.getByRole('dialog').getByRole('button', { name: 'No Client' }).click();
const clientSearch = page.getByPlaceholder('Search for a client...');
await expect(clientSearch).toBeFocused();
await clientSearch.fill(clientName);
await page.getByRole('option', { name: clientName }).click();
// The trigger updates to the chosen client.
await expect(page.getByRole('dialog').getByRole('button', { name: clientName })).toBeVisible();
// Saving persists the client assignment.
await Promise.all([
page.getByRole('button', { name: 'Update Project' }).click(),
page.waitForResponse(
async (response) =>
response.url().includes('/projects/') &&
response.request().method() === 'PUT' &&
response.status() === 200 &&
(await response.json()).data.client_id === client.id
),
]);
});
test('test that updating billable rate works with existing time entries', async ({ page, ctx }) => {
const newProjectName = 'New Project ' + Math.floor(1 + Math.random() * 10000);
const newBillableRate = Math.round(Math.random() * 10000);
@@ -474,7 +443,7 @@ test('test that sorting projects by all columns works', async ({ page, ctx }) =>
});
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await clearProjectTableState(page);
await page.reload();
await expect(page.getByTestId('project_table')).toBeVisible();
await expect(page.getByText('AAA Project')).toBeVisible();
@@ -603,7 +572,7 @@ test('test that filtering projects by status works', async ({ page, ctx }) => {
await createProjectViaApi(ctx, { name: newProjectName });
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await clearProjectTableState(page);
await page.reload();
await expect(page.getByText(newProjectName)).toBeVisible({ timeout: 10000 });
@@ -634,7 +603,7 @@ test('test that filtering projects by status works', async ({ page, ctx }) => {
test('test that filter state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await clearProjectTableState(page);
await page.reload();
// Apply Active status filter
@@ -650,96 +619,9 @@ test('test that filter state persists after page reload', async ({ page }) => {
await expect(page.getByTestId('status-filter-badge')).toBeVisible();
});
test('test that projects without a client or estimate are ordered by name at the bottom', async ({
page,
ctx,
}) => {
// Seeded a second apart: created_at only has second precision and same-second rows
// fall back to a random UUID order. The spacing makes the API order of the clientless
// rows (created_at desc: ZZZ, AAA) deterministic and different from the alphabetical
// order the name tie-break should produce.
await createProjectViaApi(ctx, { name: 'AAA Tiebreak Project' });
await page.waitForTimeout(1100);
await createProjectViaApi(ctx, { name: 'ZZZ Tiebreak Project' });
const clientAardvark = await createClientViaApi(ctx, { name: 'Aardvark Co' });
const clientZulu = await createClientViaApi(ctx, { name: 'Zulu Co' });
const projectM = await createProjectViaApi(ctx, {
name: 'MMM Tiebreak Project',
client_id: clientAardvark.id,
estimated_time: 36000, // 10h, 1h tracked below = 10%
});
await createTimeEntryViaApi(ctx, { duration: '1h', projectId: projectM.id });
const projectN = await createProjectViaApi(ctx, {
name: 'NNN Tiebreak Project',
client_id: clientZulu.id,
estimated_time: 14400, // 4h, 2h tracked below = 50%
});
await createTimeEntryViaApi(ctx, { duration: '2h', projectId: projectN.id });
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await page.reload();
const table = page.getByTestId('project_table');
await expect(table).toBeVisible();
const seeded = [
'AAA Tiebreak Project',
'MMM Tiebreak Project',
'NNN Tiebreak Project',
'ZZZ Tiebreak Project',
];
const getOrder = () => getSeededRowOrder(table, seeded);
// -- Client: empty rows last in both directions, alphabetical among themselves --
const clientHeader = table.locator('.select-none', { hasText: 'Client' }).first();
await clientHeader.click();
await expect
.poll(getOrder)
.toEqual([
'MMM Tiebreak Project',
'NNN Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
await clientHeader.click();
await expect
.poll(getOrder)
.toEqual([
'NNN Tiebreak Project',
'MMM Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
// -- Progress: same, and the first click sorts highest first --
const progressHeader = table.locator('.select-none', { hasText: 'Progress' }).first();
await progressHeader.click();
await expect
.poll(getOrder)
.toEqual([
'NNN Tiebreak Project',
'MMM Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
await progressHeader.click();
await expect
.poll(getOrder)
.toEqual([
'MMM Tiebreak Project',
'NNN Tiebreak Project',
'AAA Tiebreak Project',
'ZZZ Tiebreak Project',
]);
});
test('test that sort state persists after page reload', async ({ page }) => {
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await clearProjectTableState(page);
await page.reload();
// Click on Name header twice to sort descending
@@ -1172,152 +1054,3 @@ test.describe('Employee Billable Rate Visibility', () => {
await expect(projectRow).toContainText('200');
});
});
// ──────────────────────────────────────────────────
// Pagination Tests
// ──────────────────────────────────────────────────
test.describe('Projects Pagination', () => {
test.describe.configure({ timeout: 30000 });
test('test that project table paginates when there are more than 15 projects', async ({
page,
ctx,
}) => {
// Create 17 projects with zero-padded names so alphabetical sort is predictable.
// Page size is 15 → page 1 shows indices 00–14, page 2 shows 15–16.
const seed = Math.floor(Math.random() * 100000);
const prefix = `PaginationProj ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await page.reload();
// Default sort is name asc; first 15 projects (00–14) should be on page 1.
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
// Project 15 should be on page 2, not visible on page 1.
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Exactly 15 data rows should be mounted on page 1.
await expect(page.getByRole('row')).toHaveCount(15);
// Go to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await expect(page.getByText(prefix + '00')).not.toBeVisible();
// Page 2 contains the remaining 2 projects (15, 16).
await expect(page.getByRole('row')).toHaveCount(2);
// Return to page 1 via Previous Page.
await page.getByRole('button', { name: 'Previous Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Jump to last page then back to first page.
await page.getByRole('button', { name: 'Last Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
await page.getByRole('button', { name: 'First Page' }).click();
await expect(page.getByText(prefix + '00')).toBeVisible();
await expect(page.getByText(prefix + '15')).not.toBeVisible();
// Direct page-number button navigation.
await page.getByRole('button', { name: 'Page 2' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Page 2 button should be marked as selected.
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
'aria-current',
'page'
);
});
test('test that project pagination is not shown when there are 15 or fewer projects', async ({
page,
ctx,
}) => {
await Promise.all(
Array.from({ length: 10 }, (_, i) =>
createProjectViaApi(ctx, {
name: `FewProj ${Math.floor(Math.random() * 100000)} ${i}`,
})
)
);
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await page.reload();
await expect(page.getByTestId('project_table')).toBeVisible();
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
});
test('test that changing the sort resets pagination to page 1', async ({ page, ctx }) => {
const seed = Math.floor(Math.random() * 100000);
const prefix = `SortPagProj ${seed} `;
await Promise.all(
Array.from({ length: 17 }, (_, i) =>
createProjectViaApi(ctx, { name: prefix + String(i).padStart(2, '0') })
)
);
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await page.reload();
await expect(page.getByText(prefix + '00')).toBeVisible({ timeout: 10000 });
// Go to page 2.
await page.getByRole('button', { name: 'Next Page' }).click();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Sort by name descending: header click toggles asc → desc.
const nameHeader = page
.locator('[data-testid="project_table"] .select-none', { hasText: 'Name' })
.first();
await nameHeader.click();
// After sorting, pagination resets to page 1; desc order → 16, 15, ... 02 visible.
await expect(page.getByText(prefix + '16')).toBeVisible();
await expect(page.getByText(prefix + '15')).toBeVisible();
// Index 00 should now be on page 2 (last in desc order).
await expect(page.getByText(prefix + '00')).not.toBeVisible();
});
});
test('test that searching projects by name works', async ({ page, ctx }) => {
const suffix = Math.floor(1 + Math.random() * 10000);
const matchingProjectName = 'Searchable Project ' + suffix;
const otherProjectName = 'Unrelated Work ' + suffix;
await createProjectViaApi(ctx, { name: matchingProjectName });
await createProjectViaApi(ctx, { name: otherProjectName });
await goToProjectsOverview(page);
await clearTableState(page, 'project-table-state');
await page.reload();
await expect(page.getByText(matchingProjectName)).toBeVisible({ timeout: 10000 });
await expect(page.getByText(otherProjectName)).toBeVisible();
const searchInput = page.getByRole('searchbox', { name: 'Search projects' });
// Searching is case insensitive and matches part of the name
await searchInput.fill('SEARCHABLE');
await expect(page.getByText(matchingProjectName)).toBeVisible();
await expect(page.getByText(otherProjectName)).not.toBeVisible();
// A term that matches nothing empties the table
await searchInput.fill('no project has this name');
await expect(page.getByText(matchingProjectName)).not.toBeVisible();
await expect(page.getByText(otherProjectName)).not.toBeVisible();
await expect(page.getByText('No matching projects')).toBeVisible();
await expect(page.getByRole('button', { name: 'Create your First Project' })).not.toBeVisible();
// Clearing the search restores both projects
await searchInput.fill('');
await expect(page.getByText(matchingProjectName)).toBeVisible();
await expect(page.getByText(otherProjectName)).toBeVisible();
});

View File

@@ -717,108 +717,3 @@ test('test that keyboard navigation works in multiselect dropdown', async ({ pag
page.getByRole('button', { name: 'Projects' }).first().getByText('1')
).toBeVisible();
});
// ──────────────────────────────────────────────────
// Pagination Tests
// ──────────────────────────────────────────────────
test.describe('Reporting Detailed Pagination', () => {
test('test that detailed reporting paginates when there are more than 15 time entries', async ({
page,
ctx,
}) => {
// The detailed report paginates server-side with a page limit of 15.
// Create 17 time entries on a single project so we get exactly 2 pages.
const seed = Math.floor(Math.random() * 100000);
const projectName = `ReportPagProj ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
const descriptions = Array.from(
{ length: 17 },
(_, i) => `ReportPagEntry ${String(i).padStart(2, '0')} ${seed}`
);
await Promise.all(
descriptions.map((description) =>
createTimeEntryViaApi(ctx, {
description,
duration: '30min',
projectId: project.id,
})
)
);
await goToReportingDetailed(page);
await expect(page.getByText(descriptions[0]!).first()).toBeVisible({
timeout: 10000,
});
// Pagination nav should be rendered.
await expect(page.getByRole('button', { name: 'Next Page' })).toBeVisible();
// Collect which descriptions are currently visible on page 1.
const visiblePage1 = new Set<string>();
for (const description of descriptions) {
if ((await page.getByText(description).count()) > 0) {
visiblePage1.add(description);
}
}
// The page limit is 15 → exactly 15 entries visible on page 1.
expect(visiblePage1.size).toBe(15);
// Go to page 2 and wait for the server fetch.
await Promise.all([
page.getByRole('button', { name: 'Next Page' }).click(),
waitForDetailedReportingUpdate(page),
]);
const visiblePage2 = new Set<string>();
for (const description of descriptions) {
if ((await page.getByText(description).count()) > 0) {
visiblePage2.add(description);
}
}
// Page 2 should hold the remaining 2 entries, disjoint from page 1.
expect(visiblePage2.size).toBe(2);
for (const description of visiblePage2) {
expect(visiblePage1.has(description)).toBe(false);
}
// Across both pages, all 17 entries should have been visible.
expect(visiblePage1.size + visiblePage2.size).toBe(17);
// Page 2 button is selected.
await expect(page.getByRole('button', { name: 'Page 2' })).toHaveAttribute(
'aria-current',
'page'
);
// Previous page returns to page 1.
await Promise.all([
page.getByRole('button', { name: 'Previous Page' }).click(),
waitForDetailedReportingUpdate(page),
]);
expect((await page.getByText(descriptions[0]!).count()) > 0).toBe(true);
});
test('test that reporting pagination is not shown when there are 15 or fewer time entries', async ({
page,
ctx,
}) => {
const seed = Math.floor(Math.random() * 100000);
const projectName = `FewEntriesProj ${seed}`;
const project = await createProjectViaApi(ctx, { name: projectName });
await Promise.all(
Array.from({ length: 5 }, (_, i) =>
createTimeEntryViaApi(ctx, {
description: `FewEntries ${i} ${seed}`,
duration: '30min',
projectId: project.id,
})
)
);
await goToReportingDetailed(page);
await expect(page.getByText(`FewEntries 0 ${seed}`).first()).toBeVisible({
timeout: 10000,
});
await expect(page.getByRole('button', { name: 'Next Page' })).toHaveCount(0);
});
});

View File

@@ -1,69 +0,0 @@
import { expect } from '@playwright/test';
import { test } from '../playwright/fixtures';
import { goToReportingDetailed, waitForDetailedReportingUpdate } from './utils/reporting';
import { createTimeEntryWithTagViaApi } from './utils/api';
// Each test registers a new user and creates test data via the API
test.describe.configure({ timeout: 30000 });
test('detailed reporting: "Does Not Contain" excludes entries with the selected tag', async ({
page,
ctx,
}) => {
const tagA = 'MatchTagA ' + Math.floor(Math.random() * 10000);
const tagB = 'MatchTagB ' + Math.floor(Math.random() * 10000);
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
await goToReportingDetailed(page);
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
// Open the Tags dropdown, select tagA, then switch the match mode to "Does Not Contain"
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('option').filter({ hasText: tagA }).click(),
]);
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
]);
await page.keyboard.press('Escape');
// The entry with tagA is excluded; the entry with tagB remains
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
});
test('detailed reporting: toggling between "Contains" and "Does Not Contain" flips the result', async ({
page,
ctx,
}) => {
const tagA = 'ToggleTagA ' + Math.floor(Math.random() * 10000);
const tagB = 'ToggleTagB ' + Math.floor(Math.random() * 10000);
await createTimeEntryWithTagViaApi(ctx, tagA, '1h');
await createTimeEntryWithTagViaApi(ctx, tagB, '2h');
await goToReportingDetailed(page);
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('option').filter({ hasText: tagA }).click(),
]);
// "Contains" tagA -> only the tagA entry is listed
await page.keyboard.press('Escape');
await expect(page.getByText(`Entry with tag ${tagA}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagB}`)).toHaveCount(0);
// "Does Not Contain" tagA -> flips to the tagB entry
await page.getByRole('button', { name: 'Tags' }).click();
await Promise.all([
waitForDetailedReportingUpdate(page),
page.getByRole('radio', { name: 'Does Not Contain', exact: true }).click(),
]);
await page.keyboard.press('Escape');
await expect(page.getByText(`Entry with tag ${tagB}`).first()).toBeVisible();
await expect(page.getByText(`Entry with tag ${tagA}`)).toHaveCount(0);
});

View File

@@ -10,7 +10,6 @@ import {
createTimeEntryWithTagViaApi,
createTimeEntryWithBillableStatusViaApi,
createBareTimeEntryViaApi,
createTimeEntryOnDateViaApi,
createPublicProjectViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
@@ -97,37 +96,6 @@ test('test that project multiselect search filters the option list', async ({ pa
await page.keyboard.press('Escape');
});
test('test that the project filter virtualizes a long list (renders only a window)', async ({
page,
ctx,
}) => {
// Create many projects so the dropdown must virtualize rather than render all of them.
const projectNames = Array.from(
{ length: 80 },
(_, i) => `VirtProj ${String(i).padStart(2, '0')}`
);
await Promise.all(projectNames.map((name) => createProjectViaApi(ctx, { name })));
await goToReporting(page);
await expect(page.getByRole('button', { name: 'Export' })).toBeVisible();
await page.getByRole('button', { name: 'Projects' }).first().click();
// Only a small window of options is mounted, far fewer than the 80+ projects that exist.
await expect(page.getByRole('option').first()).toBeVisible();
const renderedCount = await page.getByRole('option').count();
expect(renderedCount).toBeGreaterThan(0);
expect(renderedCount).toBeLessThan(60);
// Virtualization must not drop options: searching narrows the list to the one deep match.
// Wait for the filtered count to settle to 1 before asserting — checking the option while
// the virtualizer is still re-rendering can transiently match a stale row (Firefox CI flake).
await page.getByPlaceholder('Search for a Project...').fill('VirtProj 79');
await expect(page.getByRole('option')).toHaveCount(1);
await expect(page.getByRole('option')).toContainText('VirtProj 79');
await page.keyboard.press('Escape');
});
test('test that selecting multiple projects shows correct badge count', async ({ page, ctx }) => {
const project1Name = 'MultiProj1 ' + Math.floor(Math.random() * 10000);
const project2Name = 'MultiProj2 ' + Math.floor(Math.random() * 10000);
@@ -842,127 +810,6 @@ test('test that setting group by to current sub group triggers sub group fallbac
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
// ──────────────────────────────────────────────────
@@ -1141,24 +988,3 @@ test.describe('Employee Reporting Restrictions', () => {
await expect(employee.page.getByText('100,00 EUR').first()).toBeVisible();
});
});
test('test that reporting has a type filter that can show only breaks', async ({ page, ctx }) => {
await updateOrganizationSettingViaApi(ctx, { breaks_enabled: true });
await createTimeEntryViaApi(ctx, { duration: '1h', description: 'Regular work entry' });
await createTimeEntryViaApi(ctx, { duration: '20min', type: 'break' });
await goToReporting(page);
// The type filter defaults to "Work time"; switching it to "Breaks" re-aggregates.
const typeFilter = page.getByRole('combobox').filter({ hasText: 'Work time' });
await expect(typeFilter).toBeVisible();
await typeFilter.click();
await Promise.all([
page.waitForResponse(
(response) =>
response.url().includes('/time-entries/aggregate') &&
response.url().includes('type=break') &&
response.status() === 200
),
page.getByRole('option', { name: 'Breaks' }).click(),
]);
});

View File

@@ -16,7 +16,6 @@ import {
createTimeEntryWithBillableStatusViaApi,
createTagViaApi,
createReportViaApi,
updateOrganizationSettingViaApi,
} from './utils/api';
import {
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();
});
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 }) => {
await page.goto(PLAYWRIGHT_BASE_URL + '/shared-report#invalid-secret-value');
await expect(page.getByText('No time entries found').first()).toBeVisible();

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