Compare commits

..

1 Commits

Author SHA1 Message Date
Gregor Vostrak
dbc927b6a9 Add core support for extendable authentication
add support for passwordless user creation; add filament loading support
for new laravel modules namespacing;
add support for pluggable password reset and login rules
2026-06-23 18:32:54 +02:00
44 changed files with 708 additions and 1283 deletions

View File

@@ -5,8 +5,12 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Models\User;
use App\Providers\FortifyServiceProvider;
use Illuminate\Auth\Passwords\PasswordBroker;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Contracts\ResetsUserPasswords;
class ResetUserPassword implements ResetsUserPasswords
@@ -20,6 +24,16 @@ class ResetUserPassword implements ResetsUserPasswords
*/
public function reset(User $user, array $input): void
{
if (! FortifyServiceProvider::canResetPassword($user, $input)) {
/** @var PasswordBroker $broker */
$broker = Password::broker(config('fortify.passwords'));
$broker->deleteToken($user);
throw ValidationException::withMessages([
'email' => [__('This password reset link is invalid.')],
]);
}
Validator::make($input, [
'password' => $this->passwordRules(),
])->validate();

View File

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

View File

@@ -59,7 +59,7 @@ class ReportController extends Controller
$filter->addBillable($properties->billable);
$filter->addMemberIdsFilter($properties->memberIds?->toArray());
$filter->addProjectIdsFilter($properties->projectIds?->toArray());
$filter->addTagIdsFilter($properties->tagIds?->toArray(), $properties->tagMatchType);
$filter->addTagIdsFilter($properties->tagIds?->toArray());
$filter->addTaskIdsFilter($properties->taskIds?->toArray());
$filter->addClientIdsFilter($properties->clientIds?->toArray());
$timeEntriesQuery = $filter->get();

View File

@@ -96,7 +96,6 @@ class ReportController extends Controller
$properties->setClientIds($request->input('properties.client_ids', null));
$properties->setProjectIds($request->input('properties.project_ids', null));
$properties->setTagIds($request->input('properties.tag_ids', null));
$properties->setTagMatchType($request->getPropertyTagMatchType());
$properties->setTaskIds($request->input('properties.task_ids', null));
$properties->weekStart = $request->has('properties.week_start') ? Weekday::from($request->input('properties.week_start')) : $user->week_start;
$timezone = $user->timezone;

View File

@@ -203,7 +203,7 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
@@ -559,7 +559,7 @@ class TimeEntryController extends Controller
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'), $request->getTagMatchType());
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\Report;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
@@ -125,11 +124,6 @@ class ReportStoreRequest extends BaseFormRequest
}
},
],
'properties.tag_match_type' => [
'nullable',
'string',
Rule::enum(TagMatchType::class),
],
'properties.task_ids' => [
'nullable',
'array',
@@ -255,15 +249,6 @@ class ReportStoreRequest extends BaseFormRequest
return TimeEntryAggregationTypeInterval::from($this->input('properties.history_group'));
}
public function getPropertyTagMatchType(): ?TagMatchType
{
if (! $this->has('properties.tag_match_type') || $this->input('properties.tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->input('properties.tag_match_type'));
}
public function getPropertyRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('properties.rounding_type') || $this->input('properties.rounding_type') === null) {

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
@@ -140,10 +139,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -251,15 +246,6 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
return ExportFormat::from($this->validated('format'));
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

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

View File

@@ -5,7 +5,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType;
use App\Models\Client;
use App\Models\Member;
@@ -111,10 +110,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -220,15 +215,6 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
return ExportFormat::from($this->validated('format'));
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryRoundingType;
use App\Http\Requests\V1\BaseFormRequest;
use App\Models\Client;
@@ -104,10 +103,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
})->uuid()->validate($attribute, $value, $fail);
},
],
'tag_match_type' => [
'string',
Rule::enum(TagMatchType::class),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
@@ -195,15 +190,6 @@ class TimeEntryIndexRequest extends BaseFormRequest
return $this->has('offset') ? (int) $this->validated('offset', 0) : 0;
}
public function getTagMatchType(): ?TagMatchType
{
if (! $this->has('tag_match_type') || $this->validated('tag_match_type') === null) {
return null;
}
return TagMatchType::from($this->validated('tag_match_type'));
}
public function getRoundingType(): ?TimeEntryRoundingType
{
if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) {

View File

@@ -56,8 +56,6 @@ class DetailedReportResource extends BaseResource
'project_ids' => $this->resource->properties->projectIds?->toArray(),
/** @var array<string>|null $tags_ids Filter by tag IDs, tag IDs are OR combined */
'tag_ids' => $this->resource->properties->tagIds?->toArray(),
/** @var string|null $tag_match_type Tag match type */
'tag_match_type' => $this->resource->properties->tagMatchType?->value,
/** @var array<string>|null $task_ids Filter by task IDs, task IDs are OR combined */
'task_ids' => $this->resource->properties->taskIds?->toArray(),
/** @var string|null $rounding_type Rounding type for time entries */

View File

@@ -145,11 +145,21 @@ class User extends Authenticatable implements AuditableContract, FilamentUser, M
return 'https://ui-avatars.com/api/?name='.urlencode($name).'&color=7F9CF5&background=EBF4FF';
}
public function canAccessPanel(Panel $panel): bool
public function isSuperAdmin(): bool
{
return in_array($this->email, config('auth.super_admins', []), true) && $this->hasVerifiedEmail();
}
public function hasLocalPassword(): bool
{
return is_string($this->password) && $this->password !== '';
}
public function canAccessPanel(Panel $panel): bool
{
return $this->isSuperAdmin();
}
public function isMemberOfOrganization(Organization $organization): bool
{
if ($this->relationLoaded('organizations')) {

View File

@@ -26,6 +26,7 @@ use Illuminate\Session\Middleware\StartSession;
use Illuminate\Support\Facades\App;
use Illuminate\View\Middleware\ShareErrorsFromSession;
use Nwidart\Modules\Facades\Module;
use Nwidart\Modules\Laravel\Module as LaravelModule;
use pxlrbt\FilamentEnvironmentIndicator\EnvironmentIndicatorPlugin;
class AdminPanelProvider extends PanelProvider
@@ -91,22 +92,77 @@ class AdminPanelProvider extends PanelProvider
$modules = Module::allEnabled();
foreach ($modules as $module) {
$moduleNamespace = $this->getModuleAppNamespace($module);
$panel->discoverResources(
in: module_path($module->getName(), 'app/Filament/Resources'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Resources'
for: $moduleNamespace.'\\Filament\\Resources'
);
$panel->discoverPages(
in: module_path($module->getName(), 'app/Filament/Pages'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Pages'
for: $moduleNamespace.'\\Filament\\Pages'
);
$panel->discoverWidgets(
in: module_path($module->getName(), 'app/Filament/Widgets'),
for: 'Extensions\\'.$module->getName().'\\App\\Filament\\Widgets'
for: $moduleNamespace.'\\Filament\\Widgets'
);
}
return $panel;
}
/** @var array<string, string> Cache of module name => resolved app namespace. */
private static array $moduleAppNamespaces = [];
private function getModuleAppNamespace(LaravelModule $module): string
{
return self::$moduleAppNamespaces[$module->getName()] ??= $this->resolveModuleAppNamespace($module);
}
/**
* Resolve the PHP namespace mapped to a module's app/ directory so the
* Filament panel can discover its Resources/Pages/Widgets under the right
* namespace.
*
* Two module layouts currently coexist in this repo:
* - laravel-modules v12 (app_folder enabled): a bare namespace maps to
* app/ e.g. "Extensions\SSO\" => app/, so classes are
* Extensions\SSO\Filament\... (this is the current convention).
* - the older layout: an "...\App" namespace maps to app/ e.g.
* "Extensions\Billing\App\" => app/, so classes are
* Extensions\Billing\App\Filament\...
*
* The package's own namespace derivation assumes the v12 (bare) layout and
* would mis-resolve the legacy modules, so we read each module's composer
* PSR-4 map and use whichever namespace actually points at app/. The legacy
* "...\App" shape is only a fallback for when composer is missing/unreadable.
* Once every module adopts the bare layout this collapses to
* config('modules.namespace').'\\'.$module->getName().
*/
private function resolveModuleAppNamespace(LaravelModule $module): string
{
$fallback = 'Extensions\\'.$module->getName().'\\App';
$composerPath = module_path($module->getName(), 'composer.json');
$psr4 = [];
if (is_file($composerPath)) {
$composer = json_decode((string) file_get_contents($composerPath), true);
$psr4 = is_array($composer) ? ($composer['autoload']['psr-4'] ?? []) : [];
}
foreach ((array) $psr4 as $namespace => $path) {
if (is_string($namespace) && $this->normalizeComposerPath($path) === 'app') {
return rtrim($namespace, '\\');
}
}
return $fallback;
}
private function normalizeComposerPath(mixed $path): string
{
return trim(str_replace('\\', '/', (string) $path), '/');
}
}

View File

@@ -25,6 +25,73 @@ use Laravel\Fortify\Fortify;
class FortifyServiceProvider extends ServiceProvider
{
/**
* Dummy bcrypt hash compared against when no user matches the submitted
* email. Hash::check is run against it so login takes the same time whether
* or not the email exists otherwise an unknown email would skip the
* (deliberately slow) hash and return faster, letting an attacker enumerate
* registered accounts by timing the response. The plaintext is irrelevant:
* it is only ever checked against attacker-supplied input and never matches.
*/
private const ABSENT_USER_PASSWORD_HASH = '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi';
/**
* Authorization rules applied AFTER the password is verified. Each rule
* receives the authenticated user + request and returns whether the login
* may proceed; any rule returning false denies it. This is an extension
* point: modules (e.g. SSO enforcement) add a rule to veto a password login
* instead of replacing this credential check which would silently drift
* from the host logic the next time it changes.
*
* @var array<int, \Closure(User, Request): bool>
*/
protected static array $loginRules = [];
/**
* Authorization rules applied before a password reset is completed. Rules
* receive the user being reset + submitted input and return whether the
* local reset flow may set a new password for that account.
*
* @var array<int, \Closure(User, array<string, mixed>): bool>
*/
protected static array $passwordResetRules = [];
/**
* Register an additional rule that gates password login (see $loginRules).
*
* @param \Closure(User, Request): bool $rule
*/
public static function authenticateUsingRule(\Closure $rule): void
{
static::$loginRules[] = $rule;
}
/**
* Register an additional rule that gates password reset completion.
*
* @param \Closure(User, array<string, mixed>): bool $rule
*/
public static function resetPasswordUsingRule(\Closure $rule): void
{
static::$passwordResetRules[] = $rule;
}
/**
* Check whether the given user may complete the local password reset flow.
*
* @param array<string, mixed> $input
*/
public static function canResetPassword(User $user, array $input = []): bool
{
foreach (static::$passwordResetRules as $rule) {
if (! $rule($user, $input)) {
return false;
}
}
return true;
}
/**
* Register any application services.
*/
@@ -92,7 +159,23 @@ class FortifyServiceProvider extends ServiceProvider
->where('is_placeholder', '=', false)
->first();
if ($user !== null && Hash::check($request->password, $user->password)) {
// Always run the hash check — against the real hash, or a dummy when
// there is no user — so login timing is identical either way (see
// ABSENT_USER_PASSWORD_HASH). Passwordless accounts (SSO-only users
// have password = null) fail here, so they cannot password-login.
$existingPasswordHash = $user->password ?? self::ABSENT_USER_PASSWORD_HASH;
$passwordIsValid = Hash::check((string) $request->password, $existingPasswordHash);
if ($user !== null && $passwordIsValid) {
// Credentials are valid; now apply any registered authorization
// rules (e.g. SSO enforcement may still block password login).
foreach (static::$loginRules as $rule) {
if (! $rule($user, $request)) {
return null;
}
}
return $user;
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Service\Dto;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
@@ -57,8 +56,6 @@ class ReportPropertiesDto implements Castable
*/
public ?Collection $tagIds = null;
public ?TagMatchType $tagMatchType = null;
/**
* @var Collection<int, string>|null
*/
@@ -118,7 +115,6 @@ class ReportPropertiesDto implements Castable
$dto->clientIds = $data->clientIds !== null ? ReportPropertiesDto::idArrayToCollection($data->clientIds) : null;
$dto->projectIds = $data->projectIds !== null ? ReportPropertiesDto::idArrayToCollection($data->projectIds) : null;
$dto->tagIds = $data->tagIds !== null ? ReportPropertiesDto::idArrayToCollection($data->tagIds) : null;
$dto->tagMatchType = isset($data->tagMatchType) ? TagMatchType::from($data->tagMatchType) : null;
$dto->taskIds = $data->taskIds ? ReportPropertiesDto::idArrayToCollection($data->taskIds) : null;
$dto->group = TimeEntryAggregationType::from($data->group);
$dto->subGroup = TimeEntryAggregationType::from($data->subGroup);
@@ -148,7 +144,6 @@ class ReportPropertiesDto implements Castable
'clientIds' => $value->clientIds?->toArray(),
'projectIds' => $value->projectIds?->toArray(),
'tagIds' => $value->tagIds?->toArray(),
'tagMatchType' => $value->tagMatchType?->value,
'taskIds' => $value->taskIds?->toArray(),
'group' => $value->group->value,
'subGroup' => $value->subGroup->value,
@@ -221,11 +216,6 @@ class ReportPropertiesDto implements Castable
$this->tagIds = $tagIds !== null ? ReportPropertiesDto::idArrayToCollection($tagIds) : null;
}
public function setTagMatchType(?TagMatchType $tagMatchType): void
{
$this->tagMatchType = $tagMatchType;
}
/**
* @param array<mixed>|null $taskIds
*/

View File

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

View File

@@ -54,7 +54,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$taskKey = $this->getTaskKey($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([
@@ -97,9 +96,9 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
]);
}
$taskId = null;
if ($taskKey !== null && $record[$taskKey] !== '') {
if ($record['Task'] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record[$taskKey],
'name' => $record['Task'],
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
@@ -117,12 +116,10 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Time entry description is too long');
}
$timeEntry->description = $record['Description'];
if (isset($record['Billable'])) {
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['Billable'] === 'Yes';
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']);
$timeEntry->is_imported = true;
@@ -217,10 +214,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'Project',
'Client',
'Description',
'Task',
'User',
'Group',
'Email',
'Tags',
'Billable',
'Start Date',
'Start Time',
'End Date',
@@ -231,26 +230,6 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
// Clockify names the task column "Task" or "Activity" depending on the export; accept either.
if ($this->getTaskKey($header) === null) {
throw new ImportException('Invalid CSV header, missing field: Task');
}
}
/**
* Clockify names the task column "Task" or "Activity" depending on the export version.
*
* @param array<string> $header
*/
private function getTaskKey(array $header): ?string
{
foreach (['Task', 'Activity'] as $field) {
if (in_array($field, $header, true)) {
return $field;
}
}
return null;
}
#[\Override]

View File

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

View File

@@ -48,6 +48,56 @@ class UserService
}
$user->save();
$this->createDefaultOrganizationForUser(
$user,
$currency,
$numberFormat,
$currencyFormat,
$dateFormat,
$intervalFormat,
$timeFormat,
);
return $user;
}
/**
* Create a user without a password (e.g. provisioned via SSO). Such users
* can only authenticate through a linked identity provider.
*/
public function createPasswordlessUser(
string $name,
string $email,
string $timezone,
Weekday $weekStart,
?string $currency,
bool $verifyEmail = false
): User {
$user = new User;
$user->name = $name;
$user->email = strtolower($email);
$user->password = null;
$user->timezone = $timezone;
$user->week_start = $weekStart;
if ($verifyEmail) {
$user->email_verified_at = Carbon::now();
}
$user->save();
$this->createDefaultOrganizationForUser($user, $currency);
return $user;
}
private function createDefaultOrganizationForUser(
User $user,
?string $currency,
?NumberFormat $numberFormat = null,
?CurrencyFormat $currencyFormat = null,
?DateFormat $dateFormat = null,
?IntervalFormat $intervalFormat = null,
?TimeFormat $timeFormat = null,
): void {
$organizations = app(InvitationService::class)->processAcceptedInvitations($user);
if ($organizations->isEmpty()) {
@@ -64,8 +114,6 @@ class UserService
);
$this->switchCurrentOrganization($user, $organization);
}
return $user;
}
/**

View File

@@ -9,9 +9,9 @@
"ext-zip": "*",
"brick/money": "^0.10.0",
"datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.13.30",
"dedoc/scramble": "^0.12.2",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.5.0",
"flowframe/laravel-trend": "^0.4.0",
"gotenberg/gotenberg-php": "^2.8",
"guzzlehttp/guzzle": "^7.2",
"inertiajs/inertia-laravel": "^2.0.3",

678
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -5,8 +5,7 @@ declare(strict_types=1);
return [
'clockify_time_entries' => [
'name' => 'Clockify Time Entries',
'description' => '<strong>Important:</strong> If you also want to import your projects use the "Clockify Projects" importer before this one, since that export contains more details such as billable status, billable rates and estimated time.<br><br>'.
'1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
'2. In the same preferences page change the language of Clockfiy to English.<br>'.
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'4. Now select the date range that you want to export in the right top. '.
@@ -62,8 +61,7 @@ return [
],
'harvest_time_entries' => [
'name' => 'Harvest Time Entries',
'description' => '<strong>Important:</strong> If you also want to import your projects use the "Harvest Projects" importer before this one, since that export contains more details such as billable status and estimated time.<br><br>'.
'1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
'description' => '1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
'<br>3. Now click on "Export all time" '.
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
],

View File

@@ -1,8 +1,6 @@
<script setup lang="ts">
import { CheckCircleIcon, TagIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
import { FolderIcon } from '@heroicons/vue/16/solid';
import { Check } from '@lucide/vue';
import { RadioGroupIndicator, RadioGroupItem, RadioGroupRoot, type AcceptableValue } from 'reka-ui';
import BillableIcon from '@/packages/ui/src/Icons/BillableIcon.vue';
import ReportingRoundingControls from '@/Components/Common/Reporting/ReportingRoundingControls.vue';
import TaskMultiselectDropdown from '@/Components/Common/Task/TaskMultiselectDropdown.vue';
@@ -16,7 +14,6 @@ import DateRangePicker from '@/packages/ui/src/Input/DateRangePicker.vue';
import TagDropdown from '@/packages/ui/src/Tag/TagDropdown.vue';
import { useTagsQuery } from '@/utils/useTagsQuery';
import { useTagsStore } from '@/utils/useTags';
import type { TagMatchType } from '@/types/reporting';
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -25,7 +22,6 @@ const selectedProjects = defineModel<string[]>('selectedProjects', { required: t
const selectedTasks = defineModel<string[]>('selectedTasks', { required: true });
const selectedClients = defineModel<string[]>('selectedClients', { required: true });
const selectedTags = defineModel<string[]>('selectedTags', { required: true });
const tagMatchType = defineModel<TagMatchType>('tagMatchType', { required: true });
const billable = defineModel<'true' | 'false' | null>('billable', { required: true });
const roundingEnabled = defineModel<boolean>('roundingEnabled', { required: true });
const roundingType = defineModel<TimeEntryRoundingType>('roundingType', { required: true });
@@ -39,16 +35,6 @@ const emit = defineEmits<{
const { tags } = useTagsQuery();
const tagMatchOptions: { value: TagMatchType; label: string }[] = [
{ value: 'contains', label: 'Contains' },
{ value: 'not_contains', label: 'Does Not Contain' },
];
function selectTagMatchType(value: AcceptableValue) {
tagMatchType.value = value as TagMatchType;
emit('submit');
}
async function createTag(name: string) {
return await useTagsStore().createTag(name);
}
@@ -107,34 +93,6 @@ async function createTag(name: string) {
title="Tags"
:icon="TagIcon" />
</template>
<template #content-before-list>
<div class="mt-2 border-b border-card-background-separator pb-2">
<div
id="tag-match-type-label"
class="mb-1.5 px-2 text-xs font-medium text-text-tertiary uppercase">
Match
</div>
<RadioGroupRoot
:model-value="tagMatchType"
aria-labelledby="tag-match-type-label"
class="space-y-1"
@update:model-value="selectTagMatchType">
<RadioGroupItem
v-for="option in tagMatchOptions"
:key="option.value"
:value="option.value"
class="relative flex w-full items-center rounded-md py-1.5 pl-2 pr-8 text-left text-sm font-medium text-text-secondary hover:bg-card-background-active data-[state=checked]:text-text-primary">
{{ option.label }}
<span
class="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<RadioGroupIndicator>
<Check class="h-4 w-4" />
</RadioGroupIndicator>
</span>
</RadioGroupItem>
</RadioGroupRoot>
</div>
</template>
</TagDropdown>
<Select v-model="billable" @update:model-value="emit('submit')">

View File

@@ -49,7 +49,6 @@ import type { ExportFormat } from '@/types/reporting';
import { getRandomColorWithSeed } from '@/packages/ui/src/utils/color';
import { useProjectsQuery } from '@/utils/useProjectsQuery';
import { useAggregatedTimeEntriesQuery } from '@/utils/useAggregatedTimeEntriesQuery';
import type { TagMatchType } from '@/types/reporting';
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -68,7 +67,6 @@ const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]);
const tagMatchType = ref<TagMatchType>('contains');
const billable = ref<'true' | 'false' | null>(null);
const roundingEnabled = ref<boolean>(false);
@@ -124,7 +122,6 @@ const filterParams = computed<AggregatedTimeEntriesQueryParams>(() => {
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
member_id: getCurrentRole() === 'employee' ? getCurrentMembershipId() : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
@@ -369,7 +366,6 @@ const tableData = computed(() => {
v-model:selected-tasks="selectedTasks"
v-model:selected-clients="selectedClients"
v-model:selected-tags="selectedTags"
v-model:tag-match-type="tagMatchType"
v-model:billable="billable"
v-model:rounding-enabled="roundingEnabled"
v-model:rounding-type="roundingType"

View File

@@ -7,10 +7,16 @@ import { Field, FieldLabel, FieldError } from '@/packages/ui/src/field';
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
import TextInput from '@/packages/ui/src/Input/TextInput.vue';
defineProps({
canResetPassword: Boolean,
status: String,
});
withDefaults(
defineProps<{
canResetPassword?: boolean;
status?: string;
}>(),
{
canResetPassword: false,
status: '',
}
);
const form = useForm({
email: '',
@@ -28,8 +34,8 @@ const submit = () => {
};
const page = usePage<{
flash: {
message: string;
flash?: {
message?: string;
};
}>();
</script>
@@ -61,6 +67,9 @@ const page = usePage<{
{{ page.props.flash?.message }}
</div>
<!-- Extension seam: alternative-auth errors (e.g. SSO callback failures) -->
<slot name="error" />
<form @submit.prevent="submit">
<Field>
<FieldLabel for="email">Email</FieldLabel>
@@ -103,5 +112,8 @@ const page = usePage<{
</PrimaryButton>
</div>
</form>
<!-- Extension seam: alternative auth methods (e.g. SSO providers) -->
<slot name="alternatives" />
</AuthenticationCard>
</template>

View File

@@ -67,7 +67,6 @@ import ReportingFilterBar from '@/Components/Common/Reporting/ReportingFilterBar
import { useTimeEntriesReportQuery } from '@/utils/useTimeEntriesReportQuery';
import { useTimeEntriesMutations } from '@/utils/useTimeEntriesMutations';
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
import type { TagMatchType } from '@/types/reporting';
// TimeEntryRoundingType is now defined in ReportingRoundingControls component
type TimeEntryRoundingType = 'up' | 'down' | 'nearest';
@@ -85,7 +84,6 @@ const selectedProjects = ref<string[]>([]);
const selectedMembers = ref<string[]>([]);
const selectedTasks = ref<string[]>([]);
const selectedClients = ref<string[]>([]);
const tagMatchType = ref<TagMatchType>('contains');
const billable = ref<'true' | 'false' | null>(null);
const roundingEnabled = ref<boolean>(false);
const roundingType = ref<TimeEntryRoundingType>('nearest');
@@ -117,7 +115,6 @@ function getFilterAttributes() {
task_ids: selectedTasks.value.length > 0 ? selectedTasks.value : undefined,
client_ids: selectedClients.value.length > 0 ? selectedClients.value : undefined,
tag_ids: selectedTags.value.length > 0 ? selectedTags.value : undefined,
tag_match_type: selectedTags.value.length > 0 ? tagMatchType.value : undefined,
billable: billable.value !== null ? billable.value : undefined,
rounding_type: roundingEnabled.value ? roundingType.value : undefined,
rounding_minutes: roundingEnabled.value ? roundingMinutes.value : undefined,
@@ -340,7 +337,6 @@ async function downloadExport(format: ExportFormat) {
v-model:selected-tasks="selectedTasks"
v-model:selected-clients="selectedClients"
v-model:selected-tags="selectedTags"
v-model:tag-match-type="tagMatchType"
v-model:billable="billable"
v-model:rounding-enabled="roundingEnabled"
v-model:rounding-type="roundingType"

View File

@@ -10,35 +10,73 @@ import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
import { type DefineComponent } from 'vue';
import { setupPrefetching } from '@/utils/prefetch';
interface ExtensionManifest {
name?: string;
alias?: string;
}
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
const pinia = createPinia();
const queryClient = new QueryClient();
const extensionManifests = import.meta.glob('../../extensions/**/module.json', {
eager: true,
import: 'default',
}) as Record<string, ExtensionManifest>;
// BillingPortal is a Vue 2 component and must not be bundled into the Vue 3 app.
const extensionPages = import.meta.glob<DefineComponent>([
'../../extensions/**/resources/js/Pages/**/*.vue',
'!**/BillingPortal.vue',
]);
const extensionDirectories = Object.entries(extensionManifests).reduce<Record<string, string>>(
(directories, [path, manifest]) => {
const match = path.match(/^\.\.\/\.\.\/extensions\/([^/]+)\/module\.json$/);
const extensionDirectory = match?.[1];
if (extensionDirectory === undefined) {
return directories;
}
for (const key of [manifest.name, manifest.alias, extensionDirectory]) {
if (typeof key !== 'string' || key === '') {
continue;
}
directories[key] = extensionDirectory;
directories[key.toLowerCase()] = extensionDirectory;
}
return directories;
},
{}
);
function resolveExtensionDirectory(moduleName: string): string {
return (
extensionDirectories[moduleName] ??
extensionDirectories[moduleName.toLowerCase()] ??
moduleName
);
}
createInertiaApp({
title: (title) => `${title} - ${appName}`,
resolve: (name) => {
if (name.includes('Invoicing::')) {
const [module, page] = name.split('::');
// "Module::Page" (both halves present) resolves to that extension's page
// directory; everything else is a host page under resources/js/Pages.
const [module, ...pageSegments] = name.split('::');
const page = pageSegments.join('::');
const pagePath = module
? `../../extensions/${module}/resources/js/Pages/${page}.vue`
: `./Pages/${page}.vue`;
if (module && page) {
const extensionDirectory = resolveExtensionDirectory(module);
const pagePath = `../../extensions/${extensionDirectory}/resources/js/Pages/${page}.vue`;
// BillingPortal is a Vue 2 Component and therefore should not be imported
const pages = module
? import.meta.glob<DefineComponent>([
'../../extensions/**/resources/js/Pages/*.vue',
'!**/BillingPortal.vue',
])
: import.meta.glob<DefineComponent>('./Pages/**/*.vue');
return resolvePageComponent(pagePath, pages);
} else {
return resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
);
return resolvePageComponent(pagePath, extensionPages);
}
return resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob<DefineComponent>('./Pages/**/*.vue')
);
},
setup({ el, App, props, plugin }) {
const app = createApp({ render: () => h(App, props) });

View File

@@ -448,7 +448,6 @@ const ReportStoreRequest = z
client_ids: z.union([z.array(z.string()), z.null()]).optional(),
project_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_ids: z.union([z.array(z.string()), z.null()]).optional(),
tag_match_type: z.enum(['contains', 'not_contains']).optional(),
task_ids: z.union([z.array(z.string()), z.null()]).optional(),
group: TimeEntryAggregationType,
sub_group: TimeEntryAggregationType,
@@ -482,7 +481,6 @@ const DetailedReportResource = z
client_ids: z.union([z.array(z.string()), z.null()]),
project_ids: z.union([z.array(z.string()), z.null()]),
tag_ids: z.union([z.array(z.string()), z.null()]),
tag_match_type: z.union([z.enum(['contains', 'not_contains']), z.null()]),
task_ids: z.union([z.array(z.string()), z.null()]),
rounding_type: z.union([z.string(), z.null()]),
rounding_minutes: z.union([z.number(), z.null()]),
@@ -3786,11 +3784,6 @@ Users with the permission &#x60;time-entries:view:own&#x60; can only use this en
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',
@@ -4172,11 +4165,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',
@@ -4371,11 +4359,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',
@@ -4504,11 +4487,6 @@ If the group parameters are all set to &#x60;null&#x60; or are all missing, the
type: 'Query',
schema: z.array(z.string()).min(1).optional(),
},
{
name: 'tag_match_type',
type: 'Query',
schema: z.enum(['contains', 'not_contains']).optional(),
},
{
name: 'task_ids',
type: 'Query',

View File

@@ -114,7 +114,6 @@ const showCreateTagModal = ref(false);
class="w-full rounded-md border border-input-border bg-input-background px-3 py-1.5 text-sm text-text-primary placeholder:text-text-tertiary focus:outline-none"
placeholder="Search for a Tag..." />
</ComboboxAnchor>
<slot name="content-before-list"></slot>
<ComboboxContent
:dismiss-able="false"
position="inline"

View File

@@ -199,7 +199,7 @@ body {
--muted-foreground: var(--color-text-tertiary);
--accent: var(--color-bg-tertiary);
--accent-foreground: var(--color-text-primary);
--destructive: 0 72% 60%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: var(--color-text-primary);
--border: var(--color-border-primary);
--input: var(--color-border-tertiary);

View File

@@ -1,2 +1 @@
export type ExportFormat = 'xlsx' | 'csv' | 'ods' | 'pdf';
export type TagMatchType = 'contains' | 'not_contains';

View File

@@ -1,3 +0,0 @@
"Project","Client","Status","Visibility","Billability","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
"Active Project","Big Company","Active","Public","Yes","Task 1, Task 2","0.00","100.00","","","","","","","","","0.00","0.00","100.01","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
"Archived Project","","Archived","Public","Yes","","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","Constantin Graf","",""
1 Project Client Status Visibility Billability Tasks Tracked (h) Estimated (h) Remaining (h) Overage (h) Tracked (USD) Estimated (USD) Remaining (USD) Overage (USD) Progress(%) Recurring estimate Billable (h) Non-billable (h) Billable Rate (USD) Amount (USD) Cost Rate (USD) Expenses (USD) Billable expenses (USD) Non-billable expenses (USD) Additional fields Project members Project manager Note
2 Active Project Big Company Active Public Yes Task 1, Task 2 0.00 100.00 0.00 0.00 100.01 0.00 0.00 0.00 0.00 Constantin Graf
3 Archived Project Archived Public Yes 0.00 0.00 0.00 0.00 0.00 0.00 0.00 Constantin Graf

View File

@@ -1,2 +0,0 @@
"Project","Client","Status","Visibility","Billability","Activities","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Tracked (USD)","Estimated (USD)","Remaining (USD)","Overage (USD)","Progress(%)","Recurring estimate","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Cost Rate (USD)","Expenses (USD)","Billable expenses (USD)","Non-billable expenses (USD)","Additional fields","Project members","Project manager","Note"
"Project With Activities","","Active","Public","Yes","Activity A, Activity B","0.00","","","","","","","","","","0.00","0.00","","0.00","","0.00","0.00","0.00","","","",""
1 Project Client Status Visibility Billability Activities Tracked (h) Estimated (h) Remaining (h) Overage (h) Tracked (USD) Estimated (USD) Remaining (USD) Overage (USD) Progress(%) Recurring estimate Billable (h) Non-billable (h) Billable Rate (USD) Amount (USD) Cost Rate (USD) Expenses (USD) Billable expenses (USD) Non-billable expenses (USD) Additional fields Project members Project manager Note
2 Project With Activities Active Public Yes Activity A, Activity B 0.00 0.00 0.00 0.00 0.00 0.00 0.00

View File

@@ -1,3 +0,0 @@
"Project","Client","Description","Task","User","Group","Email","Tags","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
1 Project Client Description Task User Group Email Tags Start Date Start Time End Date End Time Duration (h) Duration (decimal) Billable Rate (USD) Billable Amount (USD)
2 Project without Client Peter Tester peter.test@email.test Development, Backend 03/04/2024 10:23:52 AM 03/04/2024 10:23:52 AM 00:00:00 0.00 0.00 0.00
3 Project for Big Company Big Company Working hard Task 1 Peter Tester peter.test@email.test 03/04/2024 10:23 AM 03/04/2024 11:23:01 AM 01:00:01 0.00 0.00 0.00

View File

@@ -1,3 +0,0 @@
"Project","Client","Description","Activity","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)"
"Project without Client","","","","Peter Tester","","peter.test@email.test","Development, Backend","No","03/04/2024","10:23:52 AM","03/04/2024","10:23:52 AM","00:00:00","0.00","0.00","0.00"
"Project for Big Company","Big Company","Working hard","Task 1","Peter Tester","","peter.test@email.test","","Yes","03/04/2024","10:23 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00"
1 Project Client Description Activity User Group Email Tags Billable Start Date Start Time End Date End Time Duration (h) Duration (decimal) Billable Rate (USD) Billable Amount (USD)
2 Project without Client Peter Tester peter.test@email.test Development, Backend No 03/04/2024 10:23:52 AM 03/04/2024 10:23:52 AM 00:00:00 0.00 0.00 0.00
3 Project for Big Company Big Company Working hard Task 1 Peter Tester peter.test@email.test Yes 03/04/2024 10:23 AM 03/04/2024 11:23:01 AM 01:00:01 0.00 0.00 0.00

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1\Public;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\Weekday;
@@ -668,58 +667,4 @@ class PublicReportEndpointTest extends ApiEndpointTestAbstract
],
]);
}
public function test_show_applies_not_contains_tag_match_type(): void
{
// Arrange
$organization = Organization::factory()->create();
$tagA = Tag::factory()->forOrganization($organization)->create();
$tagB = Tag::factory()->forOrganization($organization)->create();
// Entry with tagA (should be excluded by "does not contain tagA")
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 100)
->create([
'tags' => [$tagA->getKey()],
]);
// Entry with a different tag (should be included)
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 200)
->create([
'tags' => [$tagB->getKey()],
]);
// Entry without tags (should be included)
TimeEntry::factory()->forOrganization($organization)
->startWithDuration(now()->subDay(), 50)
->create();
$reportDto = new ReportPropertiesDto;
$reportDto->start = now()->subDays(2);
$reportDto->end = now();
$reportDto->group = TimeEntryAggregationType::Project;
$reportDto->subGroup = TimeEntryAggregationType::Task;
$reportDto->historyGroup = TimeEntryAggregationTypeInterval::Day;
$reportDto->weekStart = Weekday::Monday;
$reportDto->timezone = 'Europe/Vienna';
$reportDto->setTagIds([$tagA->getKey()]);
$reportDto->setTagMatchType(TagMatchType::NotContains);
$report = Report::factory()->forOrganization($organization)->public()->create([
'public_until' => null,
'properties' => $reportDto,
]);
// Act
$response = $this->getJson(route('api.v1.public.reports.show'), [
'X-Api-Key' => $report->share_secret,
]);
// Assert: tagA entry (100s) excluded; tagB (200s) + untagged (50s) included
$response->assertOk();
$response->assertJson([
'data' => [
'seconds' => 250,
'grouped_type' => TimeEntryAggregationType::Project->value,
],
]);
}
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryRoundingType;
use App\Enums\Weekday;
@@ -686,64 +685,4 @@ class ReportEndpointTest extends ApiEndpointTestAbstract
'id' => $report->getKey(),
]);
}
public function test_store_endpoint_persists_tag_match_type(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:create',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->withoutExceptionHandling()->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
'name' => 'Report with tag match type',
'is_public' => false,
'properties' => [
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'group' => TimeEntryAggregationType::Project->value,
'sub_group' => TimeEntryAggregationType::Task->value,
'history_group' => TimeEntryAggregationType::Day->value,
'tag_ids' => [$tag->getKey()],
'tag_match_type' => TagMatchType::NotContains->value,
],
]);
// Assert
$response->assertStatus(201);
/** @var Report $report */
$report = Report::query()->findOrFail($response->json('data.id'));
$this->assertSame(TagMatchType::NotContains, $report->properties->tagMatchType);
// DetailedReportResource exposes the match type in the response
$response->assertJsonPath('data.properties.tag_match_type', TagMatchType::NotContains->value);
}
public function test_store_endpoint_rejects_invalid_tag_match_type(): void
{
// Arrange
$data = $this->createUserWithPermission([
'reports:create',
]);
Passport::actingAs($data->user);
// Act
$response = $this->postJson(route('api.v1.reports.store', [$data->organization->getKey()]), [
'name' => 'Report with invalid tag match type',
'is_public' => false,
'properties' => [
'start' => Carbon::now()->subDays(30)->toIso8601ZuluString(),
'end' => Carbon::now()->toIso8601ZuluString(),
'group' => TimeEntryAggregationType::Project->value,
'sub_group' => TimeEntryAggregationType::Task->value,
'history_group' => TimeEntryAggregationType::Day->value,
'tag_match_type' => 'invalid_value',
],
]);
// Assert
$response->assertStatus(422);
$response->assertInvalid(['properties.tag_match_type']);
}
}

View File

@@ -6,7 +6,6 @@ namespace Tests\Unit\Endpoint\Api\V1;
use App\Enums\ExportFormat;
use App\Enums\Role;
use App\Enums\TagMatchType;
use App\Enums\TimeEntryAggregationType;
use App\Enums\TimeEntryAggregationTypeInterval;
use App\Enums\TimeEntryRoundingType;
@@ -4352,153 +4351,4 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $timeEntryWithoutTag->getKey());
}
public function test_index_endpoint_with_not_contains_tag_match_type_excludes_entries_with_tag(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
$timeEntryWithTag = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => [$tag->getKey()],
]);
$timeEntryWithEmptyTags = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => [],
]);
$timeEntryWithNullTags = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => null,
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'tag_ids' => [$tag->getKey()],
'tag_match_type' => TagMatchType::NotContains->value,
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert: the tagged entry is excluded; the untagged (empty + null) entries remain
$response->assertValid();
$this->assertResponseCode($response, 200);
$response->assertJsonCount(2, 'data');
$returnedIds = collect($response->json('data'))->pluck('id');
$this->assertTrue($returnedIds->contains($timeEntryWithEmptyTags->getKey()));
$this->assertTrue($returnedIds->contains($timeEntryWithNullTags->getKey()));
$this->assertFalse($returnedIds->contains($timeEntryWithTag->getKey()));
}
public function test_index_endpoint_with_contains_tag_match_type_returns_only_entries_with_tag(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
$timeEntryWithTag = TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => [$tag->getKey()],
]);
TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->create([
'start' => Carbon::now()->subHour(),
'tags' => [],
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'tag_ids' => [$tag->getKey()],
'tag_match_type' => TagMatchType::Contains->value,
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert: only the entry that has the tag
$response->assertValid();
$this->assertResponseCode($response, 200);
$response->assertJsonCount(1, 'data');
$response->assertJsonPath('data.0.id', $timeEntryWithTag->getKey());
}
public function test_index_endpoint_rejects_invalid_tag_match_type(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.index', [
$data->organization->getKey(),
'tag_ids' => [$tag->getKey()],
'tag_match_type' => 'invalid_value',
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert
$this->assertResponseCode($response, 422);
$response->assertInvalid(['tag_match_type']);
}
public function test_aggregate_endpoint_with_not_contains_tag_match_type_excludes_entries_with_tag(): void
{
// Arrange
$data = $this->createUserWithPermission([
'time-entries:view:all',
]);
$tag = Tag::factory()->forOrganization($data->organization)->create();
TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->startWithDuration(Carbon::now()->subHour(), 100)
->create([
'tags' => [$tag->getKey()],
]);
TimeEntry::factory()
->forOrganization($data->organization)
->forMember($data->member)
->startWithDuration(Carbon::now()->subHour(), 200)
->create([
'tags' => [],
]);
Passport::actingAs($data->user);
// Act
$response = $this->getJson(route('api.v1.time-entries.aggregate', [
$data->organization->getKey(),
'tag_ids' => [$tag->getKey()],
'tag_match_type' => TagMatchType::NotContains->value,
'start' => Carbon::now()->subDay()->toIso8601ZuluString(),
'end' => Carbon::now()->addDay()->toIso8601ZuluString(),
]));
// Assert: only the untagged entry (200s) is aggregated
$response->assertValid();
$this->assertResponseCode($response, 200);
$response->assertJsonPath('data.seconds', 200);
}
}

View File

@@ -5,8 +5,6 @@ declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Service\Import\Importers\ClockifyProjectsImporter;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\ImportException;
@@ -52,68 +50,4 @@ class ClockifyProjectsImporterTest extends ImporterTestAbstract
// Assert
$this->checkTestScenarioProjectsOnlyAfterImport();
}
public function test_import_sets_archived_at_based_on_status_column(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
$this->assertNull($activeProject->archived_at);
$this->assertFalse($activeProject->is_archived);
$archivedProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Archived Project')->firstOrFail();
$this->assertNotNull($archivedProject->archived_at);
$this->assertTrue($archivedProject->is_archived);
}
public function test_import_supports_renamed_tasks_column(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Newer Clockify exports rename the "Task" column to "Tasks".
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_2.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$activeProject = Project::query()->where('organization_id', $organization->id)->where('name', 'Active Project')->firstOrFail();
$this->assertEqualsCanonicalizing(
['Task 1', 'Task 2'],
Task::query()->where('project_id', $activeProject->id)->pluck('name')->all(),
);
}
public function test_import_supports_activities_column_alias_for_tasks(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyProjectsImporter;
$importer->init($organization);
// Some Clockify exports name the tasks column "Activities".
$data = Storage::disk('testfiles')->get('clockify_projects_import_test_3.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$project = Project::query()->where('organization_id', $organization->id)->where('name', 'Project With Activities')->firstOrFail();
$this->assertEqualsCanonicalizing(
['Activity A', 'Activity B'],
Task::query()->where('project_id', $project->id)->pluck('name')->all(),
);
}
}

