Compare commits

..

30 Commits

Author SHA1 Message Date
Constantin Graf
fde944a84f Updated extensions 2026-10-07 18:43:16 +02:00
Constantin Graf
34273a4863 Add audit owner through parent model
Models that belong to an organization via a parent model (for example
project members via their project) implement AuditableThroughParent and
return the parent relation. The owner organization of their audits is
taken from the loaded parent if it matches the foreign key, otherwise it
is queried. The declaration is also used to backfill the owner of
existing audits.
2026-10-07 18:43:16 +02:00
Constantin Graf
6fe721fc26 Updated extensions to audit improvement branches 2026-10-07 18:43:16 +02:00
Constantin Graf
f921452419 Add marker for auditable models without owner
Models implementing AuditableWithoutOwner intentionally have no audit
owner, either because they belong to neither an organization nor a user,
or because they and their audits have to be kept when the owner is
deleted (for example billing records). Adds helpers and a scope on the
audit model to find audits that are missing an owner although they
should have one.
2026-10-07 18:43:16 +02:00
Constantin Graf
30a90f80e0 Updated extensions to audit improvement branches 2026-10-07 18:43:16 +02:00
Constantin Graf
135984f9ef Add owner organization and owner user to audits
- Rename the audit actor columns user_type/user_id to actor_type/actor_id
- Add owner_organization_id and owner_user_id to the audits table as
  foreign keys with cascade on delete, so that the audits of an
  organization or user are deleted together with it. The indexes and
  foreign keys are created without blocking writes on the large table.
- Fill the owner columns for new audits via CustomAuditable. Organizations
  and users no longer record their own deletion audit, since it would
  reference the already deleted owner.
