Compare commits

...

26 Commits

Author SHA1 Message Date
Constantin Graf
e5b99da13e Merge remote-tracking branch 'origin/main' into fix/api-docs-scramble
# Conflicts:
#	tests/Unit/ApiDocs/ApiDocsExportTest.php
2026-10-08 13:04:47 +02:00
Constantin Graf
ad9fcdb338 Updated invoicing extension to v0.0.9 2026-10-08 13:03:14 +02:00
Constantin Graf
7a3a9d7089 Fix Scramble API docs warnings
Remove redundant @var annotations from API resources and defer the user
access in the email unique rule of UserUpdateRequest, so rules() can be
evaluated by Scramble without route model binding. Add a test that fails
on any Scramble diagnostic and tests for the email unique rule.
2026-10-08 12:57:56 +02:00
Constantin Graf
ed2f19f58b Fix API docs generation with Scramble 0.13
Scramble 0.13 moved the collected resource type of resource collections
to a different template slot, which broke the custom paginated resource
collection extension and the API docs export. Scramble now infers
paginated responses natively, so the extension, the marker interface and
the @return annotations that overrode the inference are removed.

Scramble 0.13 also documents closure routes, so the API fallback routes
are moved to a controller that is excluded from the docs.

Add a test that exports the API docs.
2026-10-08 12:38:03 +02:00
Constantin Graf
f23ed53059 Fix API docs generation with Scramble 0.13
Scramble 0.13 moved the collected resource type of resource collections
to a different template slot, which broke the custom paginated resource
collection extension and the API docs export. Scramble now infers
paginated responses natively, so the extension, the marker interface and
the @return annotations that overrode the inference are removed.

Scramble 0.13 also documents closure routes, so the API fallback routes
are moved to a controller that is excluded from the docs.

Add a test that exports the API docs.
2026-10-08 12:09:16 +02:00
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
82 changed files with 1151 additions and 537 deletions

View File

@@ -103,6 +103,8 @@ jobs:
{
echo "invoicing_repository=$(jq -r '.Invoicing.repository' 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"
- name: "Checkout invoicing extension"
@@ -119,9 +121,26 @@ jobs:
- name: "Install npm dependencies in invoicing extension"
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"
run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies"
run: npm ci

View File

@@ -84,6 +84,8 @@ jobs:
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)"
echo "auditing_repository=$(jq -r '.Auditing.repository' extensions/manifest.json)"
echo "auditing_ref=$(jq -r '.Auditing.ref' extensions/manifest.json)"
} >> "$GITHUB_OUTPUT"
- name: "Checkout billing extension"
@@ -145,6 +147,25 @@ jobs:
- name: "Install npm dependencies in invoicing extension"
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"
uses: shivammathur/setup-php@v2
with:
@@ -168,6 +189,9 @@ jobs:
- name: "Activate invoicing extension"
run: php artisan module:enable Invoicing
- name: "Activate auditing extension"
run: php artisan module:enable Auditing
- name: "Install npm dependencies"
run: npm ci

View File

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

View File

@@ -1,107 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Extensions\Scramble;
use App\Http\Resources\PaginatedResourceCollection;
use App\Http\Resources\V1\TimeEntry\TimeEntryCollection;
use Dedoc\Scramble\Extensions\TypeToSchemaExtension;
use Dedoc\Scramble\Support\Generator\Response;
use Dedoc\Scramble\Support\Generator\Schema;
use Dedoc\Scramble\Support\Generator\Types\ArrayType;
use Dedoc\Scramble\Support\Generator\Types\BooleanType;
use Dedoc\Scramble\Support\Generator\Types\IntegerType;
use Dedoc\Scramble\Support\Generator\Types\ObjectType as OpenApiObjectType;
use Dedoc\Scramble\Support\Generator\Types\StringType;
use Dedoc\Scramble\Support\Type\Generic;
use Dedoc\Scramble\Support\Type\ObjectType;
use Dedoc\Scramble\Support\Type\Type;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Resources\Json\JsonResource;
class PaginatedResourceCollectionTypeToSchema extends TypeToSchemaExtension
{
public function shouldHandle(Type $type): bool
{
return $type instanceof ObjectType
&& $type->isInstanceOf(PaginatedResourceCollection::class);
}
public function toSchema(Type $type): ?OpenApiObjectType
{
/** @var Type|null $collectingClassType */
$collectingClassType = $type->templateTypes[0] ?? null;
if (! $collectingClassType instanceof ObjectType) {
return null;
}
if (! $collectingClassType->isInstanceOf(JsonResource::class) && ! $collectingClassType->isInstanceOf(Model::class)) {
return null;
}
$collectingType = $this->openApiTransformer->transform($collectingClassType);
$newType = new OpenApiObjectType;
$newType->addProperty('data', (new ArrayType)->setItems($collectingType));
if ($type instanceof ObjectType && $type->isInstanceOf(TimeEntryCollection::class)) {
$newType->addProperty(
'meta',
(new OpenApiObjectType)
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->setRequired(['total'])
);
$newType->setRequired(['data', 'meta']);
} else {
$newType->addProperty(
'links',
(new OpenApiObjectType)
->addProperty('first', (new StringType)->nullable(true))
->addProperty('last', (new StringType)->nullable(true))
->addProperty('prev', (new StringType)->nullable(true))
->addProperty('next', (new StringType)->nullable(true))
->setRequired(['first', 'last', 'prev', 'next'])
);
$newType->addProperty(
'meta',
(new OpenApiObjectType)
->addProperty('current_page', new IntegerType)
->addProperty('from', (new IntegerType)->nullable(true))
->addProperty('last_page', new IntegerType)
->addProperty('links', (new ArrayType)->setItems(
(new OpenApiObjectType)
->addProperty('url', (new StringType)->nullable(true))
->addProperty('label', new StringType)
->addProperty('active', new BooleanType)
->setRequired(['url', 'label', 'active'])
)->setDescription('Generated paginator links.'))
->addProperty('path', (new StringType)->nullable(true)->setDescription('Base path for paginator generated URLs.'))
->addProperty('per_page', (new IntegerType)->setDescription('Number of items shown per page.'))
->addProperty('to', (new IntegerType)->nullable(true)->setDescription('Number of the last item in the slice.'))
->addProperty('total', (new IntegerType)->setDescription('Total number of items being paginated.'))
->setRequired(['current_page', 'from', 'last_page', 'links', 'path', 'per_page', 'to', 'total'])
);
$newType->setRequired(['data', 'links', 'meta']);
}
return $newType;
}
/**
* @param Generic $type
*/
public function toResponse(Type $type): ?Response
{
/** @var ObjectType|null $collectingClassType */
$collectingClassType = $type->templateTypes[0] ?? null;
if (! $collectingClassType instanceof ObjectType) {
return null;
}
$type = $this->toSchema($type);
return Response::make(200)
->description('Paginated set of `'.$this->components->uniqueSchemaName($collectingClassType->name).'`')
->setContent('application/json', Schema::fromType($type));
}
}

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

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Dedoc\Scramble\Attributes\ExcludeAllRoutesFromDocs;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Fallback for unknown /api/* routes, to prevent a rendered HTML page
*/
#[ExcludeAllRoutesFromDocs]
class FallbackController extends Controller
{
public function __invoke(): never
{
throw new NotFoundHttpException('API resource not found');
}
}

