Skip to content

Company Support Links Developer Guide

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

Company support links - Developer Guide

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.


Why a link between companies, not a person in two companies

#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.

The shape of it

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

The table

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 storing 0, 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?").

The rules - includes/tenant_support.php

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.

Who may a requester raise with?

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.

The one resolver

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 through selfServiceTicketTenant(), which reads the same list. A narrowed dropdown is never a check - a hand-posted tenant_id that 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.

Managers

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.

The warning

// 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 with quiet_warning. Reversed, it still warns.
  • The dismissed column is read in its own query inside try, not added to the big ticket SELECT: a column named there would break the whole reading pane until DB Verify (see the "new column before DB Verify" rule).

Dismiss, in the browser

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>.

The picker, in the browser

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.

What deliberately did not change

  • 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.

Tests

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.

Related

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Responses to feedback

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally