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

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

View File

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

View File

@@ -29,6 +29,7 @@ return new class extends Migration
->onDelete('restrict')
->onUpdate('cascade');
$table->timestamps();
$table->unique(['project_id', 'user_id']);
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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