Added export endpoint and solidtime import; Enhanced toggl import

This commit is contained in:
Constantin Graf
2024-08-22 17:50:04 +02:00
committed by Constantin Graf
parent 056a63e193
commit ee77de04ef
43 changed files with 1304 additions and 110 deletions

View File

@@ -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) {

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Models\Organization;
use App\Service\Export\ExportException;
use App\Service\Export\ExportService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
class ExportController extends Controller
{
/**
* Export data of an organization
*
* @throws AuthorizationException
* @throws ExportException
*
* @operationId exportOrganization
*/
public function export(Organization $organization, ExportService $exportService): JsonResponse
{
$this->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);
}
}

View File

@@ -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
*

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -42,6 +42,7 @@ class Task extends Model
*/
protected $casts = [
'name' => 'string',
'done_at' => 'datetime',
];
/**

View File

@@ -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

View File

@@ -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',

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace App\Service\Export;
use App\Exceptions\Api\ApiException;
class ExportException extends ApiException
{
public const string KEY = 'export';
}

View File

@@ -0,0 +1,362 @@
<?php
declare(strict_types=1);
namespace App\Service\Export;
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;
use App\Models\Task;
use App\Models\TimeEntry;
use Exception;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\File;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use League\Csv\CannotInsertRecord;
use League\Csv\Exception as LeagueCsvException;
use League\Csv\UnavailableStream;
use League\Csv\Writer;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ZipArchive;
class ExportService
{
public const string VERSION = '1.0';
/**
* @throws ExportException
*/
public function export(Organization $organization): string
{
$exportId = Str::uuid();
$timeStamp = Carbon::now();
$temporaryDirectory = TemporaryDirectory::make();
Log::debug('Start exporting organization', [
'organization_id' => $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();
}
}
}

View File

@@ -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<OrganizationInvitation>
*/
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);

View File

@@ -14,6 +14,7 @@ class ImporterProvider
'toggl_data_importer' => TogglDataImporter::class,
'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
'clockify_projects' => ClockifyProjectsImporter::class,
'solidtime' => SolidtimeImporter::class,
];
/**

View File

@@ -0,0 +1,335 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Reader;
use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ZipArchive;
class SolidtimeImporter extends DefaultImporter
{
/**
* @var array<string>
*/
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<string>
*/
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');
}
}

View File

@@ -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');