Skip to content

Commit 7198929

Browse files
authored
Merge pull request #205 from fleetbase/fix/cross-org-invite-flow
fix: restore cross-organisation invite flow for existing users
2 parents 81bef54 + bb1d8a4 commit 7198929

26 files changed

Lines changed: 378 additions & 188 deletions

migrations/2026_04_05_000001_add_scheduled_status_to_schedule_items_table.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,7 @@
1313
* 'pending' is retained for backwards compatibility (e.g. manually-created items
1414
* that have not yet been confirmed).
1515
*/
16-
return new class extends Migration
17-
{
16+
return new class extends Migration {
1817
public function up(): void
1918
{
2019
DB::statement("

migrations/2026_04_06_000002_add_company_uuid_to_schedule_items_table.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
use Illuminate\Database\Migrations\Migration;
44
use Illuminate\Database\Schema\Blueprint;
5-
use Illuminate\Support\Facades\Schema;
65
use Illuminate\Support\Facades\DB;
6+
use Illuminate\Support\Facades\Schema;
77

88
return new class extends Migration {
99
public function up(): void
@@ -13,13 +13,13 @@ public function up(): void
1313
});
1414

1515
// Backfill from the parent schedule
16-
DB::statement("
16+
DB::statement('
1717
UPDATE schedule_items si
1818
JOIN schedules s ON s.uuid = si.schedule_uuid
1919
SET si.company_uuid = s.company_uuid
2020
WHERE si.company_uuid IS NULL
2121
AND si.schedule_uuid IS NOT NULL
22-
");
22+
');
2323
}
2424

2525
public function down(): void
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration {
8+
/**
9+
* Run the migrations.
10+
*
11+
* Adds a nullable JSON `meta` column to the `invites` table so that
12+
* arbitrary key-value data (e.g. role_uuid for user invitations) can be
13+
* stored on an invite record without requiring dedicated columns for each
14+
* use-case. The HasMetaAttributes trait is used to read and write values.
15+
*
16+
* @return void
17+
*/
18+
public function up()
19+
{
20+
Schema::table('invites', function (Blueprint $table) {
21+
$table->json('meta')->nullable()->after('reason');
22+
});
23+
}
24+
25+
/**
26+
* Reverse the migrations.
27+
*
28+
* @return void
29+
*/
30+
public function down()
31+
{
32+
Schema::table('invites', function (Blueprint $table) {
33+
$table->dropColumn('meta');
34+
});
35+
}
36+
};

src/Console/Commands/SyncSandbox.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,13 @@ public function handle()
5656
DB::connection('sandbox')
5757
->table('api_credentials')
5858
->truncate();
59+
DB::connection('sandbox')
60+
->table('company_users')
61+
->truncate();
5962
}
6063

6164
// Models that need to be synced from Production to Sandbox
62-
$syncable = [\Fleetbase\Models\User::class, \Fleetbase\Models\Company::class, \Fleetbase\Models\ApiCredential::class];
65+
$syncable = [\Fleetbase\Models\User::class, \Fleetbase\Models\Company::class, \Fleetbase\Models\CompanyUser::class, \Fleetbase\Models\ApiCredential::class];
6366

6467
// Sync each syncable data model
6568
foreach ($syncable as $model) {

src/Http/Controllers/Internal/v1/ScheduleExceptionController.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@ class ScheduleExceptionController extends FleetbaseController
1919

2020
/**
2121
* The ScheduleService instance.
22-
*
23-
* @var ScheduleService
2422
*/
2523
protected ScheduleService $scheduleService;
2624

src/Http/Controllers/Internal/v1/ScheduleTemplateController.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,6 @@ class ScheduleTemplateController extends FleetbaseController
2020

2121
/**
2222
* The ScheduleService instance.
23-
*
24-
* @var ScheduleService
2523
*/
2624
protected ScheduleService $scheduleService;
2725

src/Http/Controllers/Internal/v1/UserController.php

Lines changed: 185 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,74 @@ class UserController extends FleetbaseController
6868
*/
6969
public $updateRequest = UpdateUserRequest::class;
7070

71+
/**
72+
* Query users always against the production database.
73+
*
74+
* Users are authoritative in production. The sandbox database contains
75+
* only a mirrored copy. Temporarily restoring the production connection
76+
* for this query ensures the IAM list is correct regardless of whether
77+
* the console is in sandbox mode, without affecting any other sandbox
78+
* queries in the same request lifecycle.
79+
*
80+
* @return \Illuminate\Http\Response
81+
*/
82+
public function queryRecord(Request $request)
83+
{
84+
$isSandbox = config('fleetbase.connection.db') === 'sandbox';
85+
86+
if ($isSandbox) {
87+
config([
88+
'database.default' => env('DB_CONNECTION', 'mysql'),
89+
'fleetbase.connection.db' => env('DB_CONNECTION', 'mysql'),
90+
]);
91+
}
92+
93+
$response = parent::queryRecord($request);
94+
95+
if ($isSandbox) {
96+
config([
97+
'database.default' => 'sandbox',
98+
'fleetbase.connection.db' => 'sandbox',
99+
]);
100+
}
101+
102+
return $response;
103+
}
104+
71105
/**
72106
* Creates a record with request payload.
73107
*
108+
* If the supplied email address already belongs to an existing user the
109+
* request is treated as a cross-organisation invitation rather than a
110+
* duplicate-creation attempt. The existing user is invited to join the
111+
* current company and the response includes `invited: true` so the
112+
* frontend can display the appropriate success message.
113+
*
74114
* @return \Illuminate\Http\Response
75115
*/
76116
public function createRecord(Request $request)
77117
{
78118
$this->validateRequest($request);
79119

120+
// Detect whether the email already belongs to an existing user.
121+
// If so, redirect to the cross-organisation invite flow instead of
122+
// attempting to create a duplicate user record.
123+
$email = strtolower((string) $request->input('user.email', ''));
124+
$existingUser = $email ? User::where('email', $email)->whereNull('deleted_at')->first() : null;
125+
126+
if ($existingUser) {
127+
// Guard: the user is already a member of the current organisation.
128+
$alreadyMember = $existingUser->companyUsers()
129+
->where('company_uuid', session('company'))
130+
->exists();
131+
132+
if ($alreadyMember) {
133+
return response()->error('This user is already a member of your organisation.');
134+
}
135+
136+
return $this->inviteExistingUser($existingUser, $request);
137+
}
138+
80139
try {
81140
$record = $this->model->createRecordFromRequest($request, function (&$request, &$input) {
82141
// Get user properties
@@ -268,63 +327,126 @@ public function saveTwoFactorSettings(Request $request)
268327
}
269328

270329
/**
271-
* Creates a user, adds the user to company and sends an email to user about being added.
330+
* Invite a user (new or existing) to join the current organisation.
331+
*
332+
* - If the email belongs to an existing user in another organisation, a
333+
* cross-organisation invitation is issued without creating a new user.
334+
* - If the email is brand-new, a pending user record is created and the
335+
* invitation email is sent so they can set a password on acceptance.
272336
*
273337
* @return \Illuminate\Http\Response
274338
*/
275339
#[SkipAuthorizationCheck]
276340
public function inviteUser(InviteUserRequest $request)
277341
{
278-
// $data = $request->input(['name', 'email', 'phone', 'status', 'country', 'date_of_birth']);
279-
$data = $request->input('user');
280-
$email = strtolower($data['email']);
342+
$data = $request->input('user');
343+
$email = strtolower($data['email']);
344+
$company = Auth::getCompany();
345+
346+
if (!$company) {
347+
return response()->error('Unable to determine the current organisation.');
348+
}
281349

282-
// set company
283-
$data['company_uuid'] = session('company');
284-
$data['status'] = 'pending'; // pending acceptance
285-
$data['type'] = 'user'; // set type as regular user
286-
$data['created_at'] = Carbon::now(); // jic
350+
// Check if user already exists in the system.
351+
$user = User::where('email', $email)->whereNull('deleted_at')->first();
287352

288-
// make sure user isn't already invited
289-
$isAlreadyInvited = Invite::where([
290-
'company_uuid' => session('company'),
291-
'subject_uuid' => session('company'),
292-
'protocol' => 'email',
293-
'reason' => 'join_company',
294-
])->whereJsonContains('recipients', $email)->exists();
353+
if ($user) {
354+
// Guard: already a member of this organisation.
355+
$alreadyMember = $user->companyUsers()
356+
->where('company_uuid', $company->uuid)
357+
->exists();
295358

296-
if ($isAlreadyInvited) {
297-
return response()->error('This user has already been invited to join this organization.');
359+
if ($alreadyMember) {
360+
return response()->error('This user is already a member of your organisation.');
361+
}
362+
363+
// Existing user from another org — issue a cross-org invite.
364+
return $this->inviteExistingUser($user, $request);
298365
}
299366

300-
// get the company inviting
301-
$company = Company::where('uuid', session('company'))->first();
367+
// Brand-new user — create a pending record then invite.
368+
$data['company_uuid'] = $company->uuid;
369+
$data['status'] = 'pending';
370+
$data['type'] = 'user';
371+
$data['created_at'] = Carbon::now();
302372

303-
// check if user exists already
304-
$user = User::where('email', $email)->first();
373+
$user = User::create($data);
305374

306-
// if new user, create user
307-
if (!$user) {
308-
$user = User::create($data);
375+
// Set user type
376+
$user->setUserType('user');
377+
378+
// Assign to user
379+
$user->assignCompany($company, $request->input('user.role_uuid'));
380+
381+
// Assign role if set
382+
if ($request->filled('user.role_uuid')) {
383+
$user->assignSingleRole($request->input('user.role_uuid'));
309384
}
310385

311-
// create invitation
312386
$invitation = Invite::create([
313-
'company_uuid' => session('company'),
387+
'company_uuid' => $company->uuid,
314388
'created_by_uuid' => session('user'),
315389
'subject_uuid' => $company->uuid,
316390
'subject_type' => Utils::getMutationType($company),
317391
'protocol' => 'email',
318392
'recipients' => [$user->email],
319393
'reason' => 'join_company',
394+
'meta' => array_filter(['role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role')]),
395+
'expires_at' => now()->addHours(48),
320396
]);
321397

322-
// notify user
323398
$user->notify(new UserInvited($invitation));
324399

325400
return response()->json(['user' => new $this->resource($user)]);
326401
}
327402

403+
/**
404+
* Issue a join-company invitation to a user who already exists in the
405+
* system but belongs to a different organisation.
406+
*
407+
* This private helper is shared by both `createRecord()` (which detects
408+
* an existing email during the standard "New User" flow) and `inviteUser()`
409+
* (the dedicated invite endpoint). Keeping the logic in one place ensures
410+
* both paths behave identically.
411+
*
412+
* @param User $user the existing user to invite
413+
* @param Request $request the originating HTTP request
414+
*/
415+
private function inviteExistingUser(User $user, Request $request): \Illuminate\Http\JsonResponse
416+
{
417+
$company = Auth::getCompany();
418+
419+
if (!$company) {
420+
return response()->error('Unable to determine the current organisation.');
421+
}
422+
423+
// Guard: prevent duplicate invitations using the model helper.
424+
if (Invite::isAlreadySentToJoinCompany($user, $company)) {
425+
return response()->error('This user has already been invited to join your organisation.');
426+
}
427+
428+
$invitation = Invite::create([
429+
'company_uuid' => $company->uuid,
430+
'created_by_uuid' => session('user'),
431+
'subject_uuid' => $company->uuid,
432+
'subject_type' => Utils::getMutationType($company),
433+
'protocol' => 'email',
434+
'recipients' => [$user->email],
435+
'reason' => 'join_company',
436+
'meta' => array_filter(['role_uuid' => $request->input('user.role_uuid') ?? $request->input('user.role')]),
437+
'expires_at' => now()->addHours(48),
438+
]);
439+
440+
$user->notify(new UserInvited($invitation));
441+
442+
// Return `invited: true` so the frontend can distinguish between
443+
// a newly created user and a cross-organisation invite.
444+
return response()->json([
445+
'user' => new $this->resource($user),
446+
'invited' => true,
447+
]);
448+
}
449+
328450
/**
329451
* Resend invitation to pending user.
330452
*
@@ -345,6 +467,7 @@ public function resendInvitation(ResendUserInvite $request)
345467
'protocol' => 'email',
346468
'recipients' => [$user->email],
347469
'reason' => 'join_company',
470+
'expires_at' => now()->addHours(48),
348471
]);
349472

350473
// notify user
@@ -384,11 +507,40 @@ public function acceptCompanyInvite(AcceptCompanyInvite $request)
384507
// determine if user needs to set password (when status pending)
385508
$isPending = $needsPassword = $user->status === 'pending';
386509

387-
// add user to company
388-
CompanyUser::create([
389-
'user_uuid' => $user->uuid,
390-
'company_uuid' => $company->uuid,
391-
]);
510+
// Add user to company only if they are not already a member.
511+
// This guards against double-acceptance (e.g. clicking the invite
512+
// link twice) creating a duplicate company_users row.
513+
$alreadyMember = CompanyUser::where('user_uuid', $user->uuid)
514+
->where('company_uuid', $company->uuid)
515+
->exists();
516+
517+
if (!$alreadyMember) {
518+
// Use Company::addUser() so that role assignment is handled in
519+
// one place. The role stored in the invite meta takes precedence;
520+
// if none was set the default 'Administrator' role is used.
521+
$roleIdentifier = $invite->getMeta('role_uuid', 'Administrator');
522+
$companyUser = $company->addUser($user, $roleIdentifier);
523+
$user->setRelation('companyUser', $companyUser);
524+
} else {
525+
// User is already a member — ensure the companyUser relation is
526+
// loaded so that role assignment below can still be applied if
527+
// the invite carries a role (e.g. re-sent invite with a new role).
528+
$user->loadCompanyUser();
529+
$roleUuid = $invite->getMeta('role_uuid');
530+
if ($user->companyUser && $roleUuid) {
531+
$user->companyUser->assignSingleRole($roleUuid);
532+
}
533+
}
534+
535+
// Delete the invite
536+
$invite->delete();
537+
538+
// Switch the user's active company to the one they just joined.
539+
// This ensures that subsequent calls to /users/me resolve the
540+
// companyUser relationship (and therefore role/policies) against
541+
// the correct company rather than the user's previous company.
542+
$user->company_uuid = $company->uuid;
543+
$user->save();
392544

393545
// activate user
394546
if ($isPending) {

src/Http/Filter/ScheduleExceptionFilter.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ public function subjectType(?string $type)
6060

6161
if (Str::contains($type, '\\')) {
6262
$this->builder->where('subject_type', $type);
63+
6364
return;
6465
}
6566

0 commit comments

Comments
 (0)