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.
This commit is contained in:
Darko Gjorgjijoski
2026-04-03 22:58:55 +02:00
parent 4318c59976
commit 92a1baced4
12 changed files with 725 additions and 22 deletions

View File

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

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Http\Controllers\Company\General;
use App\Http\Controllers\Controller;
use App\Http\Resources\CompanyInvitationResource;
use App\Models\CompanyInvitation;
use App\Services\InvitationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class InvitationResponseController extends Controller
{
public function __construct(
private readonly InvitationService $invitationService,
) {}
/**
* Get pending invitations for the authenticated user.
*/
public function pending(Request $request): JsonResponse
{
$invitations = $this->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]);
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Http\Controllers\Company\Settings;
use App\Http\Controllers\Controller;
use App\Http\Resources\CompanyInvitationResource;
use App\Models\Company;
use App\Models\CompanyInvitation;
use App\Services\InvitationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class InvitationController extends Controller
{
public function __construct(
private readonly InvitationService $invitationService,
) {}
public function index(Request $request): JsonResponse
{
$company = Company::find($request->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]);
}
}