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([
+ ]);
+ }
+}