Compare commits

..

9 Commits

Author SHA1 Message Date
Constantin Graf
0a28be83a1 Add more tests; Add filter in filament resource; Added options for user create command 2025-02-06 12:15:49 -05:00
Constantin Graf
4eb716d2cc Fixed bugs causing incorrect computed attributes in imported data 2025-02-04 19:51:54 -05:00
Constantin Graf
55323fa4b4 Add missing serve option to local filesystem disk 2025-02-04 19:51:23 -05:00
Constantin Graf
6df20ed1e5 Updated composer dependencies 2025-02-04 19:50:34 -05:00
Constantin Graf
bc7c564eb2 Added estimated time to clockify project import 2025-02-04 13:45:10 -05:00
Constantin Graf
5423b03201 Fixed timezones in unit tests 2024-12-20 19:47:12 -05:00
Constantin Graf
0e910ba565 Updated composer dependencies 2024-12-20 19:28:18 -05:00
Constantin Graf
bad1cd1343 Fixed reports in deletion service 2024-12-20 19:28:10 -05:00
Constantin Graf
dd312b396b Deactivated registration 2024-12-20 19:05:21 -05:00
78 changed files with 2577 additions and 1279 deletions

View File

@@ -4,6 +4,7 @@ APP_KEY=
APP_DEBUG=true
APP_URL=http://localhost
APP_FORCE_HTTPS=false
APP_ENABLE_REGISTRATION=true
SESSION_SECURE_COOKIE=false
# Logging

View File

@@ -1,10 +1,13 @@
# Application
APP_NAME=solidtime
APP_ENV=local
APP_KEY=base64:UNQNf1SXeASNkWux01Rj8EnHYx8FO0kAxWNDwktclkk=
APP_DEBUG=true
APP_URL=https://solidtime.test
AUDITING_ENABLED=true
APP_ENABLE_REGISTRATION=true
SUPER_ADMINS=admin@example.com
PAGINATION_PER_PAGE_DEFAULT=500
# Logging
LOG_CHANNEL=single
@@ -25,9 +28,16 @@ DB_TEST_DATABASE=laravel
DB_TEST_USERNAME=root
DB_TEST_PASSWORD=root
BROADCAST_DRIVER=log
# Broadcasting
BROADCAST_DRIVER=null
# Cache
CACHE_DRIVER=file
# Queue
QUEUE_CONNECTION=sync
# Session
SESSION_DRIVER=database
SESSION_LIFETIME=120
@@ -41,14 +51,6 @@ MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="no-reply@solidtime.test"
MAIL_FROM_NAME="${APP_NAME}"
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
# Filesystems
FILESYSTEM_DISK=s3
PUBLIC_FILESYSTEM_DISK=s3
@@ -65,16 +67,9 @@ GOTENBERG_URL=http://gotenberg:3000
VITE_HOST_NAME=vite.solidtime.test
VITE_APP_NAME="${APP_NAME}"
VITE_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
VITE_PUSHER_HOST="${PUSHER_HOST}"
VITE_PUSHER_PORT="${PUSHER_PORT}"
VITE_PUSHER_SCHEME="${PUSHER_SCHEME}"
VITE_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"
# Local setup
NGINX_HOST_NAME=solidtime.test
NETWORK_NAME=reverse-proxy-docker-traefik_routing
FORWARD_DB_PORT=5432
FORWARD_WEB_PORT=8083
PAGINATION_PER_PAGE_DEFAULT=500

View File

@@ -4,16 +4,14 @@ declare(strict_types=1);
namespace App\Actions\Fortify;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Events\NewsletterRegistered;
use App\Models\Organization;
use App\Models\User;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\TimezoneService;
use App\Service\UserService;
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;
@@ -34,6 +32,12 @@ class CreateNewUser implements CreatesNewUsers
*/
public function create(array $input): User
{
if (! config('app.enable_registration')) {
throw ValidationException::withMessages([
'email' => [__('Registration is disabled.')],
]);
}
Validator::make($input, [
'name' => [
'required',
@@ -81,30 +85,16 @@ class CreateNewUser implements CreatesNewUsers
$currency = $ipLookupResponse->currency;
}
$user = null;
$organization = null;
DB::transaction(function () use (&$user, &$organization, $input, $timezone, $startOfWeek, $currency): void {
$user = User::create([
'name' => $input['name'],
'email' => $input['email'],
'password' => Hash::make($input['password']),
'timezone' => $timezone ?? 'UTC',
'week_start' => $startOfWeek,
]);
$organization = new Organization;
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$organization->personal_team = true;
$organization->currency = $currency ?? 'EUR';
$organization->owner()->associate($user);
$organization->save();
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
DB::transaction(function () use (&$user, $input, $timezone, $startOfWeek, $currency): void {
$userService = app(UserService::class);
$user = $userService->createUser(
$input['name'],
$input['email'],
$input['password'],
$timezone ?? 'UTC',
$startOfWeek,
$currency ?? 'EUR',
);
$user->ownedTeams()->save($organization);
});
$newsletterConsent = isset($input['newsletter_consent']) && (bool) $input['newsletter_consent'];

View File

@@ -7,18 +7,16 @@ namespace App\Actions\Jetstream;
use App\Enums\Role;
use App\Models\Organization;
use App\Models\User;
use App\Service\MemberService;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\In;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Laravel\Jetstream\Contracts\AddsTeamMembers;
use Laravel\Jetstream\Events\AddingTeamMember;
use Laravel\Jetstream\Events\TeamMemberAdded;
class AddOrganizationMember implements AddsTeamMembers
{
@@ -36,15 +34,7 @@ class AddOrganizationMember implements AddsTeamMembers
->where('is_placeholder', '=', false)
->firstOrFail();
AddingTeamMember::dispatch($organization, $newOrganizationMember);
DB::transaction(function () use ($organization, $newOrganizationMember, $role): void {
$organization->users()->attach(
$newOrganizationMember, ['role' => $role]
);
});
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
}
/**

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands\Admin;
use App\Enums\Weekday;
use App\Models\Organization;
use App\Models\User;
use App\Service\UserService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use LogicException;
class UserCreateCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:user:create
{ name : The name of the user }
{ email : The email of the user }
{ --ask-for-password : Ask for the password, otherwise the command will generate a random one }
{ --verify-email : Verify the email address of the user }';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Create a new user';
/**
* Execute the console command.
*/
public function handle(): int
{
$name = $this->argument('name');
$email = $this->argument('email');
$askForPassword = (bool) $this->option('ask-for-password');
$verifyEmail = (bool) $this->option('verify-email');
if (User::query()->where('email', $email)->where('is_placeholder', '=', false)->exists()) {
$this->error('User with email "'.$email.'" already exists.');
return self::FAILURE;
}
if ($askForPassword) {
$outputPassword = false;
$password = $this->secret('Enter the password');
} else {
$outputPassword = true;
$password = bin2hex(random_bytes(16));
}
$user = null;
DB::transaction(function () use (&$user, $name, $email, $password): void {
$user = app(UserService::class)->createUser(
$name,
$email,
$password,
'UTC',
Weekday::Monday,
'EUR',
);
});
/** @var Organization|null $organization */
$organization = $user->ownedTeams->first();
if ($organization === null) {
throw new LogicException('User does not have an organization');
}
if ($verifyEmail) {
$user->markEmailAsVerified();
}
$this->info('Created user "'.$name.'" ("'.$email.'")');
$this->line('ID: '.$user->getKey());
$this->line('Name: '.$name);
$this->line('Email: '.$email);
if ($outputPassword) {
$this->line('Password: '.$password);
}
$this->line('Timezone: '.$user->timezone);
$this->line('Week start: '.$user->week_start->value);
// Organization
$this->line('Currency: '.$organization->currency);
return self::SUCCESS;
}
}

View File

@@ -35,7 +35,9 @@ class UserVerifyCommand extends Command
$this->info('Start verifying user with email "'.$email.'"');
/** @var User|null $user */
$user = User::where('email', $email)->first();
$user = User::query()->where('email', $email)
->where('is_placeholder', '=', false)
->first();
if ($user === null) {
$this->error('User with email "'.$email.'" not found.');

View File

@@ -60,8 +60,13 @@ class ClientResource extends Resource
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name')
->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
])
->actions([
Tables\Actions\EditAction::make(),

View File

@@ -15,7 +15,8 @@ class EditClient extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

@@ -15,7 +15,8 @@ class ListClients extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Enums\Role;
use App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Models\OrganizationInvitation;
use App\Service\OrganizationInvitationService;
use Filament\Forms;
use Filament\Forms\Components\Select;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Table;
use Illuminate\Support\Collection;
class OrganizationInvitationResource extends Resource
{
protected static ?string $model = OrganizationInvitation::class;
protected static ?string $label = 'Invitations';
protected static ?string $navigationIcon = 'heroicon-o-user-plus';
protected static ?string $navigationGroup = 'Users';
protected static ?int $navigationSort = 9;
public static function form(Form $form): Form
{
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('email')
->label('Email')
->disabledOn(['edit'])
->required(),
Select::make('role')
->options(Role::class),
Forms\Components\Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->disabledOn(['edit'])
->required(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('organization.name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('email')
->sortable(),
Tables\Columns\TextColumn::make('role'),
Tables\Columns\TextColumn::make('created_at')
->label('Created At')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->label('Updated At')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
//
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\BulkAction::make('resend')
->label('Resend')
->action(function (Collection $records): void {
foreach ($records as $organizationInvite) {
app(OrganizationInvitationService::class)->resend($organizationInvite);
}
}),
]),
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListOrganizationInvitations::route('/'),
'edit' => Pages\EditOrganizationInvitation::route('/{record}/edit'),
'view' => Pages\ViewOrganizationInvitation::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Filament\Resources\OrganizationInvitationResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditOrganizationInvitation extends EditRecord
{
protected static string $resource = OrganizationInvitationResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Filament\Resources\OrganizationInvitationResource;
use Filament\Resources\Pages\ListRecords;
class ListOrganizationInvitations extends ListRecords
{
protected static string $resource = OrganizationInvitationResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationInvitationResource\Pages;
use App\Filament\Resources\OrganizationInvitationResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewOrganizationInvitation extends ViewRecord
{
protected static string $resource = OrganizationInvitationResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
];
}
}

View File

@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\OrganizationResource\Pages;
use App\Filament\Resources\OrganizationResource\RelationManagers\InvitationsRelationManager;
use App\Filament\Resources\OrganizationResource\RelationManagers\UsersRelationManager;
use App\Models\Organization;
use App\Service\DeletionService;
use App\Service\Export\ExportService;
use App\Service\Import\Importers\ImporterProvider;
use App\Service\Import\Importers\ImportException;
@@ -46,10 +48,13 @@ class OrganizationResource extends Resource
->maxLength(255),
Forms\Components\Toggle::make('personal_team')
->label('Is personal?')
->hiddenOn(['create'])
->required(),
Forms\Components\Select::make('user_id')
->label('Owner')
->relationship(name: 'owner', titleAttribute: 'email')
->searchable(['name', 'email'])
->disabledOn(['edit'])
->required(),
Forms\Components\Select::make('currency')
->label('Currency')
@@ -62,6 +67,7 @@ class OrganizationResource extends Resource
return $select;
})
->required()
->searchable(),
Forms\Components\TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
@@ -75,9 +81,11 @@ class OrganizationResource extends Resource
->numeric(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
]);
}
@@ -97,7 +105,7 @@ class OrganizationResource extends Resource
->sortable(),
Tables\Columns\TextColumn::make('currency'),
TextColumn::make('billable_rate')
->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100),
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
@@ -112,6 +120,10 @@ class OrganizationResource extends Resource
])
->actions([
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->using(function (Organization $record): void {
app(DeletionService::class)->deleteOrganization($record);
}),
Action::make('Export')
->icon('heroicon-o-arrow-down-tray')
->action(function (Organization $record) {
@@ -199,8 +211,6 @@ class OrganizationResource extends Resource
]),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
]),
]);
}
@@ -208,6 +218,7 @@ class OrganizationResource extends Resource
{
return [
UsersRelationManager::class,
InvitationsRelationManager::class,
];
}

