mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-08 13:53:17 +01:00
Compare commits
8 Commits
fix/api-do
...
e1ec883459
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1ec883459 | ||
|
|
0ef944a896 | ||
|
|
2072223f19 | ||
|
|
9ea774c1ac | ||
|
|
d1f7e206f2 | ||
|
|
5f23ce98d7 | ||
|
|
400df23035 | ||
|
|
981bad5414 |
19
.github/workflows/build-onpremise.yml
vendored
19
.github/workflows/build-onpremise.yml
vendored
@@ -103,8 +103,6 @@ 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"
|
||||
@@ -121,26 +119,9 @@ 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
|
||||
|
||||
|
||||
24
.github/workflows/build-private.yml
vendored
24
.github/workflows/build-private.yml
vendored
@@ -84,8 +84,6 @@ 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"
|
||||
@@ -147,25 +145,6 @@ 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:
|
||||
@@ -189,9 +168,6 @@ 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
|
||||
|
||||
|
||||
2
.github/workflows/npm-format-check.yml
vendored
2
.github/workflows/npm-format-check.yml
vendored
@@ -1,8 +1,6 @@
|
||||
name: NPM Format Check
|
||||
|
||||
on: [push]
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format-check:
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
13
app/Filament/Resources/AuditResource/Pages/CreateAudit.php
Normal file
13
app/Filament/Resources/AuditResource/Pages/CreateAudit.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?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;
|
||||
}
|
||||
18
app/Filament/Resources/AuditResource/Pages/ListAudits.php
Normal file
18
app/Filament/Resources/AuditResource/Pages/ListAudits.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?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 [];
|
||||
}
|
||||
}
|
||||
13
app/Filament/Resources/AuditResource/Pages/ViewAudit.php
Normal file
13
app/Filament/Resources/AuditResource/Pages/ViewAudit.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ class ClientController extends Controller
|
||||
/**
|
||||
* Get clients
|
||||
*
|
||||
* @return ClientCollection<ClientResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getClients
|
||||
|
||||
@@ -9,6 +9,7 @@ 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;
|
||||
@@ -29,6 +30,8 @@ class InvitationController extends Controller
|
||||
/**
|
||||
* List all invitations of an organization
|
||||
*
|
||||
* @return InvitationCollection<InvitationResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getInvitations
|
||||
@@ -85,8 +88,6 @@ 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
|
||||
|
||||
@@ -48,6 +48,8 @@ class MemberController extends Controller
|
||||
/**
|
||||
* List all members of an organization
|
||||
*
|
||||
* @return MemberCollection<MemberResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getMembers
|
||||
@@ -143,9 +145,6 @@ 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
|
||||
|
||||
@@ -35,6 +35,8 @@ class ProjectController extends Controller
|
||||
/**
|
||||
* Get projects visible to the current user
|
||||
*
|
||||
* @return ProjectCollection<ProjectResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getProjects
|
||||
|
||||
@@ -36,6 +36,8 @@ class ProjectMemberController extends Controller
|
||||
/**
|
||||
* Get project members for project
|
||||
*
|
||||
* @return ProjectMemberCollection<ProjectMemberResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getProjectMembers
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -34,6 +35,8 @@ class ReportController extends Controller
|
||||
/**
|
||||
* Get reports
|
||||
*
|
||||
* @return ReportCollection<ReportResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getReports
|
||||
@@ -68,8 +71,6 @@ 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
|
||||
|
||||
@@ -29,6 +29,8 @@ class TagController extends Controller
|
||||
/**
|
||||
* Get tags
|
||||
*
|
||||
* @return TagCollection<TagResource>
|
||||
*
|
||||
* @operationId getTags
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
|
||||
@@ -51,6 +51,8 @@ class TaskController extends Controller
|
||||
/**
|
||||
* Get tasks
|
||||
*
|
||||
* @return TaskCollection<TaskResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
* @operationId getTasks
|
||||
|
||||
@@ -109,13 +109,10 @@ class TimeEntryController extends Controller
|
||||
/**
|
||||
* Get time entries in organization
|
||||
*
|
||||
* 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.
|
||||
* If you only need time entries for a specific user, you can filter by `member_id`.
|
||||
* Users with the permission `time-entries:view:own` can only use this endpoint with their own member ID in the member_id filter.
|
||||
*
|
||||
* The `start` and `end` filters both apply to the start time of an entry, in UTC. Convert the user's local day boundaries to UTC first.
|
||||
* Results are paginated with `limit` (default 100, max 500) and `offset`; check `meta.total` and fetch further pages when needed.
|
||||
* To find the running timer, use `active=true` (or `GET /v1/users/me/time-entries/active`).
|
||||
* @return TimeEntryCollection<TimeEntryResource>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
@@ -354,11 +351,6 @@ 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{
|
||||
@@ -592,12 +584,6 @@ 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
|
||||
*
|
||||
@@ -648,8 +634,6 @@ 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
|
||||
@@ -718,10 +702,6 @@ 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
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Filament\Http\Middleware\Authenticate as Middleware;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class AuthenticateFilamentPanel extends Middleware
|
||||
{
|
||||
/**
|
||||
* The admin panel has no login page of its own (it shares the app's
|
||||
* session-based login), so Filament::getLoginUrl() always returns null.
|
||||
* Fall back to the app's login route so guests are redirected instead
|
||||
* of receiving an empty 401 response.
|
||||
*/
|
||||
protected function redirectTo($request): ?string
|
||||
{
|
||||
return parent::redirectTo($request) ?? ($request->expectsJson() ? null : route('login'));
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,6 @@ 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',
|
||||
|
||||
@@ -36,7 +36,6 @@ 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',
|
||||
|
||||
@@ -55,7 +55,6 @@ 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',
|
||||
|
||||
@@ -68,7 +68,6 @@ 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',
|
||||
],
|
||||
|
||||
@@ -31,7 +31,6 @@ 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',
|
||||
|
||||
@@ -21,7 +21,6 @@ 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',
|
||||
|
||||
@@ -35,7 +35,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
// 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
|
||||
// Filter by member ID
|
||||
'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: 100)
|
||||
// Limit the number of returned time entries (default: 150)
|
||||
'limit' => [
|
||||
'integer',
|
||||
'min:1',
|
||||
|
||||
@@ -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 (a member ID from GET /v1/users/me/memberships or the members list, not a user ID)
|
||||
// ID of the organization member that the time entry should belong to
|
||||
'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. Not derived from the project: set it to the project's is_billable value to match the web app
|
||||
// Whether time entry is billable
|
||||
'billable' => [
|
||||
'required',
|
||||
'boolean',
|
||||
|
||||
@@ -43,10 +43,9 @@ class UserUpdateRequest extends BaseFormRequest
|
||||
'email' => [
|
||||
'email:rfc,strict',
|
||||
'max:255',
|
||||
UniqueEloquent::make(User::class, 'email')->query(function (Builder $query) {
|
||||
UniqueEloquent::make(User::class, 'email')->ignore($this->user->id)->query(function (Builder $query) {
|
||||
/** @var Builder<User> $query */
|
||||
return $query->where('is_placeholder', '=', false)
|
||||
->whereKeyNot($this->user->getKey());
|
||||
return $query->where('is_placeholder', '=', false);
|
||||
}),
|
||||
],
|
||||
'photo' => [
|
||||
|
||||
7
app/Http/Resources/PaginatedResourceCollection.php
Normal file
7
app/Http/Resources/PaginatedResourceCollection.php
Normal file
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
interface PaginatedResourceCollection {}
|
||||
@@ -21,15 +21,15 @@ class ApiTokenResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of the API token, this ID is NOT a UUID */
|
||||
/** @var string $id 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,
|
||||
/** Whether the API token is revoked */
|
||||
/** @var bool $revoked Whether the API token is revoked */
|
||||
'revoked' => $this->resource->revoked,
|
||||
/** List of scopes that the API token has */
|
||||
/** @var array<string> $scopes List of scopes that the API token has */
|
||||
'scopes' => $this->resource->scopes,
|
||||
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
/** @var string $created_at 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),
|
||||
|
||||
@@ -29,15 +29,15 @@ class ApiTokenWithAccessTokenResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of the API token, this ID is NOT a UUID */
|
||||
/** @var string $id 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,
|
||||
/** Whether the API token is revoked */
|
||||
/** @var bool $revoked Whether the API token is revoked */
|
||||
'revoked' => $this->resource->revoked,
|
||||
/** List of scopes that the API token has */
|
||||
/** @var array<string> $scopes List of scopes that the API token has */
|
||||
'scopes' => $this->resource->scopes,
|
||||
/** When the API token was created (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z) */
|
||||
/** @var string $created_at 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),
|
||||
|
||||
@@ -4,9 +4,10 @@ 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
|
||||
class ClientCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,15 +21,15 @@ class ClientResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID */
|
||||
/** @var string $id ID */
|
||||
'id' => $this->resource->id,
|
||||
/** Name */
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** Whether the client is archived */
|
||||
/** @var bool $is_archived Whether the client is archived */
|
||||
'is_archived' => $this->resource->is_archived,
|
||||
/** When the tag was created */
|
||||
/** @var string $created_at When the tag was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** When the tag was last updated */
|
||||
/** @var string $updated_at When the tag was last updated */
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ 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
|
||||
class InvitationCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,11 +21,11 @@ class InvitationResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of the invitation */
|
||||
/** @var string $id ID of the invitation */
|
||||
'id' => $this->resource->id,
|
||||
/** Email */
|
||||
/** @var string $email Email */
|
||||
'email' => $this->resource->email,
|
||||
/** Role */
|
||||
/** @var string $role Role */
|
||||
'role' => $this->resource->role,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ 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
|
||||
class MemberCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -22,19 +22,19 @@ class MemberResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of membership */
|
||||
/** @var string $id ID of membership */
|
||||
'id' => $this->resource->id,
|
||||
/** ID of user */
|
||||
/** @var string $id ID of user */
|
||||
'user_id' => $this->resource->user->id,
|
||||
/** Name */
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->user->name,
|
||||
/** Email */
|
||||
/** @var string $email Email */
|
||||
'email' => $this->resource->user->email,
|
||||
/** Role */
|
||||
/** @var string $role Role */
|
||||
'role' => $this->resource->role,
|
||||
/** Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
|
||||
/** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
|
||||
'is_placeholder' => $this->resource->user->is_placeholder,
|
||||
/** Billable rate in cents per hour */
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ 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
|
||||
class PersonalMembershipCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,17 +21,17 @@ class PersonalMembershipResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of membership */
|
||||
/** @var string $id ID of membership */
|
||||
'id' => $this->resource->id,
|
||||
'organization' => [
|
||||
/** ID of organization */
|
||||
/** @var string $id ID of organization */
|
||||
'id' => $this->resource->organization->id,
|
||||
/** Name of organization */
|
||||
/** @var string $name Name of organization */
|
||||
'name' => $this->resource->organization->name,
|
||||
/** Currency code (ISO 4217) of organization */
|
||||
/** @var string $currency Currency code (ISO 4217) of organization */
|
||||
'currency' => $this->resource->organization->currency,
|
||||
],
|
||||
/** Role */
|
||||
/** @var string $role Role */
|
||||
'role' => $this->resource->role,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -43,23 +43,23 @@ class OrganizationResource extends BaseResource
|
||||
$currencyService = app(CurrencyService::class);
|
||||
|
||||
return [
|
||||
/** ID */
|
||||
/** @var string $id ID */
|
||||
'id' => $this->resource->id,
|
||||
/** Name */
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** Personal organizations automatically created after registration */
|
||||
/** @var bool $color Personal organizations automatically created after registration */
|
||||
'is_personal' => $this->resource->personal_team,
|
||||
/** Billable rate in cents per hour */
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||
/** Can members of the organization with role "employee" see the billable rates */
|
||||
/** @var bool $employees_can_see_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,
|
||||
/** Can members of the organization with role "employee" manage tasks in public projects and projects they are assigned to */
|
||||
/** @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 */
|
||||
'employees_can_manage_tasks' => $this->resource->employees_can_manage_tasks,
|
||||
/** Prevent creating overlapping time entries (only new entries) */
|
||||
/** @var bool $prevent_overlapping_time_entries Prevent creating overlapping time entries (only new entries) */
|
||||
'prevent_overlapping_time_entries' => $this->resource->prevent_overlapping_time_entries,
|
||||
/** Whether members of the organization can track breaks */
|
||||
/** @var bool $breaks_enabled Whether members of the organization can track breaks */
|
||||
'breaks_enabled' => $this->resource->breaks_enabled,
|
||||
/** Currency code (ISO 4217) */
|
||||
/** @var string $currency Currency code (ISO 4217) */
|
||||
'currency' => $this->resource->currency,
|
||||
/** @var string $currency_symbol Currency symbol */
|
||||
'currency_symbol' => $currencyService->getCurrencySymbol($this->resource->currency),
|
||||
|
||||
@@ -4,11 +4,12 @@ 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
|
||||
class ProjectCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
private bool $showBillableRates;
|
||||
|
||||
|
||||
@@ -30,25 +30,25 @@ class ProjectResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of project */
|
||||
/** @var string $id ID of project */
|
||||
'id' => $this->resource->id,
|
||||
/** Name of project */
|
||||
/** @var string $name Name of project */
|
||||
'name' => $this->resource->name,
|
||||
/** Color of project */
|
||||
/** @var string $color Color of project */
|
||||
'color' => $this->resource->color,
|
||||
/** @var string|null $client_id ID of client */
|
||||
'client_id' => $this->resource->client_id,
|
||||
/** Whether the client is archived */
|
||||
/** @var bool $is_archived Whether the client is archived */
|
||||
'is_archived' => $this->resource->is_archived,
|
||||
/** Billable rate in cents per hour */
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->showBillableRate ? $this->resource->billable_rate : null,
|
||||
/** Project time entries billable default */
|
||||
/** @var bool $is_billable Project time entries billable default */
|
||||
'is_billable' => $this->resource->is_billable,
|
||||
/** Estimated time in seconds */
|
||||
/** @var int|null $estimated_time Estimated time in seconds */
|
||||
'estimated_time' => $this->resource->estimated_time,
|
||||
/** Spent time on this project in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||
/** @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' => $this->resource->spent_time,
|
||||
/** Whether the project is public */
|
||||
/** @var bool $is_public Whether the project is public */
|
||||
'is_public' => $this->resource->is_public,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ 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
|
||||
class ProjectMemberCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,13 +21,13 @@ class ProjectMemberResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of project member */
|
||||
/** @var string $id ID of project member */
|
||||
'id' => $this->resource->id,
|
||||
/** Billable rate in cents per hour */
|
||||
/** @var int|null $billable_rate Billable rate in cents per hour */
|
||||
'billable_rate' => $this->resource->billable_rate,
|
||||
/** ID of the organization member */
|
||||
/** @var string $member_id ID of the organization member */
|
||||
'member_id' => $this->resource->member_id,
|
||||
/** ID of the project */
|
||||
/** @var string $project_id ID of the project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -21,36 +21,36 @@ class DetailedReportResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of the report */
|
||||
/** @var string $id ID of the report */
|
||||
'id' => $this->resource->id,
|
||||
/** Name */
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** Description */
|
||||
/** @var string|null $email Description */
|
||||
'description' => $this->resource->description,
|
||||
/** Whether the report can be accessed via an external link */
|
||||
/** @var bool $is_public 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' => [
|
||||
/** Type of first grouping */
|
||||
/** @var string $group Type of first grouping */
|
||||
'group' => $this->resource->properties->group->value,
|
||||
/** Type of second grouping */
|
||||
/** @var string $sub_group Type of second grouping */
|
||||
'sub_group' => $this->resource->properties->subGroup->value,
|
||||
/** Type of grouping of the historic aggregation (time chart) */
|
||||
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
|
||||
'history_group' => $this->resource->properties->historyGroup->value,
|
||||
/** Start date of the report */
|
||||
/** @var string $start Start date of the report */
|
||||
'start' => $this->formatDateTime($this->resource->properties->start),
|
||||
/** End date of the report */
|
||||
/** @var string $end End date of the report */
|
||||
'end' => $this->formatDateTime($this->resource->properties->end),
|
||||
/** Whether the report is active */
|
||||
/** @var bool|null $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(),
|
||||
/** Filter by billable status */
|
||||
/** @var bool|null $billable Filter by billable status */
|
||||
'billable' => $this->resource->properties->billable,
|
||||
/** Filter by time entry type */
|
||||
/** @var string|null $time_entry_type Filter by time entry type */
|
||||
'time_entry_type' => $this->resource->properties->timeEntryType?->value,
|
||||
/** @var array<string>|null $client_ids Filter by client IDs, client IDs are OR combined */
|
||||
'client_ids' => $this->resource->properties->clientIds?->toArray(),
|
||||
@@ -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(),
|
||||
/** Tag match type */
|
||||
/** @var string|null $tag_match_type Tag match type */
|
||||
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
|
||||
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
|
||||
'task_ids' => $this->resource->properties->taskIds?->toArray(),
|
||||
/** Rounding type for time entries */
|
||||
/** @var string|null $rounding_type Rounding type for time entries */
|
||||
'rounding_type' => $this->resource->properties->roundingType?->value,
|
||||
/** Rounding minutes for time entries */
|
||||
/** @var int|null $rounding_minutes Rounding minutes for time entries */
|
||||
'rounding_minutes' => $this->resource->properties->roundingMinutes,
|
||||
],
|
||||
/** Date when the report was created */
|
||||
/** @var string $created_at Date when the report was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** Date when the report was last updated */
|
||||
/** @var string $updated_at Date when the report was last updated */
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -73,13 +73,13 @@ class DetailedWithDataReportResource extends BaseResource
|
||||
$currencyService = app(CurrencyService::class);
|
||||
|
||||
return [
|
||||
/** Name */
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** Description */
|
||||
/** @var string|null $email Description */
|
||||
'description' => $this->resource->description,
|
||||
/** @var string|null $public_until Date until the report is public */
|
||||
'public_until' => $this->formatDateTime($this->resource->public_until),
|
||||
/** Currency code (ISO 4217) */
|
||||
/** @var string $currency 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' => [
|
||||
/** Type of first grouping */
|
||||
/** @var string $group Type of first grouping */
|
||||
'group' => $this->resource->properties->group->value,
|
||||
/** Type of second grouping */
|
||||
/** @var string $sub_group Type of second grouping */
|
||||
'sub_group' => $this->resource->properties->subGroup->value,
|
||||
/** Type of grouping of the historic aggregation (time chart) */
|
||||
/** @var string $history_group Type of grouping of the historic aggregation (time chart) */
|
||||
'history_group' => $this->resource->properties->historyGroup->value,
|
||||
/** Start date of the report */
|
||||
/** @var string $start Start date of the report */
|
||||
'start' => $this->formatDateTime($this->resource->properties->start),
|
||||
/** End date of the report */
|
||||
/** @var string $end End date of the report */
|
||||
'end' => $this->formatDateTime($this->resource->properties->end),
|
||||
],
|
||||
/** @var array{
|
||||
|
||||
@@ -4,9 +4,10 @@ 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
|
||||
class ReportCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,21 +21,21 @@ class ReportResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of the report */
|
||||
/** @var string $id ID of the report */
|
||||
'id' => $this->resource->id,
|
||||
/** Name */
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** Description */
|
||||
/** @var string|null $email Description */
|
||||
'description' => $this->resource->description,
|
||||
/** Whether the report can be accessed via an external link */
|
||||
/** @var bool $is_public 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(),
|
||||
/** Date when the report was created */
|
||||
/** @var string $created_at Date when the report was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** Date when the report was last updated */
|
||||
/** @var string $updated_at Date when the report was last updated */
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ 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
|
||||
class TagCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,13 +21,13 @@ class TagResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID */
|
||||
/** @var string $id ID */
|
||||
'id' => $this->resource->id,
|
||||
/** Name */
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** When the tag was created */
|
||||
/** @var string $created_at When the tag was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** When the tag was last updated */
|
||||
/** @var string $updated_at When the tag was last updated */
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ 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
|
||||
class TaskCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -22,21 +22,21 @@ class TaskResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID */
|
||||
/** @var string $id ID */
|
||||
'id' => $this->resource->id,
|
||||
/** Name */
|
||||
/** @var string $name Name */
|
||||
'name' => $this->resource->name,
|
||||
/** Whether the task is done */
|
||||
/** @var bool $is_done Whether the task is done */
|
||||
'is_done' => $this->resource->is_done,
|
||||
/** ID of the project */
|
||||
/** @var string $project_id ID of the project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
/** Estimated time in seconds */
|
||||
/** @var int|null $estimated_time Estimated time in seconds */
|
||||
'estimated_time' => $this->resource->estimated_time,
|
||||
/** Spent time on this task in seconds (sum of the duration of all associated time entries, excl. still running time entries) */
|
||||
/** @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' => $this->resource->spent_time,
|
||||
/** When the tag was created */
|
||||
/** @var string $created_at When the tag was created */
|
||||
'created_at' => $this->formatDateTime($this->resource->created_at),
|
||||
/** When the tag was last updated */
|
||||
/** @var string $updated_at When the tag was last updated */
|
||||
'updated_at' => $this->formatDateTime($this->resource->updated_at),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ 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
|
||||
class TimeEntryCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
|
||||
@@ -21,10 +21,10 @@ class TimeEntryResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of time entry */
|
||||
/** @var string $id ID of time entry */
|
||||
'id' => $this->resource->id,
|
||||
/**
|
||||
* Start of time entry (ISO 8601 format, UTC timezone, example: 2024-02-26T17:17:17Z)
|
||||
* @var string $start 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,
|
||||
/** ID of task */
|
||||
/** @var string|null $task_id ID of task */
|
||||
'task_id' => $this->resource->task_id,
|
||||
/** ID of project */
|
||||
/** @var string|null $project_id ID of project */
|
||||
'project_id' => $this->resource->project_id,
|
||||
/** ID of organization */
|
||||
/** @var string $organization_id ID of organization */
|
||||
'organization_id' => $this->resource->organization_id,
|
||||
/** ID of user */
|
||||
/** @var string $user_id ID of user */
|
||||
'user_id' => $this->resource->user_id,
|
||||
/** List of tag IDs */
|
||||
/** @var array<string> $tags List of tag IDs */
|
||||
'tags' => $this->resource->tags ?? [],
|
||||
/** Whether time entry is billable */
|
||||
/** @var bool $billable Whether time entry is billable */
|
||||
'billable' => $this->resource->billable,
|
||||
/** Type of the time entry (`work` time or a `break`) */
|
||||
/** @var string $type Type of the time entry (`work` time or a `break`) */
|
||||
'type' => $this->resource->type->value,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -22,21 +22,21 @@ class UserResource extends BaseResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
/** ID of user */
|
||||
/** @var string $id ID of user */
|
||||
'id' => $this->resource->id,
|
||||
/** Name of user */
|
||||
/** @var string $name Name of user */
|
||||
'name' => $this->resource->name,
|
||||
/** Email of user */
|
||||
/** @var string $email Email of user */
|
||||
'email' => $this->resource->email,
|
||||
/** Email address awaiting verification (set when the user has requested an email change but not yet verified the new address) */
|
||||
/** @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) */
|
||||
'pending_email' => $this->resource->pending_email,
|
||||
/** Profile photo URL */
|
||||
/** @var string $profile_photo_url Profile photo URL */
|
||||
'profile_photo_url' => $this->resource->profile_photo_url,
|
||||
/** Timezone (f.e. Europe/Berlin or America/New_York) */
|
||||
/** @var string $timezone 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,
|
||||
/** Whether to email the user when a time entry has been running for more than 8 hours */
|
||||
/** @var bool $send_time_entry_still_running_email Whether to email the user when a time entry has been running for more than 8 hours */
|
||||
'send_time_entry_still_running_email' => $this->resource->send_time_entry_still_running_email,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,19 +4,15 @@ 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 $actor_type
|
||||
* @property string|null $actor_id
|
||||
* @property string|null $user_type
|
||||
* @property string|null $user_id
|
||||
* @property string $event
|
||||
* @property string $auditable_type
|
||||
* @property string $auditable_id
|
||||
@@ -26,71 +22,13 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
<?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;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?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 {}
|
||||
@@ -4,8 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use OwenIt\Auditing\Auditable;
|
||||
|
||||
trait CustomAuditable
|
||||
@@ -21,92 +19,4 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,16 +96,6 @@ 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.
|
||||
*
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -30,7 +29,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, AuditableThroughParent
|
||||
class ProjectMember extends Model implements AuditableContract
|
||||
{
|
||||
use CustomAuditable;
|
||||
|
||||
@@ -83,12 +82,4 @@ class ProjectMember extends Model implements AuditableContract, AuditableThrough
|
||||
$query->whereBelongsTo($organization, 'organization');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Project, $this>
|
||||
*/
|
||||
public function getAuditParentRelation(): BelongsTo
|
||||
{
|
||||
return $this->project();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,16 +124,6 @@ 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.
|
||||
*
|
||||
|
||||
@@ -9,7 +9,7 @@ use App\Filament\Widgets\ServerOverview;
|
||||
use App\Filament\Widgets\TimeEntriesCreated;
|
||||
use App\Filament\Widgets\TimeEntriesImported;
|
||||
use App\Filament\Widgets\UserRegistrations;
|
||||
use App\Http\Middleware\AuthenticateFilamentPanel;
|
||||
use Filament\Http\Middleware\Authenticate;
|
||||
use Filament\Http\Middleware\DisableBladeIconComponents;
|
||||
use Filament\Http\Middleware\DispatchServingFilamentEvent;
|
||||
use Filament\Navigation\NavigationGroup;
|
||||
@@ -87,7 +87,7 @@ class AdminPanelProvider extends PanelProvider
|
||||
DispatchServingFilamentEvent::class,
|
||||
])
|
||||
->authMiddleware([
|
||||
AuthenticateFilamentPanel::class,
|
||||
Authenticate::class,
|
||||
]);
|
||||
|
||||
$modules = Module::allEnabled();
|
||||
|
||||
@@ -11,7 +11,6 @@ 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;
|
||||
@@ -170,10 +169,6 @@ 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();
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
|
||||
'Project',
|
||||
'Description',
|
||||
'User',
|
||||
'Group',
|
||||
'Email',
|
||||
'Tags',
|
||||
'Start Date',
|
||||
|
||||
79
composer.lock
generated
79
composer.lock
generated
@@ -429,24 +429,23 @@
|
||||
},
|
||||
{
|
||||
"name": "brick/math",
|
||||
"version": "1.0.0",
|
||||
"version": "0.19.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/brick/math.git",
|
||||
"reference": "2effe05d2177c451b86c6a073196a4034c02f211"
|
||||
"reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/brick/math/zipball/2effe05d2177c451b86c6a073196a4034c02f211",
|
||||
"reference": "2effe05d2177c451b86c6a073196a4034c02f211",
|
||||
"url": "https://api.github.com/repos/brick/math/zipball/a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce",
|
||||
"reference": "a89bc96a7cf3d7b59e725afe57ccb95eb03cf6ce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^8.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "2.2.13",
|
||||
"phpstan/phpstan-phpunit": "2.0.18",
|
||||
"phpstan/phpstan": "2.1.22",
|
||||
"phpunit/phpunit": "^11.5"
|
||||
},
|
||||
"type": "library",
|
||||
@@ -477,7 +476,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/brick/math/issues",
|
||||
"source": "https://github.com/brick/math/tree/1.0.0"
|
||||
"source": "https://github.com/brick/math/tree/0.19.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -485,7 +484,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-09-12T10:28:18+00:00"
|
||||
"time": "2026-08-08T23:03:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "brick/money",
|
||||
@@ -4219,16 +4218,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/fortify",
|
||||
"version": "v1.40.0",
|
||||
"version": "v1.39.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/fortify.git",
|
||||
"reference": "fe0fce8814660317df0684f2c7be3b573def67d3"
|
||||
"reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/fortify/zipball/fe0fce8814660317df0684f2c7be3b573def67d3",
|
||||
"reference": "fe0fce8814660317df0684f2c7be3b573def67d3",
|
||||
"url": "https://api.github.com/repos/laravel/fortify/zipball/b1fc50707bbe007fd92165d8b7d460ab549b355a",
|
||||
"reference": "b1fc50707bbe007fd92165d8b7d460ab549b355a",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4279,24 +4278,24 @@
|
||||
"issues": "https://github.com/laravel/fortify/issues",
|
||||
"source": "https://github.com/laravel/fortify"
|
||||
},
|
||||
"time": "2026-09-10T11:52:08+00:00"
|
||||
"time": "2026-08-23T07:46:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
"version": "v13.33.0",
|
||||
"version": "v13.32.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/framework.git",
|
||||
"reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359"
|
||||
"reference": "cdd8b33c246719acdd118c705ce8c7ab5ef48a96"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/91188a17ceaa3dbace6e8a5f7abd0d042e466359",
|
||||
"reference": "91188a17ceaa3dbace6e8a5f7abd0d042e466359",
|
||||
"url": "https://api.github.com/repos/laravel/framework/zipball/cdd8b33c246719acdd118c705ce8c7ab5ef48a96",
|
||||
"reference": "cdd8b33c246719acdd118c705ce8c7ab5ef48a96",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19 || ^0.20 || ^1.0",
|
||||
"brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19",
|
||||
"composer-runtime-api": "^2.2",
|
||||
"doctrine/inflector": "^2.0.5",
|
||||
"dragonmantank/cron-expression": "^3.4",
|
||||
@@ -4510,20 +4509,20 @@
|
||||
"issues": "https://github.com/laravel/framework/issues",
|
||||
"source": "https://github.com/laravel/framework"
|
||||
},
|
||||
"time": "2026-09-22T14:12:33+00:00"
|
||||
"time": "2026-09-15T14:55:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/octane",
|
||||
"version": "v2.20.0",
|
||||
"version": "v2.19.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/octane.git",
|
||||
"reference": "df4c38dcddc5f51e36e7f1d788841130c4fb8f45"
|
||||
"reference": "68a2516a0318baba0de0e4648f61e335c5e69dca"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/octane/zipball/df4c38dcddc5f51e36e7f1d788841130c4fb8f45",
|
||||
"reference": "df4c38dcddc5f51e36e7f1d788841130c4fb8f45",
|
||||
"url": "https://api.github.com/repos/laravel/octane/zipball/68a2516a0318baba0de0e4648f61e335c5e69dca",
|
||||
"reference": "68a2516a0318baba0de0e4648f61e335c5e69dca",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4599,7 +4598,7 @@
|
||||
"issues": "https://github.com/laravel/octane/issues",
|
||||
"source": "https://github.com/laravel/octane"
|
||||
},
|
||||
"time": "2026-08-23T17:25:20+00:00"
|
||||
"time": "2026-08-13T13:58:52+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/passkeys",
|
||||
@@ -4805,16 +4804,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/serializable-closure",
|
||||
"version": "v2.1.0",
|
||||
"version": "v2.0.16",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/serializable-closure.git",
|
||||
"reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5"
|
||||
"reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5",
|
||||
"reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5",
|
||||
"url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed",
|
||||
"reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4862,7 +4861,7 @@
|
||||
"issues": "https://github.com/laravel/serializable-closure/issues",
|
||||
"source": "https://github.com/laravel/serializable-closure"
|
||||
},
|
||||
"time": "2026-09-22T14:32:34+00:00"
|
||||
"time": "2026-08-18T20:28:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/tinker",
|
||||
@@ -15338,16 +15337,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/sail",
|
||||
"version": "v1.68.0",
|
||||
"version": "v1.67.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/sail.git",
|
||||
"reference": "2bc304083d515065b03944e425e62cb3c526c33e"
|
||||
"reference": "639e03ac12cf23def171770bcab05758045b2642"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/sail/zipball/2bc304083d515065b03944e425e62cb3c526c33e",
|
||||
"reference": "2bc304083d515065b03944e425e62cb3c526c33e",
|
||||
"url": "https://api.github.com/repos/laravel/sail/zipball/639e03ac12cf23def171770bcab05758045b2642",
|
||||
"reference": "639e03ac12cf23def171770bcab05758045b2642",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -15397,7 +15396,7 @@
|
||||
"issues": "https://github.com/laravel/sail/issues",
|
||||
"source": "https://github.com/laravel/sail"
|
||||
},
|
||||
"time": "2026-09-18T14:34:46+00:00"
|
||||
"time": "2026-08-12T13:55:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/sentinel",
|
||||
@@ -15457,16 +15456,16 @@
|
||||
},
|
||||
{
|
||||
"name": "laravel/telescope",
|
||||
"version": "v5.25.0",
|
||||
"version": "v5.24.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/laravel/telescope.git",
|
||||
"reference": "65fe6724f8688de01dfa74b28a0edc191bc3caa8"
|
||||
"reference": "50cbcf4553ddfd8d4bd456b04f97dfed923007d9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/laravel/telescope/zipball/65fe6724f8688de01dfa74b28a0edc191bc3caa8",
|
||||
"reference": "65fe6724f8688de01dfa74b28a0edc191bc3caa8",
|
||||
"url": "https://api.github.com/repos/laravel/telescope/zipball/50cbcf4553ddfd8d4bd456b04f97dfed923007d9",
|
||||
"reference": "50cbcf4553ddfd8d4bd456b04f97dfed923007d9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -15519,9 +15518,9 @@
|
||||
"monitoring"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/laravel/telescope/tree/v5.25.0"
|
||||
"source": "https://github.com/laravel/telescope/tree/v5.24.0"
|
||||
},
|
||||
"time": "2026-09-09T14:16:19+00:00"
|
||||
"time": "2026-09-08T16:15:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "mockery/mockery",
|
||||
|
||||
@@ -32,7 +32,7 @@ return [
|
||||
*/
|
||||
|
||||
'user' => [
|
||||
'morph_prefix' => 'actor',
|
||||
'morph_prefix' => 'user',
|
||||
'guards' => [
|
||||
'web',
|
||||
'api',
|
||||
|
||||
@@ -35,7 +35,7 @@ return [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'url' => env('DB_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', env('DATABASE_URL')),
|
||||
'url' => env('DB_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_SSL_MODE', 'prefer'),
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'pgsql_test' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'url' => env('DB_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_SSL_MODE', 'prefer'),
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL', env('DATABASE_URL')),
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
|
||||
@@ -16,7 +16,7 @@ return [
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('MAIL_MAILER', 'smtp'),
|
||||
'default' => env('MAIL_MAILER', 'log'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Extensions\Scramble\ApiExceptionTypeToSchema;
|
||||
use App\Extensions\Scramble\PaginatedResourceCollectionTypeToSchema;
|
||||
use Dedoc\Scramble\Http\Middleware\RestrictedDocsAccess;
|
||||
|
||||
return [
|
||||
@@ -27,30 +28,7 @@ return [
|
||||
/*
|
||||
* Description rendered on the home page of the API documentation (`/docs/api`).
|
||||
*/
|
||||
'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,
|
||||
'description' => '',
|
||||
],
|
||||
|
||||
/*
|
||||
@@ -100,5 +78,6 @@ MD,
|
||||
|
||||
'extensions' => [
|
||||
ApiExceptionTypeToSchema::class,
|
||||
PaginatedResourceCollectionTypeToSchema::class,
|
||||
],
|
||||
];
|
||||
|
||||
@@ -91,7 +91,7 @@ class UserFactory extends Factory
|
||||
|
||||
public function withProfilePicture(): static
|
||||
{
|
||||
$profilePhoto = $this->generateProfilePhoto();
|
||||
$profilePhoto = $this->faker->image(null, 500, 500);
|
||||
/** @see FileHelpers::hashName */
|
||||
$path = 'profile-photos/'.Str::random(40).'.png';
|
||||
Storage::disk(config('filesystems.public'))->put($path, $profilePhoto);
|
||||
@@ -103,21 +103,6 @@ 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.
|
||||
*/
|
||||
|
||||
@@ -18,8 +18,7 @@ class CreateAuditsTable extends Migration
|
||||
|
||||
Schema::connection($connection)->create($table, function (Blueprint $table): void {
|
||||
|
||||
// Note: The morph prefix is hardcoded, since the columns are renamed in a later migration
|
||||
$morphPrefix = 'user';
|
||||
$morphPrefix = config('audit.user.morph_prefix', 'user');
|
||||
|
||||
$table->bigIncrements('id');
|
||||
$table->string($morphPrefix.'_type')->nullable();
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,94 +0,0 @@
|
||||
<?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' : '';
|
||||
}
|
||||
};
|
||||
@@ -19,6 +19,7 @@ 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', () => {
|
||||
|
||||
@@ -68,7 +68,7 @@ export async function getPasswordResetUrl(
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
searchResult = await searchEmails(
|
||||
request,
|
||||
`to:${encodeURIComponent(recipientEmail)} subject:"Reset your password"`
|
||||
`to:${encodeURIComponent(recipientEmail)} subject:"Reset Password"`
|
||||
);
|
||||
if (searchResult.messages.length > 0) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
{
|
||||
"Billing": {
|
||||
"repository": "solidtime-io/extension-billing",
|
||||
"ref": "v0.0.9"
|
||||
"ref": "feature/composer-update"
|
||||
},
|
||||
"Services": {
|
||||
"repository": "solidtime-io/extension-services",
|
||||
"ref": "v0.0.4"
|
||||
"ref": "feature/composer-update"
|
||||
},
|
||||
"Invoicing": {
|
||||
"repository": "solidtime-io/extension-invoicing",
|
||||
"ref": "v0.0.9"
|
||||
},
|
||||
"Auditing": {
|
||||
"repository": "solidtime-io/extension-auditing",
|
||||
"ref": "v0.0.4"
|
||||
"ref": "feature/composer-update"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,8 +111,6 @@ export function useEventResize(params: {
|
||||
edge: 'start' | 'end',
|
||||
dayStr: string
|
||||
) {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
@@ -168,10 +168,6 @@ 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" />
|
||||
|
||||
@@ -170,10 +170,6 @@ 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"
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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;
|
||||
@@ -24,6 +23,7 @@ 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,5 +200,9 @@ 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('/', FallbackController::class);
|
||||
Route::fallback(FallbackController::class);
|
||||
Route::get('/', function (): void {
|
||||
throw new NotFoundHttpException('API resource not found');
|
||||
});
|
||||
Route::fallback(function (): void {
|
||||
throw new NotFoundHttpException('API resource not found');
|
||||
});
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@@ -245,44 +245,6 @@ 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
|
||||
|
||||
59
tests/Unit/Filament/Resources/AuditResourceTest.php
Normal file
59
tests/Unit/Filament/Resources/AuditResourceTest.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
@@ -50,8 +50,7 @@ class TrustHostsTest extends TestCase
|
||||
$this->middleware()->handle($request, fn (Request $request): Response => new Response('passed'));
|
||||
|
||||
try {
|
||||
// Note: Throws an exception if the host is not trusted
|
||||
$request->getHost();
|
||||
dump($request->getHost());
|
||||
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,9 @@ 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;
|
||||
@@ -27,7 +23,6 @@ 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;
|
||||
@@ -183,8 +178,6 @@ 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(),
|
||||
@@ -321,10 +314,6 @@ 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'
|
||||
@@ -339,24 +328,6 @@ 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
|
||||
@@ -453,45 +424,4 @@ 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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user