mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-17 04:32:15 +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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user