View File

@@ -15,7 +15,6 @@ class DeleteOrganization extends DeleteAction
protected function setUp(): void
{
parent::setUp();
// TODO: check why setting the icon is necessary
$this->icon('heroicon-m-trash');
$this->action(function (): void {
$result = $this->process(function (Organization $record): bool {

View File

@@ -4,10 +4,33 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\Pages;
use App\Enums\Role;
use App\Filament\Resources\OrganizationResource;
use App\Models\Organization;
use Filament\Resources\Pages\CreateRecord;
class CreateOrganization extends CreateRecord
{
protected static string $resource = OrganizationResource::class;
protected function mutateFormDataBeforeCreate(array $data): array
{
$data['personal_team'] = false;
return $data;
}
protected function afterCreate(): void
{
/** @var Organization $organization */
$organization = $this->record;
$user = $organization->owner;
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
}
}

View File

@@ -15,7 +15,8 @@ class ListOrganizations extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\RelationManagers;
use App\Enums\Role;
use App\Filament\Resources\OrganizationInvitationResource;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Service\InvitationService;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Validation\Rule;
class InvitationsRelationManager extends RelationManager
{
protected static string $relationship = 'teamInvitations';
protected static ?string $title = 'Invitations';
public function form(Form $form): Form
{
return $form
->schema([
TextInput::make('email')
->label('Email')
->disabledOn(['edit'])
->required(),
Select::make('role')
->options(Role::class)
->label('Role')
->rules([
'required',
'string',
Rule::enum(Role::class)
->except([Role::Owner, Role::Placeholder]),
])
->required(),
]);
}
public function table(Table $table): Table
{
return $table
->recordTitleAttribute('email')
->modelLabel('Invitation')
->pluralModelLabel('Invitations')
->columns([
Tables\Columns\TextColumn::make('email'),
Tables\Columns\TextColumn::make('role'),
])
->headerActions([
Tables\Actions\CreateAction::make()
->icon('heroicon-s-plus')
->using(function (array $data, string $model): Model {
/** @var Organization $ownerRecord */
$ownerRecord = $this->getOwnerRecord();
return app(InvitationService::class)
->inviteUser($ownerRecord, $data['email'], Role::from($data['role']));
}),
])
->actions([
Action::make('view')
->icon('heroicon-o-eye')
->color('gray')
->url(fn (OrganizationInvitation $record): string => OrganizationInvitationResource::getUrl('view', [
'record' => $record->getKey(),
])),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
]),
]);
}
}

View File

@@ -5,17 +5,24 @@ declare(strict_types=1);
namespace App\Filament\Resources\OrganizationResource\RelationManagers;
use App\Enums\Role;
use App\Exceptions\Api\ApiException;
use App\Filament\Resources\UserResource;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Service\BillableRateService;
use App\Service\MemberService;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AttachAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
use Illuminate\Validation\Rule;
class UsersRelationManager extends RelationManager
{
@@ -36,20 +43,40 @@ class UsersRelationManager extends RelationManager
public function table(Table $table): Table
{
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
return $table
->recordTitleAttribute('name')
->columns([
Tables\Columns\TextColumn::make('name'),
Tables\Columns\TextColumn::make('role'),
TextColumn::make('billable_rate')
->money($this->getOwnerRecord()->currency ?? 'EUR', divideBy: 100),
->money($organization->currency, divideBy: 100),
])
->headerActions([
Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [
$action->getRecordSelect(),
Select::make('role')
->options(Role::class),
]),
Tables\Actions\AttachAction::make()
->recordTitle(fn (User $record): string => "{$record->name} ({$record->email})")
->form(fn (AttachAction $action): array => [
$action->getRecordSelect(),
Select::make('role')
->required()
->options(Role::class)
->rule([
'required',
'string',
Rule::enum(Role::class)
->except([Role::Owner, Role::Placeholder]),
]),
])
->label('Add user')
->modalHeading('Add user')
->icon('heroicon-s-plus')
->using(function (User $record, array $data): void {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
app(MemberService::class)->addMember($record, $organization, Role::from($data['role']), true);
}),
])
->actions([
Action::make('view')
@@ -58,13 +85,55 @@ class UsersRelationManager extends RelationManager
->url(fn (User $record): string => UserResource::getUrl('view', [
'record' => $record->getKey(),
])),
Tables\Actions\EditAction::make(),
Tables\Actions\DetachAction::make(),
Tables\Actions\EditAction::make()
->using(function (User $record, array $data): User {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
/** @var Member $member */
$member = $record->getRelation('membership');
if ($data['billable_rate'] !== $member->billable_rate) {
$member->billable_rate = $data['billable_rate'];
app(BillableRateService::class)->updateTimeEntriesBillableRateForMember($member);
}
if ($data['role'] !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $organization, Role::from($data['role']), true);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Update failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}
$member->save();
return $record;
}),
Tables\Actions\DetachAction::make()
->using(function (User $record): void {
/** @var Organization $organization */
$organization = $this->getOwnerRecord();
$member = Member::query()
->whereBelongsTo($record, 'user')
->whereBelongsTo($organization, 'organization')
->firstOrFail();
try {
app(MemberService::class)->removeMember($member, $organization);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Delete failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
]),
]);
}
}

View File

@@ -15,7 +15,8 @@ class EditProjectMember extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

@@ -15,7 +15,8 @@ class ListProjectMembers extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}
}