View File

@@ -41,30 +41,6 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(1, $report->clientsCreated);
}
public function test_import_of_test_file_without_billable_works_and_defaults_to_non_billable(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_4.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(false, true);
$this->checkTimeEntries($testScenario, false, true);
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(2, $report->tagsCreated);
$this->assertSame(1, $report->tasksCreated);
$this->assertSame(1, $report->usersCreated);
$this->assertSame(2, $report->projectsCreated);
$this->assertSame(1, $report->clientsCreated);
}
public function test_import_of_test_with_special_characters_description_succeeds(): void
{
// Arrange
@@ -117,25 +93,6 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->clientsCreated);
}
public function test_import_supports_activity_column_alias_for_task(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new ClockifyTimeEntriesImporter;
$importer->init($organization);
// Some Clockify exports name the task column "Activity".
$data = Storage::disk('testfiles')->get('clockify_time_entries_import_test_5.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(1, $report->tasksCreated);
}
public function test_import_fails_if_month_in_date_is_bigger_than_12(): void
{
// Arrange

View File

@@ -26,7 +26,7 @@ class ImporterTestAbstract extends TestCase
/**
* @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag}
*/
protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false, bool $billableDefault = false): object
protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false): object
{
$users = User::all();
$this->assertCount(2, $users);
@@ -80,12 +80,12 @@ class ImporterTestAbstract extends TestCase
$this->assertSame('#ef5350', $project1->color);
$this->assertSame(null, $project1->billable_rate);
// Project for Big Company
$this->assertSame(! $billableDefault, $project2->is_billable);
$this->assertSame(true, $project2->is_billable);
$this->assertSame(false, $project2->is_public);
$this->assertSame('#ec407a', $project2->color);
$this->assertSame(10001, $project2->billable_rate);
// Project (Archived)
$this->assertSame(! $billableDefault, $project3->is_billable);
$this->assertSame(true, $project3->is_billable);
$this->assertSame(true, $project3->is_public);
$this->assertSame('#6a407f', $project3->color);
$this->assertSame(null, $project3->billable_rate);
@@ -176,7 +176,7 @@ class ImporterTestAbstract extends TestCase
/**
* @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario
*/
protected function checkTimeEntries(object $testScenario, bool $secondRun = false, bool $billableDefault = false): void
protected function checkTimeEntries(object $testScenario, bool $secondRun = false): void
{
$timeEntries = TimeEntry::all();
if ($secondRun) {
@@ -197,7 +197,7 @@ class ImporterTestAbstract extends TestCase
$this->assertSame('Working hard', $timeEntry2->description);
$this->assertSame('2024-03-04 09:23:00', $timeEntry2->start->toDateTimeString());
$this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString());
$this->assertSame(! $billableDefault, $timeEntry2->billable);
$this->assertTrue($timeEntry2->billable);
$this->assertTrue($timeEntry2->is_imported);
$this->assertSame([], $timeEntry2->tags);
}

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Enums\TagMatchType;
use App\Models\Client;
use App\Models\Project;
use App\Models\Tag;
@@ -251,188 +250,4 @@ class TimeEntryFilterTest extends TestCaseWithDatabase
$this->assertTrue($timeEntries->contains($timeEntryWithNoTags));
$this->assertFalse($timeEntries->contains($timeEntryWithTag2));
}
public function test_add_tag_ids_filter_not_contains_includes_entries_without_matching_tag(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$timeEntryWithTag1 = TimeEntry::factory()->create([
'tags' => [$tag1->getKey()],
]);
$timeEntryWithTag2 = TimeEntry::factory()->create([
'tags' => [$tag2->getKey()],
]);
$timeEntryWithAllTags = TimeEntry::factory()->create([
'tags' => [$tag1->getKey(), $tag2->getKey()],
]);
$timeEntryWithEmptyTags = TimeEntry::factory()->create([
'tags' => [],
]);
$timeEntryWithNullTags = TimeEntry::factory()->create([
'tags' => null,
]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addTagIdsFilter([$tag1->getKey()], TagMatchType::NotContains);
// Assert
$timeEntries = $builder->get();
$this->assertCount(3, $timeEntries);
$this->assertFalse($timeEntries->contains($timeEntryWithTag1));
$this->assertTrue($timeEntries->contains($timeEntryWithTag2));
$this->assertFalse($timeEntries->contains($timeEntryWithAllTags));
$this->assertTrue($timeEntries->contains($timeEntryWithEmptyTags));
$this->assertTrue($timeEntries->contains($timeEntryWithNullTags));
}
public function test_add_tag_ids_filter_not_contains_with_none_excludes_entries_without_tags(): void
{
// Arrange
$tag = Tag::factory()->create();
$timeEntryWithTag = TimeEntry::factory()->create([
'tags' => [$tag->getKey()],
]);
$timeEntryWithEmptyTags = TimeEntry::factory()->create([
'tags' => [],
]);
$timeEntryWithNullTags = TimeEntry::factory()->create([
'tags' => null,
]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act
$filter->addTagIdsFilter([TimeEntryFilter::NONE_VALUE], TagMatchType::NotContains);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithTag));
$this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags));
$this->assertFalse($timeEntries->contains($timeEntryWithNullTags));
}
public function test_add_tag_ids_filter_not_contains_with_multiple_tags_excludes_entries_with_any_of_them(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$tag3 = Tag::factory()->create();
$timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]);
$timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]);
$timeEntryWithTag3 = TimeEntry::factory()->create(['tags' => [$tag3->getKey()]]);
// a filtered tag (tag1) mixed with an unrelated one (tag3): still excluded
$timeEntryWithTag1AndTag3 = TimeEntry::factory()->create(['tags' => [$tag1->getKey(), $tag3->getKey()]]);
$timeEntryWithoutTags = TimeEntry::factory()->create(['tags' => null]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act: "does not contain tag1 or tag2" (NOT (has tag1 OR has tag2))
$filter->addTagIdsFilter([$tag1->getKey(), $tag2->getKey()], TagMatchType::NotContains);
// Assert: only entries that have neither tag1 nor tag2 remain
$timeEntries = $builder->get();
$this->assertCount(2, $timeEntries);
$this->assertFalse($timeEntries->contains($timeEntryWithTag1));
$this->assertFalse($timeEntries->contains($timeEntryWithTag2));
$this->assertTrue($timeEntries->contains($timeEntryWithTag3));
$this->assertFalse($timeEntries->contains($timeEntryWithTag1AndTag3));
$this->assertTrue($timeEntries->contains($timeEntryWithoutTags));
}
public function test_add_tag_ids_filter_contains_mode_returns_only_entries_with_tag(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]);
$timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]);
$timeEntryWithEmptyTags = TimeEntry::factory()->create(['tags' => []]);
$timeEntryWithNullTags = TimeEntry::factory()->create(['tags' => null]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act: explicit contains mode
$filter->addTagIdsFilter([$tag1->getKey()], TagMatchType::Contains);
// Assert: only the entry that has tag1
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithTag1));
$this->assertFalse($timeEntries->contains($timeEntryWithTag2));
$this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags));
$this->assertFalse($timeEntries->contains($timeEntryWithNullTags));
}
public function test_add_tag_ids_filter_not_contains_with_none_and_tag_excludes_tagged_and_untagged(): void
{
// Arrange
$tag1 = Tag::factory()->create();
$tag2 = Tag::factory()->create();
$timeEntryWithTag1 = TimeEntry::factory()->create(['tags' => [$tag1->getKey()]]);
$timeEntryWithTag2 = TimeEntry::factory()->create(['tags' => [$tag2->getKey()]]);
$timeEntryWithBothTags = TimeEntry::factory()->create(['tags' => [$tag1->getKey(), $tag2->getKey()]]);
$timeEntryWithEmptyTags = TimeEntry::factory()->create(['tags' => []]);
$timeEntryWithNullTags = TimeEntry::factory()->create(['tags' => null]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act: NOT (has tag1 OR has no tags) => has at least one tag and not tag1
$filter->addTagIdsFilter([$tag1->getKey(), TimeEntryFilter::NONE_VALUE], TagMatchType::NotContains);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertFalse($timeEntries->contains($timeEntryWithTag1));
$this->assertTrue($timeEntries->contains($timeEntryWithTag2));
$this->assertFalse($timeEntries->contains($timeEntryWithBothTags));
$this->assertFalse($timeEntries->contains($timeEntryWithEmptyTags));
$this->assertFalse($timeEntries->contains($timeEntryWithNullTags));
}
public function test_add_tag_ids_filter_with_empty_array_applies_no_filter(): void
{
// Arrange
$tag = Tag::factory()->create();
TimeEntry::factory()->create(['tags' => [$tag->getKey()]]);
TimeEntry::factory()->create(['tags' => []]);
TimeEntry::factory()->create(['tags' => null]);
// Act + Assert: an empty selection is no constraint in either mode
$builderNotContains = TimeEntry::query();
(new TimeEntryFilter($builderNotContains))->addTagIdsFilter([], TagMatchType::NotContains);
$this->assertCount(3, $builderNotContains->get());
$builderContains = TimeEntry::query();
(new TimeEntryFilter($builderContains))->addTagIdsFilter([], TagMatchType::Contains);
$this->assertCount(3, $builderContains->get());
}
public function test_add_tag_ids_filter_with_null_match_type_defaults_to_contains(): void
{
// Arrange
$tag = Tag::factory()->create();
$timeEntryWithTag = TimeEntry::factory()->create(['tags' => [$tag->getKey()]]);
$timeEntryWithoutTag = TimeEntry::factory()->create(['tags' => null]);
$builder = TimeEntry::query();
$filter = new TimeEntryFilter($builder);
// Act: a null match type falls back to "contains"
$filter->addTagIdsFilter([$tag->getKey()], null);
// Assert
$timeEntries = $builder->get();
$this->assertCount(1, $timeEntries);
$this->assertTrue($timeEntries->contains($timeEntryWithTag));
$this->assertFalse($timeEntries->contains($timeEntryWithoutTag));
}
}

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace Tests\Unit\Service;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Models\Member;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
@@ -135,4 +137,60 @@ class UserServiceTest extends TestCase
$this->assertSame(Role::Owner->value, $newMember->role);
$this->assertSame($newOrganization->getKey(), $user->currentOrganization->getKey());
}
public function test_create_passwordless_user_joins_accepted_invitation_organization_instead_of_creating_personal_one(): void
{
// Arrange — an accepted invitation exists for the email (e.g. the user
// followed the invite link, then signs up via SSO). Casing differs to
// prove the email is normalised before the invitation is matched.
$organization = Organization::factory()->create();
OrganizationInvitation::factory()
->forOrganization($organization)
->role(Role::Employee)
->accepted()
->create([
'email' => 'invitee@example.com',
]);
// Act
$user = $this->userService->createPasswordlessUser(
'Invitee',
'Invitee@Example.com',
'UTC',
Weekday::Monday,
null,
);
// Assert — invitation is materialised, no personal organization is created
$this->assertNull($user->password);
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'email' => 'invitee@example.com',
]);
$user->refresh();
$this->assertSame(1, $user->organizations()->count());
$this->assertSame($organization->getKey(), $user->organizations()->first()->getKey());
$member = Member::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail();
$this->assertSame(Role::Employee->value, $member->role);
}
public function test_create_passwordless_user_creates_personal_organization_when_no_invitation_exists(): void
{
// Act
$user = $this->userService->createPasswordlessUser(
'Solo User',
'solo@example.com',
'UTC',
Weekday::Monday,
null,
);
// Assert — a personal organization is created, owned by the user and set current
$user->refresh();
$this->assertNull($user->password);
$this->assertSame(1, $user->organizations()->count());
$organization = $user->organizations()->first();
$this->assertTrue($organization->personal_team);
$this->assertSame($user->getKey(), $organization->user_id);
$this->assertSame($organization->getKey(), $user->currentOrganization->getKey());
}
}