Merge branch 'feature/import' into feature/add_frontend_dashboard

# Conflicts:
#	app/Http/Controllers/Api/V1/TimeEntryController.php
#	app/Providers/JetstreamServiceProvider.php
This commit is contained in:
Constantin Graf
2024-03-12 17:55:49 +01:00
96 changed files with 3444 additions and 109 deletions

View File

@@ -6,9 +6,12 @@ namespace App\Actions\Fortify;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Jetstream\Jetstream;
@@ -20,12 +23,27 @@ class CreateNewUser implements CreatesNewUsers
* Create a newly registered user.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'name' => [
'required',
'string',
'max:255',
],
'email' => [
'required',
'string',
'email',
'max:255',
new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder {
/** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false);
}),
],
'password' => $this->passwordRules(),
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
])->validate();

View File

@@ -8,8 +8,11 @@ use App\Models\Organization;
use App\Models\User;
use Closure;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Laravel\Jetstream\Contracts\AddsTeamMembers;
use Laravel\Jetstream\Events\AddingTeamMember;
use Laravel\Jetstream\Events\TeamMemberAdded;
@@ -21,21 +24,24 @@ class AddOrganizationMember implements AddsTeamMembers
/**
* Add a new team member to the given team.
*/
public function add(User $user, Organization $organization, string $email, ?string $role = null): void
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
{
Gate::forUser($user)->authorize('addTeamMember', $organization);
Gate::forUser($owner)->authorize('addTeamMember', $organization);
$this->validate($organization, $email, $role);
$newTeamMember = Jetstream::findUserByEmailOrFail($email);
$newOrganizationMember = User::query()
->where('email', $email)
->where('is_placeholder', '=', false)
->firstOrFail();
AddingTeamMember::dispatch($organization, $newTeamMember);
AddingTeamMember::dispatch($organization, $newOrganizationMember);
$organization->users()->attach(
$newTeamMember, ['role' => $role]
$newOrganizationMember, ['role' => $role]
);
TeamMemberAdded::dispatch($organization, $newTeamMember);
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
}
/**
@@ -46,9 +52,7 @@ class AddOrganizationMember implements AddsTeamMembers
Validator::make([
'email' => $email,
'role' => $role,
], $this->rules(), [
'email.exists' => __('We were unable to find a registered user with this email address.'),
])->after(
], $this->rules())->after(
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
)->validateWithBag('addTeamMember');
}
@@ -56,12 +60,18 @@ class AddOrganizationMember implements AddsTeamMembers
/**
* Get the validation rules for adding a team member.
*
* @return array<string, array<Rule|string>>
* @return array<string, array<ValidationRule|Rule|string>>
*/
protected function rules(): array
{
return array_filter([
'email' => ['required', 'email', 'exists:users'],
'email' => [
'required',
'email',
(new ExistsEloquent(User::class, 'email', function (Builder $builder) {
return $builder->where('is_placeholder', '=', false);
}))->withMessage(__('We were unable to find a registered user with this email address.')),
],
'role' => Jetstream::hasRoles()
? ['required', 'string', new Role]
: null,
@@ -75,7 +85,7 @@ class AddOrganizationMember implements AddsTeamMembers
{
return function ($validator) use ($team, $email) {
$validator->errors()->addIf(
$team->hasUserWithEmail($email),
$team->hasRealUserWithEmail($email),
'email',
__('This user already belongs to the team.')
);

View File

@@ -34,6 +34,7 @@ class InviteOrganizationMember implements InvitesTeamMembers
InvitingTeamMember::dispatch($organization, $email, $role);
/** @var OrganizationInvitation $invitation */
$invitation = $organization->teamInvitations()->create([
'email' => $email,
'role' => $role,
@@ -50,9 +51,7 @@ class InviteOrganizationMember implements InvitesTeamMembers
Validator::make([
'email' => $email,
'role' => $role,
], $this->rules($organization), [
'email.unique' => __('This user has already been invited to the team.'),
])->after(
], $this->rules($organization))->after(
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
)->validateWithBag('addTeamMember');
}
@@ -68,10 +67,10 @@ class InviteOrganizationMember implements InvitesTeamMembers
'email' => [
'required',
'email',
new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) {
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) {
/** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($organization, 'organization');
}),
}))->withMessage(__('This user has already been invited to the team.')),
],
'role' => Jetstream::hasRoles()
? ['required', 'string', new Role]
@@ -86,7 +85,7 @@ class InviteOrganizationMember implements InvitesTeamMembers
{
return function ($validator) use ($organization, $email) {
$validator->errors()->addIf(
$organization->hasUserWithEmail($email),
$organization->hasRealUserWithEmail($email),
'email',
__('This user already belongs to the team.')
);

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use LogicException;
abstract class ApiException extends Exception
{
public const string KEY = 'api_exception';
/**
* Render the exception into an HTTP response.
*/
public function render(Request $request): JsonResponse
{
return response()
->json([
'error' => true,
'key' => $this->getKey(),
'message' => $this->getTranslatedMessage(),
], 400);
}
/**
* Get the key for the exception.
*/
public function getKey(): string
{
$key = static::KEY;
if ($key === ApiException::KEY) {
throw new LogicException('API exceptions need the KEY constant defined.');
}
return $key;
}
/**
* Get the translated message for the exception.
*/
public function getTranslatedMessage(): string
{
return __('exceptions.api.'.$this->getKey());
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class TimeEntryStillRunningApiException extends ApiException
{
public const string KEY = 'time_entry_still_running';
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Exceptions\Api;
class UserNotPlaceholderApiException extends ApiException
{
public const string KEY = 'user_not_placeholder';
}

View File

@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ApiException extends Exception
{
/**
* Render the exception into an HTTP response.
*/
public function render(Request $request): JsonResponse
{
return response()
->json([
'error' => true,
'message' => $this->getMessage(),
], 400);
}
}

View File

@@ -1,9 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
class TimeEntryStillRunning extends ApiException
{
}

View File

@@ -6,6 +6,8 @@ namespace App\Filament\Resources;
use App\Filament\Resources\ClientResource\Pages;
use App\Models\Client;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
@@ -26,7 +28,14 @@ class ClientResource extends Resource
{
return $form
->schema([
//
TextInput::make('name')
->label('Name')
->required(),
Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->label('Organization')
->searchable(['name'])
->required(),
]);
}

View File

@@ -5,12 +5,21 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
use App\Models\Organization;
use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto;
use App\Service\Import\ImportService;
use Filament\Forms;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Storage;
class OrganizationResource extends Resource
{
@@ -60,6 +69,55 @@ class OrganizationResource extends Resource
])
->actions([
Tables\Actions\EditAction::make(),
Action::make('Import')
->icon('heroicon-o-inbox-arrow-down')
->action(function (Organization $record, array $data) {
try {
/** @var ReportDto $report */
$report = app(ImportService::class)->import(
$record,
$data['type'],
Storage::disk(config('filament.default_filesystem_disk'))->get($data['file'])
);
Notification::make()
->title('Import successful')
->success()
->body(
'Imported time entries: '.$report->timeEntriesCreated.'<br>'.
'Imported clients: '.$report->clientsCreated.'<br>'.
'Imported projects: '.$report->projectsCreated.'<br>'.
'Imported tasks: '.$report->tasksCreated.'<br>'.
'Imported tags: '.$report->tagsCreated.'<br>'.
'Imported users: '.$report->usersCreated
)
->persistent()
->send();
} catch (ImportException $exception) {
report($exception);
Notification::make()
->title('Import failed, changes rolled back')
->danger()
->body('Message: '.$exception->getMessage())
->persistent()
->send();
}
})
->tooltip(fn (Organization $record): string => 'Import into '.$record->name)
->form([
Forms\Components\FileUpload::make('file')
->label('File')
->required(),
Select::make('type')
->required()
->options(function (): array {
$select = [];
foreach (app(ImporterProvider::class)->getImporterKeys() as $key) {
$select[$key] = $key;
}
return $select;
}),
]),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
@@ -71,7 +129,7 @@ class OrganizationResource extends Resource
public static function getRelations(): array
{
return [
//
UsersRelationManager::class,
];
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class UsersRelationManager extends RelationManager
{
protected static string $relationship = 'users';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('name'),
Tables\Columns\TextColumn::make('role'),
])
->filters([
//
])
->headerActions([
Tables\Actions\CreateAction::make(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -6,9 +6,12 @@ namespace App\Filament\Resources;
use App\Filament\Resources\TaskResource\Pages;
use App\Models\Task;
use Filament\Forms;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class TaskResource extends Resource
@@ -25,7 +28,18 @@ class TaskResource extends Resource
{
return $form
->schema([
//
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Select::make('project_id')
->relationship(name: 'project', titleAttribute: 'name')
->searchable(['name'])
->required(),
Select::make('organization_id')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->required(),
]);
}
@@ -46,7 +60,9 @@ class TaskResource extends Resource
->sortable(),
])
->filters([
//
SelectFilter::make('organization')
->relationship('organization', 'name')
->searchable(),
])
->defaultSort('created_at', 'desc')
->actions([

View File

@@ -14,6 +14,7 @@ use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class TimeEntryResource extends Resource
@@ -67,6 +68,7 @@ class TimeEntryResource extends Resource
return $table
->columns([
TextColumn::make('description')
->searchable()
->label('Description'),
TextColumn::make('user.email')
->label('User'),
@@ -89,7 +91,9 @@ class TimeEntryResource extends Resource
->sortable(),
])
->filters([
//
SelectFilter::make('organization')
->relationship('organization', 'name')
->searchable(),
])
->defaultSort('created_at', 'desc')
->actions([

View File

@@ -5,12 +5,16 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource\RelationManagers\OrganizationsRelationManager;
use App\Filament\Resources\UserResource\RelationManagers\OwnedOrganizationsRelationManager;
use App\Models\User;
use Filament\Forms;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Facades\Hash;
class UserResource extends Resource
{
@@ -41,10 +45,11 @@ class UserResource extends Resource
->label('Email')
->required()
->maxLength(255),
Forms\Components\TextInput::make('password')
->label('Password')
->required()
TextInput::make('password')
->password()
->dehydrateStateUsing(fn ($state) => Hash::make($state))
->dehydrated(fn ($state) => filled($state))
->required(fn (string $context): bool => $context === 'create')
->maxLength(255),
]);
}
@@ -77,7 +82,8 @@ class UserResource extends Resource
public static function getRelations(): array
{
return [
//
OwnedOrganizationsRelationManager::class,
OrganizationsRelationManager::class,
];
}

View File

@@ -5,9 +5,23 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use App\Models\Organization;
use App\Models\User;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected function afterCreate(): void
{
/** @var User $user */
$user = $this->record;
$user->ownedTeams()->save(Organization::forceCreate([
'user_id' => $user->id,
'name' => explode(' ', $user->name, 2)[0]."'s Organization",
'personal_team' => true,
]));
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\UserResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class OrganizationsRelationManager extends RelationManager
{
protected static string $relationship = 'organizations';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('name'),
])
->filters([
//
])
->headerActions([
Tables\Actions\CreateAction::make(),
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\UserResource\RelationManagers;
use Filament\Forms;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Table;
class OwnedOrganizationsRelationManager extends RelationManager
{
protected static ?string $title = 'Owned Organizations';
protected static string $relationship = 'ownedTeams';
public function form(Form $form): Form
{
return $form
->schema([
Forms\Components\TextInput::make('name')
->required()
->maxLength(255),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('name'),
])
->filters([
//
])
->headerActions([
])
->actions([
Tables\Actions\EditAction::make(),
])
->bulkActions([
]);
}
}

View File

@@ -0,0 +1,63 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Requests\V1\Import\ImportRequest;
use App\Models\Organization;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\ImportService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
class ImportController extends Controller
{
/**
* Import data into the organization
*
* @throws AuthorizationException
*/
public function import(Organization $organization, ImportRequest $request, ImportService $importService): JsonResponse
{
$this->checkPermission($organization, 'import');
try {
$report = $importService->import(
$organization,
$request->input('type'),
$request->input('data')
);
return new JsonResponse([
/** @var array{
* clients: array{
* created: int,
* },
* projects: array{
* created: int,
* },
* tasks: array{
* created: int,
* },
* time-entries: array{
* created: int,
* },
* tags: array{
* created: int,
* },
* users: array{
* created: int,
* }
* } $report Import report */
'report' => $report->toArray(),
], 200);
} catch (ImportException $exception) {
report($exception);
return new JsonResponse([
'message' => $exception->getMessage(),
], 400);
}
}
}

View File

@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\TimeEntryStillRunning;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
@@ -104,7 +104,7 @@ class TimeEntryController extends Controller
/**
* Create time entry
*
* @throws AuthorizationException|TimeEntryStillRunning
* @throws AuthorizationException|TimeEntryStillRunningApiException
*
* @operationId createTimeEntry
*/
@@ -118,8 +118,7 @@ class TimeEntryController extends Controller
if ($request->get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) {
// TODO: API documentation
// TODO: Create concept for api exceptions
throw new TimeEntryStillRunning('User already has an active time entry');
throw new TimeEntryStillRunningApiException();
}
$timeEntry = new TimeEntry();

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\User\UserIndexRequest;
use App\Http\Resources\V1\User\UserCollection;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
class UserController extends Controller
{
/**
* List all users in an organization
*
* @throws AuthorizationException
*/
public function index(Organization $organization, UserIndexRequest $request): UserCollection
{
$this->checkPermission($organization, 'users:view');
$users = $organization->users()
->paginate();
return UserCollection::make($users);
}
/**
* Invite a placeholder user to become a real user in the organization
*
* @throws AuthorizationException|UserNotPlaceholderApiException
*/
public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse
{
$this->checkPermission($organization, 'users:invite-placeholder');
if (! $user->is_placeholder) {
throw new UserNotPlaceholderApiException();
}
app(InvitesTeamMembers::class)->invite(
$request->user(),
$organization,
$user->email,
'employee'
);
return response()->json($user);
}
}

View File

@@ -13,7 +13,7 @@ class ValidateSignature extends Middleware
*
* @var array<int, string>
*/
protected $except = [
protected array $except = [
// 'fbclid',
// 'utm_campaign',
// 'utm_content',

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\Import;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
class ImportRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
'type' => [
'required',
'string',
],
'data' => [
'required',
'string',
],
];
}
}

View File

@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Project;
use App\Models\Client;
use App\Models\Organization;
use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
@@ -25,6 +26,7 @@ class ProjectStoreRequest extends FormRequest
{
return [
'name' => [
// TODO: unique
'required',
'string',
'min:1',
@@ -34,6 +36,7 @@ class ProjectStoreRequest extends FormRequest
'required',
'string',
'max:255',
new ColorRule(),
],
'client_id' => [
'nullable',

View File

@@ -6,6 +6,7 @@ namespace App\Http\Requests\V1\Project;
use App\Models\Client;
use App\Models\Organization;
use App\Rules\ColorRule;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Http\FormRequest;
@@ -25,6 +26,7 @@ class ProjectUpdateRequest extends FormRequest
{
return [
'name' => [
// TODO: unique
'required',
'string',
'max:255',
@@ -33,6 +35,7 @@ class ProjectUpdateRequest extends FormRequest
'required',
'string',
'max:255',
new ColorRule(),
],
'client_id' => [
'nullable',

View File

@@ -18,6 +18,7 @@ class TagStoreRequest extends FormRequest
{
return [
'name' => [
// TODO: unique
'required',
'string',
'min:1',

View File

@@ -18,6 +18,7 @@ class TagUpdateRequest extends FormRequest
{
return [
'name' => [
// TODO: unique
'required',
'string',
'min:1',

View File

@@ -30,10 +30,7 @@ class TimeEntryIndexRequest extends FormRequest
'uuid',
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
/** @var Builder<User> $builder */
return $builder->whereHas('organizations', function (Builder $builder) {
/** @var Builder<Organization> $builder */
return $builder->whereKey($this->organization->getKey());
});
return $builder->belongsToOrganization($this->organization);
}),
],
// Filter only time entries that have a start date before (not including) the given date (example: 2021-12-31)

View File

@@ -33,10 +33,7 @@ class TimeEntryStoreRequest extends FormRequest
'uuid',
new ExistsEloquent(User::class, null, function (Builder $builder): Builder {
/** @var Builder<User> $builder */
return $builder->whereHas('organizations', function (Builder $builder) {
/** @var Builder<Organization> $builder */
return $builder->whereKey($this->organization->getKey());
});
return $builder->belongsToOrganization($this->organization);
}),
],
// ID of the task that the time entry should belong to
@@ -64,7 +61,7 @@ class TimeEntryStoreRequest extends FormRequest
'description' => [
'nullable',
'string',
'max:255',
'max:500',
],
// List of tag IDs
'tags' => [

View File

@@ -51,7 +51,7 @@ class TimeEntryUpdateRequest extends FormRequest
'description' => [
'nullable',
'string',
'max:255',
'max:500',
],
// List of tag IDs
'tags' => [

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\User;
use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
/**
* @property Organization $organization
*/
class UserIndexRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\User;
use Illuminate\Http\Resources\Json\ResourceCollection;
class UserCollection extends ResourceCollection
{
/**
* The resource that this resource collects.
*
* @var string
*/
public $collects = UserResource::class;
}

View File

@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Http\Resources\V1\User;
use App\Http\Resources\V1\BaseResource;
use App\Models\Membership;
use App\Models\User;
use Illuminate\Http\Request;
/**
* @property User $resource
*/
class UserResource extends BaseResource
{
/**
* Transform the resource into an array.
*
* @return array<string, string|bool|int|null|array<string>>
*/
public function toArray(Request $request): array
{
/** @var Membership $membership */
$membership = $this->resource->getRelationValue('membership');
return [
/** @var string $id ID */
'id' => $this->resource->id,
/** @var string $name Name */
'name' => $this->resource->name,
/** @var string $email Email */
'email' => $this->resource->email,
/** @var string $role Role */
'role' => $membership->role,
/** @var bool $is_placeholder Placeholder user for imports, user might not really exist and does not know about this placeholder membership */
'is_placeholder' => $this->resource->is_placeholder,
];
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Listeners;
use App\Models\User;
use App\Service\UserService;
use Laravel\Jetstream\Events\TeamMemberAdded;
class RemovePlaceholder
{
/**
* Handle the event.
*/
public function handle(TeamMemberAdded $event): void
{
/** @var UserService $userService */
$userService = app(UserService::class);
$placeholders = User::query()
->where('is_placeholder', '=', true)
->where('email', '=', $event->user->email)
->belongsToOrganization($event->team)
->get();
foreach ($placeholders as $placeholder) {
$userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholder, $event->user);
}
}
}

View File

@@ -5,12 +5,15 @@ declare(strict_types=1);
namespace App\Models;
use Database\Factories\OrganizationFactory;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Laravel\Jetstream\Events\TeamCreated;
use Laravel\Jetstream\Events\TeamDeleted;
use Laravel\Jetstream\Events\TeamUpdated;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Team as JetstreamTeam;
/**
@@ -18,6 +21,8 @@ use Laravel\Jetstream\Team as JetstreamTeam;
* @property string $name
* @property bool $personal_team
* @property User $owner
* @property Collection<User> $users
* @property Collection<string, User> $realUsers
*
* @method HasMany<OrganizationInvitation> teamInvitations()
* @method static OrganizationFactory factory()
@@ -57,4 +62,43 @@ class Organization extends JetstreamTeam
'updated' => TeamUpdated::class,
'deleted' => TeamDeleted::class,
];
/**
* Get all the non-placeholder users of the organization including its owner.
*
* @return Collection<string, User>
*/
public function allRealUsers(): Collection
{
return $this->realUsers->merge([$this->owner]);
}
public function hasRealUserWithEmail(string $email): bool
{
return $this->allRealUsers()->contains(function (User $user) use ($email): bool {
return $user->email === $email;
});
}
/**
* Get all the users that belong to the team.
*
* @return BelongsToMany<User>
*/
public function users(): BelongsToMany
{
return $this->belongsToMany(Jetstream::userModel(), Jetstream::membershipModel())
->withPivot('role')
->withTimestamps()
->as('membership');
}
/**
* @return BelongsToMany<User>
*/
public function realUsers(): BelongsToMany
{
return $this->users()
->where('is_placeholder', false);
}
}

View File

@@ -6,6 +6,8 @@ namespace App\Models;
use Database\Factories\UserFactory;
use Filament\Panel;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -21,9 +23,16 @@ use Laravel\Passport\HasApiTokens;
* @property string $id
* @property string $name
* @property string $email
* @property string|null $email_verified_at
* @property string|null $password
* @property bool $is_placeholder
* @property Collection<Organization> $organizations
* @property Collection<TimeEntry> $timeEntries
*
* @method HasMany<Organization> ownedTeams()
* @method static UserFactory factory()
* @method static Builder<User> query()
* @method Builder<User> belongsToOrganization(Organization $organization)
*/
class User extends Authenticatable
{
@@ -64,8 +73,11 @@ class User extends Authenticatable
* @var array<string, string>
*/
protected $casts = [
'name' => 'string',
'email' => 'string',
'email_verified_at' => 'datetime',
'is_admin' => 'boolean',
'is_placeholder' => 'boolean',
];
/**
@@ -94,4 +106,27 @@ class User extends Authenticatable
->withTimestamps()
->as('membership');
}
/**
* @return HasMany<TimeEntry>
*/
public function timeEntries(): HasMany
{
return $this->hasMany(TimeEntry::class);
}
/**
* @param Builder<User> $builder
* @return Builder<User>
*/
public function scopeBelongsToOrganization(Builder $builder, Organization $organization): Builder
{
return $builder->where(function (Builder $builder) use ($organization): Builder {
return $builder->whereHas('organizations', function (Builder $query) use ($organization): void {
$query->whereKey($organization->getKey());
})->orWhereHas('ownedTeams', function (Builder $query) use ($organization): void {
$query->whereKey($organization->getKey());
});
});
}
}

View File

@@ -46,6 +46,7 @@ class AppServiceProvider extends ServiceProvider
Model::preventLazyLoading(! $this->app->isProduction());
Model::preventSilentlyDiscardingAttributes(! $this->app->isProduction());
Model::preventAccessingMissingAttributes(! $this->app->isProduction());
Relation::enforceMorphMap([
'membership' => Membership::class,
'organization' => Organization::class,
@@ -74,6 +75,7 @@ class AppServiceProvider extends ServiceProvider
if (config('app.force_https', false) || App::isProduction()) {
URL::forceScheme('https');
request()->server->set('HTTPS', request()->header('X-Forwarded-Proto', 'https') === 'https' ? 'on' : 'off');
}
}
}

View File

@@ -4,10 +4,11 @@ declare(strict_types=1);
namespace App\Providers;
use App\Listeners\RemovePlaceholder;
use Illuminate\Auth\Events\Registered;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Event;
use Laravel\Jetstream\Events\TeamMemberAdded;
class EventServiceProvider extends ServiceProvider
{
@@ -20,6 +21,9 @@ class EventServiceProvider extends ServiceProvider
Registered::class => [
SendEmailVerificationNotification::class,
],
TeamMemberAdded::class => [
RemovePlaceholder::class,
],
];
/**

View File

@@ -74,6 +74,9 @@ class JetstreamServiceProvider extends ServiceProvider
'clients:delete',
'organizations:view',
'organizations:update',
'import',
'users:invite-placeholder',
'users:view',
])->description('Administrator users can perform any action.');
Jetstream::role('manager', 'Manager', [
@@ -94,6 +97,7 @@ class JetstreamServiceProvider extends ServiceProvider
'tags:update',
'tags:delete',
'organizations:view',
'users:view',
])->description('Managers have the ability to read, create, and update their own time entries as well as those of their team.');
Jetstream::role('employee', 'Employee', [
@@ -105,5 +109,8 @@ class JetstreamServiceProvider extends ServiceProvider
'time-entries:delete:own',
'organizations:view',
])->description('Employees have the ability to read, create, and update their own time entries.');
Jetstream::role('placeholder', 'Placeholder', [
])->description('Placeholders are used for importing data. They cannot log in and have no permissions.');
}
}

32
app/Rules/ColorRule.php Normal file
View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Rules;
use App\Service\ColorService;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class ColorRule implements ValidationRule
{
/**
* Run the validation rule.
*
* @param Closure(string): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
$fail(__('validation.string'));
return;
}
if (! app(ColorService::class)->isValid($value)) {
$fail(__('validation.color'));
return;
}
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Service;
class ColorService
{
/**
* @var array<string>
*/
private const array COLORS = [
'#ef5350',
'#ec407a',
'#ab47bc',
'#7e57c2',
'#5c6bc0',
'#42a5f5',
'#29b6f6',
'#26c6da',
'#26a69a',
'#66bb6a',
'#9ccc65',
'#d4e157',
'#ffee58',
'#ffca28',
'#ffa726',
'#ff7043',
'#8d6e63',
'#bdbdbd',
'#78909c',
];
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
public function getRandomColor(): string
{
return self::COLORS[array_rand(self::COLORS)];
}
public function isValid(string $color): bool
{
return preg_match(self::VALID_REGEX, $color) === 1;
}
}

View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
namespace App\Service\Import;
use App\Service\Import\Importers\ImportException;
use Closure;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Validator;
/**
* @template TModel of Model
*/
class ImportDatabaseHelper
{
/**
* @var class-string<TModel>
*/
private string $model;
/**
* @var string[]
*/
private array $identifiers;
/**
* @var array<string, string>|null
*/
private ?array $mapIdentifierToKey = null;
/**
* @var array<string, string>
*/
private array $mapExternalIdentifierToInternalIdentifier = [];
private bool $attachToExisting;
private ?Closure $queryModifier;
private ?Closure $afterCreate;
private int $createdCount;
private array $validate;
/**
* @param class-string<TModel> $model
* @param array<string> $identifiers
*/
public function __construct(string $model, array $identifiers, bool $attachToExisting = false, ?Closure $queryModifier = null, ?Closure $afterCreate = null, array $validate = [])
{
$this->model = $model;
$this->identifiers = $identifiers;
$this->attachToExisting = $attachToExisting;
$this->queryModifier = $queryModifier;
$this->afterCreate = $afterCreate;
$this->createdCount = 0;
$this->validate = $validate;
}
/**
* @return Builder<TModel>
*/
private function getModelInstance(): Builder
{
return (new $this->model)->query();
}
/**
* @param array<string, mixed> $identifierData
* @param array<string, mixed> $createValues
*/
private function createEntity(array $identifierData, array $createValues, ?string $externalIdentifier): string
{
$data = array_merge($identifierData, $createValues);
$validator = Validator::make($data, $this->validate);
if ($validator->fails()) {
throw new ImportException('Invalid data: '.implode(', ', $validator->errors()->all()));
}
$model = new $this->model();
foreach ($data as $key => $value) {
$model->{$key} = $value;
}
$model->save();
if ($this->afterCreate !== null) {
($this->afterCreate)($model);
}
$hash = $this->getHash($identifierData);
$this->mapIdentifierToKey[$hash] = $model->getKey();
$this->createdCount++;
if ($externalIdentifier !== null) {
$this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash;
}
return $model->getKey();
}
/**
* @param array<string, mixed> $data
*/
private function getHash(array $data): string
{
$jsonData = json_encode($data);
if ($jsonData === false) {
throw new \RuntimeException('Failed to encode data to JSON');
}
return md5($jsonData);
}
/**
* @param array<string, mixed> $identifierData
* @param array<string, mixed> $createValues
*
* @throws ImportException
*/
public function getKey(array $identifierData, array $createValues = [], ?string $externalIdentifier = null): string
{
$this->checkMap();
$this->validateIdentifierData($identifierData);
$hash = $this->getHash($identifierData);
if ($this->attachToExisting) {
$key = $this->mapIdentifierToKey[$hash] ?? null;
if ($key !== null) {
if ($externalIdentifier !== null) {
$this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] = $hash;
}
return $key;
}
return $this->createEntity($identifierData, $createValues, $externalIdentifier);
} else {
throw new \RuntimeException('Not implemented');
}
}
/**
* @param array<string, mixed> $identifierData
*
* @throws ImportException
*/
private function validateIdentifierData(array $identifierData): void
{
if (array_keys($identifierData) !== $this->identifiers) {
throw new ImportException('Invalid identifier data');
}
}
public function getKeyByExternalIdentifier(string $externalIdentifier): ?string
{
$hash = $this->mapExternalIdentifierToInternalIdentifier[$externalIdentifier] ?? null;
if ($hash === null) {
return null;
}
return $this->mapIdentifierToKey[$hash] ?? null;
}
/**
* @return array<string>
*/
public function getExternalIds(): array
{
// Note: Otherwise the external ids are integers
return array_map(fn ($value) => (string) $value, array_keys($this->mapExternalIdentifierToInternalIdentifier));
}
private function checkMap(): void
{
if ($this->mapIdentifierToKey === null) {
$select = $this->identifiers;
$select[] = (new $this->model())->getKeyName();
$builder = $this->getModelInstance();
if ($this->queryModifier !== null) {
$builder = ($this->queryModifier)($builder);
}
$databaseEntries = $builder->select($select)
->get();
$this->mapIdentifierToKey = [];
foreach ($databaseEntries as $databaseEntry) {
$identifierData = [];
foreach ($this->identifiers as $identifier) {
$identifierData[$identifier] = $databaseEntry->{$identifier};
}
$hash = $this->getHash($identifierData);
$this->mapIdentifierToKey[$hash] = $databaseEntry->getKey();
}
}
}
public function getCreatedCount(): int
{
return $this->createdCount;
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Service\Import;
use App\Models\Organization;
use App\Service\Import\Importers\ImporterContract;
use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\ReportDto;
use Illuminate\Support\Facades\DB;
class ImportService
{
/**
* @throws ImportException
*/
public function import(Organization $organization, string $importerType, string $data): ReportDto
{
/** @var ImporterContract $importer */
$importer = app(ImporterProvider::class)->getImporter($importerType);
$importer->init($organization);
DB::transaction(function () use (&$importer, &$data) {
$importer->importData($data);
});
return $importer->getReport();
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use Exception;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
class ClockifyProjectsImporter extends DefaultImporter
{
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$clientId = null;
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
]);
}
$projectId = null;
if ($record['Name'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Name'],
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
]);
}
if ($record['Tasks'] !== '') {
$tasks = explode(', ', $record['Tasks']);
foreach ($tasks as $task) {
$this->taskImportHelper->getKey([
'name' => $task,
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
}
}
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
$requiredFields = [
'Name',
'Client',
'Status',
'Visibility',
'Billability',
'Tasks',
];
foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Models\TimeEntry;
use Exception;
use Illuminate\Support\Carbon;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
class ClockifyTimeEntriesImporter extends DefaultImporter
{
/**
* @return array<string>
*
* @throws ImportException
*/
private function getTags(string $tags): array
{
if (trim($tags) === '') {
return [];
}
$tagsParsed = explode(', ', $tags);
$tagIds = [];
foreach ($tagsParsed as $tagParsed) {
$tagId = $this->tagImportHelper->getKey([
'name' => $tagParsed,
'organization_id' => $this->organization->id,
]);
$tagIds[] = $tagId;
}
return $tagIds;
}
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([
'email' => $record['Email'],
], [
'name' => $record['User'],
'is_placeholder' => true,
]);
$clientId = null;
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
]);
}
$projectId = null;
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
]);
}
$taskId = null;
if ($record['Task'] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'],
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
}
$timeEntry = new TimeEntry();
$timeEntry->user_id = $userId;
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->organization_id = $this->organization->id;
if (strlen($record['Description']) > 500) {
throw new ImportException('Time entry description is too long');
}
$timeEntry->description = $record['Description'];
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']);
// Start
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['Start Time']) === 1) {
$start = Carbon::createFromFormat('m/d/Y h:i A', $record['Start Date'].' '.$record['Start Time'], 'UTC');
} else {
$start = Carbon::createFromFormat('m/d/Y H:i:s A', $record['Start Date'].' '.$record['Start Time'], 'UTC');
}
if ($start === false) {
throw new ImportException('Start date ("'.$record['Start Date'].'") or time ("'.$record['Start Time'].'") are invalid');
}
$timeEntry->start = $start;
// End
if (preg_match('/^[0-9]{1,2}:[0-9]{1,2} (AM|PM)$/', $record['End Time']) === 1) {
$end = Carbon::createFromFormat('m/d/Y h:i A', $record['End Date'].' '.$record['End Time'], 'UTC');
} else {
$end = Carbon::createFromFormat('m/d/Y H:i:s A', $record['End Date'].' '.$record['End Time'], 'UTC');
}
if ($end === false) {
throw new ImportException('End date ("'.$record['End Date'].'") or time ("'.$record['End Time'].'") are invalid');
}
$timeEntry->end = $end;
$timeEntry->save();
$this->timeEntriesCreated++;
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
$requiredFields = [
'Project',
'Client',
'Description',
'Task',
'User',
'Group',
'Email',
'Tags',
'Billable',
'Start Date',
'Start Time',
'End Date',
'End Time',
];
foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\User;
use App\Service\ColorService;
use App\Service\Import\ImportDatabaseHelper;
use Illuminate\Database\Eloquent\Builder;
abstract class DefaultImporter implements ImporterContract
{
protected Organization $organization;
/**
* @var ImportDatabaseHelper<User>
*/
protected ImportDatabaseHelper $userImportHelper;
/**
* @var ImportDatabaseHelper<Project>
*/
protected ImportDatabaseHelper $projectImportHelper;
/**
* @var ImportDatabaseHelper<Tag>
*/
protected ImportDatabaseHelper $tagImportHelper;
/**
* @var ImportDatabaseHelper<Client>
*/
protected ImportDatabaseHelper $clientImportHelper;
/**
* @var ImportDatabaseHelper<Task>
*/
protected ImportDatabaseHelper $taskImportHelper;
protected int $timeEntriesCreated;
protected ColorService $colorService;
public function init(Organization $organization): void
{
$this->organization = $organization;
$this->userImportHelper = new ImportDatabaseHelper(User::class, ['email'], true, function (Builder $builder) {
/** @var Builder<User> $builder */
return $builder->belongsToOrganization($this->organization);
}, function (User $user) {
$user->organizations()->attach($this->organization, [
'role' => 'placeholder',
]);
}, validate: [
'name' => [
'required',
'max:255',
],
]);
$this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) {
return $builder->where('organization_id', $this->organization->id);
}, validate: [
'name' => [
'required',
'max:255',
],
]);
$this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) {
return $builder->where('organization_id', $this->organization->id);
}, validate: [
'name' => [
'required',
'max:255',
],
]);
$this->clientImportHelper = new ImportDatabaseHelper(Client::class, ['name', 'organization_id'], true, function (Builder $builder) {
return $builder->where('organization_id', $this->organization->id);
}, validate: [
'name' => [
'required',
'max:255',
],
]);
$this->taskImportHelper = new ImportDatabaseHelper(Task::class, ['name', 'project_id', 'organization_id'], true, function (Builder $builder) {
return $builder->where('organization_id', $this->organization->id);
}, validate: [
'name' => [
'required',
'max:500',
],
]);
$this->timeEntriesCreated = 0;
$this->colorService = app(ColorService::class);
}
#[\Override]
public function getReport(): ReportDto
{
return new ReportDto(
clientsCreated: $this->clientImportHelper->getCreatedCount(),
projectsCreated: $this->projectImportHelper->getCreatedCount(),
tasksCreated: $this->taskImportHelper->getCreatedCount(),
timeEntriesCreated: $this->timeEntriesCreated,
tagsCreated: $this->tagImportHelper->getCreatedCount(),
usersCreated: $this->userImportHelper->getCreatedCount(),
);
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
class ImportException extends \Exception
{
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Models\Organization;
interface ImporterContract
{
public function init(Organization $organization): void;
public function importData(string $data): void;
public function getReport(): ReportDto;
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
class ImporterProvider
{
/**
* @var array<string, class-string<ImporterContract>>
*/
private array $importers = [
'toggl_time_entries' => TogglTimeEntriesImporter::class,
'toggl_data_importer' => TogglDataImporter::class,
'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
'clockify_projects' => ClockifyProjectsImporter::class,
];
/**
* @param class-string<ImporterContract> $importer
*/
public function registerImporter(string $type, string $importer): void
{
$this->importers[$type] = $importer;
}
/**
* @return array<string>
*/
public function getImporterKeys(): array
{
return array_keys($this->importers);
}
public function getImporter(string $type): ImporterContract
{
if (! array_key_exists($type, $this->importers)) {
throw new \InvalidArgumentException('Invalid importer type');
}
return new $this->importers[$type];
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
class ReportDto
{
public int $clientsCreated;
public int $projectsCreated;
public int $tasksCreated;
public int $timeEntriesCreated;
public int $tagsCreated;
public int $usersCreated;
public function __construct(int $clientsCreated, int $projectsCreated, int $tasksCreated, int $timeEntriesCreated, int $tagsCreated, int $usersCreated)
{
$this->clientsCreated = $clientsCreated;
$this->projectsCreated = $projectsCreated;
$this->tasksCreated = $tasksCreated;
$this->timeEntriesCreated = $timeEntriesCreated;
$this->tagsCreated = $tagsCreated;
$this->usersCreated = $usersCreated;
}
/**
* @return array{
* clients: array{
* created: int,
* },
* projects: array{
* created: int,
* },
* tasks: array{
* created: int,
* },
* time-entries: array{
* created: int,
* },
* tags: array{
* created: int,
* },
* users: array{
* created: int,
* }
* }
*/
public function toArray(): array
{
return [
'clients' => [
'created' => $this->clientsCreated,
],
'projects' => [
'created' => $this->projectsCreated,
],
'tasks' => [
'created' => $this->tasksCreated,
],
'time-entries' => [
'created' => $this->timeEntriesCreated,
],
'tags' => [
'created' => $this->tagsCreated,
],
'users' => [
'created' => $this->usersCreated,
],
];
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use Exception;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ZipArchive;
class TogglDataImporter extends DefaultImporter
{
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data): void
{
try {
$zip = new ZipArchive();
$temporaryDirectory = TemporaryDirectory::make();
file_put_contents($temporaryDirectory->path('import.zip'), $data);
$zip->open($temporaryDirectory->path('import.zip'), ZipArchive::RDONLY);
$temporaryDirectory = TemporaryDirectory::make();
$zip->extractTo($temporaryDirectory->path());
$zip->close();
$clientsFileContent = file_get_contents($temporaryDirectory->path('clients.json'));
if ($clientsFileContent === false) {
throw new ImportException('File clients.json missing in ZIP');
}
$clients = json_decode($clientsFileContent);
$projectsFileContent = file_get_contents($temporaryDirectory->path('projects.json'));
if ($projectsFileContent === false) {
throw new ImportException('File projects.json missing in ZIP');
}
$projects = json_decode($projectsFileContent);
$tagsFileContent = file_get_contents($temporaryDirectory->path('tags.json'));
if ($tagsFileContent === false) {
throw new ImportException('File tags.json missing in ZIP');
}
$tags = json_decode($tagsFileContent);
$workspaceUsersFileContent = file_get_contents($temporaryDirectory->path('workspace_users.json'));
if ($workspaceUsersFileContent === false) {
throw new ImportException('File workspace_users.json missing in ZIP');
}
$workspaceUsers = json_decode($workspaceUsersFileContent);
foreach ($clients as $client) {
$this->clientImportHelper->getKey([
'name' => $client->name,
'organization_id' => $this->organization->id,
], [], (string) $client->id);
}
foreach ($tags as $tag) {
$this->tagImportHelper->getKey([
'name' => $tag->name,
'organization_id' => $this->organization->id,
], [], (string) $tag->id);
}
foreach ($projects as $project) {
$clientId = null;
if ($project->client_id !== null) {
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);
if ($clientId === null) {
throw new Exception('Client does not exist');
}
}
if (! $this->colorService->isValid($project->color)) {
throw new ImportException('Invalid color');
}
$this->projectImportHelper->getKey([
'name' => $project->name,
'organization_id' => $this->organization->getKey(),
], [
'client_id' => $clientId,
'color' => $project->color,
], (string) $project->id);
}
foreach ($workspaceUsers as $workspaceUser) {
$this->userImportHelper->getKey([
'email' => $workspaceUser->email,
], [
'name' => $workspaceUser->name,
'is_placeholder' => true,
], (string) $workspaceUser->id);
}
$projectIds = $this->projectImportHelper->getExternalIds();
foreach ($projectIds as $projectIdExternal) {
$tasksFileContent = file_get_contents($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'));
if ($tasksFileContent === false) {
throw new ImportException('File tasks/'.$projectIdExternal.'.json missing in ZIP');
}
$tasks = json_decode($tasksFileContent);
foreach ($tasks as $task) {
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier((string) $projectIdExternal);
if ($projectId === null) {
throw new Exception('Project does not exist');
}
$this->taskImportHelper->getKey([
'name' => $task->name,
'project_id' => $projectId,
'organization_id' => $this->organization->getKey(),
], [], (string) $task->id);
}
}
} catch (ImportException $exception) {
throw $exception;
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
}

View File

@@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Models\TimeEntry;
use Exception;
use Illuminate\Support\Carbon;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
class TogglTimeEntriesImporter extends DefaultImporter
{
/**
* @return array<string>
*
* @throws ImportException
*/
private function getTags(string $tags): array
{
if (trim($tags) === '') {
return [];
}
$tagsParsed = explode(', ', $tags);
$tagIds = [];
foreach ($tagsParsed as $tagParsed) {
$tagId = $this->tagImportHelper->getKey([
'name' => $tagParsed,
'organization_id' => $this->organization->id,
]);
$tagIds[] = $tagId;
}
return $tagIds;
}
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([
'email' => $record['Email'],
], [
'name' => $record['User'],
'is_placeholder' => true,
]);
$clientId = null;
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
]);
}
$projectId = null;
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
]);
}
$taskId = null;
if ($record['Task'] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'],
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
}
$timeEntry = new TimeEntry();
$timeEntry->user_id = $userId;
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->organization_id = $this->organization->id;
$timeEntry->description = $record['Description'];
if (! in_array($record['Billable'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['Billable'] === 'Yes';
$timeEntry->tags = $this->getTags($record['Tags']);
$start = Carbon::createFromFormat('Y-m-d H:i:s', $record['Start date'].' '.$record['Start time'], 'UTC');
if ($start === false) {
throw new ImportException('Start date ("'.$record['Start date'].'") or time ("'.$record['Start time'].'") are invalid');
}
$timeEntry->start = $start;
$end = Carbon::createFromFormat('Y-m-d H:i:s', $record['End date'].' '.$record['End time'], 'UTC');
if ($end === false) {
throw new ImportException('End date ("'.$record['End date'].'") or time ("'.$record['End time'].'") are invalid');
}
$timeEntry->end = $end;
$timeEntry->save();
$this->timeEntriesCreated++;
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
$requiredFields = [
'User',
'Email',
'Client',
'Project',
'Task',
'Description',
'Billable',
'Start date',
'Start time',
'End date',
'End time',
'Tags',
];
foreach ($requiredFields as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Models\Organization;
use App\Models\TimeEntry;
use App\Models\User;
class UserService
{
public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void
{
// Time entries
TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->whereBelongsTo($fromUser, 'user')
->update([
'user_id' => $toUser->getKey(),
]);
}
}