View File

@@ -72,8 +72,13 @@ class ProjectResource extends Resource
])
->filters([
SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name')
->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
])
->defaultSort('created_at', 'desc')
->actions([

View File

@@ -15,7 +15,8 @@ class EditProject extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

@@ -15,7 +15,8 @@ class ListProjects extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources;
use App\Filament\Resources\ReportResource\Pages;
use App\Models\Report;
use App\Service\Dto\ReportPropertiesDto;
use Filament\Forms;
use Filament\Forms\Components\DateTimePicker;
use Filament\Forms\Components\Toggle;
use Filament\Forms\Form;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Columns\ToggleColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
use Novadaemon\FilamentPrettyJson\PrettyJson;
class ReportResource extends Resource
{
protected static ?string $model = Report::class;
protected static ?string $navigationIcon = 'heroicon-o-document-chart-bar';
protected static ?string $navigationGroup = 'Timetracking';
protected static ?int $navigationSort = 7;
public static function form(Form $form): Form
{
return $form
->columns(1)
->schema([
Forms\Components\TextInput::make('name')
->label('Name')
->required()
->maxLength(255),
Forms\Components\TextInput::make('description')
->label('Description')
->nullable()
->maxLength(255),
Toggle::make('is_public')
->label('Is public?')
->required(),
DateTimePicker::make('public_until')
->label('Public until')
->nullable(),
Forms\Components\Select::make('organization_id')
->label('Organization')
->relationship(name: 'organization', titleAttribute: 'name')
->searchable(['name'])
->disabled()
->required(),
Forms\Components\TextInput::make('share_secret')
->label('Share Secret')
->nullable(),
PrettyJson::make('properties')
->formatStateUsing(function (ReportPropertiesDto $state, Report $record): string {
return $record->getRawOriginal('properties');
})
->disabled(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
]);
}
public static function table(Table $table): Table
{
return $table
->columns([
Tables\Columns\TextColumn::make('name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('description')
->searchable()
->sortable(),
ToggleColumn::make('is_public')
->label('Is public?')
->sortable(),
TextColumn::make('organization.name')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
Tables\Columns\TextColumn::make('updated_at')
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name')
->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
])
->actions([
Action::make('public-view')
->label('Public')
->icon('heroicon-o-eye')
->color('gray')
->hidden(fn (Report $record): bool => $record->getShareableLink() === null)
->url(fn (Report $record): string => $record->getShareableLink(), true),
Tables\Actions\ViewAction::make(),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make(),
])
->bulkActions([
]);
}
public static function getRelations(): array
{
return [
];
}
public static function getPages(): array
{
return [
'index' => Pages\ListReports::route('/'),
'edit' => Pages\EditReport::route('/{record}/edit'),
'view' => Pages\ViewReport::route('/{record}'),
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReportResource\Pages;
use App\Filament\Resources\ReportResource;
use Filament\Actions;
use Filament\Resources\Pages\EditRecord;
class EditReport extends EditRecord
{
protected static string $resource = ReportResource::class;
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReportResource\Pages;
use App\Filament\Resources\ReportResource;
use Filament\Resources\Pages\ListRecords;
class ListReports extends ListRecords
{
protected static string $resource = ReportResource::class;
protected function getHeaderActions(): array
{
return [
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Filament\Resources\ReportResource\Pages;
use App\Filament\Resources\ReportResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
class ViewReport extends ViewRecord
{
protected static string $resource = ReportResource::class;
protected function getHeaderActions(): array
{
return [
EditAction::make('edit')
->icon('heroicon-s-pencil'),
];
}
}

View File

@@ -60,8 +60,13 @@ class TagResource extends Resource
->defaultSort('created_at', 'desc')
->filters([
SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name')
->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
])
->actions([
Tables\Actions\EditAction::make(),

View File

@@ -15,7 +15,8 @@ class EditTag extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

@@ -15,7 +15,8 @@ class ListTags extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}
}

View File

@@ -61,8 +61,13 @@ class TaskResource extends Resource
])
->filters([
SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name')
->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
])
->defaultSort('created_at', 'desc')
->actions([

View File

@@ -15,7 +15,8 @@ class EditTask extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

@@ -15,7 +15,8 @@ class ListTasks extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}
}

View File

@@ -92,8 +92,13 @@ class TimeEntryResource extends Resource
])
->filters([
SelectFilter::make('organization')
->label('Organization')
->relationship('organization', 'name')
->searchable(),
SelectFilter::make('organization_id')
->label('Organization ID')
->relationship('organization', 'id')
->searchable(),
])
->defaultSort('created_at', 'desc')
->actions([

View File

@@ -15,7 +15,8 @@ class EditTimeEntry extends EditRecord
protected function getHeaderActions(): array
{
return [
Actions\DeleteAction::make(),
Actions\DeleteAction::make()
->icon('heroicon-m-trash'),
];
}
}

View File

@@ -15,7 +15,8 @@ class ListTimeEntries extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}
}

View File

@@ -5,20 +5,25 @@ declare(strict_types=1);
namespace App\Filament\Resources;
use App\Enums\Weekday;
use App\Exceptions\Api\ApiException;
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 App\Service\DeletionService;
use App\Service\TimezoneService;
use Brick\Money\ISOCurrencyProvider;
use Exception;
use Filament\Forms;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\Resource;
use Filament\Tables;
use Filament\Tables\Filters\TernaryFilter;
use Filament\Tables\Table;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use STS\FilamentImpersonate\Tables\Actions\Impersonate;
@@ -52,7 +57,9 @@ class UserResource extends Resource
->required()
->maxLength(255),
Forms\Components\Toggle::make('is_placeholder')
->label('Is Placeholder'),
->label('Is Placeholder?')
->hiddenOn(['create'])
->disabledOn(['edit']),
Forms\Components\DateTimePicker::make('email_verified_at')
->label('Email Verified At')
->nullable(),
@@ -71,11 +78,27 @@ class UserResource extends Resource
->dehydrated(fn ($state) => filled($state))
->required(fn (string $context): bool => $context === 'create')
->maxLength(255),
Forms\Components\Select::make('currency')
->label('Currency (Personal Organization)')
->options(function (): array {
$currencies = ISOCurrencyProvider::getInstance()->getAvailableCurrencies();
$select = [];
foreach ($currencies as $currency) {
$select[$currency->getCurrencyCode()] = $currency->getName().' ('.$currency->getCurrencyCode().')';
}
return $select;
})
->required()
->visibleOn(['create'])
->searchable(),
Forms\Components\DateTimePicker::make('created_at')
->label('Created At')
->hiddenOn(['create'])
->disabled(),
Forms\Components\DateTimePicker::make('updated_at')
->label('Updated At')
->hiddenOn(['create'])
->disabled(),
]);
}
@@ -145,11 +168,22 @@ class UserResource extends Resource
}
}),
Tables\Actions\EditAction::make(),
Tables\Actions\DeleteAction::make()
->hidden(fn (User $record) => $record->is(Auth::user()))
->using(function (User $record): void {
try {
app(DeletionService::class)->deleteUser($record);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Delete failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DeleteBulkAction::make(),
]),
]);
}