2026-10-07 18:43:16 +02:00
Constantin Graf
af54b0db73 Updated auditing extension 2026-10-07 18:43:16 +02:00
Constantin Graf
3fc2cec751 Updated services extension 2026-10-07 18:43:16 +02:00
Constantin Graf
32ac8841bc Remove debug output from TrustHostsTest 2026-10-07 18:28:29 +02:00
Constantin Graf
1582e6f4c3 Generate profile photo in user factory locally
Faker's image() downloads the image from via.placeholder.com, which no
longer exists. Since the domain resolves again but does not respond,
the download (without timeout) hangs and the PHPUnit runs time out.
The profile photo is now generated locally with GD, which also stores a
real image instead of the temporary file path returned by image().
2026-10-07 18:28:29 +02:00
Constantin Graf
01281d80d0 Rename DB_SSLMODE env to DB_SSL_MODE and add DATABASE_URL fallback
The Laravel 13 config update made sslmode read DB_SSLMODE, which the
self-hosting examples set to require, breaking instances whose database
does not support SSL. Use DB_SSL_MODE instead so existing values are
ignored again, and fall back to DATABASE_URL when DB_URL is not set.
2026-10-07 16:23:11 +02:00
Gregor Vostrak
bb5a7fb9f9 improve API docs 2026-10-05 17:27:04 +02:00
Constantin Graf
349623d537 Reverted “Updated UUID generation” 2026-09-24 13:31:26 +02:00
Constantin Graf
a9d016d773 Added deletion of refresh tokens in DeletionService 2026-09-24 13:31:26 +02:00
Constantin Graf
2ebbbceefb Updated billing extension 2026-09-24 13:31:26 +02:00
Gregor Vostrak
e85058aefd add 1password ignore attributes for time tracker component inputs 2026-09-24 13:28:48 +02:00
Constantin Graf
a86c18ad2d Prevent non-primary mouse buttons from resizing events 2026-09-24 11:55:32 +02:00
dependabot[bot]
f683c03ff9 Bump the minor-updates group across 1 directory with 5 updates
Bumps the minor-updates group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [laravel/fortify](https://github.com/laravel/fortify) | `1.39.0` | `1.40.0` |
| [laravel/framework](https://github.com/laravel/framework) | `13.32.0` | `13.33.0` |
| [laravel/octane](https://github.com/laravel/octane) | `2.19.1` | `2.20.0` |
| [laravel/sail](https://github.com/laravel/sail) | `1.67.0` | `1.68.0` |
| [laravel/telescope](https://github.com/laravel/telescope) | `5.24.0` | `5.25.0` |



Updates `laravel/fortify` from 1.39.0 to 1.40.0
- [Release notes](https://github.com/laravel/fortify/releases)
- [Changelog](https://github.com/laravel/fortify/blob/1.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/fortify/compare/v1.39.0...v1.40.0)

Updates `laravel/framework` from 13.32.0 to 13.33.0
- [Release notes](https://github.com/laravel/framework/releases)
- [Changelog](https://github.com/laravel/framework/blob/13.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/framework/compare/v13.32.0...v13.33.0)

Updates `laravel/octane` from 2.19.1 to 2.20.0
- [Release notes](https://github.com/laravel/octane/releases)
- [Changelog](https://github.com/laravel/octane/blob/2.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/octane/compare/v2.19.1...v2.20.0)

Updates `laravel/sail` from 1.67.0 to 1.68.0
- [Release notes](https://github.com/laravel/sail/releases)
- [Changelog](https://github.com/laravel/sail/blob/1.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/sail/compare/v1.67.0...v1.68.0)

Updates `laravel/telescope` from 5.24.0 to 5.25.0
- [Release notes](https://github.com/laravel/telescope/releases)
- [Changelog](https://github.com/laravel/telescope/blob/5.x/CHANGELOG.md)
- [Commits](https://github.com/laravel/telescope/compare/v5.24.0...v5.25.0)

---
updated-dependencies:
- dependency-name: laravel/fortify
  dependency-version: 1.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/framework
  dependency-version: 13.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/octane
  dependency-version: 2.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/sail
  dependency-version: 1.68.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
- dependency-name: laravel/telescope
  dependency-version: 5.25.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-24 11:35:35 +02:00
Constantin Graf
fa0bbc8cfd Add permission config to npm-format-check GitHub action 2026-09-24 11:31:38 +02:00
Constantin Graf
fff3502e53 Updated UUID generation 2026-09-24 11:31:38 +02:00
Constantin Graf
e801c4311c Add new extension auditing 2026-09-24 11:31:38 +02:00
Constantin Graf
2dadf1ef02 Updated Laravel to v13 2026-09-24 10:45:18 +02:00
Constantin Graf
12cc3f27f8 Add check for premium to telemetry and update lookup 2026-09-24 10:45:18 +02:00
Constantin Graf
06a2048771 Add basic e2e tests for admin panel 2026-09-24 10:45:18 +02:00
Constantin Graf
a12ee1b029 Address review feedback on composer updates
- Default INERTIA_SSR_ENABLED to false to match .env.example, .env.ci and
  .env.production, so existing setups keep the previous behaviour.
- Stop tracking the published Filament assets under /public/fonts/filament and
  add them to .gitignore, as recommended by the Filament docs. They are
  regenerated by filament:assets via the filament:upgrade post-autoload-dump
  hook. /public/css and /public/js are already ignored.
- Drop the now dead theme.css filter from the Vite asset list in app.blade.php,
  since the custom Filament theme was removed along with viteTheme().
2026-09-24 10:45:18 +02:00
Constantin Graf
54dc0dbabc Add psysh to gitignore 2026-09-24 10:45:18 +02:00
Constantin Graf
08f9edbc96 Add additional tests for filament 2026-09-24 10:45:18 +02:00
Constantin Graf
0551c633a3 Add github action to prevent non-tag refs in manifest.json 2026-09-24 10:45:18 +02:00
Constantin Graf
37579b1e5c Updated compose dependencies 2026-09-24 10:45:18 +02:00
Constantin Graf
b33cf00788 Remove Group from ClockifyTimeEntriesImporter 2026-09-22 18:08:49 +02:00
45 changed files with 833 additions and 265 deletions

View File

@@ -103,6 +103,8 @@ jobs:
{ {
echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)" echo "invoicing_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)" echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: "Checkout invoicing extension" - name: "Checkout invoicing extension"
@@ -119,9 +121,26 @@ jobs:
- name: "Install npm dependencies in invoicing extension" - name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci run: cd extensions/Invoicing && npm ci
- name: "Checkout auditing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
path: extensions/Auditing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
- name: "Install composer dependencies in auditing extension"
run: cd extensions/Auditing && composer install --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
- name: "Install npm dependencies in auditing extension"
run: cd extensions/Auditing && npm ci
- name: "Activate invoicing extension" - name: "Activate invoicing extension"
run: php artisan module:enable Invoicing run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies" - name: "Install npm dependencies"
run: npm ci run: npm ci

View File

@@ -84,6 +84,8 @@ jobs:
echo "services_ref=$(jq -r '.Services.ref' 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_repository=$(jq -r '.Invoicing.repository' extensions/manifest.json)"
echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)" echo "invoicing_ref=$(jq -r '.Invoicing.ref' extensions/manifest.json)"
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT" } >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension" - name: "Checkout billing extension"
@@ -145,6 +147,25 @@ jobs:
- name: "Install npm dependencies in invoicing extension" - name: "Install npm dependencies in invoicing extension"
run: cd extensions/Invoicing && npm ci run: cd extensions/Invoicing && npm ci
- name: "Checkout auditing extension"
uses: actions/checkout@v7
with:
repository: ${{ steps.extension-manifest.outputs.auditing_repository }}
ref: ${{ steps.extension-manifest.outputs.auditing_ref }}
path: extensions/Auditing
ssh-key: ${{ secrets.SSH_PRIVATE_KEY_AUDITING_EXTENSION }}
- name: "Install composer dependencies in auditing extension"
uses: php-actions/composer@v6
with:
working_dir: "extensions/Auditing"
command: install
only_args: --no-dev --no-ansi --no-interaction --prefer-dist --ignore-platform-reqs --classmap-authoritative
php_version: 8.3
- name: "Install npm dependencies in auditing extension"
run: cd extensions/Auditing && npm ci
- name: "Setup PHP with PECL extension" - name: "Setup PHP with PECL extension"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
@@ -168,6 +189,9 @@ jobs:
- name: "Activate invoicing extension" - name: "Activate invoicing extension"
run: php artisan module:enable Invoicing run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies" - name: "Install npm dependencies"
run: npm ci run: npm ci

View File

@@ -1,6 +1,8 @@
name: NPM Format Check name: NPM Format Check
on: [push] on: [push]
permissions:
contents: read
jobs: jobs:
format-check: format-check:

View File

@@ -1,99 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\AuditResource\Pages\CreateAudit;
use App\Filament\Resources\AuditResource\Pages\ListAudits;
use App\Filament\Resources\AuditResource\Pages\ViewAudit;
use App\Models\Audit;
use Filament\Actions\ViewAction;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Support\Str;
use Novadaemon\FilamentPrettyJson\Form\PrettyJsonField;
class AuditResource extends Resource
{
protected static ?string $model = Audit::class;
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-archive-box';
protected static string|\UnitEnum|null $navigationGroup = 'System';
public static function form(Schema $schema): Schema
{
return $schema
->components([
TextInput::make('user_type')
->maxLength(255),
TextInput::make('user_id'),
TextInput::make('event')
->required()
->maxLength(255),
TextInput::make('auditable_type')
->required()
->maxLength(255),
TextInput::make('auditable_id')
->required(),
PrettyJsonField::make('old_values'),
PrettyJsonField::make('new_values'),
Textarea::make('url'),
TextInput::make('ip_address'),
TextInput::make('user_agent')
->maxLength(1023),
TextInput::make('tags')
->maxLength(255),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
TextColumn::make('user.name'),
TextColumn::make('event'),
TextColumn::make('auditable_type'),
TextColumn::make('auditable_id'),
IconColumn::make('was_command')
->getStateUsing(fn (Audit $record) => Str::startsWith($record->url, 'artisan '))
->boolean(),
TextColumn::make('created_at')
->sortable()
->dateTime(),
TextColumn::make('updated_at')
->sortable()
->dateTime(),
])
->filters([
//
])
->recordActions([
ViewAction::make(),
])
->toolbarActions([
])
->defaultSort('created_at', 'desc');
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => ListAudits::route('/'),
'create' => CreateAudit::route('/create'),
'view' => ViewAudit::route('/{record}'),
];
}
}

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\CreateRecord;
class CreateAudit extends CreateRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ListRecords;
class ListAudits extends ListRecords
{
protected static string $resource = AuditResource::class;
protected function getHeaderActions(): array
{
return [];
}
}

View File

@@ -1,13 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\AuditResource\Pages;
use App\Filament\Resources\AuditResource;
use Filament\Resources\Pages\ViewRecord;
class ViewAudit extends ViewRecord
{
protected static string $resource = AuditResource::class;
}

View File

@@ -88,6 +88,8 @@ class InvitationController extends Controller
/** /**
* Remove a pending invitation * Remove a pending invitation
* *
* This revokes the invitation: the link in the invitation email stops working. Find the invitation ID with `GET /organizations/{organization}/invitations`.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId removeInvitation * @operationId removeInvitation

View File

@@ -145,6 +145,9 @@ class MemberController extends Controller
/** /**
* Merge one member into another * Merge one member into another
* *
* Only placeholder members (for example people created by an import) can be merged. All time entries and other data of the placeholder
* are reassigned to the member given in `member_id`, and the placeholder is removed. Find both member IDs with `GET /organizations/{organization}/members`.
*
* @throws AuthorizationException * @throws AuthorizationException
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember * @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws Throwable * @throws Throwable

View File

@@ -71,6 +71,8 @@ class ReportController extends Controller
/** /**
* Create report * Create report
* *
* A report is a saved set of filters. Set `is_public` to `true` to share it: the response then contains the `shareable_link` that can be opened without logging in.
*
* @throws AuthorizationException * @throws AuthorizationException
* *
* @operationId createReport * @operationId createReport

View File

@@ -109,9 +109,14 @@ class TimeEntryController extends Controller
/** /**
* Get time entries in organization * Get time entries in organization
* *
* If you only need time entries for a specific user, you can filter by `member_id`. * Without a member filter this returns the time entries of all members of the organization (for users who may view all time entries, such as owners and admins), not only your own.
* To get only your own time entries, pass your member ID as `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter. * Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
* *
* The `start` and `end` filters both apply to the start time of an entry, in UTC. Convert the user's local day boundaries to UTC first.
* Results are paginated with `limit` (default 100, max 500) and `offset`; check `meta.total` and fetch further pages when needed.
* To find the running timer, use `active=true` (or `GET /v1/users/me/time-entries/active`).
*
* @return TimeEntryCollection<TimeEntryResource> * @return TimeEntryCollection<TimeEntryResource>
* *
* @throws AuthorizationException * @throws AuthorizationException
@@ -351,6 +356,11 @@ class TimeEntryController extends Controller
* The parameters `group` and `sub_group` allow you to group the time entries by different criteria. * The parameters `group` and `sub_group` allow you to group the time entries by different criteria.
* If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries. * If the group parameters are all set to `null` or are all missing, the endpoint will aggregate all filtered time entries.
* *
* Durations are returned in `seconds` (divide by 3600 for hours) and amounts in `cost` as cents in the organization's currency (divide by 100 for money).
* Filter by member with `member_id`. Your member ID is the `id` returned for this organization by `GET /v1/users/me/memberships`; it is not your user ID.
* Array filters use the query format `client_ids[]=<id>`.
* Example: billable hours per client for a period: `group=client&billable=true&start=...&end=...`.
*
* @operationId getAggregatedTimeEntries * @operationId getAggregatedTimeEntries
* *
* @return array{ * @return array{
@@ -584,6 +594,12 @@ class TimeEntryController extends Controller
/** /**
* Create time entry * Create time entry
* *
* `billable` is not taken from the project. To match the web app, set it to the project's `is_billable` value (or `false` without a project).
*
* A member can only have one running time entry (an entry with `end` set to `null`). Creating a running entry while another one runs fails with `time_entry_still_running`.
* To start a new timer, first stop the running entry by updating its `end` to the current time, then create the new entry.
* To log past work, send both `start` and `end` (UTC). Create one entry per block of work.
*
* @throws AuthorizationException * @throws AuthorizationException
* @throws TimeEntryStillRunningApiException * @throws TimeEntryStillRunningApiException
* *
@@ -634,6 +650,8 @@ class TimeEntryController extends Controller
/** /**
* Update time entry * Update time entry
* *
* To stop a running timer, set `end` to the stop time (UTC). Times the user gives in their own timezone must be converted to UTC first.
*
* @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException * @throws AuthorizationException|TimeEntryCanNotBeRestartedApiException
* *
* @operationId updateTimeEntry * @operationId updateTimeEntry
@@ -702,6 +720,10 @@ class TimeEntryController extends Controller
/** /**
* Update multiple time entries * Update multiple time entries
* *
* Applies the same `changes` to every entry in `ids`. To find the IDs, list entries with `GET /organizations/{organization}/time-entries`
* (filtered by `member_id`, the project and the other criteria), then send their IDs here.
* When `changes.project_id` moves entries to another project, also set `changes.task_id` to a task of the new project or to `null`, because tasks belong to a project.
*
* @operationId updateMultipleTimeEntries * @operationId updateMultipleTimeEntries
* *
* @throws AuthorizationException * @throws AuthorizationException

View File

@@ -27,6 +27,7 @@ class MemberUpdateRequest extends BaseFormRequest
'string', 'string',
Rule::enum(Role::class), Rule::enum(Role::class),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -36,6 +36,7 @@ class OrganizationUpdateRequest extends BaseFormRequest
'string', 'string',
new CurrencyRule, new CurrencyRule,
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -55,6 +55,7 @@ class ProjectStoreRequest extends BaseFormRequest
'required', 'required',
'boolean', 'boolean',
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -68,6 +68,7 @@ class ProjectUpdateRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge([ 'billable_rate' => array_merge([
'nullable', 'nullable',
], ],

View File

@@ -31,6 +31,7 @@ class ProjectMemberStoreRequest extends BaseFormRequest
return $builder->whereBelongsTo($this->organization, 'organization'); return $builder->whereBelongsTo($this->organization, 'organization');
})->uuid(), })->uuid(),
], ],
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -21,6 +21,7 @@ class ProjectMemberUpdateRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// Billable rate in cents per hour (example: 8000 means 80.00 in the organization's currency)
'billable_rate' => array_merge( 'billable_rate' => array_merge(
[ [
'nullable', 'nullable',

View File

@@ -35,7 +35,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// Filter by member ID // Filter by member ID. Without it, users who may view all time entries (owners, admins) get the entries of every member; pass your own member ID (from GET /v1/users/me/memberships) to get only yours
'member_id' => [ 'member_id' => [
'string', 'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder { ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
@@ -155,7 +155,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string', 'string',
Rule::enum(TimeEntryType::class), Rule::enum(TimeEntryType::class),
], ],
// Limit the number of returned time entries (default: 150) // Limit the number of returned time entries (default: 100)
'limit' => [ 'limit' => [
'integer', 'integer',
'min:1', 'min:1',

View File

@@ -32,7 +32,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
public function rules(): array public function rules(): array
{ {
return [ return [
// ID of the organization member that the time entry should belong to // ID of the organization member that the time entry should belong to (a member ID from GET /v1/users/me/memberships or the members list, not a user ID)
'member_id' => [ 'member_id' => [
'required', 'required',
'string', 'string',
@@ -86,7 +86,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'date_format:Y-m-d\TH:i:s\Z', 'date_format:Y-m-d\TH:i:s\Z',
'after_or_equal:start', 'after_or_equal:start',
], ],
// Whether time entry is billable // Whether time entry is billable. Not derived from the project: set it to the project's is_billable value to match the web app
'billable' => [ 'billable' => [
'required', 'required',
'boolean', 'boolean',

View File

@@ -4,15 +4,19 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\AuditableWithoutOwner;
use Database\Factories\AuditFactory; use Database\Factories\AuditFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon; use Illuminate\Support\Carbon;
use OwenIt\Auditing\Models\Audit as PackageAuditModel; use OwenIt\Auditing\Models\Audit as PackageAuditModel;
/** /**
* @property int $id * @property int $id
* @property string|null $user_type * @property string|null $actor_type
* @property string|null $user_id * @property string|null $actor_id
* @property string $event * @property string $event
* @property string $auditable_type * @property string $auditable_type
* @property string $auditable_id * @property string $auditable_id
@@ -22,13 +26,71 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
* @property string|null $ip_address * @property string|null $ip_address
* @property string|null $user_agent * @property string|null $user_agent
* @property string|null $tags * @property string|null $tags
* @property string|null $owner_user_id
* @property string|null $owner_organization_id
* @property Carbon|null $created_at * @property Carbon|null $created_at
* @property Carbon|null $updated_at * @property Carbon|null $updated_at
* @property-read User|null $ownerUser
* @property-read Organization|null $ownerOrganization
* *
* @method static AuditFactory factory() * @method static AuditFactory factory()
* @method static Builder<Audit> whereMissingOwner()
*/ */
class Audit extends PackageAuditModel class Audit extends PackageAuditModel
{ {
/** @use HasFactory<AuditFactory> */ /** @use HasFactory<AuditFactory> */
use HasFactory; use HasFactory;
/**
* @return BelongsTo<User, $this>
*/
public function ownerUser(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_user_id');
}
/**
* @return BelongsTo<Organization, $this>
*/
public function ownerOrganization(): BelongsTo
{
return $this->belongsTo(Organization::class, 'owner_organization_id');
}
/**
* Whether audits of the given auditable type intentionally have no owner (see AuditableWithoutOwner).
*/
public static function isAuditableTypeWithoutOwner(string $auditableType): bool
{
$modelClass = Relation::getMorphedModel($auditableType) ?? $auditableType;
return is_subclass_of($modelClass, AuditableWithoutOwner::class);
}
/**
* Auditable types (morph aliases) whose audits intentionally have no owner (see AuditableWithoutOwner).
*
* @return array<int, string>
*/
public static function getAuditableTypesWithoutOwner(): array
{
return collect(Relation::morphMap())
->filter(fn (string $modelClass): bool => is_subclass_of($modelClass, AuditableWithoutOwner::class))
->keys()
->values()
->all();
}
/**
* Audits that have neither an owner organization nor an owner user, although their auditable type should have one.
* These are audits whose owner no longer exists or could not be determined (yet).
*
* @param Builder<Audit> $builder
*/
public function scopeWhereMissingOwner(Builder $builder): void
{
$builder->whereNull('owner_organization_id')
->whereNull('owner_user_id')
->whereNotIn('auditable_type', self::getAuditableTypesWithoutOwner());
}
} }

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Marks an auditable model whose audits are owned by the owner of its parent model,
* because the model itself has no organization_id (for example a project member belongs to an organization via its project).
* The parent model has to have an organization_id column.
* This is used to set the owner of new audits (CustomAuditable) and to backfill the owner of existing audits.
*/
interface AuditableThroughParent
{
/**
* @return BelongsTo<covariant Model, covariant Model>
*/
public function getAuditParentRelation(): BelongsTo;
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Models\Concerns;
/**
* Marks an auditable model whose audits intentionally have no owner (neither an organization nor a user),
* either because the model belongs to neither of them, or because the model and its audits have to be kept
* when its organization or user is deleted (for example billing records).
* Audits of these models are not deleted together with an organization or user,
* and are not considered as missing an owner (for example by the audit backfill command).
*/
interface AuditableWithoutOwner {}

View File

@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Models\Concerns; namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable; use OwenIt\Auditing\Auditable;
trait CustomAuditable trait CustomAuditable
@@ -19,4 +21,92 @@ trait CustomAuditable
{ {
$this->auditEvents = []; $this->auditEvents = [];
} }
/**
* The organization that owns the audited model.
* The audits of the model are deleted (via foreign key cascade) when the organization is deleted.
*/
public function getAuditOwnerOrganizationId(): ?string
{
if ($this instanceof AuditableThroughParent) {
$relation = $this->getAuditParentRelation();
$parentId = $this->getAttributes()[$relation->getForeignKeyName()] ?? null;
if ($parentId === null) {
return null;
}
// Note: The loaded parent is only used if it still matches the foreign key and was loaded with the organization
$relationName = $relation->getRelationName();
if ($this->relationLoaded($relationName)) {
$parent = $this->getRelation($relationName);
if ($parent instanceof Model
&& $parent->getAttribute($relation->getOwnerKeyName()) === $parentId
&& array_key_exists('organization_id', $parent->getAttributes())) {
/** @var string|null $organizationId */
$organizationId = $parent->getAttributes()['organization_id'];
return $organizationId;
}
}
/** @var string|null $organizationId */
$organizationId = $relation->getRelated()->newQuery()
->toBase()
->where($relation->getOwnerKeyName(), $parentId)
->value('organization_id');
return $organizationId;
}
return $this->getAttributes()['organization_id'] ?? null;
}
/**
* The user that owns the audited model.
* The audits of the model are deleted (via foreign key cascade) when the user is deleted.
*/
public function getAuditOwnerUserId(): ?string
{
return null;
}
/**
* Models that are the owner of their own audits can not record the deletion audit,
* since the audit would reference the already deleted model and therefore violate the foreign key.
*/
protected function isAuditOwnerOfItself(): bool
{
return false;
}
/**
* @return array<int|string, string>
*/
public function getAuditEvents(): array
{
$events = $this->auditEvents ?? Config::get('audit.events', [
'created',
'updated',
'deleted',
'restored',
]);
if ($this->isAuditOwnerOfItself()) {
$events = array_filter($events, fn (string $value, int|string $key): bool => (is_int($key) ? $value : $key) !== 'deleted', ARRAY_FILTER_USE_BOTH);
}
return $events;
}
/**
* @param array<string, mixed> $data
* @return array<string, mixed>
*/
public function transformAudit(array $data): array
{
$data['owner_organization_id'] = $this->getAuditOwnerOrganizationId();
$data['owner_user_id'] = $this->getAuditOwnerUserId();
return $data;
}
} }

View File

@@ -96,6 +96,16 @@ class Organization extends Model implements AuditableContract
protected $attributes = [ protected $attributes = [
]; ];
public function getAuditOwnerOrganizationId(): ?string
{
return $this->getKey();
}
protected function isAuditOwnerOfItself(): bool
{
return true;
}
/** /**
* Get all the users that belong to the team. * Get all the users that belong to the team.
* *

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models; namespace App\Models;
use App\Models\Concerns\AuditableThroughParent;
use App\Models\Concerns\CustomAuditable; use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids; use App\Models\Concerns\HasUuids;
use Database\Factories\ProjectMemberFactory; use Database\Factories\ProjectMemberFactory;
@@ -29,7 +30,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization) * @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
* @method static ProjectMemberFactory factory() * @method static ProjectMemberFactory factory()
*/ */
class ProjectMember extends Model implements AuditableContract class ProjectMember extends Model implements AuditableContract, AuditableThroughParent
{ {
use CustomAuditable; use CustomAuditable;
@@ -82,4 +83,12 @@ class ProjectMember extends Model implements AuditableContract
$query->whereBelongsTo($organization, 'organization'); $query->whereBelongsTo($organization, 'organization');
}); });
} }
/**
* @return BelongsTo<Project, $this>
*/
public function getAuditParentRelation(): BelongsTo
{
return $this->project();
}
} }

View File

@@ -124,6 +124,16 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
'send_time_entry_still_running_email' => true, 'send_time_entry_still_running_email' => true,
]; ];
public function getAuditOwnerUserId(): ?string
{
return $this->getKey();
}
protected function isAuditOwnerOfItself(): bool
{
return true;
}
/** /**
* Get the URL to the user's profile photo. * Get the URL to the user's profile photo.
* *

View File

@@ -11,6 +11,7 @@ use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\OrganizationInvitation; use App\Models\OrganizationInvitation;
use App\Models\Passport\RefreshToken;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\Report; use App\Models\Report;
@@ -169,6 +170,10 @@ class DeletionService
} }
} }
// Refresh tokens are not linked to the user directly, so they need to be deleted via their access tokens.
// Otherwise a still-valid refresh token could be used to issue a new access token for a deleted user,
// which fails with a foreign key violation on oauth_access_tokens.user_id.
RefreshToken::query()->whereIn('access_token_id', $user->accessTokens()->pluck('id'))->delete();
$user->accessTokens()->delete(); $user->accessTokens()->delete();
$user->authCodes()->delete(); $user->authCodes()->delete();

View File

@@ -229,7 +229,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'Project', 'Project',
'Description', 'Description',
'User', 'User',
'Group',
'Email', 'Email',
'Tags', 'Tags',
'Start Date', 'Start Date',

79
composer.lock generated
View File

@@ -429,23 +429,24 @@
}, },
{ {
"name": "brick/math", "name": "brick/math",
"version": "0.19.1", "version": "1.0.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/brick/math.git", "url": "https://github.com/brick/math.git",
"reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce" "reference": "2effe05d2177c451b86c6a073196a4034c02f211"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/brick/math/zipball/a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce", "url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211",
"reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce", "reference": "2effe05d2177c451b86c6a073196a4034c02f211",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^8.2" "php": "^8.2"
}, },
"require-dev": { "require-dev": {
"phpstan/phpstan": "2.1.22", "phpstan/phpstan": "2.2.13",
"phpstan/phpstan-phpunit": "2.0.18",
"phpunit/phpunit": "^11.5" "phpunit/phpunit": "^11.5"
}, },
"type": "library", "type": "library",
@@ -476,7 +477,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/brick/math/issues", "issues": "https://github.com/brick/math/issues",
"source": "https://github.com/brick/math/tree/0.19.1" "source": "https://github.com/brick/math/tree/1.0.0"
}, },
"funding": [ "funding": [
{ {
@@ -484,7 +485,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-08-08T23:03:16+00:00" "time": "2026-09-12T10:28:18+00:00"
}, },
{ {
"name": "brick/money", "name": "brick/money",
@@ -4218,16 +4219,16 @@
}, },
{ {
"name": "laravel/fortify", "name": "laravel/fortify",
"version": "v1.39.0", "version": "v1.40.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/fortify.git", "url": "https://github.com/laravel/fortify.git",
"reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a" "reference": "fe0fce8814660317df0684f2c7be3b573def67d3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/fortify/zipball/b1fc50707bbe007fd92165d8b7d460ab549b355a", "url": "https://api.github.com/repos/laravel/fortify/zipball/fe0fce8814660317df0684f2c7be3b573def67d3",
"reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a", "reference": "fe0fce8814660317df0684f2c7be3b573def67d3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4278,24 +4279,24 @@
"issues": "https://github.com/laravel/fortify/issues", "issues": "https://github.com/laravel/fortify/issues",
"source": "https://github.com/laravel/fortify" "source": "https://github.com/laravel/fortify"
}, },
"time": "2026-08-23T07:46:41+00:00" "time": "2026-09-10T11:52:08+00:00"
}, },
{ {
"name": "laravel/framework", "name": "laravel/framework",
"version": "v13.32.0", "version": "v13.33.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/framework.git", "url": "https://github.com/laravel/framework.git",
"reference": "cdd8b33c246719acdd118c705ce8c7ab5ef48a96" "reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/cdd8b33c246719acdd118c705ce8c7ab5ef48a96", "url": "https://api.github.com/repos/laravel/framework/zipball/91188a17ceaa3dbace6e8a5f7abd0d042e466359",
"reference": "cdd8b33c246719acdd118c705ce8c7ab5ef48a96", "reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19", "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
"composer-runtime-api": "^2.2", "composer-runtime-api": "^2.2",
"doctrine/inflector": "^2.0.5", "doctrine/inflector": "^2.0.5",
"dragonmantank/cron-expression": "^3.4", "dragonmantank/cron-expression": "^3.4",
@@ -4509,20 +4510,20 @@
"issues": "https://github.com/laravel/framework/issues", "issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework" "source": "https://github.com/laravel/framework"
}, },
"time": "2026-09-15T14:55:30+00:00" "time": "2026-09-22T14:12:33+00:00"
}, },
{ {
"name": "laravel/octane", "name": "laravel/octane",
"version": "v2.19.1", "version": "v2.20.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/octane.git", "url": "https://github.com/laravel/octane.git",
"reference": "68a2516a0318baba0de0e4648f61e335c5e69dca" "reference": "df4c38dcddc5f51e36e7f1d788841130c4fb8f45"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/octane/zipball/68a2516a0318baba0de0e4648f61e335c5e69dca", "url": "https://api.github.com/repos/laravel/octane/zipball/df4c38dcddc5f51e36e7f1d788841130c4fb8f45",
"reference": "68a2516a0318baba0de0e4648f61e335c5e69dca", "reference": "df4c38dcddc5f51e36e7f1d788841130c4fb8f45",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4598,7 +4599,7 @@
"issues": "https://github.com/laravel/octane/issues", "issues": "https://github.com/laravel/octane/issues",
"source": "https://github.com/laravel/octane" "source": "https://github.com/laravel/octane"
}, },
"time": "2026-08-13T13:58:52+00:00" "time": "2026-08-23T17:25:20+00:00"
}, },
{ {
"name": "laravel/passkeys", "name": "laravel/passkeys",
@@ -4804,16 +4805,16 @@
}, },
{ {
"name": "laravel/serializable-closure", "name": "laravel/serializable-closure",
"version": "v2.0.16", "version": "v2.1.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/serializable-closure.git", "url": "https://github.com/laravel/serializable-closure.git",
"reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5",
"reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4861,7 +4862,7 @@
"issues": "https://github.com/laravel/serializable-closure/issues", "issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure" "source": "https://github.com/laravel/serializable-closure"
}, },
"time": "2026-08-18T20:28:54+00:00" "time": "2026-09-22T14:32:34+00:00"
}, },
{ {
"name": "laravel/tinker", "name": "laravel/tinker",
@@ -15337,16 +15338,16 @@
}, },
{ {
"name": "laravel/sail", "name": "laravel/sail",
"version": "v1.67.0", "version": "v1.68.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/sail.git", "url": "https://github.com/laravel/sail.git",
"reference": "639e03ac12cf23def171770bcab05758045b2642" "reference": "2bc304083d515065b03944e425e62cb3c526c33e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/sail/zipball/639e03ac12cf23def171770bcab05758045b2642", "url": "https://api.github.com/repos/laravel/sail/zipball/2bc304083d515065b03944e425e62cb3c526c33e",
"reference": "639e03ac12cf23def171770bcab05758045b2642", "reference": "2bc304083d515065b03944e425e62cb3c526c33e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -15396,7 +15397,7 @@
"issues": "https://github.com/laravel/sail/issues", "issues": "https://github.com/laravel/sail/issues",
"source": "https://github.com/laravel/sail" "source": "https://github.com/laravel/sail"
}, },
"time": "2026-08-12T13:55:56+00:00" "time": "2026-09-18T14:34:46+00:00"
}, },
{ {
"name": "laravel/sentinel", "name": "laravel/sentinel",
@@ -15456,16 +15457,16 @@
}, },
{ {
"name": "laravel/telescope", "name": "laravel/telescope",
"version": "v5.24.0", "version": "v5.25.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/laravel/telescope.git", "url": "https://github.com/laravel/telescope.git",
"reference": "50cbcf4553ddfd8d4bd456b04f97dfed923007d9" "reference": "65fe6724f8688de01dfa74b28a0edc191bc3caa8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/laravel/telescope/zipball/50cbcf4553ddfd8d4bd456b04f97dfed923007d9", "url": "https://api.github.com/repos/laravel/telescope/zipball/65fe6724f8688de01dfa74b28a0edc191bc3caa8",
"reference": "50cbcf4553ddfd8d4bd456b04f97dfed923007d9", "reference": "65fe6724f8688de01dfa74b28a0edc191bc3caa8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -15518,9 +15519,9 @@
"monitoring" "monitoring"
], ],
"support": { "support": {
"source": "https://github.com/laravel/telescope/tree/v5.24.0" "source": "https://github.com/laravel/telescope/tree/v5.25.0"
}, },
"time": "2026-09-08T16:15:16+00:00" "time": "2026-09-09T14:16:19+00:00"
}, },
{ {
"name": "mockery/mockery", "name": "mockery/mockery",

View File

@@ -32,7 +32,7 @@ return [
*/ */
'user' => [ 'user' => [
'morph_prefix' => 'user', 'morph_prefix' => 'actor',
'guards' => [ 'guards' => [
'web', 'web',
'api', 'api',

View File

@@ -35,7 +35,7 @@ return [
'sqlite' => [ 'sqlite' => [
'driver' => 'sqlite', 'driver' => 'sqlite',
'url' => env('DB_URL'), 'url' => env('DB_URL', env('DATABASE_URL')),
'database' => env('DB_DATABASE', database_path('database.sqlite')), 'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '', 'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
@@ -47,7 +47,7 @@ return [
'pgsql' => [ 'pgsql' => [
'driver' => 'pgsql', 'driver' => 'pgsql',
'url' => env('DB_URL'), 'url' => env('DB_URL', env('DATABASE_URL')),
'host' => env('DB_HOST', '127.0.0.1'), 'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '5432'), 'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'forge'), 'database' => env('DB_DATABASE', 'forge'),
@@ -57,12 +57,12 @@ return [
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
'search_path' => 'public', 'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'), 'sslmode' => env('DB_SSL_MODE', 'prefer'),
], ],
'pgsql_test' => [ 'pgsql_test' => [
'driver' => 'pgsql', 'driver' => 'pgsql',
'url' => env('DB_URL'), 'url' => env('DB_URL', env('DATABASE_URL')),
'host' => env('DB_TEST_HOST', '127.0.0.1'), 'host' => env('DB_TEST_HOST', '127.0.0.1'),
'port' => env('DB_TEST_PORT', '5432'), 'port' => env('DB_TEST_PORT', '5432'),
'database' => env('DB_TEST_DATABASE', 'forge'), 'database' => env('DB_TEST_DATABASE', 'forge'),
@@ -72,12 +72,12 @@ return [
'prefix' => '', 'prefix' => '',
'prefix_indexes' => true, 'prefix_indexes' => true,
'search_path' => 'public', 'search_path' => 'public',
'sslmode' => env('DB_SSLMODE', 'prefer'), 'sslmode' => env('DB_SSL_MODE', 'prefer'),
], ],
'sqlsrv' => [ 'sqlsrv' => [
'driver' => 'sqlsrv', 'driver' => 'sqlsrv',
'url' => env('DB_URL'), 'url' => env('DB_URL', env('DATABASE_URL')),
'host' => env('DB_HOST', 'localhost'), 'host' => env('DB_HOST', 'localhost'),
'port' => env('DB_PORT', '1433'), 'port' => env('DB_PORT', '1433'),
'database' => env('DB_DATABASE', 'laravel'), 'database' => env('DB_DATABASE', 'laravel'),

View File

@@ -28,7 +28,30 @@ return [
/* /*
* Description rendered on the home page of the API documentation (`/docs/api`). * Description rendered on the home page of the API documentation (`/docs/api`).
*/ */
'description' => '', 'description' => <<<'MD'
## Getting started
All organization endpoints live under `/v1/organizations/{organization}`, where `{organization}` is the organization's ID. Authenticate with `Authorization: Bearer <token>` and send `Accept: application/json`.
**1. Find yourself.** Call `GET /v1/users/me/memberships`. Each membership contains the **organization ID** (use it as `{organization}` in paths) and your **member ID** in that organization (the membership `id`). Most endpoints filter by member ID, not by user ID.
**2. Scope to your own data.** For owners and admins, `GET /time-entries` and `GET /time-entries/aggregate` return the whole organization's time entries unless you pass `member_id`. When acting for "me", always pass your member ID, both when reading and before changing entries.
**3. Resolve names to IDs.** Look up projects, clients, tags, tasks and members by name with their list endpoints (`GET /projects`, `GET /clients`, `GET /tags`, `GET /tasks`, `GET /members`). Never guess IDs.
**4. Use UTC.** All timestamps are sent and returned in UTC as `Y-m-d\TH:i:s\Z` (example: `2026-10-02T07:30:00Z`). Convert the user's local times and day boundaries to UTC before sending them.
**5. Money is in cents.** Billable rates and costs are integers in cents of the organization's currency (`8000` means 80.00).
## Common tasks
- **Start a timer:** stop the running entry first (find it with `GET /v1/users/me/time-entries/active`, then `PUT` its `end`), then `POST /time-entries` with `start` and `end: null`. Only one entry can run per member.
- **Log past work:** `POST /time-entries` once per block with `member_id`, `start`, `end`, `project_id` and `billable` set to the project's `is_billable` (it is not derived automatically).
- **Fix or stop an entry:** `PUT /time-entries/{timeEntry}` with the new `start` or `end` in UTC.
- **Move entries to another project:** list them with `member_id` and filters, then `PATCH /time-entries` with their `ids` and `changes.project_id`, plus `changes.task_id` set to a task of the new project or `null`.
- **Totals and reports:** `GET /time-entries/aggregate` with `group` (for example `client` or `project`) and `start`/`end`; durations are in `seconds`, amounts in `cost` (cents).
- **Share a report:** `POST /reports` with `is_public: true` and use the returned `shareable_link`.
MD,
], ],
/* /*

View File

@@ -91,7 +91,7 @@ class UserFactory extends Factory
public function withProfilePicture(): static public function withProfilePicture(): static
{ {
$profilePhoto = $this->faker->image(null, 500, 500); $profilePhoto = $this->generateProfilePhoto();
/** @see FileHelpers::hashName */ /** @see FileHelpers::hashName */
$path = 'profile-photos/'.Str::random(40).'.png'; $path = 'profile-photos/'.Str::random(40).'.png';
Storage::disk(config('filesystems.public'))->put($path, $profilePhoto); Storage::disk(config('filesystems.public'))->put($path, $profilePhoto);
@@ -103,6 +103,21 @@ class UserFactory extends Factory
}); });
} }
/**
* Generates a PNG image with a random background color.
* Note: The image is generated locally, since downloading a placeholder image (Faker image()) depends on an external service.
*/
private function generateProfilePhoto(): string
{
$image = imagecreatetruecolor(500, 500);
$color = imagecolorallocate($image, $this->faker->numberBetween(0, 255), $this->faker->numberBetween(0, 255), $this->faker->numberBetween(0, 255));
imagefill($image, 0, 0, $color);
ob_start();
imagepng($image);
return (string) ob_get_clean();
}
/** /**
* Indicate that the user should have a personal team. * Indicate that the user should have a personal team.
*/ */

View File

@@ -18,7 +18,8 @@ class CreateAuditsTable extends Migration
Schema::connection($connection)->create($table, function (Blueprint $table): void { Schema::connection($connection)->create($table, function (Blueprint $table): void {
$morphPrefix = config('audit.user.morph_prefix', 'user'); // Note: The morph prefix is hardcoded, since the columns are renamed in a later migration
$morphPrefix = 'user';
$table->bigIncrements('id'); $table->bigIncrements('id');
$table->string($morphPrefix.'_type')->nullable(); $table->string($morphPrefix.'_type')->nullable();

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* Note: Renaming columns and adding nullable columns without default are metadata-only operations in PostgreSQL,
* so this migration is fast even for a large audits table.
* The indexes and foreign keys for the new columns are added in a separate non-transactional migration.
*/
public function up(): void
{
Schema::table('audits', function (Blueprint $table): void {
$table->renameColumn('user_type', 'actor_type');
$table->renameColumn('user_id', 'actor_id');
$table->renameIndex('audits_user_id_user_type_index', 'audits_actor_id_actor_type_index');
$table->uuid('owner_user_id')->nullable();
$table->uuid('owner_organization_id')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('audits', function (Blueprint $table): void {
$table->dropColumn('owner_user_id');
$table->dropColumn('owner_organization_id');
$table->renameIndex('audits_actor_id_actor_type_index', 'audits_user_id_user_type_index');
$table->renameColumn('actor_type', 'user_type');
$table->renameColumn('actor_id', 'user_id');
});
}
};

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* PostgreSQL cannot build an index concurrently inside a transaction.
* Keeping this migration non-transactional prevents long write locks on the (large) audits table in production.
* Every step is idempotent, so the migration can be re-run if it fails halfway.
*
* @var bool
*/
public $withinTransaction = false;
/**
* Run the migrations.
*/
public function up(): void
{
$this->createIndex('audits_owner_user_id_index', 'owner_user_id');
$this->createIndex('audits_owner_organization_id_index', 'owner_organization_id');
$this->createForeignKey('audits_owner_user_id_foreign', 'owner_user_id', 'users');
$this->createForeignKey('audits_owner_organization_id_foreign', 'owner_organization_id', 'organizations');
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DB::statement('ALTER TABLE audits DROP CONSTRAINT IF EXISTS audits_owner_user_id_foreign');
DB::statement('ALTER TABLE audits DROP CONSTRAINT IF EXISTS audits_owner_organization_id_foreign');
DB::statement('DROP INDEX'.$this->concurrently().' IF EXISTS audits_owner_user_id_index');
DB::statement('DROP INDEX'.$this->concurrently().' IF EXISTS audits_owner_organization_id_index');
}
private function createIndex(string $index, string $column): void
{
$state = DB::selectOne(
<<<'SQL'
SELECT pg_index.indisvalid::int AS valid
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 && (bool) $state->valid) {
return;
}
// A failed concurrent index build leaves an invalid index behind, that needs to be dropped before rebuilding
if ($state !== null) {
DB::statement('DROP INDEX'.$this->concurrently().' '.$index);
}
DB::statement('CREATE INDEX'.$this->concurrently().' '.$index.' ON audits ('.$column.')');
}
private function createForeignKey(string $constraint, string $column, string $referencedTable): void
{
$exists = DB::selectOne(
<<<'SQL'
SELECT 1
FROM pg_constraint
JOIN pg_namespace ON pg_namespace.oid = pg_constraint.connamespace
WHERE pg_namespace.nspname = current_schema()
AND pg_constraint.conname = ?
SQL,
[$constraint],
) !== null;
// Note: Adding the constraint as NOT VALID only needs a short lock, the validation of the existing rows
// afterward does not block reads or writes on the audits table.
if (! $exists) {
DB::statement('ALTER TABLE audits ADD CONSTRAINT '.$constraint.' FOREIGN KEY ('.$column.') REFERENCES '.$referencedTable.' (id) ON DELETE CASCADE NOT VALID');
}
DB::statement('ALTER TABLE audits VALIDATE CONSTRAINT '.$constraint);
}
private function concurrently(): string
{
return DB::transactionLevel() === 0 ? ' CONCURRENTLY' : '';
}
};

View File

@@ -19,7 +19,6 @@ const resourcePages = [
{ path: '/admin/project-members', heading: 'Project Members' }, { path: '/admin/project-members', heading: 'Project Members' },
{ path: '/admin/tokens', heading: 'Tokens' }, { path: '/admin/tokens', heading: 'Tokens' },
{ path: '/admin/failed-jobs', heading: 'Failed Jobs' }, { path: '/admin/failed-jobs', heading: 'Failed Jobs' },
{ path: '/admin/audits', heading: 'Audits' },
]; ];
test.describe('Admin Panel Access', () => { test.describe('Admin Panel Access', () => {

View File

@@ -1,14 +1,18 @@
{ {
"Billing": { "Billing": {
"repository": "solidtime-io/extension-billing", "repository": "solidtime-io/extension-billing",
"ref": "v0.0.7" "ref": "v0.0.9"
}, },
"Services": { "Services": {
"repository": "solidtime-io/extension-services", "repository": "solidtime-io/extension-services",
"ref": "v0.0.3" "ref": "v0.0.4"
}, },
"Invoicing": { "Invoicing": {
"repository": "solidtime-io/extension-invoicing", "repository": "solidtime-io/extension-invoicing",
"ref": "v0.0.7" "ref": "v0.0.8"
},
"Auditing": {
"repository": "solidtime-io/extension-auditing",
"ref": "v0.0.4"
} }
} }

View File

@@ -111,6 +111,8 @@ export function useEventResize(params: {
edge: 'start' | 'end', edge: 'start' | 'end',
dayStr: string dayStr: string
) { ) {
if (e.button !== 0) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();

View File

@@ -168,6 +168,10 @@ defineExpose({ submit, focusAfterStart });
data-testid="time_entry_description" data-testid="time_entry_description"
class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary focus:ring-0 transition" class="w-full rounded-l-lg py-4 sm:py-2.5 px-3.5 border-b border-b-card-background-separator @2xl:px-4 text-base text-text-primary bg-transparent border-none placeholder-text-secondary focus:ring-0 transition"
type="text" type="text"
autocomplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
@keydown.enter="submit" @keydown.enter="submit"
@keydown.esc="showDropdown = false" @keydown.esc="showDropdown = false"
@blur="updateTimeEntryDescription" /> @blur="updateTimeEntryDescription" />

View File

@@ -170,6 +170,10 @@ function closeAndFocusInput() {
: 'text-text-primary bg-card-background border-border-secondary border border-none' : 'text-text-primary bg-card-background border-border-secondary border border-none'
" "
type="text" type="text"
autocomplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
@focusin="openModalOnTab" @focusin="openModalOnTab"
@click="openModalOnClick" @click="openModalOnClick"
@keydown.exact.tab="focusNextElement" @keydown.exact.tab="focusNextElement"

View File

@@ -1,59 +0,0 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Filament\Resources;
use App\Filament\Resources\AuditResource;
use App\Models\Audit;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\Unit\Filament\FilamentTestCase;
#[UsesClass(AuditResource::class)]
class AuditResourceTest extends FilamentTestCase
{
protected function setUp(): void
{
parent::setUp();
Config::set('auth.super_admins', ['admin@example.com']);
$user = User::factory()->withPersonalOrganization()->create([
'email' => 'admin@example.com',
]);
$this->actingAs($user);
}
public function test_can_list_audits(): void
{
// Arrange
$user = $this->createUserWithPermission();
$timeEntry = TimeEntry::factory()->forMember($user->member)->create();
DB::table((new Audit)->getTable())->delete();
$audits = Audit::factory()->auditFor($timeEntry)->auditUser($user->user)->createMany(5);
// Act
$response = Livewire::test(AuditResource\Pages\ListAudits::class);
// Assert
$response->assertSuccessful();
$response->assertCanSeeTableRecords($audits);
}
public function test_can_see_view_page_of_audit(): void
{
// Arrange
DB::table((new Audit)->getTable())->delete();
$audit = Audit::factory()->create();
// Act
$response = Livewire::test(AuditResource\Pages\ViewAudit::class, ['record' => $audit->getKey()]);
// Assert
$response->assertSuccessful();
}
}

View File

@@ -50,7 +50,8 @@ class TrustHostsTest extends TestCase
$this->middleware()->handle($request, fn (Request $request): Response => new Response('passed')); $this->middleware()->handle($request, fn (Request $request): Response => new Response('passed'));
try { try {
dump($request->getHost()); // Note: Throws an exception if the host is not trusted
$request->getHost();
return true; return true;
} catch (\Throwable) { } catch (\Throwable) {

View File

@@ -0,0 +1,209 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Model;
use App\Models\Audit;
use App\Models\Concerns\AuditableWithoutOwner;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\DB;
use PHPUnit\Framework\Attributes\CoversClass;
#[CoversClass(Audit::class)]
class AuditModelTest extends ModelTestAbstract
{
public function test_it_belongs_to_an_owner_organization(): void
{
// Arrange
$organization = Organization::factory()->create();
$audit = Audit::factory()->create([
'owner_organization_id' => $organization->getKey(),
]);
// Act
$audit->refresh();
$ownerOrganizationRel = $audit->ownerOrganization;
// Assert
$this->assertNotNull($ownerOrganizationRel);
$this->assertTrue($ownerOrganizationRel->is($organization));
}
public function test_it_belongs_to_an_owner_user(): void
{
// Arrange
$user = User::factory()->create();
$audit = Audit::factory()->create([
'owner_user_id' => $user->getKey(),
]);
// Act
$audit->refresh();
$ownerUserRel = $audit->ownerUser;
// Assert
$this->assertNotNull($ownerUserRel);
$this->assertTrue($ownerUserRel->is($user));
}
public function test_audits_of_models_with_organization_have_the_organization_as_owner(): void
{
// Arrange
$organization = Organization::factory()->create();
$member = Member::factory()->forOrganization($organization)->create();
// Act
$timeEntry = TimeEntry::factory()->forOrganization($organization)->forMember($member)->create();
// Assert
$audit = Audit::query()->where('auditable_id', $timeEntry->getKey())->sole();
$this->assertSame($organization->getKey(), $audit->owner_organization_id);
$this->assertNull($audit->owner_user_id);
}
public function test_audits_of_project_members_have_the_organization_of_the_project_as_owner(): void
{
// Arrange
$organization = Organization::factory()->create();
$project = Project::factory()->forOrganization($organization)->create();
$member = Member::factory()->forOrganization($organization)->create();
// Act
$projectMember = ProjectMember::factory()->forProject($project)->forMember($member)->create();
// Assert
$audit = Audit::query()->where('auditable_id', $projectMember->getKey())->sole();
$this->assertSame($organization->getKey(), $audit->owner_organization_id);
$this->assertNull($audit->owner_user_id);
}
public function test_owner_organization_through_parent_uses_the_loaded_parent_without_query(): void
{
// Arrange
$organization = Organization::factory()->create();
$project = Project::factory()->forOrganization($organization)->create();
$projectMember = ProjectMember::factory()->forProject($project)->forMember(Member::factory()->forOrganization($organization)->create())->create();
$projectMember->load('project');
DB::enableQueryLog();
// Act
$ownerOrganizationId = $projectMember->getAuditOwnerOrganizationId();
// Assert
$this->assertSame($organization->getKey(), $ownerOrganizationId);
$this->assertCount(0, DB::getQueryLog());
}
public function test_owner_organization_through_parent_ignores_a_loaded_parent_that_does_not_match_the_foreign_key(): void
{
// Arrange
$organization = Organization::factory()->create();
$otherOrganization = Organization::factory()->create();
$project = Project::factory()->forOrganization($organization)->create();
$otherProject = Project::factory()->forOrganization($otherOrganization)->create();
$projectMember = ProjectMember::factory()->forProject($project)->forMember(Member::factory()->forOrganization($organization)->create())->create();
$projectMember->load('project');
$projectMember->project_id = $otherProject->getKey();
// Act
$ownerOrganizationId = $projectMember->getAuditOwnerOrganizationId();
// Assert
$this->assertSame($otherOrganization->getKey(), $ownerOrganizationId);
}
public function test_audits_of_an_organization_have_the_organization_itself_as_owner(): void
{
// Act
$organization = Organization::factory()->create();
// Assert
$audit = Audit::query()->where('auditable_id', $organization->getKey())->sole();
$this->assertSame($organization->getKey(), $audit->owner_organization_id);
$this->assertNull($audit->owner_user_id);
}
public function test_audits_of_a_user_have_the_user_itself_as_owner(): void
{
// Act
$user = User::factory()->create();
// Assert
$audit = Audit::query()->where('auditable_id', $user->getKey())->sole();
$this->assertSame($user->getKey(), $audit->owner_user_id);
$this->assertNull($audit->owner_organization_id);
}
public function test_deleting_an_owner_deletes_its_audits_and_does_not_create_a_deletion_audit(): void
{
// Arrange
$user = User::factory()->create();
$organization = Organization::factory()->create();
$otherUser = User::factory()->create();
// Act
$user->delete();
$organization->delete();
// Assert
$this->assertSame(0, Audit::query()->where('auditable_id', $user->getKey())->count());
$this->assertSame(0, Audit::query()->where('auditable_id', $organization->getKey())->count());
$this->assertSame(1, Audit::query()->where('auditable_id', $otherUser->getKey())->count());
}
public function test_auditable_types_without_owner_are_determined_by_the_marker_interface(): void
{
// Arrange
$originalMorphMap = Relation::morphMap();
$modelWithoutOwner = new class extends Model implements AuditableWithoutOwner {};
Relation::morphMap(['model-without-owner' => $modelWithoutOwner::class]);
try {
// Act
$typesWithoutOwner = Audit::getAuditableTypesWithoutOwner();
$isWithoutOwner = Audit::isAuditableTypeWithoutOwner('model-without-owner');
$isTimeEntryWithoutOwner = Audit::isAuditableTypeWithoutOwner((new TimeEntry)->getMorphClass());
// Assert
$this->assertContains('model-without-owner', $typesWithoutOwner);
$this->assertNotContains((new TimeEntry)->getMorphClass(), $typesWithoutOwner);
$this->assertTrue($isWithoutOwner);
$this->assertFalse($isTimeEntryWithoutOwner);
} finally {
Relation::morphMap($originalMorphMap, false);
}
}
public function test_scope_where_missing_owner_only_returns_audits_without_owner_whose_type_should_have_one(): void
{
// Arrange
$originalMorphMap = Relation::morphMap();
$modelWithoutOwner = new class extends Model implements AuditableWithoutOwner {};
Relation::morphMap(['model-without-owner' => $modelWithoutOwner::class]);
$organization = Organization::factory()->create();
$user = User::factory()->create();
Audit::query()->delete();
$missingOwnerAudit = Audit::factory()->create(['auditable_type' => (new TimeEntry)->getMorphClass()]);
Audit::factory()->create(['auditable_type' => (new TimeEntry)->getMorphClass(), 'owner_organization_id' => $organization->getKey()]);
Audit::factory()->create(['owner_user_id' => $user->getKey()]);
Audit::factory()->create(['auditable_type' => 'model-without-owner']);
try {
// Act
$auditIds = Audit::query()->whereMissingOwner()->pluck('id')->all();
// Assert
$this->assertSame([$missingOwnerAudit->getKey()], $auditIds);
} finally {
Relation::morphMap($originalMorphMap, false);
}
}
}

View File

@@ -7,9 +7,13 @@ namespace Tests\Unit\Service;
use App\Enums\Role; use App\Enums\Role;
use App\Events\BeforeOrganizationDeletion; use App\Events\BeforeOrganizationDeletion;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Models\Audit;
use App\Models\Client; use App\Models\Client;
use App\Models\Member; use App\Models\Member;
use App\Models\Organization; use App\Models\Organization;
use App\Models\Passport\Client as PassportClient;
use App\Models\Passport\RefreshToken;
use App\Models\Passport\Token;
use App\Models\Project; use App\Models\Project;
use App\Models\ProjectMember; use App\Models\ProjectMember;
use App\Models\Report; use App\Models\Report;
@@ -23,6 +27,7 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase; use Tests\TestCaseWithDatabase;
use TiMacDonald\Log\LogEntry; use TiMacDonald\Log\LogEntry;
@@ -178,6 +183,8 @@ class DeletionServiceTest extends TestCaseWithDatabase
// Assert // Assert
$this->assertOrganizationDeleted($organization->organization); $this->assertOrganizationDeleted($organization->organization);
$this->assertOrganizationNothingDeleted($otherOrganization->organization); $this->assertOrganizationNothingDeleted($otherOrganization->organization);
$this->assertSame(0, Audit::query()->where('owner_organization_id', $organization->organization->getKey())->count());
$this->assertGreaterThan(0, Audit::query()->where('owner_organization_id', $otherOrganization->organization->getKey())->count());
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug' Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
&& $log->message === 'Start deleting organization' && $log->message === 'Start deleting organization'
&& $log->context['organization_id'] === $organization->organization->getKey(), && $log->context['organization_id'] === $organization->organization->getKey(),
@@ -314,6 +321,10 @@ class DeletionServiceTest extends TestCaseWithDatabase
$this->assertDatabaseMissing(Member::class, [ $this->assertDatabaseMissing(Member::class, [
'user_id' => $user->getKey(), 'user_id' => $user->getKey(),
]); ]);
$this->assertSame(0, Audit::query()->where('owner_user_id', $user->getKey())->count());
$this->assertSame(0, Audit::query()->where('owner_organization_id', $user->current_team_id)->count());
$this->assertGreaterThan(0, Audit::query()->where('owner_user_id', $otherUser->getKey())->count());
$this->assertGreaterThan(0, Audit::query()->where('owner_organization_id', $otherUser->current_team_id)->count());
Storage::disk(config('filesystems.public'))->assertMissing($user->profile_photo_path); Storage::disk(config('filesystems.public'))->assertMissing($user->profile_photo_path);
Storage::disk(config('filesystems.public'))->assertExists($otherUser->profile_photo_path); Storage::disk(config('filesystems.public'))->assertExists($otherUser->profile_photo_path);
Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug' Log::assertLoggedTimes(fn (LogEntry $log) => $log->level === 'debug'
@@ -328,6 +339,24 @@ class DeletionServiceTest extends TestCaseWithDatabase
); );
} }
public function test_delete_user_keeps_audits_of_other_organizations_where_the_user_is_the_actor(): void
{
// Arrange
$user = User::factory()->withPersonalOrganization()->create();
$otherOrganization = Organization::factory()->create();
$audit = Audit::factory()->auditUser($user)->auditFor($otherOrganization)->create([
'owner_organization_id' => $otherOrganization->getKey(),
]);
// Act
$this->deletionService->deleteUser($user);
// Assert
$audit->refresh();
$this->assertSame($user->getKey(), $audit->actor_id);
$this->assertSame($otherOrganization->getKey(), $audit->owner_organization_id);
}
public function test_delete_user_deletes_owned_organizations_that_have_only_one_member_and_makes_makes_the_user_placeholder_in_not_owned_organizations(): void public function test_delete_user_deletes_owned_organizations_that_have_only_one_member_and_makes_makes_the_user_placeholder_in_not_owned_organizations(): void
{ {
// Arrange // Arrange
@@ -424,4 +453,45 @@ class DeletionServiceTest extends TestCaseWithDatabase
'role' => Role::Placeholder->value, 'role' => Role::Placeholder->value,
]); ]);
} }
public function test_delete_user_deletes_access_tokens_and_their_refresh_tokens_but_does_not_delete_tokens_of_other_users(): void
{
// Arrange
$user = User::factory()->create();
$otherUser = User::factory()->create();
$passportClient = PassportClient::factory()->create();
$userToken = Token::factory()->forUser($user)->forClient($passportClient)->create();
$userRefreshToken = RefreshToken::query()->create([
'id' => Str::random(100),
'access_token_id' => $userToken->getKey(),
'revoked' => false,
'expires_at' => now()->addDays(30),
]);
$otherUserToken = Token::factory()->forUser($otherUser)->forClient($passportClient)->create();
$otherUserRefreshToken = RefreshToken::query()->create([
'id' => Str::random(100),
'access_token_id' => $otherUserToken->getKey(),
'revoked' => false,
'expires_at' => now()->addDays(30),
]);
// Act
$this->deletionService->deleteUser($user);
// Assert
$this->assertDatabaseMissing(Token::class, [
'id' => $userToken->getKey(),
]);
$this->assertDatabaseMissing(RefreshToken::class, [
'id' => $userRefreshToken->getKey(),
]);
$this->assertDatabaseHas(Token::class, [
'id' => $otherUserToken->getKey(),
]);
$this->assertDatabaseHas(RefreshToken::class, [
'id' => $otherUserRefreshToken->getKey(),
]);
}
} }