Added member and invitation endpoints

This commit is contained in:
Constantin Graf
2024-04-10 17:45:53 +02:00
parent 234fa06324
commit b67961cb07
48 changed files with 1186 additions and 106 deletions

View File

@@ -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);
}
}

View File

@@ -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,
]),
],
]);
}

View File

@@ -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);

View File

@@ -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,
]),
],
]);
}

View 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
View 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';
}

View 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';
}

View File

@@ -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';
}

View 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);
}
}

View File

@@ -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();

View File

@@ -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();

View 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 [
];
}
}

View 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),
],
];
}
}

View 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),
],
];
}
}

View 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;
}

View 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,
];
}
}

View 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;
}

View File

@@ -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 */

View 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,
];
}
}

View File

@@ -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;
}

View File

@@ -25,6 +25,7 @@ class RemovePlaceholder
foreach ($placeholders as $placeholder) {
$userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholder, $event->user);
$placeholder->delete();
}
}
}

View File

@@ -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');
}
}

View File

@@ -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',
])

View File

@@ -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
{

View File

@@ -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');
});
}
}

View File

@@ -114,6 +114,7 @@ class User extends Authenticatable
{
return $this->belongsToMany(Organization::class, Membership::class)
->withPivot([
'id',
'role',
'billable_rate',
])

View File

@@ -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);
}
}

View File

@@ -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()

View File

@@ -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();
}
}
}