Skip to content

Locations On Records Developer Guide

Ed Mozley edited this page Nov 15, 2026 · 1 revision

πŸ› οΈ A location on people, tickets, contracts and projects - Developer Guide

5.4.0 Β· #311 Β· User guide: Locations Β· Design and decisions: A location on records - design Β· Related: Locations - Developer Guide

Four tables gained a location_id pointing at asset_locations (System β†’ Locations). This page is how the one rule behind them is built, where every write goes through it, and the traps.


πŸ“ The files

File What
includes/record_locations.php The rule. Check, path, options, usage, who may manage a location, move/clear, and a new ticket's requester location
api/locations/options.php The locations a record in a company may have, for every dropdown
api/locations/in_use.php, api/locations/move.php System β†’ Maintenance β†’ Locations in use
system/locations-in-use/index.php The tool's page (+ system/help/locations-in-use.php)
api/assets/delete_asset_location.php Refuses a location in use; uses recordLocationManageError()
assets/js/location-picker.js LocationPicker.fill() - the ticket pane's dropdown
includes/services/tickets.php createTicket() (a chosen location, else the requester's) and updateTicket() (edit, history, company move)
api/tickets/save_user.php, get_person.php, includes/person_editor.php The person editor
includes/services/contracts.php locationFor() / writeLocation()
includes/services/projects.php location_id in fieldMap(), projectLocation(), history via auditDisplay()
api/v1/resources/{tickets,users,contracts,projects}.php location in reads, location_id in writes
tests/record-locations.php 36 checks

The schema

location_id INT NULL on users, tickets, contracts and projects - in database/freeitsm.sql, includes/db_verify_schema.php, with an index each (includes/db_verify_indexes.php, generated by scripts/gen_db_verify_indexes.php, never edited by hand) and a foreign key in api/system/db_verify.php:

['tickets', 'fk_tickets_location', "ALTER TABLE tickets ADD CONSTRAINT fk_tickets_location FOREIGN KEY (location_id) REFERENCES asset_locations (id) ON DELETE SET NULL"],

ON DELETE SET NULL is only a backstop. Deleting a location in use is refused in PHP; the key matters only if something deletes a row behind the app's back.

The rule: recordLocationCheck()

A record may have its own company's location or a shared one - the same assetLocationScope() the asset picker uses:

function recordLocationCheck(PDO $conn, ?int $tenantId, $value): ?int
{
    if ($value === null || $value === '' || $value === false || (is_numeric($value) && (int)$value === 0)) {
        return null;
    }
    if (!is_numeric($value) || (int)$value < 1) {
        throw new InvalidArgumentException('Unknown location.');
    }
    $id = (int)$value;
    $tid = $tenantId ?? getDefaultTenantId($conn);
    [$scope, $args] = assetLocationScope($conn, $tid, 'l');
    $st = $conn->prepare("SELECT 1 FROM asset_locations l WHERE l.id = ? $scope");
    $st->execute(array_merge([$id], $args));
    if (!$st->fetchColumn()) {
        throw new InvalidArgumentException('Unknown location.');
    }
    return $id;
}

TRAP: another company's location gets the same message as one that does not exist, so the answer says nothing about other companies.

Every write path calls it with the record's company - never the company selected in the top bar (see Assets and All companies - design for why that distinction matters). Each service turns the InvalidArgumentException into its own ServiceError('validation', 'invalid_field', 'Unknown location.') (a 422 in the REST API).

Moving company. Tickets, people and contracts can move company; recordLocationStillValid() says whether the location can come too, and if not it is set to null - as a cost centre is. Projects never move company, so they are only checked when set.

A new ticket: ticketApplyRequesterLocation()

$st = $conn->prepare(
    "SELECT t.tenant_id, t.location_id, u.location_id AS requester_location
       FROM tickets t LEFT JOIN users u ON u.id = t.user_id
      WHERE t.id = ?"
);
$st->execute([$ticketId]);
$r = $st->fetch(PDO::FETCH_ASSOC);
if (!$r || $r['location_id'] !== null || !$r['requester_location']) return;
$tenant = $r['tenant_id'] !== null ? (int)$r['tenant_id'] : null;
$loc = (int)$r['requester_location'];
if (!recordLocationStillValid($conn, $tenant, $loc)) return;
$conn->prepare("UPDATE tickets SET location_id = ? WHERE id = ? AND location_id IS NULL")->execute([$loc, $ticketId]);

It runs after the ticket exists, only when ticket_location_from_requester is on (never saved = on, Ed's decision), the ticket has no location yet, and the requester's location is one the ticket's company may use. It never throws - a ticket is never refused over a location.

TRAP: there is no single "create a ticket" function. TicketsService::createTicket() is one of nine places that INSERT INTO tickets: the service, the portal, the mail fetcher, the catalogue, the workflow engine, messaging (Teams/Slack), web chat, merge and split. Each calls the helper; merge and split first copy the source ticket's location. tests/record-locations.php scans the whole code base and fails if a file inserts a ticket without calling it - so a tenth route cannot quietly skip it.

Who may manage a location: recordLocationManageError()

One copy, used by the delete endpoint and the Locations in use tool:

  • a shared location affects every company - only someone who can reach every company;
  • otherwise the location's own company must be the one being worked in (the Default company owns the ones with no company).

It returns the reason as a string, or null when allowed; the caller has already checked the permission to manage locations at all (locationsRequireAccessJson(true)).

Refusing a delete, and the tool

delete_asset_location.php, after the sub-locations check:

$usage = array_filter(recordLocationUsage($conn, $id));
if ($usage) {
    // ... 'success' => false, 'in_use' => $usage, 'tool' => 'system/locations-in-use/?id=' . $id
}

assets/js/locations-tree.js sees in_use and offers Open, which goes to the tool for that location. Assets are not counted: a deleted location still clears from its assets, as before 5.4.0 (an open question - see the design page).

recordLocationMove($conn, $actorId, $from, $to|null) walks the four tables in one transaction. Each record is re-checked with its own company - a Default-company person cannot be moved to another company's location - and skipped if it does not fit. Tickets get a ticket_audit row (Location, old path, new path), projects a project_audit row (location_id); people and contracts keep no history.

The screens

  • Ticket pane (assets/js/inbox.js): the field is drawn with only the current location (so it shows at once), and loadTicketLocationOptions() fills the rest through LocationPicker.fill(), which keeps the current one selectable even if the list no longer offers it. assignLocation() saves through assign_ticket.php and logs the history entry, as the other properties do (writeAudit=false on that endpoint).
  • Person editor (includes/person_editor.php): paintLocations(), refilled when the Company box changes; hidden while there are no locations at all.
  • Contract edit, project form: their own small loaders against api/locations/options.php, refilled on a company change.

REST API

Resource Read Write
/tickets location: {id, name, path} location_id on POST / PATCH
/users location location_id; moving tenant_id drops one the new company cannot use
/contracts location location_id
/projects location location_id

The ticket, user and contract serializers are also called by name from array_map, so they get no connection: recordLocationRefFor() opens one, once, and only when a record actually has a location.

Translations

German used Standort for the person's Office, which is also the German for Location - the person editor would have shown two fields called Standort. Office is now BΓΌro in German (7 strings).

How it was verified

tests/record-locations.php (on a test install - it adds a company): the rule, a new ticket with the setting on, off and a chosen location, editing and its history, a company move dropping the location, contracts and projects, the real delete endpoint over HTTP refusing, the move skipping a record its new location is not open to, and the route scan. Control: with the old ticket service and delete endpoint, 8 checks fail - the requester location, the refusal and the route scan among them. The tool, the person editor, contract edit, projects, ticket settings, Locations and Tickets pages were loaded in headless Chrome with no script errors (the detector proven first on a page that throws).

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Pull requests

Links

Clone this wiki locally