View File

@@ -4,24 +4,28 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\Pages;
use App\Enums\Weekday;
use App\Filament\Resources\UserResource;
use App\Models\Organization;
use App\Models\User;
use App\Service\UserService;
use Filament\Resources\Pages\CreateRecord;
class CreateUser extends CreateRecord
{
protected static string $resource = UserResource::class;
protected function afterCreate(): void
protected function handleRecordCreation(array $data): User
{
/** @var User $user */
$user = $this->record;
$userService = app(UserService::class);
$user = $userService->createUser(
$data['name'],
$data['email'],
$data['password'],
$data['timezone'],
Weekday::from($data['week_start']),
$data['currency'],
);
$user->ownedTeams()->save(Organization::forceCreate([
'user_id' => $user->id,
'name' => explode(' ', $user->name, 2)[0]."'s Organization",
'personal_team' => true,
]));
return $user;
}
}

View File

@@ -15,7 +15,8 @@ class ListUsers extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\CreateAction::make(),
Actions\CreateAction::make()
->icon('heroicon-s-plus'),
];
}
}

View File

@@ -7,6 +7,7 @@ namespace App\Filament\Resources\UserResource\Pages;
use App\Filament\Resources\UserResource;
use Filament\Actions\EditAction;
use Filament\Resources\Pages\ViewRecord;
use STS\FilamentImpersonate\Pages\Actions\Impersonate;
class ViewUser extends ViewRecord
{
@@ -15,6 +16,7 @@ class ViewUser extends ViewRecord
protected function getHeaderActions(): array
{
return [
Impersonate::make()->record($this->getRecord()),
EditAction::make('edit')
->icon('heroicon-s-pencil'),
];

View File

@@ -5,15 +5,18 @@ declare(strict_types=1);
namespace App\Filament\Resources\UserResource\RelationManagers;
use App\Enums\Role;
use App\Exceptions\Api\ApiException;
use App\Filament\Resources\OrganizationResource;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Service\MemberService;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Form;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
use Filament\Tables;
use Filament\Tables\Actions\Action;
use Filament\Tables\Actions\AttachAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Table;
@@ -27,10 +30,6 @@ class OrganizationsRelationManager extends RelationManager
->schema([
Select::make('role')
->options(Role::class),
TextInput::make('billable_rate')
->label('Billable rate (in Cents)')
->nullable()
->numeric(),
]);
}
@@ -41,15 +40,11 @@ class OrganizationsRelationManager extends RelationManager
->columns([
TextColumn::make('name'),
TextColumn::make('role'),
TextColumn::make('billable_rate')
->money(fn (Organization $resource) => $resource->currency ?? 'EUR', divideBy: 100),
TextColumn::make('membership.billable_rate')
->label('Billable rate')
->money(fn (Organization $resource) => $resource->currency, divideBy: 100),
])
->headerActions([
Tables\Actions\AttachAction::make()->form(fn (AttachAction $action): array => [
$action->getRecordSelect(),
Select::make('role')
->options(Role::class),
]),
])
->actions([
Action::make('view')
@@ -58,13 +53,48 @@ class OrganizationsRelationManager extends RelationManager
->url(fn (Organization $record): string => OrganizationResource::getUrl('view', [
'record' => $record->getKey(),
])),
Tables\Actions\EditAction::make(),
Tables\Actions\DetachAction::make(),
Tables\Actions\EditAction::make()
->using(function (Organization $record, array $data): Organization {
/** @var Member $member */
$member = $record->getRelation('membership');
if ($data['role'] !== $member->role) {
try {
app(MemberService::class)->changeRole($member, $record, Role::from($data['role']), true);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Update failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}
$member->save();
return $record;
}),
Tables\Actions\DetachAction::make()
->using(function (Organization $record): void {
/** @var User $user */
$user = $this->getOwnerRecord();
$member = Member::query()
->whereBelongsTo($user, 'user')
->whereBelongsTo($record, 'organization')
->firstOrFail();
try {
app(MemberService::class)->removeMember($member, $record);
} catch (ApiException $exception) {
Notification::make()
->danger()
->title('Delete failed')
->body($exception->getTranslatedMessage())
->persistent()
->send();
}
}),
])
->bulkActions([
Tables\Actions\BulkActionGroup::make([
Tables\Actions\DetachBulkAction::make(),
]),
]);
}
}

View File

@@ -9,13 +9,12 @@ use App\Http\Requests\V1\Invitation\InvitationIndexRequest;
use App\Http\Requests\V1\Invitation\InvitationStoreRequest;
use App\Http\Resources\V1\Invitation\InvitationCollection;
use App\Http\Resources\V1\Invitation\InvitationResource;
use App\Mail\OrganizationInvitationMail;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Service\InvitationService;
use App\Service\OrganizationInvitationService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Mail;
class InvitationController extends Controller
{
@@ -73,12 +72,11 @@ class InvitationController extends Controller
*
* @operationId resendInvitationEmail
*/
public function resend(Organization $organization, OrganizationInvitation $invitation): JsonResponse
public function resend(Organization $organization, OrganizationInvitation $invitation, OrganizationInvitationService $organizationInvitationService): JsonResponse
{
$this->checkPermission($organization, 'invitations:resend', $invitation);
Mail::to($invitation->email)
->queue(new OrganizationInvitationMail($invitation));
$organizationInvitationService->resend($invitation);
return response()->json(null, 204);
}

View File

@@ -6,7 +6,6 @@ namespace App\Http\Controllers\Api\V1;
use App\Enums\Role;
use App\Events\MemberMadeToPlaceholder;
use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
@@ -19,8 +18,6 @@ use App\Http\Resources\V1\Member\MemberCollection;
use App\Http\Resources\V1\Member\MemberResource;
use App\Models\Member;
use App\Models\Organization;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Service\BillableRateService;
use App\Service\InvitationService;
use App\Service\MemberService;
@@ -80,22 +77,8 @@ class MemberController extends Controller
}
if ($request->has('role') && $member->role !== $request->getRole()->value) {
$newRole = $request->getRole();
$oldRole = Role::from($member->role);
if ($oldRole === Role::Owner) {
throw new OrganizationNeedsAtLeastOneOwner;
}
if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed;
}
if ($newRole === Role::Owner) {
if ($this->hasPermission($organization, 'members:change-ownership')) {
$memberService->changeOwnership($organization, $member);
} else {
throw new OnlyOwnerCanChangeOwnership;
}
} else {
$member->role = $request->getRole()->value;
}
$allowOwnerChange = $this->hasPermission($organization, 'members:change-ownership');
$memberService->changeRole($member, $organization, $newRole, $allowOwnerChange);
}
$member->save();
@@ -109,22 +92,11 @@ class MemberController extends Controller
*
* @operationId removeMember
*/
public function destroy(Organization $organization, Member $member): JsonResponse
public function destroy(Organization $organization, Member $member, MemberService $memberService): JsonResponse
{
$this->checkPermission($organization, 'members:delete', $member);
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization;
}
$member->delete();
MemberRemoved::dispatch($member, $organization);
$memberService->removeMember($member, $organization);
return response()
->json(null, 204);

View File

@@ -7,12 +7,13 @@ namespace App\Jobs;
use App\Models\Project;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RecalculateSpentTimeForProject implements ShouldQueue
class RecalculateSpentTimeForProject implements ShouldDispatchAfterCommit, ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;

View File

@@ -7,12 +7,13 @@ namespace App\Jobs;
use App\Models\Task;
use Exception;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class RecalculateSpentTimeForTask implements ShouldQueue
class RecalculateSpentTimeForTask implements ShouldDispatchAfterCommit, ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;

View File

