@@ -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 ) {
0 commit comments