Files
api/services/nginx/app/routes/usersRoute.php
T
3d0a8eeae7 feat(api): add optional invoice_email field for customers (TRU-77) (#381)
## Summary

Adds an optional `invoice_email` (Danish: *faktura email*) field to
customers, so e-conomic can deliver invoices to a dedicated accounting
mailbox instead of the customer's primary email.

Linear: **TRU-77** (DRIFT 16)

## Changes

- **Migration** (additive, via existing schema_bootstrap pattern)
- New `customer_invoice_email_schema_bootstrap` adds the `invoice_email
VARCHAR(255) NULL` column to `users` after `wash_certificate_email`.
Idempotent — skips when the column already exists.

- **Domain object — `objects/users_o.php`**
  - New `invoice_email` object property.
- `getInvoiceEmail()` returns the dedicated address or falls back to the
primary `email`.
- `getInvoiceEmailOverride()` returns only the explicit override (no
fallback).
- `setInvoiceEmail($email)` validates and writes the value; `null`/empty
clears it.
- `add($customer_number, $password, $role, ?$invoice_email = null)` now
accepts the optional field and persists it.
- The user payload output now exposes `invoice_email` and
`invoice_email_fallback`.

- **API — `routes/usersRoute.php`**
- `POST /users` accepts an optional `invoice_email`, validated before
insert.
- `PUT /users` accepts `invoice_email` (including null/empty to clear)
on existing users.

- **Customer mass import — `classes/customer_mass_import_service.php`**
  - Payload now accepts `invoice_email`.
- `normalizeInvoiceEmail()` rejects malformed addresses before any
e-conomic call.
- `resolveInvoiceEmail()` / `resolveCreateEmail()` route the e-conomic
customer email to the dedicated address when set, otherwise the primary
`email` (with the existing `jb@truckwash.dk` fallback when neither is
provided).
  - `syncLocalCustomer()` persists `invoice_email` on the local user.
  - `import()` result now includes the resolved `invoice_email`.

- **Tests — `tests/Unit/Customers/CustomerInvoiceEmailTest.php` (new)**
  - Schema bootstrap adds the column when missing.
  - Schema bootstrap is a no-op when the column already exists.
  - Schema bootstrap skips when the `users` table is not present.
  - e-conomic customer email is set to `invoice_email` when provided.
- e-conomic customer email falls back to `email` when `invoice_email` is
omitted.
  - Invalid `invoice_email` is rejected before any e-conomic call.

## Backwards compatibility

- The column is nullable; existing rows are unaffected.
- The `add()` signature is additive (new optional parameter with default
`null`).
- The route payloads ignore `invoice_email` unless supplied, so no
client change is required.

## Linear

- TRU-77 (DRIFT 16: "Add 'faktura email' field to customer creation
form")

---------

Co-authored-by: Jeppe B <jeppe@copenhagentruckwash.io>
Co-authored-by: OpenClaw Bugfix Bot <openclaw-bot@truckwash.dk>
Co-authored-by: bugfix sub-agent <bugfix@openclaw.local>
2026-08-16 18:58:05 +02:00

319 lines
14 KiB
PHP

<?php
namespace routes;
use classes\authentication;
use classes\response;
use objects\logs_o;
use objects\users_o;
use traits\route_t;
class usersRoute
{
use route_t;
public function run(): void
{
$this->get('/users', function () {
// Require the user to be logged in
global /** @var response $response */
$response;
$this->requirePermission('list_users');
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
// Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'LIST_USERS', 'Successfully listed users');
// Return the list of users
$users_o = new users_o();
$limitedEmployeeListMode = $this->limitedBackofficeEmployeeListMode($users_o);
$users = $users_o
->setSearchableFields([
// The fields that can be searched. This would otherwise make it possible to get secret information from the database, simply by searching for it and getting the result count back
'id',
'customer_number',
'group_id',
'display_name',
])
->listObjectsWithPaginationIfSet(
null,
$limitedEmployeeListMode['filters'],
[],
$limitedEmployeeListMode['additional_where']
);
if ($limitedEmployeeListMode['enabled']) {
$users = $users_o->markLimitedBackofficeManagedUsers($users);
}
$users = $users_o->parseUsers(
$users
);
$response->success($users);
} else {
// Log the incident
(new logs_o())->add('users', 'global', 1, 0, 'LIST_USERS', 'No user found, or invalid session');
// Return an error
$response->error('Invalid session', 400);
}
},
[
'list_users' => 'List all users'
]
);
$this->get('/users/customer', function () {
// Require the user to be logged in
global $response;
$this->requirePermission('get_user_from_customer_number');
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
// Check if the customer number is valid
if (!(new users_o())->automaticGetTargetUserFromRequest()->exists()) {
// Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'GET_USER_FROM_CUSTOMER_NUMBER', 'Customer not found, not imported');
$response->error('Customer not found', 400);
}
// Check
// Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'GET_USER_FROM_CUSTOMER_NUMBER', 'Successfully retrieved user from customer number');
// Return the list of users
$response->success(
(new users_o())->automaticGetTargetUserFromRequest()->getCustomerEcocomicData()->asArray()
);
} else {
// Log the incident
(new logs_o())->add('users', 'global', 1, 0, 'GET_USER_FROM_CUSTOMER_NUMBER', 'No user found, or invalid session');
// Return an error
$response->error('Invalid session', 400);
}
},
[
'get_user_from_customer_number' => 'Get user from customer number'
]
);
$this->post('/users', function () {
// Require the user to be logged in
global $response;
$this->requirePermission('add_user');
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
// Get the post data
$data = json_decode(file_get_contents('php://input'), true);
// Check if the required fields are set
if (!isset($data['customer_number'])) {
$response->error('Customer number is required', 400);
}
if (!isset($data['password'])) {
$response->error('Password is required', 400);
}
if (!isset($data['role'])) {
$response->error('Role is required', 400);
}
$role = (int)$data['role'];
// Creating users with elevated roles requires the same permission as role edits
if ($role !== 0) {
$this->requirePermission('edit_user_role');
}
// TRU-77 / DRIFT 16: optional dedicated invoice email
$invoice_email = null;
if (isset($data['invoice_email']) && $data['invoice_email'] !== null && $data['invoice_email'] !== '') {
$candidate = trim((string)$data['invoice_email']);
if ($candidate !== '') {
if (!filter_var($candidate, FILTER_VALIDATE_EMAIL)) {
$response->error('Invalid invoice email address', 400);
}
$invoice_email = $candidate;
}
}
// Add the user
(new users_o())->add($data['customer_number'], $data['password'], $role, $invoice_email);
// Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'ADD_USER', 'Successfully added a user');
// Return a success message
$response->success(['message' => 'User added']);
} else {
// Log the incident
(new logs_o())->add('users', 'global', 1, 0, 'ADD_USER', 'No user found, or invalid session');
// Return an error
$response->error('Invalid session', 400);
}
},
[
'add_user' => 'Add a user'
]
);
$this->put('/users', function () {
// Require the user to be logged in
global $response;
$this->requirePermission('edit_user');
// Get the user object
$user = (new authentication())->get_user();
// Check if the request was successful
if ($user) {
// Get the post data
$data = json_decode(file_get_contents('php://input'), true);
// Check if the required fields are set
if (!isset($data['id'])) {
$response->error('ID is required', 400);
}
if (!isset($data['customer_number'])) {
$response->error('Customer number is required', 400);
}
// Check if a new role is set, if not, set it to null to prevent it from being updated
if (!isset($data['role']) || $data['role'] === 'null' || $data['role'] === '') {
$data['role'] = null;
}
// Check if a display name is set, if not, set it to null to prevent it from being updated
if (!isset($data['display_name']) || $data['display_name'] === 'null' || $data['display_name'] === '') {
$data['display_name'] = null;
}
$targetUser = (new users_o())->getUserById((int)$data['id']);
if (!$targetUser->exists()) {
$response->error('User not found', 404);
}
if ((new users_o())->isLimitedBackofficeManagedUser((int)$data['id'])) {
$currentCustomerNumber = (string)$targetUser->customer_number->value();
if ((string)$data['customer_number'] !== $currentCustomerNumber) {
$response->error('Limited backoffice managed users cannot change customer number.', 403);
}
if ($data['role'] !== null && (int)$data['role'] !== (int)$targetUser->group_id->value()) {
$response->error('Limited backoffice managed users cannot change role.', 403);
}
$data['role'] = null;
}
// If the role is set, require the edit_user_role permission
if ($data['role']) {
$this->requirePermission('edit_user_role');
}
// Check if a new password is set, if not, set it to null to prevent it from being updated
if (!isset($data['password']) || $data['password'] === 'null' || $data['password'] === '') {
$data['password'] = null;
}
// If the password is set, require the edit_user_password permission
if ($data['password']) {
$this->requirePermission('edit_user_password');
}
// Edit the user
(new users_o())->edit((int)$data['id'], (string)$data['customer_number'], $data['role'], $data['password'], $data['display_name']);
// TRU-77 / DRIFT 16: allow updating the dedicated invoice email
if (array_key_exists('invoice_email', $data)) {
$raw = $data['invoice_email'];
if ($raw === null || $raw === '' || $raw === 'null') {
$targetUser->setInvoiceEmail(null);
} else {
$candidate = trim((string)$raw);
if ($candidate === '') {
$targetUser->setInvoiceEmail(null);
} elseif (!filter_var($candidate, FILTER_VALIDATE_EMAIL)) {
$response->error('Invalid invoice email address', 400);
} else {
$targetUser->setInvoiceEmail($candidate);
}
}
}
// Log the incident
(new logs_o())->add('users', 'global', 1, $user->id, 'EDIT_USER', 'Successfully edited a user with ID: ' . $data['id']);
// Return a success message
$response->success(['message' => 'User edited']);
} else {
// Log the incident
(new logs_o())->add('users', 'global', 1, 0, 'EDIT_USER', 'No user found, or invalid session');
// Return an error
$response->error('Invalid session', 400);
}
},
[
'edit_user' => 'Edit a user',
'edit_user_role' => 'Edit a user\'s role',
'edit_user_password' => 'Edit a user\'s password'
]
);
$this->get('/public/employees', function () {
// This route is public, no authentication is required.
global $response;
// Get the users with the permission employee_public_data
$users = (new users_o())->getUsersWithPermission('employee_public_data');
$publicData = [];
// Return the public data of the employees
/** @var users_o $user */
foreach ( $users as $user ) {
$publicData[] = $user->listPublicEmployeeData();
}
// Return the list of users
$response->success(
$publicData
);
},
[
'list_public_employees' => 'List all public employees',
'employee_public_data' => 'When this permission is set, the user is PUBLICLY visible on the employee login page'
]
);
}
/**
* @return array{enabled:bool,filters:string|null,additional_where:string|null}
*/
private function limitedBackofficeEmployeeListMode(users_o $users): array
{
$enabled = strtolower((string)($this->fromQuery('include_limited_backoffice_employees') ?? 'false')) === 'true';
$filters = $this->fromQuery('filters');
if ($filters === null || $filters === '') {
return [
'enabled' => false,
'filters' => null,
'additional_where' => null,
];
}
$filterArray = $users->filter_string_to_array($filters);
$customerNumberFilter = $filterArray['customer_number'] ?? null;
$isEmployeeFilter = $customerNumberFilter === '0'
|| $customerNumberFilter === 0
|| (is_array($customerNumberFilter) && in_array('0', $customerNumberFilter, true));
if (!$isEmployeeFilter) {
// When include mode is on but the filter is not a customer_number:0 query,
// pass the original filter through as forced filters so they are not discarded.
// When include mode is off, null causes listObjectsWithPaginationIfSet to fall
// back to reading the filters from the request, which is equivalent.
return [
'enabled' => false,
'filters' => $enabled ? $filters : null,
'additional_where' => null,
];
}
// $activeLimitedEmployeeSubquery is a hardcoded constant with no user input.
$activeLimitedEmployeeSubquery = 'SELECT `user_id` FROM `limited_backoffice_employees` WHERE `deactivated_at` IS NULL';
if (!$enabled) {
// Exclude active limited backoffice employees when the include flag is not set.
return [
'enabled' => false,
'filters' => null,
'additional_where' => '`id` NOT IN (' . $activeLimitedEmployeeSubquery . ')',
];
}
unset($filterArray['customer_number']);
return [
'enabled' => true,
'filters' => $filterArray === [] ? 'id:NOT ZERO' : $users->array_to_filters($filterArray),
'additional_where' => '(`customer_number` = 0 OR `id` IN (' . $activeLimitedEmployeeSubquery . '))',
];
}
}