@@ -13,6 +13,7 @@ use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Report;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
@@ -71,6 +72,9 @@ class DeletionService
// Delete all clients
Client::query()->whereBelongsTo($organization, 'organization')->delete();
// Delete all reports
Report::query()->whereBelongsTo($organization, 'organization')->delete();
// Reset the current organization
$organization->owner()
->where('current_team_id', $organization->getKey())

View File

@@ -188,6 +188,18 @@ class ImportDatabaseHelper
return $model;
}
/**
* @return array<TModel>
*/
public function getCachedModels(): array
{
if ($this->mapKeyToModel === null) {
return [];
}
return array_values($this->mapKeyToModel);
}
/**
* @param array<string, mixed> $identifierData
* @return TModel|null

View File

@@ -43,6 +43,7 @@ class ClockifyProjectsImporter extends DefaultImporter
'color' => $this->colorService->getRandomColor(),
'is_billable' => $record['Billability'] === 'Yes',
'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null,
'estimated_time' => $record['Estimated (h)'] !== '' && is_numeric($record['Estimated (h)']) ? (int) ($record['Estimated (h)'] * 3600) : null,
]);
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
@@ -99,6 +101,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
$this->taskImportHelper->getModelById($taskId);
}
$timeEntry = new TimeEntry;
$timeEntry->disableAuditing();
@@ -158,6 +161,12 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
$timeEntry->save();
$this->timeEntriesCreated++;
}
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
RecalculateSpentTimeForProject::dispatch($usedProject);
}
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
RecalculateSpentTimeForTask::dispatch($usedTask);
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
@@ -235,6 +237,7 @@ class SolidtimeImporter extends DefaultImporter
$taskId = null;
if ($timeEntryRow['task_id'] !== '') {
$taskId = $this->taskImportHelper->getKeyByExternalIdentifier($timeEntryRow['task_id']);
$this->taskImportHelper->getModelById($taskId);
}
$timeEntry = new TimeEntry;
$timeEntry->disableAuditing();
@@ -303,6 +306,12 @@ class SolidtimeImporter extends DefaultImporter
$timeEntry->save();
$this->timeEntriesCreated++;
}
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
RecalculateSpentTimeForProject::dispatch($usedProject);
}
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
RecalculateSpentTimeForTask::dispatch($usedTask);
}
} catch (ImportException $exception) {
throw $exception;
} catch (Exception $exception) {

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
@@ -99,6 +101,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
$this->taskImportHelper->getModelById($taskId);
}
$timeEntry = new TimeEntry;
$timeEntry->disableAuditing();
@@ -144,6 +147,12 @@ class TogglTimeEntriesImporter extends DefaultImporter
$timeEntry->save();
$this->timeEntriesCreated++;
}
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
RecalculateSpentTimeForProject::dispatch($usedProject);
}
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
RecalculateSpentTimeForTask::dispatch($usedTask);
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {

View File

@@ -5,10 +5,21 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Events\MemberRemoved;
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
use App\Exceptions\Api\EntityStillInUseApiException;
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
use App\Models\Member;
use App\Models\Organization;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use Laravel\Jetstream\Events\AddingTeamMember;
use Laravel\Jetstream\Events\TeamMemberAdded;
class MemberService
{
@@ -19,6 +30,72 @@ class MemberService
$this->userService = $userService;
}
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
{
if (! $asSuperAdmin) {
AddingTeamMember::dispatch($organization, $user);
}
$member = new Member;
DB::transaction(function () use ($organization, $user, $role, &$member): void {
$member->user()->associate($user);
$member->organization()->associate($organization);
$member->role = $role->value;
$member->save();
});
if (! $asSuperAdmin) {
TeamMemberAdded::dispatch($organization, $user);
}
return $member;
}
/**
* @throws CanNotRemoveOwnerFromOrganization
* @throws EntityStillInUseApiException
*/
public function removeMember(Member $member, Organization $organization): void
{
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
throw new EntityStillInUseApiException('member', 'time_entry');
}
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
throw new EntityStillInUseApiException('member', 'project_member');
}
if ($member->role === Role::Owner->value) {
throw new CanNotRemoveOwnerFromOrganization;
}
$member->delete();
MemberRemoved::dispatch($member, $organization);
}
/**
* @throws ChangingRoleToPlaceholderIsNotAllowed
* @throws OnlyOwnerCanChangeOwnership
* @throws OrganizationNeedsAtLeastOneOwner
*/
public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void
{
$oldRole = Role::from($member->role);
if ($oldRole === Role::Owner) {
throw new OrganizationNeedsAtLeastOneOwner;
}
if ($newRole === Role::Placeholder) {
throw new ChangingRoleToPlaceholderIsNotAllowed;
}
if ($newRole === Role::Owner) {
if ($allowOwnerChange) {
$this->changeOwnership($organization, $member);
} else {
throw new OnlyOwnerCanChangeOwnership;
}
} else {
$member->role = $newRole->value;
}
}
/**
* Change the ownership of an organization to a new user.
* The previous owner will be demoted to an admin.

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Mail\OrganizationInvitationMail;
use App\Models\OrganizationInvitation;
use Illuminate\Support\Facades\Mail;
class OrganizationInvitationService
{
public function resend(OrganizationInvitation $invitation): void
{
Mail::to($invitation->email)
->queue(new OrganizationInvitationMail($invitation));
}
}

View File

@@ -5,15 +5,45 @@ declare(strict_types=1);
namespace App\Service;
use App\Enums\Role;
use App\Enums\Weekday;
use App\Events\AfterCreateOrganization;
use App\Models\Member;
use App\Models\Organization;
use App\Models\ProjectMember;
use App\Models\TimeEntry;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
class UserService
{
public function createUser(string $name, string $email, string $password, string $timezone, Weekday $weekStart, string $currency): User
{
$user = new User;
$user->name = $name;
$user->email = $email;
$user->password = Hash::make($password);
$user->timezone = $timezone;
$user->week_start = $weekStart;
$user->save();
$organization = new Organization;
$organization->name = explode(' ', $user->name, 2)[0]."'s Organization";
$organization->personal_team = true;
$organization->currency = $currency;
$organization->owner()->associate($user);
$organization->save();
$organization->users()->attach(
$user, [
'role' => Role::Owner->value,
]
);
$user->ownedTeams()->save($organization);
return $user;
}
/**
* Assign all organization entities (time entries, project members) from one user to another.
* This is useful when a placeholder user is replaced with a real user.

View File

@@ -9,7 +9,7 @@
"ext-zip": "*",
"brick/money": "^0.10.0",
"datomatic/laravel-enum-helper": "^2.0.0",
"dedoc/scramble": "^0.11.28",
"dedoc/scramble": "^0.12.2",
"filament/filament": "^3.2",
"flowframe/laravel-trend": "^0.3.0",
"gotenberg/gotenberg-php": "^2.8",
@@ -40,7 +40,7 @@
"barryvdh/laravel-ide-helper": "^3.0",
"brianium/paratest": "^7.3",
"fakerphp/faker": "^1.9.1",
"fumeapp/modeltyper": "^2.2",
"fumeapp/modeltyper": "^3.0",
"phpstan/phpstan": "1.12.0",
"larastan/larastan": "^2.0",
"laravel/pint": "^1.0",

2158
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -67,6 +67,8 @@ return [
'force_https' => (bool) env('APP_FORCE_HTTPS', false),
'enable_registration' => (bool) env('APP_ENABLE_REGISTRATION', false),
/*
|--------------------------------------------------------------------------
| Application Timezone

View File

@@ -163,8 +163,8 @@ return [
*/
'cells' => [
'middleware' => [
//\Maatwebsite\Excel\Middleware\TrimCellValue::class,
//\Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class,
// \Maatwebsite\Excel\Middleware\TrimCellValue::class,
// \Maatwebsite\Excel\Middleware\ConvertEmptyCellValuesToNull::class,
],
],

View File

@@ -39,6 +39,7 @@ return [
'local' => [
'driver' => 'local',
'root' => storage_path('app'),
'serve' => true,
'throw' => true,
],

View File

@@ -12,6 +12,7 @@ use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Report;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
@@ -134,6 +135,7 @@ class DatabaseSeeder extends Seeder
'personal_team' => true,
'currency' => 'USD',
]);
Member::factory()->forUser($rivalOwner)->forOrganization($organizationRival)->role(Role::Owner)->create();
$userRivalManager = User::factory()->withPersonalOrganization()->create([
'name' => 'Other User',
'email' => 'test@rival-company.test',
@@ -186,6 +188,7 @@ class DatabaseSeeder extends Seeder
// Application tables
DB::table((new Audit)->getTable())->delete();
DB::table((new Report)->getTable())->delete();
DB::table((new TimeEntry)->getTable())->delete();
DB::table((new Task)->getTable())->delete();
DB::table((new Tag)->getTable())->delete();

View File

@@ -5,20 +5,18 @@ declare(strict_types=1);
return [
'clockify_time_entries' => [
'name' => 'Clockify Time Entries',
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br>'.
'2. In the same preferences page change the language of Clockfiy to English.<br>'.
'3. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'4. Now select the date range that you want to export in the right top. '.
'description' => '1. First make sure that you set the Date format to "MM/DD/YYYY" and the Time format to "12-hour" in the user settings.<br> '.
'2. Go to REPORTS -> TIME -> Detailed in the navigation on the left. <br>'.
'3. Now select the date range that you want to export in the right top. '.
'It is currently not possible to select more than one year. You can export each year separately and import them one after another .'.
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
],
'clockify_projects' => [
'name' => 'Clockify Projects',
'description' => '1. Make sure to set the language of Clockify to English in "Preferences -> General".<br>'.
'2. Go to PROJECTS in the navigation on the left.<br> '.
'3. Now click on the three dots on the right of the project that you want to export and select Export.<br> '.
'4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',
'description' => '1. Go to PROJECTS in the navigation on the left.<br> '.
'2. Now click on the three dots on the right of the project that you want to export and select Export.<br> '.
'3. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table in the top right corner.',
],
'toggl_data_importer' => [
'name' => 'Toggl Data Importer',

View File

@@ -1,3 +1,3 @@
"Project","Client","Status","Visibility","Billability","Task","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Progress(%)","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Project members","Project manager","Note"
"Project for Big Company","Big Company","Active","Public","Yes","Task 1, Task 2, Task 3","0.00","","","","","0.00","0.00","100.01","0.00","Constantin Graf","",""
"Project for Big Company","Big Company","Active","Public","Yes","Task 1, Task 2, Task 3","0.00","1001.11","","","","0.00","0.00","100.01","0.00","Constantin Graf","",""
"Project without Client","","Active","Public","Yes","","0.00","","","","","0.00","0.00","","0.00","Constantin Graf","",""
1 Project Client Status Visibility Billability Task Tracked (h) Estimated (h) Remaining (h) Overage (h) Progress(%) Billable (h) Non-billable (h) Billable Rate (USD) Amount (USD) Project members Project manager Note
2 Project for Big Company Big Company Active Public Yes Task 1, Task 2, Task 3 0.00 1001.11 0.00 0.00 100.01 0.00 Constantin Graf
3 Project without Client Active Public Yes 0.00 0.00 0.00 0.00 Constantin Graf

View File

@@ -1,3 +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
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,b4187a44-41f4-46d7-8460-f15a25b3aad6,06e79ec4-33f8-4730-804c-d03c014991d1,b49688a0-94f3-4cb3-9ca1-5003de955fb0,[],false,,2024-08-22T10:36:48Z,2024-08-22T10:36:48Z
1 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
2 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
3 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 b4187a44-41f4-46d7-8460-f15a25b3aad6 06e79ec4-33f8-4730-804c-d03c014991d1 b49688a0-94f3-4cb3-9ca1-5003de955fb0 [] false 2024-08-22T10:36:48Z 2024-08-22T10:36:48Z

View File

@@ -13,6 +13,7 @@ use App\Providers\RouteServiceProvider;
use App\Service\IpLookup\IpLookupResponseDto;
use App\Service\IpLookup\IpLookupServiceContract;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Event;
use Laravel\Fortify\Features;
use Laravel\Jetstream\Jetstream;
@@ -63,6 +64,31 @@ class RegistrationTest extends TestCase
Event::assertNotDispatched(NewsletterRegistered::class);
}
public function test_user_registration_fails_if_registration_is_deactivated(): void
{
// Arrange
Event::fake([
NewsletterRegistered::class,
]);
Config::set('app.enable_registration', false);
// Act
$response = $this->post('/register', [
'name' => 'Test User',
'email' => 'test@example.com',
'password' => 'password',
'password_confirmation' => 'password',
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature(),
]);
// Assert
$response->assertInvalid([
'email' => 'Registration is disabled.',
]);
$this->assertFalse(User::query()->where('email', 'test@example.com')->exists());
Event::assertNotDispatched(NewsletterRegistered::class);
}
public function test_new_user_can_not_register_with_likely_invalid_domain(): void
{
// Act

View File

@@ -28,8 +28,10 @@ class OrganizationDeleteCommandTest extends TestCaseWithDatabase
});
// Act
$this->artisan('admin:organization:delete', ['organization' => $organization->getKey()])
->expectsOutput("Deleting organization with ID {$organization->getKey()}")
$command = $this->artisan('admin:organization:delete', ['organization' => $organization->getKey()]);
// Assert
$command->expectsOutput("Deleting organization with ID {$organization->getKey()}")
->expectsOutput("Organization with ID {$organization->getKey()} has been deleted.")
->assertExitCode(0);
}
@@ -40,9 +42,11 @@ class OrganizationDeleteCommandTest extends TestCaseWithDatabase
$organizationId = Str::uuid()->toString();
// Act
$this->artisan('admin:organization:delete', ['organization' => $organizationId])
->expectsOutput('Organization with ID '.$organizationId.' not found.')
->assertExitCode(1);
$command = $this->artisan('admin:organization:delete', ['organization' => $organizationId]);
// Assert
$command->expectsOutput('Organization with ID '.$organizationId.' not found.');
$command->assertExitCode(1);
}
public function test_it_fails_if_organization_id_is_not_a_valid_uuid(): void
@@ -51,8 +55,10 @@ class OrganizationDeleteCommandTest extends TestCaseWithDatabase
$organizationId = 'invalid-uuid';
// Act
$this->artisan('admin:organization:delete', ['organization' => $organizationId])
->expectsOutput('Organization ID must be a valid UUID.')
$command = $this->artisan('admin:organization:delete', ['organization' => $organizationId]);
// Assert
$command->expectsOutput('Organization ID must be a valid UUID.')
->assertExitCode(1);
}
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Console\Commands\Admin;
use App\Console\Commands\Admin\UserCreateCommand;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Hash;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\TestCaseWithDatabase;
#[CoversClass(UserCreateCommand::class)]
#[UsesClass(UserCreateCommand::class)]
class UserCreateCommandCommandTest extends TestCaseWithDatabase
{
public function test_it_creates_user(): void
{
// Arrange
$email = 'mail@testuser.test';
$name = 'Test User';
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('admin:user:create', [
'name' => $name,
'email' => $email,
]);
// Assert
$this->assertSame(Command::SUCCESS, $exitCode);
$output = Artisan::output();
$this->assertStringContainsString('Created user "'.$name.'" ("'.$email.'")', $output);
$this->assertDatabaseHas(User::class, [
'name' => $name,
'email' => $email,
'email_verified_at' => null,
]);
}
public function test_created_user_is_verified_if_option_is_set(): void
{
// Arrange
$email = 'mail@testuser.test';
$name = 'Test User';
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('admin:user:create', [
'name' => $name,
'email' => $email,
'--verify-email' => true,
]);
// Assert
$this->assertSame(Command::SUCCESS, $exitCode);
$output = Artisan::output();
$this->assertStringContainsString('Created user "'.$name.'" ("'.$email.'")', $output);
$this->assertDatabaseHas(User::class, [
'name' => $name,
'email' => $email,
]);
$user = User::where('email', $email)->first();
$this->assertNotNull($user->email_verified_at);
}
public function test_it_fails_if_user_with_email_already_exists(): void
{
// Arrange
$email = 'mail@testuser.test';
$name = 'Test User';
User::factory()->create([
'email' => $email,
]);
// Act
$exitCode = $this->withoutMockingConsoleOutput()->artisan('admin:user:create', [
'name' => $name,
'email' => $email,
]);
// Assert
$this->assertSame(Command::FAILURE, $exitCode);
$output = Artisan::output();
$this->assertStringContainsString('User with email "'.$email.'" already exists.', $output);
}
public function test_it_asks_for_password_if_option_is_set(): void
{
// Arrange
$email = 'mail@testuser.test';
$name = 'Test User';
// Act
$this->artisan('admin:user:create', [
'name' => $name,
'email' => $email,
'--ask-for-password' => true,
])
->expectsQuestion('Enter the password', 'password')
->assertExitCode(Command::SUCCESS);
$this->assertDatabaseHas(User::class, [
'name' => $name,
'email' => $email,
'email_verified_at' => null,
]);
$user = User::where('email', $email)->first();
$this->assertNotNull($user->password);
$this->assertTrue(Hash::check('password', $user->password));
}
}