View File

@@ -29,8 +29,6 @@ class ClientController extends Controller
/**
* Get clients
*
* @return ClientCollection<ClientResource>
*
* @throws AuthorizationException
*
* @operationId getClients

View File

@@ -9,7 +9,6 @@ use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
use App\Http\Resources\V1\Invitation\InvitationCollection;
use App\Http\Resources\V1\Invitation\InvitationResource;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Service\InvitationService;
@@ -30,8 +29,6 @@ class InvitationController extends Controller
/**
* List all invitations of an organization
*
* @return InvitationCollection<InvitationResource>
*
* @throws AuthorizationException
*
* @operationId getInvitations
@@ -88,6 +85,8 @@ class InvitationController extends Controller
/**
* 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
*
* @operationId removeInvitation

View File

@@ -48,8 +48,6 @@ class MemberController extends Controller
/**
* List all members of an organization
*
* @return MemberCollection<MemberResource>
*
* @throws AuthorizationException
*
* @operationId getMembers
@@ -145,6 +143,9 @@ class MemberController extends Controller
/**
* 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 OnlyPlaceholdersCanBeMergedIntoAnotherMember
* @throws Throwable

View File

@@ -35,8 +35,6 @@ class ProjectController extends Controller
/**
* Get projects visible to the current user
*
* @return ProjectCollection<ProjectResource>
*
* @throws AuthorizationException
*
* @operationId getProjects

View File

@@ -36,8 +36,6 @@ class ProjectMemberController extends Controller
/**
* Get project members for project
*
* @return ProjectMemberCollection<ProjectMemberResource>
*
* @throws AuthorizationException
*
* @operationId getProjectMembers

View File

@@ -10,7 +10,6 @@ use App\Http\Requests\V1\Report\ReportStoreRequest;
use App\Http\Requests\V1\Report\ReportUpdateRequest;
use App\Http\Resources\V1\Report\DetailedReportResource;
use App\Http\Resources\V1\Report\ReportCollection;
use App\Http\Resources\V1\Report\ReportResource;
use App\Models\Organization;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
@@ -35,8 +34,6 @@ class ReportController extends Controller
/**
* Get reports
*
* @return ReportCollection<ReportResource>
*
* @throws AuthorizationException
*
* @operationId getReports
@@ -71,6 +68,8 @@ class ReportController extends Controller
/**
* 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
*
* @operationId createReport

View File

@@ -29,8 +29,6 @@ class TagController extends Controller
/**
* Get tags
*
* @return TagCollection<TagResource>
*
* @operationId getTags
*
* @throws AuthorizationException

View File

@@ -51,8 +51,6 @@ class TaskController extends Controller
/**
* Get tasks
*
* @return TaskCollection<TaskResource>
*
* @throws AuthorizationException
*
* @operationId getTasks

View File

@@ -109,10 +109,13 @@ 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`.
* 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.
*
* @return TimeEntryCollection<TimeEntryResource>
* 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`).
*
* @throws AuthorizationException
*
@@ -351,6 +354,11 @@ class TimeEntryController extends Controller
* 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.
*
* 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
*
* @return array{
@@ -584,6 +592,12 @@ class TimeEntryController extends Controller
/**
* 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 TimeEntryStillRunningApiException
*
@@ -634,6 +648,8 @@ class TimeEntryController extends Controller
/**
* 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
*
* @operationId updateTimeEntry
@@ -702,6 +718,10 @@ class TimeEntryController extends Controller
/**
* 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
*
* @throws AuthorizationException

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
public function rules(): array
{
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' => [
'string',
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
@@ -155,7 +155,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
'string',
Rule::enum(TimeEntryType::class),
],
// Limit the number of returned time entries (default: 150)
// Limit the number of returned time entries (default: 100)
'limit' => [
'integer',
'min:1',

View File

@@ -32,7 +32,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
public function rules(): array
{
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' => [
'required',
'string',
@@ -86,7 +86,7 @@ class TimeEntryStoreRequest extends BaseFormRequest
'date_format:Y-m-d\TH:i:s\Z',
'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' => [
'required',
'boolean',

View File

@@ -43,9 +43,10 @@ class UserUpdateRequest extends BaseFormRequest
'email' => [
'email:rfc,strict',
'max:255',
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
UniqueEloquent::make(User::class, 'email')->query(function (Builder $query) {
/** @var Builder<User> $query */
return $query->where('is_placeholder', '=', false);
return $query->where('is_placeholder', '=', false)
->whereKeyNot($this->user->getKey());
}),
],
'photo' => [

View File

@@ -1,7 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources;
interface PaginatedResourceCollection {}

View File

@@ -21,15 +21,15 @@ class ApiTokenResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the API token, this ID is NOT a UUID */
/** ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id,
/** @var string $name Name of the API token */
'name' => $this->resource->name,
/** @var bool $revoked Whether the API token is revoked */
/** Whether the API token is revoked */
'revoked' => $this->resource->revoked,
/** @var array<string> $scopes List of scopes that the API token has */
/** List of scopes that the API token has */
'scopes' => $this->resource->scopes,
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at),

View File

@@ -29,15 +29,15 @@ class ApiTokenWithAccessTokenResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the API token, this ID is NOT a UUID */
/** ID of the API token, this ID is NOT a UUID */
'id' => $this->resource->id,
/** @var string $name Name of the API token */
'name' => $this->resource->name,
/** @var bool $revoked Whether the API token is revoked */
/** Whether the API token is revoked */
'revoked' => $this->resource->revoked,
/** @var array<string> $scopes List of scopes that the API token has */
/** List of scopes that the API token has */
'scopes' => $this->resource->scopes,
/** @var string $created_at When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string|null $expires_at At what time the API token expires (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
'expires_at' => $this->formatDateTime($this->resource->expires_at),

View File

@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Client;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
class ClientCollection extends ResourceCollection
{
/**
* The resource that this resource collects.

View File

@@ -21,15 +21,15 @@ class ClientResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID */
/** ID */
'id' => $this->resource->id,
/** @var string $name Name */
/** Name */
'name' => $this->resource->name,
/** @var bool $is_archived Whether the client is archived */
/** Whether the client is archived */
'is_archived' => $this->resource->is_archived,
/** @var string $created_at When the tag was created */
/** When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at When the tag was last updated */
/** When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}

View File

@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Invitation;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class InvitationCollection extends ResourceCollection implements PaginatedResourceCollection
class InvitationCollection extends ResourceCollection
{
/**
* The resource that this resource collects.

View File

@@ -21,11 +21,11 @@ class InvitationResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the invitation */
/** ID of the invitation */
'id' => $this->resource->id,
/** @var string $email Email */
/** Email */
'email' => $this->resource->email,
/** @var string $role Role */
/** Role */
'role' => $this->resource->role,
];
}

View File

@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Member;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class MemberCollection extends ResourceCollection implements PaginatedResourceCollection
class MemberCollection extends ResourceCollection
{
/**
* The resource that this resource collects.

View File

@@ -22,19 +22,19 @@ class MemberResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID of membership */
/** ID of membership */
'id' => $this->resource->id,
/** @var string $id ID of user */
/** ID of user */
'user_id' => $this->resource->user->id,
/** @var string $name Name */
/** Name */
'name' => $this->resource->user->name,
/** @var string $email Email */
/** Email */
'email' => $this->resource->user->email,
/** @var string $role Role */
/** Role */
'role' => $this->resource->role,
/** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
/** Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
'is_placeholder' => $this->resource->user->is_placeholder,
/** @var int|null $billable_rate Billable rate in cents per hour */
/** Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,
];
}

View File

@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Member;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class PersonalMembershipCollection extends ResourceCollection implements PaginatedResourceCollection
class PersonalMembershipCollection extends ResourceCollection
{
/**
* The resource that this resource collects.

View File

@@ -21,17 +21,17 @@ class PersonalMembershipResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID of membership */
/** ID of membership */
'id' => $this->resource->id,
'organization' => [
/** @var string $id ID of organization */
/** ID of organization */
'id' => $this->resource->organization->id,
/** @var string $name Name of organization */
/** Name of organization */
'name' => $this->resource->organization->name,
/** @var string $currency Currency code (ISO 4217) of organization */
/** Currency code (ISO 4217) of organization */
'currency' => $this->resource->organization->currency,
],
/** @var string $role Role */
/** Role */
'role' => $this->resource->role,
];
}

View File

@@ -43,23 +43,23 @@ class OrganizationResource extends BaseResource
$currencyService = app(CurrencyService::class);
return [
/** @var string $id ID */
/** ID */
'id' => $this->resource->id,
/** @var string $name Name */
/** Name */
'name' => $this->resource->name,
/** @var bool $color Personal organizations automatically created after registration */
/** Personal organizations automatically created after registration */
'is_personal' => $this->resource->personal_team,
/** @var int|null $billable_rate Billable rate in cents per hour */
/** Billable rate in cents per hour */
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @var bool $employees_can_see_billable_rates Can members of the organization with role "employee" see the billable rates */
/** Can members of the organization with role "employee" see the billable rates */
'employees_can_see_billable_rates' => $this->resource->employees_can_see_billable_rates,
/** @var bool $employees_can_manage_tasks Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
/** Prevent 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 */
/** Whether members of the organization can track breaks */
'breaks_enabled' => $this->resource->breaks_enabled,
/** @var string $currency Currency code (ISO 4217) */
/** Currency code (ISO 4217) */
'currency' => $this->resource->currency,
/** @var string $currency_symbol Currency symbol */
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),

View File

@@ -4,12 +4,11 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Project;
use App\Http\Resources\PaginatedResourceCollection;
use App\Models\Project;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
class ProjectCollection extends ResourceCollection
{
private bool $showBillableRates;

View File

@@ -30,25 +30,25 @@ class ProjectResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID of project */
/** ID of project */
'id' => $this->resource->id,
/** @var string $name Name of project */
/** Name of project */
'name' => $this->resource->name,
/** @var string $color Color of project */
/** Color of project */
'color' => $this->resource->color,
/** @var string|null $client_id ID of client */
'client_id' => $this->resource->client_id,
/** @var bool $is_archived Whether the client is archived */
/** Whether the client is archived */
'is_archived' => $this->resource->is_archived,
/** @var int|null $billable_rate Billable rate in cents per hour */
/** Billable rate in cents per hour */
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
/** @var bool $is_billable Project time entries billable default */
/** Project time entries billable default */
'is_billable' => $this->resource->is_billable,
/** @var int|null $estimated_time Estimated time in seconds */
/** Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time,
/** @var int $spent_time Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
/** Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
'spent_time' => $this->resource->spent_time,
/** @var bool $is_public Whether the project is public */
/** Whether the project is public */
'is_public' => $this->resource->is_public,
];
}

View File

@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\ProjectMember;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ProjectMemberCollection extends ResourceCollection implements PaginatedResourceCollection
class ProjectMemberCollection extends ResourceCollection
{
/**
* The resource that this resource collects.

View File

@@ -21,13 +21,13 @@ class ProjectMemberResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID of project member */
/** ID of project member */
'id' => $this->resource->id,
/** @var int|null $billable_rate Billable rate in cents per hour */
/** Billable rate in cents per hour */
'billable_rate' => $this->resource->billable_rate,
/** @var string $member_id ID of the organization member */
/** ID of the organization member */
'member_id' => $this->resource->member_id,
/** @var string $project_id ID of the project */
/** ID of the project */
'project_id' => $this->resource->project_id,
];
}

View File

@@ -21,36 +21,36 @@ class DetailedReportResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the report */
/** ID of the report */
'id' => $this->resource->id,
/** @var string $name Name */
/** Name */
'name' => $this->resource->name,
/** @var string|null $email Description */
/** Description */
'description' => $this->resource->description,
/** @var bool $is_public Whether the report can be accessed via an external link */
/** Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(),
'properties' => [
/** @var string $group Type of first grouping */
/** Type of first grouping */
'group' => $this->resource->properties->group->value,
/** @var string $sub_group Type of second grouping */
/** Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value,
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
/** Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */
/** Start date of the report */
'start' => $this->formatDateTime($this->resource->properties->start),
/** @var string $end End date of the report */
/** End date of the report */
'end' => $this->formatDateTime($this->resource->properties->end),
/** @var bool|null $active Whether the report is active */
/** Whether the report is active */
'active' => $this->resource->properties->active,
/** @var array<string>|null $member_ids Filter by multiple member IDs, member IDs are OR combined */
'member_ids' => $this->resource->properties->memberIds?->toArray(),
/** @var bool|null $billable Filter by billable status */
/** Filter by billable status */
'billable' => $this->resource->properties->billable,
/** @var string|null $time_entry_type Filter by 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(),
@@ -58,18 +58,18 @@ class DetailedReportResource extends BaseResource
'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 */
'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 */
/** Rounding type for time entries */
'rounding_type' => $this->resource->properties->roundingType?->value,
/** @var int|null $rounding_minutes Rounding minutes for time entries */
/** Rounding minutes for time entries */
'rounding_minutes' => $this->resource->properties->roundingMinutes,
],
/** @var string $created_at Date when the report was created */
/** Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at Date when the report was last updated */
/** Date when the report was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}

View File

@@ -73,13 +73,13 @@ class DetailedWithDataReportResource extends BaseResource
$currencyService = app(CurrencyService::class);
return [
/** @var string $name Name */
/** Name */
'name' => $this->resource->name,
/** @var string|null $email Description */
/** Description */
'description' => $this->resource->description,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string $currency Currency code (ISO 4217) */
/** Currency code (ISO 4217) */
'currency' => $this->resource->organization->currency,
/** @var NumberFormat $number_format Number format */
'number_format' => $this->resource->organization->number_format->value,
@@ -94,15 +94,15 @@ class DetailedWithDataReportResource extends BaseResource
/** @var TimeFormat $time_format Time format */
'time_format' => $this->resource->organization->time_format->value,
'properties' => [
/** @var string $group Type of first grouping */
/** Type of first grouping */
'group' => $this->resource->properties->group->value,
/** @var string $sub_group Type of second grouping */
/** Type of second grouping */
'sub_group' => $this->resource->properties->subGroup->value,
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
/** Type of grouping of the historic aggregation (time chart) */
'history_group' => $this->resource->properties->historyGroup->value,
/** @var string $start Start date of the report */
/** Start date of the report */
'start' => $this->formatDateTime($this->resource->properties->start),
/** @var string $end End date of the report */
/** End date of the report */
'end' => $this->formatDateTime($this->resource->properties->end),
],
/** @var array{

View File

@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Report;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ReportCollection extends ResourceCollection implements PaginatedResourceCollection
class ReportCollection extends ResourceCollection
{
/**
* The resource that this resource collects.

View File

@@ -21,21 +21,21 @@ class ReportResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID of the report */
/** ID of the report */
'id' => $this->resource->id,
/** @var string $name Name */
/** Name */
'name' => $this->resource->name,
/** @var string|null $email Description */
/** Description */
'description' => $this->resource->description,
/** @var bool $is_public Whether the report can be accessed via an external link */
/** Whether the report can be accessed via an external link */
'is_public' => $this->resource->is_public,
/** @var string|null $public_until Date until the report is public */
'public_until' => $this->formatDateTime($this->resource->public_until),
/** @var string|null $shareable_link Get link to access the report externally, not set if the report is private */
'shareable_link' => $this->resource->getShareableLink(),
/** @var string $created_at Date when the report was created */
/** Date when the report was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at Date when the report was last updated */
/** Date when the report was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}

View File

@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Tag;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class TagCollection extends ResourceCollection implements PaginatedResourceCollection
class TagCollection extends ResourceCollection
{
/**
* The resource that this resource collects.

View File

@@ -21,13 +21,13 @@ class TagResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID */
/** ID */
'id' => $this->resource->id,
/** @var string $name Name */
/** Name */
'name' => $this->resource->name,
/** @var string $created_at When the tag was created */
/** When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at When the tag was last updated */
/** When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}

View File

@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\Task;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class TaskCollection extends ResourceCollection implements PaginatedResourceCollection
class TaskCollection extends ResourceCollection
{
/**
* The resource that this resource collects.

View File

@@ -22,21 +22,21 @@ class TaskResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID */
/** ID */
'id' => $this->resource->id,
/** @var string $name Name */
/** Name */
'name' => $this->resource->name,
/** @var bool $is_done Whether the task is done */
/** Whether the task is done */
'is_done' => $this->resource->is_done,
/** @var string $project_id ID of the project */
/** ID of the project */
'project_id' => $this->resource->project_id,
/** @var int|null $estimated_time Estimated time in seconds */
/** Estimated time in seconds */
'estimated_time' => $this->resource->estimated_time,
/** @var int $spent_time Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
/** Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
'spent_time' => $this->resource->spent_time,
/** @var string $created_at When the tag was created */
/** When the tag was created */
'created_at' => $this->formatDateTime($this->resource->created_at),
/** @var string $updated_at When the tag was last updated */
/** When the tag was last updated */
'updated_at' => $this->formatDateTime($this->resource->updated_at),
];
}

View File

@@ -4,10 +4,9 @@ declare(strict_types=1);
namespace App\Http\Resources\V1\TimeEntry;
use App\Http\Resources\PaginatedResourceCollection;
use Illuminate\Http\Resources\Json\ResourceCollection;
class TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection
class TimeEntryCollection extends ResourceCollection
{
/**
* The resource that this resource collects.

View File

@@ -21,10 +21,10 @@ class TimeEntryResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID of time entry */
/** ID of time entry */
'id' => $this->resource->id,
/**
* @var string $start Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
* Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
*/
'start' => $this->formatDateTime($this->resource->start),
/**
@@ -35,19 +35,19 @@ class TimeEntryResource extends BaseResource
'duration' => (int) $this->resource->getDuration()?->totalSeconds,
/** @var string|null $description Description of time entry */
'description' => $this->resource->description,
/** @var string|null $task_id ID of task */
/** ID of task */
'task_id' => $this->resource->task_id,
/** @var string|null $project_id ID of project */
/** ID of project */
'project_id' => $this->resource->project_id,
/** @var string $organization_id ID of organization */
/** ID of organization */
'organization_id' => $this->resource->organization_id,
/** @var string $user_id ID of user */
/** ID of user */
'user_id' => $this->resource->user_id,
/** @var array<string> $tags List of tag IDs */
/** List of tag IDs */
'tags' => $this->resource->tags ?? [],
/** @var bool $billable Whether time entry is billable */
/** Whether time entry is billable */
'billable' => $this->resource->billable,
/** @var string $type Type of the time entry (`work` time or a `break`) */
/** Type of the time entry (`work` time or a `break`) */
'type' => $this->resource->type->value,
];
}

View File

