diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 665888ee..8afe2417 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -73,10 +73,18 @@ public function create(array $input): User */ protected function createTeam(User $user): void { - $user->ownedTeams()->save(Organization::forceCreate([ - 'user_id' => $user->id, - 'name' => explode(' ', $user->name, 2)[0]."'s Organization", - 'personal_team' => true, - ])); + $organization = new Organization(); + $organization->name = explode(' ', $user->name, 2)[0]."'s Organization"; + $organization->personal_team = true; + $organization->owner()->associate($user); + $organization->save(); + + $organization->users()->attach( + $user, [ + 'role' => 'owner', + ] + ); + + $user->ownedTeams()->save($organization); } } diff --git a/app/Actions/Jetstream/AddOrganizationMember.php b/app/Actions/Jetstream/AddOrganizationMember.php index 8d3db618..de444e77 100644 --- a/app/Actions/Jetstream/AddOrganizationMember.php +++ b/app/Actions/Jetstream/AddOrganizationMember.php @@ -4,20 +4,22 @@ namespace App\Actions\Jetstream; +use App\Enums\Role; use App\Models\Organization; use App\Models\User; +use App\Service\UserService; use Closure; -use Illuminate\Contracts\Validation\Rule; 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; -use Laravel\Jetstream\Jetstream; -use Laravel\Jetstream\Rules\Role; class AddOrganizationMember implements AddsTeamMembers { @@ -37,9 +39,15 @@ public function add(User $owner, Organization $organization, string $email, ?str AddingTeamMember::dispatch($organization, $newOrganizationMember); - $organization->users()->attach( - $newOrganizationMember, ['role' => $role] - ); + DB::transaction(function () use ($organization, $newOrganizationMember, $role) { + $organization->users()->attach( + $newOrganizationMember, ['role' => $role] + ); + + if ($role === Role::Owner->value) { + app(UserService::class)->changeOwnership($organization, $newOrganizationMember); + } + }); TeamMemberAdded::dispatch($organization, $newOrganizationMember); } @@ -60,7 +68,7 @@ protected function validate(Organization $organization, string $email, ?string $ /** * Get the validation rules for adding a team member. * - * @return array> + * @return array> */ protected function rules(): array { @@ -72,9 +80,16 @@ protected function rules(): array 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, + 'role' => [ + 'required', + 'string', + Rule::in([ + Role::Owner->value, + Role::Admin->value, + Role::Manager->value, + Role::Employee->value, + ]), + ], ]); } diff --git a/app/Actions/Jetstream/CreateOrganization.php b/app/Actions/Jetstream/CreateOrganization.php index 99ea48ff..5806b15f 100644 --- a/app/Actions/Jetstream/CreateOrganization.php +++ b/app/Actions/Jetstream/CreateOrganization.php @@ -34,11 +34,19 @@ public function create(User $user, array $input): Organization AddingTeam::dispatch($user); - /** @var Organization $organization */ - $organization = $user->ownedTeams()->create([ - 'name' => $input['name'], - 'personal_team' => false, - ]); + $organization = new Organization(); + $organization->name = $input['name']; + $organization->personal_team = false; + $organization->owner()->associate($user); + $organization->save(); + + $organization->users()->attach( + $user, [ + 'role' => 'owner', + ] + ); + + $user->ownedTeams()->save($organization); $user->switchTeam($organization); diff --git a/app/Actions/Jetstream/InviteOrganizationMember.php b/app/Actions/Jetstream/InviteOrganizationMember.php index a73ebac2..129aa3cb 100644 --- a/app/Actions/Jetstream/InviteOrganizationMember.php +++ b/app/Actions/Jetstream/InviteOrganizationMember.php @@ -4,31 +4,36 @@ namespace App\Actions\Jetstream; +use App\Enums\Role; use App\Models\Organization; use App\Models\OrganizationInvitation; use App\Models\User; +use App\Service\PermissionStore; use Closure; -use Illuminate\Contracts\Validation\Rule; +use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; -use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; +use Illuminate\Validation\Rules\In; use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent; use Laravel\Jetstream\Contracts\InvitesTeamMembers; use Laravel\Jetstream\Events\InvitingTeamMember; -use Laravel\Jetstream\Jetstream; use Laravel\Jetstream\Mail\TeamInvitation; -use Laravel\Jetstream\Rules\Role; class InviteOrganizationMember implements InvitesTeamMembers { /** * Invite a new team member to the given team. + * + * @throws AuthorizationException */ public function invite(User $user, Organization $organization, string $email, ?string $role = null): void { - Gate::forUser($user)->authorize('addTeamMember', $organization); + if (! app(PermissionStore::class)->has($organization, 'invitations:create')) { + throw new AuthorizationException(); + } $this->validate($organization, $email, $role); @@ -59,7 +64,7 @@ protected function validate(Organization $organization, string $email, ?string $ /** * Get the validation rules for inviting a team member. * - * @return array> + * @return array> */ protected function rules(Organization $organization): array { @@ -72,9 +77,16 @@ protected function rules(Organization $organization): array return $builder->whereBelongsTo($organization, 'organization'); }))->withMessage(__('This user has already been invited to the team.')), ], - 'role' => Jetstream::hasRoles() - ? ['required', 'string', new Role] - : null, + 'role' => [ + 'required', + 'string', + Rule::in([ + Role::Owner->value, + Role::Admin->value, + Role::Manager->value, + Role::Employee->value, + ]), + ], ]); } diff --git a/app/Actions/Jetstream/UpdateMemberRole.php b/app/Actions/Jetstream/UpdateMemberRole.php new file mode 100644 index 00000000..13a52fb8 --- /dev/null +++ b/app/Actions/Jetstream/UpdateMemberRole.php @@ -0,0 +1,67 @@ +has($organization, 'members:change-role')) { + throw new AuthorizationException(); + } + + $user = User::where('id', '=', $userId)->firstOrFail(); + $member = Membership::whereBelongsTo($user)->whereBelongsTo($organization)->firstOrFail(); + if ($member->role === Role::Placeholder->value) { + abort(403, 'Cannot update the role of a placeholder member.'); + } + + Validator::make([ + 'role' => $role, + ], [ + 'role' => [ + 'required', + 'string', + Rule::in([ + Role::Owner->value, + Role::Admin->value, + Role::Manager->value, + Role::Employee->value, + ]), + ], + ])->validate(); + + DB::transaction(function () use ($organization, $userId, $role, $user) { + $organization->users()->updateExistingPivot($userId, [ + 'role' => $role, + ]); + + if ($role === Role::Owner->value) { + app(UserService::class)->changeOwnership($organization, $user); + } + }); + + TeamMemberUpdated::dispatch($organization->fresh(), User::findOrFail($userId)); + } +} diff --git a/app/Enums/Role.php b/app/Enums/Role.php new file mode 100644 index 00000000..3815ce16 --- /dev/null +++ b/app/Enums/Role.php @@ -0,0 +1,15 @@ + + * + * @throws AuthorizationException + * + * @operationId getInvitations + */ + public function index(Organization $organization, InvitationIndexRequest $request): InvitationCollection + { + $this->checkPermission($organization, 'invitations:view'); + + $invitations = $organization->teamInvitations() + ->paginate(); + + return InvitationCollection::make($invitations); + } + + /** + * Invite a user to the organization + * + * @throws AuthorizationException + * + * @operationId invite + */ + public function store(Organization $organization, InvitationStoreRequest $request): JsonResponse + { + $this->checkPermission($organization, 'invitations:create'); + + app(InvitesTeamMembers::class)->invite( + $request->user(), + $organization, + $request->input('email'), + $request->input('role') + ); + + return response()->json(null, 204); + } +} diff --git a/app/Http/Controllers/Api/V1/MemberController.php b/app/Http/Controllers/Api/V1/MemberController.php index 777cfd85..c6e7f506 100644 --- a/app/Http/Controllers/Api/V1/MemberController.php +++ b/app/Http/Controllers/Api/V1/MemberController.php @@ -6,21 +6,32 @@ use App\Exceptions\Api\UserNotPlaceholderApiException; use App\Http\Requests\V1\Member\MemberIndexRequest; -use App\Http\Resources\V1\User\MemberCollection; -use App\Http\Resources\V1\User\MemberResource; +use App\Http\Requests\V1\Member\MemberUpdateRequest; +use App\Http\Resources\V1\Member\MemberCollection; +use App\Http\Resources\V1\Member\MemberPivotResource; +use App\Http\Resources\V1\Member\MemberResource; +use App\Models\Membership; use App\Models\Organization; -use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\JsonResource; use Laravel\Jetstream\Contracts\InvitesTeamMembers; class MemberController extends Controller { + protected function checkPermission(Organization $organization, string $permission, ?Membership $membership = null): void + { + parent::checkPermission($organization, $permission); + if ($membership !== null && $membership->organization_id !== $organization->id) { + throw new AuthorizationException('Member does not belong to organization'); + } + } + /** * List all members of an organization * - * @return MemberCollection> + * @return MemberCollection> * * @throws AuthorizationException * @@ -37,15 +48,51 @@ public function index(Organization $organization, MemberIndexRequest $request): } /** - * Invite a placeholder user to become a member of the organization + * Update a member of the organization + * + * @throws AuthorizationException + * + * @operationId updateMember + */ + public function update(Organization $organization, Membership $membership, MemberUpdateRequest $request): JsonResource + { + $this->checkPermission($organization, 'members:update', $membership); + + $membership->billable_rate = $request->input('billable_rate'); + $membership->role = $request->input('role'); + $membership->save(); + + return new MemberResource($membership); + } + + /** + * Remove a member of the organization. + * + * @throws AuthorizationException + * + * @operationId removeMember + */ + public function destroy(Organization $organization, Membership $membership): JsonResponse + { + $this->checkPermission($organization, 'members:delete', $membership); + + $membership->delete(); + + return response() + ->json(null, 204); + } + + /** + * Invite a placeholder member to become a real member of the organization * * @throws AuthorizationException|UserNotPlaceholderApiException * * @operationId invitePlaceholder */ - public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse + public function invitePlaceholder(Organization $organization, Membership $membership, Request $request): JsonResponse { - $this->checkPermission($organization, 'members:invite-placeholder'); + $this->checkPermission($organization, 'members:invite-placeholder', $membership); + $user = $membership->user; if (! $user->is_placeholder) { throw new UserNotPlaceholderApiException(); diff --git a/app/Http/Controllers/Api/V1/ProjectMemberController.php b/app/Http/Controllers/Api/V1/ProjectMemberController.php index 3c0cf5ae..2b0e1e5a 100644 --- a/app/Http/Controllers/Api/V1/ProjectMemberController.php +++ b/app/Http/Controllers/Api/V1/ProjectMemberController.php @@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api\V1; +use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException; +use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException; use App\Http\Requests\V1\ProjectMember\ProjectMemberStoreRequest; use App\Http\Requests\V1\ProjectMember\ProjectMemberUpdateRequest; use App\Http\Resources\V1\ProjectMember\ProjectMemberCollection; @@ -11,6 +13,7 @@ use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; +use App\Models\User; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; @@ -51,16 +54,25 @@ public function index(Organization $organization, Project $project): ProjectMemb /** * Add project member to project * - * @throws AuthorizationException + * @throws AuthorizationException|InactiveUserCanNotBeUsedApiException|UserIsAlreadyMemberOfProjectApiException * * @operationId createProjectMember */ public function store(Organization $organization, Project $project, ProjectMemberStoreRequest $request): JsonResource { $this->checkPermission($organization, 'project-members:create', $project); + + $user = User::findOrFail((string) $request->input('user_id')); + if ($user->is_placeholder) { + throw new InactiveUserCanNotBeUsedApiException(); + } + if (ProjectMember::whereBelongsTo($project, 'project')->whereBelongsTo($user, 'user')->exists()) { + throw new UserIsAlreadyMemberOfProjectApiException(); + } + $projectMember = new ProjectMember(); - $projectMember->user_id = $request->input('user_id'); $projectMember->billable_rate = $request->input('billable_rate'); + $projectMember->user()->associate($user); $projectMember->project()->associate($project); $projectMember->save(); diff --git a/app/Http/Requests/V1/Invitation/InvitationIndexRequest.php b/app/Http/Requests/V1/Invitation/InvitationIndexRequest.php new file mode 100644 index 00000000..7e4250b9 --- /dev/null +++ b/app/Http/Requests/V1/Invitation/InvitationIndexRequest.php @@ -0,0 +1,26 @@ +> + */ + public function rules(): array + { + return [ + ]; + } +} diff --git a/app/Http/Requests/V1/Invitation/InvitationStoreRequest.php b/app/Http/Requests/V1/Invitation/InvitationStoreRequest.php new file mode 100644 index 00000000..dc02996a --- /dev/null +++ b/app/Http/Requests/V1/Invitation/InvitationStoreRequest.php @@ -0,0 +1,38 @@ +> + */ + public function rules(): array + { + return [ + 'email' => [ + 'required', + 'email', + ], + 'role' => [ + 'required', + 'string', + // TODO: placeholder role should not be allowed + Rule::enum(Role::class), + ], + ]; + } +} diff --git a/app/Http/Requests/V1/Member/MemberUpdateRequest.php b/app/Http/Requests/V1/Member/MemberUpdateRequest.php new file mode 100644 index 00000000..3b1859fa --- /dev/null +++ b/app/Http/Requests/V1/Member/MemberUpdateRequest.php @@ -0,0 +1,39 @@ +> + */ + public function rules(): array + { + return [ + 'billable_rate' => [ + 'nullable', + 'integer', + 'min:0', + ], + 'role' => [ + 'required', + 'string', + // TODO: placeholder role should not be allowed + Rule::enum(Role::class), + ], + ]; + } +} diff --git a/app/Http/Resources/V1/Invitation/InvitationCollection.php b/app/Http/Resources/V1/Invitation/InvitationCollection.php new file mode 100644 index 00000000..309eb7cb --- /dev/null +++ b/app/Http/Resources/V1/Invitation/InvitationCollection.php @@ -0,0 +1,18 @@ +> + */ + public function toArray(Request $request): array + { + return [ + /** @var string $id ID of the invitation */ + 'id' => $this->resource->id, + /** @var string $email Email */ + 'user_id' => $this->resource->email, + /** @var string $role Role */ + 'name' => $this->resource->role, + ]; + } +} diff --git a/app/Http/Resources/V1/Member/MemberCollection.php b/app/Http/Resources/V1/Member/MemberCollection.php new file mode 100644 index 00000000..f23b882a --- /dev/null +++ b/app/Http/Resources/V1/Member/MemberCollection.php @@ -0,0 +1,18 @@ +resource->getRelationValue('membership'); return [ - /** @var string $id ID */ - 'id' => $this->resource->id, + /** @var string $id ID of membership */ + 'id' => $membership->id, + /** @var string $id ID of user */ + 'user_id' => $this->resource->id, /** @var string $name Name */ 'name' => $this->resource->name, /** @var string $email Email */ diff --git a/app/Http/Resources/V1/Member/MemberResource.php b/app/Http/Resources/V1/Member/MemberResource.php new file mode 100644 index 00000000..57bf619e --- /dev/null +++ b/app/Http/Resources/V1/Member/MemberResource.php @@ -0,0 +1,41 @@ +> + */ + public function toArray(Request $request): array + { + return [ + /** @var string $id ID of membership */ + 'id' => $this->resource->id, + /** @var string $id ID of user */ + 'user_id' => $this->resource->user->id, + /** @var string $name Name */ + 'name' => $this->resource->user->name, + /** @var string $email Email */ + 'email' => $this->resource->user->email, + /** @var string $role Role */ + 'role' => $this->resource->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->user->is_placeholder, + /** @var int|null $billable_rate Billable rate in cents per hour */ + 'billable_rate' => $this->resource->billable_rate, + ]; + } +} diff --git a/app/Http/Resources/V1/User/MemberCollection.php b/app/Http/Resources/V1/User/MemberCollection.php deleted file mode 100644 index 68bf84df..00000000 --- a/app/Http/Resources/V1/User/MemberCollection.php +++ /dev/null @@ -1,17 +0,0 @@ -assignOrganizationEntitiesToDifferentUser($event->team, $placeholder, $event->user); + $placeholder->delete(); } } } diff --git a/app/Models/Membership.php b/app/Models/Membership.php index d8cb3d6d..a8c0fac5 100644 --- a/app/Models/Membership.php +++ b/app/Models/Membership.php @@ -4,7 +4,10 @@ namespace App\Models; +use Database\Factories\MembershipFactory; use Illuminate\Database\Eloquent\Concerns\HasUuids; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; use Laravel\Jetstream\Membership as JetstreamMembership; /** @@ -17,9 +20,12 @@ * @property string $updated_at * @property-read Organization $organization * @property-read User $user + * + * @method static MembershipFactory factory() */ class Membership extends JetstreamMembership { + use HasFactory; use HasUuids; /** @@ -28,4 +34,20 @@ class Membership extends JetstreamMembership * @var string */ protected $table = 'organization_user'; + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * @return BelongsTo + */ + public function organization(): BelongsTo + { + return $this->belongsTo(Organization::class, 'organization_id'); + } } diff --git a/app/Models/Organization.php b/app/Models/Organization.php index d03faedb..d82712c5 100644 --- a/app/Models/Organization.php +++ b/app/Models/Organization.php @@ -22,6 +22,7 @@ * @property bool $personal_team * @property string $currency * @property int|null $billable_rate + * @property string $user_id * @property User $owner * @property Collection $users * @property Collection $realUsers @@ -101,6 +102,7 @@ public function users(): BelongsToMany { return $this->belongsToMany(Jetstream::userModel(), Jetstream::membershipModel()) ->withPivot([ + 'id', 'role', 'billable_rate', ]) diff --git a/app/Models/OrganizationInvitation.php b/app/Models/OrganizationInvitation.php index 0f60d31f..4f7570a4 100644 --- a/app/Models/OrganizationInvitation.php +++ b/app/Models/OrganizationInvitation.php @@ -12,6 +12,9 @@ /** * @property string $id * @property string $email + * @property string $role + * @property string $organization_id + * @property-read Organization $organization */ class OrganizationInvitation extends JetstreamTeamInvitation { diff --git a/app/Models/ProjectMember.php b/app/Models/ProjectMember.php index cc1a26f4..e3b96a40 100644 --- a/app/Models/ProjectMember.php +++ b/app/Models/ProjectMember.php @@ -5,6 +5,7 @@ namespace App\Models; use Database\Factories\ProjectMemberFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -18,6 +19,7 @@ * @property-read Project $project * @property-read User $user * + * @method static Builder whereBelongsToOrganization(Organization $organization) * @method static ProjectMemberFactory factory() */ class ProjectMember extends Model @@ -49,4 +51,14 @@ public function user(): BelongsTo { return $this->belongsTo(User::class, 'user_id'); } + + /** + * @param Builder $builder + */ + public function scopeWhereBelongsToOrganization(Builder $builder, Organization $organization): void + { + $builder->whereHas('project', static function (Builder $query) use ($organization): void { + $query->whereBelongsTo($organization, 'organization'); + }); + } } diff --git a/app/Models/User.php b/app/Models/User.php index 07e3f096..96cdbc13 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -114,6 +114,7 @@ public function organizations(): BelongsToMany { return $this->belongsToMany(Organization::class, Membership::class) ->withPivot([ + 'id', 'role', 'billable_rate', ]) diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 67984f3d..d4ae8bae 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -23,6 +23,7 @@ use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Foundation\Application; use Illuminate\Support\Facades\App; +use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\URL; use Illuminate\Support\ServiceProvider; @@ -83,5 +84,7 @@ public function boot(): void $this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore { return new PermissionStore(); }); + + Route::model('member', Membership::class); } } diff --git a/app/Providers/JetstreamServiceProvider.php b/app/Providers/JetstreamServiceProvider.php index 99444adc..17447a5f 100644 --- a/app/Providers/JetstreamServiceProvider.php +++ b/app/Providers/JetstreamServiceProvider.php @@ -10,7 +10,9 @@ use App\Actions\Jetstream\DeleteUser; use App\Actions\Jetstream\InviteOrganizationMember; use App\Actions\Jetstream\RemoveOrganizationMember; +use App\Actions\Jetstream\UpdateMemberRole; use App\Actions\Jetstream\UpdateOrganization; +use App\Enums\Role; use App\Enums\Weekday; use App\Models\Organization; use App\Models\OrganizationInvitation; @@ -19,6 +21,7 @@ use Brick\Money\ISOCurrencyProvider; use Illuminate\Http\Request; use Illuminate\Support\ServiceProvider; +use Laravel\Jetstream\Actions\UpdateTeamMemberRole; use Laravel\Jetstream\Jetstream; class JetstreamServiceProvider extends ServiceProvider @@ -47,6 +50,7 @@ public function boot(): void Jetstream::deleteUsersUsing(DeleteUser::class); Jetstream::useTeamModel(Organization::class); Jetstream::useTeamInvitationModel(OrganizationInvitation::class); + app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class); } /** @@ -56,7 +60,47 @@ protected function configurePermissions(): void { Jetstream::defaultApiTokenPermissions([]); - Jetstream::role('admin', 'Administrator', [ + Jetstream::role(Role::Owner->value, 'Owner', [ + 'projects:view', + 'projects:view:all', + 'projects:create', + 'projects:update', + 'projects:delete', + 'project-members:view', + 'project-members:create', + 'project-members:update', + 'project-members:delete', + 'tasks:view', + 'tasks:create', + 'tasks:update', + 'tasks:delete', + 'time-entries:view:all', + 'time-entries:create:all', + 'time-entries:update:all', + 'time-entries:delete:all', + 'time-entries:view:own', + 'time-entries:create:own', + 'time-entries:update:own', + 'time-entries:delete:own', + 'tags:view', + 'tags:create', + 'tags:update', + 'tags:delete', + 'clients:view', + 'clients:create', + 'clients:update', + 'clients:delete', + 'organizations:view', + 'organizations:update', + 'import', + 'members:view', + 'members:invite-placeholder', + 'members:change-role', + 'members:update', + 'members:delete', + ])->description('Owner users can perform any action.'); + + Jetstream::role(Role::Admin->value, 'Administrator', [ 'projects:view', 'projects:view:all', 'projects:create', @@ -93,7 +137,7 @@ protected function configurePermissions(): void 'members:invite-placeholder', ])->description('Administrator users can perform any action.'); - Jetstream::role('manager', 'Manager', [ + Jetstream::role(Role::Manager->value, 'Manager', [ 'projects:view', 'projects:view:all', 'projects:create', @@ -127,7 +171,7 @@ protected function configurePermissions(): void 'members: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', [ + Jetstream::role(Role::Employee->value, 'Employee', [ 'projects:view', 'tags:view', 'tasks:view', @@ -138,7 +182,7 @@ protected function configurePermissions(): void 'organizations:view', ])->description('Employees have the ability to read, create, and update their own time entries.'); - Jetstream::role('placeholder', 'Placeholder', [ + Jetstream::role(Role::Placeholder->value, 'Placeholder', [ ])->description('Placeholders are used for importing data. They cannot log in and have no permissions.'); Jetstream::inertia() diff --git a/app/Service/UserService.php b/app/Service/UserService.php index 87be9978..ffa1b56d 100644 --- a/app/Service/UserService.php +++ b/app/Service/UserService.php @@ -4,12 +4,19 @@ namespace App\Service; +use App\Enums\Role; +use App\Models\Membership; use App\Models\Organization; +use App\Models\ProjectMember; use App\Models\TimeEntry; use App\Models\User; class UserService { + /** + * 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. + */ public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void { // Time entries @@ -19,5 +26,39 @@ public function assignOrganizationEntitiesToDifferentUser(Organization $organiza ->update([ 'user_id' => $toUser->getKey(), ]); + + // Project members + ProjectMember::query() + ->whereBelongsToOrganization($organization) + ->whereBelongsTo($fromUser, 'user') + ->update([ + 'user_id' => $toUser->getKey(), + ]); + } + + /** + * Change the ownership of an organization to a new user. + * The previous owner will be demoted to an admin. + */ + public function changeOwnership(Organization $organization, User $newOwner): void + { + $organization->update([ + 'user_id' => $newOwner->getKey(), + ]); + $userMembership = Membership::query() + ->whereBelongsTo($organization, 'organization') + ->whereBelongsTo($newOwner, 'user') + ->first(); + $userMembership->role = Role::Owner->value; + $userMembership->save(); + $oldOwners = Membership::query() + ->whereBelongsTo($organization, 'organization') + ->where('role', '=', Role::Owner->value) + ->where('user_id', '!=', $newOwner->getKey()) + ->get(); + foreach ($oldOwners as $oldOwner) { + $oldOwner->role = Role::Admin->value; + $oldOwner->save(); + } } } diff --git a/database/factories/MembershipFactory.php b/database/factories/MembershipFactory.php new file mode 100644 index 00000000..f2b7b1fe --- /dev/null +++ b/database/factories/MembershipFactory.php @@ -0,0 +1,68 @@ + + */ +class MembershipFactory extends Factory +{ + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'role' => Role::Employee, + 'organization_id' => OrganizationFactory::class, + 'user_id' => UserFactory::class, + ]; + } + + public function forOrganization(Organization $organization): static + { + return $this->state(function (array $attributes) use ($organization): array { + return [ + 'organization_id' => $organization->getKey(), + ]; + }); + } + + public function forUser(User $user): static + { + return $this->state(function (array $attributes) use ($user): array { + return [ + 'user_id' => $user->getKey(), + ]; + }); + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(function (array $attributes) { + return [ + 'email_verified_at' => null, + ]; + }); + } + + public function attachToOrganization(Organization $organization, array $pivot = []): static + { + return $this->afterCreating(function (User $user) use ($organization, $pivot) { + $user->organizations()->attach($organization, $pivot); + }); + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 1c879317..1e94da5b 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -81,15 +81,17 @@ public function attachToOrganization(Organization $organization, array $pivot = */ public function withPersonalOrganization(?callable $callback = null): static { - return $this->has( - Organization::factory() - ->state(fn (array $attributes, User $user) => [ + return $this->afterCreating(function (User $user) use ($callback): void { + $organization = Organization::factory() + ->state(fn (array $attributes) => [ 'name' => $user->name.'\'s Organization', 'user_id' => $user->id, 'personal_team' => true, ]) - ->when(is_callable($callback), $callback), - 'ownedTeams' - ); + ->when(is_callable($callback), $callback) + ->create(); + + $organization->users()->attach($user, ['role' => 'owner']); + }); } } diff --git a/database/migrations/2024_03_26_171253_create_project_members_table.php b/database/migrations/2024_03_26_171253_create_project_members_table.php index fe87a356..0c974be8 100644 --- a/database/migrations/2024_03_26_171253_create_project_members_table.php +++ b/database/migrations/2024_03_26_171253_create_project_members_table.php @@ -29,6 +29,7 @@ public function up(): void ->onDelete('restrict') ->onUpdate('cascade'); $table->timestamps(); + $table->unique(['project_id', 'user_id']); }); } diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 2e057b52..c0cda865 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -5,6 +5,7 @@ namespace Database\Seeders; // use Illuminate\Database\Console\Seeds\WithoutModelEvents; +use App\Enums\Role; use App\Models\Client; use App\Models\Organization; use App\Models\Project; @@ -24,40 +25,43 @@ public function run(): void { $this->deleteAll(); $userAcmeOwner = User::factory()->create([ - 'name' => 'ACME Admin', + 'name' => 'Acme Owner', 'email' => 'owner@acme.test', ]); $organizationAcme = Organization::factory()->withOwner($userAcmeOwner)->create([ 'name' => 'ACME Corp', ]); $userAcmeManager = User::factory()->withPersonalOrganization()->create([ - 'name' => 'Test User', + 'name' => 'Acme Manager', 'email' => 'test@example.com', ]); $userAcmeAdmin = User::factory()->withPersonalOrganization()->create([ - 'name' => 'ACME Admin', + 'name' => 'Acme Admin', 'email' => 'admin@acme.test', ]); $userAcmeEmployee = User::factory()->withPersonalOrganization()->create([ - 'name' => 'Max Mustermann', + 'name' => 'Acme Employee', 'email' => 'max.mustermann@acme.test', ]); $userAcmePlaceholder = User::factory()->placeholder()->create([ - 'name' => 'Old Employee', + 'name' => 'Acme Placeholder', 'email' => 'old.employee@acme.test', 'password' => null, ]); + $userAcmeOwner->organizations()->attach($organizationAcme, [ + 'role' => Role::Owner->value, + ]); $userAcmeManager->organizations()->attach($organizationAcme, [ - 'role' => 'manager', + 'role' => Role::Manager->value, ]); $userAcmeAdmin->organizations()->attach($organizationAcme, [ - 'role' => 'admin', + 'role' => Role::Admin->value, ]); $userAcmeEmployee->organizations()->attach($organizationAcme, [ - 'role' => 'employee', + 'role' => Role::Employee->value, ]); $userAcmePlaceholder->organizations()->attach($organizationAcme, [ - 'role' => 'employee', + 'role' => Role::Placeholder->value, ]); $timeEntriesAcmeAdmin = TimeEntry::factory() diff --git a/lang/en/exceptions.php b/lang/en/exceptions.php index d889cfd9..fb8c18e9 100644 --- a/lang/en/exceptions.php +++ b/lang/en/exceptions.php @@ -2,8 +2,10 @@ declare(strict_types=1); +use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Exceptions\Api\TimeEntryStillRunningApiException; +use App\Exceptions\Api\UserIsAlreadyMemberOfProjectApiException; use App\Exceptions\Api\UserNotPlaceholderApiException; return [ @@ -11,5 +13,7 @@ TimeEntryStillRunningApiException::KEY => 'Time entry is still running', UserNotPlaceholderApiException::KEY => 'The given user is not a placeholder', TimeEntryCanNotBeRestartedApiException::KEY => 'Time entry is already stopped and can not be restarted', + InactiveUserCanNotBeUsedApiException::KEY => 'Inactive user can not be used', + UserIsAlreadyMemberOfProjectApiException::KEY => 'User is already a member of the project', ], ]; diff --git a/routes/api.php b/routes/api.php index e1463176..1e1de749 100644 --- a/routes/api.php +++ b/routes/api.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Api\V1\ClientController; use App\Http\Controllers\Api\V1\ImportController; +use App\Http\Controllers\Api\V1\InvitationController; use App\Http\Controllers\Api\V1\MemberController; use App\Http\Controllers\Api\V1\OrganizationController; use App\Http\Controllers\Api\V1\ProjectController; @@ -36,7 +37,15 @@ // Member routes Route::name('members.')->group(static function () { Route::get('/organizations/{organization}/members', [MemberController::class, 'index'])->name('index'); - Route::post('/organizations/{organization}/members/{user}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder'); + Route::put('/organizations/{organization}/members/{member}', [MemberController::class, 'update'])->name('update'); + Route::delete('/organizations/{organization}/members/{member}', [MemberController::class, 'destroy'])->name('destroy'); + Route::post('/organizations/{organization}/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder'); + }); + + // Invitation routes + Route::name('invitations.')->group(static function () { + Route::get('/organizations/{organization}/invitations', [InvitationController::class, 'index'])->name('index'); + Route::post('/organizations/{organization}/invitations', [InvitationController::class, 'store'])->name('store'); }); // Project routes diff --git a/tests/Feature/CreateTeamTest.php b/tests/Feature/CreateTeamTest.php index 7026a478..5647a91d 100644 --- a/tests/Feature/CreateTeamTest.php +++ b/tests/Feature/CreateTeamTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; +use App\Models\Membership; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; @@ -14,13 +15,20 @@ class CreateTeamTest extends TestCase public function test_teams_can_be_created(): void { - $this->actingAs($user = User::factory()->withPersonalOrganization()->create()); + // Arrange + $user = User::factory()->withPersonalOrganization()->create(); + $this->actingAs($user); + // Act $response = $this->post('/teams', [ 'name' => 'Test Organization', ]); + // Assert + $newOrganization = $user->fresh()->ownedTeams()->latest('id')->first(); $this->assertCount(2, $user->fresh()->ownedTeams); - $this->assertEquals('Test Organization', $user->fresh()->ownedTeams()->latest('id')->first()->name); + $this->assertEquals('Test Organization', $newOrganization->name); + $member = Membership::query()->whereBelongsTo($user, 'user')->whereBelongsTo($newOrganization, 'organization')->firstOrFail(); + $this->assertSame('owner', $member->role); } } diff --git a/tests/Feature/InviteTeamMemberTest.php b/tests/Feature/InviteTeamMemberTest.php index 60bffb6b..5ac29982 100644 --- a/tests/Feature/InviteTeamMemberTest.php +++ b/tests/Feature/InviteTeamMemberTest.php @@ -118,7 +118,7 @@ public function test_team_member_invitations_can_be_accepted(): void // Assert $this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations); $user->refresh(); - $this->assertCount(1, $user->organizations); + $this->assertCount(2, $user->organizations); $this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id')); } @@ -126,9 +126,7 @@ public function test_team_member_invitations_of_placeholder_can_be_accepted_and_ { // Arrange Mail::fake(); - $placeholder = User::factory()->withPersonalOrganization()->create([ - 'is_placeholder' => true, - ]); + $placeholder = User::factory()->withPersonalOrganization()->placeholder()->create(); $owner = User::factory()->withPersonalOrganization()->create(); $owner->currentTeam->users()->attach($placeholder, ['role' => 'employee']); @@ -154,12 +152,11 @@ public function test_team_member_invitations_of_placeholder_can_be_accepted_and_ // Assert $user->refresh(); - $placeholder->refresh(); + $this->assertDatabaseMissing(User::class, ['id' => $placeholder->id]); $this->assertCount(0, $owner->currentTeam->fresh()->teamInvitations); - $this->assertCount(1, $user->organizations); + $this->assertCount(2, $user->organizations); $this->assertContains($owner->currentTeam->getKey(), $user->organizations->pluck('id')); $this->assertCount(5, $user->timeEntries); - $this->assertCount(0, $placeholder->timeEntries); } public function test_team_member_accept_fails_if_user_with_that_email_does_not_exist(): void @@ -185,6 +182,6 @@ public function test_team_member_accept_fails_if_user_with_that_email_does_not_e // Assert $this->assertCount(1, $owner->currentTeam->fresh()->teamInvitations); $user->refresh(); - $this->assertCount(0, $user->organizations); + $this->assertCount(1, $user->organizations); } } diff --git a/tests/Feature/LeaveTeamTest.php b/tests/Feature/LeaveTeamTest.php index ada5e130..598a3151 100644 --- a/tests/Feature/LeaveTeamTest.php +++ b/tests/Feature/LeaveTeamTest.php @@ -24,7 +24,7 @@ public function test_users_can_leave_teams(): void $response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id); - $this->assertCount(0, $user->currentTeam->fresh()->users); + $this->assertCount(1, $user->currentTeam->fresh()->users); } public function test_team_owners_cant_leave_their_own_team(): void diff --git a/tests/Feature/RegistrationTest.php b/tests/Feature/RegistrationTest.php index 0dcf69a2..ecd7a568 100644 --- a/tests/Feature/RegistrationTest.php +++ b/tests/Feature/RegistrationTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; +use App\Models\Membership; use App\Models\User; use App\Providers\RouteServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -52,7 +53,12 @@ public function test_new_users_can_register(): void $this->assertAuthenticated(); $response->assertRedirect(RouteServiceProvider::HOME); $user = User::where('email', 'test@example.com')->firstOrFail(); + $this->assertSame('Test User', $user->name); $this->assertSame('UTC', $user->timezone); + $organization = $user->organizations()->firstOrFail(); + $this->assertSame(true, $organization->personal_team); + $member = Membership::query()->whereBelongsTo($user, 'user')->whereBelongsTo($organization, 'organization')->firstOrFail(); + $this->assertSame('owner', $member->role); } public function test_new_users_can_register_and_frontend_can_send_timezone_for_user(): void diff --git a/tests/Feature/RemoveTeamMemberTest.php b/tests/Feature/RemoveTeamMemberTest.php index bf2bc717..d2611334 100644 --- a/tests/Feature/RemoveTeamMemberTest.php +++ b/tests/Feature/RemoveTeamMemberTest.php @@ -20,9 +20,9 @@ public function test_team_members_can_be_removed_from_teams(): void $otherUser = User::factory()->create(), ['role' => 'admin'] ); - $response = $this->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id); + $response = $this->withoutExceptionHandling()->delete('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id); - $this->assertCount(0, $user->currentTeam->fresh()->users); + $this->assertCount(1, $user->currentTeam->fresh()->users); } public function test_only_team_owner_can_remove_team_members(): void diff --git a/tests/Feature/UpdateTeamMemberRoleTest.php b/tests/Feature/UpdateTeamMemberRoleTest.php index 7eebecc9..6927361d 100644 --- a/tests/Feature/UpdateTeamMemberRoleTest.php +++ b/tests/Feature/UpdateTeamMemberRoleTest.php @@ -14,23 +14,69 @@ class UpdateTeamMemberRoleTest extends TestCase public function test_team_member_roles_can_be_updated(): void { - $this->actingAs($user = User::factory()->withPersonalOrganization()->create()); + // Arrange + $user = User::factory()->withPersonalOrganization()->create(); + $this->actingAs($user); $user->currentTeam->users()->attach( $otherUser = User::factory()->create(), ['role' => 'admin'] ); + // Act $response = $this->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id, [ 'role' => 'employee', ]); + // Assert $this->assertTrue($otherUser->fresh()->hasTeamRole( $user->currentTeam->fresh(), 'employee' )); } + public function test_team_member_roles_can_not_be_updated_to_placeholder(): void + { + // Arrange + $user = User::factory()->withPersonalOrganization()->create(); + $this->actingAs($user); + + $user->currentTeam->users()->attach( + $otherUser = User::factory()->create(), ['role' => 'admin'] + ); + + // Act + $response = $this->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id, [ + 'role' => 'placeholder', + ]); + + // Assert + $this->assertTrue($otherUser->fresh()->hasTeamRole( + $user->currentTeam->fresh(), 'admin' + )); + } + + public function test_team_member_roles_can_be_updated_to_owner_which_changes_ownership(): void + { + // Arrange + $user = User::factory()->withPersonalOrganization()->create(); + $this->actingAs($user); + $otherUser = User::factory()->create(); + $user->currentTeam->users()->attach($otherUser, ['role' => 'admin']); + + // Act + $response = $this->withoutExceptionHandling()->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->getKey(), [ + 'role' => 'owner', + ]); + + // Assert + $this->assertTrue($otherUser->fresh()->hasTeamRole( + $user->currentTeam->fresh(), 'owner' + )); + $this->assertSame($user->currentTeam->fresh()->user_id, $otherUser->getKey()); + } + public function test_only_team_owner_can_update_team_member_roles(): void { + // Arrange $user = User::factory()->withPersonalOrganization()->create(); $user->currentTeam->users()->attach( @@ -39,10 +85,13 @@ public function test_only_team_owner_can_update_team_member_roles(): void $this->actingAs($otherUser); + // Act $response = $this->put('/teams/'.$user->currentTeam->id.'/members/'.$otherUser->id, [ 'role' => 'employee', ]); + // Assert + $response->assertStatus(403); $this->assertTrue($otherUser->fresh()->hasTeamRole( $user->currentTeam->fresh(), 'admin' )); diff --git a/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php b/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php index 4dd8f37b..f3301a33 100644 --- a/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php +++ b/tests/Unit/Endpoint/Api/V1/ApiEndpointTestAbstract.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Endpoint\Api\V1; +use App\Models\Membership; use App\Models\Organization; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -16,7 +17,7 @@ class ApiEndpointTestAbstract extends TestCase /** * @param array $permissions - * @return object{user: User, organization: Organization} + * @return object{user: User, organization: Organization, member: Membership} */ protected function createUserWithPermission(array $permissions, bool $isOwner = false): object { @@ -28,13 +29,14 @@ protected function createUserWithPermission(array $permissions, bool $isOwner = } else { $organization = Organization::factory()->create(); } - $organization->users()->attach($user, [ + $membership = Membership::factory()->forUser($user)->forOrganization($organization)->create([ 'role' => 'custom-test', ]); return (object) [ 'user' => $user, 'organization' => $organization, + 'member' => $membership, ]; } } diff --git a/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php new file mode 100644 index 00000000..625b53c9 --- /dev/null +++ b/tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php @@ -0,0 +1,79 @@ +createUserWithPermission([ + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.invitations.index', $data->organization->id)); + + // Assert + $response->assertStatus(403); + } + + public function test_index_returns_invitations_of_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'invitations:view', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.invitations.index', $data->organization->getKey())); + + // Assert + $response->assertStatus(200); + } + + public function test_store_fails_if_user_has_no_permission_to_create_invitations(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [ + 'email' => 'test@mail.test', + 'role' => 'employee', + ]); + + // Assert + $response->assertStatus(403); + } + + public function test_store_invites_user_to_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'invitations:create', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.invitations.store', $data->organization->getKey()), [ + 'email' => 'test@asdf.at', + 'role' => 'employee', + ]); + + // Assert + $response->assertStatus(204); + $invitation = OrganizationInvitation::first(); + $this->assertNotNull($invitation); + $this->assertEquals('test@asdf.at', $invitation->email); + $this->assertEquals('employee', $invitation->role); + } +} diff --git a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php index 99245489..a79a1746 100644 --- a/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php @@ -4,12 +4,27 @@ namespace Tests\Unit\Endpoint\Api\V1; +use App\Models\Membership; use App\Models\Organization; use App\Models\User; use Laravel\Passport\Passport; class MemberEndpointTest extends ApiEndpointTestAbstract { + public function test_index_fails_if_user_has_no_permission_to_view_members(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.members.index', $data->organization->id)); + + // Assert + $response->assertStatus(403); + } + public function test_index_returns_members_of_organization(): void { // Arrange @@ -19,10 +34,70 @@ public function test_index_returns_members_of_organization(): void Passport::actingAs($data->user); // Act - $response = $this->getJson(route('api.v1.members.index', $data->organization->id)); + $response = $this->getJson(route('api.v1.members.index', $data->organization->getKey())); + + // Assert + $response->assertStatus(200); + } + + public function test_update_member_fails_if_user_has_no_permission_to_update_members(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $data->member->getKey()]), [ + 'billable_rate' => 10001, + 'role' => 'employee', + ]); + + // Assert + $response->assertStatus(403); + } + + public function test_update_member_fails_if_member_is_not_part_of_org(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'members:update', + ]); + $otherData = $this->createUserWithPermission([ + 'members:update', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.members.update', [$data->organization->getKey(), $otherData->member->getKey()]), [ + 'billable_rate' => 10001, + 'role' => 'employee', + ]); + + // Assert + $response->assertStatus(403); + } + + public function test_update_member_succeeds_if_data_is_valid(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'members:update', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.members.update', [$data->organization->id, $data->member]), [ + 'billable_rate' => 10001, + 'role' => 'employee', + ]); // Assert $response->assertStatus(200); + $member = $data->member; + $member->refresh(); + $this->assertSame(10001, $member->billable_rate); + $this->assertSame('employee', $member->role); } public function test_invite_placeholder_succeeds_if_data_is_valid(): void @@ -33,15 +108,13 @@ public function test_invite_placeholder_succeeds_if_data_is_valid(): void $user = User::factory()->create([ 'is_placeholder' => true, ]); - $data->organization->users()->attach($user, [ - 'role' => 'placeholder', - ]); + $member = Membership::factory()->forUser($user)->forOrganization($data->organization)->create(); Passport::actingAs($data->user); // Act $response = $this->postJson(route('api.v1.members.invite-placeholder', [ - 'organization' => $data->organization->id, - 'user' => $user->id, + 'organization' => $data->organization->getKey(), + 'member' => $member->getKey(), ])); // Assert @@ -49,6 +122,56 @@ public function test_invite_placeholder_succeeds_if_data_is_valid(): void $response->assertStatus(204); } + public function test_destroy_member_fails_if_user_has_no_permission_to_delete_members(): void + { + // Arrange + $data = $this->createUserWithPermission([ + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()])); + + // Assert + $response->assertStatus(403); + } + + public function test_destroy_member_fails_if_member_is_not_part_of_org(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'members:delete', + ]); + $otherData = $this->createUserWithPermission([ + 'members:delete', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $otherData->member->getKey()])); + + // Assert + $response->assertStatus(403); + } + + public function test_destroy_member_succeeds_if_data_is_valid(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'members:delete', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->deleteJson(route('api.v1.members.destroy', [$data->organization->getKey(), $data->member->getKey()])); + + // Assert + $response->assertStatus(204); + $this->assertDatabaseMissing(Membership::class, [ + 'id' => $data->member->getKey(), + ]); + } + public function test_invite_placeholder_fails_if_user_does_not_have_permission(): void { // Arrange @@ -57,11 +180,14 @@ public function test_invite_placeholder_fails_if_user_does_not_have_permission() $user = User::factory()->create([ 'is_placeholder' => true, ]); - $data->organization->users()->attach($user); + $member = Membership::factory()->forUser($user)->forOrganization($data->organization)->create(); Passport::actingAs($data->user); // Act - $response = $this->postJson(route('api.v1.members.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id])); + $response = $this->postJson(route('api.v1.members.invite-placeholder', [ + 'organization' => $data->organization->id, + 'member' => $member->id, + ])); // Assert $response->assertForbidden(); @@ -77,11 +203,14 @@ public function test_invite_placeholder_fails_if_user_is_not_part_of_organizatio $user = User::factory()->create([ 'is_placeholder' => true, ]); - $otherOrganization->users()->attach($user); + $member = Membership::factory()->forUser($user)->forOrganization($otherOrganization)->create(); Passport::actingAs($data->user); // Act - $response = $this->postJson(route('api.v1.members.invite-placeholder', ['organization' => $data->organization->id, 'user' => $user->id])); + $response = $this->postJson(route('api.v1.members.invite-placeholder', [ + 'organization' => $data->organization->id, + 'member' => $member->id, + ])); // Assert $response->assertForbidden(); @@ -96,7 +225,10 @@ public function test_invite_placeholder_returns_400_if_user_is_not_placeholder() Passport::actingAs($data->user); // Act - $response = $this->postJson(route('api.v1.members.invite-placeholder', ['organization' => $data->organization->id, 'user' => $data->user->id])); + $response = $this->postJson(route('api.v1.members.invite-placeholder', [ + 'organization' => $data->organization->id, + 'member' => $data->member->id, + ])); // Assert $response->assertStatus(400); diff --git a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php index a3cdd829..dc746d5d 100644 --- a/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/ProjectMemberEndpointTest.php @@ -142,6 +142,69 @@ public function test_store_endpoint_fails_if_given_user_does_not_belong_to_organ $response->assertInvalid(['user_id']); } + public function test_store_endpoint_fails_if_user_is_a_placeholder(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'project-members:create', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $projectMemberFake = ProjectMember::factory()->make(); + $user = User::factory()->attachToOrganization($data->organization)->placeholder()->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ + 'billable_rate' => $projectMemberFake->billable_rate, + 'user_id' => $user->getKey(), + ]); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'inactive_user_can_not_be_used', + 'message' => 'Inactive user can not be used', + ]); + $this->assertDatabaseMissing(ProjectMember::class, [ + 'billable_rate' => $projectMemberFake->billable_rate, + 'user_id' => $user->getKey(), + 'project_id' => $project->getKey(), + ]); + } + + public function test_store_endpoint_fails_if_user_is_already_member_of_project(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'project-members:create', + ]); + $project = Project::factory()->forOrganization($data->organization)->create(); + $projectMemberFake = ProjectMember::factory()->make(); + $user = User::factory()->attachToOrganization($data->organization)->create(); + ProjectMember::factory()->forProject($project)->forUser($user)->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->postJson(route('api.v1.project-members.store', [$data->organization->getKey(), $project->getKey()]), [ + 'billable_rate' => $projectMemberFake->billable_rate, + 'user_id' => $user->getKey(), + ]); + + // Assert + $response->assertStatus(400); + $response->assertExactJson([ + 'error' => true, + 'key' => 'user_is_already_member_of_project', + 'message' => 'User is already a member of the project', + ]); + $this->assertDatabaseMissing(ProjectMember::class, [ + 'billable_rate' => $projectMemberFake->billable_rate, + 'user_id' => $user->getKey(), + 'project_id' => $project->getKey(), + ]); + } + public function test_store_endpoint_creates_new_project_member(): void { // Arrange diff --git a/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php index 7a3f8a59..47247eea 100644 --- a/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TaskEndpointTest.php @@ -11,6 +11,21 @@ class TaskEndpointTest extends ApiEndpointTestAbstract { + public function test_non_valid_uuid_for_organization_id_fails(): void + { + // Arrange + $data = $this->createUserWithPermission([ + 'tasks:view', + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.tasks.index', ['invalid-uuid'])); + + // Assert + $response->assertStatus(404); + } + public function test_index_endpoint_fails_if_user_has_no_permission_to_view_tasks(): void { // Arrange diff --git a/tests/Unit/Model/ProjectMemberModelTest.php b/tests/Unit/Model/ProjectMemberModelTest.php index fa5e1dcd..55f573da 100644 --- a/tests/Unit/Model/ProjectMemberModelTest.php +++ b/tests/Unit/Model/ProjectMemberModelTest.php @@ -4,6 +4,7 @@ namespace Tests\Unit\Model; +use App\Models\Organization; use App\Models\Project; use App\Models\ProjectMember; use App\Models\User; @@ -40,4 +41,22 @@ public function test_it_belongs_to_a_user(): void $this->assertNotNull($userRel); $this->assertTrue($userRel->is($user)); } + + public function test_scope_where_belongs_to_organization_filters_project_members_to_only_retrieve_project_members_that_belong_to_a_project_of_the_organization(): void + { + // Arrange + $organization = Organization::factory()->create(); + $otherOrganization = Organization::factory()->create(); + $project = Project::factory()->forOrganization($organization)->create(); + $projectNotBelongingToOrganization = Project::factory()->forOrganization($otherOrganization)->create(); + $projectMember = ProjectMember::factory()->forProject($project)->create(); + $projectMemberNotBelongingToOrganization = ProjectMember::factory()->for($projectNotBelongingToOrganization)->create(); + + // Act + $projectMembers = ProjectMember::whereBelongsToOrganization($organization)->get(); + + // Assert + $this->assertCount(1, $projectMembers); + $this->assertTrue($projectMembers->first()->is($projectMember)); + } } diff --git a/tests/Unit/Service/UserServiceTest.php b/tests/Unit/Service/UserServiceTest.php index 79494844..b8d28acd 100644 --- a/tests/Unit/Service/UserServiceTest.php +++ b/tests/Unit/Service/UserServiceTest.php @@ -4,7 +4,11 @@ namespace Tests\Unit\Service; +use App\Enums\Role; +use App\Models\Membership; use App\Models\Organization; +use App\Models\Project; +use App\Models\ProjectMember; use App\Models\TimeEntry; use App\Models\User; use App\Service\UserService; @@ -19,13 +23,17 @@ public function test_assign_organization_entities_to_different_user(): void { // Arrange $organization = Organization::factory()->create(); + $project = Project::factory()->forOrganization($organization)->create(); $otherUser = User::factory()->create(); $fromUser = User::factory()->create(); $toUser = User::factory()->create(); TimeEntry::factory()->forOrganization($organization)->forUser($otherUser)->createMany(3); TimeEntry::factory()->forOrganization($organization)->forUser($fromUser)->createMany(3); + ProjectMember::factory()->forProject($project)->forUser($otherUser)->create(); + ProjectMember::factory()->forProject($project)->forUser($fromUser)->create(); // Act + /** @var UserService $userService */ $userService = app(UserService::class); $userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser); @@ -33,5 +41,32 @@ public function test_assign_organization_entities_to_different_user(): void $this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count()); $this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUser, 'user')->count()); $this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUser, 'user')->count()); + $this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUser, 'user')->count()); + $this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUser, 'user')->count()); + $this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count()); + } + + public function test_change_ownership_changes_ownership_of_organization_to_new_user(): void + { + // Arrange + $organization = Organization::factory()->create(); + $newOwner = User::factory()->create(); + $oldOwner = User::factory()->create(); + $organization->users()->attach($oldOwner->getKey(), [ + 'role' => Role::Owner->value, + ]); + $organization->users()->attach($newOwner->getKey(), [ + 'role' => Role::Admin->value, + ]); + + // Act + /** @var UserService $userService */ + $userService = app(UserService::class); + $userService->changeOwnership($organization, $newOwner); + + // Assert + $this->assertSame($newOwner->id, $organization->refresh()->user_id); + $this->assertSame(Role::Owner->value, Membership::whereBelongsTo($newOwner)->whereBelongsTo($organization)->firstOrFail()->role); + $this->assertSame(Role::Admin->value, Membership::whereBelongsTo($oldOwner)->whereBelongsTo($organization)->firstOrFail()->role); } }