View File

@@ -29,7 +29,6 @@ class UserVerifyCommandTest extends TestCaseWithDatabase
$command = $this->artisan('admin:user:verify', ['email' => $user->email]);
// Assert
$command->expectsOutput('Start verifying user with email "'.$user->email.'"')
->expectsOutput('User with email "'.$user->email.'" has been verified.')
->assertExitCode(0);

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Filament\Resources;
use App\Filament\Resources\OrganizationInvitationResource;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\User;
use Filament\Actions\DeleteAction;
use Illuminate\Support\Facades\Config;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\Unit\Filament\FilamentTestCase;
#[UsesClass(OrganizationInvitationResource::class)]
class OrganizationInvitationResourceTest extends FilamentTestCase
{
protected function setUp(): void
{
parent::setUp();
Config::set('auth.super_admins', ['admin@example.com']);
$user = User::factory()->withPersonalOrganization()->create([
'email' => 'admin@example.com',
]);
$this->actingAs($user);
}
public function test_can_list_organization_invitations(): void
{
// Arrange
$user = User::factory()->create();
$organization = Organization::factory()->withOwner($user)->create();
$organizationInvitations = OrganizationInvitation::factory()->forOrganization($organization)->createMany(5);
// Act
$response = Livewire::test(OrganizationInvitationResource\Pages\ListOrganizationInvitations::class);
// Assert
$response->assertSuccessful();
$response->assertCanSeeTableRecords($organizationInvitations);
}
public function test_can_see_edit_page_of_organization_invitation(): void
{
// Arrange
$organization = Organization::factory()->create();
$organizationInvitation = OrganizationInvitation::factory()->forOrganization($organization)->create();
// Act
$response = Livewire::test(OrganizationInvitationResource\Pages\EditOrganizationInvitation::class, [
'record' => $organizationInvitation->getKey(),
]);
// Assert
$response->assertSuccessful();
}
public function test_can_delete_a_organization_invitation(): void
{
// Arrange
$organization = Organization::factory()->create();
$organizationInvitation = OrganizationInvitation::factory()->forOrganization($organization)->create();
// Act
$response = Livewire::test(OrganizationInvitationResource\Pages\EditOrganizationInvitation::class, [
'record' => $organizationInvitation->getKey(),
])->callAction(DeleteAction::class);
// Assert
$response->assertSuccessful();
$this->assertDatabaseMissing(OrganizationInvitation::class, [
'id' => $organizationInvitation->getKey(),
]);
}
}

View File

@@ -6,6 +6,7 @@ namespace Tests\Unit\Filament\Resources;
use App\Filament\Resources\OrganizationResource;
use App\Models\Organization;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Service\DeletionService;
use Illuminate\Support\Facades\Config;
@@ -74,4 +75,41 @@ class OrganizationResourceTest extends FilamentTestCase
// Assert
$response->assertSuccessful();
}
public function test_can_list_related_users(): void
{
// Arrange
$organization = Organization::factory()->create();
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$organization->users()->attach($user1);
$organization->users()->attach($user2);
// Act
$response = Livewire::test(OrganizationResource\RelationManagers\UsersRelationManager::class, [
'ownerRecord' => $organization,
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
]);
// Assert
$response->assertSuccessful();
$response->assertCanSeeTableRecords($organization->users()->get());
}
public function test_can_list_related_invitations(): void
{
// Arrange
$organization = Organization::factory()->create();
$organizationInvitations = OrganizationInvitation::factory()->forOrganization($organization)->createMany(5);
// Act
$response = Livewire::test(OrganizationResource\RelationManagers\InvitationsRelationManager::class, [
'ownerRecord' => $organization,
'pageClass' => OrganizationResource\Pages\EditOrganization::class,
]);
// Assert
$response->assertSuccessful();
$response->assertCanSeeTableRecords($organizationInvitations);
}
}

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Filament\Resources;
use App\Filament\Resources\ReportResource;
use App\Models\Report;
use App\Models\User;
use Illuminate\Support\Facades\Config;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\Unit\Filament\FilamentTestCase;
#[UsesClass(ReportResource::class)]
class ReportResourceTest extends FilamentTestCase
{
protected function setUp(): void
{
parent::setUp();
Config::set('auth.super_admins', ['admin@example.com']);
$user = User::factory()->withPersonalOrganization()->create([
'email' => 'admin@example.com',
]);
$this->actingAs($user);
}
public function test_can_list_reports(): void
{
// Arrange
$reports = Report::factory()->createMany(5);
// Act
$response = Livewire::test(ReportResource\Pages\ListReports::class);
// Assert
$response->assertSuccessful();
$response->assertCanSeeTableRecords($reports);
}
public function test_can_see_edit_page_of_report(): void
{
// Arrange
$report = Report::factory()->create();
// Act
$response = Livewire::test(ReportResource\Pages\EditReport::class, [
'record' => $report->getKey(),
]);
// Assert
$response->assertSuccessful();
}
}

View File

@@ -7,6 +7,7 @@ namespace Tests\Unit\Filament\Resources;
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Filament\Resources\TimeEntryResource;
use App\Filament\Resources\UserResource;
use App\Models\Organization;
use App\Models\User;
use App\Service\DeletionService;
use Illuminate\Support\Facades\Config;
@@ -54,6 +55,18 @@ class UserResourceTest extends FilamentTestCase
$response->assertSuccessful();
}
public function test_can_see_view_page_of_user(): void
{
// Arrange
$user = User::factory()->create();
// Act
$response = Livewire::test(UserResource\Pages\ViewUser::class, ['record' => $user->getKey()]);
// Assert
$response->assertSuccessful();
}
public function test_can_delete_a_user(): void
{
// Arrange
@@ -91,4 +104,42 @@ class UserResourceTest extends FilamentTestCase
$response->assertNotified(__('exceptions.api.can_not_delete_user_who_is_owner_of_organization_with_multiple_members'));
$response->assertSuccessful();
}
public function test_can_list_related_organizations(): void
{
// Arrange
$user = User::factory()->create();
$ownedOrganization = Organization::factory()->withOwner($user)->create();
$organization = Organization::factory()->create();
// Act
$response = Livewire::test(UserResource\RelationManagers\OrganizationsRelationManager::class, [
'ownerRecord' => $user,
'pageClass' => UserResource\Pages\EditUser::class,
]);
// Assert
$response->assertSuccessful();
$response->assertCanSeeTableRecords($user->organizations()->get());
$response->assertCanNotSeeTableRecords($user->ownedTeams()->get());
}
public function test_can_list_related_owned_organizations(): void
{
// Arrange
$user = User::factory()->create();
$ownedOrganization = Organization::factory()->withOwner($user)->create();
$organization = Organization::factory()->create();
// Act
$response = Livewire::test(UserResource\RelationManagers\OwnedOrganizationsRelationManager::class, [
'ownerRecord' => $user,
'pageClass' => UserResource\Pages\EditUser::class,
]);
// Assert
$response->assertSuccessful();
$response->assertCanSeeTableRecords($user->ownedTeams()->get());
$response->assertCanNotSeeTableRecords($user->organizations()->get());
}
}

View File

