From 92a1baced43fa464040e6d0b0943279f76381fc8 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Fri, 3 Apr 2026 22:58:55 +0200 Subject: [PATCH] Add company invitation system (backend) New feature allowing company owners/admins to invite users by email with a specific company-scoped role. Database: - New company_invitations table (company_id, email, role_id, token, status, invited_by, expires_at) Backend: - CompanyInvitation model with pending/forUser scopes - InvitationService: invite, accept, decline, getPendingForUser - CompanyInvitationMail with markdown email template - InvitationController (company-scoped): list, send, cancel invitations - InvitationResponseController (user-scoped): pending, accept, decline - BootstrapController returns pending_invitations in response - CompanyMiddleware handles zero-company users gracefully Tests: 9 feature tests covering invite, accept, decline, cancel, expire, duplicate prevention, and bootstrap integration. --- .../Company/General/BootstrapController.php | 55 +++-- .../General/InvitationResponseController.php | 49 ++++ .../Company/Settings/InvitationController.php | 69 ++++++ app/Http/Middleware/CompanyMiddleware.php | 21 +- .../Resources/CompanyInvitationResource.php | 31 +++ app/Mail/CompanyInvitationMail.php | 43 ++++ app/Models/CompanyInvitation.php | 77 ++++++ app/Services/InvitationService.php | 113 +++++++++ ...04854_create_company_invitations_table.php | 32 +++ .../views/emails/company-invitation.blade.php | 16 ++ routes/api.php | 14 ++ tests/Feature/Admin/InvitationTest.php | 227 ++++++++++++++++++ 12 files changed, 725 insertions(+), 22 deletions(-) create mode 100644 app/Http/Controllers/Company/General/InvitationResponseController.php create mode 100644 app/Http/Controllers/Company/Settings/InvitationController.php create mode 100644 app/Http/Resources/CompanyInvitationResource.php create mode 100644 app/Mail/CompanyInvitationMail.php create mode 100644 app/Models/CompanyInvitation.php create mode 100644 app/Services/InvitationService.php create mode 100644 database/migrations/2026_04_03_204854_create_company_invitations_table.php create mode 100644 resources/views/emails/company-invitation.blade.php create mode 100644 tests/Feature/Admin/InvitationTest.php diff --git a/app/Http/Controllers/Company/General/BootstrapController.php b/app/Http/Controllers/Company/General/BootstrapController.php index 0ac23e0b..edf595f7 100644 --- a/app/Http/Controllers/Company/General/BootstrapController.php +++ b/app/Http/Controllers/Company/General/BootstrapController.php @@ -3,9 +3,11 @@ namespace App\Http\Controllers\Company\General; use App\Http\Controllers\Controller; +use App\Http\Resources\CompanyInvitationResource; use App\Http\Resources\CompanyResource; use App\Http\Resources\UserResource; use App\Models\Company; +use App\Models\CompanyInvitation; use App\Models\CompanySetting; use App\Models\Currency; use App\Models\Module; @@ -28,13 +30,47 @@ class BootstrapController extends Controller { $current_user = $request->user(); $current_user_settings = $current_user->getAllSettings(); + $companies = $current_user->companies; + + $pendingInvitations = CompanyInvitation::forUser($current_user) + ->pending() + ->with(['company', 'role', 'invitedBy']) + ->get(); + + $global_settings = Setting::getSettings([ + 'api_token', + 'admin_portal_theme', + 'admin_portal_logo', + 'login_page_logo', + 'login_page_heading', + 'login_page_description', + 'admin_page_title', + 'copyright_text', + 'save_pdf_to_disk', + ]); + + // User has no companies — return minimal bootstrap + if ($companies->isEmpty()) { + return response()->json([ + 'current_user' => new UserResource($current_user), + 'current_user_settings' => $current_user_settings, + 'current_user_abilities' => [], + 'companies' => [], + 'current_company' => null, + 'current_company_settings' => [], + 'current_company_currency' => Currency::first(), + 'config' => config('invoiceshelf'), + 'global_settings' => $global_settings, + 'main_menu' => [], + 'setting_menu' => [], + 'modules' => [], + 'pending_invitations' => CompanyInvitationResource::collection($pendingInvitations), + ]); + } $main_menu = $this->generateMenu('main_menu', $current_user); - $setting_menu = $this->generateMenu('setting_menu', $current_user); - $companies = $current_user->companies; - $current_company = Company::find($request->header('company')); if ((! $current_company) || ($current_company && ! $current_user->hasCompany($current_company->id))) { @@ -49,18 +85,6 @@ class BootstrapController extends Controller BouncerFacade::refreshFor($current_user); - $global_settings = Setting::getSettings([ - 'api_token', - 'admin_portal_theme', - 'admin_portal_logo', - 'login_page_logo', - 'login_page_heading', - 'login_page_description', - 'admin_page_title', - 'copyright_text', - 'save_pdf_to_disk', - ]); - return response()->json([ 'current_user' => new UserResource($current_user), 'current_user_settings' => $current_user_settings, @@ -74,6 +98,7 @@ class BootstrapController extends Controller 'main_menu' => $main_menu, 'setting_menu' => $setting_menu, 'modules' => Module::where('enabled', true)->pluck('name'), + 'pending_invitations' => CompanyInvitationResource::collection($pendingInvitations), ]); } diff --git a/app/Http/Controllers/Company/General/InvitationResponseController.php b/app/Http/Controllers/Company/General/InvitationResponseController.php new file mode 100644 index 00000000..70cec8e2 --- /dev/null +++ b/app/Http/Controllers/Company/General/InvitationResponseController.php @@ -0,0 +1,49 @@ +invitationService->getPendingForUser($request->user()); + + return response()->json([ + 'invitations' => CompanyInvitationResource::collection($invitations), + ]); + } + + /** + * Accept a company invitation. + */ + public function accept(Request $request, CompanyInvitation $invitation): JsonResponse + { + $this->invitationService->accept($invitation, $request->user()); + + return response()->json(['success' => true]); + } + + /** + * Decline a company invitation. + */ + public function decline(Request $request, CompanyInvitation $invitation): JsonResponse + { + $this->invitationService->decline($invitation, $request->user()); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Controllers/Company/Settings/InvitationController.php b/app/Http/Controllers/Company/Settings/InvitationController.php new file mode 100644 index 00000000..203ee0af --- /dev/null +++ b/app/Http/Controllers/Company/Settings/InvitationController.php @@ -0,0 +1,69 @@ +header('company')); + + $invitations = CompanyInvitation::where('company_id', $company->id) + ->pending() + ->with(['role', 'invitedBy']) + ->latest() + ->get(); + + return response()->json([ + 'invitations' => CompanyInvitationResource::collection($invitations), + ]); + } + + public function store(Request $request): JsonResponse + { + $request->validate([ + 'email' => 'required|email', + 'role_id' => 'required|exists:roles,id', + ]); + + $company = Company::find($request->header('company')); + + $invitation = $this->invitationService->invite( + $company, + $request->email, + $request->role_id, + $request->user() + ); + + return response()->json([ + 'success' => true, + 'invitation' => new CompanyInvitationResource($invitation->load(['company', 'role', 'invitedBy'])), + ]); + } + + public function destroy(CompanyInvitation $companyInvitation): JsonResponse + { + if ($companyInvitation->status !== CompanyInvitation::STATUS_PENDING) { + return response()->json([ + 'success' => false, + 'message' => 'Only pending invitations can be cancelled.', + ], 422); + } + + $companyInvitation->delete(); + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Middleware/CompanyMiddleware.php b/app/Http/Middleware/CompanyMiddleware.php index 49874c4d..6612ac02 100644 --- a/app/Http/Middleware/CompanyMiddleware.php +++ b/app/Http/Middleware/CompanyMiddleware.php @@ -9,18 +9,25 @@ use Symfony\Component\HttpFoundation\Response; class CompanyMiddleware { - /** - * Handle an incoming request. - * - * @return mixed - */ public function handle(Request $request, Closure $next): Response { if (Schema::hasTable('user_company')) { $user = $request->user(); - if ((! $request->header('company')) || (! $user->hasCompany($request->header('company')))) { - $request->headers->set('company', $user->companies()->first()->id); + if (! $user) { + return $next($request); + } + + $firstCompany = $user->companies()->first(); + + // User has no companies — allow request through without company header + // (BootstrapController handles this gracefully) + if (! $firstCompany) { + return $next($request); + } + + if (! $request->header('company') || ! $user->hasCompany($request->header('company'))) { + $request->headers->set('company', $firstCompany->id); } } diff --git a/app/Http/Resources/CompanyInvitationResource.php b/app/Http/Resources/CompanyInvitationResource.php new file mode 100644 index 00000000..ca978b69 --- /dev/null +++ b/app/Http/Resources/CompanyInvitationResource.php @@ -0,0 +1,31 @@ + $this->id, + 'company_id' => $this->company_id, + 'email' => $this->email, + 'token' => $this->token, + 'status' => $this->status, + 'expires_at' => $this->expires_at, + 'created_at' => $this->created_at, + 'company' => $this->when($this->relationLoaded('company'), function () { + return new CompanyResource($this->company); + }), + 'role' => $this->when($this->relationLoaded('role'), function () { + return new RoleResource($this->role); + }), + 'invited_by' => $this->when($this->relationLoaded('invitedBy'), function () { + return new UserResource($this->invitedBy); + }), + ]; + } +} diff --git a/app/Mail/CompanyInvitationMail.php b/app/Mail/CompanyInvitationMail.php new file mode 100644 index 00000000..9cfebc39 --- /dev/null +++ b/app/Mail/CompanyInvitationMail.php @@ -0,0 +1,43 @@ + $this->invitation->company->name, + ]), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.company-invitation', + with: [ + 'invitation' => $this->invitation, + 'companyName' => $this->invitation->company->name, + 'roleName' => $this->invitation->role->title, + 'inviterName' => $this->invitation->invitedBy->name, + 'acceptUrl' => url("/invitations/{$this->invitation->token}/accept"), + 'declineUrl' => url("/invitations/{$this->invitation->token}/decline"), + ], + ); + } +} diff --git a/app/Models/CompanyInvitation.php b/app/Models/CompanyInvitation.php new file mode 100644 index 00000000..e415c715 --- /dev/null +++ b/app/Models/CompanyInvitation.php @@ -0,0 +1,77 @@ +belongsTo(Company::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function invitedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by'); + } + + public function role(): BelongsTo + { + return $this->belongsTo(Role::class); + } + + public function isExpired(): bool + { + return Carbon::now()->greaterThan($this->expires_at); + } + + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING && ! $this->isExpired(); + } + + /** + * Scope to pending, non-expired invitations. + */ + public function scopePending(Builder $query): void + { + $query->where('status', self::STATUS_PENDING) + ->where('expires_at', '>', Carbon::now()); + } + + /** + * Scope to invitations for a specific user (by user_id or email). + */ + public function scopeForUser(Builder $query, User $user): void + { + $query->where(function (Builder $q) use ($user) { + $q->where('user_id', $user->id) + ->orWhere('email', $user->email); + }); + } +} diff --git a/app/Services/InvitationService.php b/app/Services/InvitationService.php new file mode 100644 index 00000000..ab97855f --- /dev/null +++ b/app/Services/InvitationService.php @@ -0,0 +1,113 @@ +id) + ->where('email', $email) + ->pending() + ->first(); + + if ($existing) { + throw ValidationException::withMessages([ + 'email' => ['An invitation is already pending for this email.'], + ]); + } + + // Check if user is already a member + $existingUser = User::where('email', $email)->first(); + if ($existingUser && $existingUser->hasCompany($company->id)) { + throw ValidationException::withMessages([ + 'email' => ['This user is already a member of this company.'], + ]); + } + + $invitation = CompanyInvitation::create([ + 'company_id' => $company->id, + 'user_id' => $existingUser?->id, + 'email' => $email, + 'role_id' => $roleId, + 'token' => Str::random(64), + 'status' => CompanyInvitation::STATUS_PENDING, + 'invited_by' => $invitedBy->id, + 'expires_at' => Carbon::now()->addDays(7), + ]); + + Mail::to($email)->send(new CompanyInvitationMail($invitation)); + + return $invitation; + } + + /** + * Accept a pending invitation and add the user to the company. + */ + public function accept(CompanyInvitation $invitation, User $user): void + { + if (! $invitation->isPending()) { + throw ValidationException::withMessages([ + 'invitation' => ['This invitation is no longer valid.'], + ]); + } + + // Add user to company + $user->companies()->attach($invitation->company_id); + + // Assign role scoped to the invitation's company + $role = Role::withoutGlobalScopes()->find($invitation->role_id); + BouncerFacade::scope()->to($invitation->company_id); + $user->assign($role->name); + + // Update invitation + $invitation->update([ + 'status' => CompanyInvitation::STATUS_ACCEPTED, + 'user_id' => $user->id, + ]); + } + + /** + * Decline a pending invitation. + */ + public function decline(CompanyInvitation $invitation, User $user): void + { + if (! $invitation->isPending()) { + throw ValidationException::withMessages([ + 'invitation' => ['This invitation is no longer valid.'], + ]); + } + + $invitation->update([ + 'status' => CompanyInvitation::STATUS_DECLINED, + 'user_id' => $user->id, + ]); + } + + /** + * Get all pending invitations for a user (by user_id or email). + */ + public function getPendingForUser(User $user): Collection + { + return CompanyInvitation::forUser($user) + ->pending() + ->with(['company', 'role', 'invitedBy']) + ->get(); + } +} diff --git a/database/migrations/2026_04_03_204854_create_company_invitations_table.php b/database/migrations/2026_04_03_204854_create_company_invitations_table.php new file mode 100644 index 00000000..8e0f6b70 --- /dev/null +++ b/database/migrations/2026_04_03_204854_create_company_invitations_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('company_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('email'); + $table->foreignId('role_id')->constrained('roles')->cascadeOnDelete(); + $table->string('token')->unique(); + $table->enum('status', ['pending', 'accepted', 'declined', 'expired'])->default('pending'); + $table->foreignId('invited_by')->constrained('users')->cascadeOnDelete(); + $table->timestamp('expires_at'); + $table->timestamps(); + + $table->index(['email', 'status']); + $table->index(['user_id', 'status']); + }); + } + + public function down(): void + { + Schema::dropIfExists('company_invitations'); + } +}; diff --git a/resources/views/emails/company-invitation.blade.php b/resources/views/emails/company-invitation.blade.php new file mode 100644 index 00000000..bcf7671b --- /dev/null +++ b/resources/views/emails/company-invitation.blade.php @@ -0,0 +1,16 @@ + +# You've been invited! + +**{{ $inviterName }}** has invited you to join **{{ $companyName }}** as **{{ $roleName }}**. + + +Accept Invitation + + +If you don't want to join, you can decline this invitation. + +This invitation will expire in 7 days. + +Thanks,
+{{ config('app.name') }} +
diff --git a/routes/api.php b/routes/api.php index f4898b96..cc400f64 100644 --- a/routes/api.php +++ b/routes/api.php @@ -27,6 +27,7 @@ use App\Http\Controllers\Company\Expense\ExpensesController; use App\Http\Controllers\Company\General\BootstrapController; use App\Http\Controllers\Company\General\ConfigController; use App\Http\Controllers\Company\General\FormatsController; +use App\Http\Controllers\Company\General\InvitationResponseController; use App\Http\Controllers\Company\General\NotesController; use App\Http\Controllers\Company\General\SearchController; use App\Http\Controllers\Company\General\SerialNumberController; @@ -43,6 +44,7 @@ use App\Http\Controllers\Company\Role\RolesController; use App\Http\Controllers\Company\Settings\CompanyController; use App\Http\Controllers\Company\Settings\CompanyMailConfigurationController; use App\Http\Controllers\Company\Settings\CompanySettingsController; +use App\Http\Controllers\Company\Settings\InvitationController; use App\Http\Controllers\Company\Settings\TaxTypesController; use App\Http\Controllers\Company\Settings\UserProfileController; use App\Http\Controllers\Company\Users\UsersController; @@ -171,6 +173,13 @@ Route::prefix('/v1')->group(function () { Route::get('/bootstrap', BootstrapController::class); + // Invitations (user-scoped — respond to invitations) + // ---------------------------------- + + Route::get('/invitations/pending', [InvitationResponseController::class, 'pending']); + Route::post('/invitations/{invitation:token}/accept', [InvitationResponseController::class, 'accept']); + Route::post('/invitations/{invitation:token}/decline', [InvitationResponseController::class, 'decline']); + // Currencies // ---------------------------------- @@ -216,6 +225,11 @@ Route::prefix('/v1')->group(function () { Route::get('/current-company', [BootstrapController::class, 'currentCompany']); + // Company Invitations (company-scoped — send invitations) + // ---------------------------------- + + Route::apiResource('company-invitations', InvitationController::class)->only(['index', 'store', 'destroy']); + // Customers // ---------------------------------- diff --git a/tests/Feature/Admin/InvitationTest.php b/tests/Feature/Admin/InvitationTest.php new file mode 100644 index 00000000..cfedfa1e --- /dev/null +++ b/tests/Feature/Admin/InvitationTest.php @@ -0,0 +1,227 @@ + 'DatabaseSeeder', '--force' => true]); + Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]); + + $user = User::find(1); + $this->withHeaders([ + 'company' => $user->companies()->first()->id, + ]); + Sanctum::actingAs($user, ['*']); +}); + +test('invite user to company', function () { + Mail::fake(); + + $company = Company::first(); + $role = Role::where('name', 'owner')->first(); + + $response = postJson('api/v1/company-invitations', [ + 'email' => 'newuser@example.com', + 'role_id' => $role->id, + ]); + + $response->assertOk(); + $response->assertJsonPath('success', true); + + $this->assertDatabaseHas('company_invitations', [ + 'company_id' => $company->id, + 'email' => 'newuser@example.com', + 'role_id' => $role->id, + 'status' => 'pending', + ]); + + Mail::assertSent(CompanyInvitationMail::class); +}); + +test('cannot invite user already in company', function () { + $company = Company::first(); + $role = Role::where('name', 'owner')->first(); + $existingUser = User::first(); + + $response = postJson('api/v1/company-invitations', [ + 'email' => $existingUser->email, + 'role_id' => $role->id, + ]); + + $response->assertStatus(422); +}); + +test('cannot send duplicate invitation', function () { + Mail::fake(); + + $company = Company::first(); + $role = Role::where('name', 'owner')->first(); + + postJson('api/v1/company-invitations', [ + 'email' => 'duplicate@example.com', + 'role_id' => $role->id, + ])->assertOk(); + + postJson('api/v1/company-invitations', [ + 'email' => 'duplicate@example.com', + 'role_id' => $role->id, + ])->assertStatus(422); +}); + +test('list pending invitations for company', function () { + Mail::fake(); + + $company = Company::first(); + $role = Role::where('name', 'owner')->first(); + + postJson('api/v1/company-invitations', [ + 'email' => 'invited@example.com', + 'role_id' => $role->id, + ]); + + $response = getJson('api/v1/company-invitations'); + + $response->assertOk(); + $response->assertJsonCount(1, 'invitations'); +}); + +test('cancel pending invitation', function () { + Mail::fake(); + + $company = Company::first(); + $role = Role::where('name', 'owner')->first(); + + $storeResponse = postJson('api/v1/company-invitations', [ + 'email' => 'cancel@example.com', + 'role_id' => $role->id, + ]); + $storeResponse->assertOk(); + + $invitation = CompanyInvitation::where('email', 'cancel@example.com')->first(); + $this->assertNotNull($invitation); + + deleteJson("api/v1/company-invitations/{$invitation->id}") + ->assertOk(); + + $this->assertDatabaseMissing('company_invitations', [ + 'email' => 'cancel@example.com', + ]); +}); + +test('accept invitation adds user to company', function () { + Mail::fake(); + + $company = Company::first(); + $role = Role::where('name', 'owner')->first(); + + // Create a new user not in the company + $newUser = User::factory()->create(['email' => 'accept@example.com']); + + // Create invitation + $invitation = CompanyInvitation::create([ + 'company_id' => $company->id, + 'user_id' => $newUser->id, + 'email' => $newUser->email, + 'role_id' => $role->id, + 'token' => 'test-accept-token', + 'status' => 'pending', + 'invited_by' => User::first()->id, + 'expires_at' => now()->addDays(7), + ]); + + // Act as the invited user + Sanctum::actingAs($newUser, ['*']); + + postJson("api/v1/invitations/{$invitation->token}/accept") + ->assertOk(); + + $this->assertTrue($newUser->fresh()->hasCompany($company->id)); + $this->assertDatabaseHas('company_invitations', [ + 'token' => 'test-accept-token', + 'status' => 'accepted', + ]); +}); + +test('decline invitation', function () { + $company = Company::first(); + $role = Role::where('name', 'owner')->first(); + $newUser = User::factory()->create(['email' => 'decline@example.com']); + + $invitation = CompanyInvitation::create([ + 'company_id' => $company->id, + 'user_id' => $newUser->id, + 'email' => $newUser->email, + 'role_id' => $role->id, + 'token' => 'test-decline-token', + 'status' => 'pending', + 'invited_by' => User::first()->id, + 'expires_at' => now()->addDays(7), + ]); + + Sanctum::actingAs($newUser, ['*']); + + postJson("api/v1/invitations/{$invitation->token}/decline") + ->assertOk(); + + $this->assertDatabaseHas('company_invitations', [ + 'token' => 'test-decline-token', + 'status' => 'declined', + ]); + $this->assertFalse($newUser->fresh()->hasCompany($company->id)); +}); + +test('cannot accept expired invitation', function () { + $company = Company::first(); + $role = Role::where('name', 'owner')->first(); + $newUser = User::factory()->create(['email' => 'expired@example.com']); + + $invitation = CompanyInvitation::create([ + 'company_id' => $company->id, + 'user_id' => $newUser->id, + 'email' => $newUser->email, + 'role_id' => $role->id, + 'token' => 'test-expired-token', + 'status' => 'pending', + 'invited_by' => User::first()->id, + 'expires_at' => now()->subDay(), + ]); + + Sanctum::actingAs($newUser, ['*']); + + postJson("api/v1/invitations/{$invitation->token}/accept") + ->assertStatus(422); + + $this->assertFalse($newUser->fresh()->hasCompany($company->id)); +}); + +test('bootstrap includes pending invitations', function () { + $company = Company::first(); + $role = Role::where('name', 'owner')->first(); + $user = User::first(); + + CompanyInvitation::create([ + 'company_id' => $company->id, + 'user_id' => $user->id, + 'email' => $user->email, + 'role_id' => $role->id, + 'token' => 'test-bootstrap-token', + 'status' => 'pending', + 'invited_by' => $user->id, + 'expires_at' => now()->addDays(7), + ]); + + $response = getJson('api/v1/bootstrap'); + + $response->assertOk(); + $response->assertJsonStructure(['pending_invitations']); +});