Skip to content

Commit

Permalink
Added placeholder users; Better exception handling; Enhanced local setup
Browse files Browse the repository at this point in the history
  • Loading branch information
korridor committed Mar 8, 2024
1 parent b74e14f commit 14e3017
Show file tree
Hide file tree
Showing 38 changed files with 880 additions and 87 deletions.
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_ADDRESS="[email protected]"
MAIL_FROM_NAME="${APP_NAME}"

AWS_ACCESS_KEY_ID=
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,4 @@ yarn-error.log
/blob-report/
/playwright/.cache/
/coverage
/extensions/*
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ Add the following entry to your `/etc/hosts`
```
127.0.0.1 solidtime.test
127.0.0.1 playwright.solidtime.test
127.0.0.1 mail.solidtime.test
```

## Running E2E Tests
Expand Down
22 changes: 20 additions & 2 deletions app/Actions/Fortify/CreateNewUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@

use App\Models\Organization;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Korridor\LaravelModelValidationRules\Rules\UniqueEloquent;
use Laravel\Fortify\Contracts\CreatesNewUsers;
use Laravel\Jetstream\Jetstream;

Expand All @@ -20,12 +23,27 @@ class CreateNewUser implements CreatesNewUsers
* Create a newly registered user.
*
* @param array<string, string> $input
*
* @throws ValidationException
*/
public function create(array $input): User
{
Validator::make($input, [
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users'],
'name' => [
'required',
'string',
'max:255',
],
'email' => [
'required',
'string',
'email',
'max:255',
new UniqueEloquent(User::class, 'email', function (Builder $builder): Builder {
/** @var Builder<User> $builder */
return $builder->where('is_placeholder', '=', false);
}),
],
'password' => $this->passwordRules(),
'terms' => Jetstream::hasTermsAndPrivacyPolicyFeature() ? ['accepted', 'required'] : '',
])->validate();
Expand Down
31 changes: 20 additions & 11 deletions app/Actions/Jetstream/AddOrganizationMember.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@
use App\Models\User;
use Closure;
use Illuminate\Contracts\Validation\Rule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
use Laravel\Jetstream\Contracts\AddsTeamMembers;
use Laravel\Jetstream\Events\AddingTeamMember;
use Laravel\Jetstream\Events\TeamMemberAdded;
Expand All @@ -21,21 +23,24 @@ class AddOrganizationMember implements AddsTeamMembers
/**
* Add a new team member to the given team.
*/
public function add(User $user, Organization $organization, string $email, ?string $role = null): void
public function add(User $owner, Organization $organization, string $email, ?string $role = null): void
{
Gate::forUser($user)->authorize('addTeamMember', $organization);
Gate::forUser($owner)->authorize('addTeamMember', $organization);

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

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

AddingTeamMember::dispatch($organization, $newTeamMember);
AddingTeamMember::dispatch($organization, $newOrganizationMember);

$organization->users()->attach(
$newTeamMember, ['role' => $role]
$newOrganizationMember, ['role' => $role]
);

TeamMemberAdded::dispatch($organization, $newTeamMember);
TeamMemberAdded::dispatch($organization, $newOrganizationMember);
}

/**
Expand All @@ -46,9 +51,7 @@ protected function validate(Organization $organization, string $email, ?string $
Validator::make([
'email' => $email,
'role' => $role,
], $this->rules(), [
'email.exists' => __('We were unable to find a registered user with this email address.'),
])->after(
], $this->rules())->after(
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
)->validateWithBag('addTeamMember');
}
Expand All @@ -61,7 +64,13 @@ protected function validate(Organization $organization, string $email, ?string $
protected function rules(): array
{
return array_filter([

Check failure on line 66 in app/Actions/Jetstream/AddOrganizationMember.php

View workflow job for this annotation

GitHub Actions / phpstan

Method App\Actions\Jetstream\AddOrganizationMember::rules() should return array<string, array<Illuminate\Contracts\Validation\Rule|string>> but returns array<string, array<int, Korridor\LaravelModelValidationRules\Rules\ExistsEloquent|Laravel\Jetstream\Rules\Role|string>>.
'email' => ['required', 'email', 'exists:users'],
'email' => [
'required',
'email',
(new ExistsEloquent(User::class, 'email', function (Builder $builder) {
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,
Expand All @@ -75,7 +84,7 @@ protected function ensureUserIsNotAlreadyOnTeam(Organization $team, string $emai
{
return function ($validator) use ($team, $email) {
$validator->errors()->addIf(
$team->hasUserWithEmail($email),
$team->hasRealUserWithEmail($email),
'email',
__('This user already belongs to the team.')
);
Expand Down
11 changes: 5 additions & 6 deletions app/Actions/Jetstream/InviteOrganizationMember.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public function invite(User $user, Organization $organization, string $email, ?s

InvitingTeamMember::dispatch($organization, $email, $role);

/** @var TeamInvitation $invitation */
$invitation = $organization->teamInvitations()->create([
'email' => $email,
'role' => $role,
Expand All @@ -50,9 +51,7 @@ protected function validate(Organization $organization, string $email, ?string $
Validator::make([
'email' => $email,
'role' => $role,
], $this->rules($organization), [
'email.unique' => __('This user has already been invited to the team.'),
])->after(
], $this->rules($organization))->after(
$this->ensureUserIsNotAlreadyOnTeam($organization, $email)
)->validateWithBag('addTeamMember');
}
Expand All @@ -68,10 +67,10 @@ protected function rules(Organization $organization): array
'email' => [
'required',
'email',
new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) {
(new UniqueEloquent(OrganizationInvitation::class, 'email', function (Builder $builder) use ($organization) {
/** @var Builder<OrganizationInvitation> $builder */
return $builder->whereBelongsTo($organization, 'organization');
}),
}))->withMessage(__('This user has already been invited to the team.')),
],
'role' => Jetstream::hasRoles()
? ['required', 'string', new Role]
Expand All @@ -86,7 +85,7 @@ protected function ensureUserIsNotAlreadyOnTeam(Organization $organization, stri
{
return function ($validator) use ($organization, $email) {
$validator->errors()->addIf(
$organization->hasUserWithEmail($email),
$organization->hasRealUserWithEmail($email),
'email',
__('This user already belongs to the team.')
);
Expand Down
46 changes: 46 additions & 0 deletions app/Exceptions/Api/ApiException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace App\Exceptions\Api;

use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use LogicException;

abstract class ApiException extends Exception
{
/**
* Render the exception into an HTTP response.
*/
public function render(Request $request): JsonResponse
{
return response()
->json([
'error' => true,
'key' => $this->getKey(),
'message' => $this->getTranslatedMessage(),
], 400);
}

/**
* Get the key for the exception.
*/
public function getKey(): string
{
if (defined(static::class.'::KEY')) {
return static::KEY;

Check failure on line 33 in app/Exceptions/Api/ApiException.php

View workflow job for this annotation

GitHub Actions / phpstan

Access to undefined constant static(App\Exceptions\Api\ApiException)::KEY.
}

throw new LogicException('API exceptions need the KEY constant defined.');
}

/**
* Get the translated message for the exception.
*/
public function getTranslatedMessage(): string
{
return __('exceptions.api.'.$this->getKey());
}
}
10 changes: 10 additions & 0 deletions app/Exceptions/Api/TimeEntryStillRunningApiException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace App\Exceptions\Api;

class TimeEntryStillRunningApiException extends ApiException
{
const string KEY = 'time_entry_still_running';
}
10 changes: 10 additions & 0 deletions app/Exceptions/Api/UserNotPlaceholderApiException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace App\Exceptions\Api;

class UserNotPlaceholderApiException extends ApiException
{
const string KEY = 'user_not_placeholder';
}
24 changes: 0 additions & 24 deletions app/Exceptions/ApiException.php

This file was deleted.

9 changes: 0 additions & 9 deletions app/Exceptions/TimeEntryStillRunning.php

This file was deleted.

7 changes: 3 additions & 4 deletions app/Http/Controllers/Api/V1/TimeEntryController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace App\Http\Controllers\Api\V1;

use App\Exceptions\TimeEntryStillRunning;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateRequest;
Expand Down Expand Up @@ -102,7 +102,7 @@ public function index(Organization $organization, TimeEntryIndexRequest $request
/**
* Create time entry
*
* @throws AuthorizationException|TimeEntryStillRunning
* @throws AuthorizationException|TimeEntryStillRunningApiException
*/
public function store(Organization $organization, TimeEntryStoreRequest $request): JsonResource
{
Expand All @@ -114,8 +114,7 @@ public function store(Organization $organization, TimeEntryStoreRequest $request

if ($request->get('end') === null && TimeEntry::query()->where('user_id', $request->get('user_id'))->where('end', null)->exists()) {
// TODO: API documentation
// TODO: Create concept for api exceptions
throw new TimeEntryStillRunning('User already has an active time entry');
throw new TimeEntryStillRunningApiException();
}

$timeEntry = new TimeEntry();
Expand Down
56 changes: 56 additions & 0 deletions app/Http/Controllers/Api/V1/UserController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\V1;

use App\Exceptions\Api\UserNotPlaceholderApiException;
use App\Http\Requests\V1\User\UserIndexRequest;
use App\Http\Resources\V1\User\UserCollection;
use App\Models\Organization;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Laravel\Jetstream\Contracts\InvitesTeamMembers;

class UserController extends Controller
{
/**
* List all users in an organization
*
* @throws AuthorizationException
*/
public function index(Organization $organization, UserIndexRequest $request): UserCollection
{
$this->checkPermission($organization, 'users:view');

$users = $organization->users()
->paginate();

return UserCollection::make($users);
}

/**
* Invite a placeholder user to become a real user in the organization
*
* @throws AuthorizationException|UserNotPlaceholderApiException
*/
public function invitePlaceholder(Organization $organization, User $user, Request $request): JsonResponse
{
$this->checkPermission($organization, 'users:invite-placeholder');

if (! $user->is_placeholder) {
throw new UserNotPlaceholderApiException();
}

app(InvitesTeamMembers::class)->invite(
$request->user(),
$organization,
$user->email,
'employee'
);

return response()->json($user);
}
}
26 changes: 26 additions & 0 deletions app/Http/Requests/V1/User/UserIndexRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests\V1\User;

use App\Models\Organization;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;

/**
* @property Organization $organization
*/
class UserIndexRequest extends FormRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule>>
*/
public function rules(): array
{
return [
];
}
}
Loading

0 comments on commit 14e3017

Please sign in to comment.