Company Support Links Developer Guide - edmozley/freeitsm GitHub Wiki
Issue: #228 · Changelog: #2746 · User guide: One company supporting another
How "company B supports company A" works under the hood: one table, one helper file, and the handful of places that ask it a question. Read this before changing anything that decides which company a portal ticket belongs to, or the reading pane's wrong-company warning.
#228 asked for a requester to belong to several companies. That was turned down on purpose. users.tenant_id is read by far more than ticket creation:
What follows users.tenant_id
|
Where |
|---|---|
| Knowledge articles a requester can read | api/self-service/get_knowledge_articles.php |
| The service catalogue | api/self-service/get_catalogue.php |
| Categories, mailboxes, ticket types, custom fields offered |
get_ticket_categories.php, includes/self_service_mailboxes.php, includes/self_service_ticket_types.php
|
| Who a manager may see |
includes/managers.php (managerReachSql(), portalTicketAccess()) |
A person in two companies needs a second rule for every one of those, and installs would want different rules - the "tie ourselves in knots" problem. A link between companies leaves every row above exactly as it was, and changes only where a ticket goes and whether one warning shows.
| File | Role |
|---|---|
database/freeitsm.sql |
tenant_support_links table; tickets.company_warning_dismissed_tenant_id
|
includes/db_verify_schema.php, api/system/db_verify.php
|
Columns; the two foreign keys (indexes come from the generated db_verify_indexes.php) |
includes/tenant_support.php |
Every rule. Nothing else reads the table |
includes/self_service_ticket_types.php |
selfServiceTicketTenant($conn, $userId, $wanted) - the one resolver |
includes/self_service_mailboxes.php |
Mailboxes follow the resolved company |
api/self-service/create_ticket.php, get_mailboxes.php, get_ticket_fields.php, get_ticket_categories.php
|
Accept tenant_id; all go through the resolver |
self-service/new-ticket.php |
The Raise with picker |
includes/managers.php, api/self-service/get_team_tickets.php
|
managerTicketCompanies() |
api/tickets/get_email_detail.php |
Warning: quiet when linked, or when dismissed |
api/tickets/dismiss_company_warning.php, assets/js/inbox.js
|
Remembered Dismiss; translated banner |
api/system/get_tenant_support.php, save_tenant_support.php, system/companies/index.php
|
The settings section |
tests/tenant-support.php |
27 checks, rolled back |
CREATE TABLE IF NOT EXISTS `tenant_support_links` (
`id` INT NOT NULL AUTO_INCREMENT,
`supporter_tenant_id` INT NOT NULL, -- B, gives the support
`supported_tenant_id` INT NOT NULL, -- A, is supported
`quiet_warning` TINYINT(1) NOT NULL DEFAULT 0,
`portal_raise` TINYINT(1) NOT NULL DEFAULT 0,
`created_datetime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uq_tenant_support_pair` (`supporter_tenant_id`,`supported_tenant_id`),
KEY `ix_tenant_support_supported` (`supported_tenant_id`),
CONSTRAINT `fk_tenant_support_supporter` FOREIGN KEY (`supporter_tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_tenant_support_supported` FOREIGN KEY (`supported_tenant_id`) REFERENCES `tenants` (`id`) ON DELETE CASCADE
);- Directional. B supporting A is one row; A supporting B would be another. Nothing reads it backwards.
-
Both ticks off = no row.
tenantSupportSet()deletes instead of storing0, 0, so "is there a link?" and "is anything ticked?" are the same question. - Cascades both ways, so deleting a company leaves no dangling links. DB Verify removes orphans before adding the keys on a grown install.
- The supported side has its own index because the portal asks from that side ("who supports my company?").
Every function answers "no links" before Database Verification has made the table (tenantSupportReady()), so an upgraded install behaves exactly as before until it is run.
function tenantSupportPortalSupporters(PDO $conn, int $ownCompanyId): array
{
if (!tenantSupportReady($conn) || !isMultiTenant($conn)) return [];
$st = $conn->prepare(
"SELECT l.supporter_tenant_id FROM tenant_support_links l
JOIN tenants t ON t.id = l.supporter_tenant_id AND t.is_active = 1
WHERE l.supported_tenant_id = ? AND l.portal_raise = 1"
);
...
}selfServiceRaiseWithCompanies($conn, $own) wraps it: the requester's own company first (own => true), then supporters by name. Empty when there is nothing to choose - no company of their own, nobody supporting it - so the page draws no picker at all.
function selfServiceTicketTenant(PDO $conn, int $userId, ?int $wanted = null): ?int
{
... $own = users.tenant_id ...
if ($wanted !== null && $wanted !== $own) {
foreach (selfServiceRaiseWithCompanies($conn, $own) as $c) {
if ($c['id'] === $wanted) return $wanted;
}
}
return $own; // (or NULL / Default exactly as before when they have no company)
}TRAP: one function for the list AND the check. The picker is drawn from
selfServiceRaiseWithCompanies()and every endpoint re-checks throughselfServiceTicketTenant(), which reads the same list. A narrowed dropdown is never a check - a hand-postedtenant_idthat was not offered silently becomes the requester's own company, the same way a hand-posted mailbox id is dropped.
Every portal call that depends on the ticket's company passes the choice through it:
// create_ticket.php
$wantedTenant = !empty($input['tenant_id']) ? (int)$input['tenant_id'] : null;
$ticketTenantId = selfServiceTicketTenant($conn, $userId, $wantedTenant);
...
if (!isset(selfServiceMailboxes($conn, $userId, $wantedTenant)[$mailboxId])) { $mailboxId = null; }Because $ticketTenantId was already what everything downstream used - ticket types, custom fields, categories, the default department, ticket type rules, the number - all of those follow the chosen company with no further change.
function managerTicketCompanies(PDO $conn, $tenantId): array
{
$own = managerCompany($conn, $tenantId);
return array_values(array_unique(array_merge([$own], tenantSupportPortalSupporters($conn, $own))));
}portalTicketAccess() used to require the ticket's company to equal the manager's. It now requires it to be in this list; get_team_tickets.php uses t.tenant_id IN (...). The team itself (managerReachSql()) is unchanged - still only people in the manager's company - so this widens where their people's tickets may sit, never whose tickets they see.
// api/tickets/get_email_detail.php - after the suggestion is worked out
if ($sug && tenantSupportQuietWarning($conn, (int)$effectiveTenant, (int)$suggested)) $sug = null;
if ($sug) {
try {
$dq = $conn->prepare('SELECT company_warning_dismissed_tenant_id FROM tickets WHERE id = ?');
$dq->execute([(int)$email['ticket_id']]);
if ((int)$dq->fetchColumn() === (int)$suggested) $sug = null;
} catch (Throwable) { /* before Database Verification: never dismissed */ }
}-
tenantSupportQuietWarning($filed, $requesterCompany)- quiet only when the filed company supports the requester's company withquiet_warning. Reversed, it still warns. - The dismissed column is read in its own query inside
try, not added to the big ticketSELECT: a column named there would break the whole reading pane until DB Verify (see the "new column before DB Verify" rule).
async function dismissCompanyWarning(btn, suggestedId) {
const banner = btn.closest('.wrong-company-banner');
if (banner) banner.remove(); // goes at once
const res = await fetch(API_BASE + 'dismiss_company_warning.php', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticket_id: currentEmail.ticket_id, suggested_id: suggestedId })
});
... // 'not_ready' -> "it will come back until Database Verification"
}It stores which company was suggested, not a yes/no, so a later, different suggestion still shows. The endpoint checks the analyst can reach the ticket and that the company exists; the most a wrong value could do is hide a warning on a ticket that analyst can already move.
The banner text used to be hard-coded English. It is now tickets.reading_pane.wrong_company_text with {filed}, {requester} and {suggested}; the JS escapes each value before wrapping the names in <strong>.
self-service/new-ticket.php draws <select id="raiseWith"> only when selfServiceRaiseWithCompanies() returns more than one entry. Changing it calls onRaiseWithChange(), which empties the ticket-type picker (normally filled once) and re-runs the three loaders with raiseWithQuery() appended:
function raiseWithQuery(sep) {
const rw = document.getElementById('raiseWith');
return rw && rw.value ? sep + 'tenant_id=' + encodeURIComponent(rw.value) : '';
}loadMailboxes() now shows its group and sets required again on every load, and loadTicketCategories() empties its list first - both used to assume they ran once.
- Where the portal asks questions (side panel / main form) stays the requester's company's choice - it decides the page layout before any choice is made.
- Knowledge, the catalogue, profile, equipment - all still the requester's own company.
- Email routing. An email from an Acme person to Support Co's pinned mailbox was already filed under Support Co; only the warning changes.
php tests/tenant-support.php - 27 checks inside a transaction that is always rolled back: no links changes nothing; Don't warn is one way; Portal offers own-first; a company not offered falls back; inactive supporters are neither offered nor accepted; no company = no picker; a company cannot support itself; both ticks off deletes; mailboxes follow the choice; managers include supporters; deleting a company cascades. Negative control: making the resolver accept any $wanted fails three of them.
It was also run end to end against a throwaway install: the settings section saves, the portal picker appears and swaps mailboxes, a ticket raised with B lands in B through B's mailbox, a hand-posted B without a link lands in A, the warning shows / goes quiet / is remembered after Dismiss.