mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-06-15 13:32:43 +01:00
Added member and invitation endpoints
This commit is contained in:
@@ -73,10 +73,18 @@ class CreateNewUser implements CreatesNewUsers
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,20 +4,22 @@ declare(strict_types=1);
|
||||
|
||||
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 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
|
||||
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 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
/**
|
||||
* Get the validation rules for adding a team member.
|
||||
*
|
||||
* @return array<string, array<ValidationRule|Rule|string>>
|
||||
* @return array<string, array<ValidationRule|Rule|string|In>>
|
||||
*/
|
||||
protected function rules(): array
|
||||
{
|
||||
@@ -72,9 +80,16 @@ class AddOrganizationMember implements AddsTeamMembers
|
||||
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,
|
||||
]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -34,11 +34,19 @@ class CreateOrganization implements CreatesTeams
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -4,31 +4,36 @@ declare(strict_types=1);
|
||||
|
||||
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 @@ class InviteOrganizationMember implements InvitesTeamMembers
|
||||
/**
|
||||
* Get the validation rules for inviting a team member.
|
||||
*
|
||||
* @return array<string, array<ValidationRule|Rule|string>>
|
||||
* @return array<string, array<ValidationRule|Rule|string|In>>
|
||||
*/
|
||||
protected function rules(Organization $organization): array
|
||||
{
|
||||
@@ -72,9 +77,16 @@ class InviteOrganizationMember implements InvitesTeamMembers
|
||||
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,
|
||||
]),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
67
app/Actions/Jetstream/UpdateMemberRole.php
Normal file
67
app/Actions/Jetstream/UpdateMemberRole.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\Jetstream;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Membership;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use App\Service\PermissionStore;
|
||||
use App\Service\UserService;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Laravel\Jetstream\Events\TeamMemberUpdated;
|
||||
|
||||
class UpdateMemberRole
|
||||
{
|
||||
/**
|
||||
* Update the role for the given team member.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function update(User $actingUser, Organization $organization, string $userId, string $role): void
|
||||
{
|
||||
if (! app(PermissionStore::class)->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));
|
||||
}
|
||||
}
|
||||
15
app/Enums/Role.php
Normal file
15
app/Enums/Role.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum Role: string
|
||||
{
|
||||
case Owner = 'owner';
|
||||
case Admin = 'admin';
|
||||
case Manager = 'manager';
|
||||
case Employee = 'employee';
|
||||
case Placeholder = 'placeholder';
|
||||
|
||||
}
|
||||
10
app/Exceptions/Api/InactiveUserCanNotBeUsedApiException.php
Normal file
10
app/Exceptions/Api/InactiveUserCanNotBeUsedApiException.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class InactiveUserCanNotBeUsedApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'inactive_user_can_not_be_used';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\Api;
|
||||
|
||||
class UserIsAlreadyMemberOfProjectApiException extends ApiException
|
||||
{
|
||||
public const string KEY = 'user_is_already_member_of_project';
|
||||
}
|
||||
57
app/Http/Controllers/Api/V1/InvitationController.php
Normal file
57
app/Http/Controllers/Api/V1/InvitationController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
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\Models\Organization;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Laravel\Jetstream\Contracts\InvitesTeamMembers;
|
||||
|
||||
class InvitationController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all invitations of an organization
|
||||
*
|
||||
* @return InvitationCollection<InvitationResource>
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
}
|
||||
@@ -6,21 +6,32 @@ namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
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<MemberResource>>
|
||||
* @return MemberCollection<MemberPivotResource>>
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*
|
||||
@@ -37,15 +48,51 @@ class MemberController extends Controller
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
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\Http\Resources\V1\ProjectMember\ProjectMemberResource;
|
||||
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 @@ class ProjectMemberController extends Controller
|
||||
/**
|
||||
* 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();
|
||||
|
||||
|
||||
26
app/Http/Requests/V1/Invitation/InvitationIndexRequest.php
Normal file
26
app/Http/Requests/V1/Invitation/InvitationIndexRequest.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Invitation;
|
||||
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
/**
|
||||
* @property Organization $organization
|
||||
*/
|
||||
class InvitationIndexRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
];
|
||||
}
|
||||
}
|
||||
38
app/Http/Requests/V1/Invitation/InvitationStoreRequest.php
Normal file
38
app/Http/Requests/V1/Invitation/InvitationStoreRequest.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Invitation;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* @property Organization $organization
|
||||
*/
|
||||
class InvitationStoreRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'email' => [
|
||||
'required',
|
||||
'email',
|
||||
],
|
||||
'role' => [
|
||||
'required',
|
||||
'string',
|
||||
// TODO: placeholder role should not be allowed
|
||||
Rule::enum(Role::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
39
app/Http/Requests/V1/Member/MemberUpdateRequest.php
Normal file
39
app/Http/Requests/V1/Member/MemberUpdateRequest.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\V1\Member;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Organization;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
/**
|
||||
* @property Organization $organization
|
||||
*/
|
||||
class MemberUpdateRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
|
||||
*/
|
||||
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),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
18
app/Http/Resources/V1/Invitation/InvitationCollection.php
Normal file
18
app/Http/Resources/V1/Invitation/InvitationCollection.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Invitation;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class InvitationCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $collects = InvitationResource::class;
|
||||
}
|
||||
32
app/Http/Resources/V1/Invitation/InvitationResource.php
Normal file
32
app/Http/Resources/V1/Invitation/InvitationResource.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Invitation;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\OrganizationInvitation;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property OrganizationInvitation $resource
|
||||
*/
|
||||
class InvitationResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|array<string>>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
18
app/Http/Resources/V1/Member/MemberCollection.php
Normal file
18
app/Http/Resources/V1/Member/MemberCollection.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Member;
|
||||
|
||||
use App\Http\Resources\PaginatedResourceCollection;
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class MemberCollection extends ResourceCollection implements PaginatedResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $collects = MemberPivotResource::class;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\User;
|
||||
namespace App\Http\Resources\V1\Member;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Membership;
|
||||
@@ -12,7 +12,7 @@ use Illuminate\Http\Request;
|
||||
/**
|
||||
* @property User $resource
|
||||
*/
|
||||
class MemberResource extends BaseResource
|
||||
class MemberPivotResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
@@ -25,8 +25,10 @@ class MemberResource extends BaseResource
|
||||
$membership = $this->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 */
|
||||
41
app/Http/Resources/V1/Member/MemberResource.php
Normal file
41
app/Http/Resources/V1/Member/MemberResource.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\Member;
|
||||
|
||||
use App\Http\Resources\V1\BaseResource;
|
||||
use App\Models\Membership;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* @property Membership $resource
|
||||
*/
|
||||
class MemberResource extends BaseResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, string|bool|int|null|array<string>>
|
||||
*/
|
||||
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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\V1\User;
|
||||
|
||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||
|
||||
class MemberCollection extends ResourceCollection
|
||||
{
|
||||
/**
|
||||
* The resource that this resource collects.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $collects = MemberResource::class;
|
||||
}
|
||||
@@ -25,6 +25,7 @@ class RemovePlaceholder
|
||||
|
||||
foreach ($placeholders as $placeholder) {
|
||||
$userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholder, $event->user);
|
||||
$placeholder->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
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 @@ use Laravel\Jetstream\Membership as JetstreamMembership;
|
||||
* @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<User, Membership>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Organization, Membership>
|
||||
*/
|
||||
public function organization(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Organization::class, 'organization_id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ use Laravel\Jetstream\Team as JetstreamTeam;
|
||||
* @property bool $personal_team
|
||||
* @property string $currency
|
||||
* @property int|null $billable_rate
|
||||
* @property string $user_id
|
||||
* @property User $owner
|
||||
* @property Collection<User> $users
|
||||
* @property Collection<string, User> $realUsers
|
||||
@@ -101,6 +102,7 @@ class Organization extends JetstreamTeam
|
||||
{
|
||||
return $this->belongsToMany(Jetstream::userModel(), Jetstream::membershipModel())
|
||||
->withPivot([
|
||||
'id',
|
||||
'role',
|
||||
'billable_rate',
|
||||
])
|
||||
|
||||
@@ -12,6 +12,9 @@ use Laravel\Jetstream\TeamInvitation as JetstreamTeamInvitation;
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $email
|
||||
* @property string $role
|
||||
* @property string $organization_id
|
||||
* @property-read Organization $organization
|
||||
*/
|
||||
class OrganizationInvitation extends JetstreamTeamInvitation
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
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 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* @property-read Project $project
|
||||
* @property-read User $user
|
||||
*
|
||||
* @method static Builder<ProjectMember> whereBelongsToOrganization(Organization $organization)
|
||||
* @method static ProjectMemberFactory factory()
|
||||
*/
|
||||
class ProjectMember extends Model
|
||||
@@ -49,4 +51,14 @@ class ProjectMember extends Model
|
||||
{
|
||||
return $this->belongsTo(User::class, 'user_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<ProjectMember> $builder
|
||||
*/
|
||||
public function scopeWhereBelongsToOrganization(Builder $builder, Organization $organization): void
|
||||
{
|
||||
$builder->whereHas('project', static function (Builder $query) use ($organization): void {
|
||||
$query->whereBelongsTo($organization, 'organization');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,7 @@ class User extends Authenticatable
|
||||
{
|
||||
return $this->belongsToMany(Organization::class, Membership::class)
|
||||
->withPivot([
|
||||
'id',
|
||||
'role',
|
||||
'billable_rate',
|
||||
])
|
||||
|
||||
@@ -23,6 +23,7 @@ use Illuminate\Database\Eloquent\Model;
|
||||
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 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->app->scoped(PermissionStore::class, function (Application $app): PermissionStore {
|
||||
return new PermissionStore();
|
||||
});
|
||||
|
||||
Route::model('member', Membership::class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,9 @@ use App\Actions\Jetstream\DeleteOrganization;
|
||||
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\Currency;
|
||||
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 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
Jetstream::deleteUsersUsing(DeleteUser::class);
|
||||
Jetstream::useTeamModel(Organization::class);
|
||||
Jetstream::useTeamInvitationModel(OrganizationInvitation::class);
|
||||
app()->singleton(UpdateTeamMemberRole::class, UpdateMemberRole::class);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +60,47 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
{
|
||||
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 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'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 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'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 @@ class JetstreamServiceProvider extends ServiceProvider
|
||||
'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()
|
||||
|
||||
@@ -4,12 +4,19 @@ declare(strict_types=1);
|
||||
|
||||
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 @@ class UserService
|
||||
->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
68
database/factories/MembershipFactory.php
Normal file
68
database/factories/MembershipFactory.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\Role;
|
||||
use App\Models\Membership;
|
||||
use App\Models\Organization;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends Factory<Membership>
|
||||
*/
|
||||
class MembershipFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -81,15 +81,17 @@ class UserFactory extends Factory
|
||||
*/
|
||||
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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ return new class extends Migration
|
||||
->onDelete('restrict')
|
||||
->onUpdate('cascade');
|
||||
$table->timestamps();
|
||||
$table->unique(['project_id', 'user_id']);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
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 @@ class DatabaseSeeder extends Seeder
|
||||
{
|
||||
$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()
|
||||
|
||||
@@ -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 @@ return [
|
||||
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',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
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 @@ Route::middleware('auth:api')->prefix('v1')->name('v1.')->group(static function
|
||||
// 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
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ class InviteTeamMemberTest extends TestCase
|
||||
// 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 @@ class InviteTeamMemberTest extends TestCase
|
||||
{
|
||||
// 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 @@ class InviteTeamMemberTest extends TestCase
|
||||
|
||||
// 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 @@ class InviteTeamMemberTest extends TestCase
|
||||
// Assert
|
||||
$this->assertCount(1, $owner->currentTeam->fresh()->teamInvitations);
|
||||
$user->refresh();
|
||||
$this->assertCount(0, $user->organizations);
|
||||
$this->assertCount(1, $user->organizations);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ class LeaveTeamTest extends TestCase
|
||||
|
||||
$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
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Membership;
|
||||
use App\Models\User;
|
||||
use App\Providers\RouteServiceProvider;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
@@ -52,7 +53,12 @@ class RegistrationTest extends TestCase
|
||||
$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
|
||||
|
||||
@@ -20,9 +20,9 @@ class RemoveTeamMemberTest extends TestCase
|
||||
$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
|
||||
|
||||
@@ -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 @@ class UpdateTeamMemberRoleTest extends TestCase
|
||||
|
||||
$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'
|
||||
));
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
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<string> $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 @@ class ApiEndpointTestAbstract extends TestCase
|
||||
} 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
79
tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php
Normal file
79
tests/Unit/Endpoint/Api/V1/InvitationEndpointTest.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Endpoint\Api\V1;
|
||||
|
||||
use App\Models\OrganizationInvitation;
|
||||
use Laravel\Passport\Passport;
|
||||
|
||||
class InvitationEndpointTest extends ApiEndpointTestAbstract
|
||||
{
|
||||
public function test_index_fails_if_user_has_no_permission_to_view_invitations(): void
|
||||
{
|
||||
// Arrange
|
||||
$data = $this->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);
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,27 @@ declare(strict_types=1);
|
||||
|
||||
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,12 +34,72 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
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
|
||||
{
|
||||
$data = $this->createUserWithPermission([
|
||||
@@ -33,15 +108,13 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
$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 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
$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 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
$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 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
$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 @@ class MemberEndpointTest extends ApiEndpointTestAbstract
|
||||
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);
|
||||
|
||||
@@ -142,6 +142,69 @@ class ProjectMemberEndpointTest extends ApiEndpointTestAbstract
|
||||
$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
|
||||
|
||||
@@ -11,6 +11,21 @@ use Laravel\Passport\Passport;
|
||||
|
||||
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
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tests\Unit\Model;
|
||||
|
||||
use App\Models\Organization;
|
||||
use App\Models\Project;
|
||||
use App\Models\ProjectMember;
|
||||
use App\Models\User;
|
||||
@@ -40,4 +41,22 @@ class ProjectMemberModelTest extends ModelTestAbstract
|
||||
$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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ declare(strict_types=1);
|
||||
|
||||
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 @@ class UserServiceTest extends TestCase
|
||||
{
|
||||
// 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 @@ class UserServiceTest extends TestCase
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user