Skip to content

Linking Equipment to Contracts Developer Guide

Ed Mozley edited this page Aug 30, 2026 · 3 revisions

Equipment covered by a contract β€” Developer Guide

How asset-to-contract linking is put together, and the one thing about it that is genuinely awkward.

The user page is Equipment covered by a contract. Related: Contracts Β· Assets Β· Roles and Permissions

Built for discussion #106 (dschipfel).


1. πŸ“ The files involved

File What it does
includes/contract_assets.php Everything. Reads, writes, the tenancy filter and the gate
api/contracts/get_contract_assets.php Equipment on a contract
api/contracts/search_contract_assets.php The picker
api/contracts/save_contract_asset.php Link, or change the note
api/contracts/delete_contract_asset.php Unlink
api/assets/get_asset_contracts.php The other direction
api/assets/search_linkable_contracts.php The picker, from the asset side
contracts/view.php The Equipment panel and the picker modal
includes/contract_report.php The report renderer and its CSS, shared by the page and the email
contracts/equipment-report.php The printable page
api/contracts/email_equipment_report.php The emailed copy
asset-management/index.php The Contracts tab
tests/contract-assets.php 24 assertions, touches the database

2. Schema

contract_assets
  id                INT AUTO_INCREMENT
  contract_id       INT NOT NULL   -- FK contracts(id)  ON DELETE CASCADE
  asset_id          INT NOT NULL   -- FK assets(id)     ON DELETE CASCADE
  reference         VARCHAR(190) NULL
  linked_by_id      INT NULL       -- FK analysts(id)   ON DELETE SET NULL
  created_datetime  DATETIME NULL DEFAULT CURRENT_TIMESTAMP
  UNIQUE KEY uq_contract_asset (contract_id, asset_id)
  KEY ix_ca_asset_id (asset_id)

Registered in database/freeitsm.sql, includes/db_verify_schema.php, includes/db_verify_indexes.php and the foreign-key list in api/system/db_verify.php.

No tenant_id, for the same reason ticket_assets has none: the company comes from the asset. No is_demo either β€” join tables do not carry it, and demo cleanup deletes the parents and lets the cascade take the links. That cascade is load-bearing, not decoration: bypassing one with FOREIGN_KEY_CHECKS=0 is how an SSO link once outlived its account and locked somebody out permanently.

reference is on the link rather than the asset because it describes the asset's place in this contract. Move a handset to another agreement and you keep the handset and lose the line.


3. πŸ”΄ The awkward bit: assets are scoped, contracts are not

This is the whole reason includes/contract_assets.php exists instead of four endpoints each doing their own SQL.

  • assets carries a tenant_id.
  • contracts does not, and neither do suppliers, rfps, lms_courses or workflows.

So the asset is the only half of the link that can answer "whose is this?", and every read of the asset side has to be filtered with activeTenantFilter($conn, $analystId, 'a'). Four endpoints filtering separately is four chances to forget, and forgetting once puts one customer's equipment on another customer's contract page β€” looking exactly like a working feature.

If contracts ever gain a tenant_id, contractsForAsset() is the function that grows a filter; it deliberately has none today and says so in a comment.

πŸ”‘ No hidden count

A reader who may see three of ten linked assets is shown three and told nothing about the other seven.

A count of what you cannot see is a leak of its own. This is exactly the mistake the requester picker made β€” it scoped the list and not the count β€” and it was a live disclosure.

πŸ”‘ A scoped list is not a gate

contractAssetCanReach() is called again on every write and every delete:

function contractAssetCanReach(PDO $conn, int $analystId, int $assetId): bool

The id in a POST body did not necessarily come from the list this server rendered. A missing asset and a forbidden one both return No such asset, deliberately β€” telling them apart tells you a row exists.


4. Module gating, and why the two directions differ

