Skip to content

Linking Equipment to Tickets Developer Guide

Ed Mozley edited this page Aug 14, 2026 · 2 revisions

Linking equipment to tickets β€” Developer Guide

How an asset gets attached to a ticket, and every gate it passes on the way.

This is the implementation half of Linking equipment to tickets. Read that first if your question is "what does it do"; stay here if it is "why is there a second ownership check when the dropdown is already scoped".

The one-line summary: it is a deliberate near-copy of ticket_cmdb_objects, with one schema difference (two creator columns, because a link can be made by an analyst or a portal user) and one behavioural difference (the picker opens with results already in it).


1. πŸ“ The files involved

Colour key: 🧠 shared rule Β· πŸ”Œ API Β· πŸ–₯️ page Β· πŸ—„οΈ schema Β· βš™οΈ registration Β· 🌍 i18n Β· 🎨 styling

🎨 File Its job
πŸ—„οΈ database/freeitsm.sql ticket_assets β€” the join table, and the reasoning for its two creator columns
πŸ—„οΈ includes/db_verify_schema.php Columns only. Indexes and FKs live elsewhere
πŸ—„οΈ includes/db_verify_indexes.php GENERATED. uq_ticket_asset, ix_ta_asset_id
βš™οΈ scripts/gen_db_verify_indexes.php Regenerates the above from freeitsm.sql
πŸ—„οΈ api/system/db_verify.php Creates the four FKs (fk_ta_ticket, fk_ta_asset, fk_ta_analyst, fk_ta_user)
πŸ”Œ api/tickets/save_ticket_asset.php Link. Idempotent. Three gates β€” see Β§3
πŸ”Œ api/tickets/delete_ticket_asset.php Unlink. Gates on the ticket, not the asset
πŸ”Œ api/tickets/get_ticket_assets.php The hydrated card for the reading pane
πŸ”Œ api/tickets/search_linkable_assets.php The picker. Returns two lists β€” see Β§4
πŸ”Œ api/assets/get_asset_tickets.php The reverse view. Two buckets β€” see Β§6
πŸ”Œ api/self-service/get_my_assets.php The portal's entire permission rule β€” see Β§5
πŸ”Œ api/self-service/create_ticket.php Re-checks ownership on write. Not optional
πŸ–₯️ assets/js/inbox.js buildLinksSection (the strip), openStripPicker, syncLinksStripEmpty, loadTicketAssets, renderTicketAssets, openLinkAssetPicker, removeTicketAsset
🎨 assets/css/inbox.css .strip-pill-group, .strip-picker-*, .asset-picker-*
πŸ–₯️ asset-management/index.php The Tickets tab, loadAssetTickets, and its CSS
πŸ–₯️ self-service/new-ticket.php loadMyEquipment, the hidden-by-default field
🧠 includes/tenancy.php analystCanAccessTicket, analystCanAccessAsset, activeTenantFilter, ticketTenantFilter
βš™οΈ includes/ticket_merge.php MERGE_MOVE_DEDUPE β€” see Β§7, and read it before editing
βš™οΈ api/tickets/permanently_delete_ticket.php Comment only; the FK does the work
βš™οΈ api/system/debug-tools/D002_delete_ticket.php Two lists, both need the table
🌍 lang/en/tickets.php tickets.assets.*
🌍 lang/en/asset-management.php asset-management.tickets.*, detail.tab_tickets
🌍 lang/en/self-service.php self-service.new_ticket.equipment*

2. πŸ—„οΈ Why there are two creator columns

ticket_cmdb_objects has one: created_by_analyst_id. Only an analyst can link a CI, so one column covers it.

ticket_assets has two:

`created_by_analyst_id` INT NULL,   -- FK -> analysts
`created_by_user_id`    INT NULL,   -- FK -> users

Because a link can be created from the ticket screen (an analyst) or from the self-service portal (an end user), and those are different tables with independent id sequences. There is no single column that can hold both without losing which one it means β€” analyst 7 and user 7 are different people.

Exactly one is set on any row. Both NULL would mean an automated path created it; nothing does that today.

There is no tenant_id. The company is inherited from the ticket and the asset, both of which carry their own, and the invariant that they match is enforced in application code (Β§3) β€” the same choice ticket_cmdb_objects made.


3. πŸ›‘οΈ Three gates on the write path, not one

save_ticket_asset.php checks, in order:

  1. analystCanAccessTicket β€” can this analyst reach the ticket at all?
  2. analystCanAccessAsset β€” can they reach the asset? The ticket gate says nothing about this. Without it, any asset id could be attached to a ticket the analyst legitimately owns, pulling another company's hostname and serial into their reading pane. Framed as not-found, never "forbidden", so the endpoint does not confirm the row exists.
  3. Same company β€” only when isMultiTenant(). Both ends being reachable is not enough for an all-access analyst, who can reach both companies. A ticket and its asset must belong to the same one, or the link itself becomes the leak.

Gate 3 is the one that is easy to talk yourself out of, and it is the one that matters for the exact role that can defeat gates 1 and 2 together.

The read path repeats the scoping

get_ticket_assets.php applies activeTenantFilter to the asset join even though gate 3 should make that impossible. A link created before a company was split β€” or before gate 3 existed β€” can still straddle two companies. The read must not depend on an invariant the write only recently started enforcing.


4. πŸ” The picker returns two lists, and why

search_linkable_assets.php responds with:

{ "success": true, "requester": [...], "others": [...], "limit": 25 }
  • requester β€” from users_assets for the ticket's user_id. Returned even when q is empty, which is what lets the picker open with something in it. "My monitor is flickering" is nearly always their own monitor, and that should be one click, not a search.
  • others β€” only populated once q is non-empty. Excludes anything already in requester, so nothing appears twice.

Both exclude assets already linked to the ticket.

πŸ”‘ The users_assets join is an INNER JOIN on purpose. That table carries no foreign key on asset_id and real installs have orphan rows. A LEFT JOIN renders them as blank entries in the dropdown.

⚠️ Location is searched, and it is load-bearing

WHERE (a.hostname LIKE ? OR a.manufacturer LIKE ? OR a.model LIKE ?
       OR a.service_tag LIKE ? OR a.asset_tag LIKE ? OR l.name LIKE ?)

That last clause is the difference between the feature working for shared equipment and not. The motivating case is a user reporting the TV in a meeting room: nobody is assigned it, and neither the reporter nor the analyst knows its hostname or serial. They know where it is. Remove l.name and there is nothing useful to type.

It depends on assets.location_id being populated. If an install has no locations set, searching a room name legitimately returns nothing β€” that is missing data, not a regression, and it is worth checking before debugging the query.

The JS flattens, the headings are siblings

openLinkAssetPicker concatenates the two lists into one current array and records firstOtherIdx. Group headings are rendered as siblings of the result rows rather than as rows, so arrow-key navigation indexes only over selectable items and can never land on a heading.


5. πŸ”’ The portal rule is the endpoint

get_my_assets.php is the whole of the portal's permission model, and it is deliberately tiny: no search, no browsing, no id parameter. The user comes from $_SESSION['ss_user_id'] and nothing else is reachable.

πŸ”΄ create_ticket.php re-checks ownership against users_assets anyway. The dropdown being scoped is not a check β€” the request is just JSON, and a hand-crafted one can name any id. Without the re-check, a portal user could attach (and then read back the make, model and serial of) equipment belonging to somebody else.

Ids that are not theirs are silently skipped, not rejected: there is no legitimate way for the UI to send one, so the only sender is someone probing, and an error would tell them which ids exist.

This is the same lesson get_users.php taught in #54, where the tenancy clause scoped the ticket count but not the user list. A scoped list is not a check.

Shared equipment is out of reach here, deliberately

Nobody is assigned a meeting-room TV, so a requester cannot pick one. The two alternatives were both judged worse β€” exposing every unassigned asset makes cupboard stock browsable to all end users, and a "shared" flag asks end users to understand a distinction they should not have to. The requester describes it and an analyst attaches it. See the user page for the full write-up so this does not get re-proposed.


6. πŸ“‡ The reverse view

get_asset_tickets.php mirrors api/cmdb/get_object_tickets.php, including both lessons that endpoint learned:

  • Two independent gates, because it straddles two modules: analystCanAccessAsset for the asset, then ticketTenantFilter for the tickets. Same reasoning as Β§3.
  • t.deleted_datetime IS NULL. The CMDB version originally lacked this and was listing tickets sitting in the recycle bin.

Two buckets: open (all of them, newest-updated first) and closed (capped at 20, newest-closed first) plus total_closed so the UI can say "showing 20 of N". A device in service for years should not render its whole history to answer "has this broken before?".


7. βš™οΈ Where a new ticket-child table must be registered

Tracing ticket_cmdb_objects found three places, and found a bug in one of them:

File What to add
includes/ticket_merge.php An entry in MERGE_MOVE_DEDUPE, and the table name in the header comment's MOVE list
api/tickets/permanently_delete_ticket.php Comment only β€” the CASCADE FK does the work
api/system/debug-tools/D002_delete_ticket.php Two lists: the delete order map and the pre-flight counts

πŸ”΄ MERGE_MOVE_DEDUPE maps table β†’ column, and the column name goes straight into SQL.

It had 'ticket_cmdb_objects' => 'object_id'. The real column is cmdb_object_id. That produced "Unknown column", which landed in a catch whose comment reads "absent" β€” written for installs where the table does not exist yet β€” so the failure looked like an expected condition.

And because the throw came from the first statement in the try, the two after it never ran either: no de-dupe, nothing recorded for a later unmerge, and no move. CMDB links silently stopped following merges. Fixed in #1064.

A wrong column name in a table-driven loop is not a mismatch. It is a silent no-op. Check the name against the schema when you add a row.


8. πŸ§ͺ Testing it

There is no UI login available to an agent, so verification runs against the API with a forged session.

# analyst
printf 'analyst_id|i:1;' > /c/wamp64/tmp/sess_mytest
# portal user
printf 'ss_user_id|i:128;' > /c/wamp64/tmp/sess_myportal
curl -k -b "PHPSESSID=mytest" "https://freeitsm.internal/api/tickets/get_ticket_assets.php?ticket_id=50"

⚠️ No trailing newline in the session file. PHP's decoder rejects it outright with "Failed to decode session object. Session has been destroyed", which reads exactly like an auth failure.

Controls worth repeating if you change any of this:

Check Why
Post a bogus asset_id Must be "Asset not found", not an FK error
Link the same pair twice Must return already_linked, not 1062
Post foreign asset ids to create_ticket.php Must link none of them
Permanently delete a ticket, then read the asset's tab Proves the CASCADE exists β€” db_verify creates FKs inside catch {}, so their existence cannot be assumed from a clean verify run
Search a string that appears only in a location The only way to prove the location join, and it needs an asset whose other fields do not contain the string

9. 🎨 Why it is a pill in the Links strip, not a section

It shipped as a bordered card below the thread, copying ticket_cmdb_objects. That was wrong, and buildLinksSection already said so in a comment written for the Jira work:

"A Jira issue IS a link, so it belongs in this strip rather than in a panel of its own β€” an analyst already looks here for 'what else is this connected to'."

Equipment and CIs are links by that definition. Both are pills in the strip now, reusing .pm-ticket-badge, and both are reached from the same Link to… menu.

The cost was never the number of sections. A section rendered a bordered card, a heading and 16px margins whether or not it had anything in it β€” so an ordinary email ticket paid full price six times over to be told "nothing here yet" six times. Empty state was most of the bill.

They are not merged, and should not be

One row, two identities: πŸ–₯️ for equipment, πŸ—„οΈ for CIs. Merging them into a single "Affected" list was considered and rejected β€” a broken mouse is an asset and will never be a CI, and the two links do different downstream work (impact analysis versus warranty and repeat-failure history). Making them look identical invites an analyst to link the wrong one and silently get no blast radius.

⚠️ The picker host must be a CHILD of .problem-strip

Not a sibling. mobile.js relocates the whole strip into a full-screen Links sheet on a phone, selecting by .problem-strip. A sibling stays behind in the reading pane, so the picker would open somewhere the user cannot see. It takes flex: 0 0 100% and wraps onto its own row.

Two loose ends this left

  • .cmdb-section / .cmdb-link-card and friends are now dead CSS in both inbox.css and mobile.css. Nothing emits that markup. Removing the mobile.css ones means bumping mobile.css across 21 pages, so it was left as a separate tidy-up.
  • mobile.js still lists a cmdb entry in SECTIONS pointing at #cmdbObjectsContainer, which no longer exists. relocateSections finds no nodes and returns, so it is inert β€” but it is dead config, and the CI pills now travel inside the Links sheet instead.

10. 🚧 Deliberately not done

  • No v1 REST API resource. The tickets resource does not expose CI links either; matching that was preferred to inventing a surface on one side only.
  • No ticket-thread event on link/unlink. Consistent with CMDB linking, which is also silent.
  • No automatic linking. Nothing infers the asset from the requester or the message text, so an empty Tickets tab is the expected state for most assets.
  • No dark-mode override block. Not needed β€” .asset-* is themed from --accent, --accent-soft, --surface, --border, --text*, all real tokens in theme.css.

πŸ”΄ The neighbouring .cmdb-* block is not theme-aware. It hardcodes #fdf2f8 / #fbcfe8 / #be185d and has no dark-mode handling anywhere, so it renders light-pink cards on a dark page. Left alone here rather than changed as a side effect, but it is a real gap sitting directly above the new section.


See also

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally