diff --git a/app/Filament/Resources/OrganizationResource.php b/app/Filament/Resources/OrganizationResource.php index d40c75f5..87cba60b 100644 --- a/app/Filament/Resources/OrganizationResource.php +++ b/app/Filament/Resources/OrganizationResource.php @@ -7,6 +7,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\Export\ExportService; use App\Service\Import\Importers\ImporterProvider; use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\ReportDto; @@ -110,6 +111,30 @@ class OrganizationResource extends Resource ]) ->actions([ Tables\Actions\EditAction::make(), + Action::make('Export') + ->icon('heroicon-o-arrow-down-tray') + ->action(function (Organization $record) { + try { + $file = app(ExportService::class)->export($record); + Notification::make() + ->title('Export successful') + ->success() + ->persistent() + ->send(); + + return response()->streamDownload(function () use ($file) { + echo Storage::disk(config('filesystems.private'))->get($file); + }, 'export.zip'); + } catch (\Exception $exception) { + report($exception); + Notification::make() + ->title('Export failed') + ->danger() + ->body('Message: '.$exception->getMessage()) + ->persistent() + ->send(); + } + }), Action::make('Import') ->icon('heroicon-o-inbox-arrow-down') ->action(function (Organization $record, array $data) { diff --git a/app/Http/Controllers/Api/V1/ExportController.php b/app/Http/Controllers/Api/V1/ExportController.php new file mode 100644 index 00000000..adca0462 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ExportController.php @@ -0,0 +1,38 @@ +checkPermission($organization, 'export'); + + $filepath = $exportService->export($organization); + $downloadUrl = Storage::disk(config('filesystems.private')) + ->temporaryUrl($filepath, Carbon::now()->addMinutes(10)); + + return new JsonResponse([ + 'success' => true, + 'download_url' => $downloadUrl, + ], 200); + } +} diff --git a/app/Models/Member.php b/app/Models/Member.php index 4af1091d..de22950d 100644 --- a/app/Models/Member.php +++ b/app/Models/Member.php @@ -9,6 +9,7 @@ use Database\Factories\MemberFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Carbon; use Laravel\Jetstream\Membership as JetstreamMembership; /** @@ -17,8 +18,8 @@ use Laravel\Jetstream\Membership as JetstreamMembership; * @property int|null $billable_rate * @property string $organization_id * @property string $user_id - * @property string $created_at - * @property string $updated_at + * @property Carbon|null $created_at + * @property Carbon|null $updated_at * @property-read Organization $organization * @property-read User $user * diff --git a/app/Models/OrganizationInvitation.php b/app/Models/OrganizationInvitation.php index 232ac54e..dd7d8a5a 100644 --- a/app/Models/OrganizationInvitation.php +++ b/app/Models/OrganizationInvitation.php @@ -8,6 +8,7 @@ use App\Models\Concerns\HasUuids; use Database\Factories\OrganizationInvitationFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Carbon; use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation; @@ -16,6 +17,8 @@ use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation; * @property string $email * @property string $role * @property string $organization_id + * @property Carbon|null $updated_at + * @property Carbon|null $created_at * @property-read Organization $organization * * @method static OrganizationInvitationFactory factory() diff --git a/app/Models/Project.php b/app/Models/Project.php index 5dd75bb0..2f6e2c25 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -22,6 +22,7 @@ use Illuminate\Support\Carbon; * @property string $organization_id * @property string $client_id * @property int|null $billable_rate + * @property bool $is_public * @property bool $is_billable * @property-read bool $is_archived * @property Carbon|null $archived_at diff --git a/app/Models/ProjectMember.php b/app/Models/ProjectMember.php index 6b3e707c..cccce192 100644 --- a/app/Models/ProjectMember.php +++ b/app/Models/ProjectMember.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Support\Carbon; /** * @property string $id @@ -17,6 +18,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string $project_id Project ID * @property string $member_id Member ID * @property string $user_id User ID (legacy) + * @property Carbon|null $created_at + * @property Carbon|null $updated_at * @property-read Project $project * @property-read Member $member * @property-read User $user diff --git a/app/Models/Task.php b/app/Models/Task.php index a322f117..65c41158 100644 --- a/app/Models/Task.php +++ b/app/Models/Task.php @@ -42,6 +42,7 @@ class Task extends Model */ protected $casts = [ 'name' => 'string', + 'done_at' => 'datetime', ]; /** diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php index bd0e6171..3d37492e 100644 --- a/app/Models/TimeEntry.php +++ b/app/Models/TimeEntry.php @@ -20,13 +20,15 @@ use Korridor\LaravelComputedAttributes\ComputedAttributes; * @property string $description * @property Carbon $start * @property Carbon|null $end - * @property int $billable_rate Billable rate per hour in cents + * @property int|null $billable_rate Billable rate per hour in cents * @property bool $billable * @property array $tags * @property string $user_id * @property string $member_id * @property bool $is_imported * @property Carbon|null $still_active_email_sent_at + * @property Carbon|null $created_at + * @property Carbon|null $updated_at * @property-read User $user * @property-read Member $member * @property string $organization_id diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index abdebd94..3ea25ed1 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -114,6 +114,7 @@ class JetstreamServiceProvider extends ServiceProvider 'organizations:update', 'organizations:delete', 'import', + 'export', 'invitations:view', 'invitations:create', 'invitations:resend', @@ -159,6 +160,7 @@ class JetstreamServiceProvider extends ServiceProvider 'organizations:view', 'organizations:update', 'import', + 'export', 'invitations:view', 'invitations:create', 'invitations:resend', diff --git a/app/Service/Export/ExportException.php b/app/Service/Export/ExportException.php new file mode 100644 index 00000000..071d03f5 --- /dev/null +++ b/app/Service/Export/ExportException.php @@ -0,0 +1,12 @@ + $organization->getKey(), + 'export_id' => $exportId, + ]); + + // Organizations + try { + $writer = Writer::createFromPath($temporaryDirectory->path('organizations.csv'), 'w+'); + $writer->insertOne([ + 'id', + 'name', + 'billable_rate', + 'currency', + 'created_at', + 'updated_at', + ]); + $writer->insertOne([ + $organization->id, + $organization->name, + $organization->billable_rate ?? '', + $organization->currency, + $organization->created_at?->toIso8601ZuluString() ?? '', + $organization->updated_at?->toIso8601ZuluString() ?? '', + ]); + + // Organization invitations + $writer = Writer::createFromPath($temporaryDirectory->path('organization_invitations.csv'), 'w+'); + $writer->insertOne([ + 'id', + 'email', + 'organization_id', + 'role', + 'created_at', + 'updated_at', + ]); + OrganizationInvitation::query() + ->whereBelongsTo($organization, 'organization') + ->chunk(1000, function (Collection $organizationInvitations) use (&$writer): void { + $organizationInvitations->each(function (OrganizationInvitation $organizationInvitation) use (&$writer): void { + $writer->insertOne([ + $organizationInvitation->id, + $organizationInvitation->email, + $organizationInvitation->organization_id, + $organizationInvitation->role, + $organizationInvitation->created_at?->toIso8601ZuluString() ?? '', + $organizationInvitation->updated_at?->toIso8601ZuluString() ?? '', + ]); + }); + }); + + // Time entries + $writer = Writer::createFromPath($temporaryDirectory->path('time_entries.csv'), 'w+'); + $writer->insertOne([ + 'id', + 'description', + 'start', + 'end', + 'billable_rate', + 'billable', + 'member_id', + 'user_id', + 'organization_id', + 'client_id', + 'project_id', + 'task_id', + 'tags', + 'is_imported', + 'still_active_email_sent_at', + 'created_at', + 'updated_at', + ]); + TimeEntry::query() + ->whereBelongsTo($organization, 'organization') + ->chunk(1000, function (Collection $timeEntries) use (&$writer): void { + $timeEntries->each(function (TimeEntry $timeEntry) use (&$writer): void { + $tags = json_encode($timeEntry->tags); + $writer->insertOne([ + $timeEntry->id, + $timeEntry->description, + $timeEntry->start->toIso8601ZuluString(), + $timeEntry->end?->toIso8601ZuluString() ?? '', + $timeEntry->billable_rate ?? '', + $timeEntry->billable ? 'true' : 'false', + $timeEntry->member_id, + $timeEntry->user_id, + $timeEntry->organization_id, + $timeEntry->client_id ?? '', + $timeEntry->project_id ?? '', + $timeEntry->task_id ?? '', + $tags === false ? '' : $tags, + $timeEntry->is_imported ? 'true' : 'false', + $timeEntry->still_active_email_sent_at?->toIso8601ZuluString() ?? '', + $timeEntry->created_at?->toIso8601ZuluString() ?? '', + $timeEntry->updated_at?->toIso8601ZuluString() ?? '', + ]); + }); + }); + + // Clients + $writer = Writer::createFromPath($temporaryDirectory->path('clients.csv'), 'w+'); + $writer->insertOne([ + 'id', + 'name', + 'organization_id', + 'archived_at', + 'created_at', + 'updated_at', + ]); + Client::query() + ->whereBelongsTo($organization, 'organization') + ->chunk(1000, function (Collection $clients) use (&$writer): void { + $clients->each(function (Client $client) use (&$writer): void { + $writer->insertOne([ + $client->id, + $client->name, + $client->organization_id, + $client->archived_at ?? '', + $client->created_at?->toIso8601ZuluString() ?? '', + $client->updated_at?->toIso8601ZuluString() ?? '', + ]); + }); + }); + + // Projects + $writer = Writer::createFromPath($temporaryDirectory->path('projects.csv'), 'w+'); + $writer->insertOne([ + 'id', + 'name', + 'color', + 'billable_rate', + 'is_public', + 'client_id', + 'organization_id', + 'is_billable', + 'archived_at', + 'created_at', + 'updated_at', + ]); + Project::query() + ->whereBelongsTo($organization, 'organization') + ->chunk(1000, function (Collection $projects) use (&$writer): void { + $projects->each(function (Project $project) use (&$writer): void { + $writer->insertOne([ + $project->id, + $project->name, + $project->color, + $project->billable_rate ?? '', + $project->is_public ? 'true' : 'false', + $project->client_id ?? '', + $project->organization_id, + $project->is_billable ? 'true' : 'false', + $project->archived_at?->toIso8601ZuluString() ?? '', + $project->created_at?->toIso8601ZuluString() ?? '', + $project->updated_at?->toIso8601ZuluString() ?? '', + ]); + }); + }); + + // Project members + $writer = Writer::createFromPath($temporaryDirectory->path('project_members.csv'), 'w+'); + $writer->insertOne([ + 'id', + 'billable_rate', + 'project_id', + 'user_id', + 'member_id', + 'created_at', + 'updated_at', + ]); + ProjectMember::query() + ->whereBelongsToOrganization($organization) + ->chunk(1000, function (Collection $projectMembers) use (&$writer): void { + $projectMembers->each(function (ProjectMember $projectMember) use (&$writer): void { + $writer->insertOne([ + $projectMember->id, + $projectMember->billable_rate ?? '', + $projectMember->project_id, + $projectMember->user_id, + $projectMember->member_id, + $projectMember->created_at?->toIso8601ZuluString() ?? '', + $projectMember->updated_at?->toIso8601ZuluString() ?? '', + ]); + }); + }); + + // Members + $writer = Writer::createFromPath($temporaryDirectory->path('members.csv'), 'w+'); + $writer->insertOne([ + 'id', + 'user_id', + 'name', + 'email', + 'organization_id', + 'billable_rate', + 'role', + 'created_at', + 'updated_at', + ]); + Member::query() + ->whereBelongsTo($organization, 'organization') + ->with([ + 'user', + ]) + ->chunk(1000, function (Collection $members) use (&$writer): void { + $members->each(function (Member $member) use (&$writer): void { + $writer->insertOne([ + $member->id, + $member->user_id, + $member->user->name, + $member->user->email, + $member->organization_id, + $member->billable_rate ?? '', + $member->role, + $member->created_at?->toIso8601ZuluString() ?? '', + $member->updated_at?->toIso8601ZuluString() ?? '', + ]); + }); + }); + + // Tasks + $writer = Writer::createFromPath($temporaryDirectory->path('tasks.csv'), 'w+'); + $writer->insertOne([ + 'id', + 'name', + 'project_id', + 'organization_id', + 'done_at', + 'created_at', + 'updated_at', + ]); + Task::query() + ->whereBelongsTo($organization, 'organization') + ->chunk(1000, function (Collection $tasks) use (&$writer): void { + $tasks->each(function (Task $task) use (&$writer): void { + $writer->insertOne([ + $task->id, + $task->name, + $task->project_id, + $task->organization_id, + $task->done_at?->toIso8601ZuluString() ?? '', + $task->created_at?->toIso8601ZuluString() ?? '', + $task->updated_at?->toIso8601ZuluString() ?? '', + ]); + }); + }); + + // Tags + $writer = Writer::createFromPath($temporaryDirectory->path('tags.csv'), 'w+'); + $writer->insertOne([ + 'id', + 'name', + 'organization_id', + 'created_at', + 'updated_at', + ]); + Tag::query() + ->whereBelongsTo($organization, 'organization') + ->chunk(1000, function (Collection $tags) use (&$writer): void { + $tags->each(function (Tag $tag) use (&$writer): void { + $writer->insertOne([ + $tag->id, + $tag->name, + $tag->organization_id, + $tag->created_at?->toIso8601ZuluString() ?? '', + $tag->updated_at?->toIso8601ZuluString() ?? '', + ]); + }); + }); + + // Meta data file + $metaData = (object) [ + 'id' => $exportId, + 'version' => self::VERSION, + 'organizations' => [$organization->getKey()], + 'exported_at' => $timeStamp->toIso8601ZuluString(), + ]; + file_put_contents($temporaryDirectory->path('meta.json'), json_encode($metaData)); + + // Create ZIP file + $temporaryDirectoryZip = TemporaryDirectory::make(); + $zip = new ZipArchive(); + if ($zip->open($temporaryDirectoryZip->path('export.zip'), ZipArchive::CREATE) !== true) { + throw new Exception('Cannot create ZIP file'); + } + $zip->addFile($temporaryDirectory->path('organizations.csv'), 'organizations.csv'); + $zip->addFile($temporaryDirectory->path('organization_invitations.csv'), 'organization_invitations.csv'); + $zip->addFile($temporaryDirectory->path('time_entries.csv'), 'time_entries.csv'); + $zip->addFile($temporaryDirectory->path('clients.csv'), 'clients.csv'); + $zip->addFile($temporaryDirectory->path('projects.csv'), 'projects.csv'); + $zip->addFile($temporaryDirectory->path('project_members.csv'), 'project_members.csv'); + $zip->addFile($temporaryDirectory->path('members.csv'), 'members.csv'); + $zip->addFile($temporaryDirectory->path('tasks.csv'), 'tasks.csv'); + $zip->addFile($temporaryDirectory->path('tags.csv'), 'tags.csv'); + $zip->addFile($temporaryDirectory->path('meta.json'), 'meta.json'); + $zip->close(); + + // Upload ZIP file to private storage + $filename = 'export_'.$organization->getKey().'_'.$timeStamp->format('Y-m-d_H-i-s').'_'.$exportId.'.zip'; + Storage::disk(config('filesystems.private'))->putFileAs( + 'exports', + new File($temporaryDirectoryZip->path('export.zip')), + $filename + ); + + // Delete temp files + $temporaryDirectoryZip->delete(); + $temporaryDirectory->delete(); + + Log::debug('Finished exporting organization', [ + 'organization_id' => $organization->getKey(), + 'export_id' => $exportId, + ]); + + return 'exports/'.$filename; + } catch (UnavailableStream|CannotInsertRecord|Exception|LeagueCsvException $exception) { + report($exception); + + throw new ExportException(); + } + } +} diff --git a/app/Service/Import/Importers/DefaultImporter.php b/app/Service/Import/Importers/DefaultImporter.php index 11830f77..45f939a6 100644 --- a/app/Service/Import/Importers/DefaultImporter.php +++ b/app/Service/Import/Importers/DefaultImporter.php @@ -7,6 +7,7 @@ namespace App\Service\Import\Importers; use App\Models\Client; use App\Models\Member; use App\Models\Organization; +use App\Models\OrganizationInvitation; use App\Models\Project; use App\Models\ProjectMember; use App\Models\Tag; @@ -63,6 +64,11 @@ abstract class DefaultImporter implements ImporterContract */ protected ImportDatabaseHelper $projectMemberImportHelper; + /** + * @var ImportDatabaseHelper + */ + protected ImportDatabaseHelper $organizationInvitationsImportHelper; + protected BillableRateService $billableRateService; public function init(Organization $organization): void @@ -149,6 +155,15 @@ abstract class DefaultImporter implements ImporterContract 'max:500', ], ]); + $this->organizationInvitationsImportHelper = new ImportDatabaseHelper(OrganizationInvitation::class, ['email', 'organization_id'], true, function (Builder $builder) { + return $builder->where('organization_id', $this->organization->id); + }, validate: [ + 'email' => [ + 'required', + 'email', + 'max:255', + ], + ]); $this->timeEntriesCreated = 0; $this->colorService = app(ColorService::class); $this->timezoneService = app(TimezoneService::class); diff --git a/app/Service/Import/Importers/ImporterProvider.php b/app/Service/Import/Importers/ImporterProvider.php index 6edf4a98..63e81f82 100644 --- a/app/Service/Import/Importers/ImporterProvider.php +++ b/app/Service/Import/Importers/ImporterProvider.php @@ -14,6 +14,7 @@ class ImporterProvider 'toggl_data_importer' => TogglDataImporter::class, 'clockify_time_entries' => ClockifyTimeEntriesImporter::class, 'clockify_projects' => ClockifyProjectsImporter::class, + 'solidtime' => SolidtimeImporter::class, ]; /** diff --git a/app/Service/Import/Importers/SolidtimeImporter.php b/app/Service/Import/Importers/SolidtimeImporter.php new file mode 100644 index 00000000..ded88da6 --- /dev/null +++ b/app/Service/Import/Importers/SolidtimeImporter.php @@ -0,0 +1,335 @@ + + */ + public const array SUPPORTED_VERSIONS = ['1.0']; + + /** + * @throws ImportException + */ + #[Override] + public function importData(string $data, string $timezone): void + { + $temporaryDirectoryZip = null; + $temporaryDirectory = null; + try { + $zip = new ZipArchive(); + $temporaryDirectoryZip = TemporaryDirectory::make(); + file_put_contents($temporaryDirectoryZip->path('import.zip'), $data); + $res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY); + if ($res !== true) { + throw new ImportException('Invalid ZIP, error code: '.$res); + } + $temporaryDirectory = TemporaryDirectory::make(); + $zip->extractTo($temporaryDirectory->path()); + $zip->close(); + + if (! file_exists($temporaryDirectory->path('meta.json'))) { + throw new ImportException('File "meta.json" missing in ZIP'); + } + $metaFileContentRaw = file_get_contents($temporaryDirectory->path('meta.json')); + if ($metaFileContentRaw === false) { + throw new ImportException('File "meta.json" can not read'); + } + $metaFileContent = json_decode($metaFileContentRaw); + if ($metaFileContent === false || ! isset($metaFileContent->version) || ! in_array($metaFileContent->version, self::SUPPORTED_VERSIONS, true)) { + throw new ImportException('Invalid version'); + } + + if (! file_exists($temporaryDirectory->path('clients.csv'))) { + throw new ImportException('File "clients.csv" missing in ZIP'); + } + $clientsReader = Reader::createFromPath($temporaryDirectory->path('clients.csv')); + $clientsReader->setHeaderOffset(0); + $clientsReader->setDelimiter(','); + + if (! file_exists($temporaryDirectory->path('members.csv'))) { + throw new ImportException('File "members.csv" missing in ZIP'); + } + $membersReader = Reader::createFromPath($temporaryDirectory->path('members.csv')); + $membersReader->setHeaderOffset(0); + $membersReader->setDelimiter(','); + + if (! file_exists($temporaryDirectory->path('organization_invitations.csv'))) { + throw new ImportException('File "organization_invitations.csv" missing in ZIP'); + } + $organizationInvitationsReader = Reader::createFromPath($temporaryDirectory->path('organization_invitations.csv')); + $organizationInvitationsReader->setHeaderOffset(0); + $organizationInvitationsReader->setDelimiter(','); + + if (! file_exists($temporaryDirectory->path('project_members.csv'))) { + throw new ImportException('File "project_members.csv" missing in ZIP'); + } + $projectMembersReader = Reader::createFromPath($temporaryDirectory->path('project_members.csv')); + $projectMembersReader->setHeaderOffset(0); + $projectMembersReader->setDelimiter(','); + + if (! file_exists($temporaryDirectory->path('projects.csv'))) { + throw new ImportException('File "projects.csv" missing in ZIP'); + } + $projectsReader = Reader::createFromPath($temporaryDirectory->path('projects.csv')); + $projectsReader->setHeaderOffset(0); + $projectsReader->setDelimiter(','); + + if (! file_exists($temporaryDirectory->path('tags.csv'))) { + throw new ImportException('File "tags.csv" missing in ZIP'); + } + $tagsReader = Reader::createFromPath($temporaryDirectory->path('tags.csv')); + $tagsReader->setHeaderOffset(0); + $tagsReader->setDelimiter(','); + + if (! file_exists($temporaryDirectory->path('tasks.csv'))) { + throw new ImportException('File "tasks.csv" missing in ZIP'); + } + $tasksReader = Reader::createFromPath($temporaryDirectory->path('tasks.csv')); + $tasksReader->setHeaderOffset(0); + $tasksReader->setDelimiter(','); + + if (! file_exists($temporaryDirectory->path('time_entries.csv'))) { + throw new ImportException('File "time_entries.csv" missing in ZIP'); + } + $timeEntriesReader = Reader::createFromPath($temporaryDirectory->path('time_entries.csv')); + $timeEntriesReader->setHeaderOffset(0); + $timeEntriesReader->setDelimiter(','); + + foreach ($clientsReader as $client) { + $this->clientImportHelper->getKey([ + 'name' => $client['name'], + 'organization_id' => $this->organization->id, + ], [ + 'archived_at' => $client['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $client['archived_at'], 'UTC') : null, + ], $client['id']); + } + + foreach ($tagsReader as $tag) { + $this->tagImportHelper->getKey([ + 'name' => $tag['name'], + 'organization_id' => $this->organization->id, + ], [], $tag['id']); + } + + foreach ($membersReader as $member) { + $userId = $this->userImportHelper->getKey([ + 'email' => $member['email'], + ], [ + 'name' => $member['name'], + 'timezone' => 'UTC', + 'is_placeholder' => true, + ], $member['user_id']); + $this->memberImportHelper->getKey([ + 'user_id' => $userId, + 'organization_id' => $this->organization->getKey(), + ], [ + 'role' => Role::Placeholder->value, + 'billable_rate' => $member['billable_rate'] === '' ? null : (int) $member['billable_rate'], + ], $member['id']); + } + + foreach ($projectsReader as $project) { + $clientId = null; + if ($project['client_id'] !== '') { + $clientId = $this->clientImportHelper->getKeyByExternalIdentifier($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(), + ], [ + 'color' => $project['color'], + 'billable_rate' => $project['billable_rate'] === '' ? null : (int) $project['billable_rate'], + 'is_public' => $project['is_public'] === 'true', + 'client_id' => $clientId, + 'is_billable' => $project['is_billable'] === 'true', + 'archived_at' => $project['archived_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $project['archived_at'], 'UTC') : null, + ], $project['id']); + } + + foreach ($projectMembersReader as $projectMember) { + $userId = $this->userImportHelper->getKeyByExternalIdentifier($projectMember['user_id']); + $memberId = $this->memberImportHelper->getKeyByExternalIdentifier($projectMember['member_id']); + $projectId = $this->projectImportHelper->getKeyByExternalIdentifier($projectMember['project_id']); + $this->projectMemberImportHelper->getKey([ + 'project_id' => $projectId, + 'member_id' => $memberId, + ], [ + 'user_id' => $userId, + 'billable_rate' => $projectMember['billable_rate'] === '' ? null : (int) $projectMember['billable_rate'], + ], $projectMember['id']); + } + + foreach ($tasksReader as $task) { + $projectId = $this->projectImportHelper->getKeyByExternalIdentifier($task['project_id']); + if ($projectId === null) { + throw new Exception('Project does not exist'); + } + $this->taskImportHelper->getKey([ + 'name' => $task['name'], + 'project_id' => $projectId, + 'organization_id' => $this->organization->getKey(), + ], [ + 'done_at' => $task['done_at'] !== '' ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $task['done_at'], 'UTC') : null, + ], (string) $task['id']); + } + + // Time entries + foreach ($timeEntriesReader as $timeEntryRow) { + $userId = $this->userImportHelper->getKeyByExternalIdentifier($timeEntryRow['user_id']); + $memberId = $this->memberImportHelper->getKeyByExternalIdentifier($timeEntryRow['member_id']); + $member = $this->memberImportHelper->getModelById($memberId); + $clientId = null; + if ($timeEntryRow['client_id'] !== '') { + $clientId = $this->clientImportHelper->getKeyByExternalIdentifier($timeEntryRow['client_id']); + } + $project = null; + $projectId = null; + $projectMember = null; + if ($timeEntryRow['project_id'] !== '') { + $projectId = $this->projectImportHelper->getKeyByExternalIdentifier($timeEntryRow['project_id']); + $project = $this->projectImportHelper->getModelById($projectId); + $projectMember = $this->projectMemberImportHelper->getModel([ + 'project_id' => $projectId, + 'member_id' => $memberId, + ]); + } + $taskId = null; + if ($timeEntryRow['task_id'] !== '') { + $taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']); + } + $timeEntry = new TimeEntry(); + $timeEntry->user_id = $userId; + $timeEntry->member_id = $memberId; + $timeEntry->task_id = $taskId; + $timeEntry->project_id = $projectId; + $timeEntry->client_id = $clientId; + $timeEntry->organization_id = $this->organization->id; + if (strlen($timeEntryRow['description']) > 500) { + throw new ImportException('Time entry description is too long'); + } + $timeEntry->description = $timeEntryRow['description']; + if (! in_array($timeEntryRow['billable'], ['true', 'false'], true)) { + throw new ImportException('Invalid billable value'); + } + $timeEntry->billable = $timeEntryRow['billable'] === 'true'; + $timeEntry->tags = $this->getTags($timeEntryRow['tags']); + $timeEntry->is_imported = true; + + try { + $start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['start'], 'UTC'); + } catch (InvalidFormatException) { + throw new ImportException('Start date ("'.$timeEntryRow['start'].'") is invalid'); + } + if ($start === null) { + throw new ImportException('Start date ("'.$timeEntryRow['start'].'") is invalid'); + } + $timeEntry->start = $start->utc(); + + if ($timeEntryRow['end'] !== '') { + try { + $end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['end'], 'UTC'); + } catch (InvalidFormatException) { + throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid'); + } + if ($end === null) { + throw new ImportException('End date ("'.$timeEntryRow['end'].'") is invalid'); + } + $timeEntry->end = $end->utc(); + } else { + $timeEntry->end = null; + } + + if ($timeEntryRow['still_active_email_sent_at'] !== '') { + try { + $stillActiveEmailSentAt = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $timeEntryRow['still_active_email_sent_at'], 'UTC'); + } catch (InvalidFormatException) { + throw new ImportException('Still active email timestamp ("'.$timeEntryRow['still_active_email_sent_at'].'") is invalid'); + } + if ($stillActiveEmailSentAt === null) { + throw new ImportException('Still active email timestamp ("'.$timeEntryRow['still_active_email_sent_at'].'") is invalid'); + } + $timeEntry->still_active_email_sent_at = $stillActiveEmailSentAt->utc(); + } else { + $timeEntry->still_active_email_sent_at = null; + } + + $timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations( + $timeEntry, + $projectMember, + $project, + $member, + $this->organization + ); + $timeEntry->save(); + $this->timeEntriesCreated++; + } + } catch (ImportException $exception) { + throw $exception; + } catch (Exception $exception) { + report($exception); + throw new ImportException('Unknown error'); + } finally { + $temporaryDirectory?->delete(); + $temporaryDirectoryZip?->delete(); + } + } + + /** + * @return array + */ + private function getTags(string $tags): array + { + if (trim($tags) === '') { + return []; + } + $tagsParsed = json_decode($tags); + if ($tagsParsed === false || ! is_array($tagsParsed)) { + return []; + } + $tagIds = []; + foreach ($tagsParsed as $tagParsed) { + if (! is_string($tagParsed) || ! Str::isUuid($tagParsed)) { + continue; + } + $tagId = $this->tagImportHelper->getKeyByExternalIdentifier($tagParsed); + $tagIds[] = $tagId; + } + + return $tagIds; + } + + #[Override] + public function getName(): string + { + return __('importer.solidtime_importer.name'); + } + + #[Override] + public function getDescription(): string + { + return __('importer.solidtime_importer.description'); + } +} diff --git a/app/Service/Import/Importers/TogglDataImporter.php b/app/Service/Import/Importers/TogglDataImporter.php index 71020bc1..c8d414c7 100644 --- a/app/Service/Import/Importers/TogglDataImporter.php +++ b/app/Service/Import/Importers/TogglDataImporter.php @@ -6,6 +6,8 @@ namespace App\Service\Import\Importers; use App\Enums\Role; use Exception; +use Illuminate\Support\Carbon; +use Override; use Spatie\TemporaryDirectory\TemporaryDirectory; use ValueError; use ZipArchive; @@ -15,14 +17,16 @@ class TogglDataImporter extends DefaultImporter /** * @throws ImportException */ - #[\Override] + #[Override] public function importData(string $data, string $timezone): void { + $temporaryDirectoryZip = null; + $temporaryDirectory = null; try { $zip = new ZipArchive(); - $temporaryDirectory = TemporaryDirectory::make(); - file_put_contents($temporaryDirectory->path('import.zip'), $data); - $res = $zip->open($temporaryDirectory->path('import.zip'), ZipArchive::RDONLY); + $temporaryDirectoryZip = TemporaryDirectory::make(); + file_put_contents($temporaryDirectoryZip->path('import.zip'), $data); + $res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY); if ($res !== true) { throw new ImportException('Invalid ZIP, error code: '.$res); } @@ -77,7 +81,9 @@ class TogglDataImporter extends DefaultImporter $this->clientImportHelper->getKey([ 'name' => $client->name, 'organization_id' => $this->organization->id, - ], [], (string) $client->id); + ], [ + 'archived_at' => $client->archived === true ? Carbon::now() : null, + ], (string) $client->id); } foreach ($tags as $tag) { $this->tagImportHelper->getKey([ @@ -121,7 +127,8 @@ class TogglDataImporter extends DefaultImporter ], [ 'client_id' => $clientId, 'color' => $project->color, - 'is_billable' => $project->rate !== null, + 'is_billable' => $project->billable, + 'is_public' => ! $project->is_private, 'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null, ], (string) $project->id); @@ -170,7 +177,9 @@ class TogglDataImporter extends DefaultImporter 'name' => $task->name, 'project_id' => $projectId, 'organization_id' => $this->organization->getKey(), - ], [], (string) $task->id); + ], [ + 'done_at' => $task->active === false ? Carbon::now() : null, + ], (string) $task->id); } } } catch (ValueError $exception) { @@ -180,16 +189,19 @@ class TogglDataImporter extends DefaultImporter } catch (Exception $exception) { report($exception); throw new ImportException('Unknown error'); + } finally { + $temporaryDirectory?->delete(); + $temporaryDirectoryZip?->delete(); } } - #[\Override] + #[Override] public function getName(): string { return __('importer.toggl_data_importer.name'); } - #[\Override] + #[Override] public function getDescription(): string { return __('importer.toggl_data_importer.description'); diff --git a/config/filesystems.php b/config/filesystems.php index 64ac0c9e..05648826 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -17,6 +17,10 @@ return [ 'default' => env('FILESYSTEM_DISK', 'local'), + 'public' => env('PUBLIC_FILESYSTEM_DISK', 'public'), + + 'private' => env('FILESYSTEM_DISK', 'local'), + /* |-------------------------------------------------------------------------- | Filesystem Disks diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 685bb558..73736125 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -8,6 +8,7 @@ use App\Enums\Role; use App\Models\Client; use App\Models\Member; use App\Models\Organization; +use App\Models\OrganizationInvitation; use App\Models\Project; use App\Models\ProjectMember; use App\Models\Tag; @@ -39,6 +40,9 @@ class DatabaseSeeder extends Seeder 'personal_team' => false, 'currency' => 'EUR', ]); + OrganizationInvitation::factory()->forOrganization($organizationAcme)->create([ + 'email' => 'new.employee@example.com', + ]); $userAcmeManager = User::factory()->withPersonalOrganization()->create([ 'name' => 'Acme Manager', 'email' => 'test@example.com', @@ -62,6 +66,15 @@ class DatabaseSeeder extends Seeder $userAcmeEmployeeMember = Member::factory()->forUser($userAcmeEmployee)->forOrganization($organizationAcme)->role(Role::Employee)->create(); $userAcmePlaceholderMember = Member::factory()->forUser($userAcmePlaceholder)->forOrganization($organizationAcme)->role(Role::Placeholder)->create(); $userWithMultipleOrganizationsAcmeMember = Member::factory()->forUser($userWithMultipleOrganizations)->forOrganization($organizationAcme)->role(Role::Employee)->create(); + Tag::factory()->forOrganization($organizationAcme)->create([ + 'name' => 'Code Review', + ]); + Tag::factory()->forOrganization($organizationAcme)->create([ + 'name' => 'Meeting', + ]); + Tag::factory()->forOrganization($organizationAcme)->create([ + 'name' => 'Research', + ]); TimeEntry::factory() ->count(10) @@ -147,6 +160,7 @@ class DatabaseSeeder extends Seeder DB::table((new Project())->getTable())->delete(); DB::table((new Client())->getTable())->delete(); DB::table((new User())->getTable())->delete(); + DB::table((new OrganizationInvitation())->getTable())->delete(); DB::table((new Organization())->getTable())->delete(); } } diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index dff4681b..8f754f40 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -14,6 +14,7 @@ use App\Exceptions\Api\TimeEntryStillRunningApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException; use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException; use App\Exceptions\Api\UserNotPlaceholderApiException; +use App\Service\Export\ExportException; return [ 'api' => [ @@ -29,6 +30,7 @@ return [ OnlyOwnerCanChangeOwnership::KEY => 'Only owner can change ownership', OrganizationNeedsAtLeastOneOwner::KEY => 'Organization needs at least one owner', ChangingRoleToPlaceholderIsNotAllowed::KEY => 'Changing role to placeholder is not allowed', + ExportException::KEY => 'Export failed, please try again later or contact support', ], 'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.', ]; diff --git a/lang/en/importer.php b/lang/en/importer.php index feb2350e..05326f8c 100644 --- a/lang/en/importer.php +++ b/lang/en/importer.php @@ -32,4 +32,8 @@ return [ '

1. Go to Admin -> Settings -> Data export.
2. Under "Time entries" select the year you want to export and click on "Export time entries".

You can export all years one after another and import them one after another. '. '
Before you import make sure that the Timezone settings in Toggl are the same as in solidtime.', ], + 'solidtime_importer' => [ + 'name' => 'Solidtime', + 'description' => '1. Choose the organization you want to export in dropdown in the left top corner
2. Click on "Export" in the left navigation under "Admin" (You need to be Admin or Owner of the organization to see this)
3. Click on "Export".
4. Save the file and upload it here.', + ], ]; diff --git a/resources/testfiles/solidtime_import_test_1/clients.csv b/resources/testfiles/solidtime_import_test_1/clients.csv new file mode 100644 index 00000000..e84ef176 --- /dev/null +++ b/resources/testfiles/solidtime_import_test_1/clients.csv @@ -0,0 +1,3 @@ +id,name,organization_id,archived_at,created_at,updated_at +b4187a44-41f4-46d7-8460-f15a25b3aad6,"Big Company",ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z +e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72,"Other Company (Archived)",ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z diff --git a/resources/testfiles/solidtime_import_test_1/members.csv b/resources/testfiles/solidtime_import_test_1/members.csv new file mode 100644 index 00000000..70140e1b --- /dev/null +++ b/resources/testfiles/solidtime_import_test_1/members.csv @@ -0,0 +1,2 @@ +id,user_id,name,email,organization_id,billable_rate,role,created_at,updated_at +06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,"Peter Tester",peter.test@email.test,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,admin,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z diff --git a/resources/testfiles/solidtime_import_test_1/meta.json b/resources/testfiles/solidtime_import_test_1/meta.json new file mode 100644 index 00000000..b40876f8 --- /dev/null +++ b/resources/testfiles/solidtime_import_test_1/meta.json @@ -0,0 +1 @@ +{"id":"d6a324ee-58d5-4096-8069-c63bd55608f7","version":"1.0","organizations":["ee5a8cd6-312f-4ae6-b044-e2014f09ecc2"],"exported_at":"2024-08-26T18:21:59Z"} \ No newline at end of file diff --git a/resources/testfiles/solidtime_import_test_1/organization_invitations.csv b/resources/testfiles/solidtime_import_test_1/organization_invitations.csv new file mode 100644 index 00000000..01bbe37d --- /dev/null +++ b/resources/testfiles/solidtime_import_test_1/organization_invitations.csv @@ -0,0 +1 @@ +id,email,organization_id,role,created_at,updated_at diff --git a/resources/testfiles/solidtime_import_test_1/organizations.csv b/resources/testfiles/solidtime_import_test_1/organizations.csv new file mode 100644 index 00000000..09cfbed0 --- /dev/null +++ b/resources/testfiles/solidtime_import_test_1/organizations.csv @@ -0,0 +1,2 @@ +id,name,billable_rate,currency,created_at,updated_at +ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,"ACME Corp",,EUR,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z diff --git a/resources/testfiles/solidtime_import_test_1/project_members.csv b/resources/testfiles/solidtime_import_test_1/project_members.csv new file mode 100644 index 00000000..3633c3d5 --- /dev/null +++ b/resources/testfiles/solidtime_import_test_1/project_members.csv @@ -0,0 +1,2 @@ +id,billable_rate,project_id,user_id,member_id,created_at,updated_at +180a1a98-2f1c-4596-86e4-63a6be0d7b1d,10002,06e79ec4-33f8-4730-804c-d03c014991d1,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,06e6e605-86bd-417b-b75d-02f671e5d520,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z diff --git a/resources/testfiles/solidtime_import_test_1/projects.csv b/resources/testfiles/solidtime_import_test_1/projects.csv new file mode 100644 index 00000000..3c3b9e3d --- /dev/null +++ b/resources/testfiles/solidtime_import_test_1/projects.csv @@ -0,0 +1,4 @@ +id,name,color,billable_rate,is_public,client_id,organization_id,is_billable,archived_at,created_at,updated_at +06e79ec4-33f8-4730-804c-d03c014991d1,"Project for Big Company",#ec407a,10001,false,b4187a44-41f4-46d7-8460-f15a25b3aad6,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,true,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z +622c74a9-7e64-44c2-9426-2a37ac738206,"Project without Client",#ef5350,,false,,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z +aa831162-dbb2-4cfe-bfe0-5e3a252c66f0,"Project (Archived)",#6a407f,,true,e5a4d8f5-81ae-4606-8e84-6ab1ffa58b72,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,true,2024-08-25T10:00:00Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z diff --git a/resources/testfiles/solidtime_import_test_1/tags.csv b/resources/testfiles/solidtime_import_test_1/tags.csv new file mode 100644 index 00000000..6791d565 --- /dev/null +++ b/resources/testfiles/solidtime_import_test_1/tags.csv @@ -0,0 +1,3 @@ +id,name,organization_id,created_at,updated_at +2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7,Development,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z +bf6c0ac5-2587-474b-8983-40bb3ea8002f,Backend,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z diff --git a/resources/testfiles/solidtime_import_test_1/tasks.csv b/resources/testfiles/solidtime_import_test_1/tasks.csv new file mode 100644 index 00000000..01babe7c --- /dev/null +++ b/resources/testfiles/solidtime_import_test_1/tasks.csv @@ -0,0 +1,3 @@ +id,name,project_id,organization_id,done_at,created_at,updated_at +b49688a0-94f3-4cb3-9ca1-5003de955fb0,"Task 1",06e79ec4-33f8-4730-804c-d03c014991d1,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z +b49688a0-94f3-4cb3-9ca1-5003de955fb0,"Task 2",06e79ec4-33f8-4730-804c-d03c014991d1,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,2024-08-24T10:00:00Z,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z diff --git a/resources/testfiles/solidtime_import_test_1/time_entries.csv b/resources/testfiles/solidtime_import_test_1/time_entries.csv new file mode 100644 index 00000000..daf0a565 --- /dev/null +++ b/resources/testfiles/solidtime_import_test_1/time_entries.csv @@ -0,0 +1,3 @@ +id,description,start,end,billable_rate,billable,member_id,user_id,organization_id,client_id,project_id,task_id,tags,is_imported,still_active_email_sent_at,created_at,updated_at +00aae3be-18fc-462d-bee4-350fb605b2f3,,2024-03-04T09:23:52Z,2024-03-04T09:23:52Z,,false,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,"[""2c5c2da7-9ef8-4410-bb8f-6e0a90f9d2c7"",""bf6c0ac5-2587-474b-8983-40bb3ea8002f""]",false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z +1c7a905d-aa12-4d08-bc41-7e92577e7cdf,"Working hard",2024-03-04T09:23:00Z,2024-03-04T10:23:01Z,,true,06e6e605-86bd-417b-b75d-02f671e5d520,0446cdd8-3ad1-43d6-9231-9e0dc4eeb71c,ee5a8cd6-312f-4ae6-b044-e2014f09ecc2,,,,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z diff --git a/resources/testfiles/toggl_data_import_test_1/clients.json b/resources/testfiles/toggl_data_import_test_1/clients.json index 9291eaef..1e1a8baa 100644 --- a/resources/testfiles/toggl_data_import_test_1/clients.json +++ b/resources/testfiles/toggl_data_import_test_1/clients.json @@ -5,5 +5,12 @@ "id": 301, "name": "Big Company", "wid": 0 + }, + { + "archived": true, + "creator_id": 201, + "id": 302, + "name": "Other Company (Archived)", + "wid": 0 } ] diff --git a/resources/testfiles/toggl_data_import_test_1/projects.json b/resources/testfiles/toggl_data_import_test_1/projects.json index f350a715..0b0b3f3c 100644 --- a/resources/testfiles/toggl_data_import_test_1/projects.json +++ b/resources/testfiles/toggl_data_import_test_1/projects.json @@ -4,7 +4,7 @@ "actual_hours": null, "actual_seconds": null, "auto_estimates": false, - "billable": true, + "billable": false, "cid": null, "client_id": null, "color": "#ef5350", @@ -32,7 +32,7 @@ "actual_hours": null, "actual_seconds": null, "auto_estimates": false, - "billable": false, + "billable": true, "cid": 301, "client_id": 301, "color": "#ec407a", @@ -54,5 +54,33 @@ "template_id": null, "wid": 0, "workspace_id": 0 + }, + { + "active": false, + "actual_hours": null, + "actual_seconds": null, + "auto_estimates": false, + "billable": true, + "cid": 302, + "client_id": 302, + "color": "#6a407f", + "currency": null, + "estimated_hours": null, + "estimated_seconds": null, + "fixed_fee": null, + "guid": "", + "id": 403, + "is_private": false, + "name": "Project (Archived)", + "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/resources/testfiles/toggl_data_import_test_1/projects_users/403.json b/resources/testfiles/toggl_data_import_test_1/projects_users/403.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/resources/testfiles/toggl_data_import_test_1/projects_users/403.json @@ -0,0 +1 @@ +[] diff --git a/resources/testfiles/toggl_data_import_test_1/tasks/402.json b/resources/testfiles/toggl_data_import_test_1/tasks/402.json index fc7d6934..844a67b8 100644 --- a/resources/testfiles/toggl_data_import_test_1/tasks/402.json +++ b/resources/testfiles/toggl_data_import_test_1/tasks/402.json @@ -9,5 +9,16 @@ "tracked_seconds": 0, "user_id": null, "workspace_id": 0 + }, + { + "active": false, + "estimated_seconds": 0, + "id": 602, + "name": "Task 2", + "project_id": 403, + "recurring": false, + "tracked_seconds": 0, + "user_id": null, + "workspace_id": 0 } ] diff --git a/resources/testfiles/toggl_data_import_test_1/tasks/403.json b/resources/testfiles/toggl_data_import_test_1/tasks/403.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/resources/testfiles/toggl_data_import_test_1/tasks/403.json @@ -0,0 +1 @@ +[] diff --git a/routes/api.php b/routes/api.php index 932e84b1..0dbae777 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\ExportController; use App\Http\Controllers\Api\V1\ImportController; use App\Http\Controllers\Api\V1\InvitationController; use App\Http\Controllers\Api\V1\MemberController; @@ -125,6 +126,11 @@ Route::middleware([ Route::get('/organizations/{organization}/importers', [ImportController::class, 'index'])->name('index'); Route::post('/organizations/{organization}/import', [ImportController::class, 'import'])->name('import'); }); + + // Export routes + Route::name('export.')->prefix('/organizations/{organization}')->group(static function () { + Route::post('/export', [ExportController::class, 'export'])->name('export'); + }); }); /** diff --git a/tests/Unit/Endpoint/Api/V1/ExportEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ExportEndpointTest.php new file mode 100644 index 00000000..71be8660 --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/ExportEndpointTest.php @@ -0,0 +1,93 @@ +createUserWithPermission(); + $this->mock(ExportService::class, function (MockInterface $mock): void { + $mock->shouldNotReceive('export'); + }); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.export.export', ['organization' => $data->organization->getKey()])); + + // Assert + $response->assertForbidden(); + } + + public function test_export_return_error_message_if_export_fails(): void + { + $user = $this->createUserWithPermission([ + 'export', + ]); + $this->mock(ExportService::class, function (MockInterface $mock) use (&$user): void { + $mock->shouldReceive('export') + ->withArgs(function (Organization $organization) use (&$user): bool { + return $organization->is($user->organization); + }) + ->andThrow(new ExportException()) + ->once(); + }); + Passport::actingAs($user->user); + + // Act + $response = $this->postJson(route('api.v1.export.export', ['organization' => $user->organization->getKey()])); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'export', + 'message' => 'Export failed, please try again later or contact support', + ]); + } + + public function test_export_calls_export_service_if_user_has_permission(): void + { + // Arrange + $user = $this->createUserWithPermission([ + 'export', + ]); + $filepath = 'exports/path.zip'; + Storage::fake('local'); + $now = Carbon::now(); + $this->travelTo($now); + $this->mock(ExportService::class, function (MockInterface $mock) use (&$user, $filepath): void { + $mock->shouldReceive('export') + ->withArgs(function (Organization $organization) use (&$user): bool { + return $organization->is($user->organization); + }) + ->andReturn($filepath) + ->once(); + }); + Passport::actingAs($user->user); + + // Act + $response = $this->postJson(route('api.v1.export.export', ['organization' => $user->organization->getKey()])); + + // Assert + $response->assertStatus(200); + $response->assertExactJson([ + 'success' => true, + 'download_url' => Storage::disk('local')->temporaryUrl($filepath, $now->addMinutes(10)), + ]); + } +} diff --git a/tests/Unit/Service/Export/ExportServiceTest.php b/tests/Unit/Service/Export/ExportServiceTest.php new file mode 100644 index 00000000..c806dc1a --- /dev/null +++ b/tests/Unit/Service/Export/ExportServiceTest.php @@ -0,0 +1,60 @@ +create(); + $user2 = User::factory()->create(); + $organization = Organization::factory()->withOwner($user1)->create(); + $member1 = Member::factory()->forUser($user1)->forOrganization($organization)->create(); + $member2 = Member::factory()->forUser($user2)->forOrganization($organization)->create(); + $timeEntry1 = TimeEntry::factory()->forMember($member1)->create(); + $timeEntry2 = TimeEntry::factory()->forMember($member1)->create(); + $project1 = Project::factory()->forOrganization($organization)->create(); + $project2 = Project::factory()->forOrganization($organization)->create(); + $task1 = Task::factory()->forOrganization($organization)->forProject($project1)->create(); + $task2 = Task::factory()->forOrganization($organization)->forProject($project1)->create(); + $task3 = Task::factory()->forOrganization($organization)->forProject($project2)->create(); + $projectMember1 = ProjectMember::factory()->forMember($member1)->forProject($project1)->create(); + $projectMember2 = ProjectMember::factory()->forMember($member2)->forProject($project1)->create(); + $client1 = Client::factory()->forOrganization($organization)->create(); + $client2 = Client::factory()->forOrganization($organization)->create(); + + return $organization; + } + + public function test_export_creates_zip_with_all_the_data_of_the_organization(): void + { + // Arrange + $organization1 = $this->getFullOrganization(); + $organization2 = $this->getFullOrganization(); + + // Act + $exportService = app(ExportService::class); + $zip = $exportService->export($organization1); + + // Assert + Storage::disk('local')->assertExists($zip); + } +} diff --git a/tests/Unit/Service/Import/Importers/ClockifyTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importers/ClockifyTimeEntriesImporterTest.php index 30c3758b..3fb035dc 100644 --- a/tests/Unit/Service/Import/Importers/ClockifyTimeEntriesImporterTest.php +++ b/tests/Unit/Service/Import/Importers/ClockifyTimeEntriesImporterTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Tests\Unit\Service\Import\Importers; use App\Models\Organization; -use App\Models\TimeEntry; use App\Service\Import\Importers\ClockifyTimeEntriesImporter; use App\Service\Import\Importers\DefaultImporter; use App\Service\Import\Importers\ImportException; @@ -30,27 +29,17 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract // Act $importer->importData($data, $timezone); + $report = $importer->getReport(); // 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 09:23:52', $timeEntry1->start->toDateTimeString()); - $this->assertSame('2024-03-04 09:23:52', $timeEntry1->end->toDateTimeString()); - $this->assertFalse($timeEntry1->billable); - $this->assertTrue($timeEntry1->is_imported); - $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 09:23:00', $timeEntry2->start->toDateTimeString()); - $this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString()); - $this->assertTrue($timeEntry2->billable); - $this->assertTrue($timeEntry2->is_imported); - $this->assertSame([], $timeEntry2->tags); + $this->checkTimeEntries($testScenario); + $this->assertSame(2, $report->timeEntriesCreated); + $this->assertSame(2, $report->tagsCreated); + $this->assertSame(1, $report->tasksCreated); + $this->assertSame(1, $report->usersCreated); + $this->assertSame(2, $report->projectsCreated); + $this->assertSame(1, $report->clientsCreated); } public function test_import_of_test_file_twice_succeeds(): void @@ -67,26 +56,16 @@ class ClockifyTimeEntriesImporterTest extends ImporterTestAbstract // Act $importer->importData($data, $timezone); + $report = $importer->getReport(); // 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 09:23:52', $timeEntry1->start->toDateTimeString()); - $this->assertSame('2024-03-04 09:23:52', $timeEntry1->end->toDateTimeString()); - $this->assertFalse($timeEntry1->billable); - $this->assertTrue($timeEntry1->is_imported); - $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 09:23:00', $timeEntry2->start->toDateTimeString()); - $this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString()); - $this->assertTrue($timeEntry2->billable); - $this->assertTrue($timeEntry2->is_imported); - $this->assertSame([], $timeEntry2->tags); + $this->checkTimeEntries($testScenario, true); + $this->assertSame(2, $report->timeEntriesCreated); + $this->assertSame(0, $report->tagsCreated); + $this->assertSame(0, $report->tasksCreated); + $this->assertSame(0, $report->usersCreated); + $this->assertSame(0, $report->projectsCreated); + $this->assertSame(0, $report->clientsCreated); } } diff --git a/tests/Unit/Service/Import/Importers/ImporterProviderTest.php b/tests/Unit/Service/Import/Importers/ImporterProviderTest.php index ba0ec646..ad620880 100644 --- a/tests/Unit/Service/Import/Importers/ImporterProviderTest.php +++ b/tests/Unit/Service/Import/Importers/ImporterProviderTest.php @@ -41,6 +41,7 @@ class ImporterProviderTest extends TestCase 'toggl_data_importer', 'clockify_time_entries', 'clockify_projects', + 'solidtime', ], $keys); } } diff --git a/tests/Unit/Service/Import/Importers/ImporterTestAbstract.php b/tests/Unit/Service/Import/Importers/ImporterTestAbstract.php index bc76d9bc..e30dc292 100644 --- a/tests/Unit/Service/Import/Importers/ImporterTestAbstract.php +++ b/tests/Unit/Service/Import/Importers/ImporterTestAbstract.php @@ -10,9 +10,14 @@ use App\Models\Member; use App\Models\Project; use App\Models\Tag; use App\Models\Task; +use App\Models\TimeEntry; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; +use Spatie\TemporaryDirectory\TemporaryDirectory; use Tests\TestCase; +use ZipArchive; class ImporterTestAbstract extends TestCase { @@ -36,29 +41,82 @@ class ImporterTestAbstract extends TestCase $this->assertNotNull($member1); $this->assertSame(Role::Placeholder->value, $member1->role); $clients = Client::all(); - $this->assertCount(1, $clients); - $client1 = $clients->firstWhere('name', 'Big Company'); - $this->assertNotNull($client1); + if ($detailed) { + $this->assertCount(2, $clients); + $client1 = $clients->firstWhere('name', 'Big Company'); + $this->assertNotNull($client1); + $this->assertNull($client1->archived_at); + $client2 = $clients->firstWhere('name', 'Other Company (Archived)'); + $this->assertNotNull($client2); + $this->assertNotNull($client2->archived_at); + } else { + $this->assertCount(1, $clients); + $client1 = $clients->firstWhere('name', 'Big Company'); + $this->assertNotNull($client1); + $this->assertNull($client1->archived_at); + } $projects = Project::with(['members'])->get(); - $this->assertCount(2, $projects); + if ($detailed) { + $this->assertCount(3, $projects); + } else { + $this->assertCount(2, $projects); + } + /** @var Project|null $project1 */ $project1 = $projects->firstWhere('name', 'Project without Client'); $this->assertNotNull($project1); $this->assertNull($project1->client_id); + /** @var Project|null $project2 */ $project2 = $projects->firstWhere('name', 'Project for Big Company'); $this->assertNotNull($project2); $this->assertSame($client1->getKey(), $project2->client_id); + $project3 = null; if ($detailed) { + /** @var Project|null $project3 */ + $project3 = $projects->firstWhere('name', 'Project (Archived)'); + $this->assertNotNull($project3); + // Project without Client + $this->assertSame(false, $project1->is_billable); + $this->assertSame(false, $project1->is_public); + $this->assertSame('#ef5350', $project1->color); + $this->assertSame(null, $project1->billable_rate); + // Project for Big Company + $this->assertSame(true, $project2->is_billable); + $this->assertSame(false, $project2->is_public); + $this->assertSame('#ec407a', $project2->color); $this->assertSame(10001, $project2->billable_rate); + // Project (Archived) + $this->assertSame(true, $project3->is_billable); + $this->assertSame(true, $project3->is_public); + $this->assertSame('#6a407f', $project3->color); + $this->assertSame(null, $project3->billable_rate); + $this->assertSame($client2->getKey(), $project3->client_id); + // Project members $projectMembersOfProject2 = $project2->members; $this->assertCount(1, $projectMembersOfProject2); $this->assertSame($user1->getKey(), $projectMembersOfProject2->first()->user_id); $this->assertSame(10002, $projectMembersOfProject2->first()->billable_rate); + } else { + // Project without Client + $this->assertSame(false, $project1->is_public); + // Project for Big Company + $this->assertSame(false, $project2->is_public); } $tasks = Task::all(); - $this->assertCount(1, $tasks); + if ($detailed) { + $this->assertCount(2, $tasks); + } else { + $this->assertCount(1, $tasks); + } $task1 = $tasks->firstWhere('name', 'Task 1'); $this->assertNotNull($task1); + $this->assertNull($task1->done_at); $this->assertSame($project2->getKey(), $task1->project_id); + if ($detailed) { + $task2 = $tasks->firstWhere('name', 'Task 2'); + $this->assertNotNull($task1); + $this->assertSame($project2->getKey(), $task2->project_id); + $this->assertNotNull($task2->done_at); + } $tags = Tag::all(); $this->assertCount(2, $tags); $tag1 = $tags->firstWhere('name', 'Development'); @@ -69,6 +127,7 @@ class ImporterTestAbstract extends TestCase 'user1' => $user1, 'project1' => $project1, 'project2' => $project2, + 'project3' => $project3, 'tag1' => $tag1, 'tag2' => $tag2, ]; @@ -111,4 +170,47 @@ class ImporterTestAbstract extends TestCase 'task1' => $task1, ]; } + + /** + * @param object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} $testScenario + */ + protected function checkTimeEntries(object $testScenario, bool $secondRun = false): void + { + $timeEntries = TimeEntry::all(); + if ($secondRun) { + $this->assertCount(4, $timeEntries); + } else { + $this->assertCount(2, $timeEntries); + } + $timeEntry1 = $timeEntries->firstWhere('description', ''); + $this->assertNotNull($timeEntry1); + $this->assertSame('', $timeEntry1->description); + $this->assertSame('2024-03-04 09:23:52', $timeEntry1->start->toDateTimeString()); + $this->assertSame('2024-03-04 09:23:52', $timeEntry1->end->toDateTimeString()); + $this->assertFalse($timeEntry1->billable); + $this->assertTrue($timeEntry1->is_imported); + $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 09:23:00', $timeEntry2->start->toDateTimeString()); + $this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString()); + $this->assertTrue($timeEntry2->billable); + $this->assertTrue($timeEntry2->is_imported); + $this->assertSame([], $timeEntry2->tags); + } + + protected function createTestZip(string $folder): string + { + $tempDir = TemporaryDirectory::make(); + $zipPath = $tempDir->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; + } } diff --git a/tests/Unit/Service/Import/Importers/SolidtimeImporterTest.php b/tests/Unit/Service/Import/Importers/SolidtimeImporterTest.php new file mode 100644 index 00000000..fd3af7bb --- /dev/null +++ b/tests/Unit/Service/Import/Importers/SolidtimeImporterTest.php @@ -0,0 +1,93 @@ +create(); + $timezone = 'Europe/Vienna'; + $importer = new SolidtimeImporter(); + $importer->init($organization); + + // Act + try { + $importer->importData('not a zip', $timezone); + } catch (Exception $e) { + $this->assertInstanceOf(ImportException::class, $e); + $this->assertSame('Invalid ZIP, error code: 19', $e->getMessage()); + + return; + } + $this->fail(); + } + + public function test_import_of_test_file_succeeds(): void + { + // Arrange + $zipPath = $this->createTestZip('solidtime_import_test_1'); + $timezone = 'Europe/Vienna'; + $organization = Organization::factory()->create(); + $importer = new SolidtimeImporter(); + $importer->init($organization); + $data = file_get_contents($zipPath); + + // Act + $importer->importData($data, $timezone); + $report = $importer->getReport(); + + // Assert + $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(true); + $this->checkTimeEntries($testScenario); + $this->assertSame(2, $report->timeEntriesCreated); + $this->assertSame(2, $report->tagsCreated); + $this->assertSame(2, $report->tasksCreated); + $this->assertSame(1, $report->usersCreated); + $this->assertSame(3, $report->projectsCreated); + $this->assertSame(2, $report->clientsCreated); + } + + public function test_import_of_test_file_twice_succeeds(): void + { + // Arrange + $zipPath = $this->createTestZip('solidtime_import_test_1'); + $timezone = 'Europe/Vienna'; + $organization = Organization::factory()->create(); + $importer = new SolidtimeImporter(); + $importer->init($organization); + $data = file_get_contents($zipPath); + $importer->importData($data, $timezone); + $importer = new SolidtimeImporter(); + $importer->init($organization); + + // Act + $importer->importData($data, $timezone); + $report = $importer->getReport(); + + // Assert + $testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(true); + $this->checkTimeEntries($testScenario, true); + $this->assertSame(2, $report->timeEntriesCreated); + $this->assertSame(0, $report->tagsCreated); + $this->assertSame(0, $report->tasksCreated); + $this->assertSame(0, $report->usersCreated); + $this->assertSame(0, $report->projectsCreated); + $this->assertSame(0, $report->clientsCreated); + } +} diff --git a/tests/Unit/Service/Import/Importers/TogglDataImporterTest.php b/tests/Unit/Service/Import/Importers/TogglDataImporterTest.php index eea87225..90d89f90 100644 --- a/tests/Unit/Service/Import/Importers/TogglDataImporterTest.php +++ b/tests/Unit/Service/Import/Importers/TogglDataImporterTest.php @@ -9,12 +9,8 @@ use App\Service\Import\Importers\DefaultImporter; use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\TogglDataImporter; use Exception; -use Illuminate\Support\Facades\Storage; -use Illuminate\Support\Str; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; -use Spatie\TemporaryDirectory\TemporaryDirectory; -use ZipArchive; #[CoversClass(TogglDataImporter::class)] #[CoversClass(ImportException::class)] @@ -22,20 +18,6 @@ use ZipArchive; #[UsesClass(TogglDataImporter::class)] class TogglDataImporterTest extends ImporterTestAbstract { - private function createTestZip(string $folder): string - { - $tempDir = TemporaryDirectory::make(); - $zipPath = $tempDir->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_throws_exception_if_data_is_not_zip(): void { // Arrange @@ -74,10 +56,10 @@ class TogglDataImporterTest extends ImporterTestAbstract $this->checkTestScenarioAfterImportExcludingTimeEntries(true); $this->assertSame(0, $report->timeEntriesCreated); $this->assertSame(2, $report->tagsCreated); - $this->assertSame(1, $report->tasksCreated); + $this->assertSame(2, $report->tasksCreated); $this->assertSame(1, $report->usersCreated); - $this->assertSame(2, $report->projectsCreated); - $this->assertSame(1, $report->clientsCreated); + $this->assertSame(3, $report->projectsCreated); + $this->assertSame(2, $report->clientsCreated); } public function test_import_of_test_file_twice_succeeds(): void diff --git a/tests/Unit/Service/Import/Importers/TogglTimeEntriesImporterTest.php b/tests/Unit/Service/Import/Importers/TogglTimeEntriesImporterTest.php index 094876a1..1622cd04 100644 --- a/tests/Unit/Service/Import/Importers/TogglTimeEntriesImporterTest.php +++ b/tests/Unit/Service/Import/Importers/TogglTimeEntriesImporterTest.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Tests\Unit\Service\Import\Importers; use App\Models\Organization; -use App\Models\TimeEntry; use App\Service\Import\Importers\DefaultImporter; use App\Service\Import\Importers\ImportException; use App\Service\Import\Importers\TogglTimeEntriesImporter; @@ -34,22 +33,7 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract // 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 09:23:52', $timeEntry1->start->toDateTimeString()); - $this->assertSame('2024-03-04 09: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 09:23:00', $timeEntry2->start->toDateTimeString()); - $this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString()); - $this->assertTrue($timeEntry2->billable); - $this->assertSame([], $timeEntry2->tags); + $this->checkTimeEntries($testScenario); $this->assertSame(2, $report->timeEntriesCreated); $this->assertSame(2, $report->tagsCreated); $this->assertSame(1, $report->tasksCreated); @@ -76,22 +60,7 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract // 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 09:23:52', $timeEntry1->start->toDateTimeString()); - $this->assertSame('2024-03-04 09: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 09:23:00', $timeEntry2->start->toDateTimeString()); - $this->assertSame('2024-03-04 10:23:01', $timeEntry2->end->toDateTimeString()); - $this->assertTrue($timeEntry2->billable); - $this->assertSame([], $timeEntry2->tags); + $this->checkTimeEntries($testScenario, true); $this->assertSame(2, $report->timeEntriesCreated); $this->assertSame(0, $report->tagsCreated); $this->assertSame(0, $report->tasksCreated);