Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,4 @@ yarn-error.log
/data
/config/caddy
/config/composer
/AGENTS.md
77 changes: 2 additions & 75 deletions app/Actions/Jetstream/AddOrganizationMember.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,9 @@

namespace App\Actions\Jetstream;

use App\Enums\Role;
use App\Exceptions\MovedToApiException;
use App\Models\Organization;
use App\Models\User;
use App\Service\MemberService;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
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;

class AddOrganizationMember implements AddsTeamMembers
Expand All @@ -25,70 +16,6 @@ class AddOrganizationMember implements AddsTeamMembers
*/
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
{
Gate::forUser($owner)->authorize('addTeamMember', $organization); // TODO: refactor after owner refactoring

$this->validate($organization, $email, $role);

$newOrganizationMember = User::query()
->where('email', $email)
->where('is_placeholder', '=', false)
->firstOrFail();

app(MemberService::class)->addMember($newOrganizationMember, $organization, Role::from($role));
}

/**
* Validate the add member operation.
*/
protected function validate(Organization $organization, string $email, ?string $role): void
{
Validator::make([
'email' => $email,
'role' => $role,
], $this->rules())->after(
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
)->validateWithBag('addTeamMember');
}

/**
* Get the validation rules for adding a team member.
*
* @return array<string, array<ValidationRule|Rule|string|In>>
*/
protected function rules(): array
{
return [
'email' => [
'required',
'email',
ExistsEloquent::make(User::class, 'email', function (Builder $builder) {
/** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false);
})->withMessage(__('We were unable to find a registered user with this email address.')),
],
'role' => [
'required',
'string',
Rule::in([
Role::Admin->value,
Role::Manager->value,
Role::Employee->value,
]),
],
];
}

/**
* Ensure that the user is not already on the team.
*/
protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $email): Closure
{
return function ($validator) use ($team, $email): void {
$validator->errors()->addIf(
$team->hasRealUserWithEmail($email),
'email',
__('This user already belongs to the team.')
);
};
throw new MovedToApiException;
}
}
2 changes: 2 additions & 0 deletions app/Actions/Jetstream/CreateOrganization.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class CreateOrganization implements CreatesTeams
*
* @throws AuthorizationException
* @throws ValidationException
*
* @deprecated Use REST endpoint instead
*/
public function create(User $user, array $input): Organization
{
Expand Down
2 changes: 2 additions & 0 deletions app/Actions/Jetstream/DeleteOrganization.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ class DeleteOrganization implements DeletesTeams
{
/**
* Delete the given team.
*
* @deprecated Use REST endpoint instead
*/
public function delete(Organization $organization): void
{
Expand Down
2 changes: 2 additions & 0 deletions app/Actions/Jetstream/DeleteUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class DeleteUser implements DeletesUsers
* Delete the given user.
*
* @throws ValidationException
*
* @deprecated Use REST endpoint instead
*/
public function delete(User $user): void
{
Expand Down
2 changes: 2 additions & 0 deletions app/Actions/Jetstream/ValidateOrganizationDeletion.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class ValidateOrganizationDeletion
* @param Organization $organization Organization to be deleted
*
* @throws AuthorizationException
*
* @deprecated Use REST endpoint instead
*/
public function validate(User $user, Organization $organization): void
{
Expand Down
28 changes: 28 additions & 0 deletions app/Events/MemberAdded.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Events;

use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;

class MemberAdded
{
use Dispatchable;

public Member $member;

public Organization $organization;

public User $user;

public function __construct(Member $member, Organization $organization, User $user)
{
$this->member = $member;
$this->organization = $organization;
$this->user = $user;
}
}
28 changes: 28 additions & 0 deletions app/Events/MemberAdding.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace App\Events;

use App\Enums\Role;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Foundation\Events\Dispatchable;

class MemberAdding
{
use Dispatchable;

public User $user;

public Organization $organization;

public Role $role;

public function __construct(User $user, Organization $organization, Role $role)
{
$this->user = $user;
$this->organization = $organization;
$this->role = $role;
}
}
50 changes: 50 additions & 0 deletions app/Http/Controllers/Api/V1/OrganizationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@
namespace App\Http\Controllers\Api\V1;

use App\Enums\Role;
use App\Events\AfterCreateOrganization;
use App\Http\Requests\V1\Organization\OrganizationStoreRequest;
use App\Http\Requests\V1\Organization\OrganizationUpdateRequest;
use App\Http\Resources\V1\Organization\OrganizationResource;
use App\Models\Organization;
use App\Service\BillableRateService;
use App\Service\DeletionService;
use App\Service\IpLookup\IpLookupServiceContract;
use App\Service\OrganizationService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;

class OrganizationController extends Controller
{
Expand Down Expand Up @@ -80,4 +86,48 @@ public function update(Organization $organization, OrganizationUpdateRequest $re

return new OrganizationResource($organization, true);
}

/**
* Create organization
*
* @operationId createOrganization
*/
public function store(OrganizationStoreRequest $request, OrganizationService $organizationService): OrganizationResource
{
$user = $this->user();
$ipLookupResponse = app(IpLookupServiceContract::class)->lookup($request->ip());

$currency = $ipLookupResponse?->currency;

$organization = $organizationService->createOrganization(
$request->getName(),
$user,
false,
$currency
);

$user->switchTeam($organization);

// Note: The refresh is necessary for currently unknown reasons. Do not remove it.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we know why it is needed now?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you mean the switchTeam or the refresh?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The refresh. The comment just sounded like a leftover.

$organization = $organization->refresh();
AfterCreateOrganization::dispatch($organization);

return new OrganizationResource($organization, true);
}

/**
* Delete organization
*
* @operationId deleteOrganization
*
* @throws AuthorizationException
*/
public function destroy(Organization $organization, DeletionService $deletionService): JsonResponse
{
$this->checkPermission($organization, 'organizations:delete');

$deletionService->deleteOrganization($organization);

return response()->json(null, 204);
}
}
29 changes: 29 additions & 0 deletions app/Http/Controllers/Api/V1/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@

namespace App\Http\Controllers\Api\V1;

use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
use App\Http\Resources\V1\User\UserResource;
use App\Models\User;
use App\Service\DeletionService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;

class UserController extends Controller
{
Expand All @@ -24,4 +28,29 @@ public function me(): UserResource

return new UserResource($user);
}

/**
* Handles the deletion of a user.
*
* This endpoint is independent of organization.
*
* @operationId deleteUser
*
* @param User $user The user instance to be deleted.
* @param DeletionService $deletionService The service responsible for performing the user deletion.
* @return JsonResponse A JSON response with a 204 No Content status upon successful deletion.
*
* @throws AuthorizationException Thrown when the authenticated user does not match the user to be deleted.
* @throws CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers Thrown when the user to be deleted is the owner of an organization with multiple members.
*/
public function destroy(User $user, DeletionService $deletionService): JsonResponse
{
if ($user->getKey() !== $this->user()->getKey()) {
throw new AuthorizationException;
}

$deletionService->deleteUser($user);

return response()->json(null, 204);
}
}
19 changes: 1 addition & 18 deletions app/Http/Controllers/Web/DashboardController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,30 +4,13 @@

namespace App\Http\Controllers\Web;

use App\Enums\Role;
use App\Service\DashboardService;
use App\Service\PermissionStore;
use Illuminate\Auth\Access\AuthorizationException;
use Inertia\Inertia;
use Inertia\Response;

class DashboardController extends Controller
{
/**
* @throws AuthorizationException
*/
public function dashboard(DashboardService $dashboardService, PermissionStore $permissionStore): Response
public function dashboard(): Response
{
$user = $this->user();
$organization = $this->currentOrganization();

$latestTeamActivity = null;
if ($permissionStore->has($organization, 'time-entries:view:all')) {
$latestTeamActivity = $dashboardService->latestTeamActivity($organization);
}

$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;

return Inertia::render('Dashboard');
}
}
64 changes: 64 additions & 0 deletions app/Http/Controllers/Web/OrganizationInvitationController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Web;

use App\Enums\Role;
use App\Models\OrganizationInvitation;
use App\Models\User;
use App\Service\MemberService;
use Illuminate\Http\RedirectResponse;
use RuntimeException;

class OrganizationInvitationController extends Controller
{
public function accept(OrganizationInvitation $invitation, MemberService $memberService): RedirectResponse
{
$email = strtolower($invitation->email);
$role = Role::tryFrom($invitation->role);
if ($role === null || $role === Role::Owner || $role === Role::Placeholder) {
throw new RuntimeException('Invalid role');
}

$newOrganizationMember = User::query()
->where('email', $email)
->where('is_placeholder', '=', false)
->first();

if ($newOrganizationMember === null) {
if ($invitation->accepted_at === null) {
$invitation->accepted_at = now();
$invitation->save();
}

return redirect(route('register', [
'bannerStyle' => 'info',
'bannerText' => __('Please create an account to finish joining the :organization organization.', [
'organization' => $invitation->organization->name,
]),
]));
} else {
$organization = $invitation->organization;
if ($memberService->isEmailAlreadyMember($organization, $email)) {
return redirect(route('dashboard', [
'bannerStyle' => 'danger',
'bannerText' => __('You are already a member of the :organization organization.', [
'organization' => $organization->name,
]),
]));
}

$memberService->addMember($newOrganizationMember, $organization, $role);

$invitation->delete();

return redirect(route('dashboard', [
'bannerStyle' => 'success',
'bannerText' => __('Great! You have accepted the invitation to join the :organization organization.', [
'organization' => $invitation->organization->name,
]),
]));
}
}
}
Loading
Loading