From b74e14f06ff90e63a0f5de833e1da52aab480b27 Mon Sep 17 00:00:00 2001 From: korridor <26689068+korridor@users.noreply.github.com> Date: Tue, 5 Mar 2024 19:19:07 +0100 Subject: [PATCH 1/5] Added import system --- .../Resources/OrganizationResource.php | 54 +++++ .../Controllers/Api/V1/ImportController.php | 64 ++++++ app/Http/Middleware/ValidateSignature.php | 2 +- app/Http/Requests/V1/Import/ImportRequest.php | 30 +++ .../V1/Project/ProjectStoreRequest.php | 1 + .../V1/Project/ProjectUpdateRequest.php | 1 + app/Http/Requests/V1/Tag/TagStoreRequest.php | 1 + app/Http/Requests/V1/Tag/TagUpdateRequest.php | 1 + app/Models/Organization.php | 2 + app/Models/User.php | 3 + app/Providers/AppServiceProvider.php | 1 + app/Providers/JetstreamServiceProvider.php | 1 + app/Service/ColorService.php | 38 ++++ app/Service/Import/ImportDatabaseHelper.php | 132 ++++++++++++ app/Service/Import/ImportService.php | 30 +++ .../Import/Importers/ImportException.php | 9 + .../Import/Importers/ImporterContract.php | 16 ++ .../Import/Importers/ImporterProvider.php | 37 ++++ app/Service/Import/Importers/ReportDto.php | 76 +++++++ .../Importers/TogglTimeEntriesImporter.php | 189 ++++++++++++++++++ config/telescope.php | 1 - .../2014_10_12_000000_create_users_table.php | 8 +- routes/api.php | 6 + storage/tests/clockify_import_test_1.csv | 1 + storage/tests/toggl_import_test_1.csv | 3 + .../Endpoint/Api/V1/ImportEndpointTest.php | 58 ++++++ .../Import/ImportDatabaseHelperTest.php | 71 +++++++ .../Import/Importer/ImporterTestAbstract.php | 53 +++++ .../Importer/TogglTimeEntriesImporterTest.php | 61 ++++++ 29 files changed, 946 insertions(+), 4 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/ImportController.php create mode 100644 app/Http/Requests/V1/Import/ImportRequest.php create mode 100644 app/Service/ColorService.php create mode 100644 app/Service/Import/ImportDatabaseHelper.php create mode 100644 app/Service/Import/ImportService.php create mode 100644 app/Service/Import/Importers/ImportException.php create mode 100644 app/Service/Import/Importers/ImporterContract.php create mode 100644 app/Service/Import/Importers/ImporterProvider.php create mode 100644 app/Service/Import/Importers/ReportDto.php create mode 100644 app/Service/Import/Importers/TogglTimeEntriesImporter.php create mode 100644 storage/tests/clockify_import_test_1.csv create mode 100644 storage/tests/toggl_import_test_1.csv create mode 100644 tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php create mode 100644 tests/Unit/Service/Import/ImportDatabaseHelperTest.php create mode 100644 tests/Unit/Service/Import/Importer/ImporterTestAbstract.php create mode 100644 tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php diff --git a/app/Filament/Resources/OrganizationResource.php b/app/Filament/Resources/OrganizationResource.php index 07664a41..3c14a313 100644 --- a/app/Filament/Resources/OrganizationResource.php +++ b/app/Filament/Resources/OrganizationResource.php @@ -6,11 +6,19 @@ use App\Filament\Resources\OrganizationResource\Pages; use App\Models\Organization; +use App\Service\Import\Importers\ImporterProvider; +use App\Service\Import\Importers\ImportException; +use App\Service\Import\Importers\ReportDto; +use App\Service\Import\ImportService; use Filament\Forms; +use Filament\Forms\Components\Select; use Filament\Forms\Form; +use Filament\Notifications\Notification; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Actions\Action; use Filament\Tables\Table; +use Illuminate\Support\Facades\Storage; class OrganizationResource extends Resource { @@ -60,6 +68,52 @@ public static function table(Table $table): Table ]) ->actions([ Tables\Actions\EditAction::make(), + Action::make('Import') + ->icon('heroicon-o-inbox-arrow-down') + ->action(function (Organization $record, array $data) { + // TODO: different disk! + try { + /** @var ReportDto $report */ + $report = app(ImportService::class)->import($record, $data['type'], Storage::disk('public')->get($data['file']), []); + Notification::make() + ->title('Import successful') + ->success() + ->body( + 'Imported time entries: '.$report->timeEntriesCreated.'
'. + 'Imported clients: '.$report->clientsCreated.'
'. + 'Imported projects: '.$report->projectsCreated.'
'. + 'Imported tasks: '.$report->tasksCreated.'
'. + 'Imported tags: '.$report->tagsCreated.'
'. + 'Imported users: '.$report->usersCreated + ) + ->persistent() + ->send(); + } catch (ImportException $exception) { + report($exception); + Notification::make() + ->title('Import failed, changes rolled back') + ->danger() + ->body('Message: '.$exception->getMessage()) + ->persistent() + ->send(); + } + }) + ->tooltip(fn (Organization $record): string => "Import into {$record->name}") + ->form([ + Forms\Components\FileUpload::make('file') + ->label('File') + ->required(), + Select::make('type') + ->required() + ->options(function (): array { + $select = []; + foreach (app(ImporterProvider::class)->getImporterKeys() as $key) { + $select[$key] = $key; + } + + return $select; + }), + ]), ]) ->bulkActions([ Tables\Actions\BulkActionGroup::make([ diff --git a/app/Http/Controllers/Api/V1/ImportController.php b/app/Http/Controllers/Api/V1/ImportController.php new file mode 100644 index 00000000..877c8c61 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ImportController.php @@ -0,0 +1,64 @@ +checkPermission($organization, 'import'); + + try { + $report = $importService->import( + $organization, + $request->input('type'), + $request->input('data'), + $request->input('options') + ); + + return new JsonResponse([ + /** @var array{ + * clients: array{ + * created: int, + * }, + * projects: array{ + * created: int, + * }, + * tasks: array{ + * created: int, + * }, + * time-entries: array{ + * created: int, + * }, + * tags: array{ + * created: int, + * }, + * users: array{ + * created: int, + * } + * } $report Import report */ + 'report' => $report->toArray(), + ], 200); + } catch (ImportException $exception) { + report($exception); + + return new JsonResponse([ + 'message' => $exception->getMessage(), + ], 400); + } + } +} diff --git a/app/Http/Middleware/ValidateSignature.php b/app/Http/Middleware/ValidateSignature.php index 0b3a971f..d979b895 100644 --- a/app/Http/Middleware/ValidateSignature.php +++ b/app/Http/Middleware/ValidateSignature.php @@ -13,7 +13,7 @@ class ValidateSignature extends Middleware * * @var array */ - protected $except = [ + protected array $except = [ // 'fbclid', // 'utm_campaign', // 'utm_content', diff --git a/app/Http/Requests/V1/Import/ImportRequest.php b/app/Http/Requests/V1/Import/ImportRequest.php new file mode 100644 index 00000000..523324b7 --- /dev/null +++ b/app/Http/Requests/V1/Import/ImportRequest.php @@ -0,0 +1,30 @@ +> + */ + public function rules(): array + { + return [ + 'type' => [ + 'required', + 'string', + ], + 'data' => [ + 'required', + 'string', + ], + ]; + } +} diff --git a/app/Http/Requests/V1/Project/ProjectStoreRequest.php b/app/Http/Requests/V1/Project/ProjectStoreRequest.php index d608e0c4..09bf72dc 100644 --- a/app/Http/Requests/V1/Project/ProjectStoreRequest.php +++ b/app/Http/Requests/V1/Project/ProjectStoreRequest.php @@ -25,6 +25,7 @@ public function rules(): array { return [ 'name' => [ + // TODO: unique 'required', 'string', 'min:1', diff --git a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php index 69b6661e..28ed09ad 100644 --- a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php +++ b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php @@ -25,6 +25,7 @@ public function rules(): array { return [ 'name' => [ + // TODO: unique 'required', 'string', 'max:255', diff --git a/app/Http/Requests/V1/Tag/TagStoreRequest.php b/app/Http/Requests/V1/Tag/TagStoreRequest.php index 27955e19..07373f3e 100644 --- a/app/Http/Requests/V1/Tag/TagStoreRequest.php +++ b/app/Http/Requests/V1/Tag/TagStoreRequest.php @@ -18,6 +18,7 @@ public function rules(): array { return [ 'name' => [ + // TODO: unique 'required', 'string', 'min:1', diff --git a/app/Http/Requests/V1/Tag/TagUpdateRequest.php b/app/Http/Requests/V1/Tag/TagUpdateRequest.php index a0f5a1db..a54a31ee 100644 --- a/app/Http/Requests/V1/Tag/TagUpdateRequest.php +++ b/app/Http/Requests/V1/Tag/TagUpdateRequest.php @@ -18,6 +18,7 @@ public function rules(): array { return [ 'name' => [ + // TODO: unique 'required', 'string', 'min:1', diff --git a/app/Models/Organization.php b/app/Models/Organization.php index c44a485f..62bd33b7 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -5,6 +5,7 @@ namespace App\Models; use Database\Factories\OrganizationFactory; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -18,6 +19,7 @@ * @property string $name * @property bool $personal_team * @property User $owner + * @property Collection $users * * @method HasMany teamInvitations() * @method static OrganizationFactory factory() diff --git a/app/Models/User.php b/app/Models/User.php index 891b3be0..19e6bc8f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -64,8 +64,11 @@ class User extends Authenticatable * @var array */ protected $casts = [ + 'name' => 'string', + 'email' => 'string', 'email_verified_at' => 'datetime', 'is_admin' => 'boolean', + 'is_placeholder' => 'boolean', ]; /** diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index b027f23f..1aa92645 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -74,6 +74,7 @@ public function boot(): void if (config('app.force_https', false) || App::isProduction()) { URL::forceScheme('https'); + request()->server->set('HTTPS', request()->header('X-Forwarded-Proto', 'https') === 'https' ? 'on' : 'off'); } } } diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index e8e5bfb0..1de94d23 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -74,6 +74,7 @@ protected function configurePermissions(): void 'clients:delete', 'organizations:view', 'organizations:update', + 'import', ])->description('Administrator users can perform any action.'); Jetstream::role('manager', 'Manager', [ diff --git a/app/Service/ColorService.php b/app/Service/ColorService.php new file mode 100644 index 00000000..a58e459b --- /dev/null +++ b/app/Service/ColorService.php @@ -0,0 +1,38 @@ + + */ + private const array COLORS = [ + '#ef5350', + '#ec407a', + '#ab47bc', + '#7e57c2', + '#5c6bc0', + '#42a5f5', + '#29b6f6', + '#26c6da', + '#26a69a', + '#66bb6a', + '#9ccc65', + '#d4e157', + '#ffee58', + '#ffca28', + '#ffa726', + '#ff7043', + '#8d6e63', + '#bdbdbd', + '#78909c', + ]; + + public function getRandomColor(): string + { + return self::COLORS[array_rand(self::COLORS)]; + } +} diff --git a/app/Service/Import/ImportDatabaseHelper.php b/app/Service/Import/ImportDatabaseHelper.php new file mode 100644 index 00000000..fa5551ae --- /dev/null +++ b/app/Service/Import/ImportDatabaseHelper.php @@ -0,0 +1,132 @@ + + */ + private string $model; + + /** + * @var string[] + */ + private array $identifiers; + + private ?array $mapIdentifierToKey = null; + + private array $mapNewAttach = []; + + private bool $attachToExisting; + + private ?Closure $queryModifier; + + private ?Closure $afterCreate; + + private int $createdCount; + + /** + * @param class-string $model + * @param array $identifiers + */ + public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null) + { + $this->model = $model; + $this->identifiers = $identifiers; + $this->attachToExisting = $attachToExisting; + $this->queryModifier = $queryModifier; + $this->afterCreate = $afterCreate; + $this->createdCount = 0; + } + + /** + * @return Builder + */ + private function getModelInstance(): Builder + { + return (new $this->model)->query(); + } + + private function createEntity(array $identifierData, array $createValues): string + { + $model = new $this->model(); + foreach ($identifierData as $identifier => $identifierValue) { + $model->{$identifier} = $identifierValue; + } + foreach ($createValues as $key => $value) { + $model->{$key} = $value; + } + $model->save(); + + if ($this->afterCreate !== null) { + ($this->afterCreate)($model); + } + + $this->mapIdentifierToKey[$this->getHash($identifierData)] = $model->getKey(); + $this->createdCount++; + + return $model->getKey(); + } + + private function getHash(array $data): string + { + return md5(json_encode($data)); + } + + public function getKey(array $identifierData, array $createValues = []): string + { + $this->checkMap(); + + $hash = $this->getHash($identifierData); + if ($this->attachToExisting) { + $key = $this->mapIdentifierToKey[$hash] ?? null; + if ($key !== null) { + return $key; + } + + return $this->createEntity($identifierData, $createValues); + } else { + throw new \RuntimeException('Not implemented'); + } + } + + private function checkMap(): void + { + if ($this->mapIdentifierToKey === null) { + $select = $this->identifiers; + $select[] = (new $this->model())->getKeyName(); + $builder = $this->getModelInstance(); + + if ($this->queryModifier !== null) { + $builder = ($this->queryModifier)($builder); + } + + $databaseEntries = $builder->select($select) + ->get(); + $this->mapIdentifierToKey = []; + foreach ($databaseEntries as $databaseEntry) { + $identifierData = []; + foreach ($this->identifiers as $identifier) { + $identifierData[$identifier] = $databaseEntry->{$identifier}; + } + $hash = $this->getHash($identifierData); + $this->mapIdentifierToKey[$hash] = $databaseEntry->getKey(); + } + } + } + + public function getCreatedCount(): int + { + return $this->createdCount; + } +} diff --git a/app/Service/Import/ImportService.php b/app/Service/Import/ImportService.php new file mode 100644 index 00000000..0c442c15 --- /dev/null +++ b/app/Service/Import/ImportService.php @@ -0,0 +1,30 @@ +getImporter($importerType); + $importer->init($organization); + DB::transaction(function () use (&$importer, &$data, &$options, &$organization) { + $importer->importData($data, $options); + }); + + return $importer->getReport(); + } +} diff --git a/app/Service/Import/Importers/ImportException.php b/app/Service/Import/Importers/ImportException.php new file mode 100644 index 00000000..00588701 --- /dev/null +++ b/app/Service/Import/Importers/ImportException.php @@ -0,0 +1,9 @@ + TogglTimeEntriesImporter::class, + ]; + + /** + * @param class-string $importer + */ + public function registerImporter(string $type, string $importer): void + { + $this->importers[$type] = $importer; + } + + /** + * @return array + */ + public function getImporterKeys(): array + { + return array_keys($this->importers); + } + + public function getImporter(string $type): ImporterContract + { + if (! array_key_exists($type, $this->importers)) { + throw new \InvalidArgumentException('Invalid importer type'); + } + + return new $this->importers[$type]; + } +} diff --git a/app/Service/Import/Importers/ReportDto.php b/app/Service/Import/Importers/ReportDto.php new file mode 100644 index 00000000..5f4748af --- /dev/null +++ b/app/Service/Import/Importers/ReportDto.php @@ -0,0 +1,76 @@ +clientsCreated = $clientsCreated; + $this->projectsCreated = $projectsCreated; + $this->tasksCreated = $tasksCreated; + $this->timeEntriesCreated = $timeEntriesCreated; + $this->tagsCreated = $tagsCreated; + $this->usersCreated = $usersCreated; + } + + /** + * @return array{ + * clients: array{ + * created: int, + * }, + * projects: array{ + * created: int, + * }, + * tasks: array{ + * created: int, + * }, + * time-entries: array{ + * created: int, + * }, + * tags: array{ + * created: int, + * }, + * users: array{ + * created: int, + * } + * } + */ + public function toArray(): array + { + return [ + 'clients' => [ + 'created' => $this->clientsCreated, + ], + 'projects' => [ + 'created' => $this->projectsCreated, + ], + 'tasks' => [ + 'created' => $this->tasksCreated, + ], + 'time-entries' => [ + 'created' => $this->timeEntriesCreated, + ], + 'tags' => [ + 'created' => $this->tagsCreated, + ], + 'users' => [ + 'created' => $this->usersCreated, + ], + ]; + } +} diff --git a/app/Service/Import/Importers/TogglTimeEntriesImporter.php b/app/Service/Import/Importers/TogglTimeEntriesImporter.php new file mode 100644 index 00000000..0021bfb6 --- /dev/null +++ b/app/Service/Import/Importers/TogglTimeEntriesImporter.php @@ -0,0 +1,189 @@ +organization = $organization; + $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { + return $builder->whereHas('organizations', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->whereKey($this->organization->getKey()); + }); + }, function (User $user) { + $user->organizations()->attach([$this->organization->id]); + }); + // TODO: user special after import + $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->timeEntriesCreated = 0; + } + + private function getTags(string $tags): array + { + if (trim($tags) === '') { + return []; + } + $tagsParsed = explode(', ', $tags); + $tagIds = []; + foreach ($tagsParsed as $tagParsed) { + if (strlen($tagParsed) > 255) { + throw new ImportException('Tag is too long'); + } + $tagId = $this->tagImportHelper->getKey([ + 'name' => $tagParsed, + 'organization_id' => $this->organization->id, + ]); + $tagIds[] = $tagId; + } + + return $tagIds; + } + + /** + * @throws ImportException + */ + #[\Override] + public function importData(string $data, array $options): void + { + try { + $colorService = app(ColorService::class); + $reader = Reader::createFromString($data); + $reader->setHeaderOffset(0); + $reader->setDelimiter(','); + $header = $reader->getHeader(); + $this->validateHeader($header); + $records = $reader->getRecords(); + foreach ($records as $record) { + $userId = $this->userImportHelper->getKey([ + 'email' => $record['Email'], + ], [ + 'name' => $record['User'], + 'is_placeholder' => true, + ]); + $clientId = null; + if ($record['Client'] !== '') { + $clientId = $this->clientImportHelper->getKey([ + 'name' => $record['Client'], + 'organization_id' => $this->organization->id, + ]); + } + $projectId = null; + if ($record['Project'] !== '') { + $projectId = $this->projectImportHelper->getKey([ + 'name' => $record['Project'], + 'organization_id' => $this->organization->id, + ], [ + 'client_id' => $clientId, + 'color' => $colorService->getRandomColor(), + ]); + } + $taskId = null; + if ($record['Task'] !== '') { + $taskId = $this->taskImportHelper->getKey([ + 'name' => $record['Task'], + 'project_id' => $projectId, + 'organization_id' => $this->organization->id, + ]); + } + $timeEntry = new TimeEntry(); + $timeEntry->user_id = $userId; + $timeEntry->task_id = $taskId; + $timeEntry->project_id = $projectId; + $timeEntry->organization_id = $this->organization->id; + $timeEntry->description = $record['Description']; + 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->start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], 'UTC'); + $timeEntry->end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], 'UTC'); + $timeEntry->save(); + $this->timeEntriesCreated++; + } + } catch (CsvException $exception) { + throw new ImportException('Invalid CSV data'); + } + + } + + private function validateHeader(array $header): void + { + $requiredFields = [ + 'User', + 'Email', + 'Client', + 'Project', + 'Task', + 'Description', + 'Billable', + 'Start date', + 'Start time', + 'End date', + 'End time', + 'Tags', + ]; + foreach ($requiredFields as $requiredField) { + if (! in_array($requiredField, $header, true)) { + throw new ImportException('Invalid CSV header, missing field: '.$requiredField); + } + } + } + + #[\Override] + public function getReport(): ReportDto + { + return new ReportDto( + clientsCreated: $this->clientImportHelper->getCreatedCount(), + projectsCreated: $this->projectImportHelper->getCreatedCount(), + tasksCreated: $this->taskImportHelper->getCreatedCount(), + timeEntriesCreated: $this->timeEntriesCreated, + tagsCreated: $this->tagImportHelper->getCreatedCount(), + usersCreated: $this->userImportHelper->getCreatedCount(), + ); + } +} diff --git a/config/telescope.php b/config/telescope.php index 312fdf26..21d8ddda 100644 --- a/config/telescope.php +++ b/config/telescope.php @@ -98,7 +98,6 @@ ], 'ignore_paths' => [ - 'livewire*', 'nova-api*', 'pulse*', ], diff --git a/database/migrations/2014_10_12_000000_create_users_table.php b/database/migrations/2014_10_12_000000_create_users_table.php index 490d4d25..ffa23ea9 100644 --- a/database/migrations/2014_10_12_000000_create_users_table.php +++ b/database/migrations/2014_10_12_000000_create_users_table.php @@ -16,13 +16,17 @@ public function up(): void Schema::create('users', function (Blueprint $table) { $table->uuid('id')->primary(); $table->string('name'); - $table->string('email')->unique(); + $table->string('email'); $table->timestamp('email_verified_at')->nullable(); - $table->string('password'); + $table->string('password')->nullable(); $table->rememberToken(); + $table->boolean('is_placeholder')->default(false); $table->foreignUuid('current_team_id')->nullable(); $table->string('profile_photo_path', 2048)->nullable(); $table->timestamps(); + + $table->uniqueIndex('email') + ->where('is_placeholder = false'); }); } diff --git a/routes/api.php b/routes/api.php index cccee762..a75683a9 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Http\Controllers\Api\V1\ClientController; +use App\Http\Controllers\Api\V1\ImportController; use App\Http\Controllers\Api\V1\OrganizationController; use App\Http\Controllers\Api\V1\ProjectController; use App\Http\Controllers\Api\V1\TagController; @@ -60,6 +61,11 @@ Route::put('/organizations/{organization}/clients/{client}', [ClientController::class, 'update'])->name('update'); Route::delete('/organizations/{organization}/clients/{client}', [ClientController::class, 'destroy'])->name('destroy'); }); + + // Import routes + Route::name('import.')->group(static function () { + Route::post('/organizations/{organization}/import', [ImportController::class, 'import'])->name('import'); + }); }); /** diff --git a/storage/tests/clockify_import_test_1.csv b/storage/tests/clockify_import_test_1.csv new file mode 100644 index 00000000..66b59c18 --- /dev/null +++ b/storage/tests/clockify_import_test_1.csv @@ -0,0 +1 @@ +"Project","Client","Description","Task","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)" diff --git a/storage/tests/toggl_import_test_1.csv b/storage/tests/toggl_import_test_1.csv new file mode 100644 index 00000000..1fdfc8d6 --- /dev/null +++ b/storage/tests/toggl_import_test_1.csv @@ -0,0 +1,3 @@ +User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Duration,Tags,Amount (EUR) +Peter Tester,peter.test@email.test,,Project without Client,,"",No,2024-03-04,10:23:52,2024-03-04,10:23:52,00:00:00,Development, +Peter Tester,peter.test@email.test,Big Company,Project for Big Company,Task 1,Working hard,Yes,2024-03-04,10:23:00,2024-03-04,11:23:01,01:00:01,,111.11 diff --git a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php new file mode 100644 index 00000000..c53b2fde --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php @@ -0,0 +1,58 @@ +createUserWithPermission([ + ]); + + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.import', ['organization' => $data->organization->id]), [ + 'type' => 'toggl_time_entries', + 'data' => 'some data', + 'options' => [], + ]); + + // Assert + $response->assertStatus(403); + } + + public function test_import_calls_import_service_if_user_has_permission(): void + { + // Arrange + $user = $this->createUserWithPermission([ + 'import', + ]); + $this->mock(ImportService::class, function (MockInterface $mock) use (&$user): void { + $mock->shouldReceive('import') + ->withArgs(function (Organization $organization, string $importerType, string $data, array $options) use (&$user): bool { + return $organization->is($user->organization) && $importerType === 'toggl_time_entries' && $data === 'some data' && $options === []; + }) + ->once(); + }); + Passport::actingAs($user->user); + + // Act + $response = $this->postJson(route('api.v1.import.import', ['organization' => $user->organization->id]), [ + 'type' => 'toggl_time_entries', + 'data' => 'some data', + 'options' => [], + ]); + + // Assert + $response->assertStatus(200); + } +} diff --git a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php new file mode 100644 index 00000000..f0a03d9b --- /dev/null +++ b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php @@ -0,0 +1,71 @@ +create(); + $helper = new ImportDatabaseHelper(User::class, ['email'], true); + + // Act + $key = $helper->getKey([ + 'email' => $user->email, + ], [ + 'name' => 'Test', + ]); + + // Assert + $this->assertSame($user->getKey(), $key); + } + + public function test_get_key_attach_to_existing_creates_model_if_not_existing(): void + { + // Arrange + $helper = new ImportDatabaseHelper(User::class, ['email'], true); + + // Act + $key = $helper->getKey([ + 'email' => 'test@mail.test', + ], [ + 'name' => 'Test', + ]); + + // Assert + $this->assertNotNull($key); + $this->assertDatabaseHas(User::class, [ + 'email' => 'test@mail.test', + 'name' => 'Test', + ]); + } + + public function test_get_key_not_attach_to_existing_returns_key_for_identifier_without_creating_model(): void + { + // Arrange + $project = Project::factory()->create(); + $helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], false); + + // Act + $key = $helper->getKey([ + 'name' => $project->name, + 'organization_id' => $project->organization_id, + ], [ + 'color' => '#000000', + ]); + + // Assert + $this->assertNotSame($project->getKey(), $key); + } +} diff --git a/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php b/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php new file mode 100644 index 00000000..7b9c44ab --- /dev/null +++ b/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php @@ -0,0 +1,53 @@ +assertCount(2, $users); + $user1 = $users->firstWhere('name', 'Peter Tester'); + $this->assertNotNull($user1); + $this->assertSame(null, $user1->password); + $this->assertSame('Peter Tester', $user1->name); + $this->assertSame('peter.test@email.test', $user1->email); + $projects = Project::all(); + $this->assertCount(2, $projects); + $project1 = $projects->firstWhere('name', 'Project without Client'); + $this->assertNotNull($project1); + $project2 = $projects->firstWhere('name', 'Project for Big Company'); + $this->assertNotNull($project2); + $tasks = Task::all(); + $this->assertCount(1, $tasks); + $task1 = $tasks->firstWhere('name', 'Task 1'); + $this->assertNotNull($task1); + $this->assertSame($project2->getKey(), $task1->project_id); + $tags = Tag::all(); + $this->assertCount(1, $tags); + $tag1 = $tags->firstWhere('name', 'Development'); + $this->assertNotNull($tag1); + + return (object) [ + 'user1' => $user1, + 'project1' => $project1, + 'project2' => $project2, + 'tag1' => $tag1, + ]; + } +} diff --git a/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php new file mode 100644 index 00000000..0e0f9ccf --- /dev/null +++ b/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php @@ -0,0 +1,61 @@ +create(); + $importer = new TogglTimeEntriesImporter(); + $importer->init($organization); + $data = file_get_contents(storage_path('tests/toggl_import_test_1.csv')); + + // Act + $importer->importData($data, []); + + // Assert + $this->checkTestScenarioAfterImportExcludingTimeEntries(); + } + + public function test_import_of_test_file_twice_succeeds(): void + { + // Arrange + $organization = Organization::factory()->create(); + $importer = new TogglTimeEntriesImporter(); + $importer->init($organization); + $data = file_get_contents(storage_path('tests/toggl_import_test_1.csv')); + $importer->importData($data, []); + $importer = new TogglTimeEntriesImporter(); + $importer->init($organization); + + // Act + $importer->importData($data, []); + + // Assert + $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(); + $timeEntries = TimeEntry::all(); + $this->assertCount(4, $timeEntries); + $timeEntry1 = $timeEntries->firstWhere('description', ''); + $this->assertNotNull($timeEntry1); + $this->assertSame('', $timeEntry1->description); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString()); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString()); + $this->assertFalse($timeEntry1->billable); + $this->assertSame([$testScenario->tag1->getKey()], $timeEntry1->tags); + $timeEntry2 = $timeEntries->firstWhere('description', 'Working hard'); + $this->assertNotNull($timeEntry2); + $this->assertSame('Working hard', $timeEntry2->description); + $this->assertSame('2024-03-04 10:23:00', $timeEntry2->start->toDateTimeString()); + $this->assertSame('2024-03-04 11:23:01', $timeEntry2->end->toDateTimeString()); + $this->assertTrue($timeEntry2->billable); + $this->assertSame([], $timeEntry2->tags); + } +} From 14e3017cb245e50d733c0e432d17d9f58ab1609d Mon Sep 17 00:00:00 2001 From: korridor <26689068+korridor@users.noreply.github.com> Date: Fri, 8 Mar 2024 13:31:49 +0100 Subject: [PATCH 2/5] Added placeholder users; Better exception handling; Enhanced local setup --- .env.example | 2 +- .gitignore | 1 + README.md | 1 + app/Actions/Fortify/CreateNewUser.php | 22 ++- .../Jetstream/AddOrganizationMember.php | 31 ++-- .../Jetstream/InviteOrganizationMember.php | 11 +- app/Exceptions/Api/ApiException.php | 46 ++++++ .../Api/TimeEntryStillRunningApiException.php | 10 ++ .../Api/UserNotPlaceholderApiException.php | 10 ++ app/Exceptions/ApiException.php | 24 --- app/Exceptions/TimeEntryStillRunning.php | 9 -- .../Api/V1/TimeEntryController.php | 7 +- .../Controllers/Api/V1/UserController.php | 56 +++++++ .../Requests/V1/User/UserIndexRequest.php | 26 ++++ app/Http/Resources/V1/User/UserCollection.php | 17 +++ app/Http/Resources/V1/User/UserResource.php | 40 +++++ app/Listeners/RemovePlaceholder.php | 30 ++++ app/Models/Organization.php | 27 ++++ app/Models/User.php | 32 ++++ app/Providers/EventServiceProvider.php | 6 +- app/Providers/JetstreamServiceProvider.php | 3 + .../Import/Importers/ImporterProvider.php | 3 + app/Service/UserService.php | 23 +++ config/telescope.php | 2 +- database/factories/OrganizationFactory.php | 4 +- database/factories/UserFactory.php | 10 ++ database/seeders/DatabaseSeeder.php | 54 +++++-- docker-compose.yml | 35 +++++ lang/en/exceptions.php | 13 ++ phpunit.xml | 1 + routes/api.php | 7 + tests/Feature/InviteTeamMemberTest.php | 138 ++++++++++++++++++ tests/Feature/RegistrationTest.php | 44 +++++- .../Endpoint/Api/V1/ImportEndpointTest.php | 33 ++++- .../Unit/Endpoint/Api/V1/UserEndpointTest.php | 85 +++++++++++ tests/Unit/Model/UserModelTest.php | 45 ++++++ .../Import/ImportDatabaseHelperTest.php | 22 ++- tests/Unit/Service/UserServiceTest.php | 37 +++++ 38 files changed, 880 insertions(+), 87 deletions(-) create mode 100644 app/Exceptions/Api/ApiException.php create mode 100644 app/Exceptions/Api/TimeEntryStillRunningApiException.php create mode 100644 app/Exceptions/Api/UserNotPlaceholderApiException.php delete mode 100644 app/Exceptions/ApiException.php delete mode 100644 app/Exceptions/TimeEntryStillRunning.php create mode 100644 app/Http/Controllers/Api/V1/UserController.php create mode 100644 app/Http/Requests/V1/User/UserIndexRequest.php create mode 100644 app/Http/Resources/V1/User/UserCollection.php create mode 100644 app/Http/Resources/V1/User/UserResource.php create mode 100644 app/Listeners/RemovePlaceholder.php create mode 100644 app/Service/UserService.php create mode 100644 lang/en/exceptions.php create mode 100644 tests/Unit/Endpoint/Api/V1/UserEndpointTest.php create mode 100644 tests/Unit/Service/UserServiceTest.php diff --git a/.env.example b/.env.example index d3e03c26..ed50bd57 100644 --- a/.env.example +++ b/.env.example @@ -37,7 +37,7 @@ MAIL_PORT=1025 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null -MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_ADDRESS="no-reply@solidtime.test" MAIL_FROM_NAME="${APP_NAME}" AWS_ACCESS_KEY_ID= diff --git a/.gitignore b/.gitignore index e6028879..ffdee140 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ yarn-error.log /blob-report/ /playwright/.cache/ /coverage +/extensions/* diff --git a/README.md b/README.md index eb0c4796..4f7a64f7 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ Add the following entry to your `/etc/hosts` ``` 127.0.0.1 solidtime.test 127.0.0.1 playwright.solidtime.test +127.0.0.1 mail.solidtime.test ``` ## Running E2E Tests diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 292463ef..3702cf5a 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -6,9 +6,12 @@ use App\Models\Organization; use App\Models\User; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; +use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Laravel\Fortify\Contracts\CreatesNewUsers; use Laravel\Jetstream\Jetstream; @@ -20,12 +23,27 @@ class CreateNewUser implements CreatesNewUsers * Create a newly registered user. * * @param array $input + * + * @throws ValidationException */ public function create(array $input): User { Validator::make($input, [ - 'name' => ['required', 'string', 'max:255'], - 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], + 'name' => [ + 'required', + 'string', + 'max:255', + ], + 'email' => [ + 'required', + 'string', + 'email', + 'max:255', + new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder { + /** @var Builder $builder */ + return $builder->where('is_placeholder', '=', false); + }), + ], 'password' => $this->passwordRules(), 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '', ])->validate(); diff --git a/app/Actions/Jetstream/AddOrganizationMember.php b/app/Actions/Jetstream/AddOrganizationMember.php index 47f9ccfa..7a84d53f 100644 --- a/app/Actions/Jetstream/AddOrganizationMember.php +++ b/app/Actions/Jetstream/AddOrganizationMember.php @@ -8,8 +8,10 @@ use App\Models\User; use Closure; use Illuminate\Contracts\Validation\Rule; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Validator; +use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; use Laravel\Jetstream\Contracts\AddsTeamMembers; use Laravel\Jetstream\Events\AddingTeamMember; use Laravel\Jetstream\Events\TeamMemberAdded; @@ -21,21 +23,24 @@ class AddOrganizationMember implements AddsTeamMembers /** * Add a new team member to the given team. */ - public function add(User $user, Organization $organization, string $email, ?string $role = null): void + public function add(User $owner, Organization $organization, string $email, ?string $role = null): void { - Gate::forUser($user)->authorize('addTeamMember', $organization); + Gate::forUser($owner)->authorize('addTeamMember', $organization); $this->validate($organization, $email, $role); - $newTeamMember = Jetstream::findUserByEmailOrFail($email); + $newOrganizationMember = User::query() + ->where('email', $email) + ->where('is_placeholder', '=', false) + ->firstOrFail(); - AddingTeamMember::dispatch($organization, $newTeamMember); + AddingTeamMember::dispatch($organization, $newOrganizationMember); $organization->users()->attach( - $newTeamMember, ['role' => $role] + $newOrganizationMember, ['role' => $role] ); - TeamMemberAdded::dispatch($organization, $newTeamMember); + TeamMemberAdded::dispatch($organization, $newOrganizationMember); } /** @@ -46,9 +51,7 @@ protected function validate(Organization $organization, string $email, ?string $ Validator::make([ 'email' => $email, 'role' => $role, - ], $this->rules(), [ - 'email.exists' => __('We were unable to find a registered user with this email address.'), - ])->after( + ], $this->rules())->after( $this->ensureUserIsNotAlreadyOnTeam($organization, $email) )->validateWithBag('addTeamMember'); } @@ -61,7 +64,13 @@ protected function validate(Organization $organization, string $email, ?string $ protected function rules(): array { return array_filter([ - 'email' => ['required', 'email', 'exists:users'], + 'email' => [ + 'required', + 'email', + (new ExistsEloquent(User::class, 'email', function (Builder $builder) { + return $builder->where('is_placeholder', '=', false); + }))->withMessage(__('We were unable to find a registered user with this email address.')), + ], 'role' => Jetstream::hasRoles() ? ['required', 'string', new Role] : null, @@ -75,7 +84,7 @@ protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $emai { return function ($validator) use ($team, $email) { $validator->errors()->addIf( - $team->hasUserWithEmail($email), + $team->hasRealUserWithEmail($email), 'email', __('This user already belongs to the team.') ); diff --git a/app/Actions/Jetstream/InviteOrganizationMember.php b/app/Actions/Jetstream/InviteOrganizationMember.php index a688fffa..686a9978 100644 --- a/app/Actions/Jetstream/InviteOrganizationMember.php +++ b/app/Actions/Jetstream/InviteOrganizationMember.php @@ -34,6 +34,7 @@ public function invite(User $user, Organization $organization, string $email, ?s InvitingTeamMember::dispatch($organization, $email, $role); + /** @var TeamInvitation $invitation */ $invitation = $organization->teamInvitations()->create([ 'email' => $email, 'role' => $role, @@ -50,9 +51,7 @@ protected function validate(Organization $organization, string $email, ?string $ Validator::make([ 'email' => $email, 'role' => $role, - ], $this->rules($organization), [ - 'email.unique' => __('This user has already been invited to the team.'), - ])->after( + ], $this->rules($organization))->after( $this->ensureUserIsNotAlreadyOnTeam($organization, $email) )->validateWithBag('addTeamMember'); } @@ -68,10 +67,10 @@ protected function rules(Organization $organization): array 'email' => [ 'required', 'email', - new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) { + (new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) { /** @var Builder $builder */ return $builder->whereBelongsTo($organization, 'organization'); - }), + }))->withMessage(__('This user has already been invited to the team.')), ], 'role' => Jetstream::hasRoles() ? ['required', 'string', new Role] @@ -86,7 +85,7 @@ protected function ensureUserIsNotAlreadyOnTeam(Organization $organization, stri { return function ($validator) use ($organization, $email) { $validator->errors()->addIf( - $organization->hasUserWithEmail($email), + $organization->hasRealUserWithEmail($email), 'email', __('This user already belongs to the team.') ); diff --git a/app/Exceptions/Api/ApiException.php b/app/Exceptions/Api/ApiException.php new file mode 100644 index 00000000..bcd5ae01 --- /dev/null +++ b/app/Exceptions/Api/ApiException.php @@ -0,0 +1,46 @@ +json([ + 'error' => true, + 'key' => $this->getKey(), + 'message' => $this->getTranslatedMessage(), + ], 400); + } + + /** + * Get the key for the exception. + */ + public function getKey(): string + { + if (defined(static::class.'::KEY')) { + return static::KEY; + } + + throw new LogicException('API exceptions need the KEY constant defined.'); + } + + /** + * Get the translated message for the exception. + */ + public function getTranslatedMessage(): string + { + return __('exceptions.api.'.$this->getKey()); + } +} diff --git a/app/Exceptions/Api/TimeEntryStillRunningApiException.php b/app/Exceptions/Api/TimeEntryStillRunningApiException.php new file mode 100644 index 00000000..e110a9ae --- /dev/null +++ b/app/Exceptions/Api/TimeEntryStillRunningApiException.php @@ -0,0 +1,10 @@ +json([ - 'error' => true, - 'message' => $this->getMessage(), - ], 400); - } -} diff --git a/app/Exceptions/TimeEntryStillRunning.php b/app/Exceptions/TimeEntryStillRunning.php deleted file mode 100644 index a4ee00a2..00000000 --- a/app/Exceptions/TimeEntryStillRunning.php +++ /dev/null @@ -1,9 +0,0 @@ -get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) { // TODO: API documentation - // TODO: Create concept for api exceptions - throw new TimeEntryStillRunning('User already has an active time entry'); + throw new TimeEntryStillRunningApiException(); } $timeEntry = new TimeEntry(); diff --git a/app/Http/Controllers/Api/V1/UserController.php b/app/Http/Controllers/Api/V1/UserController.php new file mode 100644 index 00000000..94ff6f2e --- /dev/null +++ b/app/Http/Controllers/Api/V1/UserController.php @@ -0,0 +1,56 @@ +checkPermission($organization, 'users:view'); + + $users = $organization->users() + ->paginate(); + + return UserCollection::make($users); + } + + /** + * Invite a placeholder user to become a real user in the organization + * + * @throws AuthorizationException|UserNotPlaceholderApiException + */ + public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse + { + $this->checkPermission($organization, 'users:invite-placeholder'); + + if (! $user->is_placeholder) { + throw new UserNotPlaceholderApiException(); + } + + app(InvitesTeamMembers::class)->invite( + $request->user(), + $organization, + $user->email, + 'employee' + ); + + return response()->json($user); + } +} diff --git a/app/Http/Requests/V1/User/UserIndexRequest.php b/app/Http/Requests/V1/User/UserIndexRequest.php new file mode 100644 index 00000000..f600d01b --- /dev/null +++ b/app/Http/Requests/V1/User/UserIndexRequest.php @@ -0,0 +1,26 @@ +> + */ + public function rules(): array + { + return [ + ]; + } +} diff --git a/app/Http/Resources/V1/User/UserCollection.php b/app/Http/Resources/V1/User/UserCollection.php new file mode 100644 index 00000000..e9461a8f --- /dev/null +++ b/app/Http/Resources/V1/User/UserCollection.php @@ -0,0 +1,17 @@ +> + */ + public function toArray(Request $request): array + { + /** @var Membership $membership */ + $membership = $this->resource->getRelationValue('membership'); + + return [ + /** @var string $id ID */ + 'id' => $this->resource->id, + /** @var string $name Name */ + 'name' => $this->resource->name, + /** @var string $email Email */ + 'email' => $this->resource->email, + /** @var string $role Role */ + 'role' => $membership->role, + /** @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->is_placeholder, + ]; + } +} diff --git a/app/Listeners/RemovePlaceholder.php b/app/Listeners/RemovePlaceholder.php new file mode 100644 index 00000000..4ba70a9d --- /dev/null +++ b/app/Listeners/RemovePlaceholder.php @@ -0,0 +1,30 @@ +where('is_placeholder', '=', true) + ->where('email', '=', $event->user->email) + ->belongsToOrganization($event->team) + ->get(); + + foreach ($placeholders as $placeholder) { + $userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholder, $event->user); + } + } +} diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 62bd33b7..284675d4 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Laravel\Jetstream\Events\TeamCreated; use Laravel\Jetstream\Events\TeamDeleted; @@ -59,4 +60,30 @@ class Organization extends JetstreamTeam 'updated' => TeamUpdated::class, 'deleted' => TeamDeleted::class, ]; + + /** + * Get all the non-placeholder users of the organization including its owner. + * + * @return Collection + */ + public function allRealUsers(): Collection + { + return $this->realUsers->merge([$this->owner]); + } + + public function hasRealUserWithEmail(string $email): bool + { + return $this->allRealUsers()->contains(function (User $user) use ($email): bool { + return $user->email === $email; + }); + } + + /** + * @return BelongsToMany + */ + public function realUsers(): BelongsToMany + { + return $this->users() + ->where('is_placeholder', false); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 19e6bc8f..a58a737f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -6,6 +6,8 @@ use Database\Factories\UserFactory; use Filament\Panel; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -21,9 +23,16 @@ * @property string $id * @property string $name * @property string $email + * @property string|null $email_verified_at + * @property string|null $password + * @property bool $is_placeholder + * @property Collection $organizations + * @property Collection $timeEntries * * @method HasMany ownedTeams() * @method static UserFactory factory() + * @method static Builder query() + * @method Builder belongsToOrganization(Organization $organization) */ class User extends Authenticatable { @@ -97,4 +106,27 @@ public function organizations(): BelongsToMany ->withTimestamps() ->as('membership'); } + + /** + * @return HasMany + */ + public function timeEntries(): HasMany + { + return $this->hasMany(TimeEntry::class); + } + + /** + * @param Builder $builder + * @return Builder + */ + public function scopeBelongsToOrganization(Builder $builder, Organization $organization): Builder + { + return $builder->where(function (Builder $builder) use ($organization): Builder { + return $builder->whereHas('organizations', function (Builder $query) use ($organization): void { + $query->whereKey($organization->getKey()); + })->orWhereHas('ownedTeams', function (Builder $query) use ($organization): void { + $query->whereKey($organization->getKey()); + }); + }); + } } diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index ee09f108..4dc848b5 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -4,10 +4,11 @@ namespace App\Providers; +use App\Listeners\RemovePlaceholder; use Illuminate\Auth\Events\Registered; use Illuminate\Auth\Listeners\SendEmailVerificationNotification; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; -use Illuminate\Support\Facades\Event; +use Laravel\Jetstream\Events\TeamMemberAdded; class EventServiceProvider extends ServiceProvider { @@ -20,6 +21,9 @@ class EventServiceProvider extends ServiceProvider Registered::class => [ SendEmailVerificationNotification::class, ], + TeamMemberAdded::class => [ + RemovePlaceholder::class, + ], ]; /** diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 1de94d23..e3757c7c 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -75,6 +75,8 @@ protected function configurePermissions(): void 'organizations:view', 'organizations:update', 'import', + 'users:invite-placeholder', + 'users:view', ])->description('Administrator users can perform any action.'); Jetstream::role('manager', 'Manager', [ @@ -95,6 +97,7 @@ protected function configurePermissions(): void 'tags:update', 'tags:delete', 'organizations:view', + 'users:view', ])->description('Editor users have the ability to read, create, and update.'); Jetstream::role('employee', 'Employee', [ diff --git a/app/Service/Import/Importers/ImporterProvider.php b/app/Service/Import/Importers/ImporterProvider.php index 4ed1033f..ed413566 100644 --- a/app/Service/Import/Importers/ImporterProvider.php +++ b/app/Service/Import/Importers/ImporterProvider.php @@ -6,6 +6,9 @@ class ImporterProvider { + /** + * @var array> + */ private array $importers = [ 'toggl_time_entries' => TogglTimeEntriesImporter::class, ]; diff --git a/app/Service/UserService.php b/app/Service/UserService.php new file mode 100644 index 00000000..e554e078 --- /dev/null +++ b/app/Service/UserService.php @@ -0,0 +1,23 @@ +whereBelongsTo($organization, 'organization') + ->whereBelongsTo($fromUser, 'user') + ->update([ + 'user_id' => $toUser->getKey(), + ])); + } +} diff --git a/config/telescope.php b/config/telescope.php index 21d8ddda..d4057545 100644 --- a/config/telescope.php +++ b/config/telescope.php @@ -155,7 +155,7 @@ Watchers\LogWatcher::class => [ 'enabled' => env('TELESCOPE_LOG_WATCHER', true), - 'level' => 'error', + 'level' => 'debug', ], Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true), diff --git a/database/factories/OrganizationFactory.php b/database/factories/OrganizationFactory.php index e0d629e4..e1efdea6 100644 --- a/database/factories/OrganizationFactory.php +++ b/database/factories/OrganizationFactory.php @@ -27,10 +27,10 @@ public function definition(): array ]; } - public function withOwner(): self + public function withOwner(?User $owner = null): self { return $this->state(fn (array $attributes) => [ - 'user_id' => User::factory(), + 'user_id' => $owner === null ? User::factory() : $owner, ]); } } diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 60f31ffb..893c7e65 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -31,9 +31,19 @@ public function definition(): array 'remember_token' => Str::random(10), 'profile_photo_path' => null, 'current_team_id' => null, + 'is_placeholder' => false, ]; } + public function placeholder(bool $placeholder = true): static + { + return $this->state(function (array $attributes) use ($placeholder): array { + return [ + 'is_placeholder' => $placeholder, + ]; + }); + } + /** * Indicate that the model's email address should be unverified. */ diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 7dd88fa8..b59a486d 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -22,31 +22,57 @@ class DatabaseSeeder extends Seeder public function run(): void { $this->deleteAll(); - $organization1 = Organization::factory()->create([ + $userAcmeOwner = User::factory()->create([ + 'name' => 'ACME Admin', + 'email' => 'owner@acme.test', + ]); + $organizationAcme = Organization::factory()->withOwner($userAcmeOwner)->create([ 'name' => 'ACME Corp', ]); - $user1 = User::factory()->withPersonalOrganization()->create([ + $userAcmeManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Test User', 'email' => 'test@example.com', ]); - $employee1 = User::factory()->withPersonalOrganization()->create([ - 'name' => 'Test User', - 'email' => 'employee@example.com', - ]); - $userAcmeAdmin = User::factory()->create([ + $userAcmeAdmin = User::factory()->withPersonalOrganization()->create([ 'name' => 'ACME Admin', 'email' => 'admin@acme.test', ]); - $user1->organizations()->attach($organization1, [ + $userAcmeEmployee = User::factory()->withPersonalOrganization()->create([ + 'name' => 'Max Mustermann', + 'email' => 'max.mustermann@acme.test', + ]); + $userAcmePlaceholder = User::factory()->placeholder()->create([ + 'name' => 'Old Employee', + 'email' => 'old.employee@acme.test', + 'password' => null, + ]); + $userAcmeManager->organizations()->attach($organizationAcme, [ 'role' => 'manager', ]); - $userAcmeAdmin->organizations()->attach($organization1, [ + $userAcmeAdmin->organizations()->attach($organizationAcme, [ 'role' => 'admin', ]); - $timeEntriesEmployees = TimeEntry::factory() + $userAcmeEmployee->organizations()->attach($organizationAcme, [ + 'role' => 'employee', + ]); + $userAcmePlaceholder->organizations()->attach($organizationAcme, [ + 'role' => 'employee', + ]); + + $timeEntriesAcmeAdmin = TimeEntry::factory() + ->count(10) + ->forUser($userAcmeAdmin) + ->forOrganization($organizationAcme) + ->create(); + $timeEntriesAcmePlaceholder = TimeEntry::factory() + ->count(10) + ->forUser($userAcmePlaceholder) + ->forOrganization($organizationAcme) + ->create(); + $timeEntriesAcmePlaceholder = TimeEntry::factory() ->count(10) - ->forUser($employee1) - ->forOrganization($organization1) + ->forUser($userAcmeEmployee) + ->forOrganization($organizationAcme) ->create(); $client = Client::factory()->create([ 'name' => 'Big Company', @@ -63,11 +89,11 @@ public function run(): void $organization2 = Organization::factory()->create([ 'name' => 'Rival Corp', ]); - $user1 = User::factory()->withPersonalOrganization()->create([ + $userAcmeManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Other User', 'email' => 'test@rival-company.test', ]); - $user1->organizations()->attach($organization2, [ + $userAcmeManager->organizations()->attach($organization2, [ 'role' => 'admin', ]); $otherCompanyProject = Project::factory()->forClient($client)->create([ diff --git a/docker-compose.yml b/docker-compose.yml index 876bcfe0..d4faaf13 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,10 +57,43 @@ services: - '${DB_USERNAME}' retries: 3 timeout: 5s + pgsql_test: + image: 'postgres:15' + environment: + PGPASSWORD: '${DB_PASSWORD:-secret}' + POSTGRES_DB: '${DB_DATABASE}' + POSTGRES_USER: '${DB_USERNAME}' + POSTGRES_PASSWORD: '${DB_PASSWORD:-secret}' + volumes: + - 'sail-pgsql-test:/var/lib/postgresql/data' + - './vendor/laravel/sail/database/pgsql/create-testing-database.sql:/docker-entrypoint-initdb.d/10-create-testing-database.sql' + networks: + - sail + healthcheck: + test: + - CMD + - pg_isready + - '-q' + - '-d' + - '${DB_DATABASE}' + - '-U' + - '${DB_USERNAME}' + retries: 3 + timeout: 5s mailpit: image: 'axllent/mailpit:latest' + labels: + - "traefik.enable=true" + - "traefik.docker.network=${NETWORK_NAME}" + - "traefik.http.routers.solidtime-mailpit.rule=Host(`mail.${NGINX_HOST_NAME}`)" + - "traefik.http.routers.solidtime-mailpit.entrypoints=web" + - "traefik.http.services.solidtime-mailpit.loadbalancer.server.port=8025" + - "traefik.http.routers.solidtime-mailpit-https.rule=Host(`mail.${NGINX_HOST_NAME}`)" + - "traefik.http.routers.solidtime-mailpit-https.entrypoints=websecure" + - "traefik.http.routers.solidtime-mailpit-https.tls=true" networks: - sail + - reverse-proxy playwright: image: mcr.microsoft.com/playwright:v1.41.1-jammy command: ['npx', 'playwright', 'test', '--ui-port=8080', '--ui-host=0.0.0.0'] @@ -88,3 +121,5 @@ networks: volumes: sail-pgsql: driver: local + sail-pgsql-test: + driver: local diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php new file mode 100644 index 00000000..706574fb --- /dev/null +++ b/lang/en/exceptions.php @@ -0,0 +1,13 @@ + [ + TimeEntryStillRunningApiException::KEY => 'Time entry is still running', + UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder', + ], +]; diff --git a/phpunit.xml b/phpunit.xml index 82235621..f5ab132a 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -22,6 +22,7 @@ + diff --git a/routes/api.php b/routes/api.php index a75683a9..ca46d271 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\V1\ProjectController; use App\Http\Controllers\Api\V1\TagController; use App\Http\Controllers\Api\V1\TimeEntryController; +use App\Http\Controllers\Api\V1\UserController; use Illuminate\Support\Facades\Route; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; @@ -29,6 +30,12 @@ Route::put('/organizations/{organization}', [OrganizationController::class, 'update'])->name('update'); }); + // User routes + Route::name('users.')->group(static function () { + Route::get('/organizations/{organization}/users', [UserController::class, 'index'])->name('index'); + Route::post('/organizations/{organization}/users/{user}/invite-placeholder', [UserController::class, 'invitePlaceholder'])->name('invite-placeholder'); + }); + // Project routes Route::name('projects.')->group(static function () { Route::get('/organizations/{organization}/projects', [ProjectController::class, 'index'])->name('index'); diff --git a/tests/Feature/InviteTeamMemberTest.php b/tests/Feature/InviteTeamMemberTest.php index a4f14b29..60bffb6b 100644 --- a/tests/Feature/InviteTeamMemberTest.php +++ b/tests/Feature/InviteTeamMemberTest.php @@ -4,9 +4,11 @@ namespace Tests\Feature; +use App\Models\TimeEntry; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\URL; use Laravel\Jetstream\Mail\TeamInvitation; use Tests\TestCase; @@ -31,6 +33,49 @@ public function test_team_members_can_be_invited_to_team(): void $this->assertCount(1, $user->currentTeam->fresh()->teamInvitations); } + public function test_team_member_can_not_be_invited_to_team_if_already_on_team(): void + { + // Arrange + Mail::fake(); + $user = User::factory()->withPersonalOrganization()->create(); + $existingUser = User::factory()->create(); + $user->currentTeam->users()->attach($existingUser, ['role' => 'admin']); + $this->actingAs($user); + + // Act + $response = $this->post('/teams/'.$user->currentTeam->id.'/members', [ + 'email' => $existingUser->email, + 'role' => 'admin', + ]); + + // Assert + $response->assertInvalid(['email'], 'addTeamMember'); + Mail::assertNotSent(TeamInvitation::class); + $this->assertCount(0, $user->currentTeam->fresh()->teamInvitations); + } + + public function test_team_member_can_be_invited_to_team_if_already_on_team_as_placeholder(): void + { + // Arrange + Mail::fake(); + $user = User::factory()->withPersonalOrganization()->create(); + $existingUser = User::factory()->create([ + 'is_placeholder' => true, + ]); + $user->currentTeam->users()->attach($existingUser, ['role' => 'employee']); + $this->actingAs($user); + + // Act + $response = $this->post('/teams/'.$user->currentTeam->id.'/members', [ + 'email' => $existingUser->email, + 'role' => 'employee', + ]); + + // Assert + Mail::assertSent(TeamInvitation::class); + $this->assertCount(1, $user->currentTeam->fresh()->teamInvitations); + } + public function test_team_member_invitations_can_be_cancelled(): void { // Arrange @@ -49,4 +94,97 @@ public function test_team_member_invitations_can_be_cancelled(): void // Assert $this->assertCount(0, $user->currentTeam->fresh()->teamInvitations); } + + public function test_team_member_invitations_can_be_accepted(): void + { + // Arrange + Mail::fake(); + $owner = User::factory()->withPersonalOrganization()->create(); + $user = User::factory()->withPersonalOrganization()->create(); + $invitation = $owner->currentTeam->teamInvitations()->create([ + 'email' => $user->email, + 'role' => 'employee', + ]); + $this->actingAs($user); + + // Act + $acceptUrl = URL::temporarySignedRoute( + 'team-invitations.accept', + now()->addMinutes(60), + [$invitation->getKey()] + ); + $response = $this->get($acceptUrl); + + // Assert + $this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations); + $user->refresh(); + $this->assertCount(1, $user->organizations); + $this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id')); + } + + public function test_team_member_invitations_of_placeholder_can_be_accepted_and_migrates_date_to_real_user(): void + { + // Arrange + Mail::fake(); + $placeholder = User::factory()->withPersonalOrganization()->create([ + 'is_placeholder' => true, + ]); + + $owner = User::factory()->withPersonalOrganization()->create(); + $owner->currentTeam->users()->attach($placeholder, ['role' => 'employee']); + $timeEntries = TimeEntry::factory()->forOrganization($owner->currentTeam)->forUser($placeholder)->createMany(5); + + $user = User::factory()->withPersonalOrganization()->create([ + 'email' => $placeholder->email, + ]); + + $invitation = $owner->currentTeam->teamInvitations()->create([ + 'email' => $user->email, + 'role' => 'employee', + ]); + $this->actingAs($user); + + // Act + $acceptUrl = URL::temporarySignedRoute( + 'team-invitations.accept', + now()->addMinutes(60), + [$invitation->getKey()] + ); + $response = $this->get($acceptUrl); + + // Assert + $user->refresh(); + $placeholder->refresh(); + $this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations); + $this->assertCount(1, $user->organizations); + $this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id')); + $this->assertCount(5, $user->timeEntries); + $this->assertCount(0, $placeholder->timeEntries); + } + + public function test_team_member_accept_fails_if_user_with_that_email_does_not_exist(): void + { + // Arrange + Mail::fake(); + $owner = User::factory()->withPersonalOrganization()->create(); + $user = User::factory()->withPersonalOrganization()->create(); + $invitation = $owner->currentTeam->teamInvitations()->create([ + 'email' => 'firstname.lastname@mail.test', + 'role' => 'employee', + ]); + $this->actingAs($user); + + // Act + $acceptUrl = URL::temporarySignedRoute( + 'team-invitations.accept', + now()->addMinutes(60), + [$invitation->getKey()] + ); + $response = $this->get($acceptUrl); + + // Assert + $this->assertCount(1, $owner->currentTeam->fresh()->teamInvitations); + $user->refresh(); + $this->assertCount(0, $user->organizations); + } } diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php index 23ab3426..4373809f 100644 --- a/tests/Feature/RegistrationTest.php +++ b/tests/Feature/RegistrationTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; +use App\Models\User; use App\Providers\RouteServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Fortify\Features; @@ -38,10 +39,47 @@ public function test_registration_screen_cannot_be_rendered_if_support_is_disabl public function test_new_users_can_register(): void { - if (! Features::enabled(Features::registration())) { - $this->markTestSkipped('Registration support is not enabled.'); - } + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + ]); + + $this->assertAuthenticated(); + $response->assertRedirect(RouteServiceProvider::HOME); + } + + public function test_new_users_can_not_register_if_user_with_email_already_exists(): void + { + // Arrange + $user = User::factory()->create([ + 'email' => 'test@example.com', + ]); + + // Act + $response = $this->post('/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + 'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(), + ]); + + $this->assertFalse($this->isAuthenticated(), 'The user is authenticated'); + $response->assertInvalid(['email']); + } + + public function test_new_users_can_register_if_placeholder_user_with_email_already_exists(): void + { + // Arrange + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'is_placeholder' => true, + ]); + // Act $response = $this->post('/register', [ 'name' => 'Test User', 'email' => 'test@example.com', diff --git a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php index c53b2fde..5534cf14 100644 --- a/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ImportEndpointTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Endpoint\Api\V1; use App\Models\Organization; +use App\Service\Import\Importers\ReportDto; use App\Service\Import\ImportService; use Laravel\Passport\Passport; use Mockery\MockInterface; @@ -20,7 +21,7 @@ public function test_import_fails_if_user_does_not_have_permission() Passport::actingAs($data->user); // Act - $response = $this->postJson(route('api.v1.import', ['organization' => $data->organization->id]), [ + $response = $this->postJson(route('api.v1.import.import', ['organization' => $data->organization->id]), [ 'type' => 'toggl_time_entries', 'data' => 'some data', 'options' => [], @@ -41,6 +42,14 @@ public function test_import_calls_import_service_if_user_has_permission(): void ->withArgs(function (Organization $organization, string $importerType, string $data, array $options) use (&$user): bool { return $organization->is($user->organization) && $importerType === 'toggl_time_entries' && $data === 'some data' && $options === []; }) + ->andReturn(new ReportDto( + clientsCreated: 1, + projectsCreated: 2, + tasksCreated: 3, + timeEntriesCreated: 4, + tagsCreated: 5, + usersCreated: 6, + )) ->once(); }); Passport::actingAs($user->user); @@ -54,5 +63,27 @@ public function test_import_calls_import_service_if_user_has_permission(): void // Assert $response->assertStatus(200); + $response->assertExactJson([ + 'report' => [ + 'clients' => [ + 'created' => 1, + ], + 'projects' => [ + 'created' => 2, + ], + 'tasks' => [ + 'created' => 3, + ], + 'time-entries' => [ + 'created' => 4, + ], + 'tags' => [ + 'created' => 5, + ], + 'users' => [ + 'created' => 6, + ], + ], + ]); } } diff --git a/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php new file mode 100644 index 00000000..9cbe27a2 --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php @@ -0,0 +1,85 @@ +createUserWithPermission([ + 'users:view', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.users.index', $data->organization->id)); + + // Assert + $response->assertStatus(200); + } + + public function test_invite_placeholder_fails_if_user_does_not_have_permission(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + $user = User::factory()->create([ + 'is_placeholder' => true, + ]); + $data->organization->users()->attach($user); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id])); + + // Assert + $response->assertStatus(403); + } + + public function test_invite_placeholder_fails_if_user_is_not_part_of_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'users:invite-placeholder', + ]); + $otherOrganization = Organization::factory()->create(); + $user = User::factory()->create([ + 'is_placeholder' => true, + ]); + $otherOrganization->users()->attach($user); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id])); + + // Assert + $response->assertStatus(403); + } + + public function test_invite_placeholder_returns_400_if_user_is_not_placeholder(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'users:invite-placeholder', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.users.invite-placeholder', ['organization' => $data->organization->id, 'user' => $data->user->id])); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'user_not_placeholder', + 'message' => 'The given user is not a placeholder', + ]); + } +} diff --git a/tests/Unit/Model/UserModelTest.php b/tests/Unit/Model/UserModelTest.php index c0d72d37..228284cf 100644 --- a/tests/Unit/Model/UserModelTest.php +++ b/tests/Unit/Model/UserModelTest.php @@ -4,6 +4,8 @@ namespace Tests\Unit\Model; +use App\Models\Organization; +use App\Models\TimeEntry; use App\Models\User; use App\Providers\Filament\AdminPanelProvider; use Filament\Panel; @@ -42,4 +44,47 @@ public function test_user_in_super_admin_config_can_access_admin_panel(): void // Assert $this->assertTrue($canAccess); } + + public function test_scope_belongs_to_organization_returns_only_users_of_organization_including_owners(): void + { + // Arrange + $owner = User::factory()->create(); + $organization = Organization::factory()->withOwner($owner)->create(); + $user = User::factory()->create(); + $user->organizations()->attach($organization, [ + 'role' => 'employee', + ]); + $otherOrganization = Organization::factory()->create(); + $otherUser = User::factory()->create(); + $otherUser->organizations()->attach($otherOrganization, [ + 'role' => 'employee', + ]); + + // Act + $users = User::query() + ->belongsToOrganization($organization) + ->get(); + + // Assert + $this->assertCount(2, $users); + $userIds = $users->pluck('id')->toArray(); + $this->assertContains($user->getKey(), $userIds); + $this->assertContains($owner->getKey(), $userIds); + } + + public function test_it_has_many_time_entries(): void + { + // Arrange + $user = User::factory()->create(); + $timeEntries = TimeEntry::factory()->forUser($user)->createMany(3); + + // Act + $user->refresh(); + $timeEntriesRel = $user->timeEntries; + + // Assert + $this->assertNotNull($timeEntriesRel); + $this->assertCount(3, $timeEntriesRel); + $this->assertTrue($timeEntriesRel->first()->is($timeEntries->first())); + } } diff --git a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php index f0a03d9b..d1d156f2 100644 --- a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php +++ b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php @@ -51,21 +51,27 @@ public function test_get_key_attach_to_existing_creates_model_if_not_existing(): ]); } - public function test_get_key_not_attach_to_existing_returns_key_for_identifier_without_creating_model(): void + public function test_get_key_not_attach_to_existing_is_not_implemented_yet(): void { // Arrange $project = Project::factory()->create(); $helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], false); // Act - $key = $helper->getKey([ - 'name' => $project->name, - 'organization_id' => $project->organization_id, - ], [ - 'color' => '#000000', - ]); + try { + $key = $helper->getKey([ + 'name' => $project->name, + 'organization_id' => $project->organization_id, + ], [ + 'color' => '#000000', + ]); + } catch (\Exception $e) { + $this->assertSame('Not implemented', $e->getMessage()); + + return; + } // Assert - $this->assertNotSame($project->getKey(), $key); + $this->fail(); } } diff --git a/tests/Unit/Service/UserServiceTest.php b/tests/Unit/Service/UserServiceTest.php new file mode 100644 index 00000000..79494844 --- /dev/null +++ b/tests/Unit/Service/UserServiceTest.php @@ -0,0 +1,37 @@ +create(); + $otherUser = User::factory()->create(); + $fromUser = User::factory()->create(); + $toUser = User::factory()->create(); + TimeEntry::factory()->forOrganization($organization)->forUser($otherUser)->createMany(3); + TimeEntry::factory()->forOrganization($organization)->forUser($fromUser)->createMany(3); + + // Act + $userService = app(UserService::class); + $userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser); + + // Assert + $this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count()); + $this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUser, 'user')->count()); + $this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUser, 'user')->count()); + } +} From 4738b7577ac72b6fb0d56fce4edf876e2bc3e57f Mon Sep 17 00:00:00 2001 From: korridor <26689068+korridor@users.noreply.github.com> Date: Mon, 11 Mar 2024 19:12:51 +0100 Subject: [PATCH 3/5] Added more imports --- .../Jetstream/AddOrganizationMember.php | 3 +- .../Jetstream/InviteOrganizationMember.php | 2 +- app/Exceptions/Api/ApiException.php | 10 +- .../Api/TimeEntryStillRunningApiException.php | 2 +- .../Api/UserNotPlaceholderApiException.php | 2 +- .../Resources/OrganizationResource.php | 5 +- app/Filament/Resources/TaskResource.php | 20 +- app/Filament/Resources/TimeEntryResource.php | 6 +- .../Controllers/Api/V1/ImportController.php | 3 +- .../V1/Project/ProjectStoreRequest.php | 2 + .../V1/Project/ProjectUpdateRequest.php | 2 + .../V1/TimeEntry/TimeEntryIndexRequest.php | 5 +- .../V1/TimeEntry/TimeEntryStoreRequest.php | 5 +- app/Models/Organization.php | 17 +- app/Providers/AppServiceProvider.php | 1 + app/Providers/JetstreamServiceProvider.php | 3 + app/Rules/ColorRule.php | 32 +++ app/Service/ColorService.php | 7 + app/Service/Import/ImportDatabaseHelper.php | 87 ++++++- app/Service/Import/ImportService.php | 6 +- .../Importers/ClockifyProjectsImporter.php | 143 +++++++++++ .../Importers/ClockifyTimeEntriesImporter.php | 227 ++++++++++++++++++ .../Import/Importers/ImporterContract.php | 2 +- .../Import/Importers/ImporterProvider.php | 3 + .../Import/Importers/TogglDataImporter.php | 194 +++++++++++++++ .../Importers/TogglTimeEntriesImporter.php | 57 ++++- app/Service/UserService.php | 4 +- composer.json | 2 + composer.lock | 66 ++++- config/filesystems.php | 6 + database/factories/ProjectFactory.php | 3 +- lang/en/auth.php | 22 ++ lang/en/pagination.php | 21 ++ lang/en/passwords.php | 24 ++ lang/en/validation.php | 199 +++++++++++++++ storage/tests/clockify_import_test_1.csv | 1 - .../tests/clockify_projects_import_test_1.csv | 3 + .../clockify_time_entries_import_test_1.csv | 3 + .../toggl_data_import_test_1/clients.json | 9 + .../toggl_data_import_test_1/projects.json | 58 +++++ .../tests/toggl_data_import_test_1/tags.json | 14 ++ .../toggl_data_import_test_1/tasks/401.json | 1 + .../toggl_data_import_test_1/tasks/402.json | 13 + .../workspace_users.json | 19 ++ ...v => toggl_time_entries_import_test_1.csv} | 2 +- tests/Unit/Rules/ColorRuleTest.php | 66 +++++ .../Import/ImportDatabaseHelperTest.php | 61 +++++ .../Importer/ClockifyProjectsImporterTest.php | 44 ++++ .../ClockifyTimeEntriesImporterTest.php | 77 ++++++ .../Import/Importer/ImporterTestAbstract.php | 50 +++- .../Import/Importer/TogglDataImporterTest.php | 64 +++++ .../Importer/TogglTimeEntriesImporterTest.php | 24 +- 52 files changed, 1646 insertions(+), 56 deletions(-) create mode 100644 app/Rules/ColorRule.php create mode 100644 app/Service/Import/Importers/ClockifyProjectsImporter.php create mode 100644 app/Service/Import/Importers/ClockifyTimeEntriesImporter.php create mode 100644 app/Service/Import/Importers/TogglDataImporter.php create mode 100644 lang/en/auth.php create mode 100644 lang/en/pagination.php create mode 100644 lang/en/passwords.php create mode 100644 lang/en/validation.php delete mode 100644 storage/tests/clockify_import_test_1.csv create mode 100644 storage/tests/clockify_projects_import_test_1.csv create mode 100644 storage/tests/clockify_time_entries_import_test_1.csv create mode 100644 storage/tests/toggl_data_import_test_1/clients.json create mode 100644 storage/tests/toggl_data_import_test_1/projects.json create mode 100644 storage/tests/toggl_data_import_test_1/tags.json create mode 100644 storage/tests/toggl_data_import_test_1/tasks/401.json create mode 100644 storage/tests/toggl_data_import_test_1/tasks/402.json create mode 100644 storage/tests/toggl_data_import_test_1/workspace_users.json rename storage/tests/{toggl_import_test_1.csv => toggl_time_entries_import_test_1.csv} (81%) create mode 100644 tests/Unit/Rules/ColorRuleTest.php create mode 100644 tests/Unit/Service/Import/Importer/ClockifyProjectsImporterTest.php create mode 100644 tests/Unit/Service/Import/Importer/ClockifyTimeEntriesImporterTest.php create mode 100644 tests/Unit/Service/Import/Importer/TogglDataImporterTest.php diff --git a/app/Actions/Jetstream/AddOrganizationMember.php b/app/Actions/Jetstream/AddOrganizationMember.php index 7a84d53f..dd43ee5d 100644 --- a/app/Actions/Jetstream/AddOrganizationMember.php +++ b/app/Actions/Jetstream/AddOrganizationMember.php @@ -8,6 +8,7 @@ use App\Models\User; use Closure; use Illuminate\Contracts\Validation\Rule; +use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Validator; @@ -59,7 +60,7 @@ protected function validate(Organization $organization, string $email, ?string $ /** * Get the validation rules for adding a team member. * - * @return array> + * @return array> */ protected function rules(): array { diff --git a/app/Actions/Jetstream/InviteOrganizationMember.php b/app/Actions/Jetstream/InviteOrganizationMember.php index 686a9978..a73ebac2 100644 --- a/app/Actions/Jetstream/InviteOrganizationMember.php +++ b/app/Actions/Jetstream/InviteOrganizationMember.php @@ -34,7 +34,7 @@ public function invite(User $user, Organization $organization, string $email, ?s InvitingTeamMember::dispatch($organization, $email, $role); - /** @var TeamInvitation $invitation */ + /** @var OrganizationInvitation $invitation */ $invitation = $organization->teamInvitations()->create([ 'email' => $email, 'role' => $role, diff --git a/app/Exceptions/Api/ApiException.php b/app/Exceptions/Api/ApiException.php index bcd5ae01..e68bcc50 100644 --- a/app/Exceptions/Api/ApiException.php +++ b/app/Exceptions/Api/ApiException.php @@ -11,6 +11,8 @@ abstract class ApiException extends Exception { + public const string KEY = 'api_exception'; + /** * Render the exception into an HTTP response. */ @@ -29,11 +31,13 @@ public function render(Request $request): JsonResponse */ public function getKey(): string { - if (defined(static::class.'::KEY')) { - return static::KEY; + $key = static::KEY; + + if ($key === ApiException::KEY) { + throw new LogicException('API exceptions need the KEY constant defined.'); } - throw new LogicException('API exceptions need the KEY constant defined.'); + return $key; } /** diff --git a/app/Exceptions/Api/TimeEntryStillRunningApiException.php b/app/Exceptions/Api/TimeEntryStillRunningApiException.php index e110a9ae..c153534c 100644 --- a/app/Exceptions/Api/TimeEntryStillRunningApiException.php +++ b/app/Exceptions/Api/TimeEntryStillRunningApiException.php @@ -6,5 +6,5 @@ class TimeEntryStillRunningApiException extends ApiException { - const string KEY = 'time_entry_still_running'; + public const string KEY = 'time_entry_still_running'; } diff --git a/app/Exceptions/Api/UserNotPlaceholderApiException.php b/app/Exceptions/Api/UserNotPlaceholderApiException.php index 2aae88c9..92af4739 100644 --- a/app/Exceptions/Api/UserNotPlaceholderApiException.php +++ b/app/Exceptions/Api/UserNotPlaceholderApiException.php @@ -6,5 +6,5 @@ class UserNotPlaceholderApiException extends ApiException { - const string KEY = 'user_not_placeholder'; + public const string KEY = 'user_not_placeholder'; } diff --git a/app/Filament/Resources/OrganizationResource.php b/app/Filament/Resources/OrganizationResource.php index 3c14a313..eb2a9e0d 100644 --- a/app/Filament/Resources/OrganizationResource.php +++ b/app/Filament/Resources/OrganizationResource.php @@ -74,7 +74,7 @@ public static function table(Table $table): Table // TODO: different disk! try { /** @var ReportDto $report */ - $report = app(ImportService::class)->import($record, $data['type'], Storage::disk('public')->get($data['file']), []); + $report = app(ImportService::class)->import($record, $data['type'], Storage::disk('public')->get($data['file'])); Notification::make() ->title('Import successful') ->success() @@ -98,9 +98,10 @@ public static function table(Table $table): Table ->send(); } }) - ->tooltip(fn (Organization $record): string => "Import into {$record->name}") + ->tooltip(fn (Organization $record): string => 'Import into '.$record->name) ->form([ Forms\Components\FileUpload::make('file') + // TODO: disk! ->label('File') ->required(), Select::make('type') diff --git a/app/Filament/Resources/TaskResource.php b/app/Filament/Resources/TaskResource.php index 0ed06bd6..d4be3651 100644 --- a/app/Filament/Resources/TaskResource.php +++ b/app/Filament/Resources/TaskResource.php @@ -6,9 +6,12 @@ use App\Filament\Resources\TaskResource\Pages; use App\Models\Task; +use Filament\Forms; +use Filament\Forms\Components\Select; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; class TaskResource extends Resource @@ -25,7 +28,18 @@ public static function form(Form $form): Form { return $form ->schema([ - // + Forms\Components\TextInput::make('name') + ->label('Name') + ->required() + ->maxLength(255), + Select::make('project_id') + ->relationship(name: 'project', titleAttribute: 'name') + ->searchable(['name']) + ->required(), + Select::make('organization_id') + ->relationship(name: 'organization', titleAttribute: 'name') + ->searchable(['name']) + ->required(), ]); } @@ -46,7 +60,9 @@ public static function table(Table $table): Table ->sortable(), ]) ->filters([ - // + SelectFilter::make('organization') + ->relationship('organization', 'name') + ->searchable(), ]) ->defaultSort('created_at', 'desc') ->actions([ diff --git a/app/Filament/Resources/TimeEntryResource.php b/app/Filament/Resources/TimeEntryResource.php index ca70c0db..56ac3898 100644 --- a/app/Filament/Resources/TimeEntryResource.php +++ b/app/Filament/Resources/TimeEntryResource.php @@ -14,6 +14,7 @@ use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Columns\TextColumn; +use Filament\Tables\Filters\SelectFilter; use Filament\Tables\Table; class TimeEntryResource extends Resource @@ -67,6 +68,7 @@ public static function table(Table $table): Table return $table ->columns([ TextColumn::make('description') + ->searchable() ->label('Description'), TextColumn::make('user.email') ->label('User'), @@ -89,7 +91,9 @@ public static function table(Table $table): Table ->sortable(), ]) ->filters([ - // + SelectFilter::make('organization') + ->relationship('organization', 'name') + ->searchable(), ]) ->defaultSort('created_at', 'desc') ->actions([ diff --git a/app/Http/Controllers/Api/V1/ImportController.php b/app/Http/Controllers/Api/V1/ImportController.php index 877c8c61..a3bd4040 100644 --- a/app/Http/Controllers/Api/V1/ImportController.php +++ b/app/Http/Controllers/Api/V1/ImportController.php @@ -26,8 +26,7 @@ public function import(Organization $organization, ImportRequest $request, Impor $report = $importService->import( $organization, $request->input('type'), - $request->input('data'), - $request->input('options') + $request->input('data') ); return new JsonResponse([ diff --git a/app/Http/Requests/V1/Project/ProjectStoreRequest.php b/app/Http/Requests/V1/Project/ProjectStoreRequest.php index 09bf72dc..ca57a537 100644 --- a/app/Http/Requests/V1/Project/ProjectStoreRequest.php +++ b/app/Http/Requests/V1/Project/ProjectStoreRequest.php @@ -6,6 +6,7 @@ use App\Models\Client; use App\Models\Organization; +use App\Rules\ColorRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; @@ -35,6 +36,7 @@ public function rules(): array 'required', 'string', 'max:255', + new ColorRule(), ], 'client_id' => [ 'nullable', diff --git a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php index 28ed09ad..82259815 100644 --- a/app/Http/Requests/V1/Project/ProjectUpdateRequest.php +++ b/app/Http/Requests/V1/Project/ProjectUpdateRequest.php @@ -6,6 +6,7 @@ use App\Models\Client; use App\Models\Organization; +use App\Rules\ColorRule; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; use Illuminate\Foundation\Http\FormRequest; @@ -34,6 +35,7 @@ public function rules(): array 'required', 'string', 'max:255', + new ColorRule(), ], 'client_id' => [ 'nullable', diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 0d3bb8be..92d9a591 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -30,10 +30,7 @@ public function rules(): array 'uuid', new ExistsEloquent(User::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ - return $builder->whereHas('organizations', function (Builder $builder) { - /** @var Builder $builder */ - return $builder->whereKey($this->organization->getKey()); - }); + return $builder->belongsToOrganization($this->organization); }), ], // Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31) diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php index b2441819..8cbfd2fb 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php @@ -33,10 +33,7 @@ public function rules(): array 'uuid', new ExistsEloquent(User::class, null, function (Builder $builder): Builder { /** @var Builder $builder */ - return $builder->whereHas('organizations', function (Builder $builder) { - /** @var Builder $builder */ - return $builder->whereKey($this->organization->getKey()); - }); + return $builder->belongsToOrganization($this->organization); }), ], // ID of the task that the time entry should belong to diff --git a/app/Models/Organization.php b/app/Models/Organization.php index 284675d4..5e6a99c3 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -13,6 +13,7 @@ use Laravel\Jetstream\Events\TeamCreated; use Laravel\Jetstream\Events\TeamDeleted; use Laravel\Jetstream\Events\TeamUpdated; +use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Team as JetstreamTeam; /** @@ -21,6 +22,7 @@ * @property bool $personal_team * @property User $owner * @property Collection $users + * @property Collection $realUsers * * @method HasMany teamInvitations() * @method static OrganizationFactory factory() @@ -64,7 +66,7 @@ class Organization extends JetstreamTeam /** * Get all the non-placeholder users of the organization including its owner. * - * @return Collection + * @return Collection */ public function allRealUsers(): Collection { @@ -78,6 +80,19 @@ public function hasRealUserWithEmail(string $email): bool }); } + /** + * Get all the users that belong to the team. + * + * @return BelongsToMany + */ + public function users(): BelongsToMany + { + return $this->belongsToMany(Jetstream::userModel(), Jetstream::membershipModel()) + ->withPivot('role') + ->withTimestamps() + ->as('membership'); + } + /** * @return BelongsToMany */ diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1aa92645..2674c1bf 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -46,6 +46,7 @@ public function boot(): void Model::preventLazyLoading(! $this->app->isProduction()); Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction()); + Model::preventAccessingMissingAttributes(! $this->app->isProduction()); Relation::enforceMorphMap([ 'membership' => Membership::class, 'organization' => Organization::class, diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index e3757c7c..414fe1b5 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -109,5 +109,8 @@ protected function configurePermissions(): void 'time-entries:delete:own', 'organizations:view', ])->description('Editor users have the ability to read, create, and update.'); + + Jetstream::role('placeholder', 'Placeholder', [ + ])->description('Placeholders are used for importing data. They cannot log in and have no permissions.'); } } diff --git a/app/Rules/ColorRule.php b/app/Rules/ColorRule.php new file mode 100644 index 00000000..68dda354 --- /dev/null +++ b/app/Rules/ColorRule.php @@ -0,0 +1,32 @@ +isValid($value)) { + $fail(__('validation.color')); + + return; + } + } +} diff --git a/app/Service/ColorService.php b/app/Service/ColorService.php index a58e459b..e9ff97c0 100644 --- a/app/Service/ColorService.php +++ b/app/Service/ColorService.php @@ -31,8 +31,15 @@ class ColorService '#78909c', ]; + private const string VALID_REGEX = '/^#[0-9a-f]{6}$/'; + public function getRandomColor(): string { return self::COLORS[array_rand(self::COLORS)]; } + + public function isValid(string $color): bool + { + return preg_match(self::VALID_REGEX, $color) === 1; + } } diff --git a/app/Service/Import/ImportDatabaseHelper.php b/app/Service/Import/ImportDatabaseHelper.php index fa5551ae..4f169614 100644 --- a/app/Service/Import/ImportDatabaseHelper.php +++ b/app/Service/Import/ImportDatabaseHelper.php @@ -4,9 +4,11 @@ namespace App\Service\Import; +use App\Service\Import\Importers\ImportException; use Closure; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Log; /** * @template TModel of Model @@ -23,9 +25,15 @@ class ImportDatabaseHelper */ private array $identifiers; + /** + * @var array|null + */ private ?array $mapIdentifierToKey = null; - private array $mapNewAttach = []; + /** + * @var array + */ + private array $mapExternalIdentifierToInternalIdentifier = []; private bool $attachToExisting; @@ -57,7 +65,11 @@ private function getModelInstance(): Builder return (new $this->model)->query(); } - private function createEntity(array $identifierData, array $createValues): string + /** + * @param array $identifierData + * @param array $createValues + */ + private function createEntity(array $identifierData, array $createValues, ?string $externalIdentifier): string { $model = new $this->model(); foreach ($identifierData as $identifier => $identifierValue) { @@ -72,34 +84,97 @@ private function createEntity(array $identifierData, array $createValues): strin ($this->afterCreate)($model); } - $this->mapIdentifierToKey[$this->getHash($identifierData)] = $model->getKey(); + $hash = $this->getHash($identifierData); + $this->mapIdentifierToKey[$hash] = $model->getKey(); $this->createdCount++; + if ($externalIdentifier !== null) { + $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash; + } + return $model->getKey(); } + /** + * @param array $data + */ private function getHash(array $data): string { - return md5(json_encode($data)); + $jsonData = json_encode($data); + if ($jsonData === false) { + throw new \RuntimeException('Failed to encode data to JSON'); + } + + return md5($jsonData); } - public function getKey(array $identifierData, array $createValues = []): string + /** + * @param array $identifierData + * @param array $createValues + * + * @throws ImportException + */ + public function getKey(array $identifierData, array $createValues = [], ?string $externalIdentifier = null): string { $this->checkMap(); + $this->validateIdentifierData($identifierData); + $hash = $this->getHash($identifierData); if ($this->attachToExisting) { $key = $this->mapIdentifierToKey[$hash] ?? null; if ($key !== null) { + if ($externalIdentifier !== null) { + $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash; + } + Log::debug('HIT', [ + 'class' => $this->model, + ]); + return $key; } - return $this->createEntity($identifierData, $createValues); + Log::debug('MISS', [ + 'class' => $this->model, + ]); + + return $this->createEntity($identifierData, $createValues, $externalIdentifier); } else { throw new \RuntimeException('Not implemented'); } } + /** + * @param array $identifierData + * + * @throws ImportException + */ + private function validateIdentifierData(array $identifierData): void + { + if (array_keys($identifierData) !== $this->identifiers) { + throw new ImportException('Invalid identifier data'); + } + } + + public function getKeyByExternalIdentifier(string $externalIdentifier): ?string + { + $hash = $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] ?? null; + if ($hash === null) { + return null; + } + + return $this->mapIdentifierToKey[$hash] ?? null; + } + + /** + * @return array + */ + public function getExternalIds(): array + { + // Note: Otherwise the external ids are integers + return array_map(fn ($value) => (string) $value, array_keys($this->mapExternalIdentifierToInternalIdentifier)); + } + private function checkMap(): void { if ($this->mapIdentifierToKey === null) { diff --git a/app/Service/Import/ImportService.php b/app/Service/Import/ImportService.php index 0c442c15..8f42a756 100644 --- a/app/Service/Import/ImportService.php +++ b/app/Service/Import/ImportService.php @@ -16,13 +16,13 @@ class ImportService /** * @throws ImportException */ - public function import(Organization $organization, string $importerType, string $data, array $options): ReportDto + public function import(Organization $organization, string $importerType, string $data): ReportDto { /** @var ImporterContract $importer */ $importer = app(ImporterProvider::class)->getImporter($importerType); $importer->init($organization); - DB::transaction(function () use (&$importer, &$data, &$options, &$organization) { - $importer->importData($data, $options); + DB::transaction(function () use (&$importer, &$data) { + $importer->importData($data); }); return $importer->getReport(); diff --git a/app/Service/Import/Importers/ClockifyProjectsImporter.php b/app/Service/Import/Importers/ClockifyProjectsImporter.php new file mode 100644 index 00000000..5debbf91 --- /dev/null +++ b/app/Service/Import/Importers/ClockifyProjectsImporter.php @@ -0,0 +1,143 @@ + + */ + private ImportDatabaseHelper $projectImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $clientImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $taskImportHelper; + + #[\Override] + public function init(Organization $organization): void + { + $this->organization = $organization; + $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + } + + /** + * @throws ImportException + */ + #[\Override] + public function importData(string $data): void + { + try { + $colorService = app(ColorService::class); + $reader = Reader::createFromString($data); + $reader->setHeaderOffset(0); + $reader->setDelimiter(','); + $header = $reader->getHeader(); + $this->validateHeader($header); + $records = $reader->getRecords(); + foreach ($records as $record) { + $clientId = null; + if ($record['Client'] !== '') { + $clientId = $this->clientImportHelper->getKey([ + 'name' => $record['Client'], + 'organization_id' => $this->organization->id, + ]); + } + $projectId = null; + if ($record['Name'] !== '') { + $projectId = $this->projectImportHelper->getKey([ + 'name' => $record['Name'], + 'organization_id' => $this->organization->id, + ], [ + 'client_id' => $clientId, + 'color' => $colorService->getRandomColor(), + ]); + } + + if ($record['Tasks'] !== '') { + $tasks = explode(', ', $record['Tasks']); + foreach ($tasks as $task) { + if (strlen($task) > 255) { + throw new ImportException('Task is too long'); + } + $taskId = $this->taskImportHelper->getKey([ + 'name' => $task, + 'project_id' => $projectId, + 'organization_id' => $this->organization->id, + ]); + } + } + } + } catch (ImportException $exception) { + throw $exception; + } catch (CsvException $exception) { + throw new ImportException('Invalid CSV data'); + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); + } + } + + /** + * @param array $header + * + * @throws ImportException + */ + private function validateHeader(array $header): void + { + $requiredFields = [ + 'Name', + 'Client', + 'Status', + 'Visibility', + 'Billability', + 'Tasks', + ]; + foreach ($requiredFields as $requiredField) { + if (! in_array($requiredField, $header, true)) { + throw new ImportException('Invalid CSV header, missing field: '.$requiredField); + } + } + } + + #[\Override] + public function getReport(): ReportDto + { + return new ReportDto( + clientsCreated: $this->clientImportHelper->getCreatedCount(), + projectsCreated: $this->projectImportHelper->getCreatedCount(), + tasksCreated: $this->taskImportHelper->getCreatedCount(), + timeEntriesCreated: 0, + tagsCreated: 0, + usersCreated: 0, + ); + } +} diff --git a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php new file mode 100644 index 00000000..bb7075cc --- /dev/null +++ b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php @@ -0,0 +1,227 @@ + + */ + private ImportDatabaseHelper $userImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $projectImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $tagImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $clientImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $taskImportHelper; + + private int $timeEntriesCreated; + + #[\Override] + public function init(Organization $organization): void + { + $this->organization = $organization; + $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { + /** @var Builder $builder */ + return $builder->belongsToOrganization($this->organization); + }, function (User $user) { + $user->organizations()->attach($this->organization, [ + 'role' => 'placeholder', + ]); + }); + $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->timeEntriesCreated = 0; + } + + /** + * @return array + * + * @throws ImportException + */ + private function getTags(string $tags): array + { + if (trim($tags) === '') { + return []; + } + $tagsParsed = explode(', ', $tags); + $tagIds = []; + foreach ($tagsParsed as $tagParsed) { + if (strlen($tagParsed) > 255) { + throw new ImportException('Tag is too long'); + } + $tagId = $this->tagImportHelper->getKey([ + 'name' => $tagParsed, + 'organization_id' => $this->organization->id, + ]); + $tagIds[] = $tagId; + } + + return $tagIds; + } + + /** + * @throws ImportException + */ + #[\Override] + public function importData(string $data): void + { + try { + $colorService = app(ColorService::class); + $reader = Reader::createFromString($data); + $reader->setHeaderOffset(0); + $reader->setDelimiter(','); + $header = $reader->getHeader(); + $this->validateHeader($header); + $records = $reader->getRecords(); + foreach ($records as $record) { + $userId = $this->userImportHelper->getKey([ + 'email' => $record['Email'], + ], [ + 'name' => $record['User'], + 'is_placeholder' => true, + ]); + $clientId = null; + if ($record['Client'] !== '') { + $clientId = $this->clientImportHelper->getKey([ + 'name' => $record['Client'], + 'organization_id' => $this->organization->id, + ]); + } + $projectId = null; + if ($record['Project'] !== '') { + $projectId = $this->projectImportHelper->getKey([ + 'name' => $record['Project'], + 'organization_id' => $this->organization->id, + ], [ + 'client_id' => $clientId, + 'color' => $colorService->getRandomColor(), + ]); + } + $taskId = null; + if ($record['Task'] !== '') { + $taskId = $this->taskImportHelper->getKey([ + 'name' => $record['Task'], + 'project_id' => $projectId, + 'organization_id' => $this->organization->id, + ]); + } + $timeEntry = new TimeEntry(); + $timeEntry->user_id = $userId; + $timeEntry->task_id = $taskId; + $timeEntry->project_id = $projectId; + $timeEntry->organization_id = $this->organization->id; + $timeEntry->description = $record['Description']; + 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']); + $start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], 'UTC'); + if ($start === false) { + throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid'); + } + $timeEntry->start = $start; + $end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], 'UTC'); + if ($end === false) { + throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid'); + } + $timeEntry->end = $end; + $timeEntry->save(); + $this->timeEntriesCreated++; + } + } catch (ImportException $exception) { + throw $exception; + } catch (CsvException $exception) { + throw new ImportException('Invalid CSV data'); + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); + } + } + + /** + * @param array $header + * + * @throws ImportException + */ + private function validateHeader(array $header): void + { + $requiredFields = [ + 'Project', + 'Client', + 'Description', + 'Task', + 'User', + 'Group', + 'Email', + 'Tags', + 'Billable', + 'Start Date', + 'Start Time', + 'End Date', + 'End Time', + ]; + foreach ($requiredFields as $requiredField) { + if (! in_array($requiredField, $header, true)) { + throw new ImportException('Invalid CSV header, missing field: '.$requiredField); + } + } + } + + #[\Override] + public function getReport(): ReportDto + { + return new ReportDto( + clientsCreated: $this->clientImportHelper->getCreatedCount(), + projectsCreated: $this->projectImportHelper->getCreatedCount(), + tasksCreated: $this->taskImportHelper->getCreatedCount(), + timeEntriesCreated: $this->timeEntriesCreated, + tagsCreated: $this->tagImportHelper->getCreatedCount(), + usersCreated: $this->userImportHelper->getCreatedCount(), + ); + } +} diff --git a/app/Service/Import/Importers/ImporterContract.php b/app/Service/Import/Importers/ImporterContract.php index 65ca1bb6..a4846167 100644 --- a/app/Service/Import/Importers/ImporterContract.php +++ b/app/Service/Import/Importers/ImporterContract.php @@ -10,7 +10,7 @@ interface ImporterContract { public function init(Organization $organization): void; - public function importData(string $data, array $options): void; + public function importData(string $data): void; public function getReport(): ReportDto; } diff --git a/app/Service/Import/Importers/ImporterProvider.php b/app/Service/Import/Importers/ImporterProvider.php index ed413566..56eacf08 100644 --- a/app/Service/Import/Importers/ImporterProvider.php +++ b/app/Service/Import/Importers/ImporterProvider.php @@ -11,6 +11,9 @@ class ImporterProvider */ private array $importers = [ 'toggl_time_entries' => TogglTimeEntriesImporter::class, + 'toggl_data_importer' => TogglDataImporter::class, + 'clockify_time_entries' => ClockifyTimeEntriesImporter::class, + 'clockify_projects' => ClockifyProjectsImporter::class, ]; /** diff --git a/app/Service/Import/Importers/TogglDataImporter.php b/app/Service/Import/Importers/TogglDataImporter.php new file mode 100644 index 00000000..2183b2d9 --- /dev/null +++ b/app/Service/Import/Importers/TogglDataImporter.php @@ -0,0 +1,194 @@ + + */ + private ImportDatabaseHelper $userImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $projectImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $tagImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $clientImportHelper; + + /** + * @var ImportDatabaseHelper + */ + private ImportDatabaseHelper $taskImportHelper; + + private ColorService $colorService; + + #[\Override] + public function init(Organization $organization): void + { + $this->organization = $organization; + $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { + /** @var Builder $builder */ + return $builder->belongsToOrganization($this->organization); + }, function (User $user) { + $user->organizations()->attach($this->organization, [ + 'role' => 'placeholder', + ]); + }); + $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }); + $this->colorService = app(ColorService::class); + } + + /** + * @throws ImportException + */ + #[\Override] + public function importData(string $data): void + { + try { + $zip = new ZipArchive(); + $temporaryDirectory = TemporaryDirectory::make(); + file_put_contents($temporaryDirectory->path('import.zip'), $data); + $zip->open($temporaryDirectory->path('import.zip'), ZipArchive::RDONLY); + $temporaryDirectory = TemporaryDirectory::make(); + $zip->extractTo($temporaryDirectory->path()); + $zip->close(); + $clientsFileContent = file_get_contents($temporaryDirectory->path('clients.json')); + if ($clientsFileContent === false) { + throw new ImportException('File clients.json missing in ZIP'); + } + $clients = json_decode($clientsFileContent); + $projectsFileContent = file_get_contents($temporaryDirectory->path('projects.json')); + if ($projectsFileContent === false) { + throw new ImportException('File projects.json missing in ZIP'); + } + $projects = json_decode($projectsFileContent); + $tagsFileContent = file_get_contents($temporaryDirectory->path('tags.json')); + if ($tagsFileContent === false) { + throw new ImportException('File tags.json missing in ZIP'); + } + $tags = json_decode($tagsFileContent); + $workspaceUsersFileContent = file_get_contents($temporaryDirectory->path('workspace_users.json')); + if ($workspaceUsersFileContent === false) { + throw new ImportException('File workspace_users.json missing in ZIP'); + } + $workspaceUsers = json_decode($workspaceUsersFileContent); + foreach ($clients as $client) { + $this->clientImportHelper->getKey([ + 'name' => $client->name, + 'organization_id' => $this->organization->id, + ], [], (string) $client->id); + } + foreach ($tags as $tag) { + $this->tagImportHelper->getKey([ + 'name' => $tag->name, + 'organization_id' => $this->organization->id, + ], [], (string) $tag->id); + } + + foreach ($projects as $project) { + $clientId = null; + if ($project->client_id !== null) { + $clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id); + if ($clientId === null) { + throw new Exception('Client does not exist'); + } + } + + if (! $this->colorService->isValid($project->color)) { + throw new ImportException('Invalid color'); + } + + $this->projectImportHelper->getKey([ + 'name' => $project->name, + 'organization_id' => $this->organization->getKey(), + ], [ + 'client_id' => $clientId, + 'color' => $project->color, + ], (string) $project->id); + } + foreach ($workspaceUsers as $workspaceUser) { + $this->userImportHelper->getKey([ + 'email' => $workspaceUser->email, + ], [ + 'name' => $workspaceUser->name, + 'is_placeholder' => true, + ], (string) $workspaceUser->id); + } + $projectIds = $this->projectImportHelper->getExternalIds(); + foreach ($projectIds as $projectIdExternal) { + $tasksFileContent = file_get_contents($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json')); + if ($tasksFileContent === false) { + throw new ImportException('File tasks/'.$projectIdExternal.'.json missing in ZIP'); + } + $tasks = json_decode($tasksFileContent); + foreach ($tasks as $task) { + $projectId = $this->projectImportHelper->getKeyByExternalIdentifier((string) $projectIdExternal); + + if ($projectId === null) { + throw new Exception('Project does not exist'); + } + $this->taskImportHelper->getKey([ + 'name' => $task->name, + 'project_id' => $projectId, + 'organization_id' => $this->organization->getKey(), + ], [], (string) $task->id); + } + } + } catch (ImportException $exception) { + throw $exception; + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); + } + } + + #[\Override] + public function getReport(): ReportDto + { + return new ReportDto( + clientsCreated: $this->clientImportHelper->getCreatedCount(), + projectsCreated: $this->projectImportHelper->getCreatedCount(), + tasksCreated: $this->taskImportHelper->getCreatedCount(), + timeEntriesCreated: 0, + tagsCreated: $this->tagImportHelper->getCreatedCount(), + usersCreated: $this->userImportHelper->getCreatedCount(), + ); + } +} diff --git a/app/Service/Import/Importers/TogglTimeEntriesImporter.php b/app/Service/Import/Importers/TogglTimeEntriesImporter.php index 0021bfb6..74740496 100644 --- a/app/Service/Import/Importers/TogglTimeEntriesImporter.php +++ b/app/Service/Import/Importers/TogglTimeEntriesImporter.php @@ -13,6 +13,7 @@ use App\Models\User; use App\Service\ColorService; use App\Service\Import\ImportDatabaseHelper; +use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use League\Csv\Exception as CsvException; @@ -22,14 +23,29 @@ class TogglTimeEntriesImporter implements ImporterContract { private Organization $organization; + /** + * @var ImportDatabaseHelper + */ private ImportDatabaseHelper $userImportHelper; + /** + * @var ImportDatabaseHelper + */ private ImportDatabaseHelper $projectImportHelper; + /** + * @var ImportDatabaseHelper + */ private ImportDatabaseHelper $tagImportHelper; + /** + * @var ImportDatabaseHelper + */ private ImportDatabaseHelper $clientImportHelper; + /** + * @var ImportDatabaseHelper + */ private ImportDatabaseHelper $taskImportHelper; private int $timeEntriesCreated; @@ -39,14 +55,13 @@ public function init(Organization $organization): void { $this->organization = $organization; $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { - return $builder->whereHas('organizations', function (Builder $builder): Builder { - /** @var Builder $builder */ - return $builder->whereKey($this->organization->getKey()); - }); + /** @var Builder $builder */ + return $builder->belongsToOrganization($this->organization); }, function (User $user) { - $user->organizations()->attach([$this->organization->id]); + $user->organizations()->attach($this->organization, [ + 'role' => 'placeholder', + ]); }); - // TODO: user special after import $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { return $builder->where('organization_id', $this->organization->id); }); @@ -62,6 +77,11 @@ public function init(Organization $organization): void $this->timeEntriesCreated = 0; } + /** + * @return array + * + * @throws ImportException + */ private function getTags(string $tags): array { if (trim($tags) === '') { @@ -87,7 +107,7 @@ private function getTags(string $tags): array * @throws ImportException */ #[\Override] - public function importData(string $data, array $options): void + public function importData(string $data): void { try { $colorService = app(ColorService::class); @@ -140,17 +160,34 @@ public function importData(string $data, array $options): void } $timeEntry->billable = $record['Billable'] === 'Yes'; $timeEntry->tags = $this->getTags($record['Tags']); - $timeEntry->start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], 'UTC'); - $timeEntry->end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], 'UTC'); + $start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], 'UTC'); + if ($start === false) { + throw new ImportException('Start date ("'.$record['Start date'].'") or time ("'.$record['Start time'].'") are invalid'); + } + $timeEntry->start = $start; + $end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], 'UTC'); + if ($end === false) { + throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid'); + } + $timeEntry->end = $end; $timeEntry->save(); $this->timeEntriesCreated++; } + } catch (ImportException $exception) { + throw $exception; } catch (CsvException $exception) { throw new ImportException('Invalid CSV data'); + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); } - } + /** + * @param array $header + * + * @throws ImportException + */ private function validateHeader(array $header): void { $requiredFields = [ diff --git a/app/Service/UserService.php b/app/Service/UserService.php index e554e078..87be9978 100644 --- a/app/Service/UserService.php +++ b/app/Service/UserService.php @@ -13,11 +13,11 @@ class UserService public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void { // Time entries - dump(TimeEntry::query() + TimeEntry::query() ->whereBelongsTo($organization, 'organization') ->whereBelongsTo($fromUser, 'user') ->update([ 'user_id' => $toUser->getKey(), - ])); + ]); } } diff --git a/composer.json b/composer.json index 8b895a38..8b84fdf8 100644 --- a/composer.json +++ b/composer.json @@ -6,6 +6,7 @@ "license": "AGPL-3.0-or-later", "require": { "php": "8.3.*", + "ext-zip": "*", "dedoc/scramble": "^0.8.5", "filament/filament": "^3.2", "guzzlehttp/guzzle": "^7.2", @@ -16,6 +17,7 @@ "laravel/passport": "^11.10.2", "laravel/tinker": "^2.8", "pxlrbt/filament-environment-indicator": "^2.0", + "spatie/temporary-directory": "^2.2", "tightenco/ziggy": "^1.0", "tpetry/laravel-postgresql-enhanced": "^0.33.0" }, diff --git a/composer.lock b/composer.lock index d39541ae..f3065194 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e83929e68d256367652d91e43a79288e", + "content-hash": "9e9c41ae5787e1aa711b04cc019cb7e7", "packages": [ { "name": "anourvalar/eloquent-serialize", @@ -6542,6 +6542,67 @@ ], "time": "2024-01-11T08:43:00+00:00" }, + { + "name": "spatie/temporary-directory", + "version": "2.2.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/temporary-directory.git", + "reference": "76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/temporary-directory/zipball/76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a", + "reference": "76949fa18f8e1a7f663fd2eaa1d00e0bcea0752a", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\TemporaryDirectory\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Vanderbist", + "email": "alex@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Easily create, use and destroy temporary directories", + "homepage": "https://github.com/spatie/temporary-directory", + "keywords": [ + "php", + "spatie", + "temporary-directory" + ], + "support": { + "issues": "https://github.com/spatie/temporary-directory/issues", + "source": "https://github.com/spatie/temporary-directory/tree/2.2.1" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2023-12-25T11:46:58+00:00" + }, { "name": "symfony/console", "version": "v6.4.4", @@ -12424,7 +12485,8 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "8.3.*" + "php": "8.3.*", + "ext-zip": "*" }, "platform-dev": [], "plugin-api-version": "2.6.0" diff --git a/config/filesystems.php b/config/filesystems.php index d307268d..508cc76c 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -58,6 +58,12 @@ 'throw' => false, ], + 'testfiles' => [ + 'driver' => 'local', + 'root' => storage_path('tests'), + 'throw' => false, + ], + ], /* diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php index c05a79ff..f8654731 100644 --- a/database/factories/ProjectFactory.php +++ b/database/factories/ProjectFactory.php @@ -7,6 +7,7 @@ use App\Models\Client; use App\Models\Organization; use App\Models\Project; +use App\Service\ColorService; use Illuminate\Database\Eloquent\Factories\Factory; /** @@ -23,7 +24,7 @@ public function definition(): array { return [ 'name' => $this->faker->company(), - 'color' => $this->faker->hexColor(), + 'color' => app(ColorService::class)->getRandomColor(), 'organization_id' => Organization::factory(), 'client_id' => null, ]; diff --git a/lang/en/auth.php b/lang/en/auth.php new file mode 100644 index 00000000..e2de2aca --- /dev/null +++ b/lang/en/auth.php @@ -0,0 +1,22 @@ + 'These credentials do not match our records.', + 'password' => 'The provided password is incorrect.', + 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', + +]; diff --git a/lang/en/pagination.php b/lang/en/pagination.php new file mode 100644 index 00000000..f03c42c6 --- /dev/null +++ b/lang/en/pagination.php @@ -0,0 +1,21 @@ + '« Previous', + 'next' => 'Next »', + +]; diff --git a/lang/en/passwords.php b/lang/en/passwords.php new file mode 100644 index 00000000..43092321 --- /dev/null +++ b/lang/en/passwords.php @@ -0,0 +1,24 @@ + 'Your password has been reset.', + 'sent' => 'We have emailed your password reset link.', + 'throttled' => 'Please wait before retrying.', + 'token' => 'This password reset token is invalid.', + 'user' => "We can't find a user with that email address.", + +]; diff --git a/lang/en/validation.php b/lang/en/validation.php new file mode 100644 index 00000000..9813d38a --- /dev/null +++ b/lang/en/validation.php @@ -0,0 +1,199 @@ + 'The :attribute field must be accepted.', + 'accepted_if' => 'The :attribute field must be accepted when :other is :value.', + 'active_url' => 'The :attribute field must be a valid URL.', + 'after' => 'The :attribute field must be a date after :date.', + 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.', + 'alpha' => 'The :attribute field must only contain letters.', + 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.', + 'alpha_num' => 'The :attribute field must only contain letters and numbers.', + 'array' => 'The :attribute field must be an array.', + 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.', + 'before' => 'The :attribute field must be a date before :date.', + 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.', + 'between' => [ + 'array' => 'The :attribute field must have between :min and :max items.', + 'file' => 'The :attribute field must be between :min and :max kilobytes.', + 'numeric' => 'The :attribute field must be between :min and :max.', + 'string' => 'The :attribute field must be between :min and :max characters.', + ], + 'boolean' => 'The :attribute field must be true or false.', + 'can' => 'The :attribute field contains an unauthorized value.', + 'confirmed' => 'The :attribute field confirmation does not match.', + 'current_password' => 'The password is incorrect.', + 'date' => 'The :attribute field must be a valid date.', + 'date_equals' => 'The :attribute field must be a date equal to :date.', + 'date_format' => 'The :attribute field must match the format :format.', + 'decimal' => 'The :attribute field must have :decimal decimal places.', + 'declined' => 'The :attribute field must be declined.', + 'declined_if' => 'The :attribute field must be declined when :other is :value.', + 'different' => 'The :attribute field and :other must be different.', + 'digits' => 'The :attribute field must be :digits digits.', + 'digits_between' => 'The :attribute field must be between :min and :max digits.', + 'dimensions' => 'The :attribute field has invalid image dimensions.', + 'distinct' => 'The :attribute field has a duplicate value.', + 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.', + 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.', + 'email' => 'The :attribute field must be a valid email address.', + 'ends_with' => 'The :attribute field must end with one of the following: :values.', + 'enum' => 'The selected :attribute is invalid.', + 'exists' => 'The selected :attribute is invalid.', + 'extensions' => 'The :attribute field must have one of the following extensions: :values.', + 'file' => 'The :attribute field must be a file.', + 'filled' => 'The :attribute field must have a value.', + 'gt' => [ + 'array' => 'The :attribute field must have more than :value items.', + 'file' => 'The :attribute field must be greater than :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than :value.', + 'string' => 'The :attribute field must be greater than :value characters.', + ], + 'gte' => [ + 'array' => 'The :attribute field must have :value items or more.', + 'file' => 'The :attribute field must be greater than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than or equal to :value.', + 'string' => 'The :attribute field must be greater than or equal to :value characters.', + ], + 'hex_color' => 'The :attribute field must be a valid hexadecimal color.', + 'image' => 'The :attribute field must be an image.', + 'in' => 'The selected :attribute is invalid.', + 'in_array' => 'The :attribute field must exist in :other.', + 'integer' => 'The :attribute field must be an integer.', + 'ip' => 'The :attribute field must be a valid IP address.', + 'ipv4' => 'The :attribute field must be a valid IPv4 address.', + 'ipv6' => 'The :attribute field must be a valid IPv6 address.', + 'json' => 'The :attribute field must be a valid JSON string.', + 'lowercase' => 'The :attribute field must be lowercase.', + 'lt' => [ + 'array' => 'The :attribute field must have less than :value items.', + 'file' => 'The :attribute field must be less than :value kilobytes.', + 'numeric' => 'The :attribute field must be less than :value.', + 'string' => 'The :attribute field must be less than :value characters.', + ], + 'lte' => [ + 'array' => 'The :attribute field must not have more than :value items.', + 'file' => 'The :attribute field must be less than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be less than or equal to :value.', + 'string' => 'The :attribute field must be less than or equal to :value characters.', + ], + 'mac_address' => 'The :attribute field must be a valid MAC address.', + 'max' => [ + 'array' => 'The :attribute field must not have more than :max items.', + 'file' => 'The :attribute field must not be greater than :max kilobytes.', + 'numeric' => 'The :attribute field must not be greater than :max.', + 'string' => 'The :attribute field must not be greater than :max characters.', + ], + 'max_digits' => 'The :attribute field must not have more than :max digits.', + 'mimes' => 'The :attribute field must be a file of type: :values.', + 'mimetypes' => 'The :attribute field must be a file of type: :values.', + 'min' => [ + 'array' => 'The :attribute field must have at least :min items.', + 'file' => 'The :attribute field must be at least :min kilobytes.', + 'numeric' => 'The :attribute field must be at least :min.', + 'string' => 'The :attribute field must be at least :min characters.', + ], + 'min_digits' => 'The :attribute field must have at least :min digits.', + 'missing' => 'The :attribute field must be missing.', + 'missing_if' => 'The :attribute field must be missing when :other is :value.', + 'missing_unless' => 'The :attribute field must be missing unless :other is :value.', + 'missing_with' => 'The :attribute field must be missing when :values is present.', + 'missing_with_all' => 'The :attribute field must be missing when :values are present.', + 'multiple_of' => 'The :attribute field must be a multiple of :value.', + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute field format is invalid.', + 'numeric' => 'The :attribute field must be a number.', + 'password' => [ + 'letters' => 'The :attribute field must contain at least one letter.', + 'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.', + 'numbers' => 'The :attribute field must contain at least one number.', + 'symbols' => 'The :attribute field must contain at least one symbol.', + 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.', + ], + 'present' => 'The :attribute field must be present.', + 'present_if' => 'The :attribute field must be present when :other is :value.', + 'present_unless' => 'The :attribute field must be present unless :other is :value.', + 'present_with' => 'The :attribute field must be present when :values is present.', + 'present_with_all' => 'The :attribute field must be present when :values are present.', + 'prohibited' => 'The :attribute field is prohibited.', + 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', + 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', + 'prohibits' => 'The :attribute field prohibits :other from being present.', + 'regex' => 'The :attribute field format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_array_keys' => 'The :attribute field must contain entries for: :values.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_if_accepted' => 'The :attribute field is required when :other is accepted.', + 'required_unless' => 'The :attribute field is required unless :other is in :values.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values are present.', + 'required_without' => 'The :attribute field is required when :values is not present.', + 'required_without_all' => 'The :attribute field is required when none of :values are present.', + 'same' => 'The :attribute field must match :other.', + 'size' => [ + 'array' => 'The :attribute field must contain :size items.', + 'file' => 'The :attribute field must be :size kilobytes.', + 'numeric' => 'The :attribute field must be :size.', + 'string' => 'The :attribute field must be :size characters.', + ], + 'starts_with' => 'The :attribute field must start with one of the following: :values.', + 'string' => 'The :attribute field must be a string.', + 'timezone' => 'The :attribute field must be a valid timezone.', + 'unique' => 'The :attribute has already been taken.', + 'uploaded' => 'The :attribute failed to upload.', + 'uppercase' => 'The :attribute field must be uppercase.', + 'url' => 'The :attribute field must be a valid URL.', + 'ulid' => 'The :attribute field must be a valid ULID.', + 'uuid' => 'The :attribute field must be a valid UUID.', + + /* + |-------------------------------------------------------------------------- + | Custom Validation Language Lines + |-------------------------------------------------------------------------- + | + | Here you may specify custom validation messages for attributes using the + | convention "attribute.rule" to name the lines. This makes it quick to + | specify a specific custom language line for a given attribute rule. + | + */ + + 'custom' => [ + 'attribute-name' => [ + 'rule-name' => 'custom-message', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Custom Validation Attributes + |-------------------------------------------------------------------------- + | + | The following language lines are used to swap our attribute placeholder + | with something more reader friendly such as "E-Mail Address" instead + | of "email". This simply helps us make our message more expressive. + | + */ + + 'attributes' => [], + + /* + * Custom validation rules + */ + + 'color' => 'The :attribute field must be a valid color.', + +]; diff --git a/storage/tests/clockify_import_test_1.csv b/storage/tests/clockify_import_test_1.csv deleted file mode 100644 index 66b59c18..00000000 --- a/storage/tests/clockify_import_test_1.csv +++ /dev/null @@ -1 +0,0 @@ -"Project","Client","Description","Task","User","Group","Email","Tags","Billable","Start Date","Start Time","End Date","End Time","Duration (h)","Duration (decimal)","Billable Rate (USD)","Billable Amount (USD)" diff --git a/storage/tests/clockify_projects_import_test_1.csv b/storage/tests/clockify_projects_import_test_1.csv new file mode 100644 index 00000000..d8c018fb --- /dev/null +++ b/storage/tests/clockify_projects_import_test_1.csv @@ -0,0 +1,3 @@ +"Name","Client","Status","Visibility","Billability","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Progress(%)","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Project members","Project manager","Note" +"Project for Big Company","Big Company","Active","Public","Yes","Task 1, Task 2, Task 3","0.00","","","","","0.00","0.00","","0.00","Constantin Graf","","" +"Project without Client","","Active","Public","Yes","","0.00","","","","","0.00","0.00","","0.00","Constantin Graf","","" diff --git a/storage/tests/clockify_time_entries_import_test_1.csv b/storage/tests/clockify_time_entries_import_test_1.csv new file mode 100644 index 00000000..b7b1f1ce --- /dev/null +++ b/storage/tests/clockify_time_entries_import_test_1.csv @@ -0,0 +1,3 @@ +"Project","Client","Description","Task","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:00 AM","03/04/2024","11:23:01 AM","01:00:01","0.00","0.00","0.00" diff --git a/storage/tests/toggl_data_import_test_1/clients.json b/storage/tests/toggl_data_import_test_1/clients.json new file mode 100644 index 00000000..9291eaef --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/clients.json @@ -0,0 +1,9 @@ +[ + { + "archived": false, + "creator_id": 201, + "id": 301, + "name": "Big Company", + "wid": 0 + } +] diff --git a/storage/tests/toggl_data_import_test_1/projects.json b/storage/tests/toggl_data_import_test_1/projects.json new file mode 100644 index 00000000..7c28f2d7 --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/projects.json @@ -0,0 +1,58 @@ +[ + { + "active": true, + "actual_hours": null, + "actual_seconds": null, + "auto_estimates": false, + "billable": true, + "cid": null, + "client_id": null, + "color": "#ef5350", + "currency": "EUR", + "estimated_hours": null, + "estimated_seconds": null, + "fixed_fee": null, + "guid": "", + "id": 401, + "is_private": true, + "name": "Project without Client", + "rate": null, + "rate_last_updated": null, + "recurring": false, + "recurring_parameters": null, + "start_date": "2020-01-01", + "status": "active", + "template": false, + "template_id": null, + "wid": 0, + "workspace_id": 0 + }, + { + "active": true, + "actual_hours": null, + "actual_seconds": null, + "auto_estimates": false, + "billable": false, + "cid": 301, + "client_id": 301, + "color": "#ec407a", + "currency": null, + "estimated_hours": null, + "estimated_seconds": null, + "fixed_fee": null, + "guid": "", + "id": 402, + "is_private": true, + "name": "Project for Big Company", + "rate": null, + "rate_last_updated": null, + "recurring": false, + "recurring_parameters": null, + "start_date": "2020-01-01", + "status": "active", + "template": false, + "template_id": null, + "wid": 0, + "workspace_id": 0 + } +] diff --git a/storage/tests/toggl_data_import_test_1/tags.json b/storage/tests/toggl_data_import_test_1/tags.json new file mode 100644 index 00000000..4bc90b57 --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/tags.json @@ -0,0 +1,14 @@ +[ + { + "creator_id": 0, + "id": 501, + "name": "Development", + "workspace_id": 0 + }, + { + "creator_id": 0, + "id": 502, + "name": "Backend", + "workspace_id": 0 + } +] diff --git a/storage/tests/toggl_data_import_test_1/tasks/401.json b/storage/tests/toggl_data_import_test_1/tasks/401.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/tasks/401.json @@ -0,0 +1 @@ +[] diff --git a/storage/tests/toggl_data_import_test_1/tasks/402.json b/storage/tests/toggl_data_import_test_1/tasks/402.json new file mode 100644 index 00000000..fc7d6934 --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/tasks/402.json @@ -0,0 +1,13 @@ +[ + { + "active": true, + "estimated_seconds": 0, + "id": 601, + "name": "Task 1", + "project_id": 402, + "recurring": false, + "tracked_seconds": 0, + "user_id": null, + "workspace_id": 0 + } +] diff --git a/storage/tests/toggl_data_import_test_1/workspace_users.json b/storage/tests/toggl_data_import_test_1/workspace_users.json new file mode 100644 index 00000000..356daa1f --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/workspace_users.json @@ -0,0 +1,19 @@ +[ + { + "active": true, + "admin": true, + "email": "peter.test@email.test", + "group_ids": [], + "id": 201, + "inactive": false, + "labour_cost": null, + "name": "Peter Tester", + "rate": null, + "rate_last_updated": null, + "role": "admin", + "timezone": "Europe/Vienna", + "uid": 0, + "wid": 0, + "working_hours_in_minutes": null + } +] diff --git a/storage/tests/toggl_import_test_1.csv b/storage/tests/toggl_time_entries_import_test_1.csv similarity index 81% rename from storage/tests/toggl_import_test_1.csv rename to storage/tests/toggl_time_entries_import_test_1.csv index 1fdfc8d6..effd1dfb 100644 --- a/storage/tests/toggl_import_test_1.csv +++ b/storage/tests/toggl_time_entries_import_test_1.csv @@ -1,3 +1,3 @@ User,Email,Client,Project,Task,Description,Billable,Start date,Start time,End date,End time,Duration,Tags,Amount (EUR) -Peter Tester,peter.test@email.test,,Project without Client,,"",No,2024-03-04,10:23:52,2024-03-04,10:23:52,00:00:00,Development, +Peter Tester,peter.test@email.test,,Project without Client,,"",No,2024-03-04,10:23:52,2024-03-04,10:23:52,00:00:00,"Development, Backend", Peter Tester,peter.test@email.test,Big Company,Project for Big Company,Task 1,Working hard,Yes,2024-03-04,10:23:00,2024-03-04,11:23:01,01:00:01,,111.11 diff --git a/tests/Unit/Rules/ColorRuleTest.php b/tests/Unit/Rules/ColorRuleTest.php new file mode 100644 index 00000000..3df5ee22 --- /dev/null +++ b/tests/Unit/Rules/ColorRuleTest.php @@ -0,0 +1,66 @@ + '#ef5350', + ], [ + 'color' => [new ColorRule()], + ]); + + // Act + $isValid = $validator->passes(); + $messages = $validator->messages()->toArray(); + + // Assert + $this->assertTrue($isValid); + $this->assertArrayNotHasKey('color', $messages); + } + + public function test_validation_fails_if_value_is_not_a_string(): void + { + // Arrange + $validator = Validator::make([ + 'color' => true, + ], [ + 'color' => [new ColorRule()], + ]); + + // Act + $isValid = $validator->passes(); + $messages = $validator->messages()->toArray(); + + // Assert + $this->assertFalse($isValid); + $this->assertEquals('The color field must be a string.', $messages['color'][0]); + } + + public function test_validation_fails_if_value_is_not_a_valid_color(): void + { + // Arrange + $validator = Validator::make([ + 'color' => 'rgb(0,0,0)', + ], [ + 'color' => [new ColorRule()], + ]); + + // Act + $isValid = $validator->passes(); + $messages = $validator->messages()->toArray(); + + // Assert + $this->assertFalse($isValid); + $this->assertEquals('The color field must be a valid color.', $messages['color'][0]); + } +} diff --git a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php index d1d156f2..9337b724 100644 --- a/tests/Unit/Service/Import/ImportDatabaseHelperTest.php +++ b/tests/Unit/Service/Import/ImportDatabaseHelperTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Service\Import; +use App\Models\Organization; use App\Models\Project; use App\Models\User; use App\Service\Import\ImportDatabaseHelper; @@ -74,4 +75,64 @@ public function test_get_key_not_attach_to_existing_is_not_implemented_yet(): vo // Assert $this->fail(); } + + public function test_get_key_by_external_identifier_returns_key_for_external_identifier(): void + { + // Arrange + $organization = Organization::factory()->create(); + $project = Project::factory()->forOrganization($organization)->create(); + $externalIdentifier1 = '12345'; + $externalIdentifier2 = '54321'; + $helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true); + $helper->getKey([ + 'name' => $project->name, + 'organization_id' => $organization->getKey(), + ], [ + 'color' => '#000000', + ], $externalIdentifier1); + $helper->getKey([ + 'name' => 'Not existing project', + 'organization_id' => $organization->getKey(), + ], [ + 'color' => '#000000', + ], $externalIdentifier2); + + // Act + $key1 = $helper->getKeyByExternalIdentifier($externalIdentifier1); + $key2 = $helper->getKeyByExternalIdentifier($externalIdentifier2); + + // Assert + $this->assertSame($project->getKey(), $key1); + $this->assertSame(Project::where('name', '=', 'Not existing project')->first()->getKey(), $key2); + } + + public function test_get_external_ids_returns_all_external_ids_that_were_temporary_stored_via_get_key(): void + { + // Arrange + $organization = Organization::factory()->create(); + $project = Project::factory()->forOrganization($organization)->create(); + $externalIdentifier1 = '12345'; + $externalIdentifier2 = '54321'; + $helper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true); + $helper->getKey([ + 'name' => $project->name, + 'organization_id' => $organization->getKey(), + ], [ + 'color' => '#000000', + ], $externalIdentifier1); + $helper->getKey([ + 'name' => 'Not existing project', + 'organization_id' => $organization->getKey(), + ], [ + 'color' => '#000000', + ], $externalIdentifier2); + + // Act + $externalKeys = $helper->getExternalIds(); + + // Assert + $this->assertCount(2, $externalKeys); + $this->assertContains($externalIdentifier1, $externalKeys); + $this->assertContains($externalIdentifier2, $externalKeys); + } } diff --git a/tests/Unit/Service/Import/Importer/ClockifyProjectsImporterTest.php b/tests/Unit/Service/Import/Importer/ClockifyProjectsImporterTest.php new file mode 100644 index 00000000..598a7de7 --- /dev/null +++ b/tests/Unit/Service/Import/Importer/ClockifyProjectsImporterTest.php @@ -0,0 +1,44 @@ +create(); + $importer = new ClockifyProjectsImporter(); + $importer->init($organization); + $data = file_get_contents(storage_path('tests/clockify_projects_import_test_1.csv')); + + // Act + $importer->importData($data, []); + + // Assert + $this->checkTestScenarioProjectsOnlyAfterImport(); + } + + public function test_import_of_test_file_twice_succeeds(): void + { + // Arrange + $organization = Organization::factory()->create(); + $importer = new ClockifyProjectsImporter(); + $importer->init($organization); + $data = file_get_contents(storage_path('tests/clockify_projects_import_test_1.csv')); + $importer->importData($data, []); + $importer = new ClockifyProjectsImporter(); + $importer->init($organization); + + // Act + $importer->importData($data, []); + + // Assert + $this->checkTestScenarioProjectsOnlyAfterImport(); + } +} diff --git a/tests/Unit/Service/Import/Importer/ClockifyTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importer/ClockifyTimeEntriesImporterTest.php new file mode 100644 index 00000000..837646dc --- /dev/null +++ b/tests/Unit/Service/Import/Importer/ClockifyTimeEntriesImporterTest.php @@ -0,0 +1,77 @@ +create(); + $importer = new ClockifyTimeEntriesImporter(); + $importer->init($organization); + $data = file_get_contents(storage_path('tests/clockify_time_entries_import_test_1.csv')); + + // Act + $importer->importData($data, []); + + // Assert + $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(); + $timeEntries = TimeEntry::all(); + $this->assertCount(2, $timeEntries); + $timeEntry1 = $timeEntries->firstWhere('description', ''); + $this->assertNotNull($timeEntry1); + $this->assertSame('', $timeEntry1->description); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString()); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString()); + $this->assertFalse($timeEntry1->billable); + $this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags); + $timeEntry2 = $timeEntries->firstWhere('description', 'Working hard'); + $this->assertNotNull($timeEntry2); + $this->assertSame('Working hard', $timeEntry2->description); + $this->assertSame('2024-03-04 10:23:00', $timeEntry2->start->toDateTimeString()); + $this->assertSame('2024-03-04 11:23:01', $timeEntry2->end->toDateTimeString()); + $this->assertTrue($timeEntry2->billable); + $this->assertSame([], $timeEntry2->tags); + } + + public function test_import_of_test_file_twice_succeeds(): void + { + // Arrange + $organization = Organization::factory()->create(); + $importer = new ClockifyTimeEntriesImporter(); + $importer->init($organization); + $data = file_get_contents(storage_path('tests/clockify_time_entries_import_test_1.csv')); + $importer->importData($data, []); + $importer = new ClockifyTimeEntriesImporter(); + $importer->init($organization); + + // Act + $importer->importData($data, []); + + // Assert + $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(); + $timeEntries = TimeEntry::all(); + $this->assertCount(4, $timeEntries); + $timeEntry1 = $timeEntries->firstWhere('description', ''); + $this->assertNotNull($timeEntry1); + $this->assertSame('', $timeEntry1->description); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString()); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString()); + $this->assertFalse($timeEntry1->billable); + $this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags); + $timeEntry2 = $timeEntries->firstWhere('description', 'Working hard'); + $this->assertNotNull($timeEntry2); + $this->assertSame('Working hard', $timeEntry2->description); + $this->assertSame('2024-03-04 10:23:00', $timeEntry2->start->toDateTimeString()); + $this->assertSame('2024-03-04 11:23:01', $timeEntry2->end->toDateTimeString()); + $this->assertTrue($timeEntry2->billable); + $this->assertSame([], $timeEntry2->tags); + } +} diff --git a/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php b/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php index 7b9c44ab..e22ed3a9 100644 --- a/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php +++ b/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Service\Import\Importer; +use App\Models\Client; use App\Models\Project; use App\Models\Tag; use App\Models\Task; @@ -16,7 +17,7 @@ class ImporterTestAbstract extends TestCase use RefreshDatabase; /** - * @return object{user1: User, project1: Project, project2: Project, tag1: Tag} + * @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} */ protected function checkTestScenarioAfterImportExcludingTimeEntries(): object { @@ -27,20 +28,27 @@ protected function checkTestScenarioAfterImportExcludingTimeEntries(): object $this->assertSame(null, $user1->password); $this->assertSame('Peter Tester', $user1->name); $this->assertSame('peter.test@email.test', $user1->email); + $clients = Client::all(); + $this->assertCount(1, $clients); + $client1 = $clients->firstWhere('name', 'Big Company'); + $this->assertNotNull($client1); $projects = Project::all(); $this->assertCount(2, $projects); $project1 = $projects->firstWhere('name', 'Project without Client'); $this->assertNotNull($project1); + $this->assertNull($project1->client_id); $project2 = $projects->firstWhere('name', 'Project for Big Company'); $this->assertNotNull($project2); + $this->assertSame($client1->getKey(), $project2->client_id); $tasks = Task::all(); $this->assertCount(1, $tasks); $task1 = $tasks->firstWhere('name', 'Task 1'); $this->assertNotNull($task1); $this->assertSame($project2->getKey(), $task1->project_id); $tags = Tag::all(); - $this->assertCount(1, $tags); + $this->assertCount(2, $tags); $tag1 = $tags->firstWhere('name', 'Development'); + $tag2 = $tags->firstWhere('name', 'Backend'); $this->assertNotNull($tag1); return (object) [ @@ -48,6 +56,44 @@ protected function checkTestScenarioAfterImportExcludingTimeEntries(): object 'project1' => $project1, 'project2' => $project2, 'tag1' => $tag1, + 'tag2' => $tag2, + ]; + } + + /** + * @return object{client1: Client, project1: Project, project2: Project, task1: Task} + */ + protected function checkTestScenarioProjectsOnlyAfterImport(): object + { + $clients = Client::all(); + $this->assertCount(1, $clients); + $client1 = $clients->firstWhere('name', 'Big Company'); + $this->assertNotNull($client1); + $projects = Project::all(); + $this->assertCount(2, $projects); + $project1 = $projects->firstWhere('name', 'Project without Client'); + $this->assertNotNull($project1); + $this->assertNull($project1->client_id); + $project2 = $projects->firstWhere('name', 'Project for Big Company'); + $this->assertNotNull($project2); + $this->assertSame($client1->getKey(), $project2->client_id); + $tasks = Task::all(); + $this->assertCount(3, $tasks); + $task1 = $tasks->firstWhere('name', 'Task 1'); + $this->assertNotNull($task1); + $this->assertSame($project2->getKey(), $task1->project_id); + $task2 = $tasks->firstWhere('name', 'Task 2'); + $this->assertNotNull($task2); + $this->assertSame($project2->getKey(), $task2->project_id); + $task3 = $tasks->firstWhere('name', 'Task 3'); + $this->assertNotNull($task3); + $this->assertSame($project2->getKey(), $task3->project_id); + + return (object) [ + 'client1' => $client1, + 'project1' => $project1, + 'project2' => $project2, + 'task1' => $task1, ]; } } diff --git a/tests/Unit/Service/Import/Importer/TogglDataImporterTest.php b/tests/Unit/Service/Import/Importer/TogglDataImporterTest.php new file mode 100644 index 00000000..a57c72b5 --- /dev/null +++ b/tests/Unit/Service/Import/Importer/TogglDataImporterTest.php @@ -0,0 +1,64 @@ +path('test.zip'); + $zip = new ZipArchive(); + $zip->open($zipPath, ZipArchive::CREATE); + foreach (Storage::disk('testfiles')->allFiles($folder) as $file) { + $zip->addFile(Storage::disk('testfiles')->path($file), Str::of($file)->after($folder.'/')->value()); + } + $zip->close(); + + return $zipPath; + } + + public function test_import_of_test_file_succeeds(): void + { + // Arrange + $zipPath = $this->createTestZip('toggl_data_import_test_1'); + $organization = Organization::factory()->create(); + $importer = new TogglDataImporter(); + $importer->init($organization); + $data = file_get_contents($zipPath); + + // Act + $importer->importData($data); + + // Assert + $this->checkTestScenarioAfterImportExcludingTimeEntries(); + } + + public function test_import_of_test_file_twice_succeeds(): void + { + // Arrange + $zipPath = $this->createTestZip('toggl_data_import_test_1'); + $organization = Organization::factory()->create(); + $importer = new TogglDataImporter(); + $importer->init($organization); + $data = file_get_contents($zipPath); + $importer->importData($data); + $importer = new TogglDataImporter(); + $importer->init($organization); + + // Act + $importer->importData($data); + + // Assert + $this->checkTestScenarioAfterImportExcludingTimeEntries(); + } +} diff --git a/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php index 0e0f9ccf..28cca34b 100644 --- a/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php +++ b/tests/Unit/Service/Import/Importer/TogglTimeEntriesImporterTest.php @@ -16,13 +16,29 @@ public function test_import_of_test_file_succeeds(): void $organization = Organization::factory()->create(); $importer = new TogglTimeEntriesImporter(); $importer->init($organization); - $data = file_get_contents(storage_path('tests/toggl_import_test_1.csv')); + $data = file_get_contents(storage_path('tests/toggl_time_entries_import_test_1.csv')); // Act $importer->importData($data, []); // Assert - $this->checkTestScenarioAfterImportExcludingTimeEntries(); + $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(); + $timeEntries = TimeEntry::all(); + $this->assertCount(2, $timeEntries); + $timeEntry1 = $timeEntries->firstWhere('description', ''); + $this->assertNotNull($timeEntry1); + $this->assertSame('', $timeEntry1->description); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString()); + $this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString()); + $this->assertFalse($timeEntry1->billable); + $this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags); + $timeEntry2 = $timeEntries->firstWhere('description', 'Working hard'); + $this->assertNotNull($timeEntry2); + $this->assertSame('Working hard', $timeEntry2->description); + $this->assertSame('2024-03-04 10:23:00', $timeEntry2->start->toDateTimeString()); + $this->assertSame('2024-03-04 11:23:01', $timeEntry2->end->toDateTimeString()); + $this->assertTrue($timeEntry2->billable); + $this->assertSame([], $timeEntry2->tags); } public function test_import_of_test_file_twice_succeeds(): void @@ -31,7 +47,7 @@ public function test_import_of_test_file_twice_succeeds(): void $organization = Organization::factory()->create(); $importer = new TogglTimeEntriesImporter(); $importer->init($organization); - $data = file_get_contents(storage_path('tests/toggl_import_test_1.csv')); + $data = file_get_contents(storage_path('tests/toggl_time_entries_import_test_1.csv')); $importer->importData($data, []); $importer = new TogglTimeEntriesImporter(); $importer->init($organization); @@ -49,7 +65,7 @@ public function test_import_of_test_file_twice_succeeds(): void $this->assertSame('2024-03-04 10:23:52', $timeEntry1->start->toDateTimeString()); $this->assertSame('2024-03-04 10:23:52', $timeEntry1->end->toDateTimeString()); $this->assertFalse($timeEntry1->billable); - $this->assertSame([$testScenario->tag1->getKey()], $timeEntry1->tags); + $this->assertSame([$testScenario->tag1->getKey(), $testScenario->tag2->getKey()], $timeEntry1->tags); $timeEntry2 = $timeEntries->firstWhere('description', 'Working hard'); $this->assertNotNull($timeEntry2); $this->assertSame('Working hard', $timeEntry2->description); From 0a6ec55be5b406739834d0b4d44241e19768ab0f Mon Sep 17 00:00:00 2001 From: korridor <26689068+korridor@users.noreply.github.com> Date: Tue, 12 Mar 2024 17:46:27 +0100 Subject: [PATCH 4/5] Added default importer; Fixed bug in clockify importer --- .../V1/TimeEntry/TimeEntryStoreRequest.php | 2 +- .../V1/TimeEntry/TimeEntryUpdateRequest.php | 2 +- app/Service/Import/ImportDatabaseHelper.php | 26 ++-- .../Importers/ClockifyProjectsImporter.php | 62 +--------- .../Importers/ClockifyTimeEntriesImporter.php | 105 +++------------- .../Import/Importers/DefaultImporter.php | 114 ++++++++++++++++++ .../Import/Importers/TogglDataImporter.php | 80 +----------- .../Importers/TogglTimeEntriesImporter.php | 86 +------------ .../2024_01_20_110444_create_tasks_table.php | 2 +- ...01_20_110837_create_time_entries_table.php | 2 +- .../clockify_time_entries_import_test_1.csv | 2 +- 11 files changed, 157 insertions(+), 326 deletions(-) create mode 100644 app/Service/Import/Importers/DefaultImporter.php diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php index 8cbfd2fb..1a7850bd 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryStoreRequest.php @@ -61,7 +61,7 @@ public function rules(): array 'description' => [ 'nullable', 'string', - 'max:255', + 'max:500', ], // List of tag IDs 'tags' => [ diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php index f044ce72..ec7f1c5f 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryUpdateRequest.php @@ -51,7 +51,7 @@ public function rules(): array 'description' => [ 'nullable', 'string', - 'max:255', + 'max:500', ], // List of tag IDs 'tags' => [ diff --git a/app/Service/Import/ImportDatabaseHelper.php b/app/Service/Import/ImportDatabaseHelper.php index 4f169614..12c785e6 100644 --- a/app/Service/Import/ImportDatabaseHelper.php +++ b/app/Service/Import/ImportDatabaseHelper.php @@ -8,7 +8,7 @@ use Closure; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Validator; /** * @template TModel of Model @@ -43,11 +43,13 @@ class ImportDatabaseHelper private int $createdCount; + private array $validate; + /** * @param class-string $model * @param array $identifiers */ - public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null) + public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null, array $validate = []) { $this->model = $model; $this->identifiers = $identifiers; @@ -55,6 +57,7 @@ public function __construct(string $model, array $identifiers, bool $attachToExi $this->queryModifier = $queryModifier; $this->afterCreate = $afterCreate; $this->createdCount = 0; + $this->validate = $validate; } /** @@ -71,11 +74,15 @@ private function getModelInstance(): Builder */ private function createEntity(array $identifierData, array $createValues, ?string $externalIdentifier): string { - $model = new $this->model(); - foreach ($identifierData as $identifier => $identifierValue) { - $model->{$identifier} = $identifierValue; + $data = array_merge($identifierData, $createValues); + + $validator = Validator::make($data, $this->validate); + if ($validator->fails()) { + throw new ImportException('Invalid data: '.implode(', ', $validator->errors()->all())); } - foreach ($createValues as $key => $value) { + + $model = new $this->model(); + foreach ($data as $key => $value) { $model->{$key} = $value; } $model->save(); @@ -127,17 +134,10 @@ public function getKey(array $identifierData, array $createValues = [], ?string if ($externalIdentifier !== null) { $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash; } - Log::debug('HIT', [ - 'class' => $this->model, - ]); return $key; } - Log::debug('MISS', [ - 'class' => $this->model, - ]); - return $this->createEntity($identifierData, $createValues, $externalIdentifier); } else { throw new \RuntimeException('Not implemented'); diff --git a/app/Service/Import/Importers/ClockifyProjectsImporter.php b/app/Service/Import/Importers/ClockifyProjectsImporter.php index 5debbf91..d71c6cd9 100644 --- a/app/Service/Import/Importers/ClockifyProjectsImporter.php +++ b/app/Service/Import/Importers/ClockifyProjectsImporter.php @@ -4,51 +4,12 @@ namespace App\Service\Import\Importers; -use App\Models\Client; -use App\Models\Organization; -use App\Models\Project; -use App\Models\Task; -use App\Service\ColorService; -use App\Service\Import\ImportDatabaseHelper; use Exception; -use Illuminate\Database\Eloquent\Builder; use League\Csv\Exception as CsvException; use League\Csv\Reader; -class ClockifyProjectsImporter implements ImporterContract +class ClockifyProjectsImporter extends DefaultImporter { - private Organization $organization; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $projectImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $clientImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $taskImportHelper; - - #[\Override] - public function init(Organization $organization): void - { - $this->organization = $organization; - $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - } - /** * @throws ImportException */ @@ -56,7 +17,6 @@ public function init(Organization $organization): void public function importData(string $data): void { try { - $colorService = app(ColorService::class); $reader = Reader::createFromString($data); $reader->setHeaderOffset(0); $reader->setDelimiter(','); @@ -78,17 +38,14 @@ public function importData(string $data): void 'organization_id' => $this->organization->id, ], [ 'client_id' => $clientId, - 'color' => $colorService->getRandomColor(), + 'color' => $this->colorService->getRandomColor(), ]); } if ($record['Tasks'] !== '') { $tasks = explode(', ', $record['Tasks']); foreach ($tasks as $task) { - if (strlen($task) > 255) { - throw new ImportException('Task is too long'); - } - $taskId = $this->taskImportHelper->getKey([ + $this->taskImportHelper->getKey([ 'name' => $task, 'project_id' => $projectId, 'organization_id' => $this->organization->id, @@ -127,17 +84,4 @@ private function validateHeader(array $header): void } } } - - #[\Override] - public function getReport(): ReportDto - { - return new ReportDto( - clientsCreated: $this->clientImportHelper->getCreatedCount(), - projectsCreated: $this->projectImportHelper->getCreatedCount(), - tasksCreated: $this->taskImportHelper->getCreatedCount(), - timeEntriesCreated: 0, - tagsCreated: 0, - usersCreated: 0, - ); - } } diff --git a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php index bb7075cc..228b54df 100644 --- a/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php +++ b/app/Service/Import/Importers/ClockifyTimeEntriesImporter.php @@ -4,79 +4,14 @@ namespace App\Service\Import\Importers; -use App\Models\Client; -use App\Models\Organization; -use App\Models\Project; -use App\Models\Tag; -use App\Models\Task; use App\Models\TimeEntry; -use App\Models\User; -use App\Service\ColorService; -use App\Service\Import\ImportDatabaseHelper; use Exception; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use League\Csv\Exception as CsvException; use League\Csv\Reader; -class ClockifyTimeEntriesImporter implements ImporterContract +class ClockifyTimeEntriesImporter extends DefaultImporter { - private Organization $organization; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $userImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $projectImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $tagImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $clientImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $taskImportHelper; - - private int $timeEntriesCreated; - - #[\Override] - public function init(Organization $organization): void - { - $this->organization = $organization; - $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { - /** @var Builder $builder */ - return $builder->belongsToOrganization($this->organization); - }, function (User $user) { - $user->organizations()->attach($this->organization, [ - 'role' => 'placeholder', - ]); - }); - $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->timeEntriesCreated = 0; - } - /** * @return array * @@ -90,9 +25,6 @@ private function getTags(string $tags): array $tagsParsed = explode(', ', $tags); $tagIds = []; foreach ($tagsParsed as $tagParsed) { - if (strlen($tagParsed) > 255) { - throw new ImportException('Tag is too long'); - } $tagId = $this->tagImportHelper->getKey([ 'name' => $tagParsed, 'organization_id' => $this->organization->id, @@ -110,7 +42,6 @@ private function getTags(string $tags): array public function importData(string $data): void { try { - $colorService = app(ColorService::class); $reader = Reader::createFromString($data); $reader->setHeaderOffset(0); $reader->setDelimiter(','); @@ -138,7 +69,7 @@ public function importData(string $data): void 'organization_id' => $this->organization->id, ], [ 'client_id' => $clientId, - 'color' => $colorService->getRandomColor(), + 'color' => $this->colorService->getRandomColor(), ]); } $taskId = null; @@ -154,18 +85,33 @@ public function importData(string $data): void $timeEntry->task_id = $taskId; $timeEntry->project_id = $projectId; $timeEntry->organization_id = $this->organization->id; + if (strlen($record['Description']) > 500) { + throw new ImportException('Time entry description is too long'); + } $timeEntry->description = $record['Description']; 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']); - $start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], 'UTC'); + + // Start + if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['Start Time']) === 1) { + $start = Carbon::createFromFormat('m/d/Y h:i A', $record['Start Date'].' '.$record['Start Time'], 'UTC'); + } else { + $start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], 'UTC'); + } if ($start === false) { throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid'); } $timeEntry->start = $start; - $end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], 'UTC'); + + // End + if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['End Time']) === 1) { + $end = Carbon::createFromFormat('m/d/Y h:i A', $record['End Date'].' '.$record['End Time'], 'UTC'); + } else { + $end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], 'UTC'); + } if ($end === false) { throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid'); } @@ -211,17 +157,4 @@ private function validateHeader(array $header): void } } } - - #[\Override] - public function getReport(): ReportDto - { - return new ReportDto( - clientsCreated: $this->clientImportHelper->getCreatedCount(), - projectsCreated: $this->projectImportHelper->getCreatedCount(), - tasksCreated: $this->taskImportHelper->getCreatedCount(), - timeEntriesCreated: $this->timeEntriesCreated, - tagsCreated: $this->tagImportHelper->getCreatedCount(), - usersCreated: $this->userImportHelper->getCreatedCount(), - ); - } } diff --git a/app/Service/Import/Importers/DefaultImporter.php b/app/Service/Import/Importers/DefaultImporter.php new file mode 100644 index 00000000..e60cef51 --- /dev/null +++ b/app/Service/Import/Importers/DefaultImporter.php @@ -0,0 +1,114 @@ + + */ + protected ImportDatabaseHelper $userImportHelper; + + /** + * @var ImportDatabaseHelper + */ + protected ImportDatabaseHelper $projectImportHelper; + + /** + * @var ImportDatabaseHelper + */ + protected ImportDatabaseHelper $tagImportHelper; + + /** + * @var ImportDatabaseHelper + */ + protected ImportDatabaseHelper $clientImportHelper; + + /** + * @var ImportDatabaseHelper + */ + protected ImportDatabaseHelper $taskImportHelper; + + protected int $timeEntriesCreated; + + protected ColorService $colorService; + + public function init(Organization $organization): void + { + $this->organization = $organization; + $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { + /** @var Builder $builder */ + return $builder->belongsToOrganization($this->organization); + }, function (User $user) { + $user->organizations()->attach($this->organization, [ + 'role' => 'placeholder', + ]); + }, validate: [ + 'name' => [ + 'required', + 'max:255', + ], + ]); + $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }, validate: [ + 'name' => [ + 'required', + 'max:255', + ], + ]); + $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }, validate: [ + 'name' => [ + 'required', + 'max:255', + ], + ]); + $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }, validate: [ + 'name' => [ + 'required', + 'max:255', + ], + ]); + $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }, validate: [ + 'name' => [ + 'required', + 'max:500', + ], + ]); + $this->timeEntriesCreated = 0; + $this->colorService = app(ColorService::class); + } + + #[\Override] + public function getReport(): ReportDto + { + return new ReportDto( + clientsCreated: $this->clientImportHelper->getCreatedCount(), + projectsCreated: $this->projectImportHelper->getCreatedCount(), + tasksCreated: $this->taskImportHelper->getCreatedCount(), + timeEntriesCreated: $this->timeEntriesCreated, + tagsCreated: $this->tagImportHelper->getCreatedCount(), + usersCreated: $this->userImportHelper->getCreatedCount(), + ); + } +} diff --git a/app/Service/Import/Importers/TogglDataImporter.php b/app/Service/Import/Importers/TogglDataImporter.php index 2183b2d9..d6c8415e 100644 --- a/app/Service/Import/Importers/TogglDataImporter.php +++ b/app/Service/Import/Importers/TogglDataImporter.php @@ -4,77 +4,12 @@ namespace App\Service\Import\Importers; -use App\Models\Client; -use App\Models\Organization; -use App\Models\Project; -use App\Models\Tag; -use App\Models\Task; -use App\Models\User; -use App\Service\ColorService; -use App\Service\Import\ImportDatabaseHelper; use Exception; -use Illuminate\Database\Eloquent\Builder; use Spatie\TemporaryDirectory\TemporaryDirectory; use ZipArchive; -class TogglDataImporter implements ImporterContract +class TogglDataImporter extends DefaultImporter { - private Organization $organization; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $userImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $projectImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $tagImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $clientImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $taskImportHelper; - - private ColorService $colorService; - - #[\Override] - public function init(Organization $organization): void - { - $this->organization = $organization; - $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { - /** @var Builder $builder */ - return $builder->belongsToOrganization($this->organization); - }, function (User $user) { - $user->organizations()->attach($this->organization, [ - 'role' => 'placeholder', - ]); - }); - $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->colorService = app(ColorService::class); - } - /** * @throws ImportException */ @@ -178,17 +113,4 @@ public function importData(string $data): void throw new ImportException('Unknown error'); } } - - #[\Override] - public function getReport(): ReportDto - { - return new ReportDto( - clientsCreated: $this->clientImportHelper->getCreatedCount(), - projectsCreated: $this->projectImportHelper->getCreatedCount(), - tasksCreated: $this->taskImportHelper->getCreatedCount(), - timeEntriesCreated: 0, - tagsCreated: $this->tagImportHelper->getCreatedCount(), - usersCreated: $this->userImportHelper->getCreatedCount(), - ); - } } diff --git a/app/Service/Import/Importers/TogglTimeEntriesImporter.php b/app/Service/Import/Importers/TogglTimeEntriesImporter.php index 74740496..99f301f5 100644 --- a/app/Service/Import/Importers/TogglTimeEntriesImporter.php +++ b/app/Service/Import/Importers/TogglTimeEntriesImporter.php @@ -4,79 +4,14 @@ namespace App\Service\Import\Importers; -use App\Models\Client; -use App\Models\Organization; -use App\Models\Project; -use App\Models\Tag; -use App\Models\Task; use App\Models\TimeEntry; -use App\Models\User; -use App\Service\ColorService; -use App\Service\Import\ImportDatabaseHelper; use Exception; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Carbon; use League\Csv\Exception as CsvException; use League\Csv\Reader; -class TogglTimeEntriesImporter implements ImporterContract +class TogglTimeEntriesImporter extends DefaultImporter { - private Organization $organization; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $userImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $projectImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $tagImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $clientImportHelper; - - /** - * @var ImportDatabaseHelper - */ - private ImportDatabaseHelper $taskImportHelper; - - private int $timeEntriesCreated; - - #[\Override] - public function init(Organization $organization): void - { - $this->organization = $organization; - $this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) { - /** @var Builder $builder */ - return $builder->belongsToOrganization($this->organization); - }, function (User $user) { - $user->organizations()->attach($this->organization, [ - 'role' => 'placeholder', - ]); - }); - $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) { - return $builder->where('organization_id', $this->organization->id); - }); - $this->timeEntriesCreated = 0; - } - /** * @return array * @@ -90,9 +25,6 @@ private function getTags(string $tags): array $tagsParsed = explode(', ', $tags); $tagIds = []; foreach ($tagsParsed as $tagParsed) { - if (strlen($tagParsed) > 255) { - throw new ImportException('Tag is too long'); - } $tagId = $this->tagImportHelper->getKey([ 'name' => $tagParsed, 'organization_id' => $this->organization->id, @@ -110,7 +42,6 @@ private function getTags(string $tags): array public function importData(string $data): void { try { - $colorService = app(ColorService::class); $reader = Reader::createFromString($data); $reader->setHeaderOffset(0); $reader->setDelimiter(','); @@ -138,7 +69,7 @@ public function importData(string $data): void 'organization_id' => $this->organization->id, ], [ 'client_id' => $clientId, - 'color' => $colorService->getRandomColor(), + 'color' => $this->colorService->getRandomColor(), ]); } $taskId = null; @@ -210,17 +141,4 @@ private function validateHeader(array $header): void } } } - - #[\Override] - public function getReport(): ReportDto - { - return new ReportDto( - clientsCreated: $this->clientImportHelper->getCreatedCount(), - projectsCreated: $this->projectImportHelper->getCreatedCount(), - tasksCreated: $this->taskImportHelper->getCreatedCount(), - timeEntriesCreated: $this->timeEntriesCreated, - tagsCreated: $this->tagImportHelper->getCreatedCount(), - usersCreated: $this->userImportHelper->getCreatedCount(), - ); - } } diff --git a/database/migrations/2024_01_20_110444_create_tasks_table.php b/database/migrations/2024_01_20_110444_create_tasks_table.php index 876508f2..4a0a4848 100644 --- a/database/migrations/2024_01_20_110444_create_tasks_table.php +++ b/database/migrations/2024_01_20_110444_create_tasks_table.php @@ -15,7 +15,7 @@ public function up(): void { Schema::create('tasks', function (Blueprint $table) { $table->uuid('id')->primary(); - $table->string('name', 255); + $table->string('name', 500); $table->uuid('project_id'); $table->foreign('project_id') ->references('id') diff --git a/database/migrations/2024_01_20_110837_create_time_entries_table.php b/database/migrations/2024_01_20_110837_create_time_entries_table.php index ed5ef5fb..5fa5e9fb 100644 --- a/database/migrations/2024_01_20_110837_create_time_entries_table.php +++ b/database/migrations/2024_01_20_110837_create_time_entries_table.php @@ -15,7 +15,7 @@ public function up(): void { Schema::create('time_entries', function (Blueprint $table) { $table->uuid('id')->primary(); - $table->string('description', 255); + $table->string('description', 500); $table->dateTime('start'); $table->dateTime('end')->nullable(); $table->boolean('billable')->default(false); diff --git a/storage/tests/clockify_time_entries_import_test_1.csv b/storage/tests/clockify_time_entries_import_test_1.csv index b7b1f1ce..b8f3295c 100644 --- a/storage/tests/clockify_time_entries_import_test_1.csv +++ b/storage/tests/clockify_time_entries_import_test_1.csv @@ -1,3 +1,3 @@ "Project","Client","Description","Task","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:00 AM","03/04/2024","11:23:01 AM","01:00:01","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" From 91d70694f088e1c1f37cf8ab1b4ae35083c4d1a1 Mon Sep 17 00:00:00 2001 From: korridor <26689068+korridor@users.noreply.github.com> Date: Tue, 12 Mar 2024 17:46:48 +0100 Subject: [PATCH 5/5] Enhanced filament resources --- app/Filament/Resources/ClientResource.php | 11 +++- .../Resources/OrganizationResource.php | 11 ++-- .../RelationManagers/UsersRelationManager.php | 51 +++++++++++++++++++ app/Filament/Resources/UserResource.php | 14 +++-- .../UserResource/Pages/CreateUser.php | 14 +++++ .../OrganizationsRelationManager.php | 50 ++++++++++++++++++ .../OwnedOrganizationsRelationManager.php | 47 +++++++++++++++++ 7 files changed, 189 insertions(+), 9 deletions(-) create mode 100644 app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php create mode 100644 app/Filament/Resources/UserResource/RelationManagers/OrganizationsRelationManager.php create mode 100644 app/Filament/Resources/UserResource/RelationManagers/OwnedOrganizationsRelationManager.php diff --git a/app/Filament/Resources/ClientResource.php b/app/Filament/Resources/ClientResource.php index 5e0b44d6..dee96260 100644 --- a/app/Filament/Resources/ClientResource.php +++ b/app/Filament/Resources/ClientResource.php @@ -6,6 +6,8 @@ use App\Filament\Resources\ClientResource\Pages; use App\Models\Client; +use Filament\Forms\Components\Select; +use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; @@ -26,7 +28,14 @@ public static function form(Form $form): Form { return $form ->schema([ - // + TextInput::make('name') + ->label('Name') + ->required(), + Select::make('organization_id') + ->relationship(name: 'organization', titleAttribute: 'name') + ->label('Organization') + ->searchable(['name']) + ->required(), ]); } diff --git a/app/Filament/Resources/OrganizationResource.php b/app/Filament/Resources/OrganizationResource.php index eb2a9e0d..080a5993 100644 --- a/app/Filament/Resources/OrganizationResource.php +++ b/app/Filament/Resources/OrganizationResource.php @@ -5,6 +5,7 @@ namespace App\Filament\Resources; use App\Filament\Resources\OrganizationResource\Pages; +use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager; use App\Models\Organization; use App\Service\Import\Importers\ImporterProvider; use App\Service\Import\Importers\ImportException; @@ -71,10 +72,13 @@ public static function table(Table $table): Table Action::make('Import') ->icon('heroicon-o-inbox-arrow-down') ->action(function (Organization $record, array $data) { - // TODO: different disk! try { /** @var ReportDto $report */ - $report = app(ImportService::class)->import($record, $data['type'], Storage::disk('public')->get($data['file'])); + $report = app(ImportService::class)->import( + $record, + $data['type'], + Storage::disk(config('filament.default_filesystem_disk'))->get($data['file']) + ); Notification::make() ->title('Import successful') ->success() @@ -101,7 +105,6 @@ public static function table(Table $table): Table ->tooltip(fn (Organization $record): string => 'Import into '.$record->name) ->form([ Forms\Components\FileUpload::make('file') - // TODO: disk! ->label('File') ->required(), Select::make('type') @@ -126,7 +129,7 @@ public static function table(Table $table): Table public static function getRelations(): array { return [ - // + UsersRelationManager::class, ]; } diff --git a/app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php b/app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php new file mode 100644 index 00000000..a923e49e --- /dev/null +++ b/app/Filament/Resources/OrganizationResource/RelationManagers/UsersRelationManager.php @@ -0,0 +1,51 @@ +schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('name') + ->columns([ + Tables\Columns\TextColumn::make('name'), + Tables\Columns\TextColumn::make('role'), + ]) + ->filters([ + // + ]) + ->headerActions([ + Tables\Actions\CreateAction::make(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/UserResource.php b/app/Filament/Resources/UserResource.php index a5d3681b..c1568b10 100644 --- a/app/Filament/Resources/UserResource.php +++ b/app/Filament/Resources/UserResource.php @@ -5,12 +5,16 @@ namespace App\Filament\Resources; use App\Filament\Resources\UserResource\Pages; +use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager; +use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager; use App\Models\User; use Filament\Forms; +use Filament\Forms\Components\TextInput; use Filament\Forms\Form; use Filament\Resources\Resource; use Filament\Tables; use Filament\Tables\Table; +use Illuminate\Support\Facades\Hash; class UserResource extends Resource { @@ -41,10 +45,11 @@ public static function form(Form $form): Form ->label('Email') ->required() ->maxLength(255), - Forms\Components\TextInput::make('password') - ->label('Password') - ->required() + TextInput::make('password') ->password() + ->dehydrateStateUsing(fn ($state) => Hash::make($state)) + ->dehydrated(fn ($state) => filled($state)) + ->required(fn (string $context): bool => $context === 'create') ->maxLength(255), ]); } @@ -77,7 +82,8 @@ public static function table(Table $table): Table public static function getRelations(): array { return [ - // + OwnedOrganizationsRelationManager::class, + OrganizationsRelationManager::class, ]; } diff --git a/app/Filament/Resources/UserResource/Pages/CreateUser.php b/app/Filament/Resources/UserResource/Pages/CreateUser.php index 72b81bb0..b5cac367 100644 --- a/app/Filament/Resources/UserResource/Pages/CreateUser.php +++ b/app/Filament/Resources/UserResource/Pages/CreateUser.php @@ -5,9 +5,23 @@ namespace App\Filament\Resources\UserResource\Pages; use App\Filament\Resources\UserResource; +use App\Models\Organization; +use App\Models\User; use Filament\Resources\Pages\CreateRecord; class CreateUser extends CreateRecord { protected static string $resource = UserResource::class; + + protected function afterCreate(): void + { + /** @var User $user */ + $user = $this->record; + + $user->ownedTeams()->save(Organization::forceCreate([ + 'user_id' => $user->id, + 'name' => explode(' ', $user->name, 2)[0]."'s Organization", + 'personal_team' => true, + ])); + } } diff --git a/app/Filament/Resources/UserResource/RelationManagers/OrganizationsRelationManager.php b/app/Filament/Resources/UserResource/RelationManagers/OrganizationsRelationManager.php new file mode 100644 index 00000000..ac67f248 --- /dev/null +++ b/app/Filament/Resources/UserResource/RelationManagers/OrganizationsRelationManager.php @@ -0,0 +1,50 @@ +schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('name') + ->columns([ + Tables\Columns\TextColumn::make('name'), + ]) + ->filters([ + // + ]) + ->headerActions([ + Tables\Actions\CreateAction::make(), + ]) + ->actions([ + Tables\Actions\EditAction::make(), + Tables\Actions\DeleteAction::make(), + ]) + ->bulkActions([ + Tables\Actions\BulkActionGroup::make([ + Tables\Actions\DeleteBulkAction::make(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/UserResource/RelationManagers/OwnedOrganizationsRelationManager.php b/app/Filament/Resources/UserResource/RelationManagers/OwnedOrganizationsRelationManager.php new file mode 100644 index 00000000..7630ec26 --- /dev/null +++ b/app/Filament/Resources/UserResource/RelationManagers/OwnedOrganizationsRelationManager.php @@ -0,0 +1,47 @@ +schema([ + Forms\Components\TextInput::make('name') + ->required() + ->maxLength(255), + ]); + } + + public function table(Table $table): Table + { + return $table + ->recordTitleAttribute('name') + ->columns([ + Tables\Columns\TextColumn::make('name'), + ]) + ->filters([ + // + ]) + ->headerActions([ + ]) + ->actions([ + Tables\Actions\EditAction::make(), + ]) + ->bulkActions([ + ]); + } +}