diff --git a/addon/components/modals/reset-customer-credentials.hbs b/addon/components/modals/reset-customer-credentials.hbs
deleted file mode 100644
index 34614779e..000000000
--- a/addon/components/modals/reset-customer-credentials.hbs
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
- You are about to reset the password for
- {{this.customer.name}}
-
-
Please enter the new password and confirm it below. You have the option to send the new credentials to the customer via email by selecting the checkbox.
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/addon/components/modals/reset-customer-credentials.js b/addon/components/modals/reset-customer-credentials.js
deleted file mode 100644
index 92d6d70ad..000000000
--- a/addon/components/modals/reset-customer-credentials.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import Component from '@glimmer/component';
-import { tracked } from '@glimmer/tracking';
-import { inject as service } from '@ember/service';
-
-const INTERNAL_NAMESPACE = 'int/v1';
-
-export default class ModalsResetCustomerCredentialsComponent extends Component {
- @service fetch;
- @service notifications;
- @tracked options = {};
- @tracked password;
- @tracked confirmPassword;
- @tracked sendCredentials = true;
- @tracked customer;
-
- constructor(owner, { options }) {
- super(...arguments);
- this.customer = options.customer;
- this.options = options;
- this.setupOptions();
- }
-
- setupOptions() {
- this.options.title = 'Reset Customer Credentials';
- this.options.acceptButtonText = 'Reset Credentials';
- this.options.declineButtonHidden = true;
- this.options.confirm = async (modal) => {
- modal.startLoading();
-
- try {
- await this.fetch.post(
- 'customers/reset-credentials',
- {
- customer: this.customer.id,
- password: this.password,
- password_confirmation: this.confirmPassword,
- send_credentials: this.sendCredentials,
- },
- { namespace: INTERNAL_NAMESPACE }
- );
-
- this.notifications.success('Customer password reset.');
-
- if (typeof this.options.onPasswordResetComplete === 'function') {
- this.options.onPasswordResetComplete();
- }
-
- modal.done();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- };
- }
-}
diff --git a/addon/components/modals/reset-profile-credentials.hbs b/addon/components/modals/reset-profile-credentials.hbs
new file mode 100644
index 000000000..e6374e0dc
--- /dev/null
+++ b/addon/components/modals/reset-profile-credentials.hbs
@@ -0,0 +1,23 @@
+
+
+
+ {{t "profile-account.prompts.reset-password-heading" name=this.profile.name}}
+
+
{{t "profile-account.prompts.reset-password-body"}}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/addon/components/modals/reset-profile-credentials.js b/addon/components/modals/reset-profile-credentials.js
new file mode 100644
index 000000000..2c8cbfc90
--- /dev/null
+++ b/addon/components/modals/reset-profile-credentials.js
@@ -0,0 +1,70 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { INTERNAL_NAMESPACE, updateProfileFromResponse } from '../../utils/profile-login-actions';
+
+/**
+ * Resets the login password of a driver, contact or customer profile.
+ *
+ * Options:
+ * - `profile`: the driver or contact record
+ * - `endpoint`: the reset credentials endpoint (internal namespace)
+ * - `payload`: extra body params, or a function returning them
+ * - `onPasswordResetComplete(response)`: optional callback
+ */
+export default class ModalsResetProfileCredentialsComponent extends Component {
+ @service fetch;
+ @service intl;
+ @service notifications;
+ @tracked options = {};
+ @tracked password;
+ @tracked confirmPassword;
+ @tracked sendCredentials = true;
+ @tracked profile;
+
+ constructor(owner, { options }) {
+ super(...arguments);
+ this.profile = options.profile;
+ this.options = options;
+ this.setupOptions();
+ }
+
+ get extraPayload() {
+ const { payload } = this.options;
+ return (typeof payload === 'function' ? payload(this.profile) : payload) ?? {};
+ }
+
+ setupOptions() {
+ this.options.title = this.options.title ?? this.intl.t('profile-account.prompts.reset-password-title');
+ this.options.acceptButtonText = this.options.acceptButtonText ?? this.intl.t('profile-account.prompts.reset-password-accept');
+ this.options.declineButtonHidden = true;
+ this.options.confirm = async (modal) => {
+ modal.startLoading();
+
+ try {
+ const response = await this.fetch.post(
+ this.options.endpoint,
+ {
+ ...this.extraPayload,
+ password: this.password,
+ password_confirmation: this.confirmPassword,
+ send_credentials: this.sendCredentials,
+ },
+ { namespace: INTERNAL_NAMESPACE }
+ );
+
+ updateProfileFromResponse(this.profile, response);
+ this.notifications.success(this.intl.t('profile-account.prompts.reset-password-success'));
+
+ if (typeof this.options.onPasswordResetComplete === 'function') {
+ this.options.onPasswordResetComplete(response);
+ }
+
+ modal.done();
+ } catch (error) {
+ this.notifications.serverError(error);
+ modal.stopLoading();
+ }
+ };
+ }
+}
diff --git a/addon/components/profile-identity-hint.hbs b/addon/components/profile-identity-hint.hbs
new file mode 100644
index 000000000..34e46171d
--- /dev/null
+++ b/addon/components/profile-identity-hint.hbs
@@ -0,0 +1,13 @@
+
+ {{#if this.conflict}}
+
+
+ {{this.conflict}}
+
+ {{else if this.staff}}
+
+
+ {{t "profile-account.will-link-to-staff" name=(or this.staff.name this.staff.email this.staff.phone)}}
+
+ {{/if}}
+
\ No newline at end of file
diff --git a/addon/components/profile-identity-hint.js b/addon/components/profile-identity-hint.js
new file mode 100644
index 000000000..b07a39bb9
--- /dev/null
+++ b/addon/components/profile-identity-hint.js
@@ -0,0 +1,49 @@
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { inject as service } from '@ember/service';
+import { action } from '@ember/object';
+import { task, timeout } from 'ember-concurrency';
+
+/**
+ * Inline hint shown while creating a driver, contact or customer profile:
+ * tells the user when the entered email/phone belongs to an existing team
+ * member (the profile will be linked to that account) or cannot be used.
+ *
+ * Args: `@email`, `@phone`, `@type` ('driver' | 'customer' | 'contact'),
+ * `@ignore` (optional profile id).
+ */
+export default class ProfileIdentityHintComponent extends Component {
+ @service fetch;
+ @tracked staff = null;
+ @tracked conflict = null;
+
+ @action lookupIdentity() {
+ this.lookup.perform();
+ }
+
+ @task({ restartable: true }) *lookup() {
+ const { email, phone, type, ignore } = this.args;
+
+ if (!email && !phone) {
+ this.staff = null;
+ this.conflict = null;
+ return;
+ }
+
+ yield timeout(400);
+
+ const query = { type };
+ if (email) query.email = email;
+ if (phone) query.phone = phone;
+ if (ignore) query.ignore = ignore;
+
+ try {
+ const response = yield this.fetch.get('fleet-ops/lookup/profile-identity', query, { namespace: 'int/v1' });
+ this.staff = response?.staff ?? null;
+ this.conflict = response?.conflict ?? null;
+ } catch {
+ this.staff = null;
+ this.conflict = null;
+ }
+ }
+}
diff --git a/addon/controllers/management/drivers/index.js b/addon/controllers/management/drivers/index.js
index 70ff9d2db..9762122d6 100644
--- a/addon/controllers/management/drivers/index.js
+++ b/addon/controllers/management/drivers/index.js
@@ -358,6 +358,10 @@ export default class ManagementDriversIndexController extends Controller {
{
separator: true,
},
+ ...this.driverActions.accountRowActionItems(),
+ {
+ separator: true,
+ },
{
label: this.intl.t('common.delete-resource', { resource: this.intl.t('resource.driver') }),
icon: 'trash',
diff --git a/addon/extension.js b/addon/extension.js
index a1a05121f..bcf5b4602 100644
--- a/addon/extension.js
+++ b/addon/extension.js
@@ -115,8 +115,10 @@ export default {
route: 'virtual',
slug: 'track-order',
icon: 'barcode',
- type: 'link',
- wrapperClass: 'btn-block py-1 border dark:border-gray-700 border-gray-200 hover:opacity-50',
+ type: 'default',
+ // btn-auth (ember-ui): the sign-in page's neutral button, matching the
+ // console's "Continue with ..." provider buttons, hover included.
+ wrapperClass: 'btn-block btn-auth',
component: new ExtensionComponent('@fleetbase/fleetops-engine', 'order-tracking-lookup'),
onClick: (menuItem) => {
universe.transitionMenuItem('virtual', menuItem);
diff --git a/addon/services/contact-actions.js b/addon/services/contact-actions.js
index b76d2b3e7..7021bd5cc 100644
--- a/addon/services/contact-actions.js
+++ b/addon/services/contact-actions.js
@@ -1,9 +1,8 @@
import ResourceActionService from '@fleetbase/ember-core/services/resource-action';
import { inject as service } from '@ember/service';
-import { action, get } from '@ember/object';
+import { action } from '@ember/object';
import { PANEL_DEFAULTS, closePanelsThen, registeredPanelTabs } from '../utils/context-panel';
-
-const INTERNAL_NAMESPACE = 'int/v1';
+import { confirmLoginAction, hasInactiveLogin, hasLinkedUser, hasManagedLogin, linkedUserStatus, updateProfileFromResponse } from '../utils/profile-login-actions';
export default class ContactActionsService extends ResourceActionService {
@service fetch;
@@ -124,17 +123,23 @@ export default class ContactActionsService extends ResourceActionService {
}
hasLinkedUser(contact) {
- const user = contact?.user;
- return Boolean(contact?.user_uuid || (user && (get(user, 'id') || get(user, 'uuid'))));
+ return hasLinkedUser(contact);
+ }
+
+ /**
+ * True when the contact has a login account managed from Fleet-Ops. A
+ * contact owned by a staff member is managed from IAM instead.
+ */
+ hasManagedLogin(contact) {
+ return hasManagedLogin(contact);
}
linkedUserStatus(contact) {
- const user = contact?.user;
- return (user ? (get(user, 'status') ?? get(user, 'session_status')) : null) ?? 'active';
+ return linkedUserStatus(contact);
}
hasInactiveLogin(contact) {
- return ['inactive', 'disabled', 'suspended'].includes(this.linkedUserStatus(contact));
+ return hasInactiveLogin(contact);
}
isCustomerPortalInstalled() {
@@ -156,7 +161,7 @@ export default class ContactActionsService extends ResourceActionService {
}
accountActionButton(contact, options = {}) {
- if (!this.hasLinkedUser(contact)) {
+ if (!this.hasManagedLogin(contact)) {
return null;
}
@@ -171,23 +176,23 @@ export default class ContactActionsService extends ResourceActionService {
accountActionItems(contact, options = {}) {
const actions = [
{
- text: 'Reset Password',
+ text: this.intl.t('profile-account.actions.reset-password'),
icon: 'key',
fn: () => this.openResetPasswordModal(contact),
},
{
- text: 'Send Credentials',
+ text: this.intl.t('profile-account.actions.send-credentials'),
icon: 'paper-plane',
fn: () => this.confirmSendCredentials(contact),
},
this.hasInactiveLogin(contact)
? {
- text: 'Reactivate Login',
+ text: this.intl.t('profile-account.actions.reactivate-login'),
icon: 'unlock',
fn: () => this.confirmReactivatePortalLogin(contact),
}
: {
- text: 'Deactivate Login',
+ text: this.intl.t('profile-account.actions.deactivate-login'),
icon: 'lock',
fn: () => this.confirmDeactivatePortalLogin(contact),
class: 'text-red-500 hover:text-red-600',
@@ -200,7 +205,7 @@ export default class ContactActionsService extends ResourceActionService {
separator: true,
},
{
- text: 'Convert to Vendor',
+ text: this.intl.t('profile-account.actions.convert-to-vendor'),
icon: 'building',
fn: () => this.openConvertToVendorModal(contact, options),
}
@@ -212,42 +217,42 @@ export default class ContactActionsService extends ResourceActionService {
accountRowActionItems(options = {}) {
const hasCustomerPortal = this.isCustomerPortalInstalled();
- const hasLinkedUser = (contact) => this.hasLinkedUser(contact);
+ const hasManagedLogin = (contact) => this.hasManagedLogin(contact);
const actions = [
{
- label: 'Reset Password',
+ label: this.intl.t('profile-account.actions.reset-password'),
icon: 'key',
fn: (contact) => this.openResetPasswordModal(contact),
- isVisible: hasLinkedUser,
+ isVisible: hasManagedLogin,
},
{
- label: 'Send Credentials',
+ label: this.intl.t('profile-account.actions.send-credentials'),
icon: 'paper-plane',
fn: (contact) => this.confirmSendCredentials(contact),
- isVisible: hasLinkedUser,
+ isVisible: hasManagedLogin,
},
{
- label: 'Deactivate Login',
+ label: this.intl.t('profile-account.actions.deactivate-login'),
icon: 'lock',
class: 'text-red-500 hover:text-red-600',
fn: (contact) => this.confirmDeactivatePortalLogin(contact),
- isVisible: (contact) => hasLinkedUser(contact) && !this.hasInactiveLogin(contact),
+ isVisible: (contact) => hasManagedLogin(contact) && !this.hasInactiveLogin(contact),
},
{
- label: 'Reactivate Login',
+ label: this.intl.t('profile-account.actions.reactivate-login'),
icon: 'unlock',
fn: (contact) => this.confirmReactivatePortalLogin(contact),
- isVisible: (contact) => hasLinkedUser(contact) && this.hasInactiveLogin(contact),
+ isVisible: (contact) => hasManagedLogin(contact) && this.hasInactiveLogin(contact),
},
];
if (hasCustomerPortal) {
actions.push({
- label: 'Convert to Vendor',
+ label: this.intl.t('profile-account.actions.convert-to-vendor'),
icon: 'building',
fn: (contact) => this.openConvertToVendorModal(contact, options),
- isVisible: hasLinkedUser,
+ isVisible: hasManagedLogin,
});
}
@@ -255,8 +260,10 @@ export default class ContactActionsService extends ResourceActionService {
}
openResetPasswordModal(contact) {
- this.modalsManager.show('modals/reset-customer-credentials', {
- customer: contact,
+ this.modalsManager.show('modals/reset-profile-credentials', {
+ profile: contact,
+ endpoint: 'customers/reset-credentials',
+ payload: (profile) => ({ customer: profile?.id }),
});
}
@@ -278,86 +285,40 @@ export default class ContactActionsService extends ResourceActionService {
confirmSendCredentials(contact) {
this.confirmLoginAction(contact, {
- title: 'Send Portal Credentials',
- body: 'Generate a new temporary password and email this contact their portal credentials.',
- acceptButtonText: 'Send Credentials',
+ title: this.intl.t('profile-account.prompts.send-portal-credentials-title'),
+ body: this.intl.t('profile-account.prompts.send-portal-credentials-body'),
+ acceptButtonText: this.intl.t('profile-account.actions.send-credentials'),
endpoint: 'customers/send-credentials',
- successMessage: 'Portal credentials sent.',
+ successMessage: this.intl.t('profile-account.prompts.send-portal-credentials-success'),
});
}
confirmDeactivatePortalLogin(contact) {
this.confirmLoginAction(contact, {
- title: 'Deactivate Login',
- body: 'Deactivate portal access for this contact. Their profile and history will be preserved.',
- acceptButtonText: 'Deactivate Login',
+ title: this.intl.t('profile-account.actions.deactivate-login'),
+ body: this.intl.t('profile-account.prompts.deactivate-portal-login-body'),
+ acceptButtonText: this.intl.t('profile-account.actions.deactivate-login'),
acceptButtonScheme: 'danger',
endpoint: 'customers/deactivate-portal-login',
- successMessage: 'Portal login deactivated.',
+ successMessage: this.intl.t('profile-account.prompts.deactivate-portal-login-success'),
});
}
confirmReactivatePortalLogin(contact) {
this.confirmLoginAction(contact, {
- title: 'Reactivate Login',
- body: 'Reactivate portal access for this contact.',
- acceptButtonText: 'Reactivate Login',
+ title: this.intl.t('profile-account.actions.reactivate-login'),
+ body: this.intl.t('profile-account.prompts.reactivate-portal-login-body'),
+ acceptButtonText: this.intl.t('profile-account.actions.reactivate-login'),
endpoint: 'customers/reactivate-portal-login',
- successMessage: 'Portal login reactivated.',
+ successMessage: this.intl.t('profile-account.prompts.reactivate-portal-login-success'),
});
}
- confirmLoginAction(contact, { title, body, acceptButtonText, acceptButtonScheme, endpoint, successMessage }) {
- this.modalsManager.confirm({
- title,
- body,
- acceptButtonText,
- acceptButtonScheme,
- confirm: async (modal) => {
- modal.startLoading();
-
- try {
- const response = await this.fetch.post(endpoint, { customer: contact?.id }, { namespace: INTERNAL_NAMESPACE });
- await this.updateContactFromResponse(contact, response);
- this.notifications.success(successMessage);
- modal.done();
- } catch (error) {
- this.notifications.serverError(error);
- modal.stopLoading();
- }
- },
- });
+ confirmLoginAction(contact, options = {}) {
+ return confirmLoginAction(this, contact, { payload: { customer: contact?.id }, ...options });
}
- async updateContactFromResponse(contact, response) {
- const payload = response.customer ?? response.contact;
-
- if (!payload || typeof contact?.setProperties !== 'function') {
- return;
- }
-
- contact.setProperties(
- this.withoutIdentityFields({
- user_uuid: payload.user_uuid,
- })
- );
-
- if (payload.user) {
- const user = await contact.user;
-
- if (typeof user?.setProperties === 'function') {
- user.setProperties(this.withoutIdentityFields(payload.user));
- }
- }
- }
-
- withoutIdentityFields(payload = {}) {
- const attributes = { ...payload };
-
- delete attributes.id;
- delete attributes.uuid;
- delete attributes.public_id;
-
- return attributes;
+ updateContactFromResponse(contact, response) {
+ return updateProfileFromResponse(contact, response);
}
}
diff --git a/addon/services/driver-actions.js b/addon/services/driver-actions.js
index 5252f64cf..7a4692579 100644
--- a/addon/services/driver-actions.js
+++ b/addon/services/driver-actions.js
@@ -5,6 +5,7 @@ import { action } from '@ember/object';
import { isArray } from '@ember/array';
import { dasherize } from '@ember/string';
import { PANEL_DEFAULTS, closePanelsThen } from '../utils/context-panel';
+import { confirmLoginAction, hasInactiveLogin, hasManagedLogin } from '../utils/profile-login-actions';
export default class DriverActionsService extends ResourceActionService {
@service('universe/menu-service') menuService;
@@ -422,9 +423,106 @@ export default class DriverActionsService extends ResourceActionService {
{ separator: true },
{ text: this.intl.t('driver.actions.locate-driver'), icon: 'location-dot', fn: () => this.locate(driver), permission: 'fleet-ops view driver' },
{ text: this.intl.t('driver.actions.create-issue'), icon: 'triangle-exclamation', fn: () => this.createIssue(driver), permission: 'fleet-ops create issue' },
+ ...this.accountMenuItems(driver),
];
}
+ /**
+ * The login account section of a driver's actions menu. Only offered when
+ * the driver has a login managed from Fleet-Ops; a staff-linked driver's
+ * login is managed from IAM.
+ */
+ accountMenuItems(driver) {
+ if (!hasManagedLogin(driver)) {
+ return [];
+ }
+
+ const items = this.accountRowActionItems()
+ .filter(({ isVisible }) => isVisible(driver))
+ // eslint-disable-next-line no-unused-vars
+ .map(({ label, fn, isVisible, ...item }) => ({ ...item, text: label, fn: () => fn(driver) }));
+
+ return [{ separator: true }, ...items];
+ }
+
+ /**
+ * Login account actions for the drivers table row menu.
+ */
+ accountRowActionItems() {
+ const permission = 'fleet-ops update driver';
+
+ return [
+ {
+ label: this.intl.t('profile-account.actions.reset-password'),
+ icon: 'key',
+ fn: (driver) => this.resetCredentials(driver),
+ permission,
+ isVisible: (driver) => hasManagedLogin(driver),
+ },
+ {
+ label: this.intl.t('profile-account.actions.send-credentials'),
+ icon: 'paper-plane',
+ fn: (driver) => this.sendCredentials(driver),
+ permission,
+ isVisible: (driver) => hasManagedLogin(driver),
+ },
+ {
+ label: this.intl.t('profile-account.actions.deactivate-login'),
+ icon: 'lock',
+ class: 'text-red-500 hover:text-red-600',
+ fn: (driver) => this.deactivateLogin(driver),
+ permission,
+ isVisible: (driver) => hasManagedLogin(driver) && !hasInactiveLogin(driver),
+ },
+ {
+ label: this.intl.t('profile-account.actions.reactivate-login'),
+ icon: 'unlock',
+ fn: (driver) => this.reactivateLogin(driver),
+ permission,
+ isVisible: (driver) => hasManagedLogin(driver) && hasInactiveLogin(driver),
+ },
+ ];
+ }
+
+ @action resetCredentials(driver, options = {}) {
+ return this.modalsManager.show('modals/reset-profile-credentials', {
+ profile: driver,
+ endpoint: `drivers/${driver.id}/reset-credentials`,
+ ...options,
+ });
+ }
+
+ @action sendCredentials(driver) {
+ return confirmLoginAction(this, driver, {
+ title: this.intl.t('profile-account.prompts.send-credentials-title'),
+ body: this.intl.t('profile-account.prompts.send-credentials-body', { name: driver.name }),
+ acceptButtonText: this.intl.t('profile-account.actions.send-credentials'),
+ endpoint: `drivers/${driver.id}/send-credentials`,
+ successMessage: this.intl.t('profile-account.prompts.send-credentials-success', { name: driver.name }),
+ });
+ }
+
+ @action deactivateLogin(driver) {
+ return confirmLoginAction(this, driver, {
+ title: this.intl.t('profile-account.actions.deactivate-login'),
+ body: this.intl.t('profile-account.prompts.deactivate-login-body', { name: driver.name }),
+ acceptButtonText: this.intl.t('profile-account.actions.deactivate-login'),
+ acceptButtonScheme: 'danger',
+ endpoint: `drivers/${driver.id}/deactivate-login`,
+ successMessage: this.intl.t('profile-account.prompts.deactivate-login-success', { name: driver.name }),
+ });
+ }
+
+ @action reactivateLogin(driver) {
+ return confirmLoginAction(this, driver, {
+ title: this.intl.t('profile-account.actions.reactivate-login'),
+ body: this.intl.t('profile-account.prompts.reactivate-login-body', { name: driver.name }),
+ acceptButtonText: this.intl.t('profile-account.actions.reactivate-login'),
+ endpoint: `drivers/${driver.id}/reactivate-login`,
+ successMessage: this.intl.t('profile-account.prompts.reactivate-login-success', { name: driver.name }),
+ });
+ }
+
@action createIssue(driver) {
return this.issueActions.modal.create({
driver,
diff --git a/addon/utils/profile-login-actions.js b/addon/utils/profile-login-actions.js
new file mode 100644
index 000000000..16b04e181
--- /dev/null
+++ b/addon/utils/profile-login-actions.js
@@ -0,0 +1,172 @@
+import { get } from '@ember/object';
+
+/**
+ * Shared helpers for the login account behind a driver, contact or customer
+ * profile. The account itself is created, updated and removed by the server
+ * from the profile's name/email/phone; the console only offers the account
+ * actions (reset password, send credentials, deactivate/reactivate login).
+ *
+ * A profile owned by a staff member (`is_staff_linked`) has its login
+ * managed from IAM, so no account actions are offered for it here.
+ */
+
+export const INTERNAL_NAMESPACE = 'int/v1';
+export const INACTIVE_LOGIN_STATUSES = ['inactive', 'disabled', 'suspended'];
+
+function read(subject, key) {
+ if (!subject) {
+ return undefined;
+ }
+
+ try {
+ return get(subject, key);
+ } catch {
+ return subject[key];
+ }
+}
+
+/**
+ * The linked user record when it is already loaded, without triggering a
+ * fetch for an async relationship.
+ */
+export function loadedUser(profile) {
+ if (typeof profile?.belongsTo === 'function') {
+ try {
+ return profile.belongsTo('user').value();
+ } catch {
+ return null;
+ }
+ }
+
+ const user = read(profile, 'user');
+ if (!user) {
+ return null;
+ }
+
+ return typeof user.then === 'function' ? (read(user, 'content') ?? null) : user;
+}
+
+export function isStaffLinked(profile) {
+ return Boolean(read(profile, 'is_staff_linked'));
+}
+
+export function hasLinkedUser(profile) {
+ if (!profile) {
+ return false;
+ }
+
+ if (read(profile, 'user_uuid') || read(profile, 'login_status')) {
+ return true;
+ }
+
+ const user = loadedUser(profile);
+ return Boolean(user && (read(user, 'id') || read(user, 'uuid')));
+}
+
+/**
+ * True when the profile has a login account that is managed from Fleet-Ops,
+ * i.e. a linked user which is not a staff member's account.
+ */
+export function hasManagedLogin(profile) {
+ return hasLinkedUser(profile) && !isStaffLinked(profile);
+}
+
+/**
+ * The login status, preferring the server-provided `login_status` over the
+ * embedded user's status.
+ */
+export function linkedUserStatus(profile) {
+ const loginStatus = read(profile, 'login_status');
+ if (loginStatus) {
+ return loginStatus;
+ }
+
+ const user = loadedUser(profile);
+ return (user ? (read(user, 'status') ?? read(user, 'session_status')) : null) ?? 'active';
+}
+
+export function hasInactiveLogin(profile) {
+ return INACTIVE_LOGIN_STATUSES.includes(linkedUserStatus(profile));
+}
+
+export function withoutIdentityFields(payload = {}) {
+ const attributes = { ...payload };
+
+ delete attributes.id;
+ delete attributes.uuid;
+ delete attributes.public_id;
+
+ return attributes;
+}
+
+/**
+ * Applies the profile payload returned by an account action endpoint
+ * (`{ driver: {...} }`, `{ customer: {...} }` or `{ contact: {...} }`).
+ */
+export function updateProfileFromResponse(profile, response = {}) {
+ const payload = response?.driver ?? response?.customer ?? response?.contact;
+
+ if (!payload || typeof profile?.setProperties !== 'function') {
+ return;
+ }
+
+ const attributes = {};
+ if ('user_uuid' in payload) {
+ attributes.user_uuid = payload.user_uuid;
+ }
+ if ('is_staff_linked' in payload) {
+ attributes.is_staff_linked = Boolean(payload.is_staff_linked);
+ }
+ if ('login_status' in payload) {
+ attributes.login_status = payload.login_status;
+ } else if (payload.user && 'status' in payload.user) {
+ attributes.login_status = payload.user.status;
+ }
+
+ profile.setProperties(attributes);
+
+ if (payload.user) {
+ const user = loadedUser(profile);
+
+ if (typeof user?.setProperties === 'function') {
+ user.setProperties(withoutIdentityFields(payload.user));
+ }
+ }
+}
+
+/**
+ * Opens a confirm modal which POSTs to an account action endpoint and applies
+ * the response to the profile.
+ *
+ * @param {Object} services { modalsManager, fetch, notifications }
+ * @param {Object} profile the driver or contact record
+ * @param {Object} options { title, body, acceptButtonText, acceptButtonScheme, endpoint, payload, successMessage, onSuccess }
+ */
+export function confirmLoginAction({ modalsManager, fetch, notifications }, profile, options = {}) {
+ const { title, body, acceptButtonText, acceptButtonScheme, endpoint, payload = {}, successMessage, onSuccess } = options;
+
+ return modalsManager.confirm({
+ title,
+ body,
+ acceptButtonText,
+ acceptButtonScheme,
+ confirm: async (modal) => {
+ modal.startLoading();
+
+ try {
+ const response = await fetch.post(endpoint, payload, { namespace: INTERNAL_NAMESPACE });
+ updateProfileFromResponse(profile, response);
+ notifications.success(successMessage);
+
+ if (typeof onSuccess === 'function') {
+ onSuccess(response);
+ }
+
+ modal.done();
+ } catch (error) {
+ notifications.serverError(error);
+ modal.stopLoading();
+ }
+ },
+ });
+}
diff --git a/app/components/modals/reset-customer-credentials.js b/app/components/modals/reset-profile-credentials.js
similarity index 64%
rename from app/components/modals/reset-customer-credentials.js
rename to app/components/modals/reset-profile-credentials.js
index 7a4e86563..d27dc8eb4 100644
--- a/app/components/modals/reset-customer-credentials.js
+++ b/app/components/modals/reset-profile-credentials.js
@@ -1 +1 @@
-export { default } from '@fleetbase/fleetops-engine/components/modals/reset-customer-credentials';
+export { default } from '@fleetbase/fleetops-engine/components/modals/reset-profile-credentials';
diff --git a/app/components/profile-identity-hint.js b/app/components/profile-identity-hint.js
new file mode 100644
index 000000000..0dd7930f7
--- /dev/null
+++ b/app/components/profile-identity-hint.js
@@ -0,0 +1 @@
+export { default } from '@fleetbase/fleetops-engine/components/profile-identity-hint';
diff --git a/composer.json b/composer.json
index f586305df..3907b8527 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,6 @@
{
"name": "fleetbase/fleetops-api",
- "version": "0.6.68",
+ "version": "0.6.69",
"description": "Fleet & Transport Management Extension for Fleetbase",
"keywords": [
"fleetbase-extension",
diff --git a/extension.json b/extension.json
index 9d1f35126..49ca67ca9 100644
--- a/extension.json
+++ b/extension.json
@@ -1,6 +1,6 @@
{
"name": "Fleet-Ops",
- "version": "0.6.68",
+ "version": "0.6.69",
"description": "Fleet & Transport Management Extension for Fleetbase",
"repository": "https://github.com/fleetbase/fleetops",
"license": "AGPL-3.0-or-later",
diff --git a/package.json b/package.json
index 87b5ef586..680462f58 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@fleetbase/fleetops-engine",
- "version": "0.6.68",
+ "version": "0.6.69",
"description": "Fleet & Transport Management Extension for Fleetbase",
"fleetbase": {
"route": "fleet-ops"
@@ -45,8 +45,8 @@
"dependencies": {
"@babel/core": "^7.23.2",
"@fleetbase/ember-core": "^0.3.24",
- "@fleetbase/ember-ui": "^0.4.1",
- "@fleetbase/fleetops-data": "^0.2.1",
+ "@fleetbase/ember-ui": "^0.4.3",
+ "@fleetbase/fleetops-data": "^0.2.2",
"@fleetbase/leaflet-routing-machine": "^3.2.17",
"@fortawesome/ember-fontawesome": "^2.0.0",
"@fortawesome/fontawesome-svg-core": "6.4.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a021227f4..dd3786c74 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -15,11 +15,11 @@ importers:
specifier: ^0.3.24
version: 0.3.24(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))
'@fleetbase/ember-ui':
- specifier: ^0.4.1
- version: 0.4.1(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0)
+ specifier: ^0.4.3
+ version: 0.4.3(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0)
'@fleetbase/fleetops-data':
- specifier: ^0.2.1
- version: 0.2.1(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))
+ specifier: ^0.2.2
+ version: 0.2.2(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))
'@fleetbase/leaflet-routing-machine':
specifier: ^3.2.17
version: 3.2.17
@@ -1397,12 +1397,12 @@ packages:
resolution: {integrity: sha512-WG32JlX4S75ofa34RQEMxFEqc20fTv2Qn3A5+NlqXtL0lfnCthdO+a1DrGqIIierthHl3mVTxhe+z63f8sM6EA==}
engines: {node: '>= 18'}
- '@fleetbase/ember-ui@0.4.1':
- resolution: {integrity: sha512-7xiDohPBK14bRezKDrt6yICpOS1DMRkmEBGdspK6CLzCZz2kQ6Q8rgB0jXQtOw66HFWPiXsll9PgM4IDe3e4fQ==}
+ '@fleetbase/ember-ui@0.4.3':
+ resolution: {integrity: sha512-A8JgpgtfomGl0a2D0eZFSlqMThLzPaEnGyeA2PK7zDWpmshHKTNwRX0hm9MWGLgbtlULQyfQBRetM0X9XSauwA==}
engines: {node: '>= 18'}
- '@fleetbase/fleetops-data@0.2.1':
- resolution: {integrity: sha512-zkRSSDu209DGUyxKSyiLZ5ozt9jNNCE/v6FBMpIjOfiwpaMWUa8wnPxWtzIguCfTUd7wQopXOKXtE9GPn5/h2A==}
+ '@fleetbase/fleetops-data@0.2.2':
+ resolution: {integrity: sha512-HDgcGpJAk8q+sSmV070nCh02o4S0kuFCvwrf+Ltb+IrUkQ2RhDL/w8gdrqM65HrgbgWxQnslIeqzKfF6pV4/DA==}
engines: {node: '>= 18'}
'@fleetbase/intl-lint@0.0.1':
@@ -2200,6 +2200,7 @@ packages:
'@xmldom/xmldom@0.9.10':
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
+ deprecated: this version has critical issues, please update to the latest version
'@xtuc/ieee754@1.2.0':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -3091,6 +3092,7 @@ packages:
charm@1.0.2:
resolution: {integrity: sha512-wqW3VdPnlSWT4eRiYX+hcs+C6ViBPUWk1qTCd+37qw9kEm/a5n2qcyQDMBWvSYKN/ctqZzeXNQaeBjOetJJUkw==}
+ deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
chart.js@4.5.1:
resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==}
@@ -10388,7 +10390,7 @@ snapshots:
- utf-8-validate
- webpack
- '@fleetbase/ember-ui@0.4.1(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0)':
+ '@fleetbase/ember-ui@0.4.3(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(@glimmer/component@1.1.2(@babel/core@7.29.0))(@glimmer/tracking@1.1.2)(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(postcss@8.5.14)(rollup@2.80.0)(tracked-built-ins@3.4.0(@babel/core@7.29.0))(webpack@5.106.2(postcss@8.5.14))(yaml@2.9.0)':
dependencies:
'@babel/core': 7.29.0
'@ember/render-modifiers': 2.1.0(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))
@@ -10494,7 +10496,7 @@ snapshots:
- webpack-command
- yaml
- '@fleetbase/fleetops-data@0.2.1(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))':
+ '@fleetbase/fleetops-data@0.2.2(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))':
dependencies:
'@babel/core': 7.29.0
'@fleetbase/ember-core': 0.3.24(@ember/string@3.1.1)(@ember/test-helpers@3.3.1(@babel/core@7.29.0)(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(webpack@5.106.2(postcss@8.5.14)))(ember-resolver@11.0.1(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14))))(ember-source@5.4.1(@babel/core@7.29.0)(@glimmer/component@1.1.2(@babel/core@7.29.0))(rsvp@4.8.5)(webpack@5.106.2(postcss@8.5.14)))(eslint@8.57.1)(webpack@5.106.2(postcss@8.5.14))
diff --git a/scripts/pest-bootstrap.php b/scripts/pest-bootstrap.php
index faa678b86..6ea5bf5ed 100644
--- a/scripts/pest-bootstrap.php
+++ b/scripts/pest-bootstrap.php
@@ -382,6 +382,18 @@ public static function dispatchNow(...$arguments) { \Fleetbase\TestSupport\Dispa
eval('namespace Fleetbase\Ai\Contracts; interface AIActionCapabilityInterface {}');
}
+if (!interface_exists('Fleetbase\Ai\Contracts\AIToolCapabilityInterface')) {
+ eval('namespace Fleetbase\Ai\Contracts; interface AIToolCapabilityInterface {}');
+}
+
+if (!class_exists('Fleetbase\Ai\Support\AiAudience')) {
+ eval('namespace Fleetbase\Ai\Support; class AiAudience { public function __construct(public bool $isSystemAdmin = false, public array $granted = []) {} public function can(string $permission): bool { return $this->isSystemAdmin || in_array($permission, $this->granted, true); } public function canAll(array $permissions): bool { foreach ($permissions as $permission) { if (!$this->can($permission)) { return false; } } return true; } }');
+}
+
+if (!class_exists('Fleetbase\Ai\Support\AiToolContext')) {
+ eval('namespace Fleetbase\Ai\Support; class AiToolContext { public array $actionPreviews = []; public array $uiActions = []; public function __construct(public $task, public $audience) {} public function addActionPreview($capability, array $preview): array { $preview = array_merge(["preview_id" => "preview-" . (count($this->actionPreviews) + 1), "key" => $capability->key()], $preview); $this->actionPreviews[] = $preview; return $preview; } }');
+}
+
if (!class_exists('Fleetbase\Ai\Models\AiTask')) {
eval('namespace Fleetbase\Ai\Models; class AiTask { public function __construct(array $attributes = []) { foreach ($attributes as $key => $value) { $this->{$key} = $value; } } }');
}
diff --git a/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php b/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php
new file mode 100644
index 000000000..4381ebcd1
--- /dev/null
+++ b/server/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php
@@ -0,0 +1,132 @@
+ 'Driver',
+ 'customer' => 'Fleet-Ops Customer',
+ ];
+
+ public function up(): void
+ {
+ if (!$this->hasRequiredTables()) {
+ return;
+ }
+
+ DB::table('users')
+ ->select(['uuid', 'meta'])
+ ->where('type', 'user')
+ ->whereNull('deleted_at')
+ ->where(function ($query) {
+ $query->whereExists(function ($query) {
+ $query->selectRaw(1)->from('drivers')->whereColumn('drivers.user_uuid', 'users.uuid')->whereNull('drivers.deleted_at');
+ })->orWhereExists(function ($query) {
+ $query->selectRaw(1)->from('contacts')->whereColumn('contacts.user_uuid', 'users.uuid')->where('contacts.type', 'customer')->whereNull('contacts.deleted_at');
+ });
+ })
+ ->whereNotExists(function ($query) {
+ $query->selectRaw(1)->from('companies')->whereColumn('companies.owner_uuid', 'users.uuid');
+ })
+ ->orderBy('uuid')
+ ->chunk(500, function ($users) {
+ foreach ($users as $user) {
+ $type = $this->managedTypeFor($user->uuid);
+ if (!$type) {
+ continue;
+ }
+
+ $meta = json_decode($user->meta ?? '', true);
+ $meta = is_array($meta) ? $meta : [];
+ $meta['previous_type'] = 'user';
+
+ DB::table('users')->where('uuid', $user->uuid)->update(['type' => $type, 'meta' => json_encode($meta)]);
+ }
+ });
+ }
+
+ public function down(): void
+ {
+ if (!Schema::hasTable('users')) {
+ return;
+ }
+
+ DB::table('users')
+ ->select(['uuid', 'meta'])
+ ->whereIn('type', array_keys(self::PROFILE_ROLES))
+ ->where('meta', 'like', '%"previous_type"%')
+ ->orderBy('uuid')
+ ->chunk(500, function ($users) {
+ foreach ($users as $user) {
+ $meta = json_decode($user->meta ?? '', true);
+ if (!is_array($meta) || ($meta['previous_type'] ?? null) !== 'user') {
+ continue;
+ }
+
+ unset($meta['previous_type']);
+ DB::table('users')->where('uuid', $user->uuid)->update(['type' => 'user', 'meta' => json_encode($meta)]);
+ }
+ });
+ }
+
+ /**
+ * The managed type for the account, or null when it should stay a team member.
+ */
+ private function managedTypeFor(string $userUuid): ?string
+ {
+ $hasDriver = DB::table('drivers')->where('user_uuid', $userUuid)->whereNull('deleted_at')->exists();
+ $hasCustomer = DB::table('contacts')->where('user_uuid', $userUuid)->where('type', 'customer')->whereNull('deleted_at')->exists();
+
+ // An account holding both kinds of profile is a person the organization
+ // relates to in two ways; leave it for an administrator to decide.
+ if ($hasDriver === $hasCustomer) {
+ return null;
+ }
+
+ $type = $hasDriver ? 'driver' : 'customer';
+ $companyUserUuids = DB::table('company_users')->where('user_uuid', $userUuid)->whereNull('deleted_at')->pluck('uuid');
+ $modelUuids = $companyUserUuids->push($userUuid)->all();
+
+ $roles = DB::table('model_has_roles')
+ ->join('roles', 'roles.id', '=', 'model_has_roles.role_id')
+ ->whereIn('model_has_roles.model_uuid', $modelUuids)
+ ->pluck('roles.name')
+ ->unique();
+
+ if ($roles->isEmpty() || $roles->contains(fn ($role) => $role !== self::PROFILE_ROLES[$type])) {
+ return null;
+ }
+
+ $hasPermissions = DB::table('model_has_permissions')->whereIn('model_uuid', $modelUuids)->exists();
+ $hasPolicies = DB::table('model_has_policies')->whereIn('model_uuid', $modelUuids)->exists();
+
+ return $hasPermissions || $hasPolicies ? null : $type;
+ }
+
+ private function hasRequiredTables(): bool
+ {
+ foreach (['users', 'drivers', 'contacts', 'companies', 'company_users', 'roles', 'model_has_roles', 'model_has_permissions', 'model_has_policies'] as $table) {
+ if (!Schema::hasTable($table)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+};
diff --git a/server/resources/views/mail/driver-credentials.blade.php b/server/resources/views/mail/driver-credentials.blade.php
new file mode 100644
index 000000000..3236da9aa
--- /dev/null
+++ b/server/resources/views/mail/driver-credentials.blade.php
@@ -0,0 +1,21 @@
+
+
+Your driver sign-in details
+
+
+Hi {{ $user->name }},
+
+
+{{ $companyName }} has set up your driver account. Use these details to sign in to the driver app.
+
+
+Your sign-in details
+
+Login: {{ $identity }}
+
+Temporary password: {{ $plaintextPassword }}
+
+
+You can change your password after signing in.
+
+
diff --git a/server/src/Console/Commands/FixCustomerCompanies.php b/server/src/Console/Commands/FixCustomerCompanies.php
index bd3d55436..149176df0 100644
--- a/server/src/Console/Commands/FixCustomerCompanies.php
+++ b/server/src/Console/Commands/FixCustomerCompanies.php
@@ -68,7 +68,7 @@ public function handle()
$this->line('Found user ' . $user->name . ' (' . $user->email . ') which doesnt have correct company assignment.');
$company = $this->companyByUuid($customer->company_uuid);
if ($company) {
- $user->assignCompany($company);
+ $user->assignCompany($company, 'Fleet-Ops Customer');
$this->line('User ' . $user->email . ' was assigned to company: ' . $company->name);
}
}
diff --git a/server/src/Console/Commands/FixDriverCompanies.php b/server/src/Console/Commands/FixDriverCompanies.php
index 182b7a546..279a8aa4b 100644
--- a/server/src/Console/Commands/FixDriverCompanies.php
+++ b/server/src/Console/Commands/FixDriverCompanies.php
@@ -49,7 +49,7 @@ public function handle()
$this->line('Found driver ' . $user->name . ' (' . $user->email . ') which doesnt have correct company assignment.');
$company = $this->companyByUuid($driver->company_uuid);
if ($company) {
- $user->assignCompany($company);
+ $user->assignCompany($company, 'Driver');
$this->line('Driver ' . $user->email . ' was assigned to company: ' . $company->name);
}
}
diff --git a/server/src/Exceptions/ProfileIdentityConflictException.php b/server/src/Exceptions/ProfileIdentityConflictException.php
new file mode 100644
index 000000000..ebfb51d8b
--- /dev/null
+++ b/server/src/Exceptions/ProfileIdentityConflictException.php
@@ -0,0 +1,34 @@
+field = $field;
+ }
+
+ public function getField(): string
+ {
+ return $this->field;
+ }
+
+ /**
+ * The conflict keyed by field, in the shape of a validation error bag.
+ */
+ public function getErrors(): array
+ {
+ return [$this->field => [$this->getMessage()]];
+ }
+}
diff --git a/server/src/Http/Controllers/Api/v1/CustomerController.php b/server/src/Http/Controllers/Api/v1/CustomerController.php
index 6a37525a6..204dcd85b 100644
--- a/server/src/Http/Controllers/Api/v1/CustomerController.php
+++ b/server/src/Http/Controllers/Api/v1/CustomerController.php
@@ -44,6 +44,8 @@ class CustomerController extends Controller
{
use \Fleetbase\FleetOps\Http\Controllers\Concerns\ResolvesReviewAccountBypass;
+ public const DEACTIVATED_LOGIN_MESSAGE = 'This customer login has been deactivated.';
+
/* ============================================================
| Public auth flows (API credential only, no Customer-Token)
* ============================================================ */
@@ -345,6 +347,10 @@ public function login(Request $request)
return response()->apiError('Authentication failed using credentials provided.', 401);
}
+ if ($user->status === 'inactive') {
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
+ }
+
$sessionCompany = $this->sessionCompany();
if (!$sessionCompany) {
return response()->apiError('No company resolved from API credential.', 500);
@@ -381,6 +387,10 @@ public function loginWithPhone(Request $request)
return response()->apiError('No customer with this phone number found.');
}
+ if ($user->status === 'inactive') {
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
+ }
+
try {
$this->generateSmsVerification($user, 'fleetops_customer_login', [
'messageCallback' => fn ($verification) => 'Your ' . config('app.name') . ' verification code is ' . $verification->code,
@@ -423,6 +433,10 @@ public function verifyCode(Request $request)
return response()->apiError('Unable to verify code.');
}
+ if ($user->status === 'inactive') {
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
+ }
+
$verificationCode = $this->verificationCodeExists([
'subject_uuid' => $user->uuid,
'code' => $code,
diff --git a/server/src/Http/Controllers/Api/v1/DriverController.php b/server/src/Http/Controllers/Api/v1/DriverController.php
index 8711e0e1a..1fbacbe1d 100644
--- a/server/src/Http/Controllers/Api/v1/DriverController.php
+++ b/server/src/Http/Controllers/Api/v1/DriverController.php
@@ -6,6 +6,7 @@
use Fleetbase\FleetOps\Events\GeofenceEntered;
use Fleetbase\FleetOps\Events\GeofenceExited;
use Fleetbase\FleetOps\Events\VehicleLocationChanged;
+use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException;
use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException;
use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesFleetOpsApiResources;
use Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns\ResolvesPublicExpansions;
@@ -22,6 +23,7 @@
use Fleetbase\FleetOps\Models\Vendor;
use Fleetbase\FleetOps\Support\GeofenceIntersectionService;
use Fleetbase\FleetOps\Support\OSRM;
+use Fleetbase\FleetOps\Support\ProfileAccountManager;
use Fleetbase\FleetOps\Support\Utils;
use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Http\Requests\SwitchOrganizationRequest;
@@ -47,6 +49,11 @@ class DriverController extends Controller
use ResolvesFleetOpsApiResources;
use ResolvesPublicExpansions;
+ /**
+ * Returned when a driver whose login was deactivated from the console tries to sign in.
+ */
+ public const DEACTIVATED_LOGIN_MESSAGE = 'This driver login has been deactivated.';
+
/**
* Public expansion name => Eloquent relation name.
*
@@ -97,20 +104,14 @@ public function create(CreateDriverRequest $request)
// Apply user infos
$userDetails = $this->applyUserInfoFromRequest($request, $userDetails);
- // Set company_uuid before creating user
- $userDetails['company_uuid'] = $company->uuid;
-
- // create user account for driver
- $user = $this->createUser($userDetails);
-
- // Assign company — the early return above guarantees $company is set
- $user->assignCompany($company);
-
- // Set user type
- $user->setUserType('driver');
-
- // assign driver role
- $user->assignSingleRole('Driver');
+ // Resolve the driver's login account: a team member of the company with
+ // the email/phone is linked, otherwise a driver account is created and
+ // added to the company without an organization invite.
+ try {
+ $user = $this->resolveDriverAccount($company->uuid, $userDetails);
+ } catch (ProfileIdentityConflictException $exception) {
+ return $this->jsonResponse(['error' => $exception->getMessage()], 422);
+ }
// set user id
$input['user_uuid'] = $user->uuid;
@@ -193,10 +194,11 @@ public function update($id, UpdateDriverRequest $request)
// it. The driver's own record has no timezone column; the user's does.
$userDetails = $request->only(['name', 'email', 'phone', 'timezone']);
- // update driver user details
- $driverUser = $driver->getUser();
- if ($driverUser) {
- $driverUser->update($userDetails);
+ // update the driver's login account through its proxy fields
+ try {
+ ProfileAccountManager::syncProxyFields($driver->getUser(), $userDetails);
+ } catch (ProfileIdentityConflictException $exception) {
+ return $this->jsonResponse(['error' => $exception->getMessage()], 422);
}
// latitude / longitude
@@ -519,10 +521,14 @@ function ($query) use ($identity) {
)->whereHas('driver')->first();
// Check password to authenticate driver
- if (!Hash::check($password, $user->password)) {
+ if (!$user || !is_string($user->password) || !Hash::check((string) $password, $user->password)) {
return response()->apiError('Authentication failed using password provided.', 401);
}
+ if (static::isLoginDeactivated($user)) {
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
+ }
+
// Get the user's company for this driver profile
$company = static::getDriverCompanyFromUser($user);
@@ -561,6 +567,10 @@ public function loginWithPhone()
return response()->apiError('No driver with this phone # found.');
}
+ if (static::isLoginDeactivated($user)) {
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
+ }
+
// Get the user's company for this driver profile
$company = static::getDriverCompanyFromUser($user);
@@ -627,6 +637,10 @@ public function verifyCode(Request $request)
return response()->apiError('Unable to verify code.');
}
+ if (static::isLoginDeactivated($user)) {
+ return response()->apiError(static::DEACTIVATED_LOGIN_MESSAGE, 403);
+ }
+
// find and verify code
$verificationCode = VerificationCode::where(['subject_uuid' => $user->uuid, 'code' => $code, 'for' => $for])->exists();
if (!$verificationCode && !static::verificationBypassMatches($identity, $code)) {
@@ -975,26 +989,29 @@ protected function applyUserInfoFromRequest(Request $request, array $userDetails
return User::applyUserInfoFromRequest($request, $userDetails);
}
- protected function createUser(array $userDetails): User
+ /**
+ * Resolve the login account for a driver created through the API.
+ *
+ * @throws ProfileIdentityConflictException
+ */
+ protected function resolveDriverAccount(string $companyUuid, array $userDetails): User
{
- /*
- * `password` is guarded on User, so mass assignment drops it without a
- * word. The create endpoint has always accepted and validated one, so a
- * driver created through the API could never sign in with the password
- * their operator chose for them. Set it after the fact, where the
- * model's mutator hashes it.
- */
- $password = $userDetails['password'] ?? null;
- unset($userDetails['password']);
-
- $user = User::create($userDetails);
-
- if (is_string($password) && strlen($password)) {
- $user->password = $password;
- $user->save();
- }
+ return ProfileAccountManager::resolveForProfile(
+ $companyUuid,
+ 'driver',
+ $userDetails['name'] ?? null,
+ $userDetails['email'] ?? null,
+ $userDetails['phone'] ?? null,
+ array_merge(Arr::except($userDetails, ['name', 'email', 'phone']), ['status' => 'active'])
+ );
+ }
- return $user;
+ /**
+ * Whether the driver's login was deactivated from the console.
+ */
+ public static function isLoginDeactivated(User $user): bool
+ {
+ return $user->status === 'inactive';
}
protected function getUuid(array|string $table, array $where, array $options = []): mixed
@@ -1313,7 +1330,7 @@ public function forgotPassword(Request $request)
}
$user = static::findDriverUserByIdentity($identity);
- if (!$user) {
+ if (!$user || static::isLoginDeactivated($user)) {
return response()->json(['status' => 'ok']);
}
@@ -1344,7 +1361,7 @@ public function resetPassword(Request $request)
}
$user = static::findDriverUserByIdentity($identity);
- if (!$user) {
+ if (!$user || static::isLoginDeactivated($user)) {
return response()->apiError('Invalid or expired reset code.', 422);
}
diff --git a/server/src/Http/Controllers/Internal/v1/ContactController.php b/server/src/Http/Controllers/Internal/v1/ContactController.php
index d0a8cad03..402fd4eb9 100644
--- a/server/src/Http/Controllers/Internal/v1/ContactController.php
+++ b/server/src/Http/Controllers/Internal/v1/ContactController.php
@@ -265,25 +265,13 @@ protected function customerPortalIssuesForContact(Contact $contact)
->get();
}
+ /**
+ * The login account is managed by the contact profile, so a user can't be
+ * picked or swapped from the console.
+ */
private function resolveUserInput(Request $request, array &$input): void
{
- $user = data_get($input, 'user_uuid') ?? data_get($input, 'user') ?? $request->input('contact.user_uuid') ?? $request->input('contact.user');
-
- if (is_array($user)) {
- $user = data_get($user, 'uuid') ?? data_get($user, 'id');
- }
-
- if (!$user) {
- return;
- }
-
- $input['user_uuid'] = $this->resolveUserUuid($user);
- unset($input['user']);
- }
-
- protected function resolveUserUuid(string $user): string
- {
- return User::where('uuid', $user)->orWhere('public_id', $user)->value('uuid') ?? $user;
+ unset($input['user_uuid'], $input['user']);
}
protected function assertCustomerPortalCanSendWelcomeEmail(array $input): void
diff --git a/server/src/Http/Controllers/Internal/v1/CustomerController.php b/server/src/Http/Controllers/Internal/v1/CustomerController.php
index 411c261bd..e17f4c4fd 100644
--- a/server/src/Http/Controllers/Internal/v1/CustomerController.php
+++ b/server/src/Http/Controllers/Internal/v1/CustomerController.php
@@ -4,6 +4,7 @@
use Fleetbase\FleetOps\Mail\CustomerCredentialsMail;
use Fleetbase\FleetOps\Models\Contact;
+use Fleetbase\FleetOps\Support\ProfileAccountManager;
use Fleetbase\Http\Controllers\Controller;
use Fleetbase\Models\User;
use Illuminate\Http\Request;
@@ -21,6 +22,10 @@ public function createPortalLogin(Request $request)
return response()->error('Unable to create customer portal login.');
}
+ if ($error = $this->staffLinkedError($user)) {
+ return $error;
+ }
+
if ($user->status !== 'active') {
$user->activate();
}
@@ -39,6 +44,10 @@ public function sendCredentials(Request $request)
return response()->error('Unable to send customer portal credentials.');
}
+ if ($error = $this->staffLinkedError($user)) {
+ return $error;
+ }
+
$password = $this->randomPassword();
$user->changePassword($password);
$this->sendCustomerCredentials($user, $password, $customer);
@@ -57,6 +66,10 @@ public function deactivatePortalLogin(Request $request)
return response()->error('Customer portal login not found.');
}
+ if ($error = $this->staffLinkedError($user)) {
+ return $error;
+ }
+
$user->deactivate();
return response()->json([
@@ -73,6 +86,10 @@ public function reactivatePortalLogin(Request $request)
return response()->error('Customer portal login not found.');
}
+ if ($error = $this->staffLinkedError($user)) {
+ return $error;
+ }
+
$user->activate();
return response()->json([
@@ -149,6 +166,10 @@ public function resetCredentials(Request $request)
return response()->error('Unable to reset customer credentials');
}
+ if ($error = $this->staffLinkedError($user)) {
+ return $error;
+ }
+
// Change password
$user->changePassword($password);
@@ -163,6 +184,19 @@ public function resetCredentials(Request $request)
]);
}
+ /**
+ * A customer linked to a team member's account signs in with that account,
+ * which is managed in IAM.
+ */
+ protected function staffLinkedError(User $user): ?\Illuminate\Http\JsonResponse
+ {
+ if (ProfileAccountManager::isStaffAccount($user)) {
+ return response()->error('This customer signs in with a team member account. Manage this login in IAM.', 422);
+ }
+
+ return null;
+ }
+
protected function resolveCustomer(Request $request): Contact
{
$customerId = $request->input('customer');
@@ -213,10 +247,12 @@ protected function customerPayload(Contact $customer): array
$customer->loadMissing('user');
return [
- 'id' => $customer->public_id,
- 'uuid' => $customer->uuid,
- 'user_uuid' => $customer->user_uuid,
- 'user' => $customer->user ? [
+ 'id' => $customer->public_id,
+ 'uuid' => $customer->uuid,
+ 'user_uuid' => $customer->user_uuid,
+ 'is_staff_linked' => ProfileAccountManager::isStaffAccount($customer->user),
+ 'login_status' => $customer->user?->status,
+ 'user' => $customer->user ? [
'id' => $customer->user->public_id,
'uuid' => $customer->user->uuid,
'name' => $customer->user->name,
diff --git a/server/src/Http/Controllers/Internal/v1/DriverController.php b/server/src/Http/Controllers/Internal/v1/DriverController.php
index de272c6c1..26203e962 100644
--- a/server/src/Http/Controllers/Internal/v1/DriverController.php
+++ b/server/src/Http/Controllers/Internal/v1/DriverController.php
@@ -3,6 +3,7 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
use Fleetbase\Exceptions\FleetbaseRequestValidationException;
+use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException;
use Fleetbase\FleetOps\Exports\DriverExport;
use Fleetbase\FleetOps\Http\Controllers\Api\v1\DriverController as ApiDriverController;
use Fleetbase\FleetOps\Http\Controllers\FleetOpsController;
@@ -13,11 +14,11 @@
use Fleetbase\FleetOps\Models\Driver;
use Fleetbase\FleetOps\Models\Order;
use Fleetbase\FleetOps\Models\Vehicle;
+use Fleetbase\FleetOps\Support\ProfileAccountManager;
use Fleetbase\FleetOps\Support\Utils;
use Fleetbase\Http\Requests\ExportRequest;
use Fleetbase\Http\Requests\ImportRequest;
use Fleetbase\LaravelMysqlSpatial\Types\Point;
-use Fleetbase\Models\Invite;
use Fleetbase\Models\User;
use Fleetbase\Models\VerificationCode;
use Fleetbase\Support\Auth;
@@ -58,72 +59,6 @@ public function createRecord(Request $request)
$validator = Validator::make($input, $rules);
if ($validator->fails()) {
- // here if the user exists already
- // within organization: offer to create driver record
- // outside organization: invite to join organization AS DRIVER
- if ($validator->errors()->hasAny(['phone', 'email'])) {
- // get existing user
- $existingUser = null;
-
- // if values provided for user lookup
- if (!empty($input['phone']) || !empty($input['email'])) {
- $existingUserQuery = User::query();
-
- if (!empty($input['phone']) && is_string($input['phone'])) {
- $existingUserQuery->orWhere(function ($q) use ($input) {
- $q->where('phone', $input['phone'])->whereNotNull('phone');
- });
- }
-
- if (!empty($input['email']) && is_string($input['email'])) {
- $existingUserQuery->orWhere(function ($q) use ($input) {
- $q->where('email', $input['email'])->whereNotNull('email');
- });
- }
-
- $existingUser = $existingUserQuery->first();
- }
-
- if ($existingUser) {
- // if exists in organization create driver profile for user
- $isOrganizationMember = $existingUser->companies()->where('companies.uuid', session('company'))->exists();
-
- // Check if driver profile also already exists
- $existingDriverProfile = Driver::where(['company_uuid' => session('company'), 'user_uuid' => $existingUser->uuid])->first();
- if ($existingDriverProfile) {
- return ['driver' => new $this->resource($existingDriverProfile)];
- }
-
- // create driver profile for user
- $input = collect($input)
- ->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'status'])
- ->filter()
- ->toArray();
-
- // Get current session company
- $company = Auth::getCompany();
- $input['company_uuid'] = session('company', $company->uuid);
- $input['user_uuid'] = $existingUser->uuid;
- $input['slug'] = $existingUser->slug;
-
- // If no location provided set
- if (empty($input['location'])) {
- $input['location'] = new Point(0, 0);
- }
-
- // create the profile
- $driverProfile = Driver::create($input);
-
- // If not already a member of the company assign them to the company and send the user an invite
- if (!$isOrganizationMember && $company) {
- $existingUser->assignCompany($company);
- }
-
- return ['driver' => new $this->resource($driverProfile)];
- }
- }
-
- // check from validator object if phone or email is not unique
return $createDriverRequest->responseWithErrors($validator);
}
@@ -134,104 +69,36 @@ function (&$request, &$input) {
$input = collect($input);
// Get current session company
- $company = Auth::getCompany();
+ $company = Auth::getCompany();
if (!$company) {
throw new \Exception('Unable to create driver.');
}
- if ($input->has('user_uuid')) {
- $user = User::where('uuid', $input->get('user_uuid'))->first();
-
- // Check if a driver profile already exists for this user in the current company
- if ($user) {
- $existingDriver = Driver::where(['user_uuid' => $user->uuid, 'company_uuid' => session('company')])->first();
- if ($existingDriver) {
- throw new \Exception('This user account already belongs to a driver.');
- }
- }
-
- // If user doesn't exist with provided UUID, create new user
- if (!$user) {
- $userInput = $input
- ->only(['name', 'password', 'email', 'phone', 'status', 'avatar_uuid'])
- ->filter()
- ->toArray();
-
- // handle `photo_uuid`
- if (isset($input['photo_uuid']) && Str::isUuid($input['photo_uuid'])) {
- $userInput['avatar_uuid'] = $input['photo_uuid'];
- }
-
- // Make sure password is set
- if (empty($userInput['password'])) {
- $userInput['password'] = Str::random(14);
- }
-
- // Set user company
- $userInput['company_uuid'] = session('company', $company->uuid);
-
- // Apply user infos
- $userInput = User::applyUserInfoFromRequest($request, $userInput);
-
- // Create user account
- $user = User::create($userInput);
-
- // Set the user type to driver
- $user->setType('driver');
- } elseif ($input->has('photo_uuid')) {
- // Update existing user's avatar if photo provided
- $user->update(['avatar_uuid' => $input->get('photo_uuid')]);
- }
- } else {
- $userInput = $input
- ->only(['name', 'password', 'email', 'phone', 'status', 'avatar_uuid'])
- ->filter()
- ->toArray();
-
- // handle `photo_uuid`
- if (isset($input['photo_uuid']) && Str::isUuid($input['photo_uuid'])) {
- $userInput['avatar_uuid'] = $input['photo_uuid'];
- }
-
- // Make sure password is set
- if (empty($userInput['password'])) {
- $userInput['password'] = Str::random(14);
- }
-
- // Set user company
- $userInput['company_uuid'] = session('company', $company->uuid);
-
- // Apply user infos
- $userInput = User::applyUserInfoFromRequest($request, $userInput);
-
- // Create user account
- $user = User::create($userInput);
-
- // Set the user type to driver
- $user->setType('driver');
- }
-
- // if exists in organization create driver profile for user
- $isOrganizationMember = $user->companies()->where('companies.uuid', session('company'))->exists();
+ // The login account is managed by the driver profile: a team member
+ // with the email/phone is linked, otherwise a driver account is created.
+ $avatarUuid = Str::isUuid($input->get('photo_uuid')) ? $input->get('photo_uuid') : $input->get('avatar_uuid');
+ $user = ProfileAccountManager::resolveForProfile(
+ $company->uuid,
+ 'driver',
+ $input->get('name'),
+ $input->get('email'),
+ $input->get('phone'),
+ User::applyUserInfoFromRequest($request, array_filter([
+ 'password' => $input->get('password'),
+ 'status' => 'active',
+ 'avatar_uuid' => $avatarUuid,
+ ]))
+ );
// Prepare input
$input = $input
- ->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'status'])
+ ->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'status', 'user_uuid', 'user'])
->filter()
->toArray();
- // Assign user to company and send invite
- if (!$isOrganizationMember && $company) {
- $user->assignCompany($company);
- }
-
- // Set user type as driver and set role to driver
- if ($user->type === 'driver') {
- $user->assignSingleRole('Driver');
- }
-
- $input['user_uuid'] = $user->uuid;
- $input['slug'] = $user->slug;
+ $input['company_uuid'] = $company->uuid;
+ $input['user_uuid'] = $user->uuid;
+ $input['slug'] = $user->slug;
// If no location provided set
if (empty($input['location'])) {
@@ -248,6 +115,8 @@ function ($request, &$driver) {
);
return ['driver' => new $this->resource($record)];
+ } catch (ProfileIdentityConflictException $e) {
+ return response()->error($e->getMessage(), 422, ['field' => $e->getField()]);
} catch (QueryException $e) {
return response()->error(env('DEBUG') ? $e->getMessage() : 'Error occurred while trying to create a ' . $this->resourceSingularlName);
} catch (FleetbaseRequestValidationException $e) {
@@ -287,18 +156,15 @@ public function updateRecord(Request $request, string $id)
function (&$request, &$driver, &$input) {
$driver->load(['user'])->guard(['user_uuid']);
$input = collect($input);
- $userInput = $input->only(['name', 'password', 'email', 'phone', 'avatar_uuid'])->reject(fn ($value) => $value === null)->toArray();
+ $userInput = $input->only(['name', 'password', 'email', 'phone', 'avatar_uuid'])->reject(fn ($value, $key) => $value === null && !in_array($key, ['email', 'phone'], true))->toArray();
// handle `photo_uuid`
if (isset($input['photo_uuid']) && Str::isUuid($input['photo_uuid'])) {
$userInput['avatar_uuid'] = $input['photo_uuid'];
}
- $input = $input->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid'])->toArray();
+ $input = $input->except(['name', 'password', 'email', 'phone', 'meta', 'avatar_uuid', 'photo_uuid', 'user_uuid', 'user'])->toArray();
- // Update driver user details
- $driverUser = $driver->getUser();
- if ($driverUser && !empty($userInput)) {
- $driverUser->update($userInput);
- }
+ // Update the driver's login account through its proxy fields
+ ProfileAccountManager::syncProxyFields($driver->getUser(), $userInput);
// Flush cache
$driver->flushAttributesCache();
@@ -318,6 +184,8 @@ function ($request, &$driver) {
);
return ['driver' => new $this->resource($record)];
+ } catch (ProfileIdentityConflictException $e) {
+ return response()->error($e->getMessage(), 422, ['field' => $e->getField()]);
} catch (QueryException $e) {
return response()->error(env('DEBUG') ? $e->getMessage() : 'Error occurred while trying to update a ' . $this->resourceSingularlName);
} catch (FleetbaseRequestValidationException $e) {
@@ -327,6 +195,150 @@ function ($request, &$driver) {
}
}
+ /**
+ * Generate a new password for the driver's login and send it to them.
+ *
+ * @return JsonResponse
+ */
+ public function sendCredentials(string $id)
+ {
+ [$driver, $user, $error] = $this->resolveDriverLogin($id);
+ if ($error) {
+ return $error;
+ }
+
+ try {
+ $sentVia = ProfileAccountManager::sendCredentials($driver, $user);
+ } catch (\Exception $e) {
+ return response()->error($e->getMessage());
+ }
+
+ return response()->json(['status' => 'ok', 'sent_via' => $sentVia, 'driver' => $this->driverLoginPayload($driver)]);
+ }
+
+ /**
+ * Set a new password for the driver's login, optionally sending it to them.
+ *
+ * @return JsonResponse
+ */
+ public function resetCredentials(Request $request, string $id)
+ {
+ $password = $request->input('password');
+ if (!is_string($password) || strlen($password) < 8) {
+ return response()->error('Password must be at least 8 characters.');
+ }
+
+ if ($password !== $request->input('password_confirmation')) {
+ return response()->error('Passwords do not match.');
+ }
+
+ [$driver, $user, $error] = $this->resolveDriverLogin($id);
+ if ($error) {
+ return $error;
+ }
+
+ $user->changePassword($password);
+
+ try {
+ if ($request->boolean('send_credentials')) {
+ ProfileAccountManager::deliverCredentials($driver, $user, $password);
+ }
+ } catch (\Exception $e) {
+ return response()->error($e->getMessage());
+ }
+
+ return response()->json(['status' => 'ok', 'driver' => $this->driverLoginPayload($driver)]);
+ }
+
+ /**
+ * Stop the driver from signing in to the driver app.
+ *
+ * @return JsonResponse
+ */
+ public function deactivateLogin(string $id)
+ {
+ [$driver, $user, $error] = $this->resolveDriverLogin($id);
+ if ($error) {
+ return $error;
+ }
+
+ $user->deactivate();
+
+ // Sign the driver out of the driver app
+ $user->tokens()->delete();
+
+ return response()->json(['status' => 'ok', 'driver' => $this->driverLoginPayload($driver)]);
+ }
+
+ /**
+ * Allow the driver to sign in to the driver app again.
+ *
+ * @return JsonResponse
+ */
+ public function reactivateLogin(string $id)
+ {
+ [$driver, $user, $error] = $this->resolveDriverLogin($id);
+ if ($error) {
+ return $error;
+ }
+
+ $user->activate();
+
+ return response()->json(['status' => 'ok', 'driver' => $this->driverLoginPayload($driver)]);
+ }
+
+ /**
+ * Find the driver and its managed login account. A driver linked to a team
+ * member's account has its login managed in IAM.
+ *
+ * @return array{0: ?Driver, 1: ?User, 2: ?JsonResponse}
+ */
+ protected function resolveDriverLogin(string $id): array
+ {
+ $driver = Driver::where('company_uuid', session('company'))
+ ->where(function ($query) use ($id) {
+ $query->where('uuid', $id)->orWhere('public_id', $id);
+ })
+ ->first();
+
+ if (!$driver) {
+ return [null, null, response()->error('Driver not found.', 404)];
+ }
+
+ $user = $driver->user_uuid ? User::where('uuid', $driver->user_uuid)->first() : null;
+ if (!$user) {
+ return [$driver, null, response()->error('This driver has no login account.')];
+ }
+
+ if (ProfileAccountManager::isStaffAccount($user)) {
+ return [$driver, $user, response()->error('This driver signs in with a team member account. Manage this login in IAM.', 422)];
+ }
+
+ return [$driver, $user, null];
+ }
+
+ protected function driverLoginPayload(Driver $driver): array
+ {
+ $user = User::where('uuid', $driver->user_uuid)->first();
+
+ return [
+ 'id' => $driver->public_id,
+ 'uuid' => $driver->uuid,
+ 'user_uuid' => $driver->user_uuid,
+ 'is_staff_linked' => ProfileAccountManager::isStaffAccount($user),
+ 'login_status' => $user?->status,
+ 'user' => $user ? [
+ 'id' => $user->public_id,
+ 'uuid' => $user->uuid,
+ 'name' => $user->name,
+ 'email' => $user->email,
+ 'phone' => $user->phone,
+ 'status' => $user->status,
+ 'session_status' => $user->session_status,
+ ] : null,
+ ];
+ }
+
/**
* Get all status options for an driver.
*
@@ -635,6 +647,10 @@ public function loginWithPhone()
return response()->error('No driver with this phone # found.');
}
+ if (ApiDriverController::isLoginDeactivated($user)) {
+ return response()->error(ApiDriverController::DEACTIVATED_LOGIN_MESSAGE, 403);
+ }
+
// Generate verification token
static::generateDriverLoginVerification($user);
@@ -663,6 +679,10 @@ public function verifyCode(Request $request)
return response()->error('Unable to verify code.');
}
+ if (ApiDriverController::isLoginDeactivated($user)) {
+ return response()->error(ApiDriverController::DEACTIVATED_LOGIN_MESSAGE, 403);
+ }
+
// Find and verify code
$verificationCode = static::verificationCodeExists($user, $code, $for);
if (!$verificationCode && !ApiDriverController::verificationBypassMatches($identity, $code)) {
diff --git a/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php b/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php
index 0f41d503a..ee0143ed3 100644
--- a/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php
+++ b/server/src/Http/Controllers/Internal/v1/FleetOpsLookupController.php
@@ -3,16 +3,63 @@
namespace Fleetbase\FleetOps\Http\Controllers\Internal\v1;
use Fleetbase\FleetOps\Models\Contact;
+use Fleetbase\FleetOps\Models\Driver;
use Fleetbase\FleetOps\Models\IntegratedVendor;
use Fleetbase\FleetOps\Models\Vendor;
+use Fleetbase\FleetOps\Support\ProfileAccountManager;
use Fleetbase\FleetOps\Support\Utils;
use Fleetbase\Http\Controllers\Controller;
+use Fleetbase\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
class FleetOpsLookupController extends Controller
{
+ /**
+ * Describes what an email/phone entered on a driver, contact or customer
+ * form resolves to: the team member the profile would be linked to, or why
+ * the email/phone can't be used.
+ *
+ * @return \Illuminate\Http\JsonResponse
+ */
+ public function profileIdentity(Request $request)
+ {
+ $type = $request->input('type', 'driver');
+ if (!in_array($type, ProfileAccountManager::MANAGED_TYPES, true)) {
+ return response()->error('Invalid profile type.', 422);
+ }
+
+ $currentUser = $this->currentProfileUser($type, $request->input('ignore'));
+ $lookup = ProfileAccountManager::lookup(session('company'), $type, $request->input('email'), $request->input('phone'), $currentUser);
+ $staff = $lookup['staff'];
+
+ return response()->json([
+ 'staff' => $staff ? ['uuid' => $staff->uuid, 'name' => $staff->name, 'email' => $staff->email, 'phone' => $staff->phone] : null,
+ 'conflict' => $lookup['conflict'],
+ ]);
+ }
+
+ /**
+ * The account of the profile being edited, so its own email/phone isn't
+ * reported as taken.
+ */
+ private function currentProfileUser(string $type, ?string $id): ?User
+ {
+ if (!$id) {
+ return null;
+ }
+
+ $model = $type === 'driver' ? Driver::withoutGlobalScopes() : Contact::query();
+ $profile = $model->where('company_uuid', session('company'))
+ ->where(function ($query) use ($id) {
+ $query->where('uuid', $id)->orWhere('public_id', $id);
+ })
+ ->first();
+
+ return $profile?->user_uuid ? User::where('uuid', $profile->user_uuid)->first() : null;
+ }
+
/**
* Returns a collection of polymorphic resources as JSON.
*
diff --git a/server/src/Http/Controllers/Internal/v1/OrderController.php b/server/src/Http/Controllers/Internal/v1/OrderController.php
index e07c7bce9..2751c2114 100644
--- a/server/src/Http/Controllers/Internal/v1/OrderController.php
+++ b/server/src/Http/Controllers/Internal/v1/OrderController.php
@@ -39,6 +39,7 @@
use Fleetbase\Models\Type;
use Fleetbase\Support\Auth;
use Fleetbase\Support\TemplateString;
+use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\QueryException;
use Illuminate\Http\Request;
@@ -341,7 +342,8 @@ public function importFromFiles(Request $request)
$info = Utils::lookupIp();
$disk = $request->input('disk', config('filesystems.default'));
$files = $request->input('files');
- $files = File::whereIn('uuid', $files)->get();
+ /** @var \Illuminate\Database\Eloquent\Collection
$files */
+ $files = $this->scopedToCompany(File::whereIn('uuid', $files))->get();
$country = $request->input('country', Utils::or($info, ['country_name', 'region'], 'Singapore'));
$validFileTypes = ['csv', 'tsv', 'xls', 'xlsx'];
@@ -513,7 +515,13 @@ public function bulkAssignDriver(Request $request)
}
// Prepare Order UUID Collection
- $orderUuids = collect($data['ids'])->unique()->values();
+ //
+ // Resolved through the company-scoped lookup so ids naming another
+ // organization's orders are dropped here rather than being assigned,
+ // counted in the response and queued for notification.
+ $orderUuids = $this->ordersByUuid(collect($data['ids'])->unique()->values()->all())
+ ->pluck('uuid')
+ ->values();
// Bulk Update Inside A Transaction
$this->runTransaction(function () use ($orderUuids, $driver): void {
@@ -545,8 +553,14 @@ public function bulkAssignDriver(Request $request)
*/
public function cancel(CancelOrderRequest $request)
{
- /** @var Order */
+ /** @var Order|null */
$order = $this->findOrderByUuid($request->input('order'));
+ if (!$order) {
+ // `exists:orders,uuid` on the form request is a global existence
+ // check, so a known uuid belonging to another organization reaches
+ // here and must be rejected rather than dereferenced.
+ return $this->errorResponse('No order found to cancel.');
+ }
$order->cancel();
@@ -599,9 +613,39 @@ public function dispatchOrder(Request $request)
);
}
+ /**
+ * Constrain a tenant-owned lookup to the company the caller is acting for.
+ *
+ * The order-lifecycle actions on this controller receive their target as a
+ * caller-supplied identifier in the request body or query string rather than
+ * as a bound route parameter, so nothing upstream narrows these queries to
+ * the caller's tenant: `fleetbase.protected` only checks that the caller
+ * holds the named RBAC capability, never which company the record belongs to.
+ *
+ * A missing company session fails the query closed instead of letting it run
+ * unbounded across every tenant.
+ */
+ protected function scopedToCompany(Builder $query): Builder
+ {
+ $companyUuid = $this->sessionCompany();
+ if (!$companyUuid) {
+ $query->whereRaw('1 = 0');
+
+ return $query;
+ }
+
+ return $query->where($query->getModel()->qualifyColumn('company_uuid'), $companyUuid);
+ }
+
+ /**
+ * @return \Illuminate\Database\Eloquent\Collection
+ */
protected function ordersByUuid(array $ids)
{
- return Order::whereIn('uuid', $ids)->get();
+ /** @var \Illuminate\Database\Eloquent\Collection $orders */
+ $orders = $this->scopedToCompany(Order::whereIn('uuid', $ids))->get();
+
+ return $orders;
}
protected function trackingStatusExists(?string $trackingNumberUuid, string $code): bool
@@ -611,7 +655,10 @@ protected function trackingStatusExists(?string $trackingNumberUuid, string $cod
protected function findDriverByUuid(string $uuid): ?Driver
{
- return Driver::whereUuid($uuid)->first();
+ /** @var Driver|null $driver */
+ $driver = $this->scopedToCompany(Driver::whereUuid($uuid))->first();
+
+ return $driver;
}
protected function driverDisplayName(Driver $driver): string
@@ -630,17 +677,47 @@ protected function validateBulkAssignDriverRequest(Request $request): array
protected function findOrderByUuid(string $uuid): ?Order
{
- return Order::where('uuid', $uuid)->first();
+ /** @var Order|null $order */
+ $order = $this->scopedToCompany(Order::where('uuid', $uuid))->first();
+
+ return $order;
}
- protected function findOrderById(string $id, array $with = []): ?Order
+ /**
+ * Resolve an order by uuid or public_id, constrained to the caller's company.
+ *
+ * The identifier match is grouped so the company constraint applies to both
+ * arms — ungrouped it would read as `uuid = ? OR (public_id = ? AND
+ * company_uuid = ?)` and still resolve another organization's orders.
+ *
+ * The identifier is whatever the caller put in the request, so anything
+ * that is not a non-empty string resolves to no order rather than raising.
+ *
+ * @param array $with
+ */
+ protected function findOrderById(mixed $id, array $with = []): ?Order
{
- return Order::findById($id, $with);
+ if (!is_string($id) || $id === '') {
+ return null;
+ }
+
+ /** @var Order|null $order */
+ $order = $this->scopedToCompany(Order::query())
+ ->where(function (Builder $query) use ($id) {
+ $query->where('uuid', $id)->orWhere('public_id', $id);
+ })
+ ->with($with)
+ ->first();
+
+ return $order;
}
protected function findOrderRouteForEdit(string $uuid): ?Order
{
- return Order::where('uuid', $uuid)->with(['payload'])->first();
+ /** @var Order|null $order */
+ $order = $this->scopedToCompany(Order::where('uuid', $uuid))->with(['payload'])->first();
+
+ return $order;
}
protected function orderResponse(Order $order): array
@@ -650,7 +727,7 @@ protected function orderResponse(Order $order): array
protected function assignDriverToOrders($orderUuids, Driver $driver): void
{
- Order::whereIn('uuid', $orderUuids)->update([
+ $this->scopedToCompany(Order::whereIn('uuid', $orderUuids))->update([
'driver_assigned_uuid' => $driver->uuid,
'updated_at' => now(),
]);
@@ -739,17 +816,28 @@ public function start(Request $request)
protected function findOrderForStart(?string $uuid): ?Order
{
- return Order::where('uuid', $uuid)->withoutGlobalScopes()->first();
+ /** @var Order|null $order */
+ $order = $this->scopedToCompany(Order::where('uuid', $uuid)->withoutGlobalScopes())->first();
+
+ return $order;
}
protected function findDriverForStart(?string $uuid): ?Driver
{
- return Driver::where('uuid', $uuid)->withoutGlobalScopes()->first();
+ /** @var Driver|null $driver */
+ $driver = $this->scopedToCompany(Driver::where('uuid', $uuid)->withoutGlobalScopes())->first();
+
+ return $driver;
}
protected function findPayloadForStart(?string $uuid): ?Payload
{
- return Payload::where('uuid', $uuid)->withoutGlobalScopes()->with(['waypoints', 'waypointMarkers', 'entities'])->first();
+ /** @var Payload|null $payload */
+ $payload = $this->scopedToCompany(Payload::where('uuid', $uuid)->withoutGlobalScopes())
+ ->with(['waypoints', 'waypointMarkers', 'entities'])
+ ->first();
+
+ return $payload;
}
protected function dispatchDomainEvent(object $event): object
@@ -771,7 +859,7 @@ protected function orderStartedEvent(Order $order): object
*/
public function updateActivity(string $id, Request $request)
{
- $order = Order::findById($id, [
+ $order = $this->findOrderById($id, [
'driverAssigned',
'payload.entities',
'payload.pickup',
@@ -882,9 +970,8 @@ public function updateActivity(string $id, Request $request)
*/
public function nextActivity(string $id, Request $request)
{
- try {
- $order = Order::findByIdOrFail($id);
- } catch (ModelNotFoundException $e) {
+ $order = $this->findOrderById($id);
+ if (!$order) {
return response()->error('No order found.');
}
@@ -935,7 +1022,7 @@ public function nextActivity(string $id, Request $request)
*/
public function setDestination(string $id, string $placeId)
{
- $order = Order::findById($id, [
+ $order = $this->findOrderById($id, [
'payload.pickup',
'payload.dropoff',
'payload.return',
@@ -1013,7 +1100,7 @@ function ($attribute, $value, $fail) {
return response()->error($errorMessage, 422);
}
- $order = Order::findById($id, ['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints', 'payload.waypointMarkers.place']);
+ $order = $this->findOrderById($id, ['payload.pickup', 'payload.dropoff', 'payload.return', 'payload.waypoints', 'payload.waypointMarkers.place']);
if (!$order) {
return response()->error('No order found.');
}
@@ -1214,7 +1301,14 @@ protected function resolveProof($proof): ?Proof
}
if (is_string($proof)) {
- return Proof::where('public_id', $proof)->orWhere('uuid', $proof)->first();
+ /** @var Proof|null $resolved */
+ $resolved = $this->scopedToCompany(Proof::query())
+ ->where(function (Builder $query) use ($proof) {
+ $query->where('public_id', $proof)->orWhere('uuid', $proof);
+ })
+ ->first();
+
+ return $resolved;
}
return null;
@@ -1419,7 +1513,12 @@ protected function canPingDriver(): bool
protected function findOrderForDriverPing(string $id): Order
{
- return Order::findByIdOrFail($id, ['driverAssigned']);
+ $order = $this->findOrderById($id, ['driverAssigned']);
+ if (!$order) {
+ throw new ModelNotFoundException();
+ }
+
+ return $order;
}
protected function sendDriverPing(Driver $driver, Order $order): void
@@ -1692,7 +1791,10 @@ public function proofs(Request $request, string $id, ?string $subjectId = null)
protected function findOrderForProofs(string $id): ?Order
{
- return Order::where('uuid', $id)->first();
+ /** @var Order|null $order */
+ $order = $this->scopedToCompany(Order::where('uuid', $id))->first();
+
+ return $order;
}
protected function findWaypointProofSubject(Order $order, string $subjectId): ?Waypoint
@@ -1708,7 +1810,10 @@ protected function findWaypointProofSubject(Order $order, string $subjectId): ?W
protected function findEntityProofSubject(string $subjectId): ?Entity
{
- return Entity::where('uuid', $subjectId)->withoutGlobalScopes()->first();
+ /** @var Entity|null $entity */
+ $entity = $this->scopedToCompany(Entity::where('uuid', $subjectId)->withoutGlobalScopes())->first();
+
+ return $entity;
}
protected function proofsForSubject(Order $order, Order|Waypoint|Entity $subject)
@@ -1772,14 +1877,26 @@ public function lookup(Request $request)
return new OrderResource($order);
}
+ /**
+ * Find the order a tracking number belongs to, for the public Track Order page.
+ *
+ * Deliberately NOT company-scoped, unlike the lifecycle lookups above. The
+ * `fleet-ops/lookup` route is public: the tracking page is used by recipients who
+ * have no session, and the tracking number itself is what grants access. Scoping
+ * it to the session company made every lookup from that page fail, because there
+ * is no company to scope to.
+ */
protected function findOrderByTrackingNumber(string $trackingNumber): ?Order
{
- return Order::whereHas(
+ /** @var Order|null $order */
+ $order = Order::whereHas(
'trackingNumber',
function ($query) use ($trackingNumber) {
$query->where('tracking_number', $trackingNumber);
}
)->first();
+
+ return $order;
}
/**
@@ -1824,13 +1941,25 @@ public function scheduleOrder(Request $request)
protected function findOrderForSchedule(?string $id): ?Order
{
- return Order::findById($id);
+ return $this->findOrderById($id);
}
+ /**
+ * Resolve a driver by uuid or public_id, constrained to the caller's company.
+ *
+ * The identifier match is grouped so the company constraint applies to both
+ * arms — ungrouped it would read as `uuid = ? OR (public_id = ? AND
+ * company_uuid = ?)` and still resolve another organization's drivers.
+ */
protected function findDriverForSchedule(string $id): ?Driver
{
- return Driver::where('uuid', $id)
- ->orWhere('public_id', $id)
+ /** @var Driver|null $driver */
+ $driver = $this->scopedToCompany(Driver::query())
+ ->where(function (Builder $query) use ($id) {
+ $query->where('uuid', $id)->orWhere('public_id', $id);
+ })
->first();
+
+ return $driver;
}
}
diff --git a/server/src/Http/Requests/CreateDriverRequest.php b/server/src/Http/Requests/CreateDriverRequest.php
index 3bc95d57f..5f645b953 100644
--- a/server/src/Http/Requests/CreateDriverRequest.php
+++ b/server/src/Http/Requests/CreateDriverRequest.php
@@ -41,9 +41,13 @@ public function rules(): array
* value later. Both are still validated and still unique when they
* are supplied; a driver created without them simply cannot sign in
* to Navigator until credentials are added.
+ *
+ * On create, availability is decided by ProfileAccountManager: a team
+ * member of the company with the email or phone is linked to the new
+ * driver instead of being rejected as a duplicate.
*/
- 'email' => ['nullable', Rule::when($this->filled('email'), ['email']), $this->uniqueAmongUsers()],
- 'phone' => ['nullable', 'string', $this->uniqueAmongUsers()],
+ 'email' => ['nullable', Rule::when($this->filled('email'), ['email']), Rule::when(!$isCreating, [$this->uniqueAmongUsers()])],
+ 'phone' => ['nullable', 'string', Rule::when(!$isCreating, [$this->uniqueAmongUsers()])],
'password' => 'nullable|string',
'timezone' => 'nullable|string|max:64',
diff --git a/server/src/Http/Requests/Internal/CreateDriverRequest.php b/server/src/Http/Requests/Internal/CreateDriverRequest.php
index f8fe7e350..825244789 100644
--- a/server/src/Http/Requests/Internal/CreateDriverRequest.php
+++ b/server/src/Http/Requests/Internal/CreateDriverRequest.php
@@ -23,22 +23,17 @@ public function authorize(): bool
*/
public function rules(): array
{
- $isCreating = $this->isMethod('POST');
- $isCreatingWithUser = $this->filled('driver.user_uuid');
- $shouldValidateUserAttributes = $isCreating && !$isCreatingWithUser;
+ $isCreating = $this->isMethod('POST');
return [
- // Required fields for driver creation
- 'name' => [Rule::requiredIf($shouldValidateUserAttributes), 'nullable', 'string', 'max:255'],
- 'email' => [
- Rule::requiredIf($shouldValidateUserAttributes),
- Rule::when($this->filled('email'), ['email']),
- Rule::when($shouldValidateUserAttributes, [Rule::unique('users')->whereNull('deleted_at')]),
- ],
- 'phone' => [
- Rule::requiredIf($shouldValidateUserAttributes),
- Rule::when($shouldValidateUserAttributes, [Rule::unique('users')->whereNull('deleted_at')]),
- ],
+ // The driver profile manages its login account: name, email and phone
+ // are proxied to it. Email/phone availability is checked against the
+ // account the profile resolves to (see ProfileAccountManager), because a
+ // team member of the organization with the same email or phone is linked
+ // rather than rejected as a duplicate.
+ 'name' => [Rule::requiredIf($isCreating), 'nullable', 'string', 'max:255'],
+ 'email' => ['nullable', Rule::when($this->filled('email'), ['email'])],
+ 'phone' => ['nullable', 'string'],
// Optional fields
'password' => 'nullable|string|min:8',
@@ -84,11 +79,7 @@ public function messages(): array
{
return [
'name.required' => 'Driver name is required.',
- 'email.required' => 'Email address is required.',
'email.email' => 'Please provide a valid email address.',
- 'email.unique' => 'This email address is already registered.',
- 'phone.required' => 'Phone number is required.',
- 'phone.unique' => 'This phone number is already registered.',
'password.min' => 'Password must be at least 8 characters.',
];
}
diff --git a/server/src/Http/Resources/v1/Contact.php b/server/src/Http/Resources/v1/Contact.php
index abd708b16..be4bc47b8 100644
--- a/server/src/Http/Resources/v1/Contact.php
+++ b/server/src/Http/Resources/v1/Contact.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Resources\v1;
+use Fleetbase\FleetOps\Support\ProfileAccountManager;
use Fleetbase\FleetOps\Support\Utils;
use Fleetbase\Http\Resources\FleetbaseResource;
use Fleetbase\Http\Resources\User;
@@ -40,6 +41,8 @@ public function toArray($request)
'places' => $this->whenLoaded('places', fn () => Place::collection($this->places)->without('owner')),
'user' => $this->when(Http::isInternalRequest(), fn () => new User($this->user), fn () => $this->user ? $this->user->public_id : null),
'address' => $this->when(Http::isInternalRequest(), data_get($this, 'place.address')),
+ 'is_staff_linked' => $this->when(Http::isInternalRequest(), fn () => ProfileAccountManager::isStaffAccount($this->user)),
+ 'login_status' => $this->when(Http::isInternalRequest(), fn () => data_get($this, 'user.status')),
'address_street' => $this->when(Http::isInternalRequest(), data_get($this, 'place.street1')),
'type' => $this->type ?? null,
'customer_type' => $this->when(isset($this->customer_type), Utils::toEmberResourceType($this->customer_type)),
diff --git a/server/src/Http/Resources/v1/Driver.php b/server/src/Http/Resources/v1/Driver.php
index 0da9aeea4..0a83248a1 100644
--- a/server/src/Http/Resources/v1/Driver.php
+++ b/server/src/Http/Resources/v1/Driver.php
@@ -2,6 +2,7 @@
namespace Fleetbase\FleetOps\Http\Resources\v1;
+use Fleetbase\FleetOps\Support\ProfileAccountManager;
use Fleetbase\FleetOps\Support\Utils;
use Fleetbase\Http\Resources\FleetbaseResource;
use Fleetbase\Http\Resources\FleetbaseResourceCollection;
@@ -32,6 +33,8 @@ public function toArray($request)
'public_id' => $this->when(Http::isInternalRequest(), $this->public_id),
'user' => $this->when(Http::isPublicRequest(), fn () => $this->user ? $this->user->public_id : null, new User($this->user)),
'internal_id' => $this->internal_id,
+ 'is_staff_linked' => $this->when(Http::isInternalRequest(), fn () => ProfileAccountManager::isStaffAccount($this->user)),
+ 'login_status' => $this->when(Http::isInternalRequest(), fn () => data_get($this, 'user.status')),
'company' => $this->when(Http::isPublicRequest(), fn () => $this->company ? $this->company->public_id : null),
'company_name' => $this->when(Http::isPublicRequest(), fn () => $this->company ? $this->company->name : null),
'name' => $this->name,
diff --git a/server/src/Mail/DriverCredentialsMail.php b/server/src/Mail/DriverCredentialsMail.php
new file mode 100644
index 000000000..a917e8f60
--- /dev/null
+++ b/server/src/Mail/DriverCredentialsMail.php
@@ -0,0 +1,55 @@
+driver->loadMissing('company');
+
+ return new Envelope(
+ subject: 'Your ' . ($this->driver->company?->name ?? config('app.name')) . ' driver sign-in details',
+ );
+ }
+
+ /**
+ * Get the message content definition.
+ */
+ public function content(): Content
+ {
+ $this->driver->loadMissing('company');
+
+ return new Content(
+ markdown: 'fleetops::mail.driver-credentials',
+ with: [
+ 'driver' => $this->driver,
+ 'user' => $this->user,
+ 'companyName' => $this->driver->company?->name ?? config('app.name'),
+ 'identity' => $this->user->email ?? $this->user->phone,
+ 'plaintextPassword' => $this->plaintextPassword,
+ ]
+ );
+ }
+}
diff --git a/server/src/Models/Contact.php b/server/src/Models/Contact.php
index 4a041e99d..b0b25a143 100644
--- a/server/src/Models/Contact.php
+++ b/server/src/Models/Contact.php
@@ -5,6 +5,7 @@
use Fleetbase\Casts\Json;
use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException;
use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException;
+use Fleetbase\FleetOps\Support\ProfileAccountManager;
use Fleetbase\FleetOps\Support\Utils;
use Fleetbase\Models\CompanyUser;
use Fleetbase\Models\Model;
@@ -157,9 +158,13 @@ public function anyUser(): BelongsTo|Builder
return $this->belongsTo(User::class, 'user_uuid');
}
+ /**
+ * The contact's login account: a managed `contact`/`customer` account, or a
+ * team member's account linked by email or phone.
+ */
public function user(): BelongsTo|Builder
{
- return $this->belongsTo(User::class, 'user_uuid')->where('type', $this->type);
+ return $this->belongsTo(User::class, 'user_uuid');
}
public function photo(): BelongsTo
@@ -316,74 +321,16 @@ public static function createFromImport(array $row, bool $saveInstance = false):
*/
public static function createUserFromContact(Contact $contact, bool $sendInvite = false, bool $update = false): User
{
- // Check if user already exist with email or phone number
- $existingUser = null;
- if ($contact->email || $contact->phone) {
- $existingUser = User::where(function ($query) use ($contact) {
- $query->where('company_uuid', $contact->company_uuid)
- ->orWhereHas('companyUsers', function ($query) use ($contact) {
- $query->where('company_uuid', $contact->company_uuid);
- });
- })
- ->where(function ($query) use ($contact) {
- if ($contact->email) {
- $query->where('email', $contact->email);
- }
-
- if ($contact->phone) {
- $method = $contact->email ? 'orWhere' : 'where';
- $query->{$method}('phone', Utils::formatPhoneNumber($contact->phone));
- }
- })
- ->whereNull('deleted_at')
- ->first();
- }
-
- if ($existingUser) {
- if ($contact->isCustomer()) {
- $contact->assertCustomerUserCanBeAssigned($existingUser);
- }
-
- // Check if existing user belongs to another contact
- $existingUserContact = Contact::where(['user_uuid' => $existingUser->uuid, 'company_uuid' => $contact->company_uuid])->whereHas('user')->first();
- if ($existingUserContact) {
- throw new UserAlreadyExistsException('User already exists, try to assigning the user to this contact.', $existingUser);
- }
- // Assign the user to this contact instead
- $contact->setAttribute('user_uuid', $existingUser->uuid);
- if ($update) {
- $contact->update(['user_uuid' => $existingUser->uuid]);
- }
- $contact->setRelation('user', $existingUser);
-
- return $existingUser;
- }
-
- // Load company
$contact->loadMissing('company');
- // Create the user record
- $user = User::create([
- 'company_uuid' => $contact->company_uuid,
- 'name' => $contact->name,
- 'email' => $contact->email,
- 'phone' => $contact->phone ? Utils::formatPhoneNumber($contact->phone) : null,
- 'username' => Str::slug($contact->name . '_' . Str::random(4), '_'),
- 'password' => Str::random(),
- 'timezone' => $contact->company->timezone ?? date_default_timezone_get(),
- 'status' => 'pending',
- ]);
-
- // Set user type
- $user->setType($contact->type);
-
- // Assign to company without triggering core organization invitations.
- static::assignUserToContactCompany($contact, $user);
-
- // Assign customer role
- if ($contact->isCustomer()) {
- $user->assignSingleRole('Fleet-Ops Customer');
- }
+ $user = ProfileAccountManager::resolveForProfile(
+ $contact->company_uuid,
+ $contact->isCustomer() ? 'customer' : 'contact',
+ $contact->name,
+ $contact->email,
+ $contact->phone,
+ ['timezone' => $contact->company->timezone ?? null]
+ );
// Set user to contact
$contact->setAttribute('user_uuid', $user->uuid);
@@ -412,6 +359,13 @@ public function normalizeCustomerUser(?User $user = null, bool $quiet = false):
$this->assertCustomerUserCanBeAssigned($user);
+ // A team member's account keeps its own organization role
+ if (ProfileAccountManager::isStaffAccount($user)) {
+ $this->setRelation('user', $user);
+
+ return $user;
+ }
+
$this->loadMissing('company');
if ($this->company) {
$companyUser = CompanyUser::firstOrCreate(
@@ -511,7 +465,8 @@ public function assignUser(User $user, bool $sendInvite = false): self
// Get the company user instance
$companyUser = $user->getCompanyUser($this->company);
- if ($companyUser) {
+ // A team member's account keeps its own organization role
+ if ($companyUser && ProfileAccountManager::isManagedAccount($user)) {
$companyUser->assignSingleRole($this->isCustomer() ? 'Fleet-Ops Customer' : 'Fleet-Ops Contact');
}
@@ -546,6 +501,11 @@ public function assertCustomerUserCanBeAssigned(User $user): void
return;
}
+ // A team member of the organization can hold a customer profile
+ if (ProfileAccountManager::isStaffAccount($user) && ProfileAccountManager::isCompanyMember($user, $this->company_uuid)) {
+ return;
+ }
+
throw new CustomerUserConflictException(static::customerUserConflictMessage($user), $user);
}
@@ -553,6 +513,10 @@ public static function customerUserConflictMessage(?User $user = null): string
{
$field = $user?->email ? 'email' : ($user?->phone ? 'phone number' : 'user account');
+ if (ProfileAccountManager::isManagedAccount($user)) {
+ return 'This ' . $field . ' is already used by a ' . $user->type . ' and cannot be used for a customer account.';
+ }
+
return 'This ' . $field . ' belongs to an existing staff user and cannot be used for a customer account.';
}
@@ -591,32 +555,31 @@ private function getAssignedUserWithoutTypeScope(): ?User
return User::where('uuid', $this->user_uuid)->first();
}
+ /**
+ * Push the contact's changed proxy fields (name, email, phone, timezone) to
+ * its login account. A team member's account only takes the name.
+ *
+ * @throws \Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException
+ */
public function syncWithUser(): bool
{
- $updates = [];
-
- if ($this->isDirty('name')) {
- $updates['name'] = $this->name;
- }
-
- if ($this->isDirty('email')) {
- $updates['email'] = $this->email;
+ // A new contact's account was just resolved from these values
+ if (!$this->exists) {
+ return false;
}
- if ($this->isDirty('phone')) {
- $updates['phone'] = $this->phone;
- }
-
- if ($this->isDirty('timezone')) {
- $updates['timezone'] = $this->timezone;
+ $updates = [];
+ foreach (['name', 'email', 'phone', 'timezone'] as $field) {
+ if ($this->isDirty($field)) {
+ $updates[$field] = $this->{$field};
+ }
}
- $user = $this->getUser();
- if ($user) {
- return $user->update($updates);
+ if (empty($updates)) {
+ return false;
}
- return false;
+ return ProfileAccountManager::syncProxyFields($this->getUser(), $updates);
}
/**
@@ -638,12 +601,14 @@ public function createUser(bool $sendInvite = false): User
*/
public function deleteUser(): ?bool
{
- $this->loadMissing('user');
- if ($this->user && $this->user->type === $this->type) {
- return $this->user->delete();
+ $user = $this->getUser();
+ if (!ProfileAccountManager::isManagedAccount($user)) {
+ return false;
}
- return false;
+ ProfileAccountManager::releaseForProfile($user, $this->company_uuid);
+
+ return $user->trashed();
}
public function getUser(): ?User
diff --git a/server/src/Models/Sensor.php b/server/src/Models/Sensor.php
index 8b20638ba..c1589065d 100644
--- a/server/src/Models/Sensor.php
+++ b/server/src/Models/Sensor.php
@@ -75,7 +75,6 @@ class Sensor extends Model
*/
protected $filterParams = [
'type',
- 'sensor_type',
'status',
'device_uuid',
'serial_number',
@@ -214,7 +213,7 @@ protected static function boot()
public function getSlugOptions(): SlugOptions
{
return SlugOptions::create()
- ->generateSlugsFrom(['name', 'sensor_type'])
+ ->generateSlugsFrom(['name', 'type'])
->saveSlugsTo('slug')
->doNotGenerateSlugsOnUpdate();
}
@@ -232,7 +231,6 @@ public function getActivitylogOptions(): LogOptions
'sensorable_uuid',
'sensorable_type',
'type',
- 'sensor_type',
'internal_id',
'name',
'unit',
@@ -396,7 +394,7 @@ public function getLastReadingFormattedAttribute(): ?string
*/
public function scopeByType($query, string $type)
{
- return $query->where('sensor_type', $type);
+ return $query->where('type', $type);
}
/**
@@ -493,7 +491,7 @@ protected function checkThresholdAndCreateAlert($value): void
'message' => $this->generateThresholdAlertMessage($value, $thresholdStatus),
'context' => [
'sensor_name' => $this->name,
- 'sensor_type' => $this->sensor_type,
+ 'sensor_type' => $this->type,
'value' => $value,
'unit' => $this->unit,
'threshold_status' => $thresholdStatus,
diff --git a/server/src/Observers/ContactObserver.php b/server/src/Observers/ContactObserver.php
index ebc2dafd1..18e0f78a8 100644
--- a/server/src/Observers/ContactObserver.php
+++ b/server/src/Observers/ContactObserver.php
@@ -3,7 +3,6 @@
namespace Fleetbase\FleetOps\Observers;
use Fleetbase\FleetOps\Models\Contact;
-use Fleetbase\Models\User;
class ContactObserver
{
@@ -42,17 +41,8 @@ public function saving(Contact $contact)
$contact->normalizeCustomerUser();
}
- // Validate email is available to user
- if (!empty($contact->email) && $contact->wasChanged('email') && $this->isEmailUnavailable($contact)) {
- throw new \Exception('Email attempting to update for ' . $contact->type . ' is not available.');
- }
-
- // Validate phone is available to user
- if (!empty($contact->phone) && $contact->wasChanged('phone') && $this->isPhoneUnavailable($contact)) {
- throw new \Exception('Phone attempting to update for ' . $contact->type . ' is not available.');
- }
-
- // Sync updates from contact to user
+ // Sync updates from contact to its login account. This throws when the
+ // new email or phone is already used by another account.
$contact->syncWithUser();
}
@@ -66,14 +56,4 @@ public function deleted(Contact $contact)
// Delete the assosciated user account
$contact->deleteUser();
}
-
- private function isEmailUnavailable(Contact $contact)
- {
- return User::where('email', $contact->email)->whereNot('uuid', $contact->user_uuid)->exists() || Contact::where(['email' => $contact->email, 'company_uuid' => $contact->company_uuid])->whereNot('uuid', $contact->uuid)->exists();
- }
-
- private function isPhoneUnavailable(Contact $contact)
- {
- return User::where('phone', $contact->phone)->whereNot('uuid', $contact->user_uuid)->exists() || Contact::where(['phone' => $contact->phone, 'company_uuid' => $contact->company_uuid])->whereNot('uuid', $contact->uuid)->exists();
- }
}
diff --git a/server/src/Observers/DriverObserver.php b/server/src/Observers/DriverObserver.php
index 2dbc1ff09..ba806dc83 100644
--- a/server/src/Observers/DriverObserver.php
+++ b/server/src/Observers/DriverObserver.php
@@ -5,6 +5,7 @@
use Fleetbase\FleetOps\Models\Driver;
use Fleetbase\FleetOps\Models\Order;
use Fleetbase\FleetOps\Support\LiveCacheService;
+use Fleetbase\FleetOps\Support\ProfileAccountManager;
use Fleetbase\LaravelMysqlSpatial\Types\Point;
use Fleetbase\Models\User;
@@ -64,11 +65,9 @@ public function deleted(Driver $driver)
// Unassign them from any order they are assigned to
$this->unassignOrders($driver);
- // If the driver had a user account with the role driver and type user delete it
- $user = $this->findDriverUser($driver);
- if ($user && $user->hasRole('Driver')) {
- $user->delete();
- }
+ // Delete the driver's managed login account, which frees its email and
+ // phone. A team member's account linked to the driver is left alone.
+ ProfileAccountManager::releaseForProfile($this->findDriverUser($driver), $driver->company_uuid);
$this->invalidateLiveCache();
}
@@ -85,6 +84,6 @@ protected function unassignOrders(Driver $driver): int
protected function findDriverUser(Driver $driver): ?User
{
- return User::where(['uuid' => $driver->user_uuid, 'type' => 'user'])->first();
+ return $driver->user_uuid ? User::where('uuid', $driver->user_uuid)->first() : null;
}
}
diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php
index 7203a85c5..93b8394a9 100644
--- a/server/src/Providers/FleetOpsServiceProvider.php
+++ b/server/src/Providers/FleetOpsServiceProvider.php
@@ -274,6 +274,12 @@ protected function registerAiCapabilities(): void
});
}
+ if (Utils::classExists(\Fleetbase\Ai\Support\Commands\AiCommandRegistry::class)) {
+ $this->callAfterResolving(\Fleetbase\Ai\Support\Commands\AiCommandRegistry::class, function ($commands) {
+ $commands->registerMany(\Fleetbase\FleetOps\Support\Ai\FleetOpsAiConsoleCommands::all());
+ });
+ }
+
$this->callAfterResolving(\Fleetbase\Ai\Support\AiCapabilityRegistry::class, function (\Fleetbase\Ai\Support\AiCapabilityRegistry $registry) {
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability());
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\OperationalQueryCapability());
@@ -284,6 +290,15 @@ protected function registerAiCapabilities(): void
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\CreateOrderPreviewCapability());
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\OptimizeOrderRouteCapability());
$registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\ImportOrdersPreviewCapability());
+
+ // Tool-calling versions share their capability keys, so they replace the keyword-driven ones when
+ // the installed AI extension supports tools and leave older AI extensions unaffected.
+ if (interface_exists(\Fleetbase\Ai\Contracts\AIToolCapabilityInterface::class)) {
+ $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\SearchResourcesTool());
+ $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\CreateOrderTool());
+ $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool());
+ $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool());
+ }
});
}
}
diff --git a/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php b/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php
index a070a7f79..f9c86649d 100644
--- a/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php
+++ b/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php
@@ -11,6 +11,17 @@
abstract class AbstractFleetOpsAICapability extends AbstractAICapability implements AIContextCapabilityInterface
{
+ /**
+ * Words that never identify a record and must not be used as search terms.
+ */
+ protected const SEARCH_STOPWORDS = [
+ 'the', 'and', 'for', 'with', 'from', 'this', 'that', 'what', 'where', 'when', 'which', 'who', 'how', 'many', 'much',
+ 'can', 'could', 'would', 'should', 'please', 'help', 'want', 'need', 'show', 'find', 'open', 'look', 'tell', 'about',
+ 'status', 'order', 'orders', 'vehicle', 'vehicles', 'driver', 'drivers', 'device', 'devices', 'sensor', 'sensors',
+ 'telematic', 'telematics', 'maintenance', 'maintenances', 'work', 'fleet', 'fleets', 'ops', 'create', 'new', 'all',
+ 'today', 'yesterday', 'tomorrow', 'week', 'month', 'fleetbase', 'import', 'template', 'download', 'dummy', 'test',
+ ];
+
public function module(): string
{
return 'fleet-ops';
@@ -50,6 +61,18 @@ protected function can(string $permission): bool
return Auth::can($permission);
}
+ /**
+ * Applies the current user's IAM directives (record-level scoping) for a permission to a query.
+ */
+ protected function scopeToPermission($query, string $permission)
+ {
+ if ($query instanceof Builder && Builder::hasGlobalMacro('applyDirectivesForPermissions')) {
+ return $query->applyDirectivesForPermissions($permission);
+ }
+
+ return $query;
+ }
+
protected function canAll(array $permissions): bool
{
foreach ($permissions as $permission) {
@@ -61,18 +84,52 @@ protected function canAll(array $permissions): bool
return true;
}
+ /**
+ * Extracts record-reference-like search terms from a prompt.
+ *
+ * Only terms that plausibly identify a record are kept: quoted phrases, tokens containing a digit
+ * (public ids, plates, tracking numbers), emails and other delimited identifiers, and capitalized
+ * names that are not the first word. Ordinary words are never used as LIKE terms, and an empty
+ * array is returned when the prompt does not reference a specific record.
+ */
protected function searchTerms(string $prompt): array
{
- preg_match_all('/[A-Z]{2,}[-_][A-Z0-9-_]+|[A-Za-z0-9][A-Za-z0-9-_]{2,}/', (string) $prompt, $matches);
+ $prompt = trim($prompt);
+ $terms = [];
- $terms = collect($matches[0] ?? [])
- ->reject(fn ($term) => in_array(Str::lower($term), ['find', 'show', 'open', 'order', 'orders', 'vehicle', 'vehicles', 'driver', 'drivers', 'work', 'status', 'about', 'fleet', 'ops'], true))
- ->unique()
+ preg_match_all('/"([^"]{2,64})"|\'([^\']{2,64})\'/u', $prompt, $quoted);
+ foreach (array_merge($quoted[1] ?? [], $quoted[2] ?? []) as $phrase) {
+ if (trim($phrase) !== '') {
+ $terms[] = trim($phrase);
+ }
+ }
+
+ preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}@._\-]*[\p{L}\p{N}]/u', $prompt, $matches, PREG_OFFSET_CAPTURE);
+ foreach ($matches[0] ?? [] as [$token, $offset]) {
+ if ($this->isReferenceTerm($token, $offset === 0)) {
+ $terms[] = $token;
+ }
+ }
+
+ return collect($terms)
+ ->unique(fn ($term) => Str::lower($term))
->take(6)
->values()
->all();
+ }
+
+ protected function isReferenceTerm(string $token, bool $isFirstWord = false): bool
+ {
+ if (mb_strlen($token) < 3 || in_array(Str::lower($token), static::SEARCH_STOPWORDS, true)) {
+ return false;
+ }
+
+ // Public ids, plates, tracking numbers, emails, and upper-case codes such as ORDER-ABC.
+ if (preg_match('/\p{N}|[@_]/u', $token) || preg_match('/^[\p{Lu}\p{N}]+(?:-[\p{Lu}\p{N}]+)+$/u', $token)) {
+ return true;
+ }
- return empty($terms) ? [trim((string) $prompt)] : $terms;
+ return !$isFirstWord && preg_match('/^\p{Lu}\p{Ll}+$/u', $token) === 1;
}
protected function whereLikeAny(Builder $builder, array $columns, array $terms): void
diff --git a/server/src/Support/Ai/Capabilities/AssetStatusCapability.php b/server/src/Support/Ai/Capabilities/AssetStatusCapability.php
index 8b105af02..c7e9956f7 100644
--- a/server/src/Support/Ai/Capabilities/AssetStatusCapability.php
+++ b/server/src/Support/Ai/Capabilities/AssetStatusCapability.php
@@ -28,7 +28,7 @@ public function description(): string
public function permissions(): array
{
- return ['fleet-ops see vehicle', 'fleet-ops see device', 'fleet-ops see sensor', 'fleet-ops see telematic'];
+ return ['fleet-ops see driver', 'fleet-ops see vehicle', 'fleet-ops see device', 'fleet-ops see sensor', 'fleet-ops see telematic'];
}
public function resolve(AiTask $task): array
@@ -92,27 +92,46 @@ protected function driverStatus(): array
protected function totalForModel(string $modelClass): int
{
- return $modelClass::where('company_uuid', session('company'))->count();
+ return $this->scopedQuery($modelClass)->count();
}
protected function onlineCountForModel(string $modelClass): int
{
- return $modelClass::where('company_uuid', session('company'))->where('online', true)->count();
+ return $this->scopedQuery($modelClass)->where('online', true)->count();
}
protected function offlineCountForModel(string $modelClass): int
{
- return $modelClass::where('company_uuid', session('company'))->where(function ($query) {
+ return $this->scopedQuery($modelClass)->where(function ($query) {
$query->where('online', false)->orWhereNull('online');
})->count();
}
protected function countsByStatusForModel(string $modelClass): array
{
- return $modelClass::where('company_uuid', session('company'))
+ return $this->scopedQuery($modelClass)
->selectRaw('status, count(*) as aggregate')
->groupBy('status')
->pluck('aggregate', 'status')
->all();
}
+
+ /**
+ * Company-scoped query with the user's IAM directives for listing the resource applied.
+ */
+ protected function scopedQuery(string $modelClass)
+ {
+ $permission = match ($modelClass) {
+ Driver::class => 'fleet-ops list driver',
+ Vehicle::class => 'fleet-ops list vehicle',
+ Device::class => 'fleet-ops list device',
+ Sensor::class => 'fleet-ops list sensor',
+ Telematic::class => 'fleet-ops list telematic',
+ default => null,
+ };
+
+ $query = $modelClass::where('company_uuid', session('company'));
+
+ return $permission ? $this->scopeToPermission($query, $permission) : $query;
+ }
}
diff --git a/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php b/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php
index 18a740d76..c77fcd89f 100644
--- a/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php
+++ b/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php
@@ -222,8 +222,12 @@ protected function matchesPrompt(string $prompt): bool
protected function buildDraft(AiTask $task, array $input = []): array
{
- $existing = (array) data_get($task->metadata, 'action_previews.0.draft', []);
- $draft = array_replace_recursive($this->draftFromPrompt((string) $task->prompt), $existing, (array) data_get($input, 'draft', $input));
+ // A tool call supplies structured fields, so the prompt is not re-parsed. A refresh edits the
+ // preview it was opened from, which the AI service passes as existing_draft.
+ $fromTool = data_get($input, 'source') === 'tool';
+ $existing = (array) (data_get($input, 'existing_draft') ?? ($fromTool ? [] : data_get($task->metadata, 'action_previews.0.draft', [])));
+ $changes = (array) data_get($input, 'draft', Arr::except($input, ['source', 'existing_draft']));
+ $draft = array_replace_recursive($this->draftFromPrompt($fromTool ? '' : (string) $task->prompt), $existing, $changes);
$draft['dispatched'] = filter_var(data_get($draft, 'dispatched', false), FILTER_VALIDATE_BOOLEAN);
$draft['payload'] = (array) data_get($draft, 'payload', []);
diff --git a/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php b/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php
index d266caf5b..231658113 100644
--- a/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php
+++ b/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php
@@ -34,17 +34,20 @@ public function resolve(AiTask $task): array
return ['authorized' => false, 'message' => 'Current user cannot access Fleet-Ops orders.'];
}
- $prompt = $this->prompt($task);
- $window = $this->dateWindow($prompt);
- $amount = $this->amountThreshold($prompt);
- $query = $this->orderQuery(session('company'));
+ $prompt = $this->prompt($task);
+ $window = $this->dateWindow($prompt);
+ $amount = $this->amountThreshold($prompt);
+ $currency = $this->currency();
+ $minor = $amount !== null ? $this->toMinorUnits($amount, $currency) : null;
+ $query = $this->orderQuery(session('company'));
if ($window) {
$query->whereBetween('created_at', [$window['start'], $window['end']]);
}
- if ($amount !== null) {
- $query->whereHas('transaction', fn ($transaction) => $transaction->where('amount', '>', $amount));
+ if ($minor !== null) {
+ // Transaction amounts are stored in the currency's minor unit (e.g. cents).
+ $query->whereHas('transaction', fn ($transaction) => $transaction->where('amount', '>', $minor));
}
$total = (clone $query)->count();
@@ -59,6 +62,7 @@ public function resolve(AiTask $task): array
'end' => $window['end']->toIso8601String(),
] : null,
'amount_threshold' => $amount,
+ 'amount_currency' => $amount !== null ? $currency : null,
'count' => $total,
'counts_by_status' => (clone $query)
->selectRaw('status, count(*) as aggregate')
@@ -83,6 +87,35 @@ protected function amountThreshold(string $prompt): ?float
return null;
}
+ /**
+ * Converts a major-unit amount (e.g. 500.00) to the minor units transactions are stored in,
+ * honoring the currency's ISO-4217 exponent (JPY has none, BHD has three).
+ */
+ protected function toMinorUnits(float $amount, string $currency): int
+ {
+ $exponent = 2;
+
+ try {
+ if (class_exists(\Money\Currencies\ISOCurrencies::class)) {
+ $exponent = (new \Money\Currencies\ISOCurrencies())->subunitFor(new \Money\Currency(strtoupper($currency)));
+ }
+ } catch (\Throwable) {
+ $exponent = 2;
+ }
+
+ return (int) round($amount * (10 ** $exponent));
+ }
+
+ /**
+ * @codeCoverageIgnore
+ */
+ protected function currency(): string
+ {
+ $company = function_exists('session') ? \Fleetbase\Models\Company::where('uuid', session('company'))->first(['uuid', 'currency']) : null;
+
+ return strtoupper((string) ($company?->currency ?: 'USD'));
+ }
+
protected function dateWindow(string $prompt): ?array
{
return $this->relativeDateResolver()->resolveWindow($prompt);
@@ -90,7 +123,7 @@ protected function dateWindow(string $prompt): ?array
protected function orderQuery(?string $companyUuid): mixed
{
- return Order::where('company_uuid', $companyUuid);
+ return $this->scopeToPermission(Order::where('company_uuid', $companyUuid), 'fleet-ops list order');
}
protected function relativeDateResolver(): AiRelativeDateResolver
diff --git a/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php b/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php
index b63f00a01..1aed720db 100644
--- a/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php
+++ b/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php
@@ -48,19 +48,69 @@ public function resolve(AiTask $task): array
$prompt = (string) $task->prompt;
$terms = $this->searchTerms($prompt);
- return [
+ if (empty($terms)) {
+ return [
+ 'query_terms' => [],
+ 'results' => [],
+ 'message' => 'The prompt did not reference a specific Fleet-Ops record, so no records were searched.',
+ ];
+ }
+
+ return $this->searchAll($terms);
+ }
+
+ /**
+ * Search the given resource types (all when null) for the terms. A failing search is reported
+ * without discarding the others.
+ */
+ protected function searchAll(array $terms, ?array $types = null): array
+ {
+ $results = [];
+ $failed = [];
+ $searches = [
+ 'orders' => fn () => $this->orders($terms),
+ 'vehicles' => fn () => $this->vehicles($terms),
+ 'drivers' => fn () => $this->drivers($terms),
+ 'work_orders' => fn () => $this->workOrders($terms),
+ 'maintenances' => fn () => $this->maintenances($terms),
+ 'devices' => fn () => $this->devices($terms),
+ 'sensors' => fn () => $this->sensors($terms),
+ 'telematics' => fn () => $this->telematics($terms),
+ ];
+
+ if ($types !== null) {
+ $searches = array_intersect_key($searches, array_flip($types));
+ }
+
+ foreach ($searches as $resource => $search) {
+ try {
+ $results[$resource] = $search();
+ } catch (\Throwable $e) {
+ $failed[] = $resource;
+ $this->reportSearchFailure($resource, $e);
+ }
+ }
+
+ $payload = [
'query_terms' => $terms,
- 'results' => array_filter([
- 'orders' => $this->orders($terms),
- 'vehicles' => $this->vehicles($terms),
- 'drivers' => $this->drivers($terms),
- 'work_orders' => $this->workOrders($terms),
- 'maintenances' => $this->maintenances($terms),
- 'devices' => $this->devices($terms),
- 'sensors' => $this->sensors($terms),
- 'telematics' => $this->telematics($terms),
- ]),
+ 'results' => array_filter($results),
];
+
+ if (!empty($failed)) {
+ $payload['unavailable_search'] = $failed;
+ }
+
+ return $payload;
+ }
+
+ /**
+ * @codeCoverageIgnore
+ */
+ protected function reportSearchFailure(string $resource, \Throwable $e): void
+ {
+ if (function_exists('report')) {
+ report($e);
+ }
}
protected function matchesPrompt(string $prompt): bool
@@ -76,7 +126,7 @@ protected function orders(array $terms): array
return $this->orderSearchQuery()
->where(function ($query) use ($terms) {
- $this->whereLikeAny($query, ['public_id', 'internal_id', 'uuid', 'status', 'type'], $terms);
+ $this->whereLikeAny($query, ['public_id', 'internal_id'], $terms);
$query->orWhereHas('trackingNumber', fn ($tracking) => $this->whereLikeAny($tracking, ['tracking_number', 'barcode'], $terms));
})
->limit(5)
@@ -127,7 +177,7 @@ protected function drivers(array $terms): array
return $this->driverSearchQuery()
->where(function ($query) use ($terms) {
- $this->whereLikeAny($query, ['public_id', 'uuid', 'drivers_license_number', 'status'], $terms);
+ $this->whereLikeAny($query, ['public_id', 'internal_id', 'drivers_license_number'], $terms);
$query->orWhereHas('user', fn ($user) => $this->whereLikeAny($user, ['name', 'email', 'phone'], $terms));
})
->limit(5)
@@ -146,27 +196,27 @@ protected function drivers(array $terms): array
protected function workOrders(array $terms): array
{
- return $this->generic(WorkOrder::class, 'fleet-ops see work-order', ['public_id', 'uuid', 'code', 'subject', 'status', 'priority'], 'console.fleet-ops.maintenance.work-orders.index.details', $terms);
+ return $this->generic(WorkOrder::class, 'fleet-ops see work-order', ['public_id', 'code', 'subject'], 'console.fleet-ops.maintenance.work-orders.index.details', $terms);
}
protected function maintenances(array $terms): array
{
- return $this->generic(Maintenance::class, 'fleet-ops see maintenance', ['public_id', 'uuid', 'status', 'type', 'summary', 'notes'], 'console.fleet-ops.maintenance.maintenances.index.details', $terms);
+ return $this->generic(Maintenance::class, 'fleet-ops see maintenance', ['public_id', 'summary', 'notes'], 'console.fleet-ops.maintenance.maintenances.index.details', $terms);
}
protected function devices(array $terms): array
{
- return $this->generic(Device::class, 'fleet-ops see device', ['public_id', 'uuid', 'name', 'device_id', 'imei', 'serial_number', 'status'], 'console.fleet-ops.connectivity.devices.index.details', $terms);
+ return $this->generic(Device::class, 'fleet-ops see device', ['public_id', 'name', 'device_id', 'imei', 'serial_number'], 'console.fleet-ops.connectivity.devices.index.details', $terms);
}
protected function sensors(array $terms): array
{
- return $this->generic(Sensor::class, 'fleet-ops see sensor', ['public_id', 'uuid', 'name', 'internal_id', 'serial_number', 'imei', 'type', 'sensor_type', 'status'], 'console.fleet-ops.connectivity.sensors.index.details', $terms);
+ return $this->generic(Sensor::class, 'fleet-ops see sensor', ['public_id', 'name', 'internal_id', 'serial_number', 'imei'], 'console.fleet-ops.connectivity.sensors.index.details', $terms);
}
protected function telematics(array $terms): array
{
- return $this->generic(Telematic::class, 'fleet-ops see telematic', ['public_id', 'uuid', 'name', 'provider', 'status'], 'console.fleet-ops.connectivity.telematics.details', $terms);
+ return $this->generic(Telematic::class, 'fleet-ops see telematic', ['public_id', 'name'], 'console.fleet-ops.connectivity.telematics.details', $terms);
}
protected function generic(string $modelClass, string $permission, array $columns, string $route, array $terms): array
@@ -193,23 +243,35 @@ protected function generic(string $modelClass, string $permission, array $column
protected function orderSearchQuery()
{
- return Order::with(['transaction', 'trackingNumber'])
- ->where('company_uuid', session('company'));
+ return $this->scopeToPermission(
+ Order::with(['transaction', 'trackingNumber'])->where('company_uuid', session('company')),
+ 'fleet-ops list order'
+ );
}
protected function vehicleSearchQuery()
{
- return Vehicle::where('company_uuid', session('company'));
+ return $this->scopeToPermission(Vehicle::where('company_uuid', session('company')), 'fleet-ops list vehicle');
}
protected function driverSearchQuery()
{
- return Driver::with('user')
- ->where('company_uuid', session('company'));
+ return $this->scopeToPermission(Driver::with('user')->where('company_uuid', session('company')), 'fleet-ops list driver');
}
protected function genericSearchQuery(string $modelClass)
{
- return $modelClass::where('company_uuid', session('company'));
+ $permission = match ($modelClass) {
+ WorkOrder::class => 'fleet-ops list work-order',
+ Maintenance::class => 'fleet-ops list maintenance',
+ Device::class => 'fleet-ops list device',
+ Sensor::class => 'fleet-ops list sensor',
+ Telematic::class => 'fleet-ops list telematic',
+ default => null,
+ };
+
+ $query = $modelClass::where('company_uuid', session('company'));
+
+ return $permission ? $this->scopeToPermission($query, $permission) : $query;
}
}
diff --git a/server/src/Support/Ai/FleetOpsAiConsoleCommands.php b/server/src/Support/Ai/FleetOpsAiConsoleCommands.php
new file mode 100644
index 000000000..68cdc910e
--- /dev/null
+++ b/server/src/Support/Ai/FleetOpsAiConsoleCommands.php
@@ -0,0 +1,122 @@
+ [singular, plural, list route, new route, detail route, permission resource, actions service, supports import, docs path, keywords]
+ 'orders' => ['order', 'Orders', 'operations.orders.index', 'operations.orders.index.new', 'operations.orders.index.details', 'order', 'order-actions', 'importOrders', 'operations/orders', ['dispatch', 'delivery', 'shipment', 'job']],
+ 'drivers' => ['driver', 'Drivers', 'management.drivers.index', 'management.drivers.index.new', 'management.drivers.index.details', 'driver', 'driver-actions', 'import', 'resources/drivers', ['courier', 'navigator']],
+ 'vehicles' => ['vehicle', 'Vehicles', 'management.vehicles.index', 'management.vehicles.index.new', 'management.vehicles.index.details', 'vehicle', 'vehicle-actions', 'import', 'resources/vehicles', ['truck', 'van', 'car', 'fleet']],
+ 'contacts' => ['contact', 'Contacts', 'management.contacts.index', 'management.contacts.index.new', 'management.contacts.index.details', 'contact', 'contact-actions', 'import', 'resources/contacts', ['person']],
+ 'customers' => ['customer', 'Customers', 'management.contacts.customers', 'management.contacts.customers.new', 'management.contacts.customers.details', 'contact', 'customer-actions', 'import', 'resources/contacts', ['client', 'contacts']],
+ 'places' => ['place', 'Places', 'management.places.index', 'management.places.index.new', 'management.places.index.details', 'place', 'place-actions', 'import', 'resources/places', ['address', 'location', 'depot', 'warehouse']],
+ 'fleets' => ['fleet', 'Fleets', 'management.fleets.index', 'management.fleets.index.new', 'management.fleets.index.details', 'fleet', 'fleet-actions', null, 'resources/fleets', ['team']],
+ 'issues' => ['issue', 'Issues', 'management.issues.index', 'management.issues.index.new', 'management.issues.index.details', 'issue', 'issue-actions', null, 'resources/issues', ['problem', 'incident']],
+ 'work_orders' => ['work order', 'Work Orders', 'maintenance.work-orders.index', 'maintenance.work-orders.index.new', 'maintenance.work-orders.index.details', 'work-order', 'work-order-actions', null, 'maintenance/work-orders', ['maintenance', 'repair']],
+ 'maintenances' => ['maintenance', 'Maintenances', 'maintenance.maintenances.index', 'maintenance.maintenances.index.new', 'maintenance.maintenances.index.details', 'maintenance', 'maintenance-actions', null, 'maintenance', ['service', 'repair']],
+ ];
+
+ $commands = [];
+
+ foreach ($resources as $key => [$singular, $plural, $listRoute, $newRoute, $detailRoute, $permission, $service, $import, $docs, $keywords]) {
+ $base = [
+ 'breadcrumb' => "Fleet-Ops › {$plural}",
+ 'keywords' => array_merge(['fleet-ops', $singular], $keywords),
+ 'docs_url' => static::DOCS . "/{$docs}",
+ 'module' => 'fleet-ops',
+ ];
+
+ $commands[] = $base + [
+ 'id' => "fleet-ops.{$key}.open",
+ 'label' => "Open {$plural}",
+ 'description' => "Go to the Fleet-Ops {$plural} list.",
+ 'steps' => [['type' => 'navigate', 'route' => "console.fleet-ops.{$listRoute}"]],
+ 'permissions' => ["fleet-ops list {$permission}"],
+ ];
+
+ $commands[] = $base + [
+ 'id' => "fleet-ops.{$key}.create",
+ 'label' => "Create {$singular}",
+ 'description' => "Open the new {$singular} form in Fleet-Ops.",
+ 'steps' => [['type' => 'navigate', 'route' => "console.fleet-ops.{$newRoute}"]],
+ 'permissions' => ["fleet-ops create {$permission}"],
+ 'keywords' => array_merge($base['keywords'], ['new', 'add', 'create']),
+ ];
+
+ $commands[] = $base + [
+ 'id' => "fleet-ops.{$key}.view",
+ 'label' => "Open {$singular}",
+ 'description' => "Open a specific {$singular} by its public id.",
+ 'steps' => [['type' => 'navigate', 'route' => "console.fleet-ops.{$detailRoute}", 'models' => ['public_id']]],
+ 'permissions' => ["fleet-ops see {$permission}"],
+ 'params' => ['public_id' => ['type' => 'string', 'description' => "The {$singular} public id."]],
+ ];
+
+ if ($import) {
+ $commands[] = $base + [
+ 'id' => "fleet-ops.{$key}.import",
+ 'label' => "Import {$plural}",
+ 'description' => "Go to {$plural} in Fleet-Ops and open the spreadsheet import, including the template download.",
+ 'steps' => [
+ ['type' => 'navigate', 'route' => "console.fleet-ops.{$listRoute}"],
+ ['type' => 'service', 'engine' => static::ENGINE, 'service' => $service, 'method' => $import],
+ ],
+ 'permissions' => ["fleet-ops import {$permission}"],
+ 'keywords' => array_merge($base['keywords'], ['import', 'spreadsheet', 'csv', 'excel', 'xlsx', 'template', 'bulk', 'upload']),
+ ];
+ }
+ }
+
+ return $commands;
+ }
+
+ public static function settings(): array
+ {
+ $settings = [
+ 'map' => ['Map', 'map-settings', ['map', 'provider', 'google maps', 'leaflet', 'openstreetmap', 'tiles', 'view maps']],
+ 'navigator-app' => ['Navigator App', 'navigator-settings', ['navigator', 'driver app', 'mobile app', 'onboard']],
+ 'routing' => ['Routing', 'routing-settings', ['routing', 'route', 'optimization', 'osrm', 'distance']],
+ 'notifications' => ['Notifications', 'notification-settings', ['notification', 'alert', 'email', 'sms']],
+ 'custom-fields' => ['Custom Fields', 'custom-field', ['custom field', 'field', 'attribute']],
+ 'scheduling' => ['Scheduling', null, ['schedule', 'shift', 'calendar']],
+ ];
+
+ $commands = [];
+
+ foreach ($settings as $route => [$label, $permission, $keywords]) {
+ $commands[] = [
+ 'id' => 'fleet-ops.settings.' . str_replace('-', '_', $route) . '.open',
+ 'label' => "Open {$label} settings",
+ 'breadcrumb' => "Fleet-Ops › Settings › {$label}",
+ 'description' => "Go to the Fleet-Ops {$label} settings.",
+ 'steps' => [['type' => 'navigate', 'route' => "console.fleet-ops.settings.{$route}"]],
+ 'permissions' => $permission ? ["fleet-ops view {$permission}"] : [],
+ 'keywords' => array_merge(['fleet-ops', 'settings', 'configure'], $keywords),
+ 'docs_url' => static::DOCS . "/settings/{$route}",
+ 'module' => 'fleet-ops',
+ ];
+ }
+
+ return $commands;
+ }
+}
diff --git a/server/src/Support/Ai/Tools/CreateOrderTool.php b/server/src/Support/Ai/Tools/CreateOrderTool.php
new file mode 100644
index 000000000..a7ca87412
--- /dev/null
+++ b/server/src/Support/Ai/Tools/CreateOrderTool.php
@@ -0,0 +1,86 @@
+ 'object',
+ 'properties' => [
+ 'pickup' => ['type' => 'string', 'description' => 'Pickup address or saved place name.'],
+ 'dropoff' => ['type' => 'string', 'description' => 'Dropoff address or saved place name.'],
+ 'scheduled_at' => ['type' => 'string', 'description' => 'Optional ISO 8601 date and time.'],
+ 'driver' => ['type' => 'string', 'description' => 'Optional driver name or public id.'],
+ 'vehicle' => ['type' => 'string', 'description' => 'Optional vehicle name, plate, or public id.'],
+ 'order_config' => ['type' => 'string', 'description' => 'Optional order configuration key, e.g. transport.'],
+ 'notes' => ['type' => 'string', 'description' => 'Optional notes for the order.'],
+ 'dispatch' => ['type' => 'boolean', 'description' => 'Dispatch immediately. Only true when the user asked for it.'],
+ ],
+ 'required' => ['pickup', 'dropoff'],
+ 'additionalProperties' => false,
+ ];
+ }
+
+ public function availableFor(AiToolContext $context): bool
+ {
+ return $context->audience->canAll($this->permissions());
+ }
+
+ public function invoke(AiTask $task, array $arguments, AiToolContext $context): array
+ {
+ $draft = array_filter([
+ 'payload' => array_filter([
+ 'pickup_query' => $this->argument($arguments, 'pickup'),
+ 'dropoff_query' => $this->argument($arguments, 'dropoff'),
+ ]),
+ 'scheduled_at' => $this->argument($arguments, 'scheduled_at'),
+ 'driver_query' => $this->argument($arguments, 'driver'),
+ 'vehicle_query' => $this->argument($arguments, 'vehicle'),
+ 'type' => $this->argument($arguments, 'order_config'),
+ 'notes' => $this->argument($arguments, 'notes'),
+ 'dispatched' => (bool) ($arguments['dispatch'] ?? false),
+ ], fn ($value) => $value !== null && $value !== []);
+
+ $preview = $context->addActionPreview($this, $this->preview($task, ['source' => 'tool', 'draft' => $draft]));
+
+ return [
+ 'preview_id' => $preview['preview_id'],
+ 'ready' => $preview['ready'] ?? false,
+ 'missing_fields' => $preview['missing_fields'] ?? [],
+ 'fields' => $preview['fields'] ?? [],
+ 'status' => 'draft_shown_to_user',
+ 'message' => 'A draft order card is shown to the user. The order does not exist until the user reviews the card and clicks ' . ($preview['apply_label'] ?? 'Create order') . '.',
+ ];
+ }
+
+ protected function argument(array $arguments, string $key): ?string
+ {
+ $value = trim((string) ($arguments[$key] ?? ''));
+
+ return $value === '' ? null : $value;
+ }
+}
diff --git a/server/src/Support/Ai/Tools/ImportOrdersTool.php b/server/src/Support/Ai/Tools/ImportOrdersTool.php
new file mode 100644
index 000000000..3bb89fdce
--- /dev/null
+++ b/server/src/Support/Ai/Tools/ImportOrdersTool.php
@@ -0,0 +1,53 @@
+ 'object',
+ 'properties' => new \stdClass(),
+ 'additionalProperties' => false,
+ ];
+ }
+
+ public function availableFor(AiToolContext $context): bool
+ {
+ return $context->audience->canAll($this->permissions());
+ }
+
+ public function invoke(AiTask $task, array $arguments, AiToolContext $context): array
+ {
+ $requirements = $this->resolve($task);
+
+ return [
+ 'accepted_sources' => $requirements['accepted_sources'] ?? [],
+ 'minimum_columns' => $requirements['minimum_columns'] ?? [],
+ 'can_import_here' => false,
+ 'message' => $requirements['message'] ?? 'Fleetbase AI cannot process uploaded spreadsheets.',
+ ];
+ }
+}
diff --git a/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php b/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php
new file mode 100644
index 000000000..b3722692e
--- /dev/null
+++ b/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php
@@ -0,0 +1,70 @@
+ 'object',
+ 'properties' => [
+ 'order' => ['type' => 'string', 'description' => 'The order public id, internal id, or UUID, exactly as the user gave it.'],
+ ],
+ 'required' => ['order'],
+ 'additionalProperties' => false,
+ ];
+ }
+
+ public function availableFor(AiToolContext $context): bool
+ {
+ return $context->audience->canAll($this->permissions());
+ }
+
+ public function invoke(AiTask $task, array $arguments, AiToolContext $context): array
+ {
+ $order = trim((string) ($arguments['order'] ?? ''));
+
+ if ($order === '') {
+ return ['error' => 'invalid_arguments', 'message' => 'An order id is required to optimize a route.'];
+ }
+
+ // The capability resolves the order from the prompt text, so hand it a copy carrying just the
+ // identifier the model supplied. The copy is never saved.
+ $probe = clone $task;
+ $probe->prompt = $order;
+
+ $preview = $context->addActionPreview($this, $this->preview($probe));
+
+ return [
+ 'preview_id' => $preview['preview_id'],
+ 'ready' => $preview['ready'] ?? false,
+ 'missing_fields' => $preview['missing_fields'] ?? [],
+ 'fields' => $preview['fields'] ?? [],
+ 'status' => 'proposal_shown_to_user',
+ 'message' => $preview['message'] ?? 'A route proposal card is shown to the user.',
+ ];
+ }
+}
diff --git a/server/src/Support/Ai/Tools/SearchResourcesTool.php b/server/src/Support/Ai/Tools/SearchResourcesTool.php
new file mode 100644
index 000000000..df0c8109f
--- /dev/null
+++ b/server/src/Support/Ai/Tools/SearchResourcesTool.php
@@ -0,0 +1,73 @@
+ 'object',
+ 'properties' => [
+ 'query' => ['type' => 'string', 'description' => 'The identifier or name to look for, e.g. "order_yhkejdnzgz", "SBA1234Z", or "Jane Doe".'],
+ 'types' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => static::TYPES], 'description' => 'Optional record types to search. Searches all types when omitted.'],
+ ],
+ 'required' => ['query'],
+ 'additionalProperties' => false,
+ ];
+ }
+
+ public function availableFor(AiToolContext $context): bool
+ {
+ foreach ($this->permissions() as $permission) {
+ if ($context->audience->can($permission)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function invoke(AiTask $task, array $arguments, AiToolContext $context): array
+ {
+ $query = trim((string) ($arguments['query'] ?? ''));
+
+ if (mb_strlen($query) < 2) {
+ return ['error' => 'Provide an identifier or name to search for.'];
+ }
+
+ // The model sends an intentional query, so the whole phrase is searched along with any ids in it.
+ $terms = collect([Str::limit($query, 100, '')])
+ ->merge($this->searchTerms($query))
+ ->unique(fn ($term) => Str::lower($term))
+ ->take(4)
+ ->values()
+ ->all();
+
+ $types = array_values(array_intersect((array) ($arguments['types'] ?? []), static::TYPES));
+
+ return $this->searchAll($terms, $types ?: null);
+ }
+}
diff --git a/server/src/Support/ProfileAccountManager.php b/server/src/Support/ProfileAccountManager.php
new file mode 100644
index 000000000..521ea4157
--- /dev/null
+++ b/server/src/Support/ProfileAccountManager.php
@@ -0,0 +1,405 @@
+ 'Driver',
+ 'contact' => 'Fleet-Ops Contact',
+ 'customer' => 'Fleet-Ops Customer',
+ ];
+
+ /**
+ * Whether the account is owned by a driver, contact or customer profile.
+ */
+ public static function isManagedAccount(?object $user): bool
+ {
+ return $user !== null && in_array(data_get($user, 'type'), static::MANAGED_TYPES, true);
+ }
+
+ /**
+ * Whether the account is a team member's account managed in IAM.
+ */
+ public static function isStaffAccount(?object $user): bool
+ {
+ return $user !== null && !static::isManagedAccount($user);
+ }
+
+ /**
+ * Find the active account holding the given email or phone.
+ */
+ public static function findAccountByIdentity(?string $email, ?string $phone, ?string $ignoreUserUuid = null): ?User
+ {
+ $email = static::normalizeEmail($email);
+ $phone = static::normalizePhone($phone);
+
+ if (!$email && !$phone) {
+ return null;
+ }
+
+ return User::where(function ($query) use ($email, $phone) {
+ if ($email) {
+ $query->orWhere('email', $email);
+ }
+
+ if ($phone) {
+ $query->orWhere('phone', $phone);
+ }
+ })
+ ->when($ignoreUserUuid, fn ($query) => $query->where('uuid', '!=', $ignoreUserUuid))
+ ->whereNull('deleted_at')
+ ->first();
+ }
+
+ /**
+ * Describe what would happen to an email/phone entered on a new or existing
+ * profile: `staff` is the team member the profile would link to, `conflict`
+ * is why the email/phone cannot be used.
+ *
+ * @return array{account: ?User, staff: ?User, conflict: ?string}
+ */
+ public static function lookup(string $companyUuid, string $type, ?string $email, ?string $phone, ?User $currentUser = null): array
+ {
+ $result = ['account' => null, 'staff' => null, 'conflict' => null];
+ $account = static::findAccountByIdentity($email, $phone, $currentUser?->uuid);
+
+ if (!$account) {
+ return $result;
+ }
+
+ $result['account'] = $account;
+ $field = static::matchedField($account, $email);
+
+ // An existing profile can't swap its account for another one
+ if ($currentUser) {
+ $result['conflict'] = static::takenMessage($field);
+
+ return $result;
+ }
+
+ try {
+ static::assertAccountCanHoldProfile($account, $companyUuid, $type, $field);
+ } catch (ProfileIdentityConflictException $e) {
+ $result['conflict'] = $e->getMessage();
+
+ return $result;
+ }
+
+ if (static::isStaffAccount($account)) {
+ $result['staff'] = $account;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Resolve the account for a new profile. A staff member of the organization
+ * or a managed account of the same type with the email/phone is linked;
+ * otherwise a managed account is created.
+ *
+ * @param array $attributes extra user attributes: password, status, timezone, avatar_uuid, country, ip_address, meta
+ *
+ * @throws ProfileIdentityConflictException
+ */
+ public static function resolveForProfile(string $companyUuid, string $type, ?string $name, ?string $email, ?string $phone, array $attributes = []): User
+ {
+ $account = static::findAccountByIdentity($email, $phone);
+
+ if ($account) {
+ static::assertAccountCanHoldProfile($account, $companyUuid, $type, static::matchedField($account, $email));
+
+ if (static::isManagedAccount($account)) {
+ static::attachToCompany($account, $companyUuid, $type);
+ }
+
+ return $account;
+ }
+
+ return static::createManagedAccount($companyUuid, $type, $name, $email, $phone, $attributes);
+ }
+
+ /**
+ * Create a managed account and add it to the organization without an invite.
+ */
+ public static function createManagedAccount(string $companyUuid, string $type, ?string $name, ?string $email, ?string $phone, array $attributes = []): User
+ {
+ $company = Company::where('uuid', $companyUuid)->first();
+ $password = $attributes['password'] ?? null;
+ $userData = array_filter([
+ 'company_uuid' => $companyUuid,
+ 'name' => $name,
+ 'email' => static::normalizeEmail($email),
+ 'phone' => static::normalizePhone($phone),
+ 'username' => User::generateUsername($name ?: Str::random(6)),
+ 'timezone' => $attributes['timezone'] ?? $company?->timezone ?? date_default_timezone_get(),
+ 'status' => $attributes['status'] ?? 'pending',
+ 'avatar_uuid' => $attributes['avatar_uuid'] ?? null,
+ 'country' => $attributes['country'] ?? null,
+ 'ip_address' => $attributes['ip_address'] ?? null,
+ 'meta' => $attributes['meta'] ?? null,
+ ], fn ($value) => $value !== null);
+
+ $user = new User($userData);
+ $user->password = $password ?: Str::random(16);
+ $user->save();
+ $user->setType($type);
+
+ static::attachToCompany($user, $companyUuid, $type);
+
+ return $user;
+ }
+
+ /**
+ * Push the profile's proxy fields to its account. A staff account only
+ * takes the name: its email and phone are its console identity.
+ *
+ * @throws ProfileIdentityConflictException
+ */
+ public static function syncProxyFields(?User $user, array $attributes): bool
+ {
+ if (!$user) {
+ return false;
+ }
+
+ $fields = static::isStaffAccount($user) ? ['name'] : ['name', 'email', 'phone', 'timezone', 'avatar_uuid', 'password'];
+ $input = array_intersect_key($attributes, array_flip($fields));
+
+ if (array_key_exists('email', $input)) {
+ $input['email'] = static::normalizeEmail($input['email']);
+ }
+
+ if (array_key_exists('phone', $input)) {
+ $input['phone'] = static::normalizePhone($input['phone']);
+ }
+
+ $input = array_filter($input, fn ($value, $key) => $value !== null || in_array($key, ['email', 'phone'], true), ARRAY_FILTER_USE_BOTH);
+ $input = array_filter($input, fn ($value, $key) => $key === 'password' || $user->{$key} !== $value, ARRAY_FILTER_USE_BOTH);
+
+ if (empty($input)) {
+ return false;
+ }
+
+ $changedEmail = array_key_exists('email', $input) ? $input['email'] : null;
+ $changedPhone = array_key_exists('phone', $input) ? $input['phone'] : null;
+ $taken = static::findAccountByIdentity($changedEmail, $changedPhone, $user->uuid);
+ if ($taken) {
+ throw new ProfileIdentityConflictException(static::takenMessage(static::matchedField($taken, $changedEmail)), static::matchedField($taken, $changedEmail));
+ }
+
+ if (isset($input['password'])) {
+ $user->password = $input['password'];
+ unset($input['password']);
+ }
+
+ $user->fill($input);
+
+ return $user->save();
+ }
+
+ /**
+ * Release a profile's account after the profile is deleted. A managed
+ * account with no other profile is deleted, which frees its email and
+ * phone; one still used by another organization's profile only leaves this
+ * organization. Staff accounts are never touched.
+ */
+ public static function releaseForProfile(?User $user, ?string $companyUuid = null): void
+ {
+ if (!static::isManagedAccount($user) || $user->trashed()) {
+ return;
+ }
+
+ $remainingProfiles = static::profileCompanies($user);
+ if ($remainingProfiles->isEmpty()) {
+ $user->delete();
+
+ return;
+ }
+
+ if ($companyUuid && !$remainingProfiles->contains($companyUuid)) {
+ CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->delete();
+ }
+ }
+
+ /**
+ * Generate a new password for the account and send it to the profile.
+ *
+ * @return string how the credentials were sent: `email` or `sms`
+ */
+ public static function sendCredentials(Driver|Contact $profile, User $user, ?string $password = null): string
+ {
+ $password ??= Str::random(12);
+ $user->changePassword($password);
+
+ if ($user->status !== 'active') {
+ $user->activate();
+ }
+
+ return static::deliverCredentials($profile, $user, $password);
+ }
+
+ /**
+ * Send already set credentials to the profile by email, or by SMS when the
+ * account has no email.
+ */
+ public static function deliverCredentials(Driver|Contact $profile, User $user, string $password): string
+ {
+ if ($user->email) {
+ Mail::to($user)->send($profile instanceof Driver ? new DriverCredentialsMail($password, $profile, $user) : new CustomerCredentialsMail($password, $profile));
+
+ return 'email';
+ }
+
+ if ($user->phone) {
+ $profile->loadMissing('company');
+ $companyName = $profile->company?->name ?? config('app.name');
+ $identity = $user->phone;
+ $result = app(SmsService::class)->send($user->phone, "Your {$companyName} sign-in details. Login: {$identity} Password: {$password}");
+
+ if (is_array($result) && array_key_exists('success', $result) && !$result['success']) {
+ throw new \Exception('The credentials could not be texted: ' . ($result['error'] ?? $result['message'] ?? 'the SMS provider refused it.'));
+ }
+
+ return 'sms';
+ }
+
+ throw new \Exception('This profile has no email or phone to send credentials to.');
+ }
+
+ /**
+ * Throw when the account can't hold the kind of profile being created.
+ *
+ * @throws ProfileIdentityConflictException
+ */
+ public static function assertAccountCanHoldProfile(User $account, string $companyUuid, string $type, string $field = 'email'): void
+ {
+ if (static::isStaffAccount($account)) {
+ if (!static::isCompanyMember($account, $companyUuid)) {
+ throw new ProfileIdentityConflictException(static::takenMessage($field), $field);
+ }
+ } elseif ($account->type !== $type) {
+ throw new ProfileIdentityConflictException('This ' . static::fieldLabel($field) . ' is already used by a ' . $account->type . '.', $field);
+ }
+
+ if (static::hasProfileInCompany($account, $companyUuid, $type)) {
+ throw new ProfileIdentityConflictException('A ' . $type . ' with this ' . static::fieldLabel($field) . ' already exists.', $field);
+ }
+ }
+
+ public static function hasProfileInCompany(User $user, string $companyUuid, string $type): bool
+ {
+ if ($type === 'driver') {
+ return Driver::withoutGlobalScopes()->where(['user_uuid' => $user->uuid, 'company_uuid' => $companyUuid])->whereNull('deleted_at')->exists();
+ }
+
+ return Contact::withoutGlobalScopes()->where(['user_uuid' => $user->uuid, 'company_uuid' => $companyUuid])->whereNull('deleted_at')->exists();
+ }
+
+ /**
+ * The organizations the account still has a driver or contact profile in.
+ */
+ public static function profileCompanies(User $user): \Illuminate\Support\Collection
+ {
+ $drivers = Driver::withoutGlobalScopes()->where('user_uuid', $user->uuid)->whereNull('deleted_at')->pluck('company_uuid');
+ $contacts = Contact::withoutGlobalScopes()->where('user_uuid', $user->uuid)->whereNull('deleted_at')->pluck('company_uuid');
+
+ return $drivers->merge($contacts)->filter()->unique()->values();
+ }
+
+ public static function isCompanyMember(User $user, ?string $companyUuid): bool
+ {
+ if (!$companyUuid) {
+ return false;
+ }
+
+ return $user->company_uuid === $companyUuid || CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->exists();
+ }
+
+ /**
+ * Add a managed account to the organization with its profile role, without
+ * sending an organization invite.
+ */
+ public static function attachToCompany(User $user, string $companyUuid, string $type): ?CompanyUser
+ {
+ $company = Company::where('uuid', $companyUuid)->first();
+ if (!$company) {
+ return null;
+ }
+
+ $role = static::ROLES[$type] ?? static::ROLES['contact'];
+ $companyUser = CompanyUser::where(['company_uuid' => $companyUuid, 'user_uuid' => $user->uuid])->first();
+ if (!$companyUser) {
+ $companyUser = $company->addUser($user, $role);
+ } elseif (static::isManagedAccount($user)) {
+ $companyUser->assignSingleRole($role);
+ }
+
+ if (!$user->company_uuid) {
+ $user->forceFill(['company_uuid' => $companyUuid])->saveQuietly();
+ }
+
+ $user->setRelation('companyUser', $companyUser);
+
+ return $companyUser;
+ }
+
+ public static function normalizeEmail(?string $email): ?string
+ {
+ $email = is_string($email) ? strtolower(trim($email)) : null;
+
+ return $email ?: null;
+ }
+
+ public static function normalizePhone(?string $phone): ?string
+ {
+ $phone = is_string($phone) ? trim($phone) : null;
+
+ return $phone ? Utils::formatPhoneNumber($phone) : null;
+ }
+
+ private static function matchedField(User $account, ?string $email): string
+ {
+ $email = static::normalizeEmail($email);
+
+ return $email && strtolower((string) $account->email) === $email ? 'email' : 'phone';
+ }
+
+ private static function fieldLabel(string $field): string
+ {
+ return $field === 'phone' ? 'phone number' : 'email';
+ }
+
+ private static function takenMessage(string $field): string
+ {
+ return 'This ' . static::fieldLabel($field) . ' is already in use by another account.';
+ }
+}
diff --git a/server/src/routes.php b/server/src/routes.php
index 05a7967a0..631bccc92 100644
--- a/server/src/routes.php
+++ b/server/src/routes.php
@@ -444,6 +444,10 @@ function ($router, $controller) {
$router->post('{id}/unassign-order', $controller('unassignOrder'));
$router->post('{id}/assign-vehicle', $controller('assignVehicle'));
$router->post('{id}/unassign-vehicle', $controller('unassignVehicle'));
+ $router->post('{id}/send-credentials', $controller('sendCredentials'));
+ $router->post('{id}/reset-credentials', $controller('resetCredentials'));
+ $router->post('{id}/deactivate-login', $controller('deactivateLogin'));
+ $router->post('{id}/reactivate-login', $controller('reactivateLogin'));
$router->match(['get', 'post'], 'export', $controller('export'));
$router->post('import', $controller('import'));
// Driver scheduling endpoints
@@ -764,6 +768,7 @@ function () use ($router) {
function ($router) {
$router->get('customers', 'FleetOpsLookupController@polymorphs');
$router->get('facilitators', 'FleetOpsLookupController@polymorphs');
+ $router->get('profile-identity', 'FleetOpsLookupController@profileIdentity');
}
);
$router->group(
diff --git a/server/tests/AiOperationalQueryCapabilityTest.php b/server/tests/AiOperationalQueryCapabilityTest.php
index e1a13be45..40448d3cd 100644
--- a/server/tests/AiOperationalQueryCapabilityTest.php
+++ b/server/tests/AiOperationalQueryCapabilityTest.php
@@ -406,6 +406,7 @@ function fleetopsAssetStatusCapabilityProbe(array $permissions = []): FleetOpsAs
->and($capability->label())->toBe('Fleet-Ops asset status')
->and($capability->description())->toContain('vehicle, device, sensor')
->and($capability->permissions())->toBe([
+ 'fleet-ops see driver',
'fleet-ops see vehicle',
'fleet-ops see device',
'fleet-ops see sensor',
diff --git a/server/tests/ApiCustomerControllerContractsTest.php b/server/tests/ApiCustomerControllerContractsTest.php
index ea1b7202b..49131be51 100644
--- a/server/tests/ApiCustomerControllerContractsTest.php
+++ b/server/tests/ApiCustomerControllerContractsTest.php
@@ -800,6 +800,27 @@ function fleetopsApiCustomerJson($response): array
]))))->toBe(['error' => 'Invalid verification code.']);
});
+test('api customer controller refuses deactivated customer logins', function () {
+ $controller = fleetopsApiCustomerController();
+ foreach (['activeUser', 'loginUser', 'verificationUser'] as $property) {
+ $controller->{$property}->setAttribute('status', 'inactive');
+ }
+
+ $responses = [
+ $controller->login(Request::create('/v1/customers/login', 'POST', ['identity' => 'jane@example.test', 'password' => 'password-secret'])),
+ $controller->loginWithPhone(Request::create('/v1/customers/login/phone', 'POST', ['phone' => '15551234567'])),
+ $controller->verifyCode(Request::create('/v1/customers/verify', 'POST', ['identity' => 'jane@example.test', 'code' => '123456'])),
+ ];
+
+ foreach ($responses as $response) {
+ expect($response->getStatusCode())->toBe(403)
+ ->and(fleetopsApiCustomerJson($response))->toBe(['error' => 'This customer login has been deactivated.']);
+ }
+
+ // No verification code is sent
+ expect($controller->smsVerifications)->toBe([]);
+});
+
test('api customer controller resets forgotten passwords', function () {
$controller = fleetopsApiCustomerController();
$forgot = $controller->forgotPassword(Request::create('/v1/customers/forgot-password', 'POST', [
diff --git a/server/tests/ApiDriverControllerContractsTest.php b/server/tests/ApiDriverControllerContractsTest.php
index fa5e4dfa7..27f52381e 100644
--- a/server/tests/ApiDriverControllerContractsTest.php
+++ b/server/tests/ApiDriverControllerContractsTest.php
@@ -30,6 +30,8 @@ class FleetOpsApiDriverControllerProbe extends DriverController
public array $deviceCreates = [];
public array $companyCalls = [];
public array $createdUsers = [];
+ public array $resolvedAccounts = [];
+ public ?Throwable $accountConflict = null;
public array $uuidLookups = [];
public array $relationLookups = [];
public array $relationCompanyScopes = [];
@@ -65,11 +67,17 @@ protected function applyUserInfoFromRequest(Request $request, array $userDetails
return $userDetails;
}
- protected function createUser(array $userDetails): User
+ protected function resolveDriverAccount(string $companyUuid, array $userDetails): User
{
- $this->createdUsers[] = $userDetails;
+ $this->resolvedAccounts[] = $companyUuid;
+ $this->createdUsers[] = $userDetails;
+
+ if ($this->accountConflict) {
+ throw $this->accountConflict;
+ }
+
$this->user ??= new FleetOpsApiDriverUserFake();
- $this->user->setRawAttributes(array_merge(['uuid' => 'user-uuid'], $userDetails), true);
+ $this->user->setRawAttributes(array_merge(['uuid' => 'user-uuid', 'type' => 'driver'], $userDetails), true);
return $this->user;
}
@@ -282,7 +290,17 @@ public function update(array $attributes = [], array $options = []): bool
return true;
}
- public function assignCompany(Company $company, string $role = 'Administrator'): User
+ public function save(array $options = []): bool
+ {
+ $this->updates[] = $this->getDirty();
+ $this->syncOriginal();
+
+ return true;
+ }
+
+ // Matches core-api 1.6.63+, where no role is granted unless one is given (a nullable
+ // role is also compatible with older core-api, whose parameter was a plain string).
+ public function assignCompany(Company $company, ?string $role = null): User
{
$this->assignedCompanies[] = $company->uuid;
@@ -317,6 +335,27 @@ public function setPasswordAttribute($password): void
}
}
+/**
+ * ProfileAccountManager checks a changed email/phone against the users table.
+ */
+function fleetopsApiDriverContractsUsersTable(array $rows = []): void
+{
+ $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
+ $connection->getSchemaBuilder()->create('users', function ($table) {
+ $table->string('uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ if ($rows) {
+ $connection->table('users')->insert($rows);
+ }
+ $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
+}
+
class FleetOpsApiDriverVehicleFake extends Vehicle
{
public array $quietUpdates = [];
@@ -363,16 +402,15 @@ public function or(array $keys, mixed $default = null): mixed
expect($response)->toBe(['resource' => 'driver', 'driver' => $controller->driver])
->and($controller->companyCalls)->toBe([['request', 'company_public']])
+ // The login account is resolved by ProfileAccountManager in the driver's company
+ ->and($controller->resolvedAccounts)->toBe(['company-uuid'])
->and($controller->createdUsers[0])->toMatchArray([
- 'name' => 'Driver One',
- 'email' => 'driver@example.test',
- 'phone' => '+15551234567',
- 'company_uuid' => 'company-uuid',
- 'applied' => true,
+ 'name' => 'Driver One',
+ 'email' => 'driver@example.test',
+ 'phone' => '+15551234567',
+ 'password' => 'secret-password',
+ 'applied' => true,
])
- ->and($controller->user->assignedCompanies)->toBe(['company-uuid'])
- ->and($controller->user->assignedTypes)->toBe(['driver'])
- ->and($controller->user->assignedRoles)->toBe(['Driver'])
->and($controller->createdDrivers[0])->toMatchArray([
'status' => 'available',
'vehicle_uuid' => 'vehicle-uuid',
@@ -407,8 +445,10 @@ public function or(array $keys, mixed $default = null): mixed
});
test('api driver controller updates drivers user details assignments location and photo', function () {
+ fleetopsApiDriverContractsUsersTable();
+
$user = new FleetOpsApiDriverUserFake();
- $user->setRawAttributes(['uuid' => 'user-uuid'], true);
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
$driver = new FleetOpsApiDriverFake();
$driver->setRawAttributes([
@@ -645,7 +685,7 @@ public function or(array $keys, mixed $default = null): mixed
* and now has its own endpoint that demands the current password.
*/
$user = new FleetOpsApiDriverUserFake();
- $user->setRawAttributes(['uuid' => 'user-uuid'], true);
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
$driver = new FleetOpsApiDriverFake();
$driver->setRawAttributes([
'uuid' => 'driver-uuid',
@@ -759,11 +799,9 @@ public function or(array $keys, mixed $default = null): mixed
'company_uuid' => 'company-uuid',
'status' => 'available',
])
- // The Driver-to-User relationship, organization membership, user type
- // and role are all preserved for a credential-less driver.
- ->and($controller->user->assignedCompanies)->toBe(['company-uuid'])
- ->and($controller->user->assignedTypes)->toBe(['driver'])
- ->and($controller->user->assignedRoles)->toBe(['Driver']);
+ // The Driver-to-User relationship and organization membership are
+ // preserved for a credential-less driver.
+ ->and($controller->resolvedAccounts)->toBe(['company-uuid']);
});
test('api driver controller creates a driver with only one contact method', function () {
@@ -873,7 +911,7 @@ public function or(array $keys, mixed $default = null): mixed
]));
$user = new FleetOpsApiDriverUserFake();
- $user->setRawAttributes(['uuid' => 'user-uuid'], true);
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
$driver = new FleetOpsApiDriverFake();
$driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true);
@@ -905,3 +943,47 @@ public function or(array $keys, mixed $default = null): mixed
// name is dropped rather than reaching Eloquent, where it would be a 500.
expect($request->input('with'))->toBe(['vehicle', 'currentJob']);
});
+
+test('api driver controller answers 422 when the email or phone belongs to another account', function () {
+ $create = new FleetOpsApiDriverControllerProbe();
+ $create->accountConflict = new Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException('This email is already in use by another account.');
+
+ expect($create->create(new CreateDriverRequest(['name' => 'Driver One', 'email' => 'taken@example.test'])))->toBe([
+ 'json' => ['error' => 'This email is already in use by another account.'],
+ 'status' => 422,
+ ])
+ ->and($create->createdDrivers)->toBe([]);
+
+ fleetopsApiDriverContractsUsersTable([['uuid' => 'other-user', 'type' => 'user', 'phone' => '+15550009999']]);
+
+ $user = new FleetOpsApiDriverUserFake();
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver', 'phone' => '+15550001111'], true);
+ $driver = new FleetOpsApiDriverFake();
+ $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'user-uuid'], true);
+ $driver->userForTest = $user;
+
+ $update = new FleetOpsApiDriverControllerProbe();
+ $update->driver = $driver;
+
+ expect($update->update('driver_public', new UpdateDriverRequest(['phone' => '+1 555 000 9999'])))->toBe([
+ 'json' => ['error' => 'This phone number is already in use by another account.'],
+ 'status' => 422,
+ ])
+ ->and($user->updates)->toBe([])
+ ->and($driver->updates)->toBe([]);
+});
+
+test('api driver controller only renames a team member account linked to a driver', function () {
+ $staff = new FleetOpsApiDriverUserFake();
+ $staff->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'user', 'name' => 'Staff', 'email' => 'staff@example.test'], true);
+ $driver = new FleetOpsApiDriverFake();
+ $driver->setRawAttributes(['uuid' => 'driver-uuid', 'public_id' => 'driver_public', 'user_uuid' => 'staff-uuid'], true);
+ $driver->userForTest = $staff;
+
+ $update = new FleetOpsApiDriverControllerProbe();
+ $update->driver = $driver;
+ $update->update('driver_public', new UpdateDriverRequest(['name' => 'Staff Driver', 'email' => 'changed@example.test']));
+
+ expect($staff->updates)->toBe([['name' => 'Staff Driver']])
+ ->and($staff->email)->toBe('staff@example.test');
+});
diff --git a/server/tests/CommandContractsTest.php b/server/tests/CommandContractsTest.php
index efad87823..b9c808bbe 100644
--- a/server/tests/CommandContractsTest.php
+++ b/server/tests/CommandContractsTest.php
@@ -884,7 +884,7 @@ public function syncProperty(string $property, Model $model): bool
return true;
}
- public function assignCompany(Company $company, string $role = 'Administrator'): self
+ public function assignCompany(Company $company, ?string $role = null): self
{
$this->assigned[] = [$company->uuid, $role];
@@ -2208,8 +2208,8 @@ public function getNearbyDriversForOrder(Order $order, Point $pickup, int $dista
['email', FleetOpsCustomerCommandFake::class],
['phone', FleetOpsCustomerCommandFake::class],
])
- ->and($createdUser->assigned)->toBe([['company-created', 'Administrator']])
- ->and($existingUser->assigned)->toBe([['company-existing', 'Administrator']]);
+ ->and($createdUser->assigned)->toBe([['company-created', 'Fleet-Ops Customer']])
+ ->and($existingUser->assigned)->toBe([['company-existing', 'Fleet-Ops Customer']]);
});
test('fix driver companies command syncs assigned users and missing company assignments', function () {
@@ -2236,7 +2236,7 @@ public function getNearbyDriversForOrder(Order $order, Point $pickup, int $dista
['email', Driver::class],
['phone', Driver::class],
])
- ->and($user->assigned)->toBe([['driver-company', 'Administrator']])
+ ->and($user->assigned)->toBe([['driver-company', 'Driver']])
->and($command->messages)->toContain(['line', 'Found driver Driver User (driver@example.test) which doesnt have correct company assignment.'])
->and($command->messages)->toContain(['line', 'Driver driver@example.test was assigned to company: Driver Company']);
});
diff --git a/server/tests/ControllerHelperContractsTest.php b/server/tests/ControllerHelperContractsTest.php
index aefd4ed75..cc0cbcdaf 100644
--- a/server/tests/ControllerHelperContractsTest.php
+++ b/server/tests/ControllerHelperContractsTest.php
@@ -254,7 +254,6 @@ public function callHelper(string $method, mixed ...$arguments): mixed
class FleetOpsInternalContactControllerProbe extends InternalContactController
{
public array $installedPackages = [];
- public array $resolvedUsers = [];
public array $sentCredentials = [];
public array $savedMetas = [];
public ?User $contactUser = null;
@@ -269,13 +268,6 @@ public function callHelper(string $method, mixed ...$arguments): mixed
return $reflection->invoke($this, ...$arguments);
}
- protected function resolveUserUuid(string $user): string
- {
- $this->resolvedUsers[] = $user;
-
- return 'resolved-' . $user;
- }
-
protected function installedFleetbaseExtensions(): array
{
return $this->installedPackages;
@@ -1607,7 +1599,7 @@ function fleetopsControllerStaticMethod(string $class, string $method): Reflecti
],
],
]);
- $input = ['type' => 'contact', 'user' => ['id' => 'user_public']];
+ $input = ['type' => 'contact', 'user' => ['id' => 'user_public'], 'user_uuid' => 'user-uuid'];
$controller->onBeforeCreate($request, $input);
@@ -1623,8 +1615,8 @@ function fleetopsControllerStaticMethod(string $class, string $method): Reflecti
$controller->afterSave($request, $contact);
- expect($input)->toBe(['type' => 'contact', 'user_uuid' => 'resolved-user_public'])
- ->and($controller->resolvedUsers)->toBe(['user_public'])
+ // The login account is managed by the profile, so a user can't be picked from the console
+ expect($input)->toBe(['type' => 'contact'])
->and($contact->normalized)->toBeTrue()
->and($contact->syncedCustomFields)->toBe([
['key' => 'tier', 'value' => 'gold'],
diff --git a/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php b/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php
index 3b1e8960b..5a56b5a40 100644
--- a/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php
+++ b/server/tests/Feature/Http/AfaqyRealtimeIngestionTest.php
@@ -596,6 +596,11 @@ public function normalizeTelemetrySnapshot(array $payload): array
DB::table('telematic_deliveries')->where('uuid', $id)->update(['status' => 'quarantined', 'applied' => 2, 'failed' => 1]);
Inbox::finishRun('run-pending');
expect((array) DB::table('telematic_sync_runs')->first())->toMatchArray(['status' => 'partial', 'applied' => 2, 'failed' => 1]);
+
+ // A finished run whose connection was removed completes without touching connection meta
+ DB::table('telematic_sync_runs')->insert(['uuid' => 'run-orphan', 'telematic_uuid' => 'missing-connection', 'status' => 'ingesting', 'created_at' => now(), 'updated_at' => now()]);
+ Inbox::finishRun('run-orphan');
+ expect(DB::table('telematic_sync_runs')->where('uuid', 'run-orphan')->value('status'))->not->toBe('ingesting');
});
test('telemetry diagnostics report setup receipt backlog and degraded delivery states', function () {
diff --git a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php
index 6168cf7e9..d2bf127af 100644
--- a/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php
+++ b/server/tests/Feature/Http/Api/DriverControllerAuthFlowsTest.php
@@ -230,6 +230,42 @@ public function __call($method, $arguments)
->and($connection->table('personal_access_tokens')->value('name'))->toBe('driver-1');
});
+test('deactivated driver logins are refused on every sign-in path', function () {
+ $connection = fleetopsDriverAuthBoot();
+ $controller = new DriverController();
+ $connection->table('users')->where('uuid', 'user-1')->update(['status' => 'inactive']);
+ app('hash')->checks = true;
+
+ // An unknown identity fails authentication rather than erroring on a null user
+ $unknown = $controller->login(Request::create('/x', 'POST', ['identity' => 'ghost@example.test', 'password' => 'secret']));
+ expect($unknown->getStatusCode())->toBe(401);
+
+ $password = $controller->login(Request::create('/x', 'POST', ['identity' => 'driver@example.test', 'password' => 'secret']));
+
+ app()->instance('request', Request::create('/x', 'POST', ['phone' => '6591234567']));
+ $phone = $controller->loginWithPhone();
+
+ $connection->table('verification_codes')->insert(['uuid' => 'vc-1', 'subject_uuid' => 'user-1', 'code' => '424242', 'for' => 'driver_login']);
+ $code = $controller->verifyCode(Request::create('/x', 'POST', ['identity' => '6591234567', 'code' => '424242']));
+
+ foreach ([$password, $phone, $code] as $response) {
+ expect($response->getStatusCode())->toBe(403)
+ ->and($response->getData(true)['error'])->toBe(DriverController::DEACTIVATED_LOGIN_MESSAGE);
+ }
+
+ // Password recovery answers as if the identity were unknown
+ $forgot = $controller->forgotPassword(Request::create('/x', 'POST', ['identity' => 'driver@example.test']));
+ $reset = $controller->resetPassword(Request::create('/x', 'POST', ['identity' => 'driver@example.test', 'code' => '424242', 'password' => 'new-secret-123']));
+
+ expect($forgot->getData(true))->toBe(['status' => 'ok'])
+ ->and($reset->getStatusCode())->toBe(422)
+ ->and($reset->getData(true)['error'])->toBe('Invalid or expired reset code.');
+
+ expect(DriverController::isLoginDeactivated(User::where('uuid', 'user-1')->first()))->toBeTrue()
+ ->and($connection->table('personal_access_tokens')->count())->toBe(0)
+ ->and($connection->table('verification_codes')->count())->toBe(1);
+});
+
test('phone login falls back to email verification and errors without channels', function () {
$connection = fleetopsDriverAuthBoot();
$controller = new DriverController();
diff --git a/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php b/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php
index d7bfbfbe5..5a7fdcae9 100644
--- a/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php
+++ b/server/tests/Feature/Http/Api/DriverControllerSessionHelpersTest.php
@@ -106,7 +106,7 @@ public function needsRehash($hashedValue, array $options = []): bool
$schema = $connection->getSchemaBuilder();
app()->instance('db.schema', $schema);
$tables = [
- 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'status', 'type', 'username', 'password'],
+ 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'status', 'type', 'username', 'password', 'timezone', 'slug', 'meta'],
'drivers' => ['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'status', 'online', 'location', 'meta'],
'companies' => ['uuid', 'public_id', 'name', 'status', 'owner_uuid'],
'user_devices' => ['uuid', 'public_id', 'user_uuid', 'token', 'platform', 'status'],
@@ -189,22 +189,35 @@ public function parameters()
$userDetails = $probe->callHelper('applyUserInfoFromRequest', Request::create('/x', 'POST', []), ['name' => 'Applicant']);
expect($userDetails['name'])->toBe('Applicant');
- $user = $probe->callHelper('createUser', ['name' => 'New User']);
+ // A company that isn't in the table skips the organization membership seam,
+ // which needs spatie roles; the managed account itself is still created.
+ $missingCompany = '33333333-3333-4333-8333-333333333333';
+
+ $user = $probe->callHelper('resolveDriverAccount', $missingCompany, ['name' => 'New User']);
expect($user)->toBeInstanceOf(User::class)
- ->and($connection->table('users')->where('name', 'New User')->count())->toBe(1);
+ ->and($connection->table('users')->where('name', 'New User')->count())->toBe(1)
+ ->and($connection->table('users')->where('name', 'New User')->value('type'))->toBe('driver')
+ ->and($connection->table('users')->where('name', 'New User')->value('status'))->toBe('active');
/*
* `password` is guarded on User, so mass assignment drops it without a
* word. The create endpoint has always documented and validated one, so
- * unless the helper sets it explicitly a driver created through the API
- * can never sign in with the password chosen for them.
+ * unless it is set explicitly a driver created through the API can never
+ * sign in with the password chosen for them.
*/
- $withPassword = $probe->callHelper('createUser', ['name' => 'Password User', 'password' => 'seeded-password']);
+ $withPassword = $probe->callHelper('resolveDriverAccount', $missingCompany, ['name' => 'Password User', 'password' => 'seeded-password']);
$stored = $connection->table('users')->where('name', 'Password User')->value('password');
expect(Hash::check('seeded-password', $withPassword->password))->toBeTrue()
->and($stored)->not->toBeNull()
->and($stored)->not->toBe('seeded-password');
+ // A team member of the company with the email is linked instead of duplicated
+ $connection->table('users')->insert(['uuid' => 'staff-1', 'company_uuid' => '22222222-2222-4222-8222-222222222222', 'name' => 'Staff', 'email' => 'staff@example.test', 'type' => 'user']);
+ $linked = $probe->callHelper('resolveDriverAccount', '22222222-2222-4222-8222-222222222222', ['name' => 'Staff Driver', 'email' => 'STAFF@example.test']);
+ expect($linked->uuid)->toBe('staff-1')
+ ->and($connection->table('users')->where('email', 'staff@example.test')->count())->toBe(1)
+ ->and($connection->table('users')->where('uuid', 'staff-1')->value('type'))->toBe('user');
+
$driver = $probe->callHelper('createDriver', ['company_uuid' => '22222222-2222-4222-8222-222222222222', 'user_uuid' => 'user-1']);
expect($driver)->toBeInstanceOf(Driver::class);
diff --git a/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php b/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php
new file mode 100644
index 000000000..110354bdb
--- /dev/null
+++ b/server/tests/Feature/Http/Api/ProfileOnlyUserConversionMigrationTest.php
@@ -0,0 +1,201 @@
+ $connection, 'mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ EloquentModel::setConnectionResolver($resolver);
+
+ app()->instance('db', new class($connection) {
+ public function __construct(public SQLiteConnection $c)
+ {
+ }
+
+ public function connection($name = null): SQLiteConnection
+ {
+ return $this->c;
+ }
+
+ public function table($table, $as = null)
+ {
+ return $this->c->table($table, $as);
+ }
+
+ public function __call($method, $arguments)
+ {
+ return $this->c->{$method}(...$arguments);
+ }
+ });
+ app()->instance('db.schema', $connection->getSchemaBuilder());
+ DB::clearResolvedInstance('db');
+ Schema::clearResolvedInstance('db.schema');
+
+ $schema = $connection->getSchemaBuilder();
+ $tables = [
+ 'users' => ['uuid', 'type', 'meta'],
+ 'drivers' => ['uuid', 'user_uuid'],
+ 'contacts' => ['uuid', 'user_uuid', 'type'],
+ 'companies' => ['uuid', 'owner_uuid'],
+ 'company_users' => ['uuid', 'user_uuid', 'company_uuid'],
+ 'roles' => ['name'],
+ 'model_has_roles' => ['role_id', 'model_uuid'],
+ 'model_has_permissions' => ['permission_id', 'model_uuid'],
+ ];
+ if ($withPolicies) {
+ $tables['model_has_policies'] = ['policy_id', 'model_uuid'];
+ }
+ foreach ($tables as $table => $columns) {
+ $schema->create($table, function ($blueprint) use ($columns) {
+ $blueprint->increments('id');
+ foreach ($columns as $column) {
+ $blueprint->string($column)->nullable();
+ }
+ $blueprint->timestamp('deleted_at')->nullable();
+ });
+ }
+
+ foreach (['Driver', 'Fleet-Ops Customer', 'Administrator'] as $role) {
+ $connection->table('roles')->insert(['name' => $role]);
+ }
+
+ return $connection;
+}
+
+function fleetopsProfileConversionMigration(): object
+{
+ return require dirname(__DIR__, 4) . '/migrations/2026_09_21_000001_convert_profile_only_users_to_managed_accounts.php';
+}
+
+/**
+ * Seed a user holding the given profile(s), roles on its company membership
+ * (or on the user itself), and optional direct permission or policy.
+ */
+function fleetopsProfileConversionUser(SQLiteConnection $connection, string $uuid, array $options = []): void
+{
+ $connection->table('users')->insert(['uuid' => $uuid, 'type' => $options['type'] ?? 'user', 'meta' => $options['meta'] ?? null, 'deleted_at' => $options['deleted_at'] ?? null]);
+ $connection->table('company_users')->insert(['uuid' => 'cu-' . $uuid, 'user_uuid' => $uuid, 'company_uuid' => 'company-1']);
+
+ if ($options['driver'] ?? false) {
+ $connection->table('drivers')->insert(['uuid' => 'driver-' . $uuid, 'user_uuid' => $uuid]);
+ }
+
+ if ($options['customer'] ?? false) {
+ $connection->table('contacts')->insert(['uuid' => 'contact-' . $uuid, 'user_uuid' => $uuid, 'type' => 'customer']);
+ }
+
+ foreach ($options['roles'] ?? [] as $role) {
+ $roleId = $connection->table('roles')->where('name', $role)->value('id');
+ $connection->table('model_has_roles')->insert(['role_id' => $roleId, 'model_uuid' => ($options['role_on_user'] ?? false) ? $uuid : 'cu-' . $uuid]);
+ }
+
+ if ($options['permission'] ?? false) {
+ $connection->table('model_has_permissions')->insert(['permission_id' => 1, 'model_uuid' => 'cu-' . $uuid]);
+ }
+
+ if ($options['policy'] ?? false) {
+ $connection->table('model_has_policies')->insert(['policy_id' => 1, 'model_uuid' => $uuid]);
+ }
+}
+
+function fleetopsProfileConversionTypes(SQLiteConnection $connection): array
+{
+ return $connection->table('users')->orderBy('uuid')->pluck('type', 'uuid')->all();
+}
+
+test('up converts profile-only accounts and leaves anything that looks like a team member', function () {
+ $connection = fleetopsProfileConversionBoot();
+
+ fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver'], 'meta' => json_encode(['source' => 'console'])]);
+ fleetopsProfileConversionUser($connection, 'b-customer', ['customer' => true, 'roles' => ['Fleet-Ops Customer'], 'role_on_user' => true]);
+ fleetopsProfileConversionUser($connection, 'c-owner', ['driver' => true, 'roles' => ['Driver']]);
+ $connection->table('companies')->insert(['uuid' => 'company-1', 'owner_uuid' => 'c-owner']);
+ fleetopsProfileConversionUser($connection, 'd-extra-role', ['driver' => true, 'roles' => ['Driver', 'Administrator']]);
+ fleetopsProfileConversionUser($connection, 'e-permission', ['driver' => true, 'roles' => ['Driver'], 'permission' => true]);
+ fleetopsProfileConversionUser($connection, 'f-policy', ['customer' => true, 'roles' => ['Fleet-Ops Customer'], 'policy' => true]);
+ fleetopsProfileConversionUser($connection, 'g-both-profiles', ['driver' => true, 'customer' => true, 'roles' => ['Driver']]);
+ fleetopsProfileConversionUser($connection, 'h-no-roles', ['driver' => true]);
+ fleetopsProfileConversionUser($connection, 'i-wrong-role', ['customer' => true, 'roles' => ['Driver']]);
+ fleetopsProfileConversionUser($connection, 'j-no-profile', ['roles' => ['Driver']]);
+ fleetopsProfileConversionUser($connection, 'k-admin', ['type' => 'admin', 'driver' => true, 'roles' => ['Driver']]);
+ fleetopsProfileConversionUser($connection, 'l-deleted', ['driver' => true, 'roles' => ['Driver'], 'deleted_at' => '2026-01-01 00:00:00']);
+ fleetopsProfileConversionUser($connection, 'm-bad-meta', ['driver' => true, 'roles' => ['Driver'], 'meta' => 'not-json']);
+
+ fleetopsProfileConversionMigration()->up();
+
+ expect(fleetopsProfileConversionTypes($connection))->toBe([
+ 'a-driver' => 'driver',
+ 'b-customer' => 'customer',
+ 'c-owner' => 'user',
+ 'd-extra-role' => 'user',
+ 'e-permission' => 'user',
+ 'f-policy' => 'user',
+ 'g-both-profiles' => 'user',
+ 'h-no-roles' => 'user',
+ 'i-wrong-role' => 'user',
+ 'j-no-profile' => 'user',
+ 'k-admin' => 'admin',
+ 'l-deleted' => 'user',
+ 'm-bad-meta' => 'driver',
+ ])
+ // The previous type is recorded next to any existing meta
+ ->and(json_decode($connection->table('users')->where('uuid', 'a-driver')->value('meta'), true))->toBe(['source' => 'console', 'previous_type' => 'user'])
+ ->and(json_decode($connection->table('users')->where('uuid', 'm-bad-meta')->value('meta'), true))->toBe(['previous_type' => 'user']);
+});
+
+test('down reverts only the accounts up converted', function () {
+ $connection = fleetopsProfileConversionBoot();
+
+ fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver'], 'meta' => json_encode(['source' => 'console'])]);
+ fleetopsProfileConversionUser($connection, 'b-customer', ['customer' => true, 'roles' => ['Fleet-Ops Customer']]);
+ // Managed accounts created after the change carry no previous type
+ fleetopsProfileConversionUser($connection, 'c-native-driver', ['type' => 'driver', 'driver' => true, 'meta' => json_encode(['source' => 'app'])]);
+ // A previous type other than `user` is not this migration's
+ fleetopsProfileConversionUser($connection, 'd-other-previous', ['type' => 'customer', 'customer' => true, 'meta' => json_encode(['previous_type' => 'contact'])]);
+
+ $migration = fleetopsProfileConversionMigration();
+ $migration->up();
+ $migration->down();
+
+ expect(fleetopsProfileConversionTypes($connection))->toBe([
+ 'a-driver' => 'user',
+ 'b-customer' => 'user',
+ 'c-native-driver' => 'driver',
+ 'd-other-previous' => 'customer',
+ ])
+ ->and(json_decode($connection->table('users')->where('uuid', 'a-driver')->value('meta'), true))->toBe(['source' => 'console'])
+ ->and(json_decode($connection->table('users')->where('uuid', 'b-customer')->value('meta'), true))->toBe([])
+ ->and(json_decode($connection->table('users')->where('uuid', 'd-other-previous')->value('meta'), true))->toBe(['previous_type' => 'contact']);
+});
+
+test('up and down do nothing when the tables they read are missing', function () {
+ $connection = fleetopsProfileConversionBoot(false);
+ fleetopsProfileConversionUser($connection, 'a-driver', ['driver' => true, 'roles' => ['Driver']]);
+
+ $migration = fleetopsProfileConversionMigration();
+ $migration->up();
+
+ expect(fleetopsProfileConversionTypes($connection))->toBe(['a-driver' => 'user']);
+
+ $connection->table('users')->where('uuid', 'a-driver')->update(['type' => 'driver', 'meta' => json_encode(['previous_type' => 'user'])]);
+ $connection->getSchemaBuilder()->drop('users');
+
+ // Without a users table there is nothing to revert
+ $migration->down();
+
+ expect($connection->getSchemaBuilder()->hasTable('users'))->toBeFalse();
+});
diff --git a/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php b/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php
index 25a114c67..4b4e81925 100644
--- a/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php
+++ b/server/tests/Feature/Http/Internal/ContactControllerEndpointsTest.php
@@ -252,10 +252,12 @@ public function __call($method, $arguments)
$probe->callProtected('assertContactInputIsValid', [$request, &$input, $contact]);
})->toThrow(Exception::class);
- // User references resolve through uuid or public id
- expect($probe->callProtected('resolveUserUuid', ['user_contactseam']))->toBe('88888888-8888-4888-8888-888888888881')
- ->and($probe->callProtected('resolveUserUuid', ['88888888-8888-4888-8888-888888888881']))->toBe('88888888-8888-4888-8888-888888888881')
- ->and($probe->callProtected('resolveUserUuid', ['unknown-user']))->toBe('unknown-user');
+ // The login account is managed by the profile, so user references are dropped
+ $userInput = ['name' => 'Seam Contact', 'user_uuid' => '88888888-8888-4888-8888-888888888881', 'user' => 'user_contactseam'];
+ Closure::bind(function (array &$input) {
+ $this->resolveUserInput(Request::create('/x', 'PUT', []), $input);
+ }, $probe, ContactController::class)($userInput);
+ expect($userInput)->toBe(['name' => 'Seam Contact']);
// Portal seams read users, mint passwords, and persist meta quietly
expect($probe->callProtected('contactUser', [$contact])?->uuid)->toBe('88888888-8888-4888-8888-888888888881')
diff --git a/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php b/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php
index a71caed4a..82dc5f916 100644
--- a/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php
+++ b/server/tests/Feature/Http/Internal/CustomerControllerCredentialsTest.php
@@ -125,7 +125,9 @@ public function __call($method, $arguments)
'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'email', 'phone', 'type', 'title', 'meta', 'photo_uuid', 'place_uuid', 'slug', '_key'],
'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'password', 'type', 'status', 'timezone', 'slug', 'username', 'avatar_uuid', 'meta', '_key'],
'companies' => ['uuid', 'public_id', 'name', 'country'],
- 'files' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'path', 'disk', 'type', '_key'],
+ // session_status reads the user's membership of the session company
+ 'company_users' => ['uuid', 'company_uuid', 'user_uuid', 'status'],
+ 'files' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'path', 'disk', 'type', '_key'],
];
foreach ($tables as $table => $columns) {
$schema->create($table, function ($blueprint) use ($columns) {
@@ -192,5 +194,7 @@ public function __call($method, $arguments)
$payload = $probe->callHelper('customerPayload', $customer);
expect($payload['uuid'])->toBe('55555555-5555-4555-8555-555555555555')
- ->and($payload)->toHaveKey('user');
+ ->and($payload['user']['uuid'])->toBe('user-1')
+ ->and($payload['is_staff_linked'])->toBeFalse()
+ ->and($payload['login_status'])->toBe('active');
});
diff --git a/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php b/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php
index 0d9cc86da..b502938bc 100644
--- a/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php
+++ b/server/tests/Feature/Http/Internal/DriverControllerContractsTest.php
@@ -1131,6 +1131,29 @@ public function getCurrentOrder(): ?Order
]);
});
+test('internal driver controller refuses phone login and code verification for deactivated drivers', function () {
+ FleetOpsInternalDriverAuthControllerProbe::resetProbe();
+ app()->instance('request', Request::create('/', 'POST', ['phone' => '15551234567']));
+
+ $deactivated = fleetopsInternalDriverAuthUser();
+ $deactivated->setAttribute('status', 'inactive');
+ FleetOpsInternalDriverAuthControllerProbe::$loginUser = $deactivated;
+ FleetOpsInternalDriverAuthControllerProbe::$verificationUser = $deactivated;
+ FleetOpsInternalDriverAuthControllerProbe::$verificationExists = true;
+
+ $controller = new FleetOpsInternalDriverAuthControllerProbe();
+ $login = $controller->loginWithPhone();
+ $verify = $controller->verifyCode(new Request(['identity' => '+15551234567', 'code' => '111111']));
+
+ expect($login->getStatusCode())->toBe(403)
+ ->and($login->getData(true))->toBe(['error' => 'This driver login has been deactivated.'])
+ ->and($verify->getStatusCode())->toBe(403)
+ ->and($verify->getData(true))->toBe(['error' => 'This driver login has been deactivated.'])
+ // No code is sent and no token is issued
+ ->and(FleetOpsInternalDriverAuthControllerProbe::$verifications)->toBe([])
+ ->and(FleetOpsInternalDriverAuthControllerProbe::$tokens)->toBe([]);
+});
+
test('internal driver controller delegates login and create driver verification flows to api controller', function () {
$request = new Request(['for' => 'create_driver']);
diff --git a/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php b/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php
index 141ce1e44..f527fe0fb 100644
--- a/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php
+++ b/server/tests/Feature/Http/Internal/DriverControllerCreateRecordTest.php
@@ -10,11 +10,12 @@
/**
* Covers the internal DriverController createRecord and updateRecord flows
* against an in-memory SQLite fixture. The creation closure executes end to
- * end: company resolution, user creation with driver typing, existing-user
- * adoption and conflict detection, organization membership checks, and the
- * exception-to-error-response branches. Role assignment requires the full
- * spatie permission boot unavailable in the harness, so flows settle in the
- * documented error branch after the user record is persisted.
+ * end: company resolution, managed account creation with driver typing
+ * through ProfileAccountManager, and the exception-to-error-response
+ * branches. Role assignment requires the full spatie permission boot, which
+ * this fixture leaves out, so flows settle in the documented error branch
+ * after the user record is persisted (see
+ * DriverControllerExistingUserAdoptionTest for the full flow).
*/
if (!function_exists('Fleetbase\Support\session')) {
eval('namespace Fleetbase\Support; function session($key = null, $default = null) { if ($key === null) { return new class { public function has($k) { return \session($k) !== null; } public function get($k, $d = null) { return \session($k, $d); } }; } return \session($key, $default); }');
@@ -101,6 +102,28 @@ public function validated()
}
});
Illuminate\Support\Facades\DB::clearResolvedInstance('db');
+ app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
+ public function info($hashedValue): array
+ {
+ return [];
+ }
+
+ public function make($value, array $options = []): string
+ {
+ return 'hashed:' . $value;
+ }
+
+ public function check($value, $hashedValue, array $options = []): bool
+ {
+ return 'hashed:' . $value === $hashedValue;
+ }
+
+ public function needsRehash($hashedValue, array $options = []): bool
+ {
+ return false;
+ }
+ });
+ Illuminate\Support\Facades\Hash::clearResolvedInstance('hash');
$schema = $connection->getSchemaBuilder();
$tables = [
@@ -158,31 +181,33 @@ function fleetopsInternalDriverCreateRequest(array $driver): Request
->and($connection->table('company_users')->count())->toBeGreaterThanOrEqual(0);
});
-test('create record rejects users that already belong to a driver', function () {
+test('create record ignores a picked user account and resolves the login from the email', function () {
$connection = fleetopsInternalDriverCreateBoot();
- $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Existing', 'type' => 'driver']);
- $connection->table('drivers')->insert(['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']);
+ $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Existing', 'email' => 'existing@example.com', 'type' => 'driver']);
- $result = (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([
- 'name' => 'Existing',
+ (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([
+ 'name' => 'Someone Else',
+ 'email' => 'someone@example.com',
'user_uuid' => '11111111-1111-4111-8111-111111111111',
]));
- expect($result)->toBeInstanceOf(JsonResponse::class)
- ->and($result->getData(true))->toBe(['error' => 'This user account already belongs to a driver.']);
+ // The login account is managed by the profile: a new driver account is
+ // created for the email rather than the picked account being taken over
+ expect($connection->table('users')->count())->toBe(2)
+ ->and($connection->table('users')->where('email', 'someone@example.com')->value('type'))->toBe('driver')
+ ->and($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->value('name'))->toBe('Existing');
});
-test('create record adopts an existing user and applies photo avatars', function () {
+test('create record applies photo avatars to the new driver account', function () {
$connection = fleetopsInternalDriverCreateBoot();
- $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Adoptable', 'type' => 'driver']);
$result = (new DriverController())->createRecord(fleetopsInternalDriverCreateRequest([
- 'name' => 'Adoptable',
- 'user_uuid' => '11111111-1111-4111-8111-111111111111',
+ 'name' => 'Photo Driver',
'photo_uuid' => '22222222-2222-4222-8222-222222222222',
]));
- // Avatar update applies before the harness role-assignment limitation.
+ // Avatar is set when the account is created, before the harness
+ // role-assignment limitation.
expect($connection->table('users')->value('avatar_uuid'))->toBe('22222222-2222-4222-8222-222222222222')
->and($result)->toBeInstanceOf(JsonResponse::class)
->and($result->getData(true))->toHaveKey('error');
diff --git a/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php b/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php
index 09b76933b..38aa20dd3 100644
--- a/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php
+++ b/server/tests/Feature/Http/Internal/DriverControllerExistingUserAdoptionTest.php
@@ -25,12 +25,13 @@
use Illuminate\Support\Str;
/**
- * Covers the internal DriverController createRecord unique-conflict branch
- * against SQLite with a failing validator: adopting an existing
- * organization member by creating a driver profile, returning the existing
- * driver profile when one already exists, assigning non-members to the
- * company, and falling through to the validation error response when the
- * conflict is not a phone or email collision.
+ * Covers how the internal DriverController createRecord resolves the new
+ * driver's login account through ProfileAccountManager against SQLite with
+ * real spatie roles: creating a managed driver account without an invite,
+ * linking a staff member of the organization without touching their role,
+ * linking a managed driver account of another organization, rejecting an
+ * email/phone held by another organization's staff, another profile type or
+ * an existing driver of the organization, and returning validation failures.
*/
if (!function_exists('Fleetbase\Observers\event')) {
eval('namespace Fleetbase\Observers; function event($event = null, $payload = []) { return []; }');
@@ -212,6 +213,28 @@ public function getMessageBag()
}
});
Illuminate\Support\Facades\DB::clearResolvedInstance('db');
+ app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
+ public function info($hashedValue): array
+ {
+ return [];
+ }
+
+ public function make($value, array $options = []): string
+ {
+ return 'hashed:' . $value;
+ }
+
+ public function check($value, $hashedValue, array $options = []): bool
+ {
+ return 'hashed:' . $value === $hashedValue;
+ }
+
+ public function needsRehash($hashedValue, array $options = []): bool
+ {
+ return false;
+ }
+ });
+ Illuminate\Support\Facades\Hash::clearResolvedInstance('hash');
$schema = $connection->getSchemaBuilder();
$tables = [
@@ -327,66 +350,121 @@ function fleetopsDriverAdoptionRequest(array $driver): Request
return Request::create('/int/v1/drivers', 'POST', ['driver' => $driver]);
}
-test('phone conflicts adopt existing organization members as drivers', function () {
- $connection = fleetopsDriverAdoptionBoot(['phone' => ['The phone has already been taken.']]);
- $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Member', 'phone' => '+6591234567', 'slug' => 'member', 'type' => 'driver']);
- $connection->table('company_users')->insert(['uuid' => 'cu-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']);
+function fleetopsDriverAdoptionRoles(SQLiteConnection $connection, string $userUuid): array
+{
+ return $connection->table('model_has_roles')
+ ->join('roles', 'roles.id', '=', 'model_has_roles.role_id')
+ ->join('company_users', 'company_users.uuid', '=', 'model_has_roles.model_uuid')
+ ->where('company_users.user_uuid', $userUuid)
+ ->pluck('roles.name')
+ ->unique()
+ ->values()
+ ->all();
+}
+
+test('a new driver gets a managed driver account added to the company without an invite', function () {
+ $connection = fleetopsDriverAdoptionBoot([]);
$result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([
- 'name' => 'Member',
- 'phone' => '+6591234567',
+ 'name' => 'New Driver',
+ 'email' => 'New.Driver@Example.com',
+ 'phone' => '+65 9123-0000',
+ 'password' => 'chosen-secret',
]));
+ $user = $connection->table('users')->first();
+
expect($result)->toBeArray()
- ->and($result['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111111')
- ->and($connection->table('drivers')->count())->toBe(1)
- ->and($connection->table('drivers')->value('company_uuid'))->toBe('company-1');
+ ->and($result['driver']->resource->user_uuid)->toBe($user->uuid)
+ ->and($user->type)->toBe('driver')
+ ->and($user->status)->toBe('active')
+ ->and($user->email)->toBe('new.driver@example.com')
+ ->and($user->phone)->toBe('+6591230000')
+ ->and($user->password)->toBe('hashed:chosen-secret')
+ ->and($connection->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => $user->uuid])->count())->toBe(1)
+ ->and(fleetopsDriverAdoptionRoles($connection, $user->uuid))->toBe(['Driver'])
+ ->and($connection->table('invites')->count())->toBe(0);
});
-test('phone conflicts assign non members to the session company', function () {
- $connection = fleetopsDriverAdoptionBoot(['phone' => ['The phone has already been taken.']]);
-
- // Same conflict as above, but with no company_users row the user is not yet
- // an organization member, so adoption must also attach them to the company
- $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111112', 'name' => 'Outsider', 'phone' => '+6591234568', 'slug' => 'outsider', 'type' => 'driver']);
-
- expect($connection->table('company_users')->count())->toBe(0);
+test('a staff member of the organization is linked without changing their account or role', function () {
+ $connection = fleetopsDriverAdoptionBoot([]);
+ $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Staff Member', 'email' => 'staff@example.com', 'phone' => '+6590001111', 'slug' => 'staff', 'type' => 'user']);
$result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([
- 'name' => 'Outsider',
- 'phone' => '+6591234568',
+ 'name' => 'Different Name',
+ 'email' => 'staff@example.com',
+ 'phone' => '+6599998888',
]));
+ $staff = $connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->first();
+
expect($result)->toBeArray()
- ->and($result['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111112')
- ->and($connection->table('drivers')->count())->toBe(1)
- // the assignCompany branch ran: the user is now a member of company-1
- ->and($connection->table('company_users')->where([
- 'company_uuid' => 'company-1',
- 'user_uuid' => '11111111-1111-4111-8111-111111111112',
- ])->count())->toBe(1);
+ ->and($result['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111111')
+ ->and($connection->table('users')->count())->toBe(1)
+ ->and($staff->type)->toBe('user')
+ ->and($staff->phone)->toBe('+6590001111')
+ ->and($staff->name)->toBe('Staff Member')
+ ->and($connection->table('model_has_roles')->count())->toBe(0)
+ ->and($connection->table('drivers')->value('company_uuid'))->toBe('company-1');
});
-test('email conflicts return the existing driver profile when present', function () {
- $connection = fleetopsDriverAdoptionBoot(['email' => ['The email has already been taken.']]);
- $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Member', 'email' => 'member@example.com', 'type' => 'driver']);
- $connection->table('drivers')->insert(['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']);
+test('a managed driver account of another organization is linked and joins the company', function () {
+ $connection = fleetopsDriverAdoptionBoot([]);
+ $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111112', 'company_uuid' => 'company-2', 'name' => 'Shared Driver', 'phone' => '+6591234568', 'slug' => 'shared', 'type' => 'driver']);
+ $connection->table('drivers')->insert(['uuid' => 'driver-elsewhere', 'company_uuid' => 'company-2', 'user_uuid' => '11111111-1111-4111-8111-111111111112']);
$result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([
- 'name' => 'Member',
- 'email' => 'member@example.com',
+ 'name' => 'Shared Driver',
+ 'phone' => '+6591234568',
]));
expect($result)->toBeArray()
- ->and($result['driver']->resource->uuid)->toBe('driver-1')
- ->and($connection->table('drivers')->count())->toBe(1);
+ ->and($result['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111112')
+ ->and($connection->table('drivers')->where('company_uuid', 'company-1')->count())->toBe(1)
+ ->and($connection->table('company_users')->where(['company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111112'])->count())->toBe(1)
+ ->and(fleetopsDriverAdoptionRoles($connection, '11111111-1111-4111-8111-111111111112'))->toBe(['Driver'])
+ ->and($connection->table('invites')->count())->toBe(0);
});
-test('non phone or email conflicts fall through to the error response', function () {
+test('an email or phone that cannot be linked is rejected with a 422', function (array $user, ?array $driver, array $input, string $message) {
+ $connection = fleetopsDriverAdoptionBoot([]);
+ $connection->table('users')->insert(array_merge(['uuid' => '11111111-1111-4111-8111-111111111111', 'name' => 'Existing'], $user));
+ if ($driver) {
+ $connection->table('drivers')->insert($driver);
+ }
+
+ $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(array_merge(['name' => 'New Driver'], $input)));
+
+ expect($result)->toBeInstanceOf(Illuminate\Http\JsonResponse::class)
+ ->and($result->getStatusCode())->toBe(422)
+ ->and($result->getData(true)['error'])->toBe($message)
+ ->and($connection->table('drivers')->where('company_uuid', 'company-1')->where('user_uuid', '!=', '11111111-1111-4111-8111-111111111111')->count())->toBe(0)
+ ->and($connection->table('users')->count())->toBe(1);
+})->with([
+ 'staff of another organization' => [
+ ['company_uuid' => 'company-2', 'email' => 'outsider@example.com', 'type' => 'admin'],
+ null,
+ ['email' => 'outsider@example.com'],
+ 'This email is already in use by another account.',
+ ],
+ 'a customer account' => [
+ ['company_uuid' => 'company-1', 'phone' => '+6591112222', 'type' => 'customer'],
+ null,
+ ['phone' => '+6591112222'],
+ 'This phone number is already used by a customer.',
+ ],
+ 'an existing driver of the organization' => [
+ ['company_uuid' => 'company-1', 'email' => 'member@example.com', 'type' => 'driver'],
+ ['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111'],
+ ['email' => 'member@example.com'],
+ 'A driver with this email already exists.',
+ ],
+]);
+
+test('validation failures return the error response', function () {
fleetopsDriverAdoptionBoot(['name' => ['The name field is required.']]);
- // The error response seam raises the validation failure once the
- // fall-through branch executes
+ // The error response seam raises the validation failure
$failure = null;
try {
@@ -398,104 +476,67 @@ function fleetopsDriverAdoptionRequest(array $driver): Request
expect($failure?->errors())->toBe(['name' => ['The name field is required.']]);
});
-test('valid create requests build the driver user and profile', function () {
- $connection = fleetopsDriverAdoptionBoot([]);
-
- putenv('DEBUG=1');
- $_ENV['DEBUG'] = $_SERVER['DEBUG'] = '1';
- $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([
- 'name' => 'Fresh Driver',
- 'email' => 'fresh@example.com',
- 'phone' => '+6590001111',
- ]));
+function fleetopsDriverAdoptionThrowingHasher(Throwable $exception): void
+{
+ $GLOBALS['fleetopsDriverAdoptionHashException'] = $exception;
+ app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
+ public function info($hashedValue): array
+ {
+ return [];
+ }
- expect($result)->toBeArray()->toHaveKey('driver')
- ->and($connection->table('users')->where('email', 'fresh@example.com')->value('type'))->toBe('driver')
- ->and($connection->table('drivers')->whereNotNull('user_uuid')->count())->toBe(1)
- ->and($connection->table('model_has_roles')->count())->toBeGreaterThanOrEqual(1);
-});
+ public function make($value, array $options = []): string
+ {
+ throw $GLOBALS['fleetopsDriverAdoptionHashException'];
+ }
-test('valid update requests refresh driver and user details', function () {
- $connection = fleetopsDriverAdoptionBoot([]);
- $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'name' => 'Old Name', 'email' => 'old@example.com', 'type' => 'driver']);
- $connection->table('drivers')->insert(['uuid' => '22222222-2222-4222-8222-222222222222', 'public_id' => 'driver_updone1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111']);
-
- $request = Request::create('/int/v1/drivers/driver_updone1', 'PUT', ['driver' => [
- 'name' => 'New Name',
- 'status' => 'active',
- ]]);
- $request->setRouteResolver(function () {
- return new class {
- public function getAction($key = null)
- {
- return $key === null ? ['controller' => DriverController::class . '@updateRecord'] : DriverController::class . '@updateRecord';
- }
+ public function check($value, $hashedValue, array $options = []): bool
+ {
+ return false;
+ }
- public function getActionMethod()
- {
- return 'updateRecord';
- }
+ public function needsRehash($hashedValue, array $options = []): bool
+ {
+ return false;
+ }
+ });
+ Illuminate\Support\Facades\Hash::clearResolvedInstance('hash');
+}
- public function getName()
- {
- return 'internal.drivers.update';
- }
+test('create record reports a missing organization', function () {
+ fleetopsDriverAdoptionBoot([]);
+ session(['company' => 'company-missing']);
- public function uri()
- {
- return 'int/v1/drivers/{id}';
- }
+ $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Nowhere Driver']));
- public function parameters()
- {
- return ['id' => 'driver_updone1'];
- }
+ expect($result->getData(true))->toBe(['error' => 'Unable to create driver.']);
+});
- public function parameter($name = null, $default = null)
- {
- return $name === 'id' ? 'driver_updone1' : $default;
- }
- };
- });
+test('create record syncs custom field values onto the new driver', function () {
+ $connection = fleetopsDriverAdoptionBoot([]);
+ $connection->table('custom_fields')->insert(['uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'company_uuid' => 'company-1', 'name' => 'badge', 'label' => 'Badge']);
- $result = (new DriverController())->updateRecord($request, 'driver_updone1');
+ $result = (new DriverController())->createRecord(Request::create('/int/v1/drivers', 'POST', ['driver' => [
+ 'name' => 'Badged Driver',
+ 'custom_field_values' => [['custom_field_uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'value' => 'B-12']],
+ ]]));
- expect($result)->toBeArray()->toHaveKey('driver')
- ->and($connection->table('users')->value('name'))->toBe('New Name')
- ->and($connection->table('drivers')->value('status'))->toBe('available');
+ expect($result)->toBeArray()
+ ->and($connection->table('custom_field_values')->where('subject_uuid', $result['driver']->resource->uuid)->value('value'))->toBe('B-12');
});
-test('create with user uuids provisions users guards conflicts and companies', function () {
+test('create record reports database and request validation failures', function () {
$connection = fleetopsDriverAdoptionBoot([]);
- Illuminate\Support\Facades\Cache::clearResolvedInstance('cache');
+ $connection->getSchemaBuilder()->drop('drivers');
- // Unknown user uuids provision a fresh user with generated credentials
- $created = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([
- 'user_uuid' => '99999999-9999-4999-8999-999999999901',
- 'name' => 'Provisioned Driver',
- 'email' => 'provisioned@example.com',
- 'phone' => '+6591112222',
- 'photo_uuid' => '99999999-9999-4999-8999-999999999902',
- ]));
- expect($created)->toBeArray()->toHaveKey('driver')
- ->and($connection->table('users')->where('email', 'provisioned@example.com')->count())->toBe(1)
- ->and($connection->table('users')->where('email', 'provisioned@example.com')->value('avatar_uuid'))->toBe('99999999-9999-4999-8999-999999999902');
-
- // Users already holding a driver profile in the company are rejected
- $existingUserUuid = $connection->table('users')->where('email', 'provisioned@example.com')->value('uuid');
- $conflict = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([
- 'user_uuid' => $existingUserUuid,
- 'name' => 'Duplicate Driver',
- ]));
- expect($conflict->getData(true)['error'] ?? '')->toContain('already belongs');
+ // The account is created before the driver row fails to insert
+ $queryFailure = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Tableless Driver']));
+ expect($queryFailure->getData(true)['error'])->toContain('drivers');
- // Without a session company driver creation fails outright
- session(['company' => null]);
- $noCompany = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([
- 'name' => 'Companyless Driver',
- ]));
- expect($noCompany->getData(true)['error'] ?? '')->toContain('Unable to create driver');
- session(['company' => 'company-1']);
+ fleetopsDriverAdoptionThrowingHasher(new Fleetbase\Exceptions\FleetbaseRequestValidationException(['password' => ['The password is too weak.']]));
+ $validationFailure = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Weak Password', 'password' => 'weak-password']));
+
+ expect($validationFailure->getData(true))->toBe(['error' => ['password' => ['The password is too weak.']]]);
});
test('auth can resolves real permissions for requests and ping guards', function () {
@@ -558,153 +599,3 @@ public function parameter($name = null, $default = null)
session(['user' => null]);
});
-
-function fleetopsDriverAdoptionUpdateRequest(string $publicId, array $driver): Request
-{
- $request = Request::create('/int/v1/drivers/' . $publicId, 'PUT', ['driver' => $driver]);
- $request->setRouteResolver(function () use ($publicId) {
- return new class($publicId) {
- public function __construct(private string $publicId)
- {
- }
-
- public function getAction($key = null)
- {
- return $key === null ? ['controller' => DriverController::class . '@updateRecord'] : DriverController::class . '@updateRecord';
- }
-
- public function getActionMethod()
- {
- return 'updateRecord';
- }
-
- public function getName()
- {
- return 'internal.drivers.update';
- }
-
- public function uri()
- {
- return 'int/v1/drivers/{id}';
- }
-
- public function parameters()
- {
- return ['id' => $this->publicId];
- }
-
- public function parameter($name = null, $default = null)
- {
- return $name === 'id' ? $this->publicId : $default;
- }
- };
- });
-
- return $request;
-}
-
-test('creation applies photo avatars and syncs custom field values', function () {
- $connection = fleetopsDriverAdoptionBoot([]);
- $connection->table('custom_fields')->insert(['uuid' => '33333333-3333-4333-8333-333333333301', 'company_uuid' => 'company-1', 'name' => 'badge_number', 'label' => 'Badge Number']);
-
- $result = (new DriverController())->createRecord(fleetopsDriverAdoptionRequest([
- 'name' => 'Photographed Driver',
- 'email' => 'photographed@example.com',
- 'phone' => '+6590002222',
- 'photo_uuid' => '44444444-4444-4444-8444-444444444401',
- 'custom_field_values' => [
- ['custom_field_uuid' => '33333333-3333-4333-8333-333333333301', 'value' => 'BADGE-7', 'value_type' => 'text'],
- ],
- ]));
-
- expect($result)->toBeArray()->toHaveKey('driver')
- ->and($connection->table('users')->where('email', 'photographed@example.com')->value('avatar_uuid'))->toBe('44444444-4444-4444-8444-444444444401')
- ->and($connection->table('custom_field_values')->where('value', 'BADGE-7')->count())->toBe(1);
-});
-
-test('creation maps query and validation failures onto error responses', function () {
- fleetopsDriverAdoptionBoot([]);
-
- // Query failures from the persistence pipeline report a generic message
- $queryFailure = new DriverController();
- $queryFailure->model = new class extends Fleetbase\FleetOps\Models\Driver {
- public function createRecordFromRequest($request, ?callable $onBefore = null, ?callable $onAfter = null, array $options = [])
- {
- throw new Illuminate\Database\QueryException('mysql', 'insert into drivers', [], new RuntimeException('constraint violation'));
- }
- };
- $queryResponse = $queryFailure->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Query Failure']));
- expect($queryResponse->getData(true)['error'] ?? '')->not->toBeEmpty();
-
- // Validation exceptions surface their individual error messages
- $validationFailure = new DriverController();
- $validationFailure->model = new class extends Fleetbase\FleetOps\Models\Driver {
- public function createRecordFromRequest($request, ?callable $onBefore = null, ?callable $onAfter = null, array $options = [])
- {
- throw new Fleetbase\Exceptions\FleetbaseRequestValidationException(['The name field is required.']);
- }
- };
- $validationResponse = $validationFailure->createRecord(fleetopsDriverAdoptionRequest(['name' => 'Validation Failure']));
- expect($validationResponse->getData(true)['error'])->toBe(['The name field is required.']);
-});
-
-test('updates apply photo avatars and sync custom field values', function () {
- $connection = fleetopsDriverAdoptionBoot([]);
- $connection->table('users')->insert(['uuid' => '11111111-1111-4111-8111-111111111121', 'company_uuid' => 'company-1', 'name' => 'Photo Driver', 'email' => 'photodriver@example.com', 'type' => 'driver']);
- $connection->table('drivers')->insert(['uuid' => '22222222-2222-4222-8222-222222222221', 'public_id' => 'driver_photoone', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111121']);
- $connection->table('custom_fields')->insert(['uuid' => '33333333-3333-4333-8333-333333333302', 'company_uuid' => 'company-1', 'name' => 'route_code', 'label' => 'Route Code']);
-
- $result = (new DriverController())->updateRecord(fleetopsDriverAdoptionUpdateRequest('driver_photoone', [
- 'name' => 'Photo Driver Updated',
- 'photo_uuid' => '44444444-4444-4444-8444-444444444402',
- 'custom_field_values' => [
- ['custom_field_uuid' => '33333333-3333-4333-8333-333333333302', 'value' => 'ROUTE-9', 'value_type' => 'text'],
- ],
- ]), 'driver_photoone');
-
- expect($result)->toBeArray()->toHaveKey('driver')
- ->and($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111121')->value('avatar_uuid'))->toBe('44444444-4444-4444-8444-444444444402')
- ->and($connection->table('custom_field_values')->where('value', 'ROUTE-9')->count())->toBe(1);
-});
-
-test('updates map validation and generic failures onto error responses', function () {
- fleetopsDriverAdoptionBoot([]);
-
- // Validation exceptions from the pipeline surface their error list
- $validationFailure = new DriverController();
- $validationFailure->model = new class extends Fleetbase\FleetOps\Models\Driver {
- public function updateRecordFromRequest(Request $request, $id, ?callable $onBefore = null, ?callable $onAfter = null, array $options = [])
- {
- throw new Fleetbase\Exceptions\FleetbaseRequestValidationException(['The status field is invalid.']);
- }
- };
- $validationResponse = $validationFailure->updateRecord(fleetopsDriverAdoptionUpdateRequest('driver_missing1', ['name' => 'Validation Failure']), 'driver_missing1');
- expect($validationResponse->getData(true)['error'])->toBe(['The status field is invalid.']);
-
- // Any other failure reports its own message
- $genericFailure = new DriverController();
- $genericFailure->model = new class extends Fleetbase\FleetOps\Models\Driver {
- public function updateRecordFromRequest(Request $request, $id, ?callable $onBefore = null, ?callable $onAfter = null, array $options = [])
- {
- throw new Exception('Driver profile is locked.');
- }
- };
- $genericResponse = $genericFailure->updateRecord(fleetopsDriverAdoptionUpdateRequest('driver_missing1', ['name' => 'Generic Failure']), 'driver_missing1');
- expect($genericResponse->getData(true)['error'] ?? '')->toBe('Driver profile is locked.');
-});
-
-test('update rejects requests that fail validation before persistence', function () {
- fleetopsDriverAdoptionBoot(['status' => ['The selected status is invalid.']]);
-
- // The error-response seam raises the validation failure once the rejection
- // branch executes, so the request never reaches persistence
- $failure = null;
-
- try {
- (new DriverController())->updateRecord(fleetopsDriverAdoptionUpdateRequest('driver_missing1', ['status' => 'bogus']), 'driver_missing1');
- } catch (Illuminate\Validation\ValidationException $exception) {
- $failure = $exception;
- }
-
- expect($failure?->errors())->toBe(['status' => ['The selected status is invalid.']]);
-});
diff --git a/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php b/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php
new file mode 100644
index 000000000..16bb6cd74
--- /dev/null
+++ b/server/tests/Feature/Http/Internal/DriverControllerLoginManagementTest.php
@@ -0,0 +1,716 @@
+rule; } }');
+}
+
+if (!Str::hasMacro('humanize')) {
+ Str::macro('humanize', fn ($value) => ucfirst(str_replace(['_', '-'], ' ', Str::snake((string) $value))));
+}
+
+if (!Request::hasMacro('getController')) {
+ Request::macro('getController', fn () => new DriverController());
+}
+
+if (!Request::hasMacro('or')) {
+ Request::macro('or', function (array $params = [], $default = null) {
+ foreach ($params as $param) {
+ if ($this->has($param)) {
+ return $this->input($param);
+ }
+ }
+
+ return $default;
+ });
+}
+
+function fleetopsDriverLoginContainer(): void
+{
+ $current = Illuminate\Container\Container::getInstance();
+ if (method_exists($current, 'hasDebugModeEnabled')) {
+ return;
+ }
+
+ // Core record mutation surfaces exceptions through app()->hasDebugModeEnabled()
+ // and app()->environment(), which the harness container lacks
+ $replacement = new class extends Illuminate\Container\Container {
+ public function environment(...$environments)
+ {
+ if (empty($environments)) {
+ return 'testing';
+ }
+
+ $checks = is_array($environments[0]) ? $environments[0] : $environments;
+
+ return in_array('testing', $checks, true);
+ }
+
+ public function hasDebugModeEnabled()
+ {
+ return true;
+ }
+ };
+
+ foreach (['bindings', 'instances', 'aliases', 'abstractAliases', 'resolved', 'extenders', 'tags', 'contextual', 'scopedInstances', 'reboundCallbacks', 'globalBeforeResolvingCallbacks', 'globalResolvingCallbacks', 'globalAfterResolvingCallbacks', 'beforeResolvingCallbacks', 'resolvingCallbacks', 'afterResolvingCallbacks'] as $property) {
+ if (!property_exists(Illuminate\Container\Container::class, $property)) {
+ continue;
+ }
+ $reflection = new ReflectionProperty(Illuminate\Container\Container::class, $property);
+ $reflection->setAccessible(true);
+ if ($reflection->isInitialized($current)) {
+ $reflection->setValue($replacement, $reflection->getValue($current));
+ }
+ }
+
+ Illuminate\Container\Container::setInstance($replacement);
+ Illuminate\Support\Facades\Facade::setFacadeApplication($replacement);
+}
+
+function fleetopsDriverLoginBoot(array $validatorErrors): SQLiteConnection
+{
+ fleetopsDriverLoginContainer();
+ $pdo = new PDO('sqlite::memory:');
+ $pdo->sqliteCreateFunction('ST_PointFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt);
+ $pdo->sqliteCreateFunction('ST_GeomFromText', fn ($wkt, $srid = 0, $axisOrder = null) => $wkt);
+ $connection = new SQLiteConnection($pdo);
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ EloquentModel::setConnectionResolver($resolver);
+ if (!EloquentModel::getEventDispatcher()) {
+ EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher());
+ }
+ app()->instance('db', new class($connection) {
+ public function __construct(public SQLiteConnection $c)
+ {
+ }
+
+ public function connection($name = null): SQLiteConnection
+ {
+ return $this->c;
+ }
+
+ public function __call($method, $arguments)
+ {
+ return $this->c->{$method}(...$arguments);
+ }
+ });
+ app()->instance('db.schema', $connection->getSchemaBuilder());
+ app()->instance('responsecache', new class {
+ public function __call($method, $arguments)
+ {
+ return null;
+ }
+ });
+ $GLOBALS['fleetopsDriverLoginErrors'] = $validatorErrors;
+ app()->instance('validator', new class {
+ public function make($data = [], $rules = [], $messages = [], $attributes = [])
+ {
+ return new class implements Illuminate\Contracts\Validation\Validator {
+ public function fails()
+ {
+ return !empty($GLOBALS['fleetopsDriverLoginErrors']);
+ }
+
+ public function errors()
+ {
+ return new MessageBag($GLOBALS['fleetopsDriverLoginErrors']);
+ }
+
+ public function validated()
+ {
+ return [];
+ }
+
+ public function validate()
+ {
+ return [];
+ }
+
+ public function failed()
+ {
+ return array_keys($GLOBALS['fleetopsDriverLoginErrors']);
+ }
+
+ public function sometimes($attribute, $rules, callable $callback)
+ {
+ return $this;
+ }
+
+ public function after($callback)
+ {
+ return $this;
+ }
+
+ public function getMessageBag()
+ {
+ return new MessageBag($GLOBALS['fleetopsDriverLoginErrors']);
+ }
+ };
+ }
+ });
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
+ app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
+ public function info($hashedValue): array
+ {
+ return [];
+ }
+
+ public function make($value, array $options = []): string
+ {
+ return 'hashed:' . $value;
+ }
+
+ public function check($value, $hashedValue, array $options = []): bool
+ {
+ return 'hashed:' . $value === $hashedValue;
+ }
+
+ public function needsRehash($hashedValue, array $options = []): bool
+ {
+ return false;
+ }
+ });
+ Illuminate\Support\Facades\Hash::clearResolvedInstance('hash');
+
+ $schema = $connection->getSchemaBuilder();
+ $tables = [
+ 'drivers' => ['uuid', 'public_id', 'internal_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'vendor_uuid', 'current_job_uuid', 'auth_token', 'signup_token_used', 'avatar_url', 'drivers_license_number', 'license_expiry', 'location', 'heading', 'bearing', 'altitude', 'speed', 'currency', 'current_status', 'meta', 'location_updated_at', 'slug', 'status', 'country', 'city', 'online', '_key'],
+ 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'password', 'status', 'type', 'username', 'avatar_uuid', 'slug', 'timezone', 'country', 'ip_address', 'meta', '_key'],
+ 'companies' => ['uuid', 'public_id', 'name', 'owner_uuid', 'timezone', 'options', 'status', '_key'],
+ 'company_users' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'status', '_key'],
+ 'vehicles' => ['uuid', 'public_id', 'company_uuid', 'driver_uuid'],
+ 'custom_fields' => ['uuid', 'public_id', 'company_uuid', 'subject_uuid', 'subject_type', 'name', 'label'],
+ 'custom_field_values' => ['uuid', 'public_id', 'company_uuid', 'custom_field_uuid', 'subject_uuid', 'subject_type', 'value', 'value_type', '_key'],
+ 'settings' => ['key', 'value'],
+ 'permissions' => ['name', 'guard_name', 'service', 'description'],
+ 'roles' => ['uuid', 'public_id', 'name', 'guard_name', 'company_uuid', 'service', 'description', '_key'],
+ 'model_has_roles' => ['role_id', 'model_type', 'model_uuid'],
+ 'model_has_permissions' => ['permission_id', 'model_type', 'model_uuid'],
+ 'role_has_permissions' => ['permission_id', 'role_id'],
+ 'policies' => ['name', 'guard_name', 'company_uuid'],
+ 'directives' => ['uuid', 'public_id', 'company_uuid', 'permission_uuid', 'subject_type', 'subject_uuid', 'key', 'rules'],
+ 'files' => ['uuid', 'public_id', 'company_uuid', 'uploader_uuid', 'subject_uuid', 'subject_type', 'name', 'original_filename', 'extension', 'content_type', 'path', 'bucket', 'disk', 'size', 'type', 'meta', '_key'],
+ 'invites' => ['uuid', 'public_id', 'company_uuid', 'created_by_uuid', 'subject_uuid', 'subject_type', 'code', 'uri', 'protocol', 'recipients', 'reason', 'expires_at', '_key'],
+ 'notifications' => ['type', 'notifiable_type', 'notifiable_id', 'data', 'read_at'],
+ 'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'email', 'phone', 'type'],
+ 'personal_access_tokens' => ['tokenable_type', 'tokenable_id', 'name', 'token', 'abilities', 'last_used_at', 'expires_at'],
+ ];
+ foreach ($tables as $table => $columns) {
+ $schema->create($table, function ($blueprint) use ($columns) {
+ $blueprint->increments('id');
+ foreach ($columns as $column) {
+ $blueprint->string($column)->nullable();
+ }
+ $blueprint->timestamps();
+ $blueprint->timestamp('deleted_at')->nullable();
+ });
+ }
+
+ app()->instance('cache', new class {
+ public function tags($tags = null)
+ {
+ return $this;
+ }
+
+ public function flush()
+ {
+ return true;
+ }
+
+ public function remember($key, $ttl, $callback)
+ {
+ return $callback();
+ }
+
+ public function store($name = null)
+ {
+ return $this;
+ }
+
+ public function __call($method, $arguments)
+ {
+ return null;
+ }
+ });
+ Illuminate\Support\Facades\Cache::clearResolvedInstance('cache');
+ app()->instance(Illuminate\Contracts\Notifications\Dispatcher::class, new class {
+ public array $sent = [];
+
+ public function send($notifiables, $notification)
+ {
+ $this->sent[] = $notification;
+ }
+
+ public function sendNow($notifiables, $notification, ?array $channels = null)
+ {
+ $this->sent[] = $notification;
+ }
+
+ public function __call($method, $arguments)
+ {
+ return null;
+ }
+ });
+ config()->set('cache.default', 'array');
+ config()->set('cache.stores.array', ['driver' => 'array']);
+ config()->set('permission.cache.expiration_time', 60);
+ config()->set('permission.cache.key', 'spatie.permission.cache');
+ config()->set('permission.cache.store', 'default');
+ config()->set('permission.models.permission', Fleetbase\Models\Permission::class);
+ config()->set('permission.models.role', Fleetbase\Models\Role::class);
+ config()->set('permission.table_names', ['roles' => 'roles', 'permissions' => 'permissions', 'model_has_permissions' => 'model_has_permissions', 'model_has_roles' => 'model_has_roles', 'role_has_permissions' => 'role_has_permissions']);
+ config()->set('permission.column_names', ['role_pivot_key' => null, 'permission_pivot_key' => null, 'model_morph_key' => 'model_uuid', 'team_foreign_key' => 'team_id']);
+ config()->set('permission.teams', false);
+ config()->set('permission.events_enabled', false);
+ $cacheManager = new Illuminate\Cache\CacheManager(app());
+ app()->instance(Illuminate\Cache\CacheManager::class, $cacheManager);
+ app()->instance(Spatie\Permission\PermissionRegistrar::class, new Spatie\Permission\PermissionRegistrar($cacheManager));
+ config()->set('activitylog.enabled', false);
+ config()->set('activitylog.default_auth_driver', 'web');
+ config()->set('auth.defaults.guard', 'web');
+ config()->set('permission.models.role', Fleetbase\Models\Role::class);
+ app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config());
+
+ session(['company' => 'company-1']);
+ $connection->table('companies')->insert(['uuid' => 'company-1', 'public_id' => 'company_adopt1', 'name' => 'Acme']);
+ $connection->table('roles')->insert([
+ ['uuid' => 'role-1', 'name' => 'Driver', 'guard_name' => 'web', 'company_uuid' => 'company-1'],
+ ['uuid' => 'role-2', 'name' => 'Driver', 'guard_name' => 'sanctum', 'company_uuid' => 'company-1'],
+ ['uuid' => 'role-3', 'name' => 'Administrator', 'guard_name' => 'web', 'company_uuid' => 'company-1'],
+ ['uuid' => 'role-4', 'name' => 'Administrator', 'guard_name' => 'sanctum', 'company_uuid' => 'company-1'],
+ ]);
+
+ return $connection;
+}
+
+class FleetOpsDriverLoginSmsFake extends SmsService
+{
+ public array $sent = [];
+
+ public function __construct()
+ {
+ }
+
+ public function send(string $to, string $text, array $options = [], ?string $provider = null): array
+ {
+ $this->sent[] = [$to, $text];
+
+ return ['success' => true];
+ }
+}
+
+function fleetopsDriverLoginFixture(): SQLiteConnection
+{
+ $connection = fleetopsDriverLoginBoot([]);
+
+ Illuminate\Support\Facades\Mail::swap(new class {
+ public array $sent = [];
+
+ public function to($users)
+ {
+ return $this;
+ }
+
+ public function send($mailable)
+ {
+ $this->sent[] = $mailable;
+
+ return null;
+ }
+ });
+ app()->instance(SmsService::class, new FleetOpsDriverLoginSmsFake());
+
+ $users = [
+ ['uuid' => '11111111-1111-4111-8111-111111111111', 'public_id' => 'user_managed', 'company_uuid' => 'company-1', 'name' => 'Managed Driver', 'email' => 'managed@example.com', 'phone' => '+6590000001', 'password' => 'hashed:old', 'status' => 'pending', 'type' => 'driver'],
+ ['uuid' => '22222222-2222-4222-8222-222222222222', 'public_id' => 'user_staff', 'company_uuid' => 'company-1', 'name' => 'Staff Driver', 'email' => 'staff@example.com', 'phone' => '+6590000002', 'password' => 'hashed:staff', 'status' => 'active', 'type' => 'user'],
+ ['uuid' => '33333333-3333-4333-8333-333333333333', 'public_id' => 'user_nocontact', 'company_uuid' => 'company-1', 'name' => 'Unreachable', 'status' => 'active', 'type' => 'driver'],
+ ];
+ foreach ($users as $user) {
+ $connection->table('users')->insert($user);
+ }
+ $connection->table('company_users')->insert(['uuid' => 'cu-managed', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111', 'status' => 'pending']);
+ $drivers = [
+ ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', 'public_id' => 'driver_managed', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111'],
+ ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2', 'public_id' => 'driver_staff', 'company_uuid' => 'company-1', 'user_uuid' => '22222222-2222-4222-8222-222222222222'],
+ ['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa3', 'public_id' => 'driver_nocontact', 'company_uuid' => 'company-1', 'user_uuid' => '33333333-3333-4333-8333-333333333333'],
+ ];
+ foreach ($drivers as $driver) {
+ $connection->table('drivers')->insert($driver);
+ }
+
+ return $connection;
+}
+
+function fleetopsDriverLoginUser(SQLiteConnection $connection, string $uuid = '11111111-1111-4111-8111-111111111111'): object
+{
+ return $connection->table('users')->where('uuid', $uuid)->first();
+}
+
+test('send credentials sets a new password activates the login and emails it', function () {
+ $connection = fleetopsDriverLoginFixture();
+
+ $response = (new DriverController())->sendCredentials('driver_managed');
+ $data = $response->getData(true);
+ $user = fleetopsDriverLoginUser($connection);
+
+ expect($data['status'])->toBe('ok')
+ ->and($data['sent_via'])->toBe('email')
+ ->and($data['driver'])->toMatchArray([
+ 'id' => 'driver_managed',
+ 'uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1',
+ 'user_uuid' => '11111111-1111-4111-8111-111111111111',
+ 'is_staff_linked' => false,
+ 'login_status' => 'active',
+ ])
+ ->and($data['driver']['user'])->toMatchArray([
+ 'id' => 'user_managed',
+ 'email' => 'managed@example.com',
+ 'phone' => '+6590000001',
+ 'status' => 'active',
+ ])
+ ->and($user->status)->toBe('active')
+ ->and($user->password)->not->toBe('hashed:old')
+ ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent[0])->toBeInstanceOf(Fleetbase\FleetOps\Mail\DriverCredentialsMail::class);
+
+ // A driver with neither an email nor a phone can't be sent credentials
+ $unreachable = (new DriverController())->sendCredentials('driver_nocontact');
+ expect($unreachable->getData(true))->toBe(['error' => 'This profile has no email or phone to send credentials to.']);
+});
+
+test('login management endpoints report missing drivers accounts and staff-linked logins', function () {
+ $connection = fleetopsDriverLoginFixture();
+ // The driver scope keeps drivers whose account was deleted (the user
+ // relation includes trashed accounts), but such a driver has no login
+ $connection->table('users')->insert(['uuid' => '44444444-4444-4444-8444-444444444444', 'company_uuid' => 'company-1', 'type' => 'driver', 'deleted_at' => '2026-01-01 00:00:00']);
+ $connection->table('drivers')->insert(['uuid' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa4', 'public_id' => 'driver_orphan', 'company_uuid' => 'company-1', 'user_uuid' => '44444444-4444-4444-8444-444444444444']);
+ $controller = new DriverController();
+ $reset = fn (string $id) => $controller->resetCredentials(Request::create('/x', 'POST', ['password' => 'long-enough', 'password_confirmation' => 'long-enough']), $id);
+
+ foreach ([
+ fn (string $id) => $controller->sendCredentials($id),
+ $reset,
+ fn (string $id) => $controller->deactivateLogin($id),
+ fn (string $id) => $controller->reactivateLogin($id),
+ ] as $endpoint) {
+ $missing = $endpoint('driver_unknown');
+ $orphan = $endpoint('driver_orphan');
+ $staff = $endpoint('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2');
+
+ expect($missing->getStatusCode())->toBe(404)
+ ->and($missing->getData(true))->toBe(['error' => 'Driver not found.'])
+ ->and($orphan->getData(true))->toBe(['error' => 'This driver has no login account.'])
+ ->and($staff->getStatusCode())->toBe(422)
+ ->and($staff->getData(true))->toBe(['error' => 'This driver signs in with a team member account. Manage this login in IAM.']);
+ }
+
+ // The team member's account is never touched
+ $staffUser = fleetopsDriverLoginUser($connection, '22222222-2222-4222-8222-222222222222');
+ expect($staffUser->password)->toBe('hashed:staff')
+ ->and($staffUser->status)->toBe('active')
+ ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent)->toBe([]);
+});
+
+test('reset credentials validates the password and optionally sends it', function () {
+ $connection = fleetopsDriverLoginFixture();
+ $controller = new DriverController();
+ $request = fn (array $input) => Request::create('/x', 'POST', $input);
+
+ expect($controller->resetCredentials($request(['password' => 'short', 'password_confirmation' => 'short']), 'driver_managed')->getData(true))
+ ->toBe(['error' => 'Password must be at least 8 characters.'])
+ ->and($controller->resetCredentials($request([]), 'driver_managed')->getData(true))
+ ->toBe(['error' => 'Password must be at least 8 characters.'])
+ ->and($controller->resetCredentials($request(['password' => 'long-enough', 'password_confirmation' => 'different']), 'driver_managed')->getData(true))
+ ->toBe(['error' => 'Passwords do not match.']);
+
+ $quiet = $controller->resetCredentials($request(['password' => 'quiet-secret', 'password_confirmation' => 'quiet-secret']), 'driver_managed');
+ expect($quiet->getData(true)['status'])->toBe('ok')
+ ->and(fleetopsDriverLoginUser($connection)->password)->toBe('hashed:quiet-secret')
+ ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent)->toBe([]);
+
+ $sent = $controller->resetCredentials($request(['password' => 'mailed-secret', 'password_confirmation' => 'mailed-secret', 'send_credentials' => true]), 'driver_managed');
+ expect($sent->getData(true)['driver']['user_uuid'])->toBe('11111111-1111-4111-8111-111111111111')
+ ->and(fleetopsDriverLoginUser($connection)->password)->toBe('hashed:mailed-secret')
+ ->and(Illuminate\Support\Facades\Mail::getFacadeRoot()->sent)->toHaveCount(1);
+
+ // The password is still changed when there's nowhere to send it
+ $unsendable = $controller->resetCredentials($request(['password' => 'kept-secret', 'password_confirmation' => 'kept-secret', 'send_credentials' => '1']), 'driver_nocontact');
+ expect($unsendable->getData(true))->toBe(['error' => 'This profile has no email or phone to send credentials to.'])
+ ->and(fleetopsDriverLoginUser($connection, '33333333-3333-4333-8333-333333333333')->password)->toBe('hashed:kept-secret');
+});
+
+test('deactivating a login signs the driver out and reactivating restores it', function () {
+ $connection = fleetopsDriverLoginFixture();
+ $connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->update(['status' => 'active']);
+ $connection->table('personal_access_tokens')->insert([
+ 'tokenable_type' => Fleetbase\Models\User::class,
+ 'tokenable_id' => '11111111-1111-4111-8111-111111111111',
+ 'name' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1',
+ 'token' => 'token-hash',
+ ]);
+ $connection->table('personal_access_tokens')->insert([
+ 'tokenable_type' => Fleetbase\Models\User::class,
+ 'tokenable_id' => '22222222-2222-4222-8222-222222222222',
+ 'name' => 'staff-session',
+ 'token' => 'staff-token-hash',
+ ]);
+ $controller = new DriverController();
+
+ $deactivated = $controller->deactivateLogin('driver_managed')->getData(true);
+
+ expect($deactivated['status'])->toBe('ok')
+ ->and($deactivated['driver']['login_status'])->toBe('inactive')
+ ->and(fleetopsDriverLoginUser($connection)->status)->toBe('inactive')
+ ->and($connection->table('company_users')->where('uuid', 'cu-managed')->value('status'))->toBe('inactive')
+ // Only the driver's own tokens are revoked
+ ->and($connection->table('personal_access_tokens')->pluck('name')->all())->toBe(['staff-session']);
+
+ $reactivated = $controller->reactivateLogin('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1')->getData(true);
+
+ expect($reactivated['driver']['login_status'])->toBe('active')
+ ->and($reactivated['driver']['user']['session_status'])->toBe('active')
+ ->and(fleetopsDriverLoginUser($connection)->status)->toBe('active');
+});
+
+function fleetopsDriverLoginUpdateRequest(string $id, array $driver): Request
+{
+ $request = Request::create('/int/v1/drivers/' . $id, 'PUT', ['driver' => $driver]);
+ $store = app('session.store');
+ $store->put('company', 'company-1');
+ $request->setLaravelSession($store);
+ $request->setRouteResolver(fn () => new class($id) {
+ public function __construct(private string $id)
+ {
+ }
+
+ public function getAction($key = null)
+ {
+ return DriverController::class . '@updateRecord';
+ }
+
+ public function getActionMethod()
+ {
+ return 'updateRecord';
+ }
+
+ public function uri()
+ {
+ return 'int/v1/drivers/{id}';
+ }
+
+ public function getName()
+ {
+ return 'int.v1.drivers.update';
+ }
+
+ public function parameters()
+ {
+ return ['id' => $this->id];
+ }
+
+ public function parameter($key, $default = null)
+ {
+ return $key === 'id' ? $this->id : $default;
+ }
+ });
+ app()->instance('request', $request);
+
+ return $request;
+}
+
+test('update record syncs proxy fields to a managed account and only the name to a team member', function () {
+ $connection = fleetopsDriverLoginFixture();
+ $controller = new DriverController();
+
+ $managed = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', [
+ 'name' => 'Renamed Driver',
+ 'email' => 'Renamed@Example.com',
+ 'phone' => null,
+ 'photo_uuid' => 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
+ 'user_uuid' => '22222222-2222-4222-8222-222222222222',
+ ]), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1');
+
+ $user = fleetopsDriverLoginUser($connection);
+
+ expect($managed)->toBeArray()
+ ->and($managed['driver']->resource->user_uuid)->toBe('11111111-1111-4111-8111-111111111111')
+ ->and($user->name)->toBe('Renamed Driver')
+ ->and($user->email)->toBe('renamed@example.com')
+ ->and($user->phone)->toBeNull()
+ ->and($user->avatar_uuid)->toBe('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb');
+
+ $staff = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2', [
+ 'name' => 'Staff As Driver',
+ 'email' => 'changed@example.com',
+ ]), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa2');
+
+ $staffUser = fleetopsDriverLoginUser($connection, '22222222-2222-4222-8222-222222222222');
+
+ expect($staff)->toBeArray()
+ ->and($staffUser->name)->toBe('Staff As Driver')
+ ->and($staffUser->email)->toBe('staff@example.com');
+
+ // Taking another account's email is refused
+ $conflict = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', [
+ 'email' => 'staff@example.com',
+ ]), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1');
+
+ expect($conflict->getStatusCode())->toBe(422)
+ ->and($conflict->getData(true))->toBe(['error' => 'This email is already in use by another account.'])
+ ->and(fleetopsDriverLoginUser($connection)->email)->toBe('renamed@example.com');
+});
+
+function fleetopsDriverLoginLookup(array $input): array
+{
+ return (new FleetOpsLookupController())->profileIdentity(Request::create('/int/v1/fleet-ops/lookup/profile-identity', 'GET', $input))->getData(true);
+}
+
+test('profile identity lookup reports the team member to link or the conflict', function () {
+ $connection = fleetopsDriverLoginFixture();
+ $connection->table('users')->insert(['uuid' => '55555555-5555-4555-8555-555555555555', 'company_uuid' => 'company-1', 'name' => 'Office Staff', 'email' => 'office@example.com', 'phone' => '+6590000005', 'type' => 'admin']);
+ $connection->table('users')->insert(['uuid' => '66666666-6666-4666-8666-666666666666', 'company_uuid' => 'company-1', 'name' => 'Customer', 'email' => 'customer@example.com', 'type' => 'customer']);
+ $connection->table('contacts')->insert(['uuid' => 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', 'public_id' => 'contact_customer', 'company_uuid' => 'company-1', 'user_uuid' => '66666666-6666-4666-8666-666666666666', 'type' => 'customer']);
+ $connection->table('contacts')->insert(['uuid' => 'cccccccc-cccc-4ccc-8ccc-ccccccccccc2', 'public_id' => 'contact_nouser', 'company_uuid' => 'company-1', 'type' => 'contact']);
+
+ expect((new FleetOpsLookupController())->profileIdentity(Request::create('/x', 'GET', ['type' => 'user']))->getStatusCode())->toBe(422)
+ ->and(fleetopsDriverLoginLookup(['type' => 'staff']))->toBe(['error' => 'Invalid profile type.'])
+ // Nothing entered, nothing found
+ ->and(fleetopsDriverLoginLookup([]))->toBe(['staff' => null, 'conflict' => null])
+ // A new driver with a team member's email links to them
+ ->and(fleetopsDriverLoginLookup(['email' => 'office@example.com']))->toBe([
+ 'staff' => ['uuid' => '55555555-5555-4555-8555-555555555555', 'name' => 'Office Staff', 'email' => 'office@example.com', 'phone' => '+6590000005'],
+ 'conflict' => null,
+ ])
+ // A new driver can't take a customer's email
+ ->and(fleetopsDriverLoginLookup(['type' => 'driver', 'email' => 'customer@example.com']))->toBe(['staff' => null, 'conflict' => 'This email is already used by a customer.'])
+ // An existing driver's own email isn't taken, another account's is
+ ->and(fleetopsDriverLoginLookup(['ignore' => 'driver_managed', 'email' => 'managed@example.com']))->toBe(['staff' => null, 'conflict' => null])
+ ->and(fleetopsDriverLoginLookup(['ignore' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', 'phone' => '+6590000005']))->toBe(['staff' => null, 'conflict' => 'This phone number is already in use by another account.'])
+ // Customer profiles are looked up among contacts
+ ->and(fleetopsDriverLoginLookup(['type' => 'customer', 'ignore' => 'contact_customer', 'email' => 'customer@example.com']))->toBe(['staff' => null, 'conflict' => null])
+ // A profile without an account, or an unknown profile id, is treated as a new profile
+ ->and(fleetopsDriverLoginLookup(['type' => 'contact', 'ignore' => 'contact_nouser', 'email' => 'customer@example.com']))->toBe(['staff' => null, 'conflict' => 'This email is already used by a customer.'])
+ ->and(fleetopsDriverLoginLookup(['type' => 'contact', 'ignore' => 'contact_unknown', 'email' => 'customer@example.com']))->toBe(['staff' => null, 'conflict' => 'This email is already used by a customer.']);
+});
+
+test('update record syncs custom field values and reports failures', function () {
+ $connection = fleetopsDriverLoginFixture();
+ $connection->table('custom_fields')->insert(['uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'company_uuid' => 'company-1', 'name' => 'badge', 'label' => 'Badge']);
+ $controller = new DriverController();
+
+ $updated = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', [
+ 'custom_field_values' => [['custom_field_uuid' => 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', 'value' => 'B-12']],
+ ]), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1');
+
+ expect($updated)->toBeArray()
+ ->and($connection->table('custom_field_values')->where('subject_uuid', 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1')->value('value'))->toBe('B-12');
+
+ // An unknown driver surfaces the model's not-found error
+ $missing = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa9', ['name' => 'Ghost']), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa9');
+ expect($missing->getData(true)['error'])->toContain('not found');
+
+ // A request validation failure raised while updating the account is returned
+ $GLOBALS['fleetopsDriverLoginHashException'] = new Fleetbase\Exceptions\FleetbaseRequestValidationException(['password' => ['The password is too weak.']]);
+ app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
+ public function info($hashedValue): array
+ {
+ return [];
+ }
+
+ public function make($value, array $options = []): string
+ {
+ throw $GLOBALS['fleetopsDriverLoginHashException'];
+ }
+
+ public function check($value, $hashedValue, array $options = []): bool
+ {
+ return false;
+ }
+
+ public function needsRehash($hashedValue, array $options = []): bool
+ {
+ return false;
+ }
+ });
+ Illuminate\Support\Facades\Hash::clearResolvedInstance('hash');
+
+ $weak = $controller->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', ['password' => 'weak-password']), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1');
+ expect($weak->getData(true))->toBe(['error' => ['password' => ['The password is too weak.']]]);
+});
+
+test('update record returns validation failures', function () {
+ fleetopsDriverLoginFixture();
+ $GLOBALS['fleetopsDriverLoginErrors'] = ['email' => ['The email must be a valid email address.']];
+
+ $failure = null;
+ try {
+ (new DriverController())->updateRecord(fleetopsDriverLoginUpdateRequest('aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1', ['email' => 'not-an-email']), 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1');
+ } catch (Illuminate\Validation\ValidationException $exception) {
+ $failure = $exception;
+ }
+
+ expect($failure?->errors())->toBe(['email' => ['The email must be a valid email address.']]);
+});
diff --git a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php
index 400e68b11..6c815da8a 100644
--- a/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php
+++ b/server/tests/Feature/Http/Internal/OrderControllerContractsTest.php
@@ -298,7 +298,7 @@ protected function findOrderByUuid(string $uuid): ?Order
return $this->order;
}
- protected function findOrderById(string $id, array $with = []): ?Order
+ protected function findOrderById(mixed $id, array $with = []): ?Order
{
$this->order?->setAttribute('lookup_id', $id);
$this->order?->setAttribute('lookup_with', $with);
@@ -990,10 +990,15 @@ function fleetopsSuppressStrNullDeprecations(): Closure
});
test('internal order controller bulk assign driver deduplicates orders and queues notifications', function () {
- $controller = fleetopsInternalOrderLifecycleController();
$driverUuid = '11111111-1111-4111-8111-111111111111';
$orderA = '22222222-2222-4222-8222-222222222222';
$orderB = '33333333-3333-4333-8333-333333333333';
+ // The ids are resolved through the company-scoped `ordersByUuid()` lookup, so
+ // only orders it returns are assigned, counted and notified.
+ $controller = fleetopsInternalOrderLifecycleController([
+ fleetopsInternalOrderLifecycleOrder($orderA),
+ fleetopsInternalOrderLifecycleOrder($orderB),
+ ]);
$response = $controller->bulkAssignDriver(fleetopsBulkActionRequest([
'ids' => [$orderA, $orderA, $orderB],
@@ -1011,7 +1016,9 @@ function fleetopsSuppressStrNullDeprecations(): Closure
->and($controller->assignedDriverUuid)->toBe($driverUuid)
->and($controller->bulkNotification)->toBe([[$orderA, $orderB], $driverUuid]);
- $controller = fleetopsInternalOrderLifecycleController();
+ $controller = fleetopsInternalOrderLifecycleController([
+ fleetopsInternalOrderLifecycleOrder($orderA),
+ ]);
$controller->bulkAssignDriver(fleetopsBulkActionRequest([
'ids' => [$orderA],
'driver' => $driverUuid,
diff --git a/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php b/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php
index 48d7fa42a..cd4cba0b2 100644
--- a/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php
+++ b/server/tests/Feature/Http/Internal/OrderControllerImportFromFilesTest.php
@@ -177,3 +177,21 @@ function fleetopsOrderImportFilesRequest(array $input): Request
]));
expect($unreadable->getStatusCode())->toBeGreaterThanOrEqual(400);
});
+
+test('import ignores files uploaded by another company', function () {
+ $connection = fleetopsOrderImportFilesBoot();
+ $connection->table('files')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'public_id' => 'file_ordimport4', 'company_uuid' => 'company-2', 'path' => 'uploads/orders.xlsx', 'disk' => 'local']);
+ fleetopsOrderImportFilesExcelFake([[
+ ['name' => 'Victim Stop', 'street1' => 'Victim Rd 1', 'city' => 'Singapore'],
+ ]]);
+
+ // The file uuid is caller-supplied and nothing upstream checks who owns it,
+ // so an unscoped lookup here would read a rival company's spreadsheet.
+ $response = (new OrderController())->importFromFiles(fleetopsOrderImportFilesRequest([
+ 'files' => ['33333333-3333-4333-8333-333333333333'],
+ ]));
+
+ $data = $response->getData(true);
+ expect($data['places'])->toHaveCount(0)
+ ->and($data['entities'])->toHaveCount(0);
+});
diff --git a/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php b/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php
new file mode 100644
index 000000000..0b639c61f
--- /dev/null
+++ b/server/tests/Feature/Http/Internal/OrderControllerTenantScopingTest.php
@@ -0,0 +1,411 @@
+{$method}(...$arguments);
+ }
+}
+
+const FLEETOPS_TENANT_OWN_ORDER = '55555555-5555-4555-8555-555555555501';
+const FLEETOPS_TENANT_VICTIM_ORDER = '55555555-5555-4555-8555-555555555502';
+const FLEETOPS_TENANT_OWN_DRIVER = '55555555-5555-4555-8555-555555555511';
+const FLEETOPS_TENANT_VICTIM_DRIVER = '55555555-5555-4555-8555-555555555512';
+
+function fleetopsOrderTenantBoot(): SQLiteConnection
+{
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ EloquentModel::setConnectionResolver($resolver);
+ app()->instance('db', new class($connection) {
+ public function __construct(public SQLiteConnection $c)
+ {
+ }
+
+ public function connection($name = null): SQLiteConnection
+ {
+ return $this->c;
+ }
+
+ public function __call($method, $arguments)
+ {
+ return $this->c->{$method}(...$arguments);
+ }
+ });
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
+
+ $schema = $connection->getSchemaBuilder();
+ $tables = [
+ 'orders' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'order_config_uuid', 'tracking_number_uuid', 'driver_assigned_uuid', 'status', 'type', 'adhoc', 'dispatched', 'started', 'scheduled_at', 'meta', '_key'],
+ 'payloads' => ['uuid', 'public_id', 'company_uuid', 'pickup_uuid', 'dropoff_uuid', 'current_waypoint_uuid', 'meta', 'type'],
+ 'places' => ['uuid', 'public_id', 'company_uuid', 'name', 'location'],
+ 'waypoints' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'place_uuid', 'tracking_number_uuid', 'order', 'type'],
+ 'entities' => ['uuid', 'public_id', 'company_uuid', 'payload_uuid', 'destination_uuid', 'tracking_number_uuid', 'name', 'type'],
+ 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'vehicle_uuid', 'status', 'online', 'location', 'current_job_uuid'],
+ 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'status', 'type'],
+ 'tracking_numbers' => ['uuid', 'public_id', 'company_uuid', 'tracking_number', 'owner_uuid', 'owner_type', '_key'],
+ 'tracking_statuses' => ['uuid', 'public_id', 'company_uuid', 'tracking_number_uuid', 'status', 'details', 'code', '_key'],
+ 'proofs' => ['uuid', 'public_id', 'company_uuid', 'order_uuid', 'subject_uuid', 'subject_type', 'file_uuid', 'remarks', 'raw_data', 'data'],
+ 'companies' => ['uuid', 'public_id', 'name', 'country'],
+ ];
+ foreach ($tables as $table => $columns) {
+ $schema->create($table, function ($blueprint) use ($columns) {
+ $blueprint->increments('id');
+ foreach ($columns as $column) {
+ $blueprint->string($column)->nullable();
+ }
+ $blueprint->timestamps();
+ $blueprint->timestamp('deleted_at')->nullable();
+ });
+ }
+
+ session(['company' => 'company-1']);
+
+ return $connection;
+}
+
+/**
+ * Seeds one order/driver/payload per tenant. The two tenants' records are
+ * identical apart from `company_uuid`, so any lookup that resolves the
+ * `company-2` identifier is crossing the tenant boundary.
+ */
+function fleetopsOrderTenantSeed(SQLiteConnection $connection): void
+{
+ $connection->table('users')->insert([
+ ['uuid' => 'user-own', 'company_uuid' => 'company-1', 'name' => 'Own Driver'],
+ ['uuid' => 'user-victim', 'company_uuid' => 'company-2', 'name' => 'Victim Driver'],
+ ]);
+ $connection->table('drivers')->insert([
+ ['uuid' => FLEETOPS_TENANT_OWN_DRIVER, 'public_id' => 'driver_own1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-own'],
+ ['uuid' => FLEETOPS_TENANT_VICTIM_DRIVER, 'public_id' => 'driver_victim1', 'company_uuid' => 'company-2', 'user_uuid' => 'user-victim'],
+ ]);
+ $connection->table('payloads')->insert([
+ ['uuid' => 'payload-own', 'company_uuid' => 'company-1'],
+ ['uuid' => 'payload-victim', 'company_uuid' => 'company-2'],
+ ]);
+ $connection->table('tracking_numbers')->insert([
+ ['uuid' => 'tn-own', 'company_uuid' => 'company-1', 'tracking_number' => 'FLB-OWN-1'],
+ ['uuid' => 'tn-victim', 'company_uuid' => 'company-2', 'tracking_number' => 'FLB-VICTIM-1'],
+ ]);
+ $connection->table('orders')->insert([
+ [
+ 'uuid' => FLEETOPS_TENANT_OWN_ORDER,
+ 'public_id' => 'order_own1',
+ 'company_uuid' => 'company-1',
+ 'payload_uuid' => 'payload-own',
+ 'tracking_number_uuid' => 'tn-own',
+ 'driver_assigned_uuid' => FLEETOPS_TENANT_OWN_DRIVER,
+ 'status' => 'created',
+ 'type' => 'transport',
+ ],
+ [
+ 'uuid' => FLEETOPS_TENANT_VICTIM_ORDER,
+ 'public_id' => 'order_victim1',
+ 'company_uuid' => 'company-2',
+ 'payload_uuid' => 'payload-victim',
+ 'tracking_number_uuid' => 'tn-victim',
+ 'driver_assigned_uuid' => FLEETOPS_TENANT_VICTIM_DRIVER,
+ 'status' => 'created',
+ 'type' => 'transport',
+ ],
+ ]);
+ $connection->table('entities')->insert([
+ ['uuid' => 'entity-own', 'company_uuid' => 'company-1', 'payload_uuid' => 'payload-own', 'name' => 'Own Parcel'],
+ ['uuid' => 'entity-victim', 'company_uuid' => 'company-2', 'payload_uuid' => 'payload-victim', 'name' => 'Victim Parcel'],
+ ]);
+ $connection->table('proofs')->insert([
+ ['uuid' => 'proof-own', 'public_id' => 'proof_own1', 'company_uuid' => 'company-1', 'order_uuid' => FLEETOPS_TENANT_OWN_ORDER, 'subject_uuid' => FLEETOPS_TENANT_OWN_ORDER],
+ ['uuid' => 'proof-victim', 'public_id' => 'proof_victim1', 'company_uuid' => 'company-2', 'order_uuid' => FLEETOPS_TENANT_VICTIM_ORDER, 'subject_uuid' => FLEETOPS_TENANT_VICTIM_ORDER],
+ ]);
+}
+
+test('order lookups resolve the callers own records', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
+
+ expect($probe->callHelper('ordersByUuid', [FLEETOPS_TENANT_OWN_ORDER])->pluck('uuid')->all())->toBe([FLEETOPS_TENANT_OWN_ORDER])
+ ->and($probe->callHelper('findOrderByUuid', FLEETOPS_TENANT_OWN_ORDER)?->public_id)->toBe('order_own1')
+ ->and($probe->callHelper('findOrderById', FLEETOPS_TENANT_OWN_ORDER)?->public_id)->toBe('order_own1')
+ ->and($probe->callHelper('findOrderById', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
+ ->and($probe->callHelper('findOrderRouteForEdit', FLEETOPS_TENANT_OWN_ORDER)?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
+ ->and($probe->callHelper('findOrderForStart', FLEETOPS_TENANT_OWN_ORDER)?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
+ ->and($probe->callHelper('findPayloadForStart', 'payload-own')?->uuid)->toBe('payload-own')
+ ->and($probe->callHelper('findOrderForSchedule', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
+ ->and($probe->callHelper('findOrderForProofs', FLEETOPS_TENANT_OWN_ORDER)?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
+ ->and($probe->callHelper('findOrderForDriverPing', 'order_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
+ ->and($probe->callHelper('findEntityProofSubject', 'entity-own')?->uuid)->toBe('entity-own')
+ ->and($probe->callHelper('resolveProof', 'proof_own1')?->uuid)->toBe('proof-own')
+ ->and($probe->callHelper('resolveProof', 'proof-own')?->uuid)->toBe('proof-own');
+});
+
+test('driver lookups resolve the callers own drivers', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
+
+ expect($probe->callHelper('findDriverByUuid', FLEETOPS_TENANT_OWN_DRIVER)?->public_id)->toBe('driver_own1')
+ ->and($probe->callHelper('findDriverForStart', FLEETOPS_TENANT_OWN_DRIVER)?->public_id)->toBe('driver_own1')
+ ->and($probe->callHelper('findDriverForSchedule', FLEETOPS_TENANT_OWN_DRIVER)?->public_id)->toBe('driver_own1')
+ ->and($probe->callHelper('findDriverForSchedule', 'driver_own1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_DRIVER);
+});
+
+test('order lookups refuse identifiers belonging to another company', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
+
+ // Both identifier arms are covered: an unguarded
+ // `where(uuid)->orWhere(public_id)->where(company_uuid)` chain would read as
+ // `uuid = ? OR (public_id = ? AND company_uuid = ?)` and still resolve the
+ // victim by uuid.
+ expect($probe->callHelper('ordersByUuid', [FLEETOPS_TENANT_VICTIM_ORDER]))->toHaveCount(0)
+ ->and($probe->callHelper('findOrderByUuid', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull()
+ ->and($probe->callHelper('findOrderById', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull()
+ ->and($probe->callHelper('findOrderById', 'order_victim1'))->toBeNull()
+ ->and($probe->callHelper('findOrderRouteForEdit', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull()
+ ->and($probe->callHelper('findOrderForStart', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull()
+ ->and($probe->callHelper('findPayloadForStart', 'payload-victim'))->toBeNull()
+ ->and($probe->callHelper('findOrderForSchedule', 'order_victim1'))->toBeNull()
+ ->and($probe->callHelper('findOrderForProofs', FLEETOPS_TENANT_VICTIM_ORDER))->toBeNull()
+ ->and($probe->callHelper('findEntityProofSubject', 'entity-victim'))->toBeNull()
+ ->and($probe->callHelper('resolveProof', 'proof_victim1'))->toBeNull()
+ ->and($probe->callHelper('resolveProof', 'proof-victim'))->toBeNull()
+ ->and($probe->callHelper('findDriverByUuid', FLEETOPS_TENANT_VICTIM_DRIVER))->toBeNull()
+ ->and($probe->callHelper('findDriverForStart', FLEETOPS_TENANT_VICTIM_DRIVER))->toBeNull()
+ ->and($probe->callHelper('findDriverForSchedule', FLEETOPS_TENANT_VICTIM_DRIVER))->toBeNull()
+ ->and($probe->callHelper('findDriverForSchedule', 'driver_victim1'))->toBeNull();
+
+ // The ping lookup reports a miss the same way it reports an unknown id, so
+ // the endpoint cannot be used to probe which ids exist in other tenants.
+ expect(fn () => $probe->callHelper('findOrderForDriverPing', 'order_victim1'))
+ ->toThrow(ModelNotFoundException::class);
+});
+
+test('the public tracking lookup finds an order by its tracking number alone', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
+
+ // `fleet-ops/lookup` backs the public Track Order page: recipients have no session
+ // and the tracking number is the credential. It must work without a company, and
+ // for whichever company owns the order.
+ session(['company' => null]);
+
+ expect($probe->callHelper('findOrderByTrackingNumber', 'FLB-OWN-1')?->uuid)->toBe(FLEETOPS_TENANT_OWN_ORDER)
+ ->and($probe->callHelper('findOrderByTrackingNumber', 'FLB-VICTIM-1')?->uuid)->toBe(FLEETOPS_TENANT_VICTIM_ORDER)
+ ->and($probe->callHelper('findOrderByTrackingNumber', 'FLB-UNKNOWN'))->toBeNull();
+
+ session(['company' => 'company-1']);
+
+ expect($probe->callHelper('findOrderByTrackingNumber', 'FLB-VICTIM-1')?->uuid)->toBe(FLEETOPS_TENANT_VICTIM_ORDER);
+});
+
+test('order lookups fail closed when no company session is present', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+ session(['company' => null]);
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
+
+ expect($probe->callHelper('ordersByUuid', [FLEETOPS_TENANT_OWN_ORDER, FLEETOPS_TENANT_VICTIM_ORDER]))->toHaveCount(0)
+ ->and($probe->callHelper('findOrderByUuid', FLEETOPS_TENANT_OWN_ORDER))->toBeNull()
+ ->and($probe->callHelper('findOrderById', 'order_own1'))->toBeNull()
+ ->and($probe->callHelper('findDriverByUuid', FLEETOPS_TENANT_OWN_DRIVER))->toBeNull()
+ ->and($probe->callHelper('findDriverForSchedule', 'driver_own1'))->toBeNull();
+});
+
+test('order resolution by id rejects empty identifiers without querying', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
+
+ // The identifier is raw request input, so a non-string body value has to
+ // resolve to no order rather than raising out of the endpoint.
+ expect($probe->callHelper('findOrderById', null))->toBeNull()
+ ->and($probe->callHelper('findOrderById', ''))->toBeNull()
+ ->and($probe->callHelper('findOrderById', ['uuid' => FLEETOPS_TENANT_OWN_ORDER]))->toBeNull()
+ ->and($probe->callHelper('findOrderById', 42))->toBeNull()
+ ->and($probe->callHelper('findOrderForSchedule', null))->toBeNull();
+});
+
+test('bulk driver assignment only touches orders the caller owns', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+ $connection->table('orders')->update(['driver_assigned_uuid' => null]);
+ $controller = new OrderController();
+
+ $response = $controller->bulkAssignDriver(Request::create('/x', 'PATCH', [
+ 'ids' => [FLEETOPS_TENANT_OWN_ORDER, FLEETOPS_TENANT_VICTIM_ORDER],
+ 'driver' => FLEETOPS_TENANT_OWN_DRIVER,
+ 'silent' => true,
+ ]));
+
+ // The victim order is dropped before the update, so it is neither
+ // reassigned nor counted in the response.
+ expect($response->getData(true)['count'])->toBe(1)
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->value('driver_assigned_uuid'))->toBe(FLEETOPS_TENANT_OWN_DRIVER)
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('driver_assigned_uuid'))->toBeNull();
+});
+
+test('bulk driver assignment refuses a driver from another company', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+ $controller = new OrderController();
+
+ $response = $controller->bulkAssignDriver(Request::create('/x', 'PATCH', [
+ 'ids' => [FLEETOPS_TENANT_OWN_ORDER],
+ 'driver' => FLEETOPS_TENANT_VICTIM_DRIVER,
+ 'silent' => true,
+ ]));
+
+ expect($response->getData(true)['error'] ?? '')->toContain('Invalid driver selected');
+});
+
+test('the bulk assignment update is itself scoped to the callers company', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+ $connection->table('orders')->update(['driver_assigned_uuid' => null]);
+ $probe = new FleetOpsInternalOrderTenantScopeProbe();
+ $driver = Driver::where('uuid', FLEETOPS_TENANT_OWN_DRIVER)->first();
+
+ // Called directly with a victim uuid, standing in for any future caller
+ // that reaches this seam without pre-filtering the ids.
+ $probe->callHelper('assignDriverToOrders', [FLEETOPS_TENANT_OWN_ORDER, FLEETOPS_TENANT_VICTIM_ORDER], $driver);
+
+ expect($connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->value('driver_assigned_uuid'))->toBe(FLEETOPS_TENANT_OWN_DRIVER)
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('driver_assigned_uuid'))->toBeNull();
+});
+
+test('bulk cancel and bulk dispatch skip orders from another company', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+ $controller = new OrderController();
+
+ $canceled = $controller->bulkCancel(Request::create('/x', 'PATCH', [
+ 'ids' => [FLEETOPS_TENANT_VICTIM_ORDER],
+ ]));
+ expect($canceled->getData(true)['count'])->toBe(0)
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('status'))->toBe('created');
+
+ $dispatched = $controller->bulkDispatch(BulkDispatchRequest::create('/x', 'POST', [
+ 'ids' => [FLEETOPS_TENANT_VICTIM_ORDER],
+ ]));
+ expect($dispatched->getData(true)['count'])->toBe(0)
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('dispatched'))->toBeNull();
+});
+
+test('cancel rejects a known order uuid that belongs to another company', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+
+ // `exists:orders,uuid` on CancelOrderRequest is a global existence check, so
+ // this uuid passes validation and the controller itself has to refuse it.
+ $response = (new OrderController())->cancel(
+ CancelOrderRequest::create('/x', 'PATCH', ['order' => FLEETOPS_TENANT_VICTIM_ORDER])
+ );
+
+ expect($response->getData(true)['error'] ?? '')->toContain('No order found to cancel')
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('status'))->toBe('created');
+});
+
+test('dispatch start and schedule refuse another companys order', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+ $controller = new OrderController();
+
+ $dispatched = $controller->dispatchOrder(Request::create('/x', 'PATCH', ['order' => FLEETOPS_TENANT_VICTIM_ORDER]));
+ expect($dispatched->getData(true)['error'] ?? '')->toContain('No order found to dispatch');
+
+ $started = $controller->start(Request::create('/x', 'PATCH', ['order' => FLEETOPS_TENANT_VICTIM_ORDER]));
+ expect($started->getData(true)['error'] ?? '')->toContain('Unable to find order to start');
+
+ $scheduled = $controller->scheduleOrder(Request::create('/x', 'PATCH', [
+ 'order' => FLEETOPS_TENANT_VICTIM_ORDER,
+ 'scheduled_at' => '2026-01-01 09:00:00',
+ ]));
+ expect($scheduled->getData(true)['error'] ?? '')->toContain('No order found to schedule')
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_VICTIM_ORDER)->value('scheduled_at'))->toBeNull()
+ ->and($connection->table('drivers')->where('uuid', FLEETOPS_TENANT_VICTIM_DRIVER)->value('current_job_uuid'))->toBeNull();
+});
+
+test('schedule ignores a driver from another company', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+ $connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->update(['driver_assigned_uuid' => null]);
+
+ $response = (new OrderController())->scheduleOrder(Request::create('/x', 'PATCH', [
+ 'order' => FLEETOPS_TENANT_OWN_ORDER,
+ 'driver_id' => 'driver_victim1',
+ ]));
+
+ expect($response->getData(true)['status'])->toBe('OK')
+ ->and($connection->table('orders')->where('uuid', FLEETOPS_TENANT_OWN_ORDER)->value('driver_assigned_uuid'))->toBeNull();
+});
+
+test('activity destination and next-activity endpoints refuse another companys order', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+ $controller = new OrderController();
+
+ $nextActivity = $controller->nextActivity('order_victim1', Request::create('/x', 'GET'));
+ expect($nextActivity->getData(true))->toBe(['error' => 'No order found.']);
+
+ $updateActivity = $controller->updateActivity(FLEETOPS_TENANT_VICTIM_ORDER, Request::create('/x', 'PATCH', ['activity' => []]));
+ expect($updateActivity->getData(true))->toBe(['error' => 'No order found.']);
+
+ $setDestination = $controller->setDestination(FLEETOPS_TENANT_VICTIM_ORDER, 'place-victim');
+ expect($setDestination->getData(true))->toBe(['error' => 'No order found.']);
+
+ $trackerInfo = $controller->trackerInfo(Request::create('/x', 'GET'), FLEETOPS_TENANT_VICTIM_ORDER);
+ expect($trackerInfo->getData(true))->toBe(['error' => 'No order found.']);
+
+ $waypointEtas = $controller->waypointEtas(Request::create('/x', 'GET'), FLEETOPS_TENANT_VICTIM_ORDER);
+ expect($waypointEtas->getData(true))->toBe(['error' => 'No order found.']);
+
+ $editRoute = $controller->editOrderRoute(FLEETOPS_TENANT_VICTIM_ORDER, Request::create('/x', 'PATCH'));
+ expect($editRoute->getData(true)['error'] ?? '')->toContain('Unable to find order to update route for');
+});
+
+test('proofs endpoint refuses another companys order', function () {
+ $connection = fleetopsOrderTenantBoot();
+ fleetopsOrderTenantSeed($connection);
+
+ $response = (new OrderController())->proofs(Request::create('/x', 'GET'), FLEETOPS_TENANT_VICTIM_ORDER);
+
+ expect($response->getData(true)['error'] ?? '')->toContain('Unable to retrieve proof');
+});
diff --git a/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php b/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php
index deed7491c..cd0cc3069 100644
--- a/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php
+++ b/server/tests/Feature/Http/Internal/OrderControllerUpstreamNotFoundTest.php
@@ -6,27 +6,15 @@
/**
* Covers the not-found branch of Internal\v1\OrderController::nextActivity().
*
- * ---------------------------------------------------------------------------
- * THIS FILE IS EXPECTED TO FAIL until fleetbase/core-api 1.6.55 is released and
- * pulled into server_vendor. Do not "fix" it by weakening the assertion.
- * ---------------------------------------------------------------------------
+ * This branch used to depend on upstream behaviour that never fired: the
+ * controller wrapped `Order::findByIdOrFail($id)` in a
+ * `catch (ModelNotFoundException)`, but core-api's findByIdOrFail() raised a
+ * BadMethodCallException that escaped the catch and surfaced as a 500.
*
- * The controller wraps `Order::findByIdOrFail($id)` in a
- * `catch (ModelNotFoundException)` that returns 'No order found.'. Today that
- * catch never fires: core-api's Model::findByIdOrFail() calls a
- * getModelNotFoundException() method that does not exist on Eloquent's builder,
- * so a missing order raises BadMethodCallException, escapes the catch, and
- * surfaces as a 500 instead of the intended error response.
- *
- * core-api#231 (branch dev-v1.6.55) replaces that with
- * `throw (new ModelNotFoundException())->setModel(static::class, [$identifier]);`
- * which makes this branch live. The assertion below states the post-fix
- * contract deliberately — asserting today's BadMethodCallException would
- * codify the defect instead of the intent.
- *
- * If CI must be green before that release lands, neutralise this file with a
- * single `->skip('pending fleetbase/core-api 1.6.55')` on the test below rather
- * than changing what it asserts.
+ * nextActivity() now resolves the order through the controller's own
+ * company-scoped `findOrderById()` and returns the error response on a null
+ * result, so the branch is live here regardless of the upstream release: an
+ * unknown id and an id belonging to another company are reported identically.
*/
function fleetopsUpstreamNotFoundBoot(): Illuminate\Database\SQLiteConnection
{
diff --git a/server/tests/InternalCustomerControllerContractsTest.php b/server/tests/InternalCustomerControllerContractsTest.php
index c958b07f2..59d370c1f 100644
--- a/server/tests/InternalCustomerControllerContractsTest.php
+++ b/server/tests/InternalCustomerControllerContractsTest.php
@@ -122,11 +122,12 @@ function fleetopsInternalCustomer(): FleetOpsInternalCustomerContactFake
return $customer;
}
-function fleetopsInternalCustomerUser(string $status = 'active'): FleetOpsInternalCustomerUserFake
+function fleetopsInternalCustomerUser(string $status = 'active', string $type = 'customer'): FleetOpsInternalCustomerUserFake
{
$user = new FleetOpsInternalCustomerUserFake();
$user->setRawAttributes([
'uuid' => 'user-uuid',
+ 'type' => $type,
'public_id' => 'user_public',
'name' => 'Ada Customer',
'email' => 'ada@example.test',
@@ -236,3 +237,33 @@ function fleetopsInternalCustomerJson(mixed $response): array
'password_confirmation' => 'one',
])))['error'])->toBe('Unable to reset customer credentials');
});
+
+test('internal customer controller refuses login management for staff-linked customers', function () {
+ $staff = fleetopsInternalCustomerUser('active', 'user');
+ $controller = fleetopsInternalCustomerController($staff);
+ $message = 'This customer signs in with a team member account. Manage this login in IAM.';
+ $request = new Request(['customer' => 'customer_public']);
+
+ $responses = [
+ $controller->createPortalLogin($request),
+ $controller->sendCredentials($request),
+ $controller->deactivatePortalLogin($request),
+ $controller->reactivatePortalLogin($request),
+ $controller->resetCredentials(new Request([
+ 'customer' => 'customer_public',
+ 'password' => 'chosen-secret',
+ 'password_confirmation' => 'chosen-secret',
+ ])),
+ ];
+
+ foreach ($responses as $response) {
+ expect($response->getStatusCode())->toBe(422)
+ ->and(fleetopsInternalCustomerJson($response)['error'])->toBe($message);
+ }
+
+ // The team member's account is never touched
+ expect($staff->passwords)->toBe([])
+ ->and($staff->activations)->toBe(0)
+ ->and($staff->deactivations)->toBe(0)
+ ->and($controller->sentCredentials)->toBe([]);
+});
diff --git a/server/tests/ModelAccessorContractsTest.php b/server/tests/ModelAccessorContractsTest.php
index a03d38b21..c40fff6cd 100644
--- a/server/tests/ModelAccessorContractsTest.php
+++ b/server/tests/ModelAccessorContractsTest.php
@@ -130,14 +130,51 @@ public function update(array $attributes = [], array $options = []): bool
return true;
}
+ public function save(array $options = []): bool
+ {
+ $this->updates[] = $this->getDirty();
+ $this->syncOriginal();
+
+ return true;
+ }
+
public function delete()
{
$this->deleted = true;
+ $this->setAttribute('deleted_at', '2026-01-01 00:00:00');
return true;
}
}
+function fleetopsModelAccessorsUseProfileAccountTables(): void
+{
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
+ $schema = $connection->getSchemaBuilder();
+ $schema->create('users', function ($table) {
+ $table->string('uuid')->nullable();
+ $table->string('type')->nullable();
+ $table->string('email')->nullable();
+ $table->string('phone')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ foreach (['drivers', 'contacts'] as $profileTable) {
+ $schema->create($profileTable, function ($table) {
+ $table->string('uuid')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ }
+
+ $resolver = new ConnectionResolver([
+ 'default' => $connection,
+ 'mysql' => $connection,
+ ]);
+ $resolver->setDefaultConnection('mysql');
+ EloquentModel::setConnectionResolver($resolver);
+}
+
class FleetOpsContactAccessorFake extends Contact
{
public array $loadedMissing = [];
@@ -858,6 +895,8 @@ public function update(array $attributes = [], array $options = []): bool
});
test('contact user sync lookup and deletion guards are stable', function () {
+ fleetopsModelAccessorsUseProfileAccountTables();
+
$user = new FleetOpsContactSyncUserFake();
$user->setRawAttributes([
'uuid' => 'user-uuid',
@@ -868,22 +907,25 @@ public function update(array $attributes = [], array $options = []): bool
], true);
$contact = new FleetOpsContactAccessorFake();
+ $contact->exists = true;
$contact->fakeUser = $user;
$contact->setRawAttributes([
- 'uuid' => 'contact-uuid',
- 'user_uuid' => 'not-a-uuid',
- 'type' => 'customer',
- 'name' => 'Old Name',
- 'email' => 'old@example.com',
- 'phone' => '+15550000000',
- 'timezone' => 'UTC',
+ 'uuid' => 'contact-uuid',
+ 'company_uuid' => 'company-uuid',
+ 'user_uuid' => 'not-a-uuid',
+ 'type' => 'customer',
+ 'name' => 'Old Name',
+ 'email' => 'old@example.com',
+ 'phone' => '+15550000000',
+ 'timezone' => 'UTC',
], true);
$contact->name = 'New Name';
- $contact->email = 'new@example.com';
- $contact->phone = '+15551112222';
+ $contact->email = 'New@Example.com';
+ $contact->phone = '+1 555-111-2222';
$contact->timezone = 'Asia/Singapore';
+ // Proxy fields are normalized and pushed to the managed account
expect($contact->syncWithUser())->toBeTrue()
->and($user->updates[0])->toBe([
'name' => 'New Name',
@@ -895,26 +937,46 @@ public function update(array $attributes = [], array $options = []): bool
->and($contact->hasUser())->toBeTrue()
->and($contact->doesntHaveUser())->toBeFalse();
- $deleteContact = new FleetOpsContactAccessorFake();
- $deleteContact->type = 'customer';
- $deleteContact->setRelation('user', $user);
+ // An unsaved contact's account was just resolved from these values
+ $newContact = new FleetOpsContactAccessorFake();
+ $newContact->fakeUser = $user;
+ $newContact->name = 'Brand New';
+
+ // A saved contact with no proxy changes has nothing to sync
+ $cleanContact = new FleetOpsContactAccessorFake();
+ $cleanContact->exists = true;
+ $cleanContact->fakeUser = $user;
+ $cleanContact->setRawAttributes(['name' => 'New Name', 'type' => 'customer'], true);
+
+ expect($newContact->syncWithUser())->toBeFalse()
+ ->and($cleanContact->syncWithUser())->toBeFalse()
+ ->and($user->updates)->toHaveCount(1);
+
+ // Deleting the only profile holding a managed account deletes the account
+ $deleteContact = new FleetOpsContactAccessorFake();
+ $deleteContact->type = 'customer';
+ $deleteContact->company_uuid = 'company-uuid';
+ $deleteContact->fakeUser = $user;
expect($deleteContact->deleteUser())->toBeTrue()
- ->and($user->deleted)->toBeTrue()
- ->and($deleteContact->loadedMissing)->toBe(['user']);
+ ->and($user->deleted)->toBeTrue();
- $mismatchedUser = new FleetOpsContactSyncUserFake();
- $mismatchedUser->setRawAttributes(['type' => 'admin'], true);
+ // A team member's account linked to the contact is never deleted
+ $staffUser = new FleetOpsContactSyncUserFake();
+ $staffUser->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'admin'], true);
- $mismatchedContact = new FleetOpsContactAccessorFake();
- $mismatchedContact->type = 'customer';
- $mismatchedContact->setRelation('user', $mismatchedUser);
+ $staffContact = new FleetOpsContactAccessorFake();
+ $staffContact->type = 'customer';
+ $staffContact->fakeUser = $staffUser;
$emptyContact = new FleetOpsContactAccessorFake();
+ $emptyContact->exists = true;
$emptyContact->fakeUser = null;
$emptyContact->setRawAttributes(['user_uuid' => 'not-a-uuid'], true);
+ $emptyContact->name = 'Changed';
- expect($mismatchedContact->deleteUser())->toBeFalse()
+ expect($staffContact->deleteUser())->toBeFalse()
+ ->and($staffUser->deleted)->toBeFalse()
->and($emptyContact->syncWithUser())->toBeFalse()
->and($emptyContact->getUser())->toBeNull()
->and($emptyContact->hasUser())->toBeFalse()
diff --git a/server/tests/ObserverContractsTest.php b/server/tests/ObserverContractsTest.php
index aef25a1de..38b097fa3 100644
--- a/server/tests/ObserverContractsTest.php
+++ b/server/tests/ObserverContractsTest.php
@@ -263,6 +263,7 @@ public function hasRole($roles, ?string $guard = null): bool
public function delete()
{
$this->deleted = true;
+ $this->setAttribute('deleted_at', '2026-01-01 00:00:00');
return true;
}
@@ -1149,14 +1150,30 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order
});
test('driver observer defaults location unassigns related records and deletes driver users', function () {
+ // releaseForProfile looks for the account's remaining driver/contact profiles
+ $connection = new Illuminate\Database\SQLiteConnection(new PDO('sqlite::memory:'));
+ $resolver = new Illuminate\Database\ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ Illuminate\Database\Eloquent\Model::setConnectionResolver($resolver);
+ foreach (['drivers', 'contacts'] as $profileTable) {
+ $connection->getSchemaBuilder()->create($profileTable, function ($table) {
+ $table->string('uuid')->nullable();
+ $table->string('user_uuid')->nullable();
+ $table->string('company_uuid')->nullable();
+ $table->timestamp('deleted_at')->nullable();
+ });
+ }
+
$driver = new Driver();
$driver->setRawAttributes([
- 'uuid' => 'driver-uuid',
- 'user_uuid' => 'user-uuid',
+ 'uuid' => 'driver-uuid',
+ 'user_uuid' => 'user-uuid',
+ 'company_uuid' => 'company-uuid',
], true);
$observer = new FleetOpsDriverObserverProbe();
$user = new FleetOpsDriverObserverUserFake();
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
$observer->user = $user;
$observer->creating($driver);
@@ -1175,6 +1192,14 @@ protected function resolveOrder(PurchaseRate $purchaseRate): ?Order
->and($observer->unassigned)->toBe(['driver-uuid'])
->and($user->deleted)->toBeTrue();
+ // A team member's account linked to the driver is never deleted
+ $staff = new FleetOpsDriverObserverUserFake();
+ $staff->setRawAttributes(['uuid' => 'staff-uuid', 'type' => 'user'], true);
+ $observer->user = $staff;
+ $observer->deleted($driver);
+
+ expect($staff->deleted)->toBeFalse();
+
$driverWithLocation = new Driver();
$location = new Fleetbase\LaravelMysqlSpatial\Types\Point(1.3, 103.8);
$driverWithLocation->location = $location;
diff --git a/server/tests/RequestContractsTest.php b/server/tests/RequestContractsTest.php
index 9cda5fd89..86f7280eb 100644
--- a/server/tests/RequestContractsTest.php
+++ b/server/tests/RequestContractsTest.php
@@ -534,10 +534,13 @@ protected function canUpdateDriver(): bool
$request = new InternalCreateDriverRequest();
$updateProbe = new FleetOpsInternalUpdateDriverRequestProbe();
+ // Email/phone availability is decided by ProfileAccountManager (a team
+ // member of the company is linked), so they're optional and not unique here.
expect(ruleStrings($createRules['name']))->toContain('required', 'nullable', 'string', 'max:255')
- ->and(ruleStrings($createRules['email']))->toContain('required')
- ->and(ruleStrings($createRules['phone']))->toContain('required')
- ->and(ruleStrings($userRules['name']))->not->toContain('required')
+ ->and(ruleStrings($createRules['email']))->toBe(['nullable'])
+ ->and(ruleStrings($createRules['phone']))->toBe(['nullable', 'string'])
+ ->and(ruleStrings($userRules['name']))->toContain('required')
+ ->and(ruleStrings(InternalCreateDriverRequest::create('/fleetops-test', 'POST', ['email' => 'driver@example.test'])->rules()['email']))->toBe(['nullable', 'email'])
->and(ruleStrings($patchRules['name']))->not->toContain('required')
->and($updateProbe->authorize())->toBeFalse();
@@ -556,9 +559,10 @@ protected function canUpdateDriver(): bool
])
->and($request->messages())->toMatchArray([
'name.required' => 'Driver name is required.',
- 'email.required' => 'Email address is required.',
+ 'email.email' => 'Please provide a valid email address.',
'password.min' => 'Password must be at least 8 characters.',
- ]);
+ ])
+ ->and($request->messages())->not->toHaveKeys(['email.required', 'email.unique', 'phone.required', 'phone.unique']);
});
test('public create order request validates payload alternatives and pod methods', function () {
@@ -929,12 +933,16 @@ public function parameter($key, $default = null)
expect($request->authorize())->toBeTrue()
->and(ruleStrings($createRules['name']))->toContain('required')
->and(ruleStrings($patchRules['name']))->not->toContain('required')
- // Contact details are optional but still validated and still unique:
- // an operational driver record may legitimately have neither.
- ->and(ruleStrings($createRules['email']))->toContain('nullable', 'email', 'unique:users')
- ->and(ruleStrings($createRules['email']))->not->toContain('required')
- ->and(ruleStrings($createRules['phone']))->toContain('nullable', 'unique:users')
- ->and(ruleStrings($createRules['phone']))->not->toContain('required')
+ // Contact details are optional but still validated: an operational
+ // driver record may legitimately have neither. On create, availability
+ // is decided by ProfileAccountManager (a team member is linked); on
+ // update they must stay unique among users.
+ ->and(ruleStrings($createRules['email']))->toContain('nullable', 'email')
+ ->and(ruleStrings($createRules['email']))->not->toContain('required', 'unique:users')
+ ->and(ruleStrings($createRules['phone']))->toContain('nullable')
+ ->and(ruleStrings($createRules['phone']))->not->toContain('required', 'unique:users')
+ ->and(ruleStrings($patchRules['email']))->toContain('nullable', 'unique:users')
+ ->and(ruleStrings($patchRules['phone']))->toContain('nullable', 'string', 'unique:users')
->and($createRules['password'])->toBe('nullable|string')
->and($createRules['country'])->toBe('nullable|size:2')
->and(ruleStrings($createRules['vehicle']))->toContain('nullable', 'string', 'starts_with:vehicle_', 'exists:vehicles,public_id')
diff --git a/server/tests/SupportJobAndAiCoverageTest.php b/server/tests/SupportJobAndAiCoverageTest.php
index df2223f35..cc42249f9 100644
--- a/server/tests/SupportJobAndAiCoverageTest.php
+++ b/server/tests/SupportJobAndAiCoverageTest.php
@@ -889,8 +889,8 @@ function fleetopsInvokeSyncTelematicJob(SyncTelematicDevicesJob $job, string $me
->and($capability->shouldResolve($task))->toBeTrue()
->and($capability->promptMatches('tell me about sensor SENSOR-1'))->toBeTrue()
->and($capability->promptMatches('compose a friendly email'))->toBeFalse()
- ->and($capability->termsFor('Find order ORDER-123 for driver driver_456'))->toBe(['ORDER-123', 'for', 'driver_456'])
- ->and($capability->termsFor('show order'))->toBe(['show order'])
+ ->and($capability->termsFor('Find order ORDER-123 for driver driver_456'))->toBe(['ORDER-123', 'driver_456'])
+ ->and($capability->termsFor('show order'))->toBe([])
->and($capability->genericWhenDenied())->toBe([])
->and($capability->allResourceBranchesWhenDenied(['needle']))->toBe([
'orders' => [],
@@ -903,7 +903,7 @@ function fleetopsInvokeSyncTelematicJob(SyncTelematicDevicesJob $job, string $me
'telematics' => [],
])
->and($capability->resolve($task))->toBe([
- 'query_terms' => ['DRV-123', 'and', 'TRUCK_9'],
+ 'query_terms' => ['DRV-123', 'TRUCK_9'],
'results' => [],
]);
});
diff --git a/server/tests/TelematicControllerContractsTest.php b/server/tests/TelematicControllerContractsTest.php
index 699054534..aa0ceef45 100644
--- a/server/tests/TelematicControllerContractsTest.php
+++ b/server/tests/TelematicControllerContractsTest.php
@@ -159,6 +159,7 @@ public function getAttribute($key)
$customer->setRelation('user', (object) [
'uuid' => 'user-uuid',
'public_id' => 'user-public',
+ 'type' => 'customer',
'name' => 'Ada Customer',
'email' => 'ada@example.test',
'phone' => '+15551234567',
@@ -168,10 +169,12 @@ public function getAttribute($key)
]);
expect($controller->callHelper('customerPayload', $customer))->toBe([
- 'id' => 'customer-public',
- 'uuid' => 'customer-uuid',
- 'user_uuid' => 'user-uuid',
- 'user' => [
+ 'id' => 'customer-public',
+ 'uuid' => 'customer-uuid',
+ 'user_uuid' => 'user-uuid',
+ 'is_staff_linked' => false,
+ 'login_status' => 'active',
+ 'user' => [
'id' => 'user-public',
'uuid' => 'user-uuid',
'name' => 'Ada Customer',
@@ -182,4 +185,19 @@ public function getAttribute($key)
'avatar_url' => 'https://example.test/avatar.png',
],
]);
+
+ // A customer linked to a team member's account is reported as staff-linked
+ $customer->setRelation('user', (object) ['uuid' => 'staff-uuid', 'public_id' => 'staff-public', 'type' => 'user', 'status' => 'inactive']);
+ $staffPayload = $controller->callHelper('customerPayload', $customer);
+
+ expect($staffPayload['is_staff_linked'])->toBeTrue()
+ ->and($staffPayload['login_status'])->toBe('inactive');
+
+ // A customer without a login account is neither
+ $customer->setRelation('user', null);
+ $emptyPayload = $controller->callHelper('customerPayload', $customer);
+
+ expect($emptyPayload['is_staff_linked'])->toBeFalse()
+ ->and($emptyPayload['login_status'])->toBeNull()
+ ->and($emptyPayload['user'])->toBeNull();
});
diff --git a/server/tests/Unit/Models/ContactCustomerUserTest.php b/server/tests/Unit/Models/ContactCustomerUserTest.php
index d075d6847..8f2a2c562 100644
--- a/server/tests/Unit/Models/ContactCustomerUserTest.php
+++ b/server/tests/Unit/Models/ContactCustomerUserTest.php
@@ -9,7 +9,7 @@
}
use Fleetbase\FleetOps\Exceptions\CustomerUserConflictException;
-use Fleetbase\FleetOps\Exceptions\UserAlreadyExistsException;
+use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException;
use Fleetbase\FleetOps\Models\Contact;
use Fleetbase\Models\User;
use Illuminate\Database\ConnectionResolver;
@@ -75,9 +75,38 @@ public function __call($method, $arguments)
});
Illuminate\Support\Facades\DB::clearResolvedInstance('db');
+ app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
+ public function info($hashedValue): array
+ {
+ return [];
+ }
+
+ public function make($value, array $options = []): string
+ {
+ return md5((string) $value);
+ }
+
+ public function check($value, $hashedValue, array $options = []): bool
+ {
+ return md5((string) $value) === $hashedValue;
+ }
+
+ public function needsRehash($hashedValue, array $options = []): bool
+ {
+ return false;
+ }
+
+ public function verifyConfiguration($value): bool
+ {
+ return true;
+ }
+ });
+ Illuminate\Support\Facades\Hash::clearResolvedInstance('hash');
+
$schema = $connection->getSchemaBuilder();
$tables = [
'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'email', 'phone', 'type', 'title'],
+ 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid'],
'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'username', 'password', 'timezone', 'status', 'type', 'slug', 'avatar_uuid', 'last_login'],
'companies' => ['uuid', 'public_id', 'name', 'timezone', 'owner_uuid'],
'company_users' => ['uuid', 'company_uuid', 'user_uuid', 'status'],
@@ -188,24 +217,45 @@ function fleetopsContactCustomerUserContact(array $attributes = []): FleetOpsCon
->and($connection->table('users')->count())->toBe(1);
});
-test('create user from contact rejects users already linked to another contact', function () {
+test('create user from contact rejects an account that already holds a contact in the company', function () {
$connection = fleetopsContactCustomerUserBoot();
- // The contact user() relation constrains users.type against the querying
- // contact type, which is null in the static whereHas context — a null-typed
- // user keeps the linked-contact subquery matchable.
- $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'taken@example.com', 'type' => null]);
+ $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'taken@example.com', 'type' => 'contact']);
$connection->table('contacts')->insert(['uuid' => 'contact-owner', 'company_uuid' => 'company-1', 'user_uuid' => 'user-9', 'name' => 'Owner', 'type' => 'contact']);
$contact = fleetopsContactCustomerUserContact(['uuid' => 'contact-2', 'email' => 'taken@example.com']);
- expect(fn () => Contact::createUserFromContact($contact))->toThrow(UserAlreadyExistsException::class);
+ expect(fn () => Contact::createUserFromContact($contact))
+ ->toThrow(ProfileIdentityConflictException::class, 'A contact with this email already exists.');
});
-test('create user from contact rejects staff users for customer contacts', function () {
+test('create user from contact rejects staff users of another organization for customer contacts', function () {
$connection = fleetopsContactCustomerUserBoot();
- $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff']);
+ $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-other', 'email' => 'staff@example.com', 'type' => 'user']);
$contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']);
- expect(fn () => Contact::createUserFromContact($contact))->toThrow(CustomerUserConflictException::class);
+ expect(fn () => Contact::createUserFromContact($contact))
+ ->toThrow(ProfileIdentityConflictException::class, 'This email is already in use by another account.');
+});
+
+test('create user from contact links a staff member of the organization without changing their role', function () {
+ $connection = fleetopsContactCustomerUserBoot();
+ $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'user']);
+ $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'Staff@Example.com']);
+
+ $user = Contact::createUserFromContact($contact);
+
+ expect($user->uuid)->toBe('user-9')
+ ->and($user->type)->toBe('user')
+ ->and($contact->user_uuid)->toBe('user-9')
+ ->and($connection->table('model_has_roles')->count())->toBe(0);
+});
+
+test('create user from contact rejects a managed account of another type', function () {
+ $connection = fleetopsContactCustomerUserBoot();
+ $connection->table('users')->insert(['uuid' => 'user-9', 'company_uuid' => 'company-1', 'phone' => '+6591112222', 'type' => 'driver']);
+ $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'phone' => '+65 9111-2222']);
+
+ expect(fn () => Contact::createUserFromContact($contact))
+ ->toThrow(ProfileIdentityConflictException::class, 'This phone number is already used by a driver.');
});
test('create user from contact assigns the customer role and persists the link', function () {
@@ -305,17 +355,27 @@ function fleetopsContactCustomerUserContact(array $attributes = []): FleetOpsCon
test('assert customer identity is available detects staff conflicts', function () {
$connection = fleetopsContactCustomerUserBoot();
- $connection->table('users')->insert(['uuid' => 'user-1', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff']);
+ $connection->table('users')->insert([
+ ['uuid' => 'user-1', 'company_uuid' => 'company-1', 'email' => 'staff@example.com', 'type' => 'staff'],
+ ['uuid' => 'user-2', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'type' => 'driver'],
+ ]);
$nonCustomer = fleetopsContactCustomerUserContact();
$nonCustomer->assertCustomerIdentityIsAvailable();
- $conflicted = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']);
- expect(fn () => $conflicted->assertCustomerIdentityIsAvailable())->toThrow(CustomerUserConflictException::class);
+ // A staff member of the organization can hold a customer profile
+ $staffMember = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'staff@example.com']);
+ $staffMember->assertCustomerIdentityIsAvailable();
+
+ // A driver's managed account cannot
+ $conflicted = fleetopsContactCustomerUserContact(['type' => 'customer', 'email' => 'driver@example.com']);
+ expect(fn () => $conflicted->assertCustomerIdentityIsAvailable())
+ ->toThrow(CustomerUserConflictException::class, 'This email is already used by a driver and cannot be used for a customer account.');
// An already-assigned user is checked ahead of the identity lookup, so a
// contact whose email matches nothing still trips on its own assignment
- $connection->table('users')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'company_uuid' => 'company-1', 'email' => 'assigned@example.com', 'type' => 'staff']);
+ // (a staff account of another organization)
+ $connection->table('users')->insert(['uuid' => '33333333-3333-4333-8333-333333333333', 'company_uuid' => 'company-other', 'email' => 'assigned@example.com', 'type' => 'staff']);
$assigned = fleetopsContactCustomerUserContact([
'type' => 'customer',
'email' => 'unmatched@example.com',
@@ -368,3 +428,19 @@ function fleetopsContactCustomerUserContact(array $attributes = []): FleetOpsCon
expect($connection->table('company_users')->where('user_uuid', 'user-n1')->count())->toBe(1)
->and($connection->table('model_has_roles')->count())->toBeGreaterThanOrEqual(1);
});
+
+test('normalize customer user keeps a staff member of the organization untouched', function () {
+ $connection = fleetopsContactCustomerUserBoot();
+ $connection->table('companies')->insert(['uuid' => 'company-1', 'name' => 'Acme']);
+ $connection->table('users')->insert(['uuid' => 'user-s1', 'company_uuid' => 'company-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user', 'status' => 'active']);
+
+ $contact = fleetopsContactCustomerUserContact(['type' => 'customer', 'user_uuid' => 'user-s1']);
+ $user = User::where('uuid', 'user-s1')->first();
+
+ expect($contact->normalizeCustomerUser($user))->toBe($user)
+ ->and($contact->getRelation('user'))->toBe($user)
+ // A team member's account keeps its own organization membership and role
+ ->and($connection->table('company_users')->count())->toBe(0)
+ ->and($connection->table('model_has_roles')->count())->toBe(0)
+ ->and($connection->table('users')->where('uuid', 'user-s1')->value('type'))->toBe('user');
+});
diff --git a/server/tests/Unit/Models/ContactTest.php b/server/tests/Unit/Models/ContactTest.php
index 40d8738f6..9c3fa0e89 100644
--- a/server/tests/Unit/Models/ContactTest.php
+++ b/server/tests/Unit/Models/ContactTest.php
@@ -25,7 +25,8 @@ class FleetOpsContactUnitCompanyFake extends Company
public array $addUserCalls = [];
public ?FleetOpsContactUnitCompanyUserFake $companyUser = null;
- public function addUser(User $user, string $role = 'Administrator', string $status = 'active'): CompanyUser
+ // Matches core-api 1.6.63+, where no role is granted unless one is given.
+ public function addUser(User $user, ?string $role = null, string $status = 'active'): CompanyUser
{
$this->addUserCalls[] = [$user, $role, $status];
@@ -71,9 +72,18 @@ public function update(array $attributes = [], array $options = []): bool
return true;
}
+ public function save(array $options = []): bool
+ {
+ $this->updates[] = $this->getDirty();
+ $this->syncOriginal();
+
+ return true;
+ }
+
public function delete()
{
$this->deleted = true;
+ $this->setAttribute('deleted_at', '2026-01-01 00:00:00');
return true;
}
@@ -180,6 +190,16 @@ public static function where($column, $operator = null, $value = null, $boolean
}
}
+function fleetopsContactUnitUseProfileTables(): SQLiteConnection
+{
+ $connection = fleetopsContactUnitUseInMemoryConnection();
+ $connection->statement('create table drivers (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, deleted_at datetime null)');
+ $connection->statement('create table contacts (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, deleted_at datetime null)');
+ $connection->statement('create table company_users (uuid varchar(64), user_uuid varchar(64) null, company_uuid varchar(64) null, created_at datetime null, updated_at datetime null, deleted_at datetime null)');
+
+ return $connection;
+}
+
function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection
{
$connection = new SQLiteConnection(new PDO('sqlite::memory:'));
@@ -496,6 +516,8 @@ function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection
});
test('contact sync delete and user presence helpers use loaded user relations', function () {
+ fleetopsContactUnitUseProfileTables();
+
$user = new FleetOpsContactUnitUserFake();
$user->setRawAttributes([
'uuid' => 'user-uuid',
@@ -521,6 +543,7 @@ function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection
]);
$contact->setRelation('user', $user);
$contact->fakeUser = $user;
+ $contact->exists = true;
expect($contact->syncWithUser())->toBeTrue()
->and($user->updates)->toBe([[
@@ -543,20 +566,40 @@ function fleetopsContactUnitUseInMemoryConnection(): SQLiteConnection
->and($unlinked->doesntHaveUser())->toBeTrue();
});
-test('contact delete user ignores mismatched relation types', function () {
+test('contact delete user leaves team member accounts alone', function () {
$user = new FleetOpsContactUnitUserFake();
$user->setRawAttributes([
'uuid' => 'user-uuid',
- 'type' => 'customer',
+ 'type' => 'user',
], true);
- $contact = new FleetOpsContactUnitFake(['type' => 'contact']);
- $contact->setRelation('user', $user);
+ $contact = new FleetOpsContactUnitFake(['type' => 'customer']);
+ $contact->fakeUser = $user;
expect($contact->deleteUser())->toBeFalse()
->and($user->deleted)->toBeFalse();
});
+test('contact delete user keeps a managed account still used by another organization', function () {
+ $connection = fleetopsContactUnitUseProfileTables();
+ $connection->table('drivers')->insert(['uuid' => 'driver-elsewhere', 'user_uuid' => 'user-uuid', 'company_uuid' => 'other-company']);
+ $connection->table('company_users')->insert([
+ ['uuid' => 'cu-1', 'user_uuid' => 'user-uuid', 'company_uuid' => 'company-uuid'],
+ ['uuid' => 'cu-2', 'user_uuid' => 'user-uuid', 'company_uuid' => 'other-company'],
+ ]);
+
+ $user = new FleetOpsContactUnitUserFake();
+ $user->setRawAttributes(['uuid' => 'user-uuid', 'type' => 'driver'], true);
+
+ $contact = new FleetOpsContactUnitFake(['type' => 'contact', 'company_uuid' => 'company-uuid']);
+ $contact->fakeUser = $user;
+
+ // The account only leaves this organization
+ expect($contact->deleteUser())->toBeFalse()
+ ->and($user->deleted)->toBeFalse()
+ ->and($connection->table('company_users')->whereNull('deleted_at')->pluck('company_uuid')->all())->toBe(['other-company']);
+});
+
test('contact real user helpers return loaded relations without database lookup', function () {
$user = new FleetOpsContactUnitUserFake();
$user->setRawAttributes([
diff --git a/server/tests/Unit/Models/SensorTest.php b/server/tests/Unit/Models/SensorTest.php
index af3e3811b..068b678df 100644
--- a/server/tests/Unit/Models/SensorTest.php
+++ b/server/tests/Unit/Models/SensorTest.php
@@ -116,7 +116,7 @@ function fleetopsSensorModelUseInMemoryConnection(): SQLiteConnection
$sensor = new Sensor();
- expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', 'sensor_type'])
+ expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', 'type'])
->and($sensor->getSlugOptions()->slugField)->toBe('slug')
->and($sensor->getActivitylogOptions()->logOnlyDirty)->toBeTrue()
->and($sensor->telematic())->toBeInstanceOf(BelongsTo::class)
@@ -222,7 +222,7 @@ function fleetopsSensorModelUseInMemoryConnection(): SQLiteConnection
->and($sensor->scopeActive($query))->toBe($query)
->and($sensor->scopeWithRecentReadings($query, 30))->toBe($query)
->and($sensor->scopeOutOfThreshold($query))->toBe($query)
- ->and($query->calls[0])->toBe(['where', ['sensor_type', 'temperature']])
+ ->and($query->calls[0])->toBe(['where', ['type', 'temperature']])
->and($query->calls[1])->toBe(['where', ['status', 'active']])
->and($query->calls[2][0])->toBe('where')
->and($query->calls[2][1][0])->toBe('last_reading_at')
diff --git a/server/tests/Unit/Models/SmallModelSurfacesTest.php b/server/tests/Unit/Models/SmallModelSurfacesTest.php
index 142f1f77f..6aecd0683 100644
--- a/server/tests/Unit/Models/SmallModelSurfacesTest.php
+++ b/server/tests/Unit/Models/SmallModelSurfacesTest.php
@@ -176,45 +176,51 @@ public function __call($method, $arguments)
expect($customer->orders())->toBeInstanceOf(HasMany::class);
});
-test('contact observer availability checks and deletion resolve against users', function () {
+test('contact observer deletion releases the managed account and leaves team members alone', function () {
$connection = fleetopsSmallModelBoot();
- $connection->table('users')->insert(['uuid' => 'user-2', 'company_uuid' => 'company-1', 'email' => 'taken@example.test', 'phone' => '+6512345678']);
+ config(['activitylog.enabled' => false]);
+ app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config());
+ $connection->getSchemaBuilder()->create('drivers', function ($blueprint) {
+ $blueprint->increments('id');
+ $blueprint->string('uuid')->nullable();
+ $blueprint->string('company_uuid')->nullable();
+ $blueprint->string('user_uuid')->nullable();
+ $blueprint->timestamp('deleted_at')->nullable();
+ });
+ $connection->table('users')->insert([
+ ['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'type' => 'contact'],
+ ['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'type' => 'user'],
+ ]);
- $observer = new ContactObserver();
- $reflection = new ReflectionClass($observer);
+ $observer = new ContactObserver();
$contact = new Fleetbase\FleetOps\Models\Contact();
- $contact->setRawAttributes(['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'user_uuid' => 'user-1', 'email' => 'taken@example.test', 'phone' => '+6512345678', 'type' => 'contact'], true);
+ $contact->setRawAttributes(['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'user_uuid' => '11111111-1111-4111-8111-111111111111', 'type' => 'contact'], true);
$contact->exists = true;
- $emailCheck = $reflection->getMethod('isEmailUnavailable');
- $emailCheck->setAccessible(true);
- expect($emailCheck->invoke($observer, $contact))->toBeTrue();
-
- $phoneCheck = $reflection->getMethod('isPhoneUnavailable');
- $phoneCheck->setAccessible(true);
- expect($phoneCheck->invoke($observer, $contact))->toBeTrue();
+ $staffContact = new Fleetbase\FleetOps\Models\Contact();
+ $staffContact->setRawAttributes(['uuid' => 'contact-2', 'company_uuid' => 'company-1', 'user_uuid' => '22222222-2222-4222-8222-222222222222', 'type' => 'contact'], true);
+ $staffContact->exists = true;
- $contact->email = 'free@example.test';
- $contact->phone = '+6599999999';
- expect($emailCheck->invoke($observer, $contact))->toBeFalse()
- ->and($phoneCheck->invoke($observer, $contact))->toBeFalse();
-
- // Deletion removes the associated user account
$observer->deleted($contact);
- expect(true)->toBeTrue();
+ $observer->deleted($staffContact);
+
+ // The managed account had no other profile, so it is deleted
+ expect($connection->table('users')->where('uuid', '11111111-1111-4111-8111-111111111111')->value('deleted_at'))->not->toBeNull()
+ ->and($connection->table('users')->where('uuid', '22222222-2222-4222-8222-222222222222')->value('deleted_at'))->toBeNull();
});
test('contact observer rejects saves whose email or phone belongs to another account', function () {
$connection = fleetopsSmallModelBoot();
- $connection->table('users')->insert(['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'email' => 'taken@example.test', 'phone' => '+6512345678']);
+ $connection->table('users')->insert([
+ ['uuid' => '11111111-1111-4111-8111-111111111111', 'company_uuid' => 'company-1', 'type' => 'contact', 'email' => 'free@example.test', 'phone' => '+6599999999'],
+ ['uuid' => '22222222-2222-4222-8222-222222222222', 'company_uuid' => 'company-1', 'type' => 'user', 'email' => 'taken@example.test', 'phone' => '+6512345678'],
+ ]);
$observer = new ContactObserver();
- // The availability checks are private, so the guards can only be reached
- // through saving() with a contact that genuinely collides on a real table.
// hasUser() keys off a well-formed uuid, so a real one keeps saving() from
- // detouring into account provisioning before it reaches the guards.
+ // detouring into account provisioning before it reaches the sync.
$contact = new Fleetbase\FleetOps\Models\Contact();
$contact->setRawAttributes([
'uuid' => 'contact-collide',
@@ -226,23 +232,16 @@ public function __call($method, $arguments)
], true);
$contact->exists = true;
- // wasChanged() reads the post-save change set, which setRawAttributes does
- // not populate — syncChanges promotes the pending edit into it.
$contact->email = 'taken@example.test';
- $contact->syncChanges();
expect(fn () => $observer->saving($contact))
- ->toThrow(Exception::class, 'Email attempting to update for contact is not available.');
+ ->toThrow(Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException::class, 'This email is already in use by another account.');
- // The phone guard sits behind the email one, so it only surfaces once the
- // email is back to an available value
- $contact->syncOriginal();
$contact->email = 'free@example.test';
$contact->phone = '+6512345678';
- $contact->syncChanges();
expect(fn () => $observer->saving($contact))
- ->toThrow(Exception::class, 'Phone attempting to update for contact is not available.');
+ ->toThrow(Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException::class, 'This phone number is already in use by another account.');
});
test('tracking status request authorizes by session and rejects duplicates', function () {
diff --git a/server/tests/Unit/Observers/ObserverListenerSeamsTest.php b/server/tests/Unit/Observers/ObserverListenerSeamsTest.php
index febb8bee7..03223f571 100644
--- a/server/tests/Unit/Observers/ObserverListenerSeamsTest.php
+++ b/server/tests/Unit/Observers/ObserverListenerSeamsTest.php
@@ -98,11 +98,16 @@ function fleetopsObserverSeamInvoke(string $class, string $method, array $argume
->and($connection->table('orders')->where('uuid', 'order-seam-1')->value('driver_assigned_uuid'))->toBeNull()
->and($connection->table('orders')->where('uuid', 'order-seam-2')->value('driver_assigned_uuid'))->toBe('driver-other');
- // The linked account resolves only when it is still a plain user
+ // The linked account resolves whatever its type: releaseForProfile decides
+ // whether a managed account is deleted and leaves a team member alone
expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$driver])?->uuid)->toBe('user-seam-1');
+ $managedDriver = new Driver();
+ $managedDriver->setRawAttributes(['uuid' => 'driver-seam-2', 'user_uuid' => 'user-seam-2'], true);
+ expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$managedDriver])?->uuid)->toBe('user-seam-2');
+
$driverWithoutUser = new Driver();
- $driverWithoutUser->setRawAttributes(['uuid' => 'driver-seam-2', 'user_uuid' => 'user-seam-2'], true);
+ $driverWithoutUser->setRawAttributes(['uuid' => 'driver-seam-3', 'user_uuid' => null], true);
expect(fleetopsObserverSeamInvoke(Fleetbase\FleetOps\Observers\DriverObserver::class, 'findDriverUser', [$driverWithoutUser]))->toBeNull();
});
diff --git a/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php b/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php
index 363d4ae85..b3364a1d5 100644
--- a/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php
+++ b/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php
@@ -4,6 +4,10 @@
eval('namespace Fleetbase\Ai\Support; class AiCapabilityRegistry { public array $registered = []; public function register($capability) { $this->registered[] = $capability; return $this; } }');
}
+if (!class_exists('Fleetbase\Ai\Support\Commands\AiCommandRegistry', false)) {
+ eval('namespace Fleetbase\Ai\Support\Commands; class AiCommandRegistry { public array $registered = []; public function registerMany(array $commands) { $this->registered = array_merge($this->registered, $commands); return $this; } }');
+}
+
if (!class_exists('Fleetbase\Ai\Support\AiQueryRegistry', false)) {
eval('namespace Fleetbase\Ai\Support; class AiQueryRegistry { public array $registered = []; public function register($resource = null) { $this->registered[] = $resource; return $this; } }');
}
@@ -44,9 +48,18 @@
$capabilityRegistry = app(Fleetbase\Ai\Support\AiCapabilityRegistry::class);
fwrite(STDERR, "\nDBG ai: " . json_encode([is_object($queryRegistry) ? get_class($queryRegistry) : gettype($queryRegistry), is_object($capabilityRegistry) ? get_class($capabilityRegistry) : gettype($capabilityRegistry), is_object($capabilityRegistry) ? count($capabilityRegistry->registered) : null]) . "\n");
- expect($capabilityRegistry->registered)->toHaveCount(9)
+ expect($capabilityRegistry->registered)->toHaveCount(13)
->and(collect($capabilityRegistry->registered)->map(fn ($capability) => get_class($capability)))
- ->toContain(Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability::class);
+ ->toContain(
+ Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability::class,
+ Fleetbase\FleetOps\Support\Ai\Tools\SearchResourcesTool::class,
+ Fleetbase\FleetOps\Support\Ai\Tools\CreateOrderTool::class,
+ Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool::class,
+ Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool::class,
+ );
+
+ $commandRegistry = app(Fleetbase\Ai\Support\Commands\AiCommandRegistry::class);
+ expect(collect($commandRegistry->registered)->pluck('id'))->toContain('fleet-ops.orders.create', 'fleet-ops.settings.map.open');
// The query resources helper registers the fleet-ops queryables directly
$freshQueryRegistry = new Fleetbase\Ai\Support\AiQueryRegistry();
diff --git a/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php b/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php
index 08b9c56a0..6fe8eed4f 100644
--- a/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php
+++ b/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php
@@ -117,4 +117,46 @@ public function __call($method, $arguments)
->and($helper('vehicleSearchQuery')->count())->toBe(0)
->and($helper('driverSearchQuery')->count())->toBe(3)
->and($helper('genericSearchQuery', Fleetbase\FleetOps\Models\Contact::class)->count())->toBe(0);
+
+ foreach ([Fleetbase\FleetOps\Models\WorkOrder::class, Fleetbase\FleetOps\Models\Maintenance::class, Fleetbase\FleetOps\Models\Device::class, Fleetbase\FleetOps\Models\Sensor::class, Fleetbase\FleetOps\Models\Telematic::class] as $modelClass) {
+ expect($helper('genericSearchQuery', $modelClass))->toBeInstanceOf(Illuminate\Database\Eloquent\Builder::class);
+ }
+});
+
+test('asset status scoped queries resolve a list permission for every supported model', function () {
+ fleetopsAssetStatusCapabilityBoot();
+
+ $capability = (new ReflectionClass(AssetStatusCapability::class))->newInstanceWithoutConstructor();
+ $scoped = new ReflectionMethod(AssetStatusCapability::class, 'scopedQuery');
+ $scoped->setAccessible(true);
+
+ foreach ([Fleetbase\FleetOps\Models\Vehicle::class, Fleetbase\FleetOps\Models\Device::class, Fleetbase\FleetOps\Models\Sensor::class, Fleetbase\FleetOps\Models\Telematic::class, Fleetbase\FleetOps\Models\Contact::class] as $modelClass) {
+ expect($scoped->invoke($capability, $modelClass))->toBeInstanceOf(Illuminate\Database\Eloquent\Builder::class);
+ }
+});
+
+test('capability queries apply IAM directives for the list permission when the directive macro is available', function () {
+ fleetopsAssetStatusCapabilityBoot();
+
+ if (!Illuminate\Database\Eloquent\Builder::hasGlobalMacro('applyDirectivesForPermissions')) {
+ Illuminate\Database\Eloquent\Builder::macro('applyDirectivesForPermissions', function (string|array $names = []) {
+ $GLOBALS['fleetopsAppliedDirectivePermissions'][] = $names;
+
+ return $this;
+ });
+ }
+
+ $GLOBALS['fleetopsAppliedDirectivePermissions'] = [];
+
+ $capability = (new ReflectionClass(AssetStatusCapability::class))->newInstanceWithoutConstructor();
+ $scope = new ReflectionMethod(AssetStatusCapability::class, 'scopeToPermission');
+ $scope->setAccessible(true);
+ $plain = new stdClass();
+
+ expect($scope->invoke($capability, $plain, 'fleet-ops list driver'))->toBe($plain);
+
+ $builder = Fleetbase\FleetOps\Models\Driver::query();
+
+ expect($scope->invoke($capability, $builder, 'fleet-ops list driver'))->toBe($builder)
+ ->and($GLOBALS['fleetopsAppliedDirectivePermissions'])->toContain('fleet-ops list driver');
});
diff --git a/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php b/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php
index fcd291479..80fd077f6 100644
--- a/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php
+++ b/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php
@@ -123,6 +123,11 @@ protected function can(string $permission): bool
return $this->allowed && $permission === 'fleet-ops see order';
}
+ protected function currency(): string
+ {
+ return 'USD';
+ }
+
protected function orderQuery(?string $companyUuid): mixed
{
$this->companies[] = $companyUuid;
@@ -183,6 +188,7 @@ function fleetopsOrderInsightsTask(string $prompt): AiTask
'authorized' => true,
'metric' => 'orders',
'amount_threshold' => 125.50,
+ 'amount_currency' => 'USD',
'count' => 7,
'counts_by_status' => ['completed' => 5, 'active' => 2],
'sample_order_ids' => ['order_1', 'order_2'],
@@ -196,7 +202,7 @@ function fleetopsOrderInsightsTask(string $prompt): AiTask
->and($capability->companies)->toBe(['company-123'])
->and($capability->query->recordedCalls())->toContain(
['whereBetween', 'created_at', [$start, $end]],
- ['whereHas', 'transaction', [['where', 'amount', '>', 125.50]]],
+ ['whereHas', 'transaction', [['where', 'amount', '>', 12550.0]]],
['count'],
['selectRaw', 'status, count(*) as aggregate'],
['groupBy', 'status'],
@@ -224,3 +230,15 @@ function fleetopsOrderInsightsTask(string $prompt): AiTask
->and($capability->query->recordedCalls())->not->toContain(['whereBetween'])
->and($capability->query->recordedCalls())->not->toContain(['whereHas']);
});
+
+test('order insights converts major-unit thresholds using the currency exponent', function () {
+ $capability = new FleetOpsOrderInsightsCapabilityFake();
+ $method = new ReflectionMethod(OrderInsightsCapability::class, 'toMinorUnits');
+ $method->setAccessible(true);
+
+ expect($method->invoke($capability, 500.0, 'USD'))->toBe(50000)
+ ->and($method->invoke($capability, 125.5, 'usd'))->toBe(12550)
+ ->and($method->invoke($capability, 1000.0, 'JPY'))->toBe(1000)
+ ->and($method->invoke($capability, 1.5, 'BHD'))->toBe(1500)
+ ->and($method->invoke($capability, 10.0, 'NOT-A-CURRENCY'))->toBe(1000);
+});
diff --git a/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php b/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php
index 1bedf0d84..62c0e0f7b 100644
--- a/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php
+++ b/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php
@@ -67,6 +67,17 @@ class FleetOpsSearchResourcesCapabilityProbe extends SearchResourcesCapability
public array $queries = [];
public array $appliedLikes = [];
+ public array $failing = [];
+
+ public function exposeSearchTerms(string $prompt): array
+ {
+ return $this->searchTerms($prompt);
+ }
+
+ protected function reportSearchFailure(string $resource, Throwable $e): void
+ {
+ }
+
public function exposePromptMatches(string $prompt): bool
{
return $this->matchesPrompt($prompt);
@@ -103,6 +114,10 @@ protected function driverSearchQuery()
protected function genericSearchQuery(string $modelClass)
{
+ if ($modelClass === Sensor::class && in_array('sensors', $this->failing, true)) {
+ throw new RuntimeException("SQLSTATE[42S22]: Column not found: 1054 Unknown column 'sensor_type'");
+ }
+
return match ($modelClass) {
WorkOrder::class => $this->queries['work_orders'],
Maintenance::class => $this->queries['maintenances'],
@@ -197,7 +212,7 @@ function fleetopsSearchResourcesModel(string $class, array $attributes)
$result = $capability->resolve(new AiTask(['prompt' => 'Find order ORDER-1 vehicle VEH-1 driver DRV-1 device DEV-1 sensor SNS-1 telematic TEL-1']));
expect($capability->exposePromptMatches('look up work order WO-1'))->toBeTrue()
- ->and($result['query_terms'])->toBe(['ORDER-1', 'VEH-1', 'DRV-1', 'device', 'DEV-1', 'sensor'])
+ ->and($result['query_terms'])->toBe(['ORDER-1', 'VEH-1', 'DRV-1', 'DEV-1', 'SNS-1', 'TEL-1'])
->and($result['results'])->toHaveKeys(['orders', 'vehicles', 'drivers', 'work_orders', 'maintenances', 'devices', 'sensors', 'telematics'])
->and($result['results']['orders'][0])->toMatchArray([
'id' => 'order_public',
@@ -235,3 +250,72 @@ function fleetopsSearchResourcesModel(string $class, array $attributes)
->and($capability->queries['orders']->calls)->toContain(['limit', 5], ['get'])
->and($capability->appliedLikes)->not->toBeEmpty();
});
+
+function fleetopsSearchResourcesEmptyQueries(): array
+{
+ return collect(['orders', 'vehicles', 'drivers', 'work_orders', 'maintenances', 'devices', 'sensors', 'telematics'])
+ ->mapWithKeys(fn ($key) => [$key => new FleetOpsSearchResourcesQueryFake()])
+ ->all();
+}
+
+test('search terms ignore ordinary words from real production prompts', function () {
+ $capability = new FleetOpsSearchResourcesCapabilityProbe();
+
+ expect($capability->exposeSearchTerms('i want turn this into a platform where i add cisterns and on a different page BUSINESS leave they orders'))->toBe([])
+ ->and($capability->exposeSearchTerms('Create a few dummy orders so we can test with it'))->toBe([])
+ ->and($capability->exposeSearchTerms('download order import template'))->toBe([])
+ ->and($capability->exposeSearchTerms('config driver app ยังไง'))->toBe([])
+ ->and($capability->exposeSearchTerms('como activar un conductor'))->toBe([]);
+});
+
+test('search terms keep record references such as public ids, plates, emails, quoted and capitalized names', function () {
+ $capability = new FleetOpsSearchResourcesCapabilityProbe();
+
+ expect($capability->exposeSearchTerms('status of order order_yhkejdnzgz'))->toBe(['order_yhkejdnzgz'])
+ ->and($capability->exposeSearchTerms('vehicle plate SBA1234Z'))->toBe(['SBA1234Z'])
+ ->and($capability->exposeSearchTerms('driver with email ada@example.com'))->toBe(['ada@example.com'])
+ ->and($capability->exposeSearchTerms('where is driver Jane Doe right now'))->toBe(['Jane', 'Doe'])
+ ->and($capability->exposeSearchTerms('find "north depot" vehicles'))->toBe(['north depot']);
+});
+
+test('search resources skips querying when the prompt has no record reference', function () {
+ $capability = new FleetOpsSearchResourcesCapabilityProbe();
+ $capability->allowedPermissions = $capability->permissions();
+ $capability->queries = fleetopsSearchResourcesEmptyQueries();
+
+ $result = $capability->resolve(new AiTask(['prompt' => 'how do I create a new order']));
+
+ expect($result['query_terms'])->toBe([])
+ ->and($result['results'])->toBe([])
+ ->and($capability->appliedLikes)->toBe([]);
+});
+
+test('search resources never searches the dropped sensor_type column or status/uuid columns', function () {
+ $capability = new FleetOpsSearchResourcesCapabilityProbe();
+ $capability->allowedPermissions = $capability->permissions();
+ $capability->queries = fleetopsSearchResourcesEmptyQueries();
+
+ $capability->resolve(new AiTask(['prompt' => 'find SNS-1']));
+
+ $columns = collect($capability->appliedLikes)->flatMap(fn ($like) => $like[0])->unique()->all();
+
+ expect($columns)->not->toContain('sensor_type')
+ ->and($columns)->not->toContain('status')
+ ->and($columns)->not->toContain('uuid')
+ ->and($columns)->not->toContain('type');
+});
+
+test('a failing resource search does not discard the other results or leak the error', function () {
+ $vehicle = fleetopsSearchResourcesModel(Vehicle::class, ['public_id' => 'vehicle_public', 'uuid' => 'vehicle-uuid', 'name' => 'Van 12']);
+
+ $capability = new FleetOpsSearchResourcesCapabilityProbe();
+ $capability->allowedPermissions = $capability->permissions();
+ $capability->failing = ['sensors'];
+ $capability->queries = array_merge(fleetopsSearchResourcesEmptyQueries(), ['vehicles' => new FleetOpsSearchResourcesQueryFake([$vehicle])]);
+
+ $result = $capability->resolve(new AiTask(['prompt' => 'find VEH-12']));
+
+ expect($result['results'])->toHaveKey('vehicles')
+ ->and($result['unavailable_search'])->toBe(['sensors'])
+ ->and(json_encode($result))->not->toContain('SQLSTATE');
+});
diff --git a/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php b/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php
new file mode 100644
index 000000000..d29d700f4
--- /dev/null
+++ b/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php
@@ -0,0 +1,31 @@
+pluck('id')->duplicates())->toBeEmpty()
+ ->and($commands->every(fn ($command) => filled($command['label']) && filled($command['breadcrumb']) && filled($command['description']) && !empty($command['steps'])))->toBeTrue()
+ ->and($commands->every(fn ($command) => collect($command['steps'])->every(fn ($step) => $step['type'] === 'navigate'
+ ? str_starts_with($step['route'], 'console.fleet-ops.')
+ : $step['type'] === 'service' && $step['engine'] === FleetOpsAiConsoleCommands::ENGINE && preg_match('/^[a-zA-Z]+$/', $step['method']))))->toBeTrue()
+ ->and($commands->every(fn ($command) => ($command['audience'] ?? 'end_user') === 'end_user'))->toBeTrue();
+});
+
+test('fleet-ops console commands cover create, import, view, and settings', function () {
+ $commands = collect(FleetOpsAiConsoleCommands::all())->keyBy('id');
+
+ expect($commands['fleet-ops.orders.create']['steps'])->toBe([['type' => 'navigate', 'route' => 'console.fleet-ops.operations.orders.index.new']])
+ ->and($commands['fleet-ops.orders.create']['permissions'])->toBe(['fleet-ops create order'])
+ ->and($commands['fleet-ops.orders.import']['steps'][1])->toBe(['type' => 'service', 'engine' => '@fleetbase/fleetops-engine', 'service' => 'order-actions', 'method' => 'importOrders'])
+ ->and($commands['fleet-ops.customers.import']['steps'][1]['service'])->toBe('customer-actions')
+ ->and($commands['fleet-ops.customers.import']['permissions'])->toBe(['fleet-ops import contact'])
+ ->and($commands)->not->toHaveKey('fleet-ops.fleets.import')
+ ->and($commands['fleet-ops.drivers.view']['steps'][0]['models'])->toBe(['public_id'])
+ ->and($commands['fleet-ops.drivers.view']['params'])->toHaveKey('public_id')
+ ->and($commands['fleet-ops.settings.map.open']['steps'][0]['route'])->toBe('console.fleet-ops.settings.map')
+ ->and($commands['fleet-ops.settings.map.open']['permissions'])->toBe(['fleet-ops view map-settings'])
+ ->and($commands['fleet-ops.settings.map.open']['keywords'])->toContain('google maps', 'view maps')
+ ->and($commands['fleet-ops.settings.scheduling.open']['permissions'])->toBe([]);
+});
diff --git a/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php b/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php
new file mode 100644
index 000000000..766b84930
--- /dev/null
+++ b/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php
@@ -0,0 +1,246 @@
+buildDraft($task, $input);
+ }
+
+ protected function can(string $permission): bool
+ {
+ return true;
+ }
+
+ protected function draftFromPrompt(string $prompt): array
+ {
+ return array_filter([
+ 'order_config_uuid' => 'order-config-uuid',
+ 'type' => 'transport',
+ 'payload' => $prompt !== '' ? ['pickup_query' => 'Parsed from prompt'] : [],
+ 'dispatched' => false,
+ ], fn ($value) => $value !== []) + ['payload' => []];
+ }
+
+ protected function resolvePlace(?string $query): ?array
+ {
+ return $this->resolvedPlaces[$query] ?? null;
+ }
+
+ protected function resolveOrderConfig(array $draft): ?OrderConfig
+ {
+ $config = new OrderConfig();
+ $config->setRawAttributes(['uuid' => 'order-config-uuid', 'key' => 'transport', 'name' => 'Transport'], true);
+
+ return $config;
+ }
+
+ protected function resolveDriver(array $draft): ?Fleetbase\FleetOps\Models\Driver
+ {
+ return null;
+ }
+
+ protected function resolveVehicle(array $draft): ?Fleetbase\FleetOps\Models\Vehicle
+ {
+ return null;
+ }
+}
+
+class FleetOpsSearchResourcesToolProbe extends SearchResourcesTool
+{
+ public array $searched = [];
+
+ protected function searchAll(array $terms, ?array $types = null): array
+ {
+ $this->searched[] = [$terms, $types];
+
+ return ['query_terms' => $terms, 'results' => []];
+ }
+}
+
+test('create order tool prepares one draft preview per call from structured fields without parsing the prompt', function () {
+ $tool = new FleetOpsCreateOrderToolProbe();
+ $task = new AiTask(['prompt' => 'Create some dummy orders with made up data', 'metadata' => []]);
+ $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops create order']));
+
+ $first = $tool->invoke($task, [
+ 'pickup' => 'Kabelweg 57, Amsterdam',
+ 'dropoff' => 'Danzigerkade 15, Amsterdam',
+ 'scheduled_at' => '2026-09-16T09:30:00+02:00',
+ 'driver' => ' ',
+ 'notes' => 'Test order',
+ ], $context);
+ $second = $tool->invoke($task, ['pickup' => 'Diemerhof 32, Diemen', 'dropoff' => 'Herikerbergweg 238, Amsterdam', 'dispatch' => true], $context);
+
+ expect($first['preview_id'])->toBe('preview-1')
+ ->and($second['preview_id'])->toBe('preview-2')
+ ->and($first['status'])->toBe('draft_shown_to_user')
+ ->and($first['message'])->toContain('does not exist until the user reviews the card')
+ ->and($context->actionPreviews)->toHaveCount(2)
+ ->and($context->actionPreviews[0]['draft']['payload']['pickup_query'])->toBe('Kabelweg 57, Amsterdam')
+ ->and($context->actionPreviews[0]['draft']['scheduled_at'])->toBe('2026-09-16T09:30:00+02:00')
+ ->and($context->actionPreviews[0]['draft']['notes'])->toBe('Test order')
+ ->and($context->actionPreviews[0]['draft'])->not->toHaveKey('driver_query')
+ ->and($context->actionPreviews[0]['draft']['dispatched'])->toBeFalse()
+ ->and($context->actionPreviews[1]['draft']['payload']['pickup_query'])->toBe('Diemerhof 32, Diemen')
+ ->and($context->actionPreviews[1]['draft']['dispatched'])->toBeTrue()
+ ->and(json_encode($context->actionPreviews))->not->toContain('Parsed from prompt')
+ ->and($tool->toolName())->toBe('propose_create_order')
+ ->and($tool->key())->toBe('fleet-ops.create_order')
+ ->and($tool->toolDescription())->toContain('NOT created by this tool')
+ ->and($tool->toolParameters()['required'])->toBe(['pickup', 'dropoff'])
+ ->and($tool->availableFor($context))->toBeTrue()
+ ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, []))))->toBeFalse();
+});
+
+test('create order drafts refresh from the edited preview and still parse prompts in keyword mode', function () {
+ $tool = new FleetOpsCreateOrderToolProbe();
+ $task = new AiTask(['prompt' => 'create order', 'metadata' => ['action_previews' => [['draft' => ['notes' => 'first preview']]]]]);
+
+ $refreshed = $tool->exposeBuildDraft($task, ['existing_draft' => ['notes' => 'second preview'], 'draft' => ['dispatched' => true]]);
+ $keyword = $tool->exposeBuildDraft($task, ['dispatched' => false]);
+
+ expect($refreshed['notes'])->toBe('second preview')
+ ->and($refreshed['dispatched'])->toBeTrue()
+ ->and($refreshed['payload']['pickup_query'])->toBe('Parsed from prompt')
+ ->and($keyword['notes'])->toBe('first preview')
+ ->and($keyword)->not->toHaveKey('existing_draft');
+});
+
+test('search tool searches the requested phrase and identifiers for the chosen record types', function () {
+ $tool = new FleetOpsSearchResourcesToolProbe();
+ $task = new AiTask(['prompt' => 'where is Jane']);
+ $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops see driver']));
+
+ $tool->invoke($task, ['query' => 'Jane Doe', 'types' => ['drivers', 'pets']], $context);
+ $tool->invoke($task, ['query' => 'order order_yhkejdnzgz'], $context);
+
+ expect($tool->searched[0])->toBe([['Jane Doe', 'Doe'], ['drivers']])
+ ->and($tool->searched[1])->toBe([['order order_yhkejdnzgz', 'order_yhkejdnzgz'], null])
+ ->and($tool->invoke($task, ['query' => ' x '], $context))->toBe(['error' => 'Provide an identifier or name to search for.'])
+ ->and($tool->toolName())->toBe('fleetops_search')
+ ->and($tool->key())->toBe('fleet-ops.search_resources')
+ ->and($tool->toolDescription())->toContain('particular record')
+ ->and($tool->toolParameters()['properties']['types']['items']['enum'])->toBe(SearchResourcesTool::TYPES)
+ ->and($tool->availableFor($context))->toBeTrue()
+ ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, []))))->toBeFalse();
+});
+
+test('search tool narrows the real search to the requested types', function () {
+ $tool = new class extends SearchResourcesTool {
+ public array $called = [];
+
+ protected function orders(array $terms): array
+ {
+ $this->called[] = 'orders';
+
+ return [['id' => 'order_1']];
+ }
+
+ protected function drivers(array $terms): array
+ {
+ $this->called[] = 'drivers';
+
+ return [];
+ }
+ };
+
+ $result = $tool->invoke(new AiTask(['prompt' => '']), ['query' => 'ORDER-1', 'types' => ['orders']], new AiToolContext(new AiTask(), new AiAudience(true)));
+
+ expect($tool->called)->toBe(['orders'])
+ ->and($result['results'])->toBe(['orders' => [['id' => 'order_1']]]);
+});
+
+class FleetOpsOptimizeOrderRouteToolProbe extends Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool
+{
+ public array $previewedPrompts = [];
+
+ public function preview(AiTask $task, array $input = []): array
+ {
+ $this->previewedPrompts[] = $task->prompt;
+
+ return [
+ 'action' => $this->key(),
+ 'ready' => true,
+ 'message' => 'Fleetbase AI prepared an optimized waypoint sequence. Review it before applying.',
+ 'missing_fields' => [],
+ 'fields' => [['label' => 'Order', 'value' => 'order_abc']],
+ ];
+ }
+}
+
+test('optimize route tool proposes a reviewable preview for the order the model named', function () {
+ $tool = new FleetOpsOptimizeOrderRouteToolProbe();
+ $task = new AiTask(['prompt' => 'can you optimise the route please', 'metadata' => []]);
+ $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops optimize order', 'fleet-ops update-route-for order']));
+
+ $result = $tool->invoke($task, ['order' => 'order_abc'], $context);
+
+ expect($result['preview_id'])->toBe('preview-1')
+ ->and($result['ready'])->toBeTrue()
+ ->and($result['status'])->toBe('proposal_shown_to_user')
+ ->and($result['fields'])->toBe([['label' => 'Order', 'value' => 'order_abc']])
+ ->and($result['missing_fields'])->toBe([])
+ // The capability finds the order in the prompt text, so the tool hands it the id the model gave.
+ ->and($tool->previewedPrompts)->toBe(['order_abc'])
+ ->and($task->prompt)->toBe('can you optimise the route please')
+ ->and($context->actionPreviews)->toHaveCount(1)
+ ->and($tool->toolName())->toBe('propose_optimize_order_route')
+ ->and($tool->key())->toBe('fleet-ops.optimize_order_route')
+ ->and($tool->toolDescription())->toContain('NOT changed by this tool')
+ ->and($tool->toolParameters()['required'])->toBe(['order'])
+ ->and($tool->availableFor($context))->toBeTrue()
+ ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, ['fleet-ops optimize order']))))->toBeFalse();
+});
+
+test('optimize route tool refuses to build a preview without an order', function () {
+ $tool = new FleetOpsOptimizeOrderRouteToolProbe();
+ $task = new AiTask(['prompt' => 'optimise a route', 'metadata' => []]);
+ $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops optimize order', 'fleet-ops update-route-for order']));
+
+ expect($tool->invoke($task, ['order' => ' '], $context))->toBe([
+ 'error' => 'invalid_arguments',
+ 'message' => 'An order id is required to optimize a route.',
+ ])
+ ->and($tool->invoke($task, [], $context)['error'])->toBe('invalid_arguments')
+ ->and($context->actionPreviews)->toBe([])
+ ->and($tool->previewedPrompts)->toBe([]);
+});
+
+class FleetOpsImportOrdersToolProbe extends Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool
+{
+ protected function can(string $permission): bool
+ {
+ return true;
+ }
+}
+
+test('import tool reports what an import needs and never claims to have imported anything', function () {
+ $tool = new FleetOpsImportOrdersToolProbe();
+ $task = new AiTask(['prompt' => 'can I import orders from a spreadsheet', 'metadata' => []]);
+ $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops import order']));
+
+ $result = $tool->invoke($task, [], $context);
+
+ expect($result['accepted_sources'])->toBe(['xlsx', 'csv'])
+ ->and($result['minimum_columns'])->toContain('pickup address or pickup place')
+ ->and($result['can_import_here'])->toBeFalse()
+ ->and($result['message'])->toContain('will not process uploaded spreadsheets')
+ ->and($context->actionPreviews)->toBe([])
+ ->and($tool->toolName())->toBe('fleetops_import_requirements')
+ ->and($tool->key())->toBe('fleet-ops.import_orders_preview')
+ ->and($tool->toolDescription())->toContain('does NOT import anything')
+ ->and($tool->toolParameters()['properties'])->toBeInstanceOf(stdClass::class)
+ ->and(json_encode($tool->toolParameters()['properties']))->toBe('{}')
+ ->and($tool->availableFor($context))->toBeTrue()
+ ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, []))))->toBeFalse();
+});
diff --git a/server/tests/Unit/Support/AiCapabilityPermissionsTest.php b/server/tests/Unit/Support/AiCapabilityPermissionsTest.php
index b619610e6..61f6dc4a6 100644
--- a/server/tests/Unit/Support/AiCapabilityPermissionsTest.php
+++ b/server/tests/Unit/Support/AiCapabilityPermissionsTest.php
@@ -96,7 +96,7 @@ function fleetopsAiPermissionsHelper(string $method): ReflectionMethod
->and(fn () => fleetopsAiPermissionsHelper('canAll')->invoke($capability, ['fleet-ops list order']))->toThrow(TypeError::class);
});
-test('search terms extract identifiers and fall back to the raw prompt', function () {
+test('search terms extract identifiers and never fall back to the raw prompt', function () {
fleetopsAiPermissionsBoot();
$capability = new OperationalQueryCapability();
$searchTerms = fleetopsAiPermissionsHelper('searchTerms');
@@ -105,8 +105,8 @@ function fleetopsAiPermissionsHelper(string $method): ReflectionMethod
expect($terms)->toContain('TRK-12345', 'atlas99')
->and($terms)->not->toContain('find', 'order');
- // Stop-word-only prompts fall back to the trimmed prompt
- expect($searchTerms->invoke($capability, 'find order'))->toBe(['find order']);
+ // Prompts without a record reference produce no search terms, so nothing is searched
+ expect($searchTerms->invoke($capability, 'find order'))->toBe([]);
});
test('where like any matches across columns and terms', function () {
diff --git a/server/tests/Unit/Support/ProfileAccountManagerTest.php b/server/tests/Unit/Support/ProfileAccountManagerTest.php
new file mode 100644
index 000000000..9c1828f49
--- /dev/null
+++ b/server/tests/Unit/Support/ProfileAccountManagerTest.php
@@ -0,0 +1,643 @@
+ str_replace('_', ' ', Illuminate\Support\Str::snake((string) $value)));
+}
+
+use Fleetbase\FleetOps\Exceptions\ProfileIdentityConflictException;
+use Fleetbase\FleetOps\Mail\CustomerCredentialsMail;
+use Fleetbase\FleetOps\Mail\DriverCredentialsMail;
+use Fleetbase\FleetOps\Models\Contact;
+use Fleetbase\FleetOps\Models\Driver;
+use Fleetbase\FleetOps\Support\ProfileAccountManager;
+use Fleetbase\Models\CompanyUser;
+use Fleetbase\Models\User;
+use Fleetbase\Services\SmsService;
+use Illuminate\Database\ConnectionResolver;
+use Illuminate\Database\Eloquent\Model as EloquentModel;
+use Illuminate\Database\SQLiteConnection;
+
+/**
+ * Covers ProfileAccountManager against SQLite with real spatie roles: account
+ * classification, identity lookups, linking a staff member or a managed
+ * account of the same type, creating managed accounts without invites, proxy
+ * field sync, releasing an account when its profile is deleted, and sending
+ * credentials by email or SMS.
+ */
+class FleetOpsProfileAccountSmsFake extends SmsService
+{
+ public array $sent = [];
+ public array $result = ['success' => true];
+
+ public function __construct()
+ {
+ }
+
+ public function send(string $to, string $text, array $options = [], ?string $provider = null): array
+ {
+ $this->sent[] = [$to, $text];
+
+ return $this->result;
+ }
+}
+
+function fleetopsProfileAccountBoot(): SQLiteConnection
+{
+ // Model uuid hooks bind to whichever dispatcher exists when the class boots
+ if (!EloquentModel::getEventDispatcher()) {
+ EloquentModel::setEventDispatcher(new Illuminate\Events\Dispatcher());
+ }
+
+ $connection = new SQLiteConnection(new PDO('sqlite::memory:'));
+ $resolver = new ConnectionResolver(['default' => $connection, 'mysql' => $connection]);
+ $resolver->setDefaultConnection('mysql');
+ EloquentModel::setConnectionResolver($resolver);
+ app()->instance('db', new class($connection) {
+ public function __construct(public SQLiteConnection $c)
+ {
+ }
+
+ public function connection($name = null): SQLiteConnection
+ {
+ return $this->c;
+ }
+
+ public function __call($method, $arguments)
+ {
+ return $this->c->{$method}(...$arguments);
+ }
+ });
+ app()->instance('db.schema', $connection->getSchemaBuilder());
+ Illuminate\Support\Facades\DB::clearResolvedInstance('db');
+ app()->instance('responsecache', new class {
+ public function __call($method, $arguments)
+ {
+ return null;
+ }
+ });
+ app()->instance('hash', new class implements Illuminate\Contracts\Hashing\Hasher {
+ public function info($hashedValue): array
+ {
+ return [];
+ }
+
+ public function make($value, array $options = []): string
+ {
+ return 'hashed:' . $value;
+ }
+
+ public function check($value, $hashedValue, array $options = []): bool
+ {
+ return 'hashed:' . $value === $hashedValue;
+ }
+
+ public function needsRehash($hashedValue, array $options = []): bool
+ {
+ return false;
+ }
+ });
+ Illuminate\Support\Facades\Hash::clearResolvedInstance('hash');
+
+ Illuminate\Support\Facades\Mail::swap(new class {
+ public array $sent = [];
+ public array $to = [];
+
+ public function to($users)
+ {
+ $this->to[] = $users;
+
+ return $this;
+ }
+
+ public function send($mailable)
+ {
+ $this->sent[] = $mailable;
+
+ return null;
+ }
+ });
+
+ $sms = new FleetOpsProfileAccountSmsFake();
+ app()->instance(SmsService::class, $sms);
+
+ $schema = $connection->getSchemaBuilder();
+ $tables = [
+ 'users' => ['uuid', 'public_id', 'company_uuid', 'name', 'email', 'phone', 'password', 'status', 'type', 'username', 'avatar_uuid', 'slug', 'timezone', 'country', 'ip_address', 'meta', 'last_login', '_key'],
+ 'drivers' => ['uuid', 'public_id', 'company_uuid', 'user_uuid'],
+ 'contacts' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'name', 'type'],
+ 'companies' => ['uuid', 'public_id', 'name', 'owner_uuid', 'timezone'],
+ 'company_users' => ['uuid', 'public_id', 'company_uuid', 'user_uuid', 'status', '_key'],
+ 'permissions' => ['name', 'guard_name', 'service', 'description'],
+ 'roles' => ['uuid', 'public_id', 'name', 'guard_name', 'company_uuid', 'service', 'description', '_key'],
+ 'model_has_roles' => ['role_id', 'model_type', 'model_uuid'],
+ 'model_has_permissions' => ['permission_id', 'model_type', 'model_uuid'],
+ 'role_has_permissions' => ['permission_id', 'role_id'],
+ ];
+ foreach ($tables as $table => $columns) {
+ $schema->create($table, function ($blueprint) use ($columns) {
+ $blueprint->increments('id');
+ foreach ($columns as $column) {
+ $blueprint->string($column)->nullable();
+ }
+ $blueprint->timestamps();
+ $blueprint->timestamp('deleted_at')->nullable();
+ });
+ }
+
+ app()->instance('cache', new class {
+ public function tags($tags = null)
+ {
+ return $this;
+ }
+
+ public function flush()
+ {
+ return true;
+ }
+
+ public function remember($key, $ttl, $callback)
+ {
+ return $callback();
+ }
+
+ public function store($name = null)
+ {
+ return $this;
+ }
+
+ public function __call($method, $arguments)
+ {
+ return null;
+ }
+ });
+ Illuminate\Support\Facades\Cache::clearResolvedInstance('cache');
+ config()->set('cache.default', 'array');
+ config()->set('cache.stores.array', ['driver' => 'array']);
+ config()->set('permission.cache.expiration_time', 60);
+ config()->set('permission.cache.key', 'spatie.permission.cache');
+ config()->set('permission.cache.store', 'default');
+ config()->set('permission.models.permission', Fleetbase\Models\Permission::class);
+ config()->set('permission.models.role', Fleetbase\Models\Role::class);
+ config()->set('permission.table_names', ['roles' => 'roles', 'permissions' => 'permissions', 'model_has_permissions' => 'model_has_permissions', 'model_has_roles' => 'model_has_roles', 'role_has_permissions' => 'role_has_permissions']);
+ config()->set('permission.column_names', ['role_pivot_key' => null, 'permission_pivot_key' => null, 'model_morph_key' => 'model_uuid', 'team_foreign_key' => 'team_id']);
+ config()->set('permission.teams', false);
+ config()->set('permission.events_enabled', false);
+ $cacheManager = new Illuminate\Cache\CacheManager(app());
+ app()->instance(Illuminate\Cache\CacheManager::class, $cacheManager);
+ app()->instance(Spatie\Permission\PermissionRegistrar::class, new Spatie\Permission\PermissionRegistrar($cacheManager));
+ config()->set('activitylog.enabled', false);
+ config()->set('activitylog.default_auth_driver', 'web');
+ config()->set('auth.defaults.guard', 'web');
+ config()->set('app.name', 'Fleetbase');
+ app()->bind(Illuminate\Contracts\Config\Repository::class, fn () => config());
+
+ session(['company' => 'company-1']);
+ $connection->table('companies')->insert([
+ ['uuid' => 'company-1', 'public_id' => 'company_one', 'name' => 'Acme', 'timezone' => 'Asia/Singapore'],
+ ['uuid' => 'company-2', 'public_id' => 'company_two', 'name' => 'Globex', 'timezone' => null],
+ ]);
+ $roles = [];
+ foreach (['company-1', 'company-2'] as $company) {
+ foreach (['Driver', 'Fleet-Ops Contact', 'Fleet-Ops Customer'] as $name) {
+ foreach (['web', 'sanctum'] as $guard) {
+ $roles[] = ['uuid' => $company . $name . $guard, 'name' => $name, 'guard_name' => $guard, 'company_uuid' => $company];
+ }
+ }
+ }
+ $connection->table('roles')->insert($roles);
+
+ return $connection;
+}
+
+function fleetopsProfileAccountRoles(SQLiteConnection $connection, string $userUuid, string $companyUuid): array
+{
+ return $connection->table('model_has_roles')
+ ->join('roles', 'roles.id', '=', 'model_has_roles.role_id')
+ ->join('company_users', 'company_users.uuid', '=', 'model_has_roles.model_uuid')
+ ->where('company_users.user_uuid', $userUuid)
+ ->where('company_users.company_uuid', $companyUuid)
+ ->pluck('roles.name')
+ ->unique()
+ ->values()
+ ->all();
+}
+
+/**
+ * Insert rows one at a time: a multi-row insert takes its column list from the
+ * first row, so rows with different keys would land in the wrong columns.
+ */
+function fleetopsProfileAccountRows(SQLiteConnection $connection, string $table, array $rows): void
+{
+ foreach ($rows as $row) {
+ $connection->table($table)->insert($row);
+ }
+}
+
+function fleetopsProfileAccountUser(array $attributes): User
+{
+ app('db')->connection()->table('users')->insert($attributes);
+
+ return User::where('uuid', $attributes['uuid'])->first();
+}
+
+test('accounts are managed when typed driver contact or customer and staff otherwise', function () {
+ expect(ProfileAccountManager::isManagedAccount(null))->toBeFalse()
+ ->and(ProfileAccountManager::isStaffAccount(null))->toBeFalse()
+ ->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'driver']))->toBeTrue()
+ ->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'contact']))->toBeTrue()
+ ->and(ProfileAccountManager::isManagedAccount((object) ['type' => 'customer']))->toBeTrue()
+ ->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'customer']))->toBeFalse()
+ ->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'user']))->toBeTrue()
+ ->and(ProfileAccountManager::isStaffAccount((object) ['type' => 'admin']))->toBeTrue()
+ ->and(ProfileAccountManager::isStaffAccount((object) []))->toBeTrue()
+ ->and(ProfileAccountManager::normalizeEmail(' Ada@Example.COM '))->toBe('ada@example.com')
+ ->and(ProfileAccountManager::normalizeEmail(' '))->toBeNull()
+ ->and(ProfileAccountManager::normalizeEmail(null))->toBeNull()
+ ->and(ProfileAccountManager::normalizePhone(' +65 9123-4567 '))->toBe('+6591234567')
+ ->and(ProfileAccountManager::normalizePhone(''))->toBeNull()
+ ->and(ProfileAccountManager::normalizePhone(null))->toBeNull();
+});
+
+test('identity lookups match email or phone and skip ignored and deleted accounts', function () {
+ $connection = fleetopsProfileAccountBoot();
+ fleetopsProfileAccountRows($connection, 'users', [
+ ['uuid' => 'user-email', 'email' => 'ada@example.com', 'type' => 'user'],
+ ['uuid' => 'user-phone', 'phone' => '+6591234567', 'type' => 'driver'],
+ ['uuid' => 'user-deleted', 'email' => 'gone@example.com', 'type' => 'driver', 'deleted_at' => '2026-01-01 00:00:00'],
+ ]);
+
+ expect(ProfileAccountManager::findAccountByIdentity(null, ' '))->toBeNull()
+ ->and(ProfileAccountManager::findAccountByIdentity('ADA@example.com', null)?->uuid)->toBe('user-email')
+ ->and(ProfileAccountManager::findAccountByIdentity(null, '+65 9123 4567')?->uuid)->toBe('user-phone')
+ ->and(ProfileAccountManager::findAccountByIdentity('nobody@example.com', '+6591234567')?->uuid)->toBe('user-phone')
+ ->and(ProfileAccountManager::findAccountByIdentity('ada@example.com', null, 'user-email'))->toBeNull()
+ ->and(ProfileAccountManager::findAccountByIdentity('gone@example.com', null))->toBeNull();
+});
+
+test('lookup reports the staff member to link or why the identity is unavailable', function () {
+ $connection = fleetopsProfileAccountBoot();
+ fleetopsProfileAccountRows($connection, 'users', [
+ ['uuid' => 'staff-1', 'company_uuid' => 'company-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user'],
+ ['uuid' => 'staff-2', 'company_uuid' => 'company-2', 'email' => 'outsider@example.com', 'type' => 'admin'],
+ ['uuid' => 'driver-1', 'company_uuid' => 'company-2', 'phone' => '+6590001111', 'type' => 'driver'],
+ ['uuid' => 'current-1', 'company_uuid' => 'company-1', 'email' => 'me@example.com', 'type' => 'driver'],
+ ]);
+ $current = User::where('uuid', 'current-1')->first();
+
+ $nothing = ProfileAccountManager::lookup('company-1', 'driver', 'free@example.com', null);
+ $staff = ProfileAccountManager::lookup('company-1', 'driver', 'staff@example.com', null);
+ $outsider = ProfileAccountManager::lookup('company-1', 'driver', 'outsider@example.com', null);
+ $customer = ProfileAccountManager::lookup('company-1', 'customer', null, '+6590001111');
+ $sameType = ProfileAccountManager::lookup('company-1', 'driver', null, '+6590001111');
+ $taken = ProfileAccountManager::lookup('company-1', 'driver', 'staff@example.com', null, $current);
+ $own = ProfileAccountManager::lookup('company-1', 'driver', 'me@example.com', null, $current);
+
+ expect($nothing)->toBe(['account' => null, 'staff' => null, 'conflict' => null])
+ ->and($staff['staff']?->uuid)->toBe('staff-1')
+ ->and($staff['conflict'])->toBeNull()
+ ->and($outsider['staff'])->toBeNull()
+ ->and($outsider['conflict'])->toBe('This email is already in use by another account.')
+ ->and($customer['conflict'])->toBe('This phone number is already used by a driver.')
+ // A driver account of another organization is linked, not reported as staff
+ ->and($sameType['account']?->uuid)->toBe('driver-1')
+ ->and($sameType['staff'])->toBeNull()
+ ->and($sameType['conflict'])->toBeNull()
+ // An existing profile can't swap its account for another one
+ ->and($taken['conflict'])->toBe('This email is already in use by another account.')
+ ->and($taken['staff'])->toBeNull()
+ ->and($own['account'])->toBeNull();
+});
+
+test('resolve for profile creates a managed account with the profile role and no invite', function () {
+ $connection = fleetopsProfileAccountBoot();
+
+ $driver = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Dana Driver', 'Dana@Example.com', '+65 9000 1111', [
+ 'password' => 'chosen-secret',
+ 'status' => 'active',
+ 'avatar_uuid' => 'avatar-1',
+ 'country' => 'SG',
+ 'ip_address' => '127.0.0.1',
+ ]);
+
+ $row = $connection->table('users')->where('uuid', $driver->uuid)->first();
+
+ expect($driver->uuid)->not->toBeNull()
+ ->and($row->type)->toBe('driver')
+ ->and($row->email)->toBe('dana@example.com')
+ ->and($row->phone)->toBe('+6590001111')
+ ->and($row->password)->toBe('hashed:chosen-secret')
+ ->and($row->status)->toBe('active')
+ ->and($row->timezone)->toBe('Asia/Singapore')
+ ->and($row->avatar_uuid)->toBe('avatar-1')
+ ->and($row->company_uuid)->toBe('company-1')
+ ->and(fleetopsProfileAccountRoles($connection, $driver->uuid, 'company-1'))->toBe(['Driver'])
+ ->and($driver->getRelation('companyUser'))->toBeInstanceOf(CompanyUser::class);
+
+ // A nameless customer in a company without a timezone gets a generated
+ // username, a random password, the default status and the server timezone
+ $customer = ProfileAccountManager::resolveForProfile('company-2', 'customer', null, null, '+6590002222');
+ $customerRow = $connection->table('users')->where('uuid', $customer->uuid)->first();
+
+ expect($customerRow->type)->toBe('customer')
+ ->and($customerRow->status)->toBe('pending')
+ ->and($customerRow->name)->toBeNull()
+ ->and($customerRow->username)->not->toBeEmpty()
+ ->and($customerRow->password)->toStartWith('hashed:')
+ ->and($customerRow->timezone)->toBe(date_default_timezone_get())
+ ->and(fleetopsProfileAccountRoles($connection, $customer->uuid, 'company-2'))->toBe(['Fleet-Ops Customer']);
+
+ // A contact of an organization missing from the table is created without membership
+ $contact = ProfileAccountManager::createManagedAccount('company-missing', 'contact', 'Carl Contact', 'carl@example.com', null, ['timezone' => 'UTC']);
+
+ expect($connection->table('users')->where('uuid', $contact->uuid)->value('type'))->toBe('contact')
+ ->and($connection->table('users')->where('uuid', $contact->uuid)->value('timezone'))->toBe('UTC')
+ ->and($connection->table('company_users')->where('user_uuid', $contact->uuid)->count())->toBe(0);
+});
+
+test('resolve for profile links staff members and managed accounts of the same type', function () {
+ $connection = fleetopsProfileAccountBoot();
+ fleetopsProfileAccountRows($connection, 'users', [
+ ['uuid' => 'staff-1', 'company_uuid' => 'company-2', 'name' => 'Staff', 'email' => 'staff@example.com', 'type' => 'user'],
+ ['uuid' => 'driver-1', 'company_uuid' => 'company-2', 'phone' => '+6590001111', 'type' => 'driver'],
+ ['uuid' => 'driver-2', 'company_uuid' => null, 'phone' => '+6590003333', 'type' => 'driver'],
+ ]);
+ // The staff member belongs to company-1 through a membership row
+ $connection->table('company_users')->insert(['uuid' => 'cu-staff', 'company_uuid' => 'company-1', 'user_uuid' => 'staff-1', 'status' => 'active']);
+
+ $staff = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Other Name', 'STAFF@example.com', null);
+ $driver = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Driver', null, '+6590001111');
+ $orphan = ProfileAccountManager::resolveForProfile('company-1', 'driver', 'Orphan', null, '+6590003333');
+
+ expect($staff->uuid)->toBe('staff-1')
+ ->and($staff->type)->toBe('user')
+ ->and($connection->table('model_has_roles')->where('model_uuid', 'cu-staff')->count())->toBe(0)
+ ->and($driver->uuid)->toBe('driver-1')
+ ->and(fleetopsProfileAccountRoles($connection, 'driver-1', 'company-1'))->toBe(['Driver'])
+ // The account keeps its original organization
+ ->and($connection->table('users')->where('uuid', 'driver-1')->value('company_uuid'))->toBe('company-2')
+ // An account without an organization adopts this one
+ ->and($orphan->uuid)->toBe('driver-2')
+ ->and($connection->table('users')->where('uuid', 'driver-2')->value('company_uuid'))->toBe('company-1')
+ ->and($connection->table('users')->count())->toBe(3);
+});
+
+test('resolve for profile rejects accounts that cannot hold the profile', function () {
+ $connection = fleetopsProfileAccountBoot();
+ fleetopsProfileAccountRows($connection, 'users', [
+ ['uuid' => 'staff-2', 'company_uuid' => 'company-2', 'email' => 'outsider@example.com', 'type' => 'admin'],
+ ['uuid' => 'customer-1', 'company_uuid' => 'company-1', 'phone' => '+6590001111', 'type' => 'customer'],
+ ['uuid' => 'driver-1', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'type' => 'driver'],
+ ['uuid' => 'contact-1', 'company_uuid' => 'company-1', 'email' => 'contact@example.com', 'type' => 'contact'],
+ ]);
+ $connection->table('drivers')->insert(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'driver-1']);
+ $connection->table('contacts')->insert(['uuid' => 'c-1', 'company_uuid' => 'company-1', 'user_uuid' => 'contact-1', 'type' => 'contact']);
+
+ $conflict = function (callable $callback): ?ProfileIdentityConflictException {
+ try {
+ $callback();
+ } catch (ProfileIdentityConflictException $exception) {
+ return $exception;
+ }
+
+ return null;
+ };
+
+ $outsider = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'outsider@example.com', null));
+ $wrong = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'nobody@example.com', '+6590001111'));
+ $driver = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'driver', 'X', 'driver@example.com', null));
+ $contact = $conflict(fn () => ProfileAccountManager::resolveForProfile('company-1', 'contact', 'X', 'contact@example.com', null));
+
+ expect($outsider?->getMessage())->toBe('This email is already in use by another account.')
+ ->and($outsider?->getField())->toBe('email')
+ ->and($outsider?->getErrors())->toBe(['email' => ['This email is already in use by another account.']])
+ ->and($wrong?->getMessage())->toBe('This phone number is already used by a customer.')
+ ->and($wrong?->getField())->toBe('phone')
+ ->and($driver?->getMessage())->toBe('A driver with this email already exists.')
+ ->and($contact?->getMessage())->toBe('A contact with this email already exists.')
+ ->and(ProfileAccountManager::hasProfileInCompany(User::where('uuid', 'driver-1')->first(), 'company-2', 'driver'))->toBeFalse()
+ ->and(ProfileAccountManager::isCompanyMember(User::where('uuid', 'driver-1')->first(), null))->toBeFalse()
+ ->and(new ProfileIdentityConflictException('Taken'))->getField()->toBe('email');
+});
+
+test('sync proxy fields pushes normalized changes to managed accounts and only the name to staff', function () {
+ $connection = fleetopsProfileAccountBoot();
+ $connection->table('users')->insert(['uuid' => 'other-1', 'email' => 'taken@example.com', 'phone' => '+6599990000', 'type' => 'user']);
+ $managed = fleetopsProfileAccountUser(['uuid' => 'managed-1', 'name' => 'Old', 'email' => 'old@example.com', 'phone' => '+6590000000', 'timezone' => 'UTC', 'type' => 'driver']);
+ $staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'name' => 'Staff', 'email' => 'staff@example.com', 'phone' => '+6591111111', 'type' => 'user']);
+
+ expect(ProfileAccountManager::syncProxyFields(null, ['name' => 'Nobody']))->toBeFalse()
+ // Unchanged values and fields outside the proxy set are ignored
+ ->and(ProfileAccountManager::syncProxyFields($managed, ['name' => 'Old', 'email' => 'OLD@example.com', 'type' => 'admin', 'timezone' => null]))->toBeFalse();
+
+ expect(ProfileAccountManager::syncProxyFields($managed, [
+ 'name' => 'New',
+ 'email' => ' New@Example.com ',
+ 'phone' => '+65 9000 0001',
+ 'timezone' => 'Asia/Singapore',
+ 'password' => 'new-secret',
+ ]))->toBeTrue();
+
+ $row = $connection->table('users')->where('uuid', 'managed-1')->first();
+ expect($row->name)->toBe('New')
+ ->and($row->email)->toBe('new@example.com')
+ ->and($row->phone)->toBe('+6590000001')
+ ->and($row->timezone)->toBe('Asia/Singapore')
+ ->and($row->password)->toBe('hashed:new-secret');
+
+ // Clearing the phone is a change the account takes
+ expect(ProfileAccountManager::syncProxyFields($managed, ['phone' => null]))->toBeTrue()
+ ->and($connection->table('users')->where('uuid', 'managed-1')->value('phone'))->toBeNull();
+
+ // A staff account only takes the name
+ expect(ProfileAccountManager::syncProxyFields($staff, ['name' => 'Staff Driver', 'email' => 'changed@example.com', 'phone' => null]))->toBeTrue()
+ ->and($connection->table('users')->where('uuid', 'staff-1')->first())
+ ->name->toBe('Staff Driver')
+ ->email->toBe('staff@example.com')
+ ->phone->toBe('+6591111111');
+
+ // Taking another account's email or phone is refused
+ $emailConflict = null;
+ try {
+ ProfileAccountManager::syncProxyFields($managed, ['email' => 'Taken@example.com']);
+ } catch (ProfileIdentityConflictException $exception) {
+ $emailConflict = $exception;
+ }
+
+ $phoneConflict = null;
+ try {
+ ProfileAccountManager::syncProxyFields($managed, ['email' => 'new@example.com', 'phone' => '+6599990000']);
+ } catch (ProfileIdentityConflictException $exception) {
+ $phoneConflict = $exception;
+ }
+
+ expect($emailConflict?->getMessage())->toBe('This email is already in use by another account.')
+ ->and($emailConflict?->getField())->toBe('email')
+ ->and($phoneConflict?->getMessage())->toBe('This phone number is already in use by another account.')
+ ->and($phoneConflict?->getField())->toBe('phone')
+ ->and($connection->table('users')->where('uuid', 'managed-1')->value('email'))->toBe('new@example.com');
+});
+
+test('release for profile deletes an unused managed account and leaves staff alone', function () {
+ $connection = fleetopsProfileAccountBoot();
+ config()->set('activitylog.enabled', false);
+ $lonely = fleetopsProfileAccountUser(['uuid' => 'lonely-1', 'type' => 'driver']);
+ $shared = fleetopsProfileAccountUser(['uuid' => 'shared-1', 'type' => 'contact']);
+ $staying = fleetopsProfileAccountUser(['uuid' => 'staying-1', 'type' => 'driver']);
+ $staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'type' => 'user']);
+ $connection->table('contacts')->insert(['uuid' => 'c-2', 'company_uuid' => 'company-2', 'user_uuid' => 'shared-1', 'type' => 'contact']);
+ $connection->table('drivers')->insert(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'staying-1']);
+ $connection->table('company_users')->insert([
+ ['uuid' => 'cu-1', 'company_uuid' => 'company-1', 'user_uuid' => 'shared-1'],
+ ['uuid' => 'cu-2', 'company_uuid' => 'company-2', 'user_uuid' => 'shared-1'],
+ ['uuid' => 'cu-3', 'company_uuid' => 'company-1', 'user_uuid' => 'staying-1'],
+ ]);
+
+ ProfileAccountManager::releaseForProfile(null, 'company-1');
+ ProfileAccountManager::releaseForProfile($staff, 'company-1');
+ ProfileAccountManager::releaseForProfile($lonely, 'company-1');
+ // Already deleted accounts are skipped
+ ProfileAccountManager::releaseForProfile($lonely, 'company-1');
+ ProfileAccountManager::releaseForProfile($shared, 'company-1');
+ ProfileAccountManager::releaseForProfile($staying, 'company-1');
+ ProfileAccountManager::releaseForProfile($staying);
+
+ expect($connection->table('users')->where('uuid', 'lonely-1')->value('deleted_at'))->not->toBeNull()
+ ->and($lonely->trashed())->toBeTrue()
+ ->and($connection->table('users')->where('uuid', 'staff-1')->value('deleted_at'))->toBeNull()
+ ->and($connection->table('users')->where('uuid', 'shared-1')->value('deleted_at'))->toBeNull()
+ // The shared account only leaves the organization it has no profile in
+ ->and($connection->table('company_users')->whereNull('deleted_at')->where('user_uuid', 'shared-1')->pluck('company_uuid')->all())->toBe(['company-2'])
+ ->and($connection->table('users')->where('uuid', 'staying-1')->value('deleted_at'))->toBeNull()
+ ->and($connection->table('company_users')->whereNull('deleted_at')->where('user_uuid', 'staying-1')->count())->toBe(1)
+ ->and(ProfileAccountManager::profileCompanies($shared)->all())->toBe(['company-2']);
+});
+
+test('attach to company adds membership once and only re-roles managed accounts', function () {
+ $connection = fleetopsProfileAccountBoot();
+ $managed = fleetopsProfileAccountUser(['uuid' => 'managed-1', 'company_uuid' => 'company-2', 'type' => 'customer']);
+ $staff = fleetopsProfileAccountUser(['uuid' => 'staff-1', 'company_uuid' => 'company-1', 'type' => 'user']);
+ $connection->table('company_users')->insert(['uuid' => 'cu-staff', 'company_uuid' => 'company-1', 'user_uuid' => 'staff-1', 'status' => 'active']);
+
+ expect(ProfileAccountManager::attachToCompany($managed, 'company-missing', 'customer'))->toBeNull();
+
+ $first = ProfileAccountManager::attachToCompany($managed, 'company-1', 'customer');
+ $second = ProfileAccountManager::attachToCompany($managed, 'company-1', 'unknown-type');
+ $staffMembership = ProfileAccountManager::attachToCompany($staff, 'company-1', 'driver');
+
+ expect($first->uuid)->toBe($second->uuid)
+ ->and($connection->table('company_users')->where('user_uuid', 'managed-1')->count())->toBe(1)
+ // An unknown type falls back to the contact role
+ ->and(fleetopsProfileAccountRoles($connection, 'managed-1', 'company-1'))->toBe(['Fleet-Ops Contact'])
+ ->and($staffMembership->uuid)->toBe('cu-staff')
+ ->and($connection->table('model_has_roles')->where('model_uuid', 'cu-staff')->count())->toBe(0)
+ ->and($connection->table('users')->where('uuid', 'managed-1')->value('company_uuid'))->toBe('company-2');
+});
+
+test('send credentials resets the password activates the account and emails or texts it', function () {
+ $connection = fleetopsProfileAccountBoot();
+ $mail = Illuminate\Support\Facades\Mail::getFacadeRoot();
+ $sms = app(SmsService::class);
+
+ $emailUser = fleetopsProfileAccountUser(['uuid' => 'email-1', 'company_uuid' => 'company-1', 'email' => 'driver@example.com', 'status' => 'pending', 'type' => 'driver']);
+ $driver = new Driver();
+ $driver->setRawAttributes(['uuid' => 'd-1', 'company_uuid' => 'company-1', 'user_uuid' => 'email-1'], true);
+ $driver->setRelation('company', (object) ['name' => 'Acme']);
+
+ expect(ProfileAccountManager::sendCredentials($driver, $emailUser, 'chosen-secret'))->toBe('email')
+ ->and($connection->table('users')->where('uuid', 'email-1')->value('password'))->toBe('hashed:chosen-secret')
+ ->and($connection->table('users')->where('uuid', 'email-1')->value('status'))->toBe('active')
+ ->and($mail->sent[0])->toBeInstanceOf(DriverCredentialsMail::class)
+ ->and($mail->to[0])->toBe($emailUser);
+
+ // An active account is not re-activated; a random password is generated
+ $customerUser = fleetopsProfileAccountUser(['uuid' => 'email-2', 'company_uuid' => 'company-1', 'email' => 'customer@example.com', 'status' => 'active', 'type' => 'customer']);
+ $customer = new Contact();
+ $customer->setRawAttributes(['uuid' => 'c-1', 'company_uuid' => 'company-1', 'type' => 'customer'], true);
+
+ expect(ProfileAccountManager::sendCredentials($customer, $customerUser))->toBe('email')
+ ->and($connection->table('users')->where('uuid', 'email-2')->value('password'))->toStartWith('hashed:')
+ ->and($mail->sent[1])->toBeInstanceOf(CustomerCredentialsMail::class);
+
+ // Without an email the credentials are texted, naming the organization
+ $phoneUser = fleetopsProfileAccountUser(['uuid' => 'phone-1', 'phone' => '+6590001111', 'status' => 'active', 'type' => 'driver']);
+ expect(ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'texted-secret'))->toBe('sms')
+ ->and($sms->sent[0])->toBe(['+6590001111', 'Your Acme sign-in details. Login: +6590001111 Password: texted-secret']);
+
+ // An organization-less profile falls back to the app name
+ $orphan = new Driver();
+ $orphan->setRawAttributes(['uuid' => 'd-2'], true);
+ $orphan->setRelation('company', null);
+ ProfileAccountManager::deliverCredentials($orphan, $phoneUser, 'app-secret');
+ expect($sms->sent[1][1])->toBe('Your Fleetbase sign-in details. Login: +6590001111 Password: app-secret');
+
+ // A refused text surfaces the provider's reason
+ $sms->result = ['success' => false, 'error' => 'Invalid number'];
+ expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))
+ ->toThrow(Exception::class, 'The credentials could not be texted: Invalid number');
+
+ $sms->result = ['success' => false, 'message' => 'Quota exceeded'];
+ expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))
+ ->toThrow(Exception::class, 'The credentials could not be texted: Quota exceeded');
+
+ $sms->result = ['success' => false];
+ expect(fn () => ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))
+ ->toThrow(Exception::class, 'The credentials could not be texted: the SMS provider refused it.');
+
+ // A result without a success flag is treated as sent
+ $sms->result = [];
+ expect(ProfileAccountManager::deliverCredentials($driver, $phoneUser, 'x'))->toBe('sms');
+
+ $unreachable = fleetopsProfileAccountUser(['uuid' => 'none-1', 'status' => 'active', 'type' => 'driver']);
+ expect(fn () => ProfileAccountManager::deliverCredentials($driver, $unreachable, 'x'))
+ ->toThrow(Exception::class, 'This profile has no email or phone to send credentials to.');
+});
+
+test('driver credentials mail names the organization and the sign-in identity', function () {
+ config()->set('app.name', 'Fleetbase');
+ $user = new User();
+ $user->setRawAttributes(['name' => 'Dana', 'email' => null, 'phone' => '+6590001111'], true);
+
+ $driver = new Driver();
+ $driver->setRelation('company', (object) ['name' => 'Acme']);
+
+ $mail = new DriverCredentialsMail('plain-secret', $driver, $user);
+ $content = $mail->content();
+
+ expect($mail->envelope()->subject)->toBe('Your Acme driver sign-in details')
+ ->and($content->markdown)->toBe('fleetops::mail.driver-credentials')
+ ->and($content->with)->toMatchArray([
+ 'driver' => $driver,
+ 'user' => $user,
+ 'companyName' => 'Acme',
+ 'identity' => '+6590001111',
+ 'plaintextPassword' => 'plain-secret',
+ ]);
+
+ $user->setRawAttributes(['name' => 'Dana', 'email' => 'dana@example.com'], true);
+ $orphan = new Driver();
+ $orphan->setRelation('company', null);
+ $orphanMail = new DriverCredentialsMail('plain-secret', $orphan, $user);
+
+ expect($orphanMail->envelope()->subject)->toBe('Your Fleetbase driver sign-in details')
+ ->and($orphanMail->content()->with)->toMatchArray(['companyName' => 'Fleetbase', 'identity' => 'dana@example.com']);
+
+ $view = file_get_contents(__DIR__ . '/../../../resources/views/mail/driver-credentials.blade.php');
+ expect($view)->toContain('{{ $identity }}')
+ ->toContain('{{ $plaintextPassword }}')
+ ->toContain('{{ $companyName }}');
+});
diff --git a/tests/integration/components/customer/form-test.js b/tests/integration/components/customer/form-test.js
index 907fee6e8..9e08661ef 100644
--- a/tests/integration/components/customer/form-test.js
+++ b/tests/integration/components/customer/form-test.js
@@ -49,14 +49,24 @@ module('Integration | Component | customer/form', function (hooks) {
this.owner.register('component:model-select', setComponentTemplate(hbs``, ModelSelectStub));
});
- test('the user account selector only offers customer users without a contact', async function (assert) {
- this.set('customer', makeResource({ isNew: true }));
+ test('it does not offer a user account selector', async function (assert) {
+ this.set('customer', makeResource({ isNew: false }));
await render(hbs``);
- const userSelect = modelSelectQueries.find((entry) => entry.modelName === 'user');
+ assert.notOk(
+ modelSelectQueries.find((entry) => entry.modelName === 'user'),
+ 'login accounts are managed by the server'
+ );
+ });
+
+ test('email and phone are read-only for a staff-linked customer', async function (assert) {
+ this.set('customer', makeResource({ isNew: false, is_staff_linked: true, email: 'staff@example.com' }));
+
+ await render(hbs``);
- assert.deepEqual(userSelect.query, { doesnt_have_contact: true, is_customer: true });
+ assert.dom('input[placeholder="Email"]').isDisabled();
+ assert.dom('[data-test-staff-linked-helper]').exists();
});
test('the welcome email option is offered when creating a customer with the portal installed', async function (assert) {
diff --git a/tests/integration/components/modals/reset-customer-credentials-test.js b/tests/integration/components/modals/reset-customer-credentials-test.js
deleted file mode 100644
index a1152f024..000000000
--- a/tests/integration/components/modals/reset-customer-credentials-test.js
+++ /dev/null
@@ -1,26 +0,0 @@
-import { module, test } from 'qunit';
-import { setupRenderingTest } from 'dummy/tests/helpers';
-import { render } from '@ember/test-helpers';
-import { hbs } from 'ember-cli-htmlbars';
-
-module('Integration | Component | modals/reset-customer-credentials', function (hooks) {
- setupRenderingTest(hooks);
-
- test('it renders', async function (assert) {
- // Set any properties with this.set('myProperty', 'value');
- // Handle any actions with this.set('myAction', function(val) { ... });
-
- await render(hbs``);
-
- assert.dom().hasText('');
-
- // Template block usage:
- await render(hbs`
-
- template block text
-
- `);
-
- assert.dom().hasText('template block text');
- });
-});
diff --git a/tests/integration/components/modals/reset-profile-credentials-test.js b/tests/integration/components/modals/reset-profile-credentials-test.js
new file mode 100644
index 000000000..cb24840c7
--- /dev/null
+++ b/tests/integration/components/modals/reset-profile-credentials-test.js
@@ -0,0 +1,61 @@
+import Service from '@ember/service';
+import { module, test } from 'qunit';
+import { setupRenderingTest } from 'dummy/tests/helpers';
+import { render } from '@ember/test-helpers';
+import { hbs } from 'ember-cli-htmlbars';
+
+let posted;
+
+class FetchStub extends Service {
+ post(endpoint, payload, options) {
+ posted = { endpoint, payload, options };
+ return Promise.resolve({ status: 'ok', driver: { user_uuid: 'user-1', is_staff_linked: false, login_status: 'active' } });
+ }
+}
+
+class NotificationsStub extends Service {
+ success() {}
+ serverError() {}
+}
+
+module('Integration | Component | modals/reset-profile-credentials', function (hooks) {
+ setupRenderingTest(hooks);
+
+ hooks.beforeEach(function () {
+ posted = null;
+ this.owner.register('service:fetch', FetchStub);
+ this.owner.register('service:notifications', NotificationsStub);
+ });
+
+ test('it posts the new password with the configured endpoint and payload', async function (assert) {
+ const profile = {
+ id: 'driver-1',
+ name: 'Alex Driver',
+ setProperties(values) {
+ Object.assign(this, values);
+ },
+ };
+
+ this.set('options', {
+ profile,
+ endpoint: 'drivers/driver-1/reset-credentials',
+ payload: (subject) => ({ subject: subject.id }),
+ });
+
+ await render(hbs``);
+
+ const component = this.options;
+ await component.confirm({
+ startLoading: () => assert.step('loading'),
+ stopLoading: () => assert.step('stopped'),
+ done: () => assert.step('done'),
+ });
+
+ assert.strictEqual(posted.endpoint, 'drivers/driver-1/reset-credentials');
+ assert.strictEqual(posted.payload.subject, 'driver-1');
+ assert.true(posted.payload.send_credentials, 'sends credentials by default');
+ assert.deepEqual(posted.options, { namespace: 'int/v1' });
+ assert.strictEqual(profile.login_status, 'active', 'applies the login status from the response');
+ assert.verifySteps(['loading', 'done']);
+ });
+});
diff --git a/tests/unit/services/driver-actions-test.js b/tests/unit/services/driver-actions-test.js
index aafdb64dc..adba110fb 100644
--- a/tests/unit/services/driver-actions-test.js
+++ b/tests/unit/services/driver-actions-test.js
@@ -128,4 +128,57 @@ module('Unit | Service | driver-actions', function (hooks) {
await service.unassignOrders({ id: 'driver-1', name: 'Alex Driver' });
});
+
+ test('account actions are hidden without a managed login', function (assert) {
+ const service = this.owner.lookup('service:driver-actions');
+ service.intl = { t: (key) => key };
+
+ assert.deepEqual(service.accountMenuItems({ id: 'driver-1', user_uuid: null }), [], 'no login account');
+ assert.deepEqual(service.accountMenuItems({ id: 'driver-1', user_uuid: 'user-1', is_staff_linked: true }), [], 'staff-linked login is managed from IAM');
+ });
+
+ test('account actions toggle deactivate/reactivate by login status', function (assert) {
+ const service = this.owner.lookup('service:driver-actions');
+ service.intl = { t: (key) => key };
+
+ const active = service.accountMenuItems({ id: 'driver-1', user_uuid: 'user-1', login_status: 'active' }).map((item) => item.text);
+ assert.deepEqual(active, [undefined, 'profile-account.actions.reset-password', 'profile-account.actions.send-credentials', 'profile-account.actions.deactivate-login']);
+
+ const inactive = service.accountMenuItems({ id: 'driver-1', user_uuid: 'user-1', login_status: 'inactive' }).map((item) => item.text);
+ assert.true(inactive.includes('profile-account.actions.reactivate-login'));
+ assert.false(inactive.includes('profile-account.actions.deactivate-login'));
+ });
+
+ test('deactivateLogin posts to the driver endpoint and applies the response', async function (assert) {
+ const service = this.owner.lookup('service:driver-actions');
+ const driver = {
+ id: 'driver-1',
+ name: 'Alex Driver',
+ user_uuid: 'user-1',
+ login_status: 'active',
+ setProperties(values) {
+ Object.assign(this, values);
+ },
+ };
+ let confirmOptions;
+
+ service.intl = { t: (key) => key };
+ service.notifications = { success: () => assert.step('success'), serverError: () => assert.ok(false, 'unexpected call') };
+ service.fetch = {
+ post: async (url, payload, options) => {
+ assert.strictEqual(url, 'drivers/driver-1/deactivate-login');
+ assert.deepEqual(payload, {});
+ assert.deepEqual(options, { namespace: 'int/v1' });
+
+ return { status: 'ok', driver: { id: 'driver-1', user_uuid: 'user-1', is_staff_linked: false, login_status: 'inactive' } };
+ },
+ };
+ service.modalsManager = { confirm: (options) => (confirmOptions = options) };
+
+ service.deactivateLogin(driver);
+ await confirmOptions.confirm({ startLoading() {}, stopLoading() {}, done: () => assert.step('done') });
+
+ assert.strictEqual(driver.login_status, 'inactive');
+ assert.verifySteps(['success', 'done']);
+ });
});
diff --git a/translations/en-us.yaml b/translations/en-us.yaml
index 1996dca4c..98e50bedd 100644
--- a/translations/en-us.yaml
+++ b/translations/en-us.yaml
@@ -770,6 +770,9 @@ contact:
customer:
fields:
+ addresses: Addresses
+ no-addresses: This customer has no saved addresses.
+ primary-address: Primary
customer-details: Customer Details
select-address: Select Address
new-address: New Adddress
@@ -801,11 +804,9 @@ driver:
fields:
avatar: Avatar
driver-details: Driver Details
- select-user: Select User
select-vehicle: Select Vehicle
upload-new-photo: Upload new photo
user-account: User Account
- user-account-help-text: Each driver profile needs to be connected to a specific user account. This association is essential for handling the authentication process, ensuring that each driver can securely access and manage their profile.
vendor: Vendor
select-vendor: Select Vendor
driver-license: Drivers License
@@ -837,6 +838,40 @@ driver:
unassign-vehicle-body: This removes {driverName} from {vehicleName}. Order assignments are not changed.
unassign-vehicle-success: Unassigned {vehicleName} from {driverName}.
issue-title: Issue reported for {driverName}
+
+profile-account:
+ staff-linked-helper: Linked to a team member account — manage email and phone from IAM.
+ will-link-to-staff: Will be linked to existing team member {name}.
+ actions:
+ reset-password: Reset Password
+ send-credentials: Send Credentials
+ deactivate-login: Deactivate Login
+ reactivate-login: Reactivate Login
+ convert-to-vendor: Convert to Vendor
+ prompts:
+ send-credentials-title: Send Login Credentials
+ send-credentials-body: Generate a new temporary password and send {name} their login credentials.
+ send-credentials-success: Login credentials sent to {name}.
+ deactivate-login-body: Deactivate the login for {name}. Their profile and history will be preserved.
+ deactivate-login-success: Login deactivated for {name}.
+ reactivate-login-body: Reactivate the login for {name}.
+ reactivate-login-success: Login reactivated for {name}.
+ send-portal-credentials-title: Send Portal Credentials
+ send-portal-credentials-body: Generate a new temporary password and email this contact their portal credentials.
+ send-portal-credentials-success: Portal credentials sent.
+ deactivate-portal-login-body: Deactivate portal access for this contact. Their profile and history will be preserved.
+ deactivate-portal-login-success: Portal login deactivated.
+ reactivate-portal-login-body: Reactivate portal access for this contact.
+ reactivate-portal-login-success: Portal login reactivated.
+ reset-password-title: Reset Login Credentials
+ reset-password-heading: You are about to reset the password for {name}
+ reset-password-body: Please enter the new password and confirm it below. You can optionally send the new credentials by selecting the checkbox.
+ reset-password-accept: Reset Credentials
+ reset-password-success: Password reset.
+ new-password: New Password
+ confirm-new-password: Confirm New Password
+ send-new-credentials: Send the new credentials to this account
+
device:
attachment:
title: Attached asset
diff --git a/translations/uk-ua.yaml b/translations/uk-ua.yaml
new file mode 100644
index 000000000..86363b30f
--- /dev/null
+++ b/translations/uk-ua.yaml
@@ -0,0 +1,3475 @@
+fleet-ops:
+ extension-name: Fleet-Ops
+
+common:
+ last-modified: Останні зміни
+ "yes": "Так"
+ "no": "Ні"
+ proof-of-delivery: Підтвердження доставки
+ ad-hoc: Ad-Hoc (на вимогу)
+ bulk-dispatch: Масова відправка
+ dispatch-orders: Відправити замовлення
+ dispatch-order: Відправити замовлення
+ assign-driver: Призначити водія
+ unassign-driver: Скасувати призначення водія
+ assign-drivers: Призначити водіїв
+ no-driver-assigned: Водія не призначено
+ no-vehicle-assigned: ТЗ не призначено
+ details: Деталі
+ name: Назва
+ title: Заголовок
+ email: Електронна пошта
+ phone: Телефон
+ internal-id: Внутрішній ID
+ type: Тип
+ address: Адреса
+ create-order: Створити замовлення
+ coordinates: Координати
+ city: Місто
+ country: Країна
+ status: Статус
+ upload-new-photo: Завантажити нове фото
+ resource-actions: >-
+ Дії з {resource}
+ resource-location: >-
+ Розташування {resource}
+ search-countries: Пошук країн
+ select-resource-filter-by: Виберіть {resource} для фільтрації
+ table-view: Табличний вигляд
+ grid-view: Сітка
+ calendar-view: Календарний вигляд
+ change-layout: Змінити вигляд
+ livemap: Онлайн-карта
+ tracking: Відстеження
+ activity: Активність
+ eta: Очікуваний час прибуття (ETA)
+ position: Позиція
+ positions: Позиції
+ pos: POS
+ edit-address: Редагувати адресу
+ new-address: Нова адреса
+ select-address: Вибрати адресу
+ save: Зберегти
+ save-changes: Зберегти зміни
+ done: Готово
+ delete: Видалити
+ overview: Огляд
+ notes: Примітки
+ schedule: Розклад
+ optional: Необов'язково
+ unauthorized-access: У вас немає доступу до цього ресурсу.
+ online: В мережі
+ offline: Поза мережею
+ refresh: Оновити
+ loading-route: Завантаження маршруту...
+ hover-to-load-route: Завантаження попереднього перегляду маршруту...
+ unable-to-load-route: Не вдалося завантажити попередній перегляд маршруту.
+
+menu:
+ operations: Операції
+ dashboard: Панель керування
+ radar: Радар
+ orders: Замовлення
+ service-rates: Тарифи на послуги
+ scheduler: Планувальник
+ order-config: Конфігурація замовлень
+ resources: Ресурси
+ drivers: Водії
+ vehicles: Транспортні засоби
+ trailers: Причепи
+ fleets: Автопарки
+ vendors: Постачальники
+ contacts: Контакти
+ places: Місця
+ fuel-reports: Звіти про пальне
+ fuel-transactions: Транзакції пального
+ issues: Проблеми
+ maintenance: Технічне обслуговування
+ schedules: Графіки
+ work-orders: Заявки на роботу
+ inspection-forms: 'Форми інспекції'
+ inspections: 'Інспекції'
+ equipment: Обладнання
+ parts: Запчастини
+ maintenance-history: Історія ТО
+ connectivity: Підключення
+ telematics: Телематика
+ fuel-providers: Інтеграції пального
+ devices: Пристрої
+ sensors: Сенсори
+ events: Події
+ tracking: Відстеження
+ analytics: Аналітика
+ reports: Звіти
+ settings: Налаштування
+ navigator-app: Додаток Navigator
+ payments: Платежі
+ notifications: Сповіщення
+ routing: Маршрутизація
+ map: Карта
+ custom-fields: Настроювані поля
+ order-board: Дошка замовлень
+ avatars: Аватари
+ scheduling: Планування
+ maintenances: Технічні обслуговування
+ orchestrator: Оркестратор
+ orchestrator-settings: Налаштування оркестратора
+
+trailer:
+ navigation-description: Керування буксируваними активами, з'єднаннями, обладнанням та телематикою.
+ sections:
+ details: Деталі
+ identification: Ідентифікація причепа
+ classification: Класифікація та статус
+ registration: Реєстрація
+ location: Розташування
+ dimensions: Габарити та вантажопідйомність
+ measurement: Вимірювання та використання
+ dimensions-fields: "Габарити ({unit})"
+ weight: "Вага ({unit})"
+ running-gear: Осі, гальма та зчіпка
+ refrigeration: Охолодження
+ ownership: Власність та фінанси
+ finance: Фінансові деталі
+ connection: Зчіпний пристрій
+ telemetry: Розташування та телематика
+ notes: Примітки та історія
+ fields:
+ photo: Фото
+ id: ID
+ name: Назва
+ code: Код
+ description: Опис
+ type: Тип причепа
+ body-type: Тип кузова
+ color: Колір
+ status: Статус
+ category: Категорія
+ vin: VIN-код
+ plate-number: Номерний знак
+ serial-number: Серійний номер
+ make: Марка
+ model: Модель
+ year: Рік
+ vendor: Постачальник
+ warranty: Гарантія
+ vehicle: Тягач
+ attachment-state: Стан підключення
+ attached-at: Приєднано з
+ position: Позиція зчіпки
+ source: Джерело
+ connected-at: З'єднано о
+ disconnected-at: Від'єднано о
+ duration: Тривалість
+ connectivity: Підключення
+ last-online: Вересні в мережі
+ last-provider: Провайдер телеметрії
+ last-event-at: Остання подія телеметрії
+ coordinates: Координати
+ speed: Швидкість
+ heading: Курс
+ altitude: Висота
+ devices-count: Приєднані пристрої
+ equipment-count: Приєднане обладнання
+ measurement-system: Система вимірювання
+ odometer: "Одометр / лічильник маточини {unit}"
+ odometer-unit: Одиниця вимірювання одометра
+ length: "Довжина ({unit})"
+ width: "Ширина ({unit})"
+ height: "Висота ({unit})"
+ cargo-volume: "Об'єм вантажу ({unit})"
+ tare-weight: "Маса без навантаження ({unit})"
+ gvwr: "Повна маса ТЗ ({unit})"
+ payload-capacity: "Корисне навантаження ({unit})"
+ axle-count: Осі
+ tire-count: Шини
+ door-count: Двері
+ coupling-type: Тип зчіпки
+ brake-type: Тип гальм
+ abs: ABS
+ ebs: EBS
+ refrigerated: Рефрижератор
+ temperature-min: "Мінімальна температура ({unit})"
+ temperature-max: "Максимальна температура ({unit})"
+ reefer-hours: Мотогодини рефрижератора
+ ownership-type: Форма власності
+ financing-status: Фінансовий статус
+ currency: Валюта
+ acquisition-cost: Вартість придбання
+ current-value: Поточна вартість
+ insurance-value: Страхова вартість
+ depreciation-rate: Норма амортизації (% на рік)
+ purchased-at: Дата купівлі
+ lease-expires-at: Закінчення оренди/лізингу
+ notes: Примітки
+ created-at: Створено
+ updated-at: Оновлено
+ placeholders:
+ filter-vendor: Фільтр за постачальником
+ name: напр. Рефрижератор 12
+ code: Внутрішній код автопарку, напр. TRL-0012
+ description: Для чого використовується цей причіп
+ type: Виберіть тип причепа
+ status: Виберіть статус
+ body-type: напр. Закритий, шторний, платформа
+ color: напр. Білий
+ category: Виберіть категорію (необов'язково)
+ vin: 17-значний VIN-код
+ plate-number: Реєстраційний номер
+ serial-number: Серійний номер виробника
+ make: напр. Utility, Schmitz, Wabash
+ model: напр. 3000R
+ year: напр. 2024
+ vendor: Виберіть постачальника (необов'язково)
+ warranty: Виберіть гарантію (необов'язково)
+ vehicle: Виберіть транспортний засіб
+ trailer: Виберіть причіп
+ equipment: Виберіть обладнання
+ filter-vehicle: Фільтр за тягачем
+ position: "1"
+ measurement-system: Виберіть систему вимірювання
+ odometer-unit: Виберіть одиницю
+ odometer: Поточні показники
+ length: "0.000"
+ width: "0.000"
+ height: "0.000"
+ cargo-volume: "0.000"
+ tare-weight: "0.000"
+ gvwr: "0.000"
+ payload-capacity: "0.000"
+ axle-count: напр. 2
+ tire-count: напр. 8
+ door-count: напр. 2
+ coupling-type: Виберіть тип зчіпки
+ brake-type: Виберіть тип гальм
+ temperature-min: напр. -20
+ temperature-max: naпр. 4
+ reefer-hours: Години роботи рефрижератора
+ ownership-type: Виберіть форму власності
+ financing-status: напр. Виплачено, в лізингу до 2028
+ depreciation-rate: напр. 10
+ date: ДД-ММ-РРРР
+ notes: Внутрішні примітки про цей причіп
+ help:
+ photo: Завантажте фото цього причепа.
+ name: Назва, яка відображається у Fleet-Ops. Обов'язково.
+ code: Ваш власний код автопарку для цього причепа.
+ type: Тип визначає відповідні розділи, наприклад, охолодження для рефрижераторів.
+ status: Статус життєвого циклу. Підключення та зв'язок відстежуються окремо.
+ body-type: Довільний опис кузова для класифікації та пошуку.
+ category: Довільне групування, спільне з іншими ресурсами автопарку.
+ vin: Номер шасі або ідентифікаційний номер ТЗ, вибитий на причепі.
+ coordinates: Останнє відоме або домашнє місцерозташування. Пристрої телематики оновлюють це автоматично.
+ measurement-system: Визначає одиниці вимірювання для габаритів, ваги, об'єму та температури.
+ odometer: Показники одометра або лічильника маточини у вибраних одиницях.
+ cargo-volume: Корисний об'єм вантажного відсіку причепа.
+ tare-weight: Вага порожнього причепа.
+ gvwr: Повна дозволена маса ТЗ з навантаженням.
+ payload-capacity: Максимальна вага вантажу, яку може перевозити причіп.
+ coupling-type: Спосіб зчеплення причепа з тягачем.
+ abs: Встановлено антиблокувальну систему (ABS).
+ ebs: Встановлено електронну систему гальмування (EBS).
+ refrigerated: Увімкніть для причепів з контролем температури для запису налаштувань рефрижератора.
+ temperature: Діапазон робочих температур рефрижератора.
+ reefer-hours: Зафіксовані мотогодини холодильної установки.
+ ownership-type: Форма володіння причепом. Деталі лізингу та фінансування відображаються за потреби.
+ lease-expires-at: Дата закінчення договору оренди, лізингу або фінансування.
+ vendor: Постачальник або орендодавець, у якого було отримано причіп.
+ warranty: Гарантійна політика, що покриває цей причіп.
+ acquisition-cost: Ціна купівлі або капіталізована вартість у вибраній валюті.
+ depreciation-rate: Річна амортизація, що використовується для оцінки поточної вартості.
+ attach-vehicle: Причіп буде з'єднано з вибраним ТЗ. Причіп може бути приєднаний лише до одного ТЗ одночасно.
+ select-trailer: Відображаються лише від'єднані причепи.
+ select-equipment: Відображається лише непідключене обладнання.
+ position: Позиція причепа у зчіпці. Позиція 1 — одразу за тягачем; використовуйте 2 або більше для автопоїздів.
+ units:
+ length-metric: м
+ length-imperial: фути
+ weight-metric: кг
+ weight-imperial: фунти
+ volume-metric: м³
+ volume-imperial: фути³
+ temperature-metric: °C
+ temperature-imperial: °F
+ actions:
+ new: Новий причіп
+ locate: Показати на карті
+ attach-vehicle: Приєднати до ТЗ
+ detach-vehicle: Від'єднати від ТЗ
+ attach-device: Приєднати пристрій
+ attach-equipment: Приєднати обладнання
+ detach-equipment: Від'єднати обладнання
+ schedule-maintenance: Запланувати ТО
+ create-work-order: Створити заявку на роботу
+ log-maintenance: Зареєструвати ТО
+ prompts:
+ attach-vehicle-title: "Приєднати {trailerName} до транспортного засобу"
+ select-vehicle-warning: Виберіть транспортний засіб для приєднання цього причепа.
+ attach-vehicle-success: "{trailerName} приєднано до {vehicleName}."
+ detach-vehicle-title: "Від'єднати {trailerName}?"
+ detach-vehicle-body: "Це завершить поточне з'єднання між {trailerName} та {vehicleName}. З'єднання збережеться в історії причепа."
+ detach-vehicle-success: "{trailerName} від'єднано від {vehicleName}."
+ not-attached-warning: "{trailerName} не приєднано до жодного ТЗ."
+ attach-device-title: "Приєднати пристрій до {trailerName}"
+ select-device-warning: Виберіть пристрій для приєднання до цього причепа.
+ attach-device-success: Пристрій приєднано до {trailerName}.
+ attach-equipment-title: "Приєднати обладнання до {resourceName}"
+ select-equipment-warning: Виберіть обладнання для приєднання.
+ attach-equipment-success: "{equipmentName} приєднано до {resourceName}."
+ detach-equipment-title: "Від'єднати {equipmentName}?"
+ detach-equipment-body: "{equipmentName} більше не буде закріплено за {resourceName}."
+ detach-equipment-success: "{equipmentName} від'єднано від {resourceName}."
+ no-location-warning: "Для {trailerName} ще немає записаного розташування."
+ photo-upload-error: "Не вдалося завантажити фото: {message}"
+ empty:
+ title: Додайте свій перший причіп
+ description: Причепи відстежують зчеплення, приєднані пристрої та обладнання, оперативну телеметрію та ТО для ваших буксируваних активів.
+ filtered-title: Жоден причіп не відповідає вашим фільтрам
+ filtered-description: Змініть параметри пошуку або фільтри, щоб знайти інший причіп.
+ connections: Історія зчеплень відсутня
+ connections-description: Приєднайте цей причіп до транспортного засобу, щоб розпочати запис історії зчеплень.
+ equipment: Немає приєднаного обладнання
+ equipment-description: Приєднайте до цього причепа таке обладнання, як гидроборти, ремені або рефрижератори.
+ schedules: Немає графіків ТО
+ schedules-description: Створіть графік для планування періодичних інспекцій або обслуговування цього причепа.
+ work-orders: Немає заявок на роботу
+ work-orders-description: Створіть заявку на роботу для відстеження ремонту або обслуговування цього причепа.
+ maintenance: Історія ТО відсутня
+ maintenance-description: Зареєстроване технічне обслуговування для цього причепа відображатиметься тут.
+ docs:
+ title: Посібник з причепів
+ link: Відкрити посібник з причепів
+ tabs:
+ overview: Огляд
+ positions: Позиції
+ devices: Пристрої
+ equipment: Обладнання
+ connections: Історія зчеплень
+ schedules: Графіки
+ work-orders: Заявки на роботу
+ maintenance: Технічне обслуговування
+ columns:
+ name: Назва
+ id: ID
+ code: Код
+ type: Тип
+ plate-number: Номерний знак
+ status: Статус
+ vehicle: Тягач
+ attachment-state: Підключення
+ connectivity: Зв'язок
+ last-online: Востаннє в мережі
+ location: Розташування
+ devices: Пристрої
+ equipment: Обладнання
+ vin: VIN-код
+ serial-number: Серійний номер
+ make: Марка
+ model: Модель
+ year: Рік
+ vendor: Постачальник
+ ownership: Власність
+ axles: Осі
+ gvwr: Повна маса ТЗ
+ payload-capacity: Вантажопідйомність
+ length: Довжина
+ refrigerated: Рефрижератор
+ created-at: Створено
+ updated-at: Оновлено
+ filters:
+ created-between: Створено в період
+ updated-between: Оновлено в період
+ last-online-between: Востаннє в мережі в період
+ connections:
+ description: Кожен ТЗ, з яким був з'єднаний цей причіп, починаючи з найновіших.
+ ongoing: Триває
+ connected: З'єднано {date}
+ disconnected: Від'єднано {date}
+ still-attached: Досі приєднано
+ equipment:
+ description: Обладнання, що наразі встановлено на цей причіп.
+ telemetry:
+ no-location: Розташування не записано
+ maintenance:
+ interval: Інтервал
+ next-due: Наступне ТО
+ code: Код
+ subject: Тема
+ priority: Пріоритет
+ due: Термін
+ summary: Сводка
+ odometer: Одометр
+ total-cost: Загальна вартість
+ date: Дата
+ statuses:
+ available: Доступний
+ in_use: В використанні
+ maintenance: На обслуговуванні
+ out_of_service: Не експлуатується
+ retired: Списаний
+ status-descriptions:
+ available: Готовий до відправки або приєднання.
+ in_use: Наразі виконує завдання або приєднаний до ТЗ.
+ maintenance: Проходить обслуговування, інспекцію або ремонт.
+ out_of_service: Непридатний до використання до подальшого повідомлення.
+ retired: Остаточно виведений з активної експлуатації.
+ attachment:
+ attached: Приєднано
+ detached: Від'єднано
+ no-vehicle: Не приєднано до ТЗ
+ towed-by: Буксирується
+ position: Позиція {position}
+ connectivity:
+ online: В мережі
+ recently_offline: Нещодавно поза мережею
+ offline: Поза мережею
+ never_connected: Ніколи не підключався
+ measurement:
+ metric: Метрична
+ imperial: Імперська
+ measurement-descriptions:
+ metric: Метри, кілограми, кубічні метри та градуси Цельсія.
+ imperial: Фути, фунти, кубічні фути та градуси Фаренгейта.
+ odometer-units:
+ km: Кілометри
+ mi: Милі
+ coupling-types:
+ fifth_wheel: Седельно-зчіпний пристрій
+ pintle_hook: Буксирувальний гак
+ ball_hitch: Кульове зчеплення
+ gooseneck: >-
+ "Гусяча шия" (Gooseneck)
+ drawbar: Дишло
+ other: Інше
+ brake-types:
+ air: Пневматичні гальма
+ electric: Електричні гальма
+ hydraulic_surge: Гідравлічні інерційні гальма
+ none: Без гальм
+ other: Інше
+ ownership-types:
+ owned: У власності
+ leased: В лізингу
+ financed: В кредиті
+ rented: В оренді
+ types:
+ dry_van: Сухий фургон
+ reefer: Рефрижератор
+ flatbed: Бортова платформа
+ step_deck: Низькорамний причіп (Step deck)
+ lowboy: Важкий низькорамник (Lowboy)
+ tanker: Цистерна
+ bulk: Самоскид/Самоскидний бункер
+ dump: Самоскид
+ chassis: Контейнеровозне шасі
+ curtain_side: Шторний причіп
+ car_carrier: Автовоз
+ livestock: Худобовоз
+ logging: Лісовоз
+ dolly: Причіп-підкочувальний візок (Dolly)
+ specialty: Спеціалізований
+ other: Інше
+
+resource:
+ asset: Актив
+ assets: Активи
+ trailer: Причіп
+ trailers: Причепи
+ activity: Активність
+ activities: Активності
+ contact: Контакт
+ contacts: Контакти
+ customer-contact: Контакт клієнта
+ customer-contacts: Контакти клієнтів
+ customer-vendor: Постачальник клієнта
+ customer-vendors: Постачальники клієнтів
+ customer: Клієнт
+ customers: Клієнти
+ custom-entity: Настроювана сутність
+ custom-entities: Настроювані сутності
+ device-event: Подія пристрою
+ device-events: Події пристроїв
+ device: Пристрій
+ devices: Пристрої
+ driver: Водій
+ drivers: Водії
+ entity: Сутність
+ entities: Сутності
+ equipment: Обладнання
+ equipments: Обладнання
+ facilitator-contact: Контакт посередника
+ facilitator-contacts: Контакти посередників
+ facilitator-customer: Клієнт посередника
+ facilitator-customers: Клієнти посередників
+ facilitator-integrated-vendor: Інтегрований постачальник посередника
+ facilitator-integrated-vendors: Інтегровані постачальники посередників
+ facilitator-vendor: Постачальник посередника
+ facilitator-vendors: Постачальники посередників
+ facilitator: Посередник
+ facilitators: Посередники
+ fleet-driver: Водій автопарку
+ fleet-drivers: Водії автопарку
+ fleet: Автопарк
+ fleets: Автопарки
+ fuel-report: Звіт про пальне
+ fuel-reports: Звіти про пальне
+ inspection: Інспекція
+ inspections: Інспекції
+ integrated-vendor: Інтегрований постачальник
+ integrated-vendors: Інтегровані постачальники
+ issue: Проблема
+ issues: Проблеми
+ maintenance: Технічне обслуговування
+ maintenances: Технічні обслуговування
+ maintenance-schedule: Графік ТО
+ maintenance-schedules: Графіки ТО
+ order-config: Конфігурація замовлень
+ order-configs: Конфігурації замовлень
+ order: Замовлення
+ orders: Замовлення
+ part: Запчастина
+ parts: Запчастини
+ payload: Вантаж
+ payloads: Вантажі
+ place: Місце
+ places: Місця
+ purchase-rate: Закупівельний тариф
+ purchase-rates: Закупівельні тарифи
+ route: Маршрут
+ routes: Маршрути
+ sensor: Сенсор
+ sensors: Сенсори
+ service-area: Зона обслуговування
+ service-areas: Зони обслуговування
+ service-quote-item: Позиція розрахунку вартості послуги
+ service-quote-items: Позиції розрахунку вартості послуг
+ service-quote: Розрахунок вартості послуги
+ service-quotes: Розрахунки вартості послуг
+ service-rate-fee: Збір за тарифом послуги
+ service-rate-fees: Збори за тарифами послуг
+ service-rate-parcel-fee: Збір за посилку за тарифом
+ service-rate-parcel-fees: Збори за посилки за тарифом
+ service-rate: Тариф послуги
+ service-rates: Тарифи послуг
+ telematic: Телематика
+ telematics: Телематика
+ tracking-number: Номер відстеження
+ tracking-numbers: Номери відстеження
+ tracking-status: Статус відстеження
+ tracking-statuses: Статуси відстеження
+ vehicle-device: Пристрій ТЗ
+ vehicle-devices: Пристрої ТЗ
+ vehicle: Транспортний засіб
+ vehicles: Транспортні засоби
+ vendor: Постачальник
+ vendors: Постачальники
+ warranty: Гарантія
+ warranties: Гарантії
+ waypoint: Точка маршруту
+ waypoints: Точки маршруту
+ work-order: Заявка на роботу
+ work-orders: Заявки на роботу
+ zone: Зона
+ zones: Зони
+
+order-status:
+ created: Створено
+ ready: Готово
+ dispatched: Відправлено
+ started: Розпочато
+ completed: Завершено
+ canceled: Скасовано
+
+column:
+ target: Ціль
+ address: Адреса
+ country: Країна
+ created-at: Створено
+ id: ID
+ internal-id: Внутрішній ID
+ name: Назва
+ phone: Телефон
+ route-type: Тип маршруту
+ status: Статус
+ type: Тип
+ updated-at: Оновлено
+ vehicle: ТЗ
+ vendor: Постачальник
+ created: Створено
+ email: Електронна пошта
+ title: Заголовок
+ updated: Оновлено
+ fleet: Автопарк
+ license: Ліцензія / Посвідчення
+ active-manpower: Активний персонал
+ manpower: Персонал
+ parent-fleet: Батьківський автопарк
+ service-area: Зона обслуговування
+ task: Завдання
+ zone: Зона
+ driver: Водій
+ edit-fuel: Редагувати пальне
+ odometer: Одометр
+ reporter: Заявник
+ view: Перегляд
+ volume: Об'єм
+ assignee: Виконавець
+ category: Категорія
+ priority: Пріоритет
+ city: Місто
+ neighborhood: Район
+ postal-code: Поштовий індекс
+ state: Область / Штат
+ make: Марка
+ model: Модель
+ plate-number: Номерний знак
+ year: Рік
+ website-url: URL-адреса вебсайту
+ created-by: Створив
+ customer: Клієнт
+ driver-assigned: Призначений водій
+ dropoff: Точка розвантаження
+ facilitator: Посередник
+ items: Позиції
+ payload: Вантаж
+ pickup: Точка завантаження
+ scheduled-at: Заплановано на
+ tracking: Відстеження
+ transaction: Транзакція
+ updated-by: Оновив
+ vehicle-assigned: Призначений ТЗ
+ service: Послуга
+ location: Розташування
+ coordinates: Координати
+ current-job: Поточна робота
+ last-seen: Востаннє помічено
+ place: Місце
+ subject: Тема
+ summary: Короткий зміст
+ total-cost: Загальна вартість
+ due-at: Термін виконання
+ serial-number: Серійний номер
+ manufacturer: Виробник
+ part-number: Номер деталі
+ quantity-on-hand: Кількість в наявності
+ quantity: Кількість
+ unit-cost: Вартість за одиницю
+ code: Код
+ interval: Інтервал
+ next-due: Наступний термін
+ asset: Актив
+ performed-by: Виконав
+
+map:
+ visibility-controls:
+ title: Елементи керування видимістю
+ hide-places: Приховати всі місця
+ show-places: Показати всі місця
+ driver-controls: Керування водіями
+ driver-visibility-controls: Керування видимістю водіїв
+ show-drivers: Показати всіх водіїв
+ hide-drivers: Приховати всіх водіїв
+ show-online-drivers: Показати лише водіїв в мережі
+ show-offline-drivers: Показати лише водіїв поза мережею
+ vehicle-controls: Керування ТЗ
+ vehicle-visibility-controls: Керування видимістю ТЗ
+ hide-vehicles: Приховати всі ТЗ
+ show-vehicles: Показати всі ТЗ
+ show-online-vehicles: Показати лише ТЗ в мережі
+ show-offline-vehicles: Показати лише ТЗ поза мережею
+ zones-panel:
+ title: Зони обслуговування
+ create-service: Створити нову зону обслуговування
+ show: Показати всі зони обслуговування
+ hide: Приховати всі зони обслуговування
+ action: Дії
+ focus: Фокус
+ focus-resource: >-
+ Фокус: {resource}
+ blur: Приховати зону обслуговування
+ blur-resource: >-
+ Приховати: {resource}
+ create-zone: Створити зону всередині
+ create-zone-inside: >-
+ Створити зону: {serviceAreaName}
+ edit-resource-boundaries: Редагувати межі {resource}
+ edit-boundaries: >-
+ Редагувати межі: {resource}
+ toolbar:
+ create: Створити замовлення
+ view-order: Переглянути замовлення
+ view-service-area: Переглянути зони обслуговування
+ visibility: Елементи керування видимістю
+ scope: Область видимості
+
+activity:
+ form:
+ code: Код
+ code-help-text: Введіть унікальний ідентифікатор для цієї активності.
+ completes-order: Завершує замовлення
+ details: Деталі
+ details-help-text: "Надайте детальний опис активності для розуміння кінцевим користувачем."
+ key: Ключ
+ key-help-text: '"Введіть ключ, що використовується для програмного прийняття рішень. Цей ключ може представляти одну або кілька активностей з однаковим ідентифікатором. Примітка: Цей ключ не повинен бути унікальним."'
+ pod-method-placeholder: Виберіть метод підтвердження доставки
+ require-pod: Потрібне підтвердження доставки
+ require-pod-help-text: Ця активність вимагає створення підтвердження перед застосуванням до замовлення.
+ select-pod-method: Метод підтвердження доставки
+ status: Статус
+ status-help-text: Вкажіть статус, який відображається кінцевому користувачу при спрацьовуванні цієї активності.
+ logic-builder:
+ activity-logic: Логіка активності
+ activity-logic-help-text: Визначте настроювані логічні умови, які визначають, коли активність стає доступною в робочому процесі. Ця функція дозволяє адаптувати процес на основі конкретних критеріїв.
+ activity-logic-add-logic-help-text: Створіть новий набір умов, які визначатимуть, коли активність повинна бути активована. Натисніть для створення нового логічного правила.
+ activity-logic-select-logic-type-help-text: Виберіть тип логіки для цієї умови. Виберіть 'If' для прямої умови, 'And'/'Or' для складених умов або 'Not' для заперечення умови.
+ activity-logic-add-condition-help-text: Додайте конкретну вимогу, яка має бути виконана в цьому наборі логіки.
+ field: Поле
+ value: Значення
+ add-logic: Додати логіку
+ types:
+ and: >-
+ Логічне ТА (AND): Істина, якщо всі умови істинні
+ or: >-
+ Логічне АБО (OR): Істина, якщо хоча б одна умова істинна
+ not: >-
+ Логічне НІ (NOT): Інвертує значення істинності умови
+ if: >-
+ Умовна логіка: Виконує наступну дію, якщо умова істинна
+ operators:
+ equal: Перевіряє рівність двох значень
+ not-equal: Перевіряє нерівність двох значень
+ greater-than: Перевіряє, чи значення більше за інше
+ less-than: Перевіряє, чи значення менше за інше
+ greater-than-or-equal: Перевіряє, чи значення більше або дорівнює іншому
+ less-than-or-equal: Перевіряє, чи значення менше або дорівнює іншому
+ exists: Перевіряє існування поля або значення
+ has: Перевіряє, чи має об'єкт певну властивість або чи містить колекція елемент
+ contains: Перевіряє, чи містить рядок інший рядок або чи включає масив елемент
+ begins-with: Перевіряє, чи починається рядок з вказаного підрядка
+ ends-with: Перевіряє, чи закінчується рядок вказаним підрядком
+ in: Перевіряє, чи знаходиться значення в заданій множині або діапазоні
+ not-in: Перевіряє, чи знаходиться значення поза заданою множиною або діапазоном
+ and: Логічне ТА, істина, якщо обидва операнди істинні
+ or: Логічне АБО, істина, якщо хоча б один операнд істинний
+ not: Логічне НІ, інвертує значення істинності
+ event-selector:
+ activity-events: Події активності
+ activity-events-select-to-fire: Виберіть події, які будуть активовані при виконанні цієї активності.
+ activity-events-select-events-info: Виберіть конкретну подію, яку ви хочете запустити у відповідь на цю активність.
+ select-event-to-add: Виберіть подію для додавання...
+ events:
+ order:
+ dispatched: Спрацьовує при успішній відправці замовлення.
+ failed: Спрацьовує, якщо замовлення зазнало невдачі через помилку або виключення.
+ canceled: Спрацьовує при скасуванні замовлення користувачем, водієм або системним процесом.
+ completed: Спрацьовує при завершенні замовлення водієм або системним процесом.
+
+contact:
+ fields:
+ contact-details: Контактні дані
+ upload-new-photo: Завантажити нове фото
+
+customer:
+ fields:
+ addresses: Адреси
+ no-addresses: У цього клієнта немає збережених адрес.
+ primary-address: Основна
+ customer-details: Деталі клієнта
+ select-address: Вибрати адресу
+ new-address: Нова адреса
+ select-user: Вибрати користувача
+ send-welcome-email: Надіслати вітальний лист клієнтського порталу
+ send-welcome-email-help-text: Згенерувати тимчасовий пароль та надіслати клієнту електронну пошту з посиланням для входу.
+ upload-new-photo: Завантажити нове фото
+ user-account: Обліковий запис користувача
+ user-account-help-text: Кожен профіль клієнта має бути пов'язаний з конкретним обліковим записом користувача для обробки процесів автентифікації.
+
+custom-entity:
+ fields:
+ description: Опис
+ details: Деталі
+ entity-image: Зображення сутності
+ height: Висота
+ height-text: Текст висоти
+ length: Довжина
+ length-text: Текст довжини
+ measurements: Виміри
+ name: Назва
+ type: Тип
+ weight: Вага
+ weight-text: Текст ваги
+ width: Ширина
+ width-text: Текст ширини
+
+driver:
+ fields:
+ avatar: Аватар
+ driver-details: Деталі водія
+ select-user: Вибрати користувача
+ select-vehicle: Вибрати ТЗ
+ upload-new-photo: Завантажити нове фото
+ user-account: Обліковий запис користувача
+ user-account-help-text: Кожен профіль водія має бути пов'язаний з конкретним обліковим записом користувача для забезпечення безпечного доступу та автентифікації.
+ vendor: Постачальник
+ select-vendor: Вибрати постачальника
+ driver-license: Посвідчення водія
+ license-expiry: Термін дії посвідчення
+ no-vehicle-assigned: ТЗ не призначено
+ actions:
+ unassign-orders: Скасувати призначення замовлень
+ unassign-vehicle: Скасувати призначення ТЗ
+ create-issue: Створити проблему
+ assign-order: Призначити замовлення водію
+ assign-vehicle: Призначити ТЗ водію
+ locate-driver: Знайти водія на карті
+
+ prompts:
+ assign-order-title: "Призначити замовлення водію {driverName}"
+ assign-order-success: Замовлення призначено водію {driverName}.
+ select-order-warning: "Виберіть замовлення для призначення."
+ assign-vehicle-title: "Призначити ТЗ водію {driverName}"
+ assign-vehicle-success: "ТЗ призначено водію {driverName}."
+ select-vehicle-warning: Виберіть ТЗ для призначення.
+ no-assigned-orders-warning: Для {driverName} не знайдено призначених замовлень.
+ select-orders-warning: Виберіть щонайменше одне замовлення для скасування призначення.
+ unassign-orders-title: Скасувати призначення замовлень від {driverName}
+ confirm-unassign-orders-title: Скасувати призначення {count} замовлень від {driverName}?
+ confirm-unassign-orders-body: "Це вилучить {driverName} з цих замовлень: {orders}."
+ unassign-orders-success: Скасовано призначення {driverName} з {count} замовлень.
+ no-vehicle-assigned-warning: За {driverName} не закріплено жодного ТЗ.
+ unassign-vehicle-title: Відв'язати {vehicleName} від {driverName}?
+ unassign-vehicle-body: Це відв'яже {driverName} від {vehicleName}. Призначення замовлень не зміняться.
+ unassign-vehicle-success: Відв'язано {vehicleName} від {driverName}.
+ issue-title: Повідомлено про проблему для {driverName}
+device:
+ attachment:
+ title: Приєднаний актив
+ description: Контекст автопарку для телеметрії з цього пристрою.
+ open: Відкрити актив
+ asset: Приєднаний актив
+ synced-device: Синхронізований пристрій
+ asset-type: Тип активу
+ select-asset-type: Виберіть тип активу
+ locate: Знайти
+ change: Змінити актив
+ detach: Від'єднати
+ attached-device: Приєднаний пристрій
+ last-seen: Пристрій востаннє помічено
+ none-title: Немає приєднаного активу
+ none-message: Приєднайте цей пристрій до ТЗ або причепа, щоб телеметрія мала контекст автопарку.
+ latest-positions: Останні позиції
+ positions-description: Останні координати активу, отримані через це прив'язане пристрою.
+ full-history: Повна історія
+ no-positions: Немає останніх позицій
+ no-positions-message: Для цього приєднаного активу ще немає доступної історії останніх позицій.
+ attach-or-change: Приєднати або змінити актив
+ view-attached: Переглянути приєднаний актив
+ locate-attached: Знайти приєднаний актив на карті
+ unknown: Невідомий актив
+ fields:
+ title: Деталі пристрою
+ device-name: Назва пристрою
+ device-name-placeholder: Назва пристрою
+ device-type: Тип пристрою
+ device-type-placeholder: Виберіть тип пристрою
+ device-id: ID пристрою
+ device-id-placeholder: ID пристрою
+ device-provider: Провайдер пристрою
+ device-provider-placeholder: Провайдер пристрою
+ device-model: Модель пристрою
+ device-model-placeholder: Модель пристрою
+ manufacturer: Виробник
+ manufacturer-placeholder: Виробник
+ serial-number: Серійний номер
+ serial-number-placeholder: Серійний номер
+ device-location: Розташування пристрою
+ device-location-placeholder: Розташування пристрою
+ installation-date: Дата встановлення
+ installation-date-placeholder: Виберіть дату встановлення
+ last-maintenance-date: Дата останнього ТО
+ last-maintenance-date-placeholder: Виберіть дату останнього ТО
+ data-frequency: Частота передачі даних
+ data-frequency-placeholder: Частота передачі даних
+ status: Статус
+ status-placeholder: Виберіть статус
+ telematic: Телематика
+ telematic-placeholder: Виберіть телематику
+ warranty: Гарантія
+ warranty-placeholder: Виберіть гарантію
+ notes: Примітки
+ notes-placeholder: Примітки
+
+ actions:
+ attach-device: Приєднати пристрій
+ attach-to-vehicle: Приєднати до ТЗ
+ detach-from-vehicle: Від'єднати від ТЗ
+ attach-to-asset: Приєднати до активу
+ prompts:
+ attach-to-vehicle-title: Приєднати пристрій до ТЗ
+ attach-device-to-vehicle-title: "Приєднати {deviceName} до ТЗ"
+ select-vehicle-warning: Виберіть ТЗ для приєднання цього пристрою.
+ attach-to-vehicle-success: Пристрій приєднано до ТЗ.
+ attach-to-asset-title: Приєднати пристрій до активу
+ select-asset-warning: Виберіть ТЗ або причіп для приєднання цього пристрою.
+ attach-to-asset-success: Пристрій приєднано до активу.
+ detach-from-asset-title: "Від'єднати {deviceName} від активу?"
+ detach-from-asset-body: "Це від'єднає {deviceName} від {assetName} і зупинить оновлення телеметрії активу, доки його не буде приєднано знову."
+ detach-from-asset-success: Пристрій від'єднано від активу.
+ not-attached-warning: "{deviceName} не приєднано до ТЗ."
+ detach-from-vehicle-title: "Від'єднати {deviceName} від ТЗ?"
+ detach-from-vehicle-body: "Це від'єднає {deviceName} від {vehicleName} і зупинить оновлення телеметрії ТЗ, доки його не буде приєднано знову."
+ detach-from-vehicle-success: Пристрій від'єднано від ТЗ.
+ select-device-to-attach-title: "Виберіть пристрій для приєднання до {resourceName}"
+ confirm-attach-device: "Підтвердити та приєднати пристрій"
+ attach-device-success: Пристрій успішно приєднано.
+ detach-from-resource-title: "Від'єднати {deviceName} від {resourceName}?"
+ detach-from-resource-body: "Це від'єднає {deviceName} від {resourceName} і зупинить оновлення телеметрії та подій для цього {resourceType}."
+ detach-from-resource-success: "Від'єднано {deviceName} від {resourceName}."
+ detach-telematic-device-title: "Від'єднати {deviceName} від ТЗ?"
+ detach-telematic-device-body: "{deviceName} залишиться синхронізованим від провайдера, але більше не буде прив'язаний до ТЗ."
+ manager-description: Телематичні пристрої, встановлені на цьому {resourceType}. Приєднайте пристрій, щоб почати отримувати дані про позиції та події.
+ no-devices-attached-title: Немає приєднаних пристроїв
+ no-devices-attached-body: "Натисніть 'Приєднати пристрій' вище, щоб почати отримувати телематичні дані для цього {resourceType}."
+entity:
+ fields:
+ declare: Декларувати
+ declare-text: Текст декларації
+ description: Опис
+ description-text: Текст опису
+ height: Висота
+ height-text: Текст висоти
+ id-text: Текст ID
+ length: Довжина
+ length-text: Текст довжини
+ measurement-title: Назва вимірювання
+ name-text: Текст назви
+ price: Ціна
+ price-text: Текст ціни
+ price-title: Назва ціни
+ sale-price: Акційна ціна
+ sale-price-text: Текст акційної ціни
+ sku: Артикул (SKU)
+ sku-text: Текст артикулу
+ weight: Вага
+ weight-text: Текст ваги
+ width: Ширина
+ width-text: Текст ширини
+
+fleet:
+ fields:
+ fleet-details: Деталі автопарку
+ fleet-name: Назва автопарку
+ parent-fleet: Батьківський автопарк
+ select-parent-fleet: Виберіть батьківський автопарк для призначення
+ select-service: Виберіть зону обслуговування для призначення автопарку
+ select-status: Виберіть статус
+ select-status-fleet: Виберіть статус автопарку.
+ select-vendor: Виберіть постачальника для призначення автопарку
+ select-zone: Виберіть зону для призначення автопарку
+ service-title: Призначити до зони обслуговування
+ task-mission-title: Завдання / Місія
+ task-text: "Надайте опис основного завдання або місії цього автопарку, якщо застосовно."
+ zone-title: Призначити до зони
+ active-manpower: Активний персонал
+ task: Завдання
+ driver-listing:
+ search-driver: Пошук водіїв в автопарку
+ add-driver: Додати водія до автопарку
+ loading-message: Завантаження водіїв автопарку...
+ vehicle-listing:
+ search-vehicle: Пошук ТЗ в автопарку
+ add-vehicle: Додати ТЗ до автопарку
+ loading-message: Завантаження ТЗ автопарку...
+ actions:
+ assign-vehicle: Призначити ТЗ до автопарку
+ assign-driver: Призначити водія до автопарку
+
+ prompts:
+ assign-driver-title: Призначити водія до {fleetName}
+ assign-vehicle-title: Призначити ТЗ до {fleetName}
+ select-driver-warning: Виберіть водія для призначення.
+ select-vehicle-warning: Виберіть ТЗ для призначення.
+ assign-driver-success: Водія призначено до {fleetName}.
+ assign-vehicle-success: ТЗ призначено до {fleetName}.
+fuel-report:
+ fields:
+ details: Деталі звіту про пальне
+ reporter: Заявник
+ select-reporter: Вибрати заявника
+ fuel-cost: Вартість пального
+ odometer: Одометр
+ select-volume-help-text: Виберіть метричну одиницю та об'єм пального для звіту.
+ fuel-volume: Пальне / Об'єм
+
+issue:
+ fields:
+ title: Заголовок
+ add-tags: Додати теги
+ assigned-to: Призначено
+ category: Категорія проблеми
+ issue-report: Звіт про проблему
+ priority: Пріоритет проблеми
+ report: Звіт про проблему
+ reported-by: Повідомив
+ select-assign: Вибрати виконавця
+ select-category: Виберіть категорію проблеми
+ select-priority: Виберіть пріоритет проблеми
+ select-reporter: Виберіть заявника
+ select-status: Виберіть статус проблеми
+ select-type: Виберіть тип проблеми
+ tags: Теги проблеми
+ type: Тип проблеми
+
+order:
+ placeholders:
+ filter-internal-id: Введіть або скануйте внутрішні ID
+ card:
+ linked-order: Пов'язане замовлення
+ unknown-order: Невідоме замовлення
+ no-type: Без типу
+ pickup: Завантаження
+ dropoff: Розвантаження
+ return: Повернення
+ waypoints: Точки маршруту
+ additional-stop: додаткова зупинка
+ additional-stops: додаткові зупинки
+ no-pickup-address: Немає адреси завантаження
+ no-dropoff-address: Немає адреси розвантаження
+ no-route-details: Деталі маршруту недоступні.
+ no-order-linked: Жодне замовлення не пов'язане з цією проблемою.
+ fields:
+ ad-hoc: Ad-Hoc
+ ad-hoc-help-text: Вмикання Ad-Hoc дозволить замовленню інтелектуально надсилати сповіщення водіям поблизу місця завантаження, дозволяючи першому вільному водію прийняти замовлення. Це альтернатива ручному призначенню.
+ add-entity: Додати сутність
+ add-item-button: Додати позицію
+ add-item-order: Додати позицію до замовлення
+ add-waypoint: Додати точку маршруту
+ add-waypoint-help-text: 'Використовуйте кнопку `Додати точку маршруту` для додавання нових зупинок. Маршрутизатор Fleetbase автоматично оптимізує маршрут при додаванні або видаленні зупинок.'
+ adhoc-ping: Відстань сповіщення Ad-Hoc
+ adhoc-ping-help-text: Це налаштування визначає радіус Ad-Hoc сповіщення для цього замовлення. Якщо не заповнено, використовується значення за замовчуванням.
+ adhoc-ping-message: Відстань має бути в метрах
+ apply-service-rate: Застосувати тариф послуги
+ assign-driver: Призначити водія
+ assign-driver-help-text: Призначте водія, якому буде відправлено це замовлення.
+ assign-driver-placeholder: Вибрати водія
+ assign-vehicle: Призначити ТЗ
+ assign-vehicle-help-text: Призначте ТЗ, на якому буде виконано замовлення.
+ assign-vehicle-placeholder: Вибрати ТЗ
+ breakdown: Деталізація
+ customer: Клієнт
+ customer-help-text: "Опціонально призначте клієнта для цього замовлення. Якщо це новий клієнт, скористайтеся кнопкою 'Новий клієнт' вище."
+ customer-placeholder: Вибрати клієнта
+ dispatch: Відправити
+ dispatch-help-text: Увімкнення відправки запустить процес відправки замовлення одразу після створення.
+ documents-panel-title: 'Документи та файли'
+ dropoff: Точка розвантаження
+ edit-address: Редагувати адресу
+ edit-item: Редагувати позицію
+ facilitator: Посередник
+ facilitator-help-text: Опціонально призначте посередника (сторонній субпідрядник або служба).
+ facilitator-placeholder: Вибрати посередника
+ info-text: Виберіть розрахунок вартості послуги в реальному часі для цього замовлення. Після застосування розрахунку він стане придбаним тарифом.
+ internal-id: Внутрішній ID
+ internal-id-help-text: Використовуйте це поле для встановлення власного або внутрішнього ідентифікатора замовлення.
+ invalid-coordinates: 'Недійсні координати!'
+ items-drop: Позиції вивантажуються о
+ loading-message: Завантаження розрахунків вартості...
+ updating-service-quotes-message: Оновлення розрахунків вартості...
+ notes-placeholder: Введіть примітки до замовлення тут....
+ notes-title: Примітки
+ optimize-route: Оптимізувати маршрут
+ optimize-route-help-text: Fleetbase автоматично оптимізує маршрут.
+ order-type: Тип замовлення
+ order-type-help-text: Вибір типу замовлення вкаже Fleetbase, які конфігурації замовлення використовувати.
+ order-type-placeholder: Виберіть тип замовлення
+ payload-entities: Вантаж / Сутності
+ pickup: Точка завантаження
+ proof-delivery: Підтвердження доставки
+ proof-delivery-help-text: Виберіть потрібний тип підтвердження доставки (QR-код або підпис).
+ proof-delivery-placeholder: Виберіть метод підтвердження доставки
+ refresh-button: Оновити
+ require-proof: Вимагати підтвердження доставки
+ require-proof-help-text: Увімкнення цієї опції зобов'яже водія надати підтвердження доставки.
+ return: Повернення
+ route: Маршрут
+ route-error: Помилка оптимізації маршруту. Перевірте введені дані та спробуйте знову.
+ route-label: "Кілька точок розвантаження"
+ schedule: Запланувати
+ schedule-help-text: Дата та час відправки замовлення. Якщо водія не призначено на момент відправки, вона зазнає невдачі.
+ select-destination: Виберіть пункт призначення
+ select-dropoff: Виберіть точку розвантаження
+ select-pickup: Виберіть точку завантаження
+ select-return: Виберіть точку повернення
+ select-waypoint: Виберіть точку маршруту
+ service: Послуга
+ select-service-rate: Виберіть тариф послуги
+ service-help-text: Виберіть визначений тариф для генерації розрахунків вартості в реальному часі.
+ service-quotes-help-text: Виберіть тариф для отримання розрахунків вартості.
+ service-type: Тип послуги
+ service-type-placeholder: Виберіть тип послуги
+ sku: Артикул (SKU)
+ activity: Активність
+ comments-title: Коментарі
+ date-dispatched: Дата відправки
+ date-scheduled: Запланована дата
+ date-started: Дата початку
+ driver: Водій
+ driver-assigned: Призначений водій
+ no-driver-assigned: Водія не призначено
+ get-label: Отримати етикетку
+ order-notes-updated: Примітки до замовлення оновлено.
+ payload: Вантаж
+ proof-of-delivery: Підтвердження доставки
+ purchase-rate-panel-title: Придбаний тариф
+ route-panel-title: Маршрут
+ save-order-note: Зберегти примітку до замовлення
+ tracking: Відстеження
+ tracking-number: Номер відстеження
+ vehicle-assigned: Призначений ТЗ
+ view-activity: Переглянути активність
+ no-order-activity: Немає активності за замовленням
+ waypoint-actions: Дії з точкою маршруту
+ documents-files: Документи та файли
+ order-metadata: Метадані замовлення
+ order-label: Маркування замовлення
+ waypoint-label: Маркування точки маршруту
+ entity-label: Маркування сутності
+ current-eta: Поточний очікуваний час (ETA)
+ ect: Очікуваний час завершення (ECT)
+ current-destination: Поточний пункт призначення
+ no-service-quotes: Немає розрахунків вартості послуг.
+ input-order-routes: Введіть маршрут замовлення для перегляду розрахунків.
+ service-quote-info: Виберіть розрахунок вартості в реальному часі для застосування до замовлення.
+ prompts:
+ update-details-success: 'Деталі замовлення {orderId} оновлено.'
+ route-error: Помилка оптимізації маршруту, перевірте введені дані маршруту та спробуйте знову.
+ route-update-success: 'Деталі маршруту замовлення {orderId} оновлено.'
+ unassign-driver-title: Ви дійсно бажаєте скасувати призначення водія ({driverName}) для цього замовлення?
+ unassign-driver-body: Після скасування призначення водій більше не матиме доступу до деталей цього замовлення.
+ unassign-driver-success: Водія успішно відв'язано від цього замовлення.
+ no-driver-assigned-error: За цим замовленням не закріплено водія.
+ failed-to-load-order-label: Не вдалося завантажити маркування замовлення.
+ failed-to-load-waypoint-label: Не вдалося завантажити маркування точки маршруту.
+ failed-to-load-entity-label: Не вдалося завантажити маркування сутності.
+ unable-to-add-entity: Не вдалося додати нову сутність до замовлення.
+ assign-driver-success: Водія ({driverName}) призначено до замовлення {orderId}.
+ cancel-title: Ви дійсно бажаєте скасувати це замовлення?
+ cancel-body: Після скасування замовлення запис залишиться видимим, але додавати активність до нього буде неможливо.
+ cancel-success: Замовлення {orderId} скасовано.
+ dispatch-title: Ви дійсно бажаєте відправити це замовлення?
+ dispatch-body: Після відправки замовлення призначений водій отримає сповіщення.
+ dispatch-success: Замовлення {orderId} відправлено.
+ order-bulk-search: Введіть ID замовлень або номери відстеження через кому для масового пошуку
+ actions:
+ create-new-customer: Створити нового клієнта
+ create-new-facilitator: Створити нового посередника
+ create-new-place: Створити нове місце
+ change-driver: Змінити водія
+ assign-driver: Призначити водія
+ update-activity: Оновити активність
+ reload-activity: Перезавантажити активність
+ view-activity-as-timeline: Переглянути як часову шкалу
+ view-activity-as-list: Переглянути як список
+ cancel-order: Скасувати замовлення
+ dispatch-order: Відправити замовлення
+ dispatch-orders: Відправити замовлення
+ assign-driver-to-orders: Призначити водія до замовлень
+ edit-order-details: Редагувати деталі замовлення
+ edit-order-route: Редагувати маршрут замовлення
+ edit-order-metadata: Редагувати метадані замовлення
+ update-order-activity: Оновити активність замовлення
+ submit-new-activity: Надіслати нову активність
+ add-entity: Додати сутність
+ unassign-driver-name: >-
+ Скасувати призначення водія: {driverName}
+ schedule-card:
+ destination: Пункт призначення
+ unassign-driver: Скасувати призначення водія?
+ unassign-text: Ви збираєтеся скасувати призначення водія для замовлення {orderId}. Натисніть "Продовжити" для підтвердження.
+ unassign-button: Продовжити та скасувати призначення водія
+ assign-driver: Призначити нового водія?
+ assign-text: Ви збираєтеся призначити нового водія ({driverName}) для замовлення {orderId}. Натисніть "Продовжити" для підтвердження.
+ assign-button: Продовжити та призначити водія
+ date: Запланована дата
+ date-created: Дата створення
+ driver-assigned: Водій
+ vehicle-assigned: Транспортний засіб
+ no-driver: Без водія
+ no-vehicle: Без ТЗ
+ select-driver: Вибрати водія
+ select-vehicle: Вибрати ТЗ
+ kanban:
+ status-updated: Активність замовлення оновлено до статусу {status}.
+ cannot-update-status: Не вдалося перемістити замовлення до статусу {status}.
+
+order-config-manager:
+ title: Конфігурація замовлень
+ message: Конфігурації замовлень дозволяють визначати динамічні параметри, які вказують Fleetbase, як мають виконуватися різні типи замовлень.
+ configuration: Конфігурація
+ new-order-config: Нова конфігурація замовлення
+ select-order-config: Вибрати конфігурацію замовлення
+ create-new-title: Створити нову конфігурацію замовлення
+ create-warning-message: Конфігурація замовлення вимагає назви
+ create-success-message: Нову конфігурацію замовлення успішно створено.
+ no-order-config-selected: Не вибрано конфігурацію замовлення.
+ saved-success-message: 'Конфігурацію {orderConfigName} збережено.'
+ cloned-success-message: Конфігурацію замовлення успішно клоновано.
+ enter-clone-name: Введіть назву клонованої конфігурації
+ no-config-name-warning-message: Назву конфігурації не введено.
+ delete-warning-message: Після видалення цієї конфігурації ви більше не зможете створювати замовлення з її використанням. Ви впевнені?
+ uninstall-order-config: Видалити конфігурацію
+ uninstall-order-config-success-message: Розширення {extensionName} видалено.
+ select-order-config-to-start: Виберіть або створіть конфігурацію замовлення для початку.
+ tabs:
+ details: Деталі
+ custom-fields: Настроювані поля
+ activity-flow: Потік активностей
+ entities: Сутності
+ details:
+ details: Деталі
+ name: Назва
+ description: Опис
+ tags: Теги
+ add-tags: Додати теги
+ key: Ключ
+ version: Версія
+ namespace: Простір імен (Namespace)
+ controls: Елементи керування
+ delete-config: Видалити конфігурацію
+ delete:
+ delete-title: Видалити цю конфігурацію замовлень?
+ delete-body-message: Після видалення цієї конфігурації замовлень її неможливо буде відновити, і всі налаштування будуть втрачені.
+ confirm-delete: Видалити
+ activity-flow:
+ save-activity-flow: Зберегти потік активностей
+ edit-activity-unique-code-warning: Код активності повинен бути унікальним!
+ custom-fields:
+ new-field-group: Нова група полів
+ new-custom-field: Створити нове настроюване поле
+ grid-size: Розмір сітки
+ delete-custom-field-prompt:
+ modal-title: Видалити це настроюване поле?
+ delete-body-message: Після видалення цього поля його відновлення буде неможливим.
+ confirm-delete: Видалити
+ delete-custom-field-group-prompt:
+ modal-title: Видалити цю групу полів?
+ delete-body-message: Після видалення цієї групи полів усі настроювані поля всередині неї будуть втрачені.
+ confirm-delete: Видалити
+ entities:
+ new-custom-entity: Нова настроювана сутність
+ name: Назва
+ description: Опис
+ type: Тип
+ length: Довжина
+ width: Ширина
+ height: Висота
+ weight: Вага
+ delete-custom-entity-title: Видалити цю настроювану сутність?
+ delete-custom-entity-body: Після видалення цієї сутності її відновлення буде неможливим.
+ confirm-delete: Підтвердити
+ types:
+ no-order-installed: Не встановлено жодної конфігурації замовлень!
+ you-have-no-configs: Наразі у вас немає встановлених конфігурацій замовлень. Перевірте розширення, щоб встановити конфігурацію для початку.
+ view-extensions: Переглянути розширення
+
+place:
+ fields:
+ details: Деталі місця
+ building: Будівля / Корпус
+ neighborhood: Район
+ postal-code: Поштовий індекс
+ security-access-code: Код доступу / Охорони
+ city: Місто
+ province: Область / Провінція
+ country: Країна
+ state: Штат / Область
+ street-1: Вулиця (лінія 1)
+ street-2: Вулиця (лінія 2)
+ coordinates: Координати
+ phone: Телефон
+ prompts:
+ vendor-assigned-success: Постачальнику призначено {placeName} як нове розташування.
+ actions:
+ locate-place: Знайти місце на карті
+
+service-area:
+ actions:
+ edit-boundary: Редагувати межу на карті
+ fields:
+ details: Деталі зони обслуговування
+ area-color: Колір заливки
+ area-color-help-text: Налаштуйте колір заливки для цієї зони обслуговування.
+ border-color: Колір межі
+ border-color-help-text: Налаштуйте колір межі для цієї зони обслуговування.
+ country: Країна
+ country-help-text: Опціонально вкажіть країну, у якій розташована або базується ця зона обслуговування.
+ name: Назва
+ name-help-text: Встановіть відображувану назву для зони обслуговування.
+ type: Тип
+ type-help-text: Виберіть визначення типу для цієї зони обслуговування.
+
+service-rate:
+ fields:
+ details: Деталі тарифу послуги
+ terms: Умови
+ add-drop-off-button: Додати діапазон розвантажень
+ additional-fee: Додатковий збір
+ algorithm-help-text: Визначте формулу для розрахунку цього тарифу.
+ algorithm-label: Алгоритм
+ algorithm-placeholder: >-
+ (( '{distance}' / 50 ) * .05 ) + (( '{time}' / 60 ) * .01)
+ base-fee-help-text: Встановіть базовий збір, який представляє мінімальну вартість цієї послуги.
+ base-fee-label: Базовий збір
+ calculation-method-help-text: Метод розрахунку комісії за накладений платіж (COD).
+ calculation-method-label: Метод розрахунку комісії COD
+ calculation-method-placeholder: Виберіть метод розрахунку комісії COD
+ cash-delivery-label: 'Увімкнути додатковий збір для замовлень з накладеним платежем?'
+ cash-delivery-title: Оплата при отриманні (COD)
+ custom-algorithm-info-message: "Ця опція призначена для визначення власного розрахунку збору за послугу зі змінними."
+ custom-algorithm-info-second-message: Зверніть увагу, що змінні повинні бути обгорнуті в поодинокі фігурні дужки.
+ custom-algorithm-title: Власний алгоритм
+ delivery-flat-fee: Фіксований збір за накладений платіж
+ delivery-flat-fee-help-text: Визначає фіксований збір, який додається в години пік.
+ deminsions-unit: Розмірність
+ distance: Відстань
+ distance-continue-message: >-
+ - відстань у метрах від маршруту замовлення.
+ distance-message: відстань
+ distance-unit: Одиниця відстані
+ distance-unit-help-text: Одиниця відстані може бути за кілометр або метр.
+ distance-unit-placeholder: Виберіть одиницю відстані.
+ duration-terms-help-text: Додайте додаткові умови обслуговування щодо тривалості для цього тарифу.
+ duration-terms-label: Умови тривалості
+ duration-terms-placeholder: Умови тривалості, якщо застосовно
+ estimated-days: Очікувані дні доставки
+ estimated-days-help-text: 'Очікувана кількість днів для цієї послуги. Для доставки в той самий день використовуйте `0`.'
+ example: Приклад
+ example-message: Якщо запит на замовлення має відстань 2200 метрів з ETA 30 хвилин.
+ example-second-message: Тоді наступна формула розрахує збір за послугу в розмірі $2.50, який буде додано до базового збору.
+ fee: Збір
+ fee-percentage: Відсоток комісії за накладений платіж
+ fee-percentage-help-text: Визначає відсотковий збір від проміжного підсумку тарифу послуги для накладеного платежу.
+ fixed-meter: Опції фіксованого метражу
+ fixed-meter-example-text: >-
+ Наприклад: За кожен 1 кілометр визначається фіксована плата. Якщо відстань замовлення 3300 метрів, фіксована плата за 3-й кілометр додається до суми базового збору.
+ fixed-meter-text: Ця опція визначає фіксовану плату за кілометр.
+ flat-fee-help-text: Визначає фіксований збір, який додається в години пік.
+ flat-fee-label: Фіксований збір у години пік
+ height-label: Висота
+ height-placeholder: 'Висота в {parcelFeeDimension}'
+ length-label: Довжина
+ length-placeholder: 'Довжина в {parcelFeeDimension}'
+ max: Макс.
+ max-drop: Макс. кількість точок розвантаження
+ maximum-distance: Максимальна відстань
+ maximum-distance-help-text: Максимальна відстань обслуговування.
+ min: Мін.
+ min-drop: Мін. кількість точок розвантаження
+ order-type-placeholder: Заповнювач типу замовлення
+ peak-hours-end-help-text: Визначає час закінчення дії збору за години пік.
+ peak-hours-end-label: Кінці годин пік
+ peak-hours-fee-help-text: Метод, що використовується для розрахунку збору в години пік.
+ peak-hours-fee-label: Метод розрахунку збору в години пік
+ peak-hours-fee-placeholder: Виберіть метод розрахунку збору в години пік
+ peak-hours-label: 'Увімкнути додатковий збір для замовлень, зроблених у визначені "години пік"?'
+ peak-hours-percentage-help-text: Визначає відсотковий збір від суми тарифу в години пік.
+ peak-hours-percentage-label: Відсоток збору в години пік
+ peak-hours-start-help-text: Визначає час початку дії збору за години пік.
+ peak-hours-start-label: Початок годин пік
+ peak-hours-title: Години пік
+ per-drop-off-example-text: >-
+ Наприклад: 1-5 розвантажень у замовленні коштуватимуть $x, 5-10 розвантажень коштуватимуть $x. Це буде додано до суми базового збору.
+ per-drop-off-text: Ця опція визначає фіксований збір за кожну точку розвантаження.
+ per-drop-off-title: Опції за точку розвантаження
+ per-meter: За метр
+ per-meter-example-text: >-
+ Наприклад: '{fee}' * '{distance}' + '{baseFee}'
+ per-meter-rate-fee: Фіксована ставка за метр
+ per-meter-rate-fee-help-text: Єдиний фіксований збір, який помножується на відстань.
+ per-meter-text: Ця опція дозволяє розраховувати послугу за кілометр або метр (фіксований збір множиться на відстань).
+ percel-fee-title: "Збори за посилки"
+ percentage-placeholder: Відсоток
+ rate-calculation-help-text: Метод, який використовується цією послугою для розрахунку тарифів при запиті.
+ rate-calculation-label: Метод розрахунку тарифу
+ rate-calculation-placeholder: Виберіть метод розрахунку тарифу
+ restrict-service-title: Обмежити послугу
+ select-unit-placeholder: Вибрати одиницю
+ select-order-type: Вибрати тип замовлення
+ service-area-help-text: Обмежити цю послугу запитами на замовлення, що надходять із зони обслуговування.
+ service-area-label: Зона обслуговування
+ service-area-placeholder: Обмежити зоною обслуговування
+ service-name: Назва послуги
+ service-name-help-text: Відображувана назва цієї послуги.
+ service-order-help-text: Виберіть конфігурацію замовлення, до якої цей тариф буде застосовуватись виключно.
+ service-order-label: Тип замовлення послуги
+ time-continue-message: >-
+ - очікуваний час прибуття (ETA) за маршрутом у секундах.
+ time-message: час
+ weight-label: Вага
+ weight-unit: Одиниця ваги
+ width-label: Ширина
+ width-placeholder: 'Ширина в {parcelFeeDimension}'
+ zone-help-text: Обмежити цю послугу запитами, що надходять з конкретної зони.
+ zone-label: Зона
+ zone-placeholder: Обмежити зоною
+ cod-fee-label: Ярлик комісії COD
+
+vehicle:
+ placeholders:
+ filter-internal-id: Введіть або скануйте внутрішні ID
+ filters:
+ has-trailer: Приєднано причіп
+ has-driver: Призначено водія
+ has-device: Приєднано пристрій
+ any: Будь-який
+ attached: Приєднано
+ not-attached: Не приєднано
+ assigned: Призначено
+ not-assigned: Не призначено
+ fields:
+ updated-at: Востаннє оновлено
+ avatar: Аватар
+ driver-assigned: Призначений водій
+ make: Марка
+ model: Модель
+ plate-number: Номерний знак
+ select-driver: Вибрати водія
+ upload-new-photo: Завантажити нове фото
+ vehicle-make: Марка ТЗ
+ vehicle-model: Модель ТЗ
+ vehicle-year: Рік випуску ТЗ
+ vin-number: VIN-код
+ year: Рік
+ actions:
+ attach-trailer: Приєднати причіп
+ attach-equipment: Приєднати обладнання
+ detach-equipment: Від'єднати обладнання
+ attach-device: Приєднати пристрій
+ unassign-orders: Скасувати призначення замовлень
+ create-issue: Створити проблему
+ locate-vehicle: Знайти ТЗ на карті
+ schedule-maintenance: Запланувати ТО
+ create-work-order: Створити заявку на роботу
+ log-maintenance: Зареєструвати ТО
+ empty:
+ trailers: Немає приєднаних причепів
+ trailers-description: Приєднайте відчеплений причіп до цього ТЗ, щоб розпочати зчеплення.
+ equipment: Немає приєднаного обладнання
+ equipment-description: Приєднайте до цього ТЗ таке обладнання, як гидроборти, інструменти або засоби безпеки.
+ inspections: Немає поданих інспекцій
+ inspections-description: Інспекції, подані водієм для цього ТЗ, відображатимуться тут.
+ trailers:
+ description: Причепи, наразі зчеплені з цим ТЗ.
+ equipment:
+ description: Обладнання, наразі встановлене на цей ТЗ.
+
+ prompts:
+ attach-trailer-title: Приєднати причіп до {vehicleName}
+ select-trailer-warning: Виберіть причіп для приєднання до цього ТЗ.
+ attach-trailer-success: Приєднано {trailerName} до {vehicleName}.
+ attach-device-title: Приєднати пристрій до {vehicleName}
+ select-device-warning: Виберіть пристрій для приєднання.
+ attach-device-success: Пристрій приєднано до {vehicleName}.
+ no-assigned-orders-warning: Для {vehicleName} не знайдено призначених замовлень.
+ select-orders-warning: Виберіть щонайменше одне замовлення для скасування призначення.
+ unassign-orders-title: Скасувати призначення замовлень від {vehicleName}
+ confirm-unassign-orders-title: Скасувати призначення {count} замовлень від {vehicleName}?
+ confirm-unassign-orders-body: "Це вилучить {vehicleName} з цих замовлень: {orders}."
+ unassign-orders-success: Скасовано призначення {vehicleName} з {count} замовлень.
+ issue-title: Повідомлено про проблему для {vehicleName}
+vendor:
+ fields:
+ setup-vendor: Налаштування постачальника
+ choose-vendor: Виберіть інтегрованого провайдера постачальника
+ email-help-text: Електронна пошта постачальника (може використовуватись для надсилання листів).
+ name-help-text: Назва постачальника (зазвичай назва компанії/бізнесу).
+ phone-text: >-
+ Телефон постачальника (може використовуватись для SMS або повідомлень).
+ website: URL-адреса вебсайту
+ edit-address: Редагувати адресу
+ new-address: Нова адреса
+ select-address: Вибрати адресу
+ select-vendor-status: Виберіть статус постачальника
+ select-vendor-type: Виберіть тип постачальника
+ title: Налаштування
+ vendor-details: Деталі постачальника
+ website-help-text: Вебсайт постачальника (опціонально для довідки).
+
+integrated-vendor:
+ fields:
+ provider: Провайдер
+ credentials: Облікові дані
+ credentials-configure-help-text: Опціонально вкажіть власний {param} для налаштування цього постачальника.
+ options: Опції
+ choose-vendor: Виберіть інтегрованого провайдера постачальника
+ select-integrated: Виберіть інтегрованого провайдера постачальника.
+ advanced-options: Додаткові параметри
+ cancel-credentials: Скасувати скидання облікових даних
+ sensitive-credentials: Чутливі облікові дані можна лише скинути; для оновлення необхідно ввести їх повторно.
+ reset-credentials: Натисніть тут для скидання облікових даних
+ sandbox: Песочниця (Sandbox)
+ webhook: URL-адреса Webhook
+ webhook-help-text: Інтегрованим постачальникам зазвичай це потрібно для отримання оновлень активності замовлення у Fleetbase. Змінюйте лише якщо впевнені в діях!
+ host: Власний хост
+ host-help-text: Опціонально вкажіть власний хост для цієї інтеграції.
+ namespace: Власний простір імен
+ namespace-help-text: Опціонально вкажіть власний простір імен або версію API.
+
+zone:
+ actions:
+ edit-boundary: Редагувати межу на карті
+ fields:
+ details: Деталі зони
+ border-color: Колір межі
+ customize-border: Налаштувати межу
+ customize-fill-color: Налаштувати колір заливки
+ description: Опис
+ name: Назва
+ name-help-text: Введіть назву цієї зони.
+ zone-color: Колір зони
+
+# Рефакторинг застарілих перекладів у процесі
+activity-logic-builder:
+ activity-logic: Логіка активності
+ activity-logic-help-text: Визначте настроювані логічні умови, які визначають доступність активності у робочому процесі.
+ activity-logic-add-logic-help-text: Створіть новий набір умов для активації активності.
+ activity-logic-select-logic-type-help-text: Виберіть тип логіки (If, And/Or, Not).
+ activity-logic-add-condition-help-text: Додайте конкретну вимогу до цього набору логіки.
+ field: Поле
+ value: Значення
+ add-logic: Додати логіку
+
+activity-event-selector:
+ activity-events: Події активності
+ activity-events-select-to-fire: Виберіть події, які запускатимуться при цій активності.
+ activity-events-select-events-info: Виберіть конкретну подію для запуску.
+ select-event-to-add: Виберіть подію для додавання...
+
+custom-field-form-panel:
+ meta-col-span: 'Мета: охоплення колонок (Column-Span)'
+ col-span-size: Розмір охоплення колонок
+ custom-field-title-concat: 'Настроюване поле: '
+ new-custom-field: Нове настроюване поле
+ close-new-custom-field: Скасувати зміни настроюваного поля
+ save-custom-field: Зберегти зміни
+ create-custom-field: Створити нове поле
+ field-name: Назва поля
+ field-label: Ярлик поля
+ field-type: Тип поля
+ select-field-type: Виберіть тип поля...
+ field-description: Опис поля
+ field-help-text: Текст підказки поля
+ field-default-value: Значення поля за замовчуванням
+ field-is-required: Поле є обов'язковим
+ field-is-editable: Поле доступне для редагування
+ field-options: Опції поля
+ field-validation-rules: Правила валідації поля
+ field-model-type: Тип моделі поля
+ field-select-model-type: Виберіть тип моделі
+ add-new-option: Додати нову опцію
+
+activity-form-panel:
+ title-concat: 'Активність: '
+ new-activity-title: Створити нову активність
+ key: Ключ
+ code: Код
+ status: Статус
+ details: Деталі
+ proof-of-delivery: Підтвердження доставки
+ require-pod: Вимагає підтвердження доставки
+ require-pod-help-text: Ця активність вимагає створення підтвердження перед застосуванням.
+ select-pod-method: Метод підтвердження доставки
+ pod-method-placeholder: Виберіть метод підтвердження доставки
+ completes-order: Завершує замовлення
+ complete: Завершити
+ key-help: 'Введіть ключ для програмного прийняття рішень.'
+ code-help: Введіть унікальний ідентифікатор для цієї активності.
+ status-help: Вкажіть статус, що відображається користувачеві.
+ details-help: Надайте детальний опис активності.
+custom-entity-issue-form-panel:
+ new-custom-entity: Нова настроювана сутність
+ name: Назва
+ description: Опис
+ details: Деталі
+ type: Тип
+ length: Довжина
+ width: Ширина
+ height: Висота
+ weight: Вага
+ entity-image: Зображення сутності
+ measurements: Виміри
+
+avatar-picker:
+ avatar: Аватар
+ select-map-avatar: Вибрати аватар для карти
+ select-avatar-rendering: Виберіть аватар для відображення на картах при геолокаційному відстеженні.
+ select-avatar: Вибрати аватар
+ select-for-preview: Вибрати для попереднього перегляду
+
+admin:
+ navigator-app:
+ title: Додаток Navigator
+ name: URL-адреса зв'язування екземпляра
+ message: Використовуйте цю URL-адресу для зв'язування офіційного додатка Navigator з екземпляром {companyName}.
+ settings: Налаштування сутності
+ select-input: Дозволити водієві оновлювати деталі сутності
+ driver-settings: Налаштування водія
+ description: Увімкнути самостійну реєстрацію водія
+ upload-document: Вимагати від водія завантаження документів
+ upload-body: Додати документи
+ document: Документ
+ avatar-management:
+ upload-avatar: Завантажити аватар
+ view-avatar: Переглянути аватар
+ vehicles: Транспортні засоби
+ drivers: Водії
+ places: Місця
+
+entity-field-editing-settings:
+ entitiy-field-editing-settings: Налаштування редагування полів сутності
+ entity-field-editing-settings-help-text: Ці налаштування визначають, які поля сутності водій може редагувати через додаток Navigator
+ select-order-config: Вибрати конфігурацію замовлення
+ select-order-config-help-text: Виберіть конфігурацію замовлення, до якої застосовуватимуться ці налаштування
+ enable-driver-to-edit-entity-fields: Дозволити водієві редагувати деталі сутності
+ enable-driver-to-edit-entity-fields-help-text: Вмикання дозволяє водієві змінювати вибрані нижче поля.
+
+driver-onboard-settings:
+ driver-onboard-settings: Налаштування реєстрації водіїв
+ enable-driver-onboard-from-app: Дозволити реєстрацію водіїв з додатка Navigator
+ enable-driver-onboard-from-app-help-text: Дозволяє водіям самостійно створювати обліковий запис у додатку Navigator.
+ select-onboard-method: Виберіть метод реєстрації водія
+ select-onboard-method-help-text: Визначає спосіб створення облікового запису (Запрошення або Кнопка в додатку).
+ require-driver-to-upload-onboard-documents: Вимагати завантаження документів при реєстрації
+ require-driver-to-upload-onboard-documents-help-text: Зобов'язує надати документи до створення облікового запису.
+ required-onboard-documents: Обов'язкові документи для реєстрації
+ required-onboard-documents-help-text: Введіть усі необхідні документи, використовуючи кнопку "Додати документ для реєстрації".
+ enter-document-name: Введіть назву документа
+ add-onboard-document: Додати документ для реєстрації
+
+cell:
+ driver-name:
+ not-assigned: Водія не призначено
+
+live-map-drawer:
+ driver-listing:
+ location: Розташування
+ current-job: Поточна робота
+ last-seen: Востаннє помічено
+ view-driver: Переглянути деталі водія...
+ edit-driver: Редагувати водія...
+ locate-driver: Знайти водія...
+ delete-driver: Видалити водія...
+ warning-message: Не вдалося знайти водія.
+ message: Фільтр водіїв за ключовим словом...
+ place-listing:
+ location: Розташування
+ place: Місце
+ view-place: Переглянути деталі місця...
+ edit-place: Редагувати місце...
+ locate-place: Знайти місце...
+ delete-place: Видалити місце...
+ warning-message: Не вдалося знайти місце.
+ message: Фільтр місць за ключовим словом...
+ vehicle-listing:
+ location: Розташування
+ last-seen: Востаннє помічено
+ view-vehicle: Переглянути деталі ТЗ...
+ edit-vehicle: Редагувати ТЗ...
+ locate-vehicle: Знайти ТЗ...
+ delete-vehicle: Видалити ТЗ...
+ warning-message: Не вдалося знайти ТЗ.
+ message: Фільтр ТЗ за ключовим словом...
+
+scheduler:
+ success-message: Замовлення {orderId} заплановано на {orderAt}.
+ info-message: Планування замовлення {orderId} скасовано.
+ title: Планувальник
+ unscheduled-orders: Незаплановані замовлення
+ scheduled-orders: Заплановані замовлення
+ unauthorized-to-schedule: У вас немає прав для планування замовлень.
+ scheduling-for: Планування для {orderId}
+ add-shift: Додати зміну
+ create-shift: Створити зміну
+ edit-shift: Редагувати зміну
+ delete-shift: Видалити зміну
+ delete-shift-confirm: Ви дійсно бажаєте видалити цю зміну? Цю дію неможливо скасувати.
+ shift-created: Зміну успішно створено.
+ shift-updated: Зміну успішно оновлено.
+ shift-title: Назва зміни
+ shift-title-placeholder: напр. Ранковий рейс доставки
+ shift-start: Початок зміни
+ shift-end: Кінець зміни
+ shift-notes-placeholder: Додаткові примітки про цю зміну
+ select-start-datetime: Виберіть дату та час початку
+ select-end-datetime: Виберіть дату та час закінчення
+ select-driver: Вибрати водія
+ upcoming-shifts: Майбутні зміни
+ no-upcoming-shifts: Майбутніх змін не заплановано.
+ hours-of-service: Робочий час (Hours of Service)
+ daily-driving-hours: Денний час водіння
+ weekly-hours: Тижневі години
+ compliance-status: Статус відповідності нормам
+ set-availability: Встановити доступність
+ availability-set: Доступність успішно збережено.
+ availability-time-off: Доступність та відпустки/відгули
+ no-availability-restrictions: Обмежень доступності не встановлено.
+ available: Доступний
+ unavailable: Недоступний
+ unavailable-time-off: Недоступний / Відпустка
+ availability-type: Тип доступності
+ request-time-off: Запросити відпустку / відгул
+ submit-request: Надіслати запит
+ time-off-requested: Запит на відгул надіслано.
+ delete-availability: Видалити доступність
+ delete-availability-confirm: Ви дійсно бажаєте вилучити цей запис доступності?
+ availability-deleted: Запис доступності вилучено.
+ start-date: Дата початку
+ end-date: Дата закінчення
+ select-start-date: Виберіть дату початку
+ select-end-date: Виберіть дату закінчення
+ reason: Причина
+ reason-placeholder: напр. Особисті справи
+ notes-placeholder: Додаткові примітки
+ shift: Зміна
+ add-shift-hint: Натисніть "Додати зміну", щоб запланувати нову зміну.
+ time-off-exceptions: Відгули та винятки
+ no-exceptions: Відгулів або винятків не зафіксовано.
+ exception-approved: Виняток схвалено.
+ exception-rejected: Виняток відхилено.
+ exception-deleted: Виняток вилучено.
+ delete-exception: Видалити виняток
+ delete-exception-confirm: Ви дійсно бажаєте вилучити цей виняток?
+ approve: Схвалити
+ reject: Відхилити
+ recurring-schedule-created: Періодичний графік створено, зміни матеріалізовано.
+ recurring-schedule: Періодичний графік
+ is-recurring: Повторюваний
+ recurrence-pattern: Шаблон повторення
+ recurrence-start-date: Починається з
+ recurrence-end-date: Закінчується (опціонально)
+ template-name: Назва шаблону
+ template-name-placeholder: напр. Стандартний тиждень
+ schedule-name: Назва графіка
+ schedule-name-placeholder: напр. Стандартний тиждень
+ one-off-shift: Разова зміна
+ one-off-shift-help: Увімкніть додавання однієї зміни на конкретну дату замість регулярного графіка.
+ template-color: Колір
+ shift-start-time: Час початку зміни
+ shift-end-time: Час закінчення зміни
+ break-start-time: Час початку перерви
+ break-end-time: Час закінчення перерви
+ day-monday: Понеділок
+ day-tuesday: Вівторок
+ day-wednesday: Середа
+ day-thursday: Четвер
+ day-friday: П'ятниця
+ day-saturday: Субота
+ day-sunday: Неділя
+ frequency-daily: Щодня
+ frequency-weekly: Щотижня
+ frequency-monthly: Щомісяця
+ today: Сьогодні
+ orders-tab: Графік замовлень
+ fleet-schedule-tab: Графік автопарку
+ break-start: Початок перерви
+ break-end: Кінець перерви
+ select-break-start: Виберіть час початку перерви
+ select-break-end: Виберіть час закінчення перерви
+ select-date: Виберіть дату
+ working-days: Робочі дні
+ recurrence-start: Починається з
+ recurrence-end: Закінчується
+ recurring-schedule-help: Визначте повторюваний шаблон змін за днями та часом.
+ shift-deleted: Зміну успішно видалено.
+ day-mon: Пн
+ day-tue: Вт
+ day-wed: Ср
+ day-thu: Чт
+ day-fri: Пт
+ day-sat: Сб
+ day-sun: Нд
+ view-day: День
+ view-week: Тиждень
+ undo: Скасувати останню дію
+ redo: Повторити останню дію
+ search-orders: Пошук замовлень...
+ select-all: Вибрати все
+ all-scheduled: Усі замовлення заплановано
+ not-scheduled: Не заплановано
+ expand-sidebar: Розгорнути бічну панель
+ collapse-sidebar: Згорнути бічну панель
+ bulk-assign: Призначити {count} замовлень
+ bulk-assign-title: Масове призначення {count} замовлень
+ bulk-assign-success: Успішно призначено {count} замовлень.
+ bulk-assign-order-count: '{count} замовлень вибрано для призначення'
+ bulk-assign-spacing-hint: Замовлення будуть заплановані послідовно, починаючи з вибраної дати й часу.
+ assign-to-driver: Призначити водію
+ assign-orders: Призначити замовлення
+ orders-to-assign: '{count} замовлень для призначення'
+ scheduled-date: Запланована дата
+ conflict-title: Виявлено конфлікт у графіку
+ conflict-description: '{orderTracking} перетинається з існуючим замовленням водія {driverName}.'
+ conflicting-orders: Конфліктні замовлення
+ conflict-resolution-prompt: Як ви бажаєте вирішити цей конфлікт?
+ auto-adjust: Автокоригування часу
+ assign-anyway: Все одно призначити
+ clear-selection: Очистити вибір
+modals:
+ new-custom-field-group:
+ modal-title: Створити нову групу настроюваних полів
+ group-name: Назва групи
+
+ assign-driver:
+ select-driver: Вибрати водія
+ select-assign: Вибрати водія для призначення
+
+ clone-config-form:
+ name: Назва конфігурації
+ description: Опис конфігурації
+
+ driver-assign-order:
+ order: Вибрати замовлення
+ assign-order: Вибрати замовлення для призначення
+
+ driver-assign-vehicle:
+ vehicle: Вибрати ТЗ
+ assign-vehicle: Вибрати ТЗ для призначення
+
+ driver-assign-vendor:
+ vendor: Вибрати постачальника
+
+ driver-details:
+ job: Поточна робота
+ view: Переглянути на карті
+
+ driver-form:
+ select-vendor: Вибрати постачальника
+ select-vehicle: Вибрати ТЗ
+
+ edit-meta-form:
+ add: Додати метаполе
+
+ entity-form:
+ name-text: Назва позиції або сутності.
+ id-text: Використовуйте це поле для власного або внутрішнього ідентифікатора.
+ sku: Артикул (SKU)
+ sku-text: Номер облікової одиниці (за наявності).
+ description: Опис
+ description-text: Додатковий опис сутності.
+ price-title: Ціноутворення та вартість
+ price: Ціна
+ price-text: Ціна сутності.
+ sale-price: Акційна ціна
+ sale-price-text: Знижена або акційна ціна сутності.
+ declare: Декларована вартість
+ declare-text: Декларована вартість сутності (корисно для страхування).
+ measurement-title: Габарити та вага
+ length: Довжина
+ length-text: Довжина сутності.
+ width: Ширина
+ width-text: Ширина сутності.
+ height: Висота
+ height-text: Висота сутності.
+ weight: Вага
+ weight-text: Вага сутності.
+
+ entity-meta-field-prompt:
+ name: Ключ метаполя
+
+ fleet-form:
+ title: Назва автопарку
+ assign: Призначити до зони обслуговування
+ assign-text: Виберіть зону обслуговування для призначення автопарку
+ zone: Призначити до зони
+ zone-text: Виберіть зону для призначення автопарку
+ task: Завдання / Місія
+ task-text: Опишіть основне завдання або місію цього автопарку.
+ select-task: Вибрати завдання
+ status-text: Виберіть статус автопарку.
+ select-status: Вибрати статус
+
+ fuel-report-details:
+ fuel: Пальне / Об'єм
+
+ fuel-report-form:
+ select-driver: Вибрати водія
+ select-vehicle: Вибрати ТЗ
+ volume: Об'єм
+ volume-text: Виберіть метричну одиницю та об'єм пального.
+
+ group-details:
+ name: Назва групи
+ member: Учасники
+ address: Адреса
+
+ group-form:
+ title: Назва групи
+ users-add: Вибрати користувачів для додавання до групи
+ search: Пошук та вибір користувачів для додавання до цієї групи.
+ select-user: Вибрати користувача для додавання
+ no-users: До групи не додано жодного користувача
+
+ install-prompt:
+ message: Ви намагаєтеся встановити розширення {extensionName}. Бажаєте продовжити?
+ sub-message: Деякі розширення вимагають доступу до ваших ресурсів.
+
+ issue-details:
+ assigned: Призначено
+ type: Тип проблеми
+ category: Категорія проблеми
+
+ issue-form:
+ reported: Повідомив
+ select-reporter: Вибрати заявника
+ assigned: Призначено
+ select-assignee: Вибрати виконавця
+ select-driver: Вибрати водія
+ select-vehicle: Вибрати ТЗ
+ select-type: Вибрати тип
+ select-category: Вибрати категорію
+ type: Тип проблеми
+ type-text: Виберіть тип проблеми
+ category: Категорія проблеми
+ category-text: Виберіть категорію проблеми
+ report: Звіт про проблему
+ tags: Теги проблеми
+ add-tags: Додати теги
+ select-status: Виберіть статус проблеми
+
+ map-layer-form:
+ layer-type: Тип шару
+ layer-type-text: Виберіть тип шару, який ви хочете створити.
+ select-layer-type: Виберіть тип шару...
+ service-area: Зона обслуговування
+ service-area-text: Виберіть зону обслуговування, до якої належить ця зона.
+ select-service-area: Виберіть зону обслуговування для призначення
+ set-name: Встановіть відображувану назву для {options}.
+ service-type: Тип зони обслуговування
+ service-type-text: Виберіть визначення типу для цієї зони обслуговування.
+ select-service-type: Виберіть тип зони обслуговування...
+ customize-border: Налаштуйте колір межі для цього {options}.
+ customize-fill-color: Налаштуйте колір заливки для цього {options}.
+ service-area-country: Країна зони обслуговування
+ service-area-country-text: Опціонально вкажіть країну розташування.
+
+ new-order-config:
+ name: Назва конфігурації
+ description: Опис конфігурації
+ tags: Теги конфігурації
+ add-tags: Додати теги
+
+ order-assign-driver:
+ select-driver: Вибрати водія
+ select-assign: Вибрати водія для призначення
+
+ order-config-new-status:
+ new-status: Новий статус
+ status-text: Введіть новий статус активності для додавання до потоку
+
+ order-event:
+ schedule: Запланувати
+ unschedule: Скасувати планування
+
+ order-form:
+ id-text: Використовуйте це поле для власного або внутрішнього ідентифікатора замовлення.
+ schedule-text: Дата та час відправки замовлення.
+ customer: Клієнт
+ customer-text: Опціонально призначте клієнта замовлення.
+ select-customer: Вибрати клієнта
+ facilitator: Посередник
+ facilitator-text: Опціонально призначте посередника.
+ select-facilitator: Вибрати посередника
+ assign-driver: Призначити водія
+ assign-driver-text: Призначте водія для виконання замовлення.
+ select-driver: Вибрати водія
+ assign-vehicle: Призначити ТЗ
+ assign-vehicle-text: Призначте ТЗ для виконання замовлення.
+ select-vehicle: Вибрати ТЗ
+
+ order-import:
+ loading-message: Обробка імпорту...
+ drop-upload: Перетягніть для завантаження
+ invalid: Недійсний
+ ready-upload: готово до завантаження.
+ upload-spreadsheets: Завантажити електронні таблиці
+ drag-drop: Перетягніть файли таблиць у цю область
+ button-text: або виберіть електронні таблиці для завантаження
+ spreadsheets: електронні таблиці
+ upload-queue: Черга завантаження
+
+ order-label:
+ loading: Завантаження маркування...
+
+ order-new-activity:
+ select-message: Виберіть статус активності для оновлення відстеження або введіть власний статус.
+ order-currently-message: Це замовлення наразі в статусі {options}, додаткова активність перезапише цей статус.
+ not-recommend-message: Fleetbase не рекомендує це робити, якщо ви не впевнені у своїх діях.
+ activity-options: Параметри активності
+ activity: активність
+ dispatch: Це запустить процес відправки замовлення.
+ status: Власний статус активності
+ details: Власні деталі активності
+ code: Власний код активності
+ fleetbase-message: Fleetbase не рекомендує це робити, якщо ви повністю не розумієте наслідки.
+
+ order-route-form:
+ toggle: Переключити декілька точок розвантаження
+ optimize: Оптимізувати маршрут
+ optimize-text: Fleetbase автоматично оптимізує маршрут.
+ add: Додати точку маршруту
+ add-text: Скористайтеся кнопкою `Додати точку маршруту` для створення нових зупинок.
+ select-waypoint: Вибрати точку маршруту
+ pickup: Точка завантаження
+ edit-address: Редагувати адресу
+ select-pickup: Вибрати точку завантаження
+ invalid: Недійсні координати!
+ dropoff: Точка розвантаження
+ select-dropoff: Вибрати точку розвантаження
+ return: Точка повернення
+ select-return: Вибрати точку повернення
+
+ place-assign-vendor:
+ select-vendor: Вибрати постачальника
+
+ place-details:
+ view: Переглянути на карті
+
+ policy-form:
+ name: Назва політики
+ name-text: Введіть назву для вашої політики
+ description: Опис політики
+ description-text: Введіть опис для вашої політики
+ permissions: Вибрати дозволи
+
+ role-form:
+ name: Назва ролі
+ name-text: Введіть назву для цієї ролі
+ permissions: Вибрати дозволи
+
+ select-payment-method:
+ card-ending: Картка, що закінчується на {method}
+
+ service-area-form:
+ name: Назва зони обслуговування
+ name-text: Встановіть відображувану назву для зони обслуговування.
+ type: Тип зони обслуговування
+ type-text: Виберіть тип зони обслуговування.
+ select-type: Виберіть тип зони обслуговування...
+ border-color: Колір межі зони обслуговування
+ customize-border-color: Налаштуйте колір межі.
+ area-color: Колір зони обслуговування
+ customize-area-color: Налаштуйте колір заливки.
+ country: Країна зони обслуговування
+ country-text: Опціонально вкажіть країну.
+
+ set-password:
+ message: Ласкаво просимо до Fleetbase! Встановіть новий пароль для продовження.
+ new-password: Новий пароль
+ confirm-password: Підтвердьте пароль
+ help-text: Введіть пароль довжиною не менше 8 символів.
+
+ uninstall-prompt:
+ message: Ви намагаєтеся видалити розширення "{options}". Бажаєте продовжити?
+ sub-message: Усі конфігурації та персональні налаштування будуть втрачені.
+
+ vehicle-details:
+ make: Марка
+ model: Модель
+ year: Рік
+ driver-assigned: Призначений водій
+ no-driver-assigned: Водія не призначено
+ vehicle-assigned: Призначений ТЗ
+ no-vehicle-assigned: ТЗ не призначено
+ avatar: Аватар
+ model-information: Інформація про модель
+ acceleration: Розгін 0-100 км/год
+ body: Кузов
+ doors: Двері
+ length: Довжина (мм)
+ seats: Місць
+ top-speed: Максимальна швидкість (км/год)
+ transmission: Тип трансмісії
+ weight: Вага (кг)
+ wheelbase: Колісна база (мм)
+ width: Ширина (мм)
+ engine-information: Інформація про двигун
+ engine-bore: Діаметр циліндра (мм)
+ cc: Об'єм (см³)
+ compression: Ступінь стиснення
+ cylinder: Циліндри
+ position: Розташування
+ power-ps: Потужність (к.с. / ps)
+ power-rpm: Потужність (об/хв)
+ stroke: Хід поршня (мм)
+ torque-nm: Крутний момент (Нм)
+ torque-rpm: Крутний момент (об/хв)
+ valves: Клапанів на циліндр
+ fuel-information: Інформація про пальне
+ fuel: Пальне
+ fuel-cap: Об'єм бака (л)
+ liters-city: Витрата на 100 км (місто)
+ liters-highway: Витрата на 100 км (траса)
+ liters-mixed: Витрата на 100 км (змішаний)
+ vehicle-form:
+ map-avatar: Вибрати аватар для карти
+ map-avatar-text: Виберіть аватар для відображення на картах при відстеженні.
+ select-avatar: Вибрати аватар
+ make: Марка ТЗ
+ model: Модель ТЗ
+ year: Рік випуску ТЗ
+ select-driver: Вибрати водія
+ body: Тип кузова ТЗ
+ doors: Кількість дверей
+ type: Тип приводу ТЗ
+ length: Довжина ТЗ (мм)
+ seats: Кількість місць
+ top-speed: Максимальна швидкість ТЗ (км/год)
+ transmission: Тип трансмісії ТЗ
+ weight: Вага ТЗ (кг)
+ wheelbase: Колісна база ТЗ (мм)
+ width: Ширина ТЗ (мм)
+ engine-bore: Діаметр циліндра (мм)
+ cc: Об'єм (см³)
+ compression: Ступінь стиснення двигуна
+ cylinder: Циліндри двигуна
+ position: Розташування двигуна
+ power-ps: Потужність двигуна (к.с.)
+ power-rpm: Потужність двигуна (об/хв)
+ stroke: Хід поршня (мм)
+ torque-nm: Крутний момент двигуна (Нм)
+ torque-rpm: Крутний момент двигуна (об/хв)
+ fuel-type: Тип пального
+ fuel-cap: Об'єм паливного бака (л)
+
+ vendor-details:
+ sandbox: Песочниця (Sandbox)
+ host: Хост
+ namespace: Простір імен
+
+ vendor-form:
+ choose-vendor: Вибрати інтегрованого постачальника
+ create-vendor: Створити власного постачальника
+ credentials: Облікові дані
+ options: Параметри
+ hide-advanced: Приховати додаткові
+ show-advanced: Показати додаткові
+ custom-host: Власний хост
+ host-text: Опціонально вкажіть власний хост.
+ custom-namespace: Власний простір імен
+ namespace-text: Опціонально вкажіть власний простір імен або версію API.
+ webhook: URL-адреса Webhook
+ webhook-text: Необхідно для отримання оновлень активності. Змінюйте обережно!
+ cancel-credentials: Скасувати скидання облікових даних
+ reset-message: Чутливі дані можна лише скинути шляхом повторного введення.
+ optionally: Опціонально вкажіть власний
+ configure-vendor: для налаштування цього постачальника.
+ select: Вибрати
+ sandbox: Песочниця (Sandbox)
+ name-text: Назва постачальника (зазвичай назва компанії).
+ email-text: Електронна пошта постачальника.
+ phone-text: Телефон постачальника.
+ website-text: Вебсайт постачальника.
+ select-address: Вибрати адресу
+
+ zone-form:
+ name: Назва зони
+ name-text: Встановіть відображувану назву зони.
+ border-color: Колір межі зони
+ customize-border: Налаштуйте колір межі.
+ zone-color: Колір зони
+ customize-fill-color: Налаштуйте колір заливки.
+ optionally: Опціонально додайте опис зони.
+ description-zone: Опис зони
+
+radar:
+ title: Радар
+ eyebrow: Ресурси
+ subtitle: Усе за ресурсами, обслуговуванням, інспекціями та персоналом, що потребує рішення — по одному рядку на запис.
+ summary: "{open} відкрито · {snoozed} відкладено · остання синхронізація {time}"
+ search-placeholder: Фільтрувати елементи
+ reload: Перезавантажити
+ new-notice: Нове сповіщення
+ showing: "Відображається {count} з {total} · згруповано за терміном"
+ sources-warning: "Не вдалося завантажити деякі джерела: {sources}"
+ briefing:
+ title: Ранковий брифінг
+ score: Оцінка здоров'я автопарку
+ of-100: "/ 100"
+ vs-yesterday: "{delta} порівняно з вчора"
+ no-history: перше зчитування
+ generated: "Згенеровано {time} · джерела пов'язані"
+ yesterday: "Вчора: закрито {closed} прогалин, перенесено {rolled}"
+ decisions: Рішення, що чекають на вас
+ decisions-count: "{shown} з {total}"
+ nothing-to-decide: Немає рішень для прийняття.
+ confirm: Підтвердити
+ not-now: Не зараз
+ undo: Скасувати дію
+ wake-now: Відновити зараз
+ confirmed: "Підтверджено {time}"
+ snoozed-until: "Відкладено до {time}"
+ filter: Фільтр
+ collapse: Згорнути брифінг
+ expand: Показати брифінг
+ open-record: Відкрити запис
+ working: Обробка…
+ views:
+ list: Список
+ agenda: Порядок денний
+ agenda:
+ window-24h: Наступні 24 год
+ window-7d: 7 днів
+ overdue: Прострочено
+ later: Пізніше
+ anytime: Будь-коли
+ anytime-count: "{count} без дати"
+ no-events: Немає подій у цьому часовому вікні.
+ drag-hint: Перетягніть елемент на доріжку, щоб призначити йому час.
+ print: Друк передавальної відомості
+ see-all: "Переглянути всі {count} →"
+ planned: Заплановано
+ now: зараз
+ lanes:
+ shifts: Зміни
+ maintenance: Обслуговування
+ expiries: Завершення термінів
+ notices: Сповіщення
+ shift-states:
+ on_shift: на зміні
+ not_online: поза мережею
+ upcoming: майбутні
+ ended: завершено
+ summary: "{shifts} змін у вікні · {overdue} прострочено · {anytime} без дати"
+ handover:
+ title: Завершення зміни
+ ends-in: "через {minutes}"
+ orders-on-driver: "{count} активних замовлень все ще на водієві {name}"
+ finishes-after: завершується після закінчення зміни
+ suggested: Пропонована передача
+ on-shift-till: "на зміні до {time}"
+ away: "{distance} км звідси"
+ reassign: "Перепризначити {count} замовлень на водія {name}"
+ extend: Продовжити зміну на 1 год
+ snooze: Відкласти на 1 год
+ no-cover: Немає нікого на зміні для підміни; продовжте зміну або перепризначте вручну.
+ reassigned: "Замовлення переміщено до {name}."
+ extended: Зміну продовжено на одну годину.
+ loading: Пошук підміни…
+ tabs:
+ open: Відкриті
+ snoozed: Відкладені
+ resolved: Вирішені
+ pills:
+ overdue: Прострочено
+ due_week: Термін цього тижня
+ unassigned: Не призначено
+ issues: Відкриті проблеми
+ inspections: Інспекції
+ shifts: Зміни графіку
+ expiring: Загасає через 30д
+ low_stock: Низький запас
+ fuel: Незідставлене пальне
+ notices: Сповіщення
+ groups:
+ overdue: Прострочено
+ today: Сьогодні
+ week: Цього тижня
+ later: Пізніше
+ none: Без дати
+ rules:
+ maintenance_overdue: ТО
+ maintenance_due_soon: ТО
+ inspection_due: Інспекція
+ inspection_failed: Інспекція
+ inspection_unresolved: Інспекція
+ inspection_draft: Інспекція
+ inspection_link_pending: Інспекція
+ vehicle_inspection_failed: Інспекція
+ work_order_overdue: Заявка на роботу
+ work_order_blocked: Заявка на роботу
+ issue_open: Проблема
+ shift_late_start: Зміна
+ shift_no_vehicle: Зміна
+ shift_handover: Передача зміни
+ driver_without_vehicle: Не призначено
+ vehicle_without_driver: Простоює
+ vehicle_without_device: Пристрій
+ device_unattached: Пристрій
+ license_expiring: Посвідчення
+ lease_expiring: Лізинг
+ fuel_unmatched: Пальне
+ part_low_stock: Запчастини
+ notice: Сповіщення
+ actions:
+ acknowledge: Підтвердити
+ snooze: Відкласти
+ wake: Відновити зараз
+ assign: Призначити
+ unassign: Очистити власника
+ plan: Призначити час
+ unplan: Очистити час
+ resolve: Вирішити
+ open-record: Відкрити запис
+ assign-vehicle: Призначити ТЗ
+ assign-driver: Призначити водія
+ create-work-order: Створити заявку на роботу
+ create-issue: Створити проблему
+ mark-resolved: Позначити як вирішене
+ send-pin: Надіслати PIN
+ revoke-link: Відкликати посилання
+ revoke-links: "Відкликати {count, plural, one {# посилання} few {# посилання} other {# посилань}}"
+ match-vehicle: Зіставити з ТЗ
+ ignore: Ігнорувати
+ attach: Приєднати до ТЗ
+ call: Зателефонувати
+ cover-shift: Підмінити зміну
+ reassign: Перепризначити замовлення
+ extend-shift: Продовжити зміну на 1 год
+ select: Вибрати
+ deselect: Скасувати вибір
+ more: Більше
+ snooze:
+ 1h: 1 година
+ 4h: 4 години
+ tomorrow: Завтра вранці
+ next-week: Наступного тижня
+ pick: Вибрати дату…
+ pick-title: Відкласти до
+ pick-label: Відновити
+ state:
+ acknowledged-by: "Підтверджено {name} о {time}"
+ acknowledged: Підтверджено
+ snoozed-until: "Відкладено до {time}"
+ assigned-to: "Призначено {name}"
+ unassigned: Не призначено
+ planned-for: "Заплановано на {time}"
+ resolved-by: "Вирішено {name} о {time}"
+ resolved-auto: Закрито в записі
+ bulk:
+ selected: "Вибрано {count}"
+ clear: Очистити
+ summary: "{count} елементів"
+ keys:
+ move: перемістити
+ select: вибрати
+ acknowledge: підтвердити
+ snooze: відкласти
+ assign: призначити
+ open: відкрити запис
+ close: закрити
+ empty:
+ title: Нічого не потребує рішення.
+ snoozed-hint: "{count} відкладених елементів відновляться цього тижня."
+ nothing-snoozed: Нічого не відкладено.
+ wakes: "відновиться о {time}"
+ filtered-title: Нічого не відповідає цим фільтрам.
+ filtered-hint: Очистіть фільтр або пошук для перегляду деталей.
+ snoozed-title: Нічого не відкладено.
+ snoozed-tab-hint: Відкладені елементи повернуться до списку після закінчення заданого часу.
+ resolved-title: Нічого не вирішено за останні 7 днів.
+ resolved-hint: Елементи закриваються тут, коли змінюється запис або вирішується сповіщення.
+ saved-views:
+ title: Вигляди
+ save-current: Зберегти поточний
+ name-prompt: Назвіть цей вигляд
+ name-placeholder: напр. Термін цього тижня
+ saved: Вигляд збережено.
+ delete: Видалити вигляд
+ deleted: Вигляд вилучено.
+ defaults:
+ my-assignments: Мої призначення
+ shift-changes: Зміни графіку
+ due-this-week: Термін цього тижня
+ unmatched-fuel: Незіставине пальне
+ notice:
+ title: Нове сповіщення
+ message: Повідомлення
+ message-placeholder: Територія закрита в суботу на ремонт — перемістіть причепи до Пт 18:00
+ severity: Рівень важливості
+ due: Граничний термін
+ scope: Застосовується до
+ scope-placeholder: Двір 3 · весь автопарк
+ create: Опублікувати сповіщення
+ created: Сповіщення опубліковано.
+ delete: Видалити сповіщення
+ deleted: Сповіщення видалено.
+ severities:
+ info: Інформація
+ warning: Попередження
+ critical: Критично
+ drawer:
+ failed-items: Елементи з помилками
+ state: Стан
+ subject: Запис
+ due: Термін
+ window: Зміна
+ details: Деталі
+ prompts:
+ select-vehicle: Виберіть ТЗ
+ select-user: Виберіть особу
+ assign-vehicle-title: "Призначити ТЗ для {name}"
+ assign-driver-title: "Призначити водія для {name}"
+ match-vehicle-title: Зіставити цю транзакцію з ТЗ
+ create-work-order-title: Створити заявку на роботу з цього графіка?
+ create-work-order-body: Заявка на роботу відкривається з параметрами за замовчуванням та датою виконання з графіка.
+ ignore-title: Ігнорувати цю транзакцію?
+ ignore-body: Вона залишить чергу незіставлених і не буде прив'язана до ТЗ.
+ attach-device-title: Приєднати цей пристрій до ТЗ
+ revoke-link-title: Відкликати це посилання інспекції?
+ send-pin-title: Надіслати PIN інспекції
+ send-pin-to: "Як {name} має отримати посилання та PIN?"
+ send-pin-body: Як надіслати посилання та PIN?
+ send-pin-unavailable: У отримувача посилання немає адреси електронної пошти або номера телефону.
+ revoke-link-body: Посилання негайно припинить працювати.
+ bulk-revoke-link-title: "Відкликати {count, plural, one {# посилання інспекції} few {# посилання інспекції} other {# посилань інспекції}}?"
+ bulk-revoke-link-body: Посилання негайно припинять працювати.
+ assign-user-title: "Хто власник цього?"
+ assign-user-help: Власник відображається в рядку та у вигляді 'Мої призначення'.
+ no-selection: Спочатку виберіть елемент.
+ toasts:
+ acknowledged: Підтверджено.
+ snoozed: "Відкладено до {time}."
+ woken: Повернуто до списку.
+ assigned: "Призначено {name}."
+ unassigned: Власника очищено.
+ planned: Час встановлено.
+ resolved: Вирішено.
+ work-order-created: Заявку на роботу створено.
+ vehicle-assigned: ТЗ призначено.
+ driver-assigned: Водія призначено.
+ matched: Транзакцію зіставлено.
+ ignored: Транзакцію проігноровано.
+ device-attached: Пристрій приєднано.
+ pin-sent: PIN надіслано.
+ link-revoked: Посилання відкликано.
+ links-revoked: "{count, plural, one {# посилання відкликано} few {# посилання відкликано} other {# посилань відкликано}}."
+ links-revoke-failed: "{count, plural, one {# посилання не вдалося відкликати} few {# посилання не вдалося відкликати} other {# посилань не вдалося відкликати}}."
+ bulk-done: "Оновлено елементів: {count}."
+widget:
+ refresh: Оновити
+ radar:
+ title: Радар
+ description: Що потребує рішення в автопарку прямо зараз.
+ open: відкрито
+ overdue: прострочено
+ snoozed: відкладено
+ open-radar: Відкрити Радар
+ kpi-earnings:
+ title: Доходи
+ kpi-distance:
+ title: Пройдена відстань
+ kpi-aov:
+ title: Середній чек замовлення
+ kpi-active-orders:
+ title: Активні замовлення
+ kpi-drivers-online:
+ title: Водії в мережі
+ kpi-open-issues:
+ title: Відкриті проблеми
+ operations-pulse:
+ title: Пульс операцій
+ revenue-trend:
+ title: Тренди доходів
+ orders-by-status:
+ title: Обсяг замовлень за статусом
+ on-time-delivery:
+ title: Доставка вчасно
+ top-drivers:
+ title: Кращі водії
+ fuel-efficiency:
+ title: Вартість пального та ефективність
+ issues-insights:
+ title: Аналітика проблем
+ maintenance-overview:
+ title: Огляд технічного обслуговування
+ geofence-violations:
+ title: Порушення геозон
+ live-fleet:
+ title: Карта автопарку в реальному часі
+ get-started:
+ title: Початок роботи з Fleet-Ops
+ subtitle: Налаштуйте основні параметри та виконайте перший процес відправки.
+ completed: завершено
+ preview-title: Робочий процесс першого замовлення
+ recommended-features:
+ title: Рекомендовані функції для вас
+ subtitle: Досліджуйте інструменти, які допомагають новим операційним командам працювати ефективніше.
+ learn-more: Дiзнатися більше
+ fleet-ops-quickstart:
+ message: Давайте подивимося, що можна зробити для швидкого запуску операцій...
+ start-task: Розпочати завдання
+ key-metrics:
+ title: Метрики Fleet-Ops
+ earnings: Доходи
+ fuel-expenses: Витрати на пальне
+ traveled: Пройдена відстань
+ total-time: Загальний час
+ order-scheduled: Заплановані замовлення
+ order-completed: Завершені замовлення
+ order-progress: Замовлення в процесі
+ order-canceled: Скасовані замовлення
+ driver-online: Водії в мережі
+ customers: Клієнти
+ open-issue: Відкриті проблеми
+ closed-issues: Закриті проблеми
+ live-order-map:
+ loading: Завантаження замовлень у процесі виконання...
+ assigned: Призначено водія
+ no-order: Немає замовлень у процесі
+ message: Коли замовлення виконуються, вони відображаються у цьому віджеті з активністю в реальному часі.
+ recent-orders:
+ title: Останні замовлення
+ loading: Завантаження останніх замовлень...
+ no-orders: Немає замовлень на цьому тижні
+ message-part-1: На цьому тижні замовлень не було, розпочніть рух шляхом
+ message-part-2: створення нового замовлення
+ message-part-3: або дозвольте вашим клієнтам надсилати замовлення через API.
+ transactions:
+ title: Останні транзакції
+ loading: Завантаження останніх транзакцій...
+ no-transactions: Немає транзакцій на цьому тижні
+ message: Коли замовлення проходять із тарифами на послуги, Fleetbase відстежує транзакції автоматично.
+
+display-panel:
+ no-address: Немає адреси {type}!
+ no-address-message: Адреса відсутня!
+
+driver-card:
+ no-fleets: Немає автопарків
+
+global-search:
+ search: Пошук за ключовим словом...
+
+integrated-order-details:
+ order-id: ID замовлення
+ quotation-id: ID розрахунку
+ driver-id: ID водія
+ link: Посилання для поширення
+ price-breakdown: Деталізація ціни
+ metadata: Метадані
+
+geofence:
+ prompts:
+ use-draw-controls-create-service-area: Використовуйте елементи малювання праворуч для створення зони обслуговування. З'єднайте точки для збереження.
+ use-draw-controls-create-zone: Використовуйте елементи малювання праворуч для створення зони всередині зони обслуговування. З'єднайте точки для збереження.
+ no-layer-found-for-resource: Не знайдено шар для цього {resource}.
+ editing-enabled: Редагування увімкнено — відкоригуйте вершини та натисніть галочку редагування для застосування.
+ resource-boundaries-updated: >-
+ Межі {resource} успішно оновлено.
+ edit-canceled: Редагування скасовано.
+ failed-to-update-resource: Не вдалося оновлення {resource}.
+
+live-map:
+ show-coordinates: Показати координати
+ center-map: Центрувати карту тут
+ zoom-in: Наблизити
+ zoom-out: Віддалити
+ toggle-draw-controls: Переключити елементи малювання
+ hide-draw: Приховати елементи малювання
+ enable-draw: Увімкнути елементи малювання
+ create-new-service: Створити нову зону обслуговування
+ focus-service: 'Фокус на зоні обслуговування: {serviceName}'
+ view-driver: 'Переглянути водія: {driverName}'
+ edit-driver: 'Редагувати водія: {driverName}'
+ delete-driver: 'Видалити водія: {driverName}'
+ view-vehicle-for: 'Переглянути ТЗ для: {driverName}'
+ view-vehicle: 'Переглянути ТЗ: {vehicleName}'
+ edit-vehicle: 'Редагувати ТЗ: {vehicleName}'
+ delete-vehicle: 'Видалити ТЗ: {vehicleName}'
+ edit-zone: 'Редагувати зону: {zoneName}'
+ delete-zone: 'Видалити зону: {zoneName}'
+ assign-zone: 'Призначити автопарк до зони: {zoneName}'
+ blur-service: 'Приховати зону обслуговування: {serviceName}'
+ create-zone: 'Створити зону всередині: {serviceName}'
+ assign-fleet: 'Призначити автопарк до зони обслуговування: {serviceName}'
+ edit-service: 'Редагувати зону обслуговування: {serviceName}'
+ delete-service: 'Видалити зону обслуговування: {serviceName}'
+ service-area: Зона обслуговування
+ edit-boundaries: 'Редагувати межі: {resource}'
+ zone: Зона
+
+order-config:
+ create-new-title: Створити нову конфігурацію замовлень
+ warning-message: Конфігурація замовлень вимагає назви
+ success-message: Нову конфігурацію замовлення успішно створено.
+ no-order-warning: Не вибрано конфігурацію замовлень.
+ saved-success: 'Конфігурацію замовлення {orderName} збережено.'
+ enter-name-title: Введіть назву клонованої конфігурації
+ no-config-warning: Назву конфігурації не введено.
+ cloned-success: Конфігурацію замовлення успішно клоновано.
+ body: Після видалення цієї конфігурації замовлень ви більше не зможете створювати замовлення з її використанням. Ви впевнені?
+ uninstall-title: Видалити конфігурацію
+ uninstall-success: Розширення {extensionName} видалено.
+ activity-flow-editor:
+ add-status: Додати новий статус до потоку
+ no-status: Статус не введено.
+ overwrite: Це перезапише існуючий статус!
+ overwrite-text: Статус {statusName} вже існує в цьому потоці. Бажаєте перезаписати попередній статус {statusName}?
+ overwrite-button: Так, перезаписати!
+ remove: Видалити статус
+ remove-text: Ви дійсно бажаєте видалити цей статус? Усі пов'язані активності та логіку буде видалено разом зі статусом.
+ delete-button: Так, видалити
+ unable-warning: Статус {status} має бути в такій послідовності, зсув неможливий.
+ order-warning: Створений статус завжди має бути першим у послідовності потоку замовлення.
+ title: Потік активностей
+ message: Конфігурація потоку активностей дозволяє визначати різні типи статусів відстеження для цього типу замовлення. Також можна налаштувати логіку.
+ order-flow: Потік замовлення
+ waypoint-flow: Потік точок маршруту
+ new-activity: Нова активність
+ activity-details: Деталі активності
+ logic-stack: Стек логіки
+ add-logic: Додати логічну умову
+ select-field: Вибрати поле
+ select-operator: Вибрати оператор
+ enter-value: Введіть значення для порівняння
+ no-logic: Логічні умови не застосовані
+ details-editor:
+ success: Встановлено
+ key-text: Передайте цей ключ у тип замовлення для його конфігурації.
+ namespace-text: Унікальний простір імен для цього розширення.
+ id: ID розширення
+ id-text: Унікальний ID для цього розширення.
+ version: Версія розширення
+ version-text: Версія цього розширення.
+ clone: Клонувати конфігурацію
+ uninstall: Видалити розширення
+ delete: Видалити конфігурацію
+ entities-editor:
+ warning-message: Не введено назву ключа метаполя.
+ title: Сутності
+ message: Конфігурація сутностей дозволяє визначати типи сутностей, які мають бути доступні для вибору в цьому типі замовлення (використовуються як шаблони).
+ type: Тип сутності
+ add-meta: Додати метаполе
+ fields-editor:
+ text-field: Текстове поле
+ boolean: Логічне значення (Boolean)
+ boolean-text: Дозволяє користувачеві перемикати значення прапорцем (True/False)
+ dropdown: Выпадаючий список
+ dropdown-text: Дозволяє вибрати опцію з випадного списку
+ datetime: Вибір дати та часу
+ datetime-text: Дозволяє вибрати дату та час
+ port: Вибір порту
+ port-text: Дозволяє користувачеві вибрати порт
+ vessel: Вибір судна
+ vessel-text: Дозволяє користувачеві вибрати судно
+ warning-message: Назву групи не введено.
+ field-title: Метаполя
+ new-field: Нове налаштовуване поле
+ new-custom-field: Нова група налаштовуваних полів
+ field-text: Налаштування метаполів дозволить вам визначати власні поля вводу, які застосовуватимуться до замовлень.
+ custom-field-group: Група налаштовуваних полів
+ default-field: Поля за замовчуванням
+ custom-field: Налаштовуване поле
+ type: Тип поля
+ select-type: Оберіть тип поля
+ add-option: Додати варіант
+ select-message: Оберіть конфігурацію замовлення для зміни або натисніть "Нова конфігурація", щоб створити нову.
+ new-config: Нова конфігурація
+ loading-configuration: Завантаження ваших конфігурацій замовлень...
+ select-order: Оберіть конфігурацію замовлення
+ save-changes: Зберегти зміни
+ details: Деталі
+ fields: Налаштовувані поля
+ flow: Потік активності
+ entities: Сутності
+
+order-list-overlay:
+ search: Пошук замовлень...
+ selected: Обрано
+ assign: Призначити водієві...
+ cancel: Скасувати замовлення...
+ delete: Видалити замовлення...
+ dispatch: Диспетчеризувати замовлення...
+ actions: Дії
+ create-order: Створити нове замовлення...
+ create-fleet: Створити новий автопарк...
+ active-orders: Активні замовлення
+ unassigned-orders: Непризначені замовлення
+
+route-list:
+ expand: Натисніть, щоб розгорнути
+ collapse: Натисніть, щоб згорнути
+ more-waypoints: ще маршрутних точок
+
+settings:
+ avatar-management: Управління аватарками
+ custom-fields: Налаштовувані поля
+ notifications:
+ fleet-ops-notification-settings: Налаштування сповіщень Fleet-Ops
+ fleet-ops-notifications: Сповіщення Fleet-Ops
+ configure-notifications: Налаштувати сповіщення
+ select-notifiables: Оберіть отримувачів сповіщень
+ routing:
+ fleet-ops-routing-settings: Налаштування маршрутизації Fleet-Ops
+ fleet-ops-routing: Маршрутизація Fleet-Ops
+ configure-routing: Налаштувати маршрутизацію
+ select-routing-service: Оберіть сервіс маршрутизації
+ routing-service: Сервіс маршрутизації
+ routing-service-help-text: Оберіть сервіс, який відповідає за розрахунок і побудову маршрутів на карті.
+ select-routing-distance-unit: Оберіть одиницю вимірювання відстані
+ routing-distance-unit: Одиниця вимірювання відстані
+ routing-distance-unit-help-text: Одиниця, що використовується для розрахунку відстані та маршрутів.
+ map:
+ fleet-ops-map-settings: Налаштування карти Fleet-Ops
+ fleet-ops-map: Карта Fleet-Ops
+ configure-map: Налаштувати карту
+ map-provider: Провайдер карти
+ map-provider-help-text: Оберіть движок карти для відображення інтерактивної операційної карти. Leaflet використовує тайли OpenStreetMap і не потребує ключа API. Google Maps потребує дійсного ключа API Google Maps JavaScript.
+ select-map-provider: Оберіть провайдера карти
+ leaflet-tile-url: URL провайдера тайлів Leaflet
+ leaflet-tile-url-help-text: Необов'язковий шаблон URL для власних XYZ-тайлів Leaflet. Залиште порожнім для використання стандартних тайлів OpenStreetMap, які не потребують API-ключа.
+ leaflet-dark-tile-url: URL тайлів Leaflet для темного режиму
+ leaflet-dark-tile-url-help-text: Необов'язковий URL провайдера тайлів для темної теми консолі. Залиште порожнім для використання основної теми або стандартних тайлів OpenStreetMap.
+ google-maps-map-type: Тип карти
+ google-maps-map-type-help-text: Стиль карти за замовчуванням при активних Google Maps.
+ select-map-type: Оберіть тип карти
+ show-traffic-layer: Показувати шар трафіку
+ show-traffic-layer-help-text: Накладати актуальну інформацію про дорожній рух на карту Google Maps.
+ show-transit-layer: Показувати шар громадського транспорту
+ show-transit-layer-help-text: Накладати маршрути громадського транспорту на карту Google Maps.
+ settings-saved: Налаштування карти успішно збережено.
+ settings-save-failed: Не вдалося зберегти налаштування карти. Будь ласка, спробуйте ще раз.
+ navigator-app:
+ navigator-app-settings: Налаштування застосунку Навігатора
+ payments:
+ payments: Платежі
+ payment-settings: Налаштування платежів
+ account-not-setup-for-payments: Ваш обліковий запис ще не налаштовано для прийому платежів.
+ to-accept-payments: Щоб приймати та обробляти платежі, ви повинні пройти процес реєстрації через Stripe.
+ system-stripe-not-setup: Наразі система не може приймати або обробляти платежі. Зверніться до адміністратора.
+ loading-settings: Завантаження налаштувань платежів...
+ onboard:
+ header-title: Підключення платежів
+ title-completed: Реєстрацію успішно завершено!
+ title-incomplete: Пройдіть процес реєстрації, щоб приймати платежі.
+ subtitle-completed: Реєстрацію успішно завершено, тепер ви можете отримувати платежі від клієнтів та третіх сторін.
+ subtitle-incomplete: Цей процес необхідно пройти для можливості прийому оплати за замовлення.
+ button-continue: Продовжити
+ button-start: Розпочати реєстрацію зараз
+ button-in-progress: Реєстрація в процесі
+ scheduling:
+ scheduling-settings: Налаштування розкладу
+ materialisation: Матеріалізація
+ horizon-days: Горизонт розкладу (днів)
+ horizon-days-help: На скільки днів наперед генерувати зміни при застосуванні шаблону розкладу.
+ default-shift-duration: Тривалість зміни за замовчуванням (годин)
+ default-shift-duration-help: Тривалість зміни за замовчуванням, якщо час закінчення не вказано.
+ hours-of-service: Обмеження часу роботи (HOS)
+ hos-daily-limit: Денний ліміт водіння (годин)
+ hos-daily-limit-help: Максимальна кількість годин, яку водій може керувати авто за один день.
+ hos-weekly-limit: Тижневий ліміт водіння (годин)
+ hos-weekly-limit-help: Максимальна кількість годин водіння за ковзний 7-денний період.
+ behaviour: Поведінка
+ auto-activate-schedule: Автоактивація розкладу
+ auto-activate-schedule-help: Автоматично переводити розклад із стану Чернетка в Активний при створенні першої зміни.
+ notify-drivers: Сповіщати водіїв про зміни в розкладі
+ notify-drivers-help: Надсилати push-сповіщення водіям при створенні, оновленні або видаленні їхніх змін.
+ schedule-templates: Багаторазові шаблони розкладу
+ no-templates: Багаторазових шаблонів ще не створено.
+ new-template: Новий шаблон
+ edit-template: Редагувати шаблон
+ delete-template: Видалити шаблон
+ delete-template-confirm: Ви впевнені, що хочете видалити шаблон "{name}"? Це не вплине на існуючі розклади.
+ template-deleted: Шаблон успішно видалено.
+ settings-saved: Налаштування розкладу збережено.
+maintenance-schedule:
+ actions:
+ trigger-now: Запустити робоче завдання зараз
+ pause: Призупинити розклад
+ resume: Відновити розклад
+work-order:
+ actions:
+ view: Переглянути робоче завдання
+ edit: Редагувати робоче завдання
+ delete: Видалити робоче завдання
+maintenance:
+ actions:
+ view: Переглянути запис техобслуговування
+ edit: Редагувати запис техобслуговування
+ delete: Видалити запис техобслуговування
+equipment:
+ actions:
+ view: Переглянути обладнання
+ edit: Редагувати обладнання
+ delete: Видалити обладнання
+part:
+ actions:
+ view: Переглянути деталь
+ edit: Редагувати деталь
+ delete: Видалити деталь
+
+orchestrator:
+ settings-title: Налаштування Оркестратора
+ engine-settings: Движок розподілу
+ active-engine: Активний движок розподілу
+ active-engine-help: Оберіть движок для призначення замовлень водіям та транспортним засобам.
+ auto-allocate-on-create: Авторозподіл при створенні замовлення
+ auto-allocate-on-create-label: Автоматично запускати розподіл при створенні нового замовлення.
+ auto-reallocate-on-complete: Автоповторний розподіл при завершенні доставки
+ auto-reallocate-on-complete-label: Повторно запускати розподіл після виконання доставки для заповнення вивільнених потужностей.
+ constraint-settings: Обмеження розподілу
+ max-travel-time: Макс. час у дорозі (секунди)
+ max-travel-time-help: Максимальний час у дорозі в секундах, який може бути призначений водієві. Вкажіть 0 для скасування обмеження.
+ balance-workload: Балансування навантаження
+ balance-workload-label: Рівномірно розподіляти замовлення між доступними водіями та ТЗ.
+ settings-saved: Налаштування Оркестратора збережено.
+ workbench-title: Робоча область Оркестратора
+ unassigned-orders: Непризначені замовлення
+ no-unassigned-orders: Непризначених замовлень немає — усе виконано!
+ run-allocation: Запустити Оркестратор
+ running: Виконується…
+ plan-ready: План готовий — перевірте та підтвердьте.
+ commit-plan: Застосувати план
+ discard-plan: Відхилити
+ committed: 'Оркестрацію застосовано — призначено {count} замовлень.'
+ committed-badge: План застосовано
+ overridden: Перевизначено
+ on-shift: На зміні
+ empty-state-title: План оркестрації ще відсутній
+ empty-state-body: Натисніть "Запустити Оркестратор", щоб згенерувати оптимізований план призначення для непризначених замовлень.
+ unassigned-warning: Деякі замовлення не вдалося призначити — недостатньо місткості ТЗ або немає водіїв на зміні.
+ # Панель інструментів та опції
+ run-orchestration: Запустити Оркестратор
+ options: Опції
+ mode: Режим
+ mode-allocate: Розподілити замовлення
+ mode-optimize: Оптимізувати маршрути
+ engine: Движок
+ allocation-strategy: Стратегія розподілу
+ allocation-strategy-route-aware: Маршрут + Місткість
+ allocation-strategy-capacity-only: Тільки місткість
+ vehicle-packing: Завантаження ТЗ
+ vehicle-packing-minimize-vehicles: Мінімізувати кількість ТЗ
+ vehicle-packing-balanced: Збалансоване
+ respect-skills: Враховувати навички
+ respect-capacity: Враховувати місткість
+ return-to-depot: Повернення в депо
+ # Стан плану
+ unassigned-count: 'Непризначено:'
+ clear: Очистити
+ # Пул замовлень
+ search-orders: Пошук замовлень…
+ filter-all: Усі
+ filter-scheduled: Заплановані
+ filter-urgent: Термінові
+ filter-today: На сьогодні
+ filter-unassigned: Непризначені
+ filter-imported: Імпортовані
+ advanced-filters: Розширені фільтри
+ filter-country: Країна
+ filter-type: Тип замовлення
+ filter-status: Статус
+ filter-date: Запланована дата
+ filter-country-placeholder: Усі країни
+ filter-type-placeholder: Усі типи
+ filter-status-placeholder: Усі статуси
+ filter-date-placeholder: Будь-яка дата
+ clear-filters: Очистити фільтри
+ no-address: Без адреси
+ selected: обрано
+ clear-selection: Очистити
+ # Перемикачі панелей
+ show-orders: Показати пул замовлень
+ hide-orders: Сховати пул замовлень
+ show-drivers: Показати ресурси
+ hide-drivers: Сховати ресурси
+ # Панель водіїв / ресурсів
+ available-drivers: Доступні водії
+ available-vehicles: Доступні ТЗ
+ no-available-drivers: Наразі немає доступних водіїв.
+ no-available-vehicles: Наразі немає доступних ТЗ.
+ no-search-results: За вашим запитом нічого не знайдено.
+ search-drivers: Пошук водіїв...
+ search-vehicles: Пошук ТЗ...
+ no-position: Без геопозиції
+ filter-online: Онлайн
+ filter-offline: Офлайн
+ filter-on-shift: На завданні
+ filter-on-job: На завданні
+ filter-active: Активні
+ filter-no-driver: Без водія
+ proposed-routes: Запропоновані маршрути
+ stops: зупинок
+ route: Маршрут
+ drivers-selected: обрано водіїв
+ vehicles-selected: обрано ТЗ
+ drop-orders-here: Перетягніть замовлення сюди
+ # Модальне вікно імпорту
+ import-orders: Імпорт замовлень
+ import-step-upload: Завантаження
+ import-step-map: Зіставлення колонок
+ import-step-preview: Попередній перегляд та імпорт
+ drop-file-here: Перетягніть ваш CSV або Excel файл сюди
+ or: або
+ browse-file: Оглянути файл
+ accepted-formats: 'Підтримуються: .csv, .xlsx, .xls'
+ remove-file: Видалити файл
+ download-template: Завантажити шаблон імпорту
+ download-template-desc: Використовуйте наш шаблон для коректного імпорту даних.
+ download: Завантажити
+ next: Далі
+ back: Назад
+ map-columns-title: Зіставлення колонок
+ map-columns-desc: 'Зіставте колонки у вашій таблиці'
+ col-target-field: Цільове поле
+ col-your-spreadsheet: Ваша таблиця
+ mapped: зіставлено
+ mapping-required-hint: 'Поле "Вулиця розвантаження 1" є обов’язковим для продовження.'
+ select-column: '— пропустити —'
+ data-preview: 'Перегляд перших 3 замовлень:'
+ preview-import: Попередній перегляд імпорту
+ preview-desc: '{count} замовлень готово до імпорту'
+ errors: помилок
+ all-rows-valid: Усі рядки коректні
+ validation-warning: '{count} замовлень мають помилки і будуть пропущені.'
+ import-confirm: Підтвердити імпорт
+ importing: 'Імпортування…'
+ col-pickup: Завантаження
+ col-dropoff: Розвантаження
+ col-scheduled: Заплановано
+ col-weight: Вага
+ col-customer: Клієнт
+ col-status: Статус
+ col-preview-ref: № / ID
+ col-preview-type: Тип
+ col-preview-facilitator: Посередник / Експедитор
+ col-preview-vehicle: ТЗ
+ col-preview-driver: Водій
+ col-preview-entities: Товари
+ col-preview-multi-stop: '{count} зупинок'
+ col-preview-pickup-dropoff: Завантаження та розвантаження
+ col-preview-pickup-dropoff-stops: 'Завантаження і розвантаження + {count} зупинок'
+ error: Помилка
+ valid: Коректно
+ import-success: Успішно імпортовано {count} замовлень.
+ no-valid-rows: Немає коректних замовлень для імпорту.
+ invalid-file-type: 'Будь ласка, завантажте файл у форматі .csv, .xlsx або .xls.'
+ parse-error: Не вдалося розібрати файл. Перевірте його формат.
+ read-error: Не вдалося прочитати файл.
+ empty-file: Файл порожній.
+ missing-dropoff: Поле "Вулиця розвантаження 1" є обов’язковим.
+ stops-count: '{count} зупинок'
+ # Поля картки замовлення
+ pickup: Завантаження
+ dropoff: Розвантаження
+ scheduled: Заплановано
+ customer: Клієнт
+ driver-assigned: Водія призначено
+ vehicle-assigned: ТЗ призначено
+ no-vehicle-assigned: ТЗ не призначено
+ no-driver-assigned: Водія не призначено
+ created: Створено
+ # Конструктор фаз
+ phases: Фази
+ phases-description: Складіть багатокроковий прогон оркестрації. Кожна фаза послідовно запускає певний режим.
+ add-phase: Додати фазу
+ no-phases: Фази не налаштовані. Додайте фазу, щоб розпочати.
+ phase-n: 'Фаза {n}'
+ phase-label: Назва фази
+ save-phase: Зберегти фазу
+ select-phase-to-edit: Оберіть фазу для редагування її налаштувань.
+ run-phases: 'Запустити {count} фаз(и)'
+ include-order-statuses: Включити статуси замовлень
+ constraints: Обмеження
+ auto-commit: Автоматично застосовувати після фази
+ # Режими розподілу
+ mode-assign-vehicles: Призначити ТЗ
+ mode-assign-drivers: Призначити водіїв
+ mode-optimize-routes: Оптимізувати маршрути
+ # Статуси замовлень для фільтра фаз
+ status-created: Створено
+ status-dispatched: Диспетчеризовано
+ status-started: Розпочато
+ # Налаштування полів карток
+ card-fields: Поля карток
+ card-fields-description: Налаштуйте, які поля відображатимуться на картках замовлень у робочій області. Поля згруповані за конфігурацією замовлень.
+ card-fields-saved: Поля карток замовлень збережено.
+ save-card-fields: Зберегти поля карток
+ standard-fields: Стандартні поля
+ standard-fields-description: Ці поля відображаються на кожній картці замовлення незалежно від його типу.
+ config-fields-description: 'Налаштовувані поля для замовлень типу {name}.'
+ meta-fields: Метаполя
+ meta-fields-description: Довільні ключі метаданих, що передаються інтеграціями.
+ no-custom-fields: Для цієї конфігурації замовлень не визначено налаштовуваних полів.
+ field-tracking: Трек-номер
+ field-status: Статус
+ field-scheduled-at: Заплановано на
+ field-customer: Клієнт
+ field-type: Тип
+ field-notes: Примітки
+ field-priority: Пріоритет
+ field-dropoff: Адреса розвантаження
+ field-pickup: Адреса завантаження
+ # Фільтри панелі ресурсів
+ filter-available: Доступні
+ # Переглядач плану
+ vehicle: ТЗ
+ expand-all: Розгорнути все
+ tab-routes: Маршрути
+ tab-timeline: Хронологія
+ collapse-all: Згорнути все
+ collapse-route: Згорнути маршрут
+ expand-route: Розгорнути маршрут
+ no-plan: Запустити оркестратор, щоб побачити запропонований план.
+ unassigned-description: Ці замовлення не вдалося призначити. Вони залишаться в черзі для повторного розподілу.
+ unassigned: непризначені
+ # Загальні доповнення
+ vehicles: Транспортні засоби
+ drivers: Водії
+ orders: Замовлення
+ cancel: Скасувати
+ no-vehicles: ТЗ не знайдено.
+ no-drivers: Водіїв не знайдено.
+ no-orders: Замовлень не знайдено.
+ expand-panel: Розгорнути панель
+ collapse-panel: Згорнути панель
+ # Стан помилки виконання
+ run-failed-title: Призначень не повернуто
+ try-again: Спробувати знову
+ change-resources: Змінити ресурси
+ no-assignments-returned: Оркестратор не зміг призначити жодного замовлення. Перевірте місткість ТЗ та наявність водіїв на зміні.
+ # Ярлики списку зупинок
+ stop-type-pickup: Завантаження
+ stop-type-dropoff: Розвантаження
+ pod-required: Потрібне підтвердження доставки (POD)
+ no-pod: Без POD
+
+inspection:
+ form:
+ overview: Огляд
+ submissions: Подані інспекції
+ no-vehicle: Без ТЗ
+ no-submissions: Ще нічого не подано
+ no-submissions-description: Інспекції, пройдені за цією формою, з'являться тут.
+ details: Деталі форми
+ name: Назва
+ name-placeholder: Щоденний огляд ТЗ
+ type: Тип
+ status: Статус
+ description: Опис
+ description-placeholder: Що охоплює ця інспекція
+ builder: Конструктор форм
+ fields: Поля
+ published: Опубліковано
+ structure: Структура форми
+ loading-structure: Завантаження структури форми...
+ no-structure: Ця форма ще не має груп полів.
+ settings: Налаштування
+ type-placeholder: Оберіть тип інспекції
+ status-placeholder: Оберіть статус
+ setting-create-issue: Створювати запис про несправність при виявленні дефекту
+ setting-create-issue-help: Непройдена інспекція реєструє несправність для ТЗ.
+ setting-create-work-order: Відкривати робоче завдання при виявленні дефекту
+ setting-create-work-order-help: Непройдені пункти стають чеклістом робочого завдання для майстерні. Потрібно увімкнути модуль майстерень.
+ setting-require-signature: Вимагати підпис водія
+ setting-require-signature-help: Застосунок водія не відправить форму без підпису.
+ legacy-checklist: Застарілий чекліст
+ legacy-checklist-help: Початковий чекліст, збережений тільки для читання.
+ create: Створити форму інспекції
+ created: Форму інспекції створено.
+ updated: Форму інспекції оновлено.
+ builder:
+ help: Згрупуйте елементи перевірки, потім додайте поле для кожного з них.
+ loading: Завантаження конструктора форм...
+ new-group: Нова група полів
+ new-field: Нове поле
+ edit-field: 'Редагувати поле: {label}'
+ save-field: Зберегти поле
+ untitled-group: Група без назви
+ untitled-field: Поле без назви
+ group-name: Назва групи
+ group-name-placeholder: Зовнішній огляд
+ group-description: Опис групи
+ group-description-placeholder: Що перевіряється в цій групі
+ grid-size: Колонки
+ move-up: Перемістити вгору
+ move-down: Перемістити вниз
+ no-fields: У цій групі ще немає полів.
+ empty-title: Немає груп полів
+ empty-description: Додайте першу групу полів, щоб розпочати створення форми.
+ delete: Видалити
+ delete-group-title: Видалити цю групу полів?
+ delete-group-body: Видалення цієї групи видалить усі поля всередині неї. Це дію неможливо скасувати після збереження форми.
+ delete-field-title: Видалити це поле?
+ delete-field-body: Видалення цього поля також видалить усі відповіді, зареєстровані за ним.
+ field:
+ label: Ярлик поля
+ label-placeholder: Стоп-сигнали
+ name: Системне ім'я поля
+ name-help: Системне ім'я, за яким інспекція посилається на це поле.
+ name-placeholder: brake-lights
+ type: Тип поля
+ description: Опис поля
+ description-placeholder: Що перевіряти і як виглядає успішна перевірка
+ help-text: Текст підказки
+ help-text-placeholder: Відображається поруч із полем під час заповнення
+ required: Поле є обов'язковим
+ editable: Поле можна редагувати
+ options: Варіанти відповідей
+ no-options: Варіантів ще немає.
+ option-placeholder: Додати варіант
+ add-option: Додати
+ unit: Одиниця вимірювання
+ unit-help: Відображається поруч із числом, наприклад, км або годин.
+ unit-placeholder: км
+ odometer-role: Це показник одометра
+ odometer-role-help: Відповідь копіюється в колонку одометра самої інспекції.
+ on-fail: При невідповідності (помилці)
+ on-fail-help: Що означає негативна відповідь і що повинен надати інспектор.
+ default-severity: Критичність за замовчуванням
+ require-photo-on-fail: Вимагати фото при помилці
+ require-comment-on-fail: Вимагати коментар при помилці
+ unsafe-on-fail: Позначати ТЗ як небезпечний для експлуатації при помилці
+ unsafe-on-fail-help: Помилкова відповідь за замовчуванням позначається як небезпечна.
+ instructions: Інструкції
+ instructions-placeholder: Як це перевірити (інструкція для водія)
+ column-span: Ширина колонки
+ field-type:
+ pass-fail: Пройдено / Не пройдено
+ input: Текст
+ textarea: Багаторядковий текст
+ number: Число
+ select: Вибір зі списку
+ radio-button: Перемикачі (Radio)
+ boolean: Так / Ні
+ date-picker: Дата
+ date-time-input: Дата та час
+ file-upload: Фото або файл
+ signature: Підпис
+ severity:
+ low: Низька
+ medium: Середня
+ high: Висока
+ critical: Критична
+ answer:
+ pass: Пройдено
+ fail: Не пройдено
+ not-applicable: Н/З (Не застосовується)
+ unanswered: Без відповіді
+ severity: Критичність
+ unsafe: Небезпечно для експлуатації
+ comments: Коментарі
+ comments-placeholder: Що не так і що потрібно зробити
+ photos: Фотографії
+ add-photo: Додати фото
+ upload-photo: Завантажити фото
+ no-photo: Фото не прикріплено.
+ upload-signature: Завантажити підпис
+ no-signature: Підпис відсутній.
+ select-placeholder: Оберіть відповідь
+ note-placeholder: Додати примітку
+ text-placeholder: Введіть відповідь
+ no-options: У цьому полі немає варіантів для вибору.
+ uploads-unavailable: Можна додати з веб-консолі або застосунку водія.
+ comments-required: Опишіть несправність та необхідні дії
+ comment-required: Коментар обов'язковий при помилці
+ photo-required: Фото обов'язкове при помилці
+ comment-and-photo-required: Коментар + фото обов'язкові при помилці
+ record:
+ overview: Огляд
+ inspection: Інспекція
+ details: Деталі інспекції
+ form: Форма
+ select-form: Оберіть форму інспекції
+ status: Статус
+ result: Результат
+ vehicle: ТЗ
+ select-vehicle: Оберіть ТЗ
+ driver: Водій
+ select-driver: Оберіть водія
+ odometer: Одометр
+ odometer-placeholder: Поточний одометр
+ engine-hours: Мотогодини
+ engine-hours-placeholder: Поточні мотогодини
+ answers: Відповіді
+ loading-form: Завантаження форми інспекції...
+ loading-answers: Завантаження відповідей...
+ form-has-no-fields: Ця форма ще не має полів.
+ summary: Підсумок
+ failed-of: '{failed} не пройдено з {total} перевірок'
+ metadata: Метадані
+ submitted: Подано
+ submitted-by: Подано користувачем
+ submission-title: "Результат інспекції: {form}"
+ due: Термін виконання
+ create-issue: Створити запис про несправність
+ create-work-order: Створити робоче завдання
+ resolve: Вирішити (закрити)
+ via-link: Через публічне посилання
+ via-link-pin: Через публічне посилання з PIN-підтвердженням
+ signed-as: 'Підписано як "{name}"'
+ name-unverified: Ім'я введено вручну (неавторизований обліковий запис)
+ resolved: Вирішено
+ item-results: Результати за пунктами
+ no-item-results: Результатів за пунктами не записано.
+ follow-up: Подальші дії
+ open-form: Відкрити цю форму інспекції
+ failed: Не пройдено
+ failed-count: "{count, plural, one {# не пройдено} few {# не пройдено} many {# не пройдено} other {# не пройдено}}"
+ linked-inspections: "{count, plural, one {Пов'язана інспекція} few {Пов'язані інспекції} many {Пов'язаних інспекцій} other {Пов'язані інспекції}}"
+ linked-issue: Пов'язана несправність
+ linked-work-order: Пов'язане робоче завдання
+ photos: Фотографії
+ loading-photos: Завантаження фотографій...
+ audit: Аудит
+ create: Створити інспекцію
+ saved: Інспекцію збережено.
+ updated: Інспекцію оновлено.
+ form-help: Опублікована форма, на основі якої заповнюється ця інспекція.
+ choose-a-form: Оберіть форму інспекції, щоб розпочати.
+ group-has-no-fields: У цьому розділі немає пунктів перевірки.
+ outstanding: залишилося відповісти
+ section-outstanding: "{count, plural, one {# залишилося} few {# залишилося} many {# залишилося} other {# залишилося}}"
+ outstanding-field: "Поле {label} є обов’язковим для заповнення перед відправкою"
+ review: "Перегляд →"
+ jump-to: "Перейти до →"
+ follow-up:
+ create-issue-title: Створити запис про несправність на основі цієї інспекції?
+ create-issue-summary: Запис про несправність створюється для ТЗ та водія зі списком непройдених перевірок та найвищим рівнем критичності серед них.
+ create-issue-accept: Створити запис про несправність
+ create-work-order-title: Відкрити робоче завдання на основі цієї інспекції?
+ create-work-order-summary: Робоче завдання відкривається для ТЗ із чеклістом із непройдених перевірок.
+ create-work-order-accept: Створити робоче завдання
+ resolve-title: Закрити (вирішити) цю інспекцію?
+ resolve-summary: Інспекція позначається як вирішена із фіксацією часу. Створені несправності або робочі завдання залишаться відкритими.
+ resolve-accept: Вирішити інспекцію
+ from-these: "{count, plural, one {З цієї непройденої перевірки} few {З цих # непройдених перевірок} many {З цих # непройдених перевірок} other {З цих # непройдених перевірок}}"
+ unsafe-note: "{count, plural, one {# перевірку позначено як небезпечну для експлуатації} few {# перевірки позначено як небезпечні для експлуатації} many {# перевірок позначено як небезпечні для експлуатації} other {# перевірок позначено як небезпечні для експлуатації}}"
+ due-note: "Термін: {due}, на основі найвищого рівня критичності."
+ nothing-failed: У цій інспекції немає помилок, тому немає підстав для створення завдань.
+ submit-title: Відправити цю інспекцію?
+ submit-summary: Це реєструє інспекцію, фіксує час подачі та підраховує підсумки. Дію неможливо скасувати.
+ submit-accept: Відправити інспекцію
+ cancel: Скасувати
+ link:
+ public-links: Публічні посилання
+ existing: Згенеровані посилання
+ modal-help: "Згенерувати одноразове посилання для форми {form}. Той, хто відкриє його, вводить PIN-код і заповнює форму без входу в систему."
+ loading: Завантаження посилань...
+ none: Для цієї форми ще не генерувалися посилання.
+ load-failed: Не вдалося завантажити ці посилання.
+ copy: Копіювати
+ copied: Посилання на інспекцію скопійовано.
+ revoke: Відкликати
+ revoked: Посилання на інспекцію відкликано.
+ generated: Згенеровано
+ generated-toast: Посилання на інспекцію згенеровано.
+ publish-first: Опублікуйте форму інспекції перед генерацією публічного посилання.
+ expires: Закінчується
+ no-expiry: Без терміну дії
+ used: Використано
+ viewed: Востаннє відкрито
+ never-opened: Ніколи не відкривалося
+ unassigned: Будь-хто з посиланням та PIN-кодом
+ url-not-kept: Це посилання було створено до впровадження збереження URL, тому його адресу неможливо відобразити знову.
+ state-active: Активне
+ state-expired: Прострочене
+ state-used: Використано
+ state-revoked: Відкликано
+ state-locked: Заблоковано
+ expires-at: Дійсне до
+ expires-help: Посилання дійсні протягом 72 годин після створення, якщо не вказано інший час.
+ assign-to: Призначити
+ assign-to-help: Необов'язково. Будь-хто у вашій організації може пройти інспекцію; подана інспекція буде зарахована йому.
+ select-assignee: Оберіть користувача
+ pin: PIN-код
+ pin-delivery: Надіслати посилання та PIN-код
+ pin-delivery-none: Не надсилати, я поділюся сам(а)
+ pin-delivery-email: Електронною поштою
+ pin-delivery-sms: SMS-повідомленням
+ pin-delivery-help: "Надіслано користувачеві {name} із посиланням для відкриття та PIN-кодом."
+ pin-delivery-no-recipient: Призначте посилання комусь або оберіть водія, щоб надіслати. В іншому разі поділіться посиланням та PIN-кодом самостійно.
+ generated-title: Посилання згенеровано
+ generated-help: Посилання скопійовано в буфер обміну. Для доступу також знадобиться PIN-код.
+ copied-pin: PIN-код скопійовано.
+ email-pin: Надіслати посилання на Email
+ text-pin: Надіслати посилання через SMS
+ pin-sent-email: "Посилання та PIN-код надіслано на email {to}."
+ pin-sent-sms: "Посилання та PIN-код надіслано через SMS на номер {to}."
+ pin-not-sent: "Посилання та PIN-код не надіслано: {reason}"
+ pin-sent-by: "{via, select, email {Надіслано на email} sms {Надіслано через SMS} other {Надіслано}}"
+ wrong-pins: "{count, plural, one {# невірна спроба PIN} few {# невірні спроби PIN} many {# невірних спроб PIN} other {# невірних спроб PIN}}"
+ no-pin: Без PIN-коду. Це посилання було згенеровано до введення PIN-кодів.
+ public:
+ submitted-title: Інспекцію відправлено
+ submitted-body: Дякуємо. Цю інспекцію зареєстровано, а посилання тепер закрите.
+ unavailable-title: Ця інспекція недоступна
+ sign-off: Підписати
+ your-name: Ваше ім'я
+ your-name-help: Записується в цій інспекції як ім'я особи, що її заповнила.
+ your-name-placeholder: Повне ім'я
+ for: Для
+ pin-title: Введіть ваш PIN-код
+ pin-help: Введіть 6-значний PIN-код, який ви отримали для цієї інспекції.
+ pin-label: PIN-код
+ pin-continue: Продовжити
+ pin-wrong: "Невірний PIN-код. Залишилося спроб: {count} до блокування посилання."
+ submit: Відправити інспекцію
+ blocked-required: "{count, plural, one {Відсутня # обов'язкова відповідь} few {Відсутні # обов'язкові відповіді} many {Відсутня # обов'язкових відповідей} other {Відсутні обов'язкові відповіді}}"
+ blocked-defects: "{count, plural, one {# дефект потребує коментаря або фото} few {# дефекти потребують коментаря або фото} many {# дефектів потребують коментаря або фото} other {# дефектів потребують коментаря або фото}}"
+ flyout:
+ title: "Дефект: {label}"
+ done: Готово
+ close: Закрити
+ defect:
+ edit: Редагувати дефект
+ photos: "{count, plural, one {# фотографія} few {# фотографії} many {# фотографій} other {# фотографій}}"
+ comment: Коментар
+ needs-comment: Потрібен коментар
+ needs-photo: Потрібне фото
+ needs-both: Потрібні коментар та фото
+ no-evidence: Коментарі та фото відсутні
+ tray:
+ title: Дефекти
+ review: Переглянути
+select-option:
+ vehicle:
+ vin: VIN-код
+ serial_number: Серійний номер
+ call_sign: Позивний
+resource-summary:
+ facts:
+ address: Адреса
+ altitude: Висота
+ amount: Сума / Кількість
+ arrived: Прибув
+ assignee: Виконавець / Призначена особа
+ attached-to: Прикріплено до
+ attachment: Вкладення
+ axles: Осі
+ base-fee: Базовий тариф
+ body-coupling: Кузов / Зчіпка
+ calculation: Розрахунок
+ category: Категорія
+ cod: Оплата при доставці (COD)
+ code: Код
+ comments: Коментарі
+ completion: Завершення
+ connected: Підключено
+ coordinates: Координати
+ core-service: Основна послуга
+ countries: Країни
+ country: Країна
+ coverage: Покриття
+ created: Створено
+ customer: Клієнт
+ declared-value: Оголошена вартість
+ description: Опис
+ destination: Пункт призначення
+ details: Деталі
+ device: Пристрій
+ dimensions: Габарити
+ disconnected: Відключено
+ distance: Відстань
+ driver: Водій
+ drivers: Водії
+ dropoff: Розвантаження
+ due: Термін
+ dwell: Час простою
+ email: Email
+ entities: Сутності / Товари
+ environment: Середовище
+ equipped-to: Оснащено для
+ eta: Розрахунковий час прибуття (ETA)
+ event-type: Тип події
+ expires: Закінчується
+ failed-items: Невдалі пункти
+ fallback: Резервний варіант
+ fee: Збір / Комісія
+ fees: Збори / Комісії
+ firmware: Прошивка
+ fitted-to: Встановлено на
+ fleet: Автопарк
+ form: Форма
+ fuel-report: Звіт по пальному
+ heading: Напрямок руху (курс)
+ host: Хост
+ imei: IMEI
+ installed: Встановлено
+ interval: Інтервал
+ items: Позиції / Товари
+ key: Ключ
+ last-error: Остання помилка
+ last-online: Востаннє в мережі
+ last-reading: Останній показник
+ last-seen: Востаннє помічено
+ last-status: Останній статус
+ last-synced: Востаннє синхронізовано
+ last-value: Останнє значення
+ licence: Ліцензія / Посвідчення
+ location: Локація
+ maintainable: Підлягає обслуговуванню
+ make-model: Марка / модель
+ model: Модель
+ namespace: Простір імен
+ next-due: Наступний термін
+ next-occurrence: Наступне настання
+ occurred: Сталося
+ odometer: Одометр
+ order: Замовлення
+ order-config: Конфігурація замовлення
+ performed-by: Виконано
+ period: Період
+ phone: Телефон
+ pickup: Завантаження
+ place: Місце
+ plate: Номерний знак / VIN
+ policy: Політика
+ priority: Пріоритет
+ progress: Прогрес
+ provider: Провайдер
+ published: Опубліковано
+ purchase-price: Ціна покупки
+ quantity: Кількість
+ reason: Причина
+ recorded: Записано
+ region: Регіон
+ relationship: Зв'язок / Відносини
+ reporter: Хто повідомив
+ request: Запит
+ route: Маршрут
+ sandbox: Пісочниця (Sandbox)
+ schedule: Розклад
+ scheduled: Заплановано
+ sequence: Послідовність
+ serial: Серійний номер
+ service-area: Зона обслуговування
+ service-quote: Розрахунок вартості послуги
+ service-rate: Тариф послуги
+ service-types: Типи послуг
+ severity: Критичність
+ sku: Артикул (SKU)
+ speed: Швидкість
+ speed-limit: Обмеження швидкості
+ station: Станція
+ status: Статус
+ subject: Тема
+ submitted: Подано
+ submitted-by: Подано користувачем
+ target: Ціль
+ task: Завдання
+ telematic: Телематика
+ threshold: Поріг
+ timezone: Часовий пояс
+ title: Заголовок
+ total-cost: Загальна вартість
+ tracking: Відстеження
+ trailer: Причіп
+ transaction-at: Транзакція о
+ triggers: Тригери
+ type: Тип
+ unit-cost: Вартість за одиницю
+ user: Користувач
+ vehicle: Транспортний засіб
+ vehicles: Транспортні засоби
+ vendor: Постачальник
+ version: Версія
+ volume: Об'єм
+ warranty: Гарантія
+ waypoints: Маршрутні точки
+ website: Вебсайт
+ weight: Вага
+ window: Вікно (часове)
+ work-order: Робоче завдання
+ zone: Зона
+ zones: Зони
\ No newline at end of file