@@ -22,21 +22,21 @@ class UserResource extends BaseResource
public function toArray(Request $request): array
{
return [
/** @var string $id ID of user */
/** ID of user */
'id' => $this->resource->id,
/** @var string $name Name of user */
/** Name of user */
'name' => $this->resource->name,
/** @var string $email Email of user */
/** Email of user */
'email' => $this->resource->email,
/** @var string|null $pending_email Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */
/** Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */
'pending_email' => $this->resource->pending_email,
/** @var string $profile_photo_url Profile photo URL */
/** Profile photo URL */
'profile_photo_url' => $this->resource->profile_photo_url,
/** @var string $timezone Timezone (f.e. Europe/Berlin or America/New_York) */
/** Timezone (f.e. Europe/Berlin or America/New_York) */
'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 */
/** 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

@@ -4,15 +4,19 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\AuditableWithoutOwner;
use Database\Factories\AuditFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Models\Audit as PackageAuditModel;
/**
* @property int $id
* @property string|null $user_type
* @property string|null $user_id
* @property string|null $actor_type
* @property string|null $actor_id
* @property string $event
* @property string $auditable_type
* @property string $auditable_id
@@ -22,13 +26,71 @@ use OwenIt\Auditing\Models\Audit as PackageAuditModel;
* @property string|null $ip_address
* @property string|null $user_agent
* @property string|null $tags
* @property string|null $owner_user_id
* @property string|null $owner_organization_id
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
* @property-read User|null $ownerUser
* @property-read Organization|null $ownerOrganization
*
* @method static AuditFactory factory()
* @method static Builder<Audit> whereMissingOwner()
*/
class Audit extends PackageAuditModel
{
/** @use HasFactory<AuditFactory> */
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;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use OwenIt\Auditing\Auditable;
trait CustomAuditable
@@ -19,4 +21,92 @@ trait CustomAuditable
{
$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 = [
];
public function getAuditOwnerOrganizationId(): ?string
{
return $this->getKey();
}
protected function isAuditOwnerOfItself(): bool
{
return true;
}
/**
* Get all the users that belong to the team.
*

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Models;
use App\Models\Concerns\AuditableThroughParent;
use App\Models\Concerns\CustomAuditable;
use App\Models\Concerns\HasUuids;
use Database\Factories\ProjectMemberFactory;
@@ -29,7 +30,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
* @method static ProjectMemberFactory factory()
*/
class ProjectMember extends Model implements AuditableContract
class ProjectMember extends Model implements AuditableContract, AuditableThroughParent
{
use CustomAuditable;
@@ -82,4 +83,12 @@ class ProjectMember extends Model implements AuditableContract
$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,
];
public function getAuditOwnerUserId(): ?string
{
return $this->getKey();
}
protected function isAuditOwnerOfItself(): bool
{
return true;
}
/**
* 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\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Passport\RefreshToken;
use App\Models\Project;
use App\Models\ProjectMember;
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->authCodes()->delete();

79
composer.lock generated
View File

@@ -429,23 +429,24 @@
},
{
"name": "brick/math",
"version": "0.19.1",
"version": "1.0.0",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
"reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce"
"reference": "2effe05d2177c451b86c6a073196a4034c02f211"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/brick/math/zipball/a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce",
"reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce",
"url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211",
"reference": "2effe05d2177c451b86c6a073196a4034c02f211",
"shasum": ""
},
"require": {
"php": "^8.2"
},
"require-dev": {
"phpstan/phpstan": "2.1.22",
"phpstan/phpstan": "2.2.13",
"phpstan/phpstan-phpunit": "2.0.18",
"phpunit/phpunit": "^11.5"
},
"type": "library",
@@ -476,7 +477,7 @@
],
"support": {
"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": [
{
@@ -484,7 +485,7 @@
"type": "github"
}
],
"time": "2026-08-08T23:03:16+00:00"
"time": "2026-09-12T10:28:18+00:00"
},
{
"name": "brick/money",
@@ -4218,16 +4219,16 @@
},
{
"name": "laravel/fortify",
"version": "v1.39.0",
"version": "v1.40.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/fortify.git",
"reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a"
"reference": "fe0fce8814660317df0684f2c7be3b573def67d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/fortify/zipball/b1fc50707bbe007fd92165d8b7d460ab549b355a",
"reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a",
"url": "https://api.github.com/repos/laravel/fortify/zipball/fe0fce8814660317df0684f2c7be3b573def67d3",
"reference": "fe0fce8814660317df0684f2c7be3b573def67d3",
"shasum": ""
},
"require": {
@@ -4278,24 +4279,24 @@
"issues": "https://github.com/laravel/fortify/issues",
"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",
"version": "v13.32.0",
"version": "v13.33.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
"reference": "cdd8b33c246719acdd118c705ce8c7ab5ef48a96"
"reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/framework/zipball/cdd8b33c246719acdd118c705ce8c7ab5ef48a96",
"reference": "cdd8b33c246719acdd118c705ce8c7ab5ef48a96",
"url": "https://api.github.com/repos/laravel/framework/zipball/91188a17ceaa3dbace6e8a5f7abd0d042e466359",
"reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359",
"shasum": ""
},
"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",
"doctrine/inflector": "^2.0.5",
"dragonmantank/cron-expression": "^3.4",
@@ -4509,20 +4510,20 @@
"issues": "https://github.com/laravel/framework/issues",
"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",
"version": "v2.19.1",
"version": "v2.20.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/octane.git",
"reference": "68a2516a0318baba0de0e4648f61e335c5e69dca"
"reference": "df4c38dcddc5f51e36e7f1d788841130c4fb8f45"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/octane/zipball/68a2516a0318baba0de0e4648f61e335c5e69dca",
"reference": "68a2516a0318baba0de0e4648f61e335c5e69dca",
"url": "https://api.github.com/repos/laravel/octane/zipball/df4c38dcddc5f51e36e7f1d788841130c4fb8f45",
"reference": "df4c38dcddc5f51e36e7f1d788841130c4fb8f45",
"shasum": ""
},
"require": {
@@ -4598,7 +4599,7 @@
"issues": "https://github.com/laravel/octane/issues",
"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",
@@ -4804,16 +4805,16 @@
},
{
"name": "laravel/serializable-closure",
"version": "v2.0.16",
"version": "v2.1.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/serializable-closure.git",
"reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed"
"reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed",
"reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed",
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5",
"reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5",
"shasum": ""
},
"require": {
@@ -4861,7 +4862,7 @@
"issues": "https://github.com/laravel/serializable-closure/issues",
"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",
@@ -15337,16 +15338,16 @@
},
{
"name": "laravel/sail",
"version": "v1.67.0",
"version": "v1.68.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/sail.git",
"reference": "639e03ac12cf23def171770bcab05758045b2642"
"reference": "2bc304083d515065b03944e425e62cb3c526c33e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sail/zipball/639e03ac12cf23def171770bcab05758045b2642",
"reference": "639e03ac12cf23def171770bcab05758045b2642",
"url": "https://api.github.com/repos/laravel/sail/zipball/2bc304083d515065b03944e425e62cb3c526c33e",
"reference": "2bc304083d515065b03944e425e62cb3c526c33e",
"shasum": ""
},
"require": {
@@ -15396,7 +15397,7 @@
"issues": "https://github.com/laravel/sail/issues",
"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",
@@ -15456,16 +15457,16 @@
},
{
"name": "laravel/telescope",
"version": "v5.24.0",
"version": "v5.25.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/telescope.git",
"reference": "50cbcf4553ddfd8d4bd456b04f97dfed923007d9"
"reference": "65fe6724f8688de01dfa74b28a0edc191bc3caa8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/telescope/zipball/50cbcf4553ddfd8d4bd456b04f97dfed923007d9",
"reference": "50cbcf4553ddfd8d4bd456b04f97dfed923007d9",
"url": "https://api.github.com/repos/laravel/telescope/zipball/65fe6724f8688de01dfa74b28a0edc191bc3caa8",
"reference": "65fe6724f8688de01dfa74b28a0edc191bc3caa8",
"shasum": ""
},
"require": {
@@ -15518,9 +15519,9 @@
"monitoring"
],
"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",

View File

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

View File

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

View File

@@ -3,7 +3,6 @@
declare(strict_types=1);
use App\Extensions\Scramble\ApiExceptionTypeToSchema;
use App\Extensions\Scramble\PaginatedResourceCollectionTypeToSchema;
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
return [
@@ -28,7 +27,30 @@ return [
/*
* 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,
],
/*
@@ -78,6 +100,5 @@ return [
'extensions' => [
ApiExceptionTypeToSchema::class,
PaginatedResourceCollectionTypeToSchema::class,
],
];

View File

@@ -91,7 +91,7 @@ class UserFactory extends Factory
public function withProfilePicture(): static
{
$profilePhoto = $this->faker->image(null, 500, 500);
$profilePhoto = $this->generateProfilePhoto();
/** @see FileHelpers::hashName */
$path = 'profile-photos/'.Str::random(40).'.png';
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.
*/

View File

@@ -18,7 +18,8 @@ class CreateAuditsTable extends Migration
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->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/tokens', heading: 'Tokens' },
{ path: '/admin/failed-jobs', heading: 'Failed Jobs' },
{ path: '/admin/audits', heading: 'Audits' },
];
test.describe('Admin Panel Access', () => {

View File

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

View File

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

View File

@@ -168,6 +168,10 @@ defineExpose({ submit, focusAfterStart });
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"
type="text"
autocomplete="off"
data-1p-ignore
data-lpignore="true"
data-form-type="other"
@keydown.enter="submit"
@keydown.esc="showDropdown = false"
@blur="updateTimeEntryDescription" />

View File

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

View File

@@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Http\Controllers\Api\FallbackController;
use App\Http\Controllers\Api\V1\ApiTokenController;
use App\Http\Controllers\Api\V1\ChartController;
use App\Http\Controllers\Api\V1\ClientController;
@@ -23,7 +24,6 @@ use App\Http\Controllers\Api\V1\UserController;
use App\Http\Controllers\Api\V1\UserMembershipController;
use App\Http\Controllers\Api\V1\UserTimeEntryController;
use Illuminate\Support\Facades\Route;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/*
|--------------------------------------------------------------------------
@@ -200,9 +200,5 @@ Route::prefix('v1')->name('v1.')->group(static function (): void {
* Fallback routes, to prevent a rendered HTML page in /api/* routes
* The / route is also included since the fallback is not triggered on the root route
*/
Route::get('/', function (): void {
throw new NotFoundHttpException('API resource not found');
});
Route::fallback(function (): void {
throw new NotFoundHttpException('API resource not found');
});
Route::get('/', FallbackController::class);
Route::fallback(FallbackController::class);

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\ApiDocs;
use App\Http\Controllers\Api\FallbackController;
use Dedoc\Scramble\Contracts\Diagnostics\Diagnostic;
use Dedoc\Scramble\Generator;
use Dedoc\Scramble\Infer\Context;
use Dedoc\Scramble\Scramble;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCase;
#[CoversClass(FallbackController::class)]
class ApiDocsExportTest extends TestCase
{
private string $path;
protected function setUp(): void
{
parent::setUp();
// Scramble keeps its inference context in a static property, which would otherwise outlive the refreshed application
Context::reset();
$this->path = storage_path('framework/testing/api-docs-'.uniqid().'.json');
File::ensureDirectoryExists(dirname($this->path));
}
protected function tearDown(): void
{
File::delete($this->path);
parent::tearDown();
}
/**
* @return array<string, mixed>
*/
private function exportApiDocs(): array
{
$exitCode = $this->withoutMockingConsoleOutput()->artisan('scramble:export', [
'--path' => $this->path,
]);
$this->assertSame(Command::SUCCESS, $exitCode);
$this->assertFileExists($this->path);
return json_decode(File::get($this->path), true, flags: JSON_THROW_ON_ERROR);
}
/**
* @param array<string, mixed> $docs
* @param array<string, mixed> $schema
* @return array<string, mixed>
*/
private function resolveSchema(array $docs, array $schema): array
{
if (isset($schema['$ref'])) {
$name = str_replace('#/components/schemas/', '', $schema['$ref']);
return $this->resolveSchema($docs, $docs['components']['schemas'][$name]);
}
return $schema;
}
public function test_api_docs_can_be_exported(): void
{
// Act
$docs = $this->exportApiDocs();
// Assert
$this->assertArrayHasKey('paths', $docs);
$this->assertNotEmpty($docs['paths']);
}
public function test_api_docs_are_generated_without_diagnostics(): void
{
// Act
$result = app(Generator::class)->generate(Scramble::getGeneratorConfig('default'));
// Assert
$diagnostics = $result->diagnostics()->map(function (Diagnostic $diagnostic): string {
$location = $diagnostic->codeLocation();
return sprintf(
'[%s] %s%s',
$diagnostic->code(),
$diagnostic->message(),
$location !== null ? ' ('.$location->file.':'.$location->line.')' : ''
);
})->all();
$this->assertSame([], $diagnostics);
}
public function test_paginated_endpoints_are_documented_with_pagination(): void
{
// Act
$docs = $this->exportApiDocs();
// Assert
$tagsSchema = $docs['paths']['/v1/organizations/{organization}/tags']['get']['responses']['200']['content']['application/json']['schema'];
$this->assertSame(['data', 'links', 'meta'], $tagsSchema['required']);
$tagsData = $this->resolveSchema($docs, $tagsSchema['properties']['data']);
$this->assertSame('array', $tagsData['type']);
$this->assertSame('#/components/schemas/TagResource', $tagsData['items']['$ref']);
$timeEntriesSchema = $docs['paths']['/v1/organizations/{organization}/time-entries']['get']['responses']['200']['content']['application/json']['schema'];
$this->assertSame(['data', 'meta'], $timeEntriesSchema['required']);
$this->assertSame(['total'], $timeEntriesSchema['properties']['meta']['required']);
}
public function test_fallback_routes_are_excluded_from_api_docs(): void
{
// Act
$docs = $this->exportApiDocs();
// Assert
$this->assertArrayNotHasKey('/', $docs['paths']);
$this->assertArrayNotHasKey('/{fallbackPlaceholder}', $docs['paths']);
}
public function test_fallback_routes_return_not_found(): void
{
// Act
$rootResponse = $this->getJson('/api');
$unknownResponse = $this->getJson('/api/does-not-exist');
// Assert
$rootResponse->assertNotFound();
$rootResponse->assertJsonPath('message', 'API resource not found');
$unknownResponse->assertNotFound();
$unknownResponse->assertJsonPath('message', 'API resource not found');
}
}

View File

@@ -245,6 +245,44 @@ class UserEndpointTest extends ApiEndpointTestAbstract
Mail::assertNothingSent();
}
public function test_update_fails_if_email_is_already_used_by_another_user(): void
{
// Arrange
Mail::fake();
$data = $this->createUserWithPermission();
User::factory()->create(['email' => 'taken@example.com']);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'email' => 'taken@example.com',
]);
// Assert
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['email']);
$this->assertNull($data->user->fresh()->pending_email);
Mail::assertNothingSent();
}
public function test_update_email_succeeds_if_email_is_only_used_by_a_placeholder_user(): void
{
// Arrange
Mail::fake();
$data = $this->createUserWithPermission();
User::factory()->placeholder()->create(['email' => 'placeholder@example.com']);
Passport::actingAs($data->user);
// Act
$response = $this->putJson(route('api.v1.users.update', $data->user->getKey()), [
'email' => 'placeholder@example.com',
]);
// Assert
$response->assertSuccessful();
$this->assertSame('placeholder@example.com', $data->user->fresh()->pending_email);
}
public function test_update_fails_if_email_format_is_invalid(): void
{
// Arrange

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'));
try {
dump($request->getHost());
// Note: Throws an exception if the host is not trusted
$request->getHost();
return true;
} 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\Events\BeforeOrganizationDeletion;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Models\Audit;
use App\Models\Client;
use App\Models\Member;
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\ProjectMember;
use App\Models\Report;
@@ -23,6 +27,7 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use PHPUnit\Framework\Attributes\CoversClass;
use Tests\TestCaseWithDatabase;
use TiMacDonald\Log\LogEntry;
@@ -178,6 +183,8 @@ class DeletionServiceTest extends TestCaseWithDatabase
// Assert
$this->assertOrganizationDeleted($organization->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->message === 'Start deleting organization'
&& $log->context['organization_id'] === $organization->organization->getKey(),
@@ -314,6 +321,10 @@ class DeletionServiceTest extends TestCaseWithDatabase
$this->assertDatabaseMissing(Member::class, [
'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'))->assertExists($otherUser->profile_photo_path);
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
{
// Arrange
@@ -424,4 +453,45 @@ class DeletionServiceTest extends TestCaseWithDatabase
'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(),
]);
}
}