@@ -12,6 +12,7 @@ use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\ProjectMember;
use App\Models\Report;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
@@ -55,7 +56,8 @@ class DeletionServiceTest extends TestCaseWithDatabase
* members: Collection<Member>,
* tasks: Collection<Task>,
* timeEntries: Collection<TimeEntry>,
* owner: User
* owner: User,
* reports: Collection<Report>
* }
*/
private function createOrganizationWithAllRelations(): object
@@ -96,6 +98,10 @@ class DeletionServiceTest extends TestCaseWithDatabase
$task2 = Task::factory()->forProject($projectWithoutClient)->forOrganization($organization)->create();
$tasks = collect([$task1, $task2]);
$report1 = Report::factory()->forOrganization($organization)->create();
$report2 = Report::factory()->forOrganization($organization)->create();
$reports = collect([$report1, $report2]);
$timeEntries = TimeEntry::factory()->forOrganization($organization)->forMember($memberOwner)->createMany(2);
$timeEntriesWithTask = TimeEntry::factory()->forTask($task1)->forOrganization($organization)->forMember($memberEmployee)->createMany(2);
$timeEntriesWithProject = TimeEntry::factory()->forProject($projectWithClient)->forOrganization($organization)->forMember($memberPlaceholder)->createMany(2);
@@ -111,6 +117,7 @@ class DeletionServiceTest extends TestCaseWithDatabase
'tasks' => $tasks,
'timeEntries' => $timeEntries,
'owner' => $userOwner,
'reports' => $reports,
];
}
@@ -126,6 +133,7 @@ class DeletionServiceTest extends TestCaseWithDatabase
$this->assertSame(0, Tag::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(0, Member::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(0, Task::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(0, Report::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count());
}
@@ -138,6 +146,7 @@ class DeletionServiceTest extends TestCaseWithDatabase
$this->assertSame(2, Tag::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(3, Member::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(2, Task::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame(2, Report::query()->whereBelongsTo($organization, 'organization')->count());
$this->assertSame($specialCase ? 7 : 6, TimeEntry::query()->whereBelongsTo($organization, 'organization')->count());
}

View File

@@ -229,4 +229,23 @@ class ImportDatabaseHelperTest extends TestCase
// Assert
$this->assertSame($user->getKey(), $model1->getKey());
}
public function test_get_cached_models_returns_all_models_where_the_helper_already_fetched_the_model(): void
{
// Arrange
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$helper = new ImportDatabaseHelper(User::class, ['email'], true);
$helper->getModelById($user1->getKey());
$helper->getModelById($user2->getKey());
$helper->getModelById($user1->getKey());
// Act
$models = $helper->getCachedModels();
// Assert
$this->assertCount(2, $models);
$this->assertContains($user1->getKey(), collect($models)->pluck('id')->toArray());
$this->assertContains($user2->getKey(), collect($models)->pluck('id')->toArray());
}
}

View File

@@ -147,10 +147,12 @@ class ImporterTestAbstract extends TestCase
$project1 = $projects->firstWhere('name', 'Project without Client');
$this->assertNotNull($project1);
$this->assertNull($project1->client_id);
$this->assertSame(null, $project1->estimated_time);
$project2 = $projects->firstWhere('name', 'Project for Big Company');
$this->assertNotNull($project2);
$this->assertSame(10001, $project2->billable_rate);
$this->assertSame($client1->getKey(), $project2->client_id);
$this->assertSame(3603996, $project2->estimated_time);
$tasks = Task::all();
$this->assertCount(3, $tasks);
$task1 = $tasks->firstWhere('name', 'Task 1');

View File

@@ -4,11 +4,15 @@ declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\Organization;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\ImportException;
use App\Service\Import\Importers\SolidtimeImporter;
use Exception;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
@@ -47,12 +51,20 @@ class SolidtimeImporterTest extends ImporterTestAbstract
$importer = new SolidtimeImporter;
$importer->init($organization);
$data = file_get_contents($zipPath);
Queue::fake([
RecalculateSpentTimeForProject::class,
RecalculateSpentTimeForTask::class,
]);
// Act
DB::enableQueryLog();
DB::flushQueryLog();
$importer->importData($data, $timezone);
$report = $importer->getReport();
$queryLog = DB::getQueryLog();
// Assert
$this->assertCount(25, $queryLog);
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(true);
$this->checkTimeEntries($testScenario);
$this->assertSame(2, $report->timeEntriesCreated);
@@ -61,6 +73,8 @@ class SolidtimeImporterTest extends ImporterTestAbstract
$this->assertSame(1, $report->usersCreated);
$this->assertSame(3, $report->projectsCreated);
$this->assertSame(2, $report->clientsCreated);
Queue::assertPushed(RecalculateSpentTimeForProject::class, 1);
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
}
public function test_import_of_test_file_twice_succeeds(): void
@@ -75,12 +89,20 @@ class SolidtimeImporterTest extends ImporterTestAbstract
$importer->importData($data, $timezone);
$importer = new SolidtimeImporter;
$importer->init($organization);
Queue::fake([
RecalculateSpentTimeForProject::class,
RecalculateSpentTimeForTask::class,
]);
// Act
DB::enableQueryLog();
DB::flushQueryLog();
$importer->importData($data, $timezone);
$report = $importer->getReport();
$queryLog = DB::getQueryLog();
// Assert
$this->assertCount(13, $queryLog);
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries(true);
$this->checkTimeEntries($testScenario, true);
$this->assertSame(2, $report->timeEntriesCreated);
@@ -89,5 +111,7 @@ class SolidtimeImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->usersCreated);
$this->assertSame(0, $report->projectsCreated);
$this->assertSame(0, $report->clientsCreated);
Queue::assertPushed(RecalculateSpentTimeForProject::class, 1);
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
}
}

View File

@@ -4,12 +4,15 @@ declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
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;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
@@ -23,6 +26,10 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
public function test_import_of_test_file_succeeds(): void
{
// Arrange
Queue::fake([
RecalculateSpentTimeForProject::class,
RecalculateSpentTimeForTask::class,
]);
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new TogglTimeEntriesImporter;
@@ -37,7 +44,7 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
$queryLog = DB::getQueryLog();
// Assert
$this->assertCount(21, $queryLog);
$this->assertCount(22, $queryLog);
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
$this->checkTimeEntries($testScenario);
$this->assertSame(2, $report->timeEntriesCreated);
@@ -46,11 +53,17 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(1, $report->usersCreated);
$this->assertSame(2, $report->projectsCreated);
$this->assertSame(1, $report->clientsCreated);
Queue::assertPushed(RecalculateSpentTimeForProject::class, 2);
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
}
public function test_import_of_test_with_special_characters_description_succeeds(): void
{
// Arrange
Queue::fake([
RecalculateSpentTimeForProject::class,
RecalculateSpentTimeForTask::class,
]);
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new TogglTimeEntriesImporter;
@@ -84,6 +97,10 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
$importer->importData($data, $timezone);
$importer = new TogglTimeEntriesImporter;
$importer->init($organization);
Queue::fake([
RecalculateSpentTimeForProject::class,
RecalculateSpentTimeForTask::class,
]);
// Act
DB::enableQueryLog();
@@ -93,7 +110,7 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
$queryLog = DB::getQueryLog();
// Assert
$this->assertCount(13, $queryLog);
$this->assertCount(14, $queryLog);
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
$this->checkTimeEntries($testScenario, true);
$this->assertSame(2, $report->timeEntriesCreated);
@@ -102,5 +119,7 @@ class TogglTimeEntriesImporterTest extends ImporterTestAbstract
$this->assertSame(0, $report->usersCreated);
$this->assertSame(0, $report->projectsCreated);
$this->assertSame(0, $report->clientsCreated);
Queue::assertPushed(RecalculateSpentTimeForProject::class, 2);
Queue::assertPushed(RecalculateSpentTimeForTask::class, 1);
}
}

View File

@@ -29,7 +29,7 @@ class TimezoneServiceTest extends TestCase
// Assert
$this->assertIsArray($result);
$this->assertCount(419, $result);
$this->assertTrue(in_array(count($result), [418, 419], true));
$this->assertContains('Europe/Vienna', $result);
$this->assertContains('Europe/Berlin', $result);
$this->assertContains('Europe/London', $result);