Endpoint Gate
the four api/contracts/* requireModuleAccessJson('contracts')
api/assets/get_asset_contracts.php requireModuleAccessJson('assets')
api/assets/search_linkable_contracts.php both β€” assets, then an explicit analystCanAccessModule(..., 'contracts')

The asset-side endpoint is on the assets module, because it serves the asset's own page. It then checks analystCanAccessModule($conn, $analystId, 'contracts') separately and returns permitted: false with an empty list, so the browser can say "you do not have access to the Contracts module" rather than "no contracts".

⚠️ Those are different answers and the UI must not collapse them. Reading one as the other is how somebody concludes a contract was never set up.

πŸ”΄ The asset-side picker needs both modules and refuses outright without contracts access. It returns contract numbers and titles, so without that second check a search box on an asset page would be a way to read the contract register without access to it. The read-only tab degrades politely; the picker does not degrade at all.

Writes are not duplicated. Linking and unlinking from the asset side call api/contracts/save_contract_asset.php and delete_contract_asset.php β€” the same endpoints the contract page uses. Only the search is mirrored, because the two ends genuinely search different things. One set of rules about who may link what.


5. Linking is an upsert

INSERT INTO contract_assets (...) VALUES (...)
ON DUPLICATE KEY UPDATE reference = VALUES(reference)

The unique key makes a second row impossible, and "it is already on here" is not an error anybody wants to read. Linking something already linked updates its note instead.

The note is trimmed to 190 characters in PHP rather than left to MySQL, and a note of only whitespace is stored as NULL rather than ''.


6. Two wrong assumptions, both caught by running the query

Neither would have been caught by reading the code:

  • contract_statuses has no colour column. The first draft selected st.colour.
  • suppliers has no name. It is legal_name and trading_name, and the contracts list returns both.

A third one bit the test: assets has no created_datetime. It also has no NOT NULL column without a default, so a hostname alone is a valid fixture.


7. Testing

php tests/contract-assets.php     # 24 assertions

Everything it makes is prefixed ZZCA and removed in a finally, including on failure.

The guards are tested from the attacker's side, with a positive control β€” four "this must refuse" assertions followed by one "and a real link still loads", so the refusals cannot be passing because everything refuses.

It also covers the cascade in both directions, because demo-data cleanup depends on it.

Verified beyond the unit tests: all five endpoints driven with curl against real rows, and the asset page driven in a real browser through a same-origin iframe β€” tab renders, badge counts, clicking activates the panel, the populated row carries number, title, reference, supplier, end and notice, and the notice colour resolves from --warning-text in both themes.


8. The equipment report

includes/contract_report.php holds the renderer and its CSS. One renderer, three destinations β€” the printable page, the emailed copy, and the CSV built from the same rows. This is the shape asset handover already uses, and for the same reason: a report that looks different depending on how it left the building is one people stop trusting.

πŸ”‘ The CSS is a PHP function, not a .css file. A mail client will not fetch an external stylesheet, so it has to be inlined into the message, so it has to be a string. It is also deliberately literal β€” no custom properties, no theme, no dark-mode query β€” because a printed page is white and a mail client resolves none of those reliably.

"PDF" means printing to one. There is no PDF library in the stack; asset-management/handover.php and the RFP preview already work this way. Adding one to lay out a six-column table would be a dependency to maintain forever in exchange for a button every browser already has. ?print=1 opens the dialog on load; a plain visit does not, because a print dialog nobody asked for is how a page gets closed before it is read.

The CSV is built in the browser from get_contract_assets.php, matching software/index.php. Two things in it are not optional:

  • Every field is quoted and its own quotes doubled. A model name with a comma is not exotic, and an unquoted one silently shifts every column after it.
  • The string starts '\uFEFF'. Without that byte-order mark Excel guesses at the local codepage and mangles every accented location name. Write it as the escape, never as a literal BOM character in the source β€” an invisible byte is one normalising tool away from vanishing, and nobody reviewing the file can see it is there.

⚠️ Scoping. The page and the email both call contractAssetsFor() with the viewer's analyst id, so a report is never a way around the company filter. The emailed copy contains what the SENDER can see, which is the only honest reading: they are the one choosing to send it.

The email defaults to the contract owner (contract_owner_id to analysts.email) and an explicit address wins. No address and no owner is reported as nobody to send to rather than as a send failure β€” there is nothing wrong with the mail setup.

πŸ”΄ Messages go through showToast(), never alert(). A native alert names the host ("freeitsm.internal says"), blocks the page, and looks nothing like the rest of the product. This page already used showToast eleven times before these functions were added to it.

9. ⬜ Not built

The requester also asked to filter or search the asset list by contract β€” "show me everything on contract X" from the Asset Management side. The picker searches, and both panels show their links, but that filter does not exist.

Renewal notifications were the fourth item on the request and already existed: contract.expiring is a workflow trigger in includes/workflow_scheduled.php, fired by cron/workflow_scheduled.php. Nothing was built for it.

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally