Files
itflow/functions/links.php
johnnyq 9e55d52432 Vendors overhaul for daily MSP work: new vendor page built around calling them, with Call support (dials the extension), Email, Support site, a star, and a New menu (case/ticket with them, license from them, asset bought from them, portal login, document, upload, or expense and recurring expense for our own vendors), Support card (phone, hours, SLA, email, site), Account card (account number, PIN shown as dots with show and copy, account manager name, direct line and email), blur-to-save notes, an Also used by list of the agent's other clients with the same vendor by name or template, and tabs for Cases (open first, with their ticket number), Licenses (soonest renewal first), Assets (serial, order ref, warranty status), Domains (registrar, web host, DNS, mail), Services, Logins, Documents, Files and History; our own vendors show spend this year, last year and in all, recent expenses and recurring expenses; new vendor quick look; rebuilt vendors list with starred first, initials tiles coloured by vendor name, support line, hours and SLA, account number and PIN, chips for open cases, licenses, assets, domains, services and logins, an Ours / Clients' switch with an all-clients view in All Client Docs, wider search (account manager, emails, tags), tag filter and bulk Tags/Archive/Restore/Delete; shared tabbed vendor form (Details, Support, Account, Notes) with star, tags and start from template that fills every field; Add a Template to Clients to roll one vendor out to many clients, skipping those that have it; Admin Vendor Templates shows how many clients use each template and has Add to clients; vendor tags (tag type 6) with vendor tags in the Tags starter pack; ticket vendor block shows their ticket number, support line, hours, SLA, account number and PIN with copy buttons; vendor tiles in every vendor picker and quick look links across tickets, licenses, assets, domains, services and expense lists; search opens the vendor page; CSV export adds tags, open cases, account manager and PIN; logins, documents and files can be linked to a vendor and ones made from a vendor's page are linked to it; demo vendors get tags, a star and an account manager; database update 2.8.2 adds vendor_tags and vendor_contact_phone_country_code, vendor_contact_phone, vendor_contact_email (db.sql updated) and clears references to vendors that no longer exist; ticket vendor now validated against the ticket's client and removing a vendor from a ticket is logged as such; deleting a vendor from a template no longer unlinks every other vendor from that template; deleting a vendor clears it from assets, licenses, tickets and domains, and vendors with expenses, recurring expenses or payment provider fees are archived rather than deleted; vendor websites without a scheme open correctly; domain form vendor names escaped; new vendor templates keep the phone country code and pushing a template update with nothing ticked no longer errors; vendors API accepts the account manager fields; new functions/vendor.php (VENDOR_USES, vendorCan/vendorEnforce with client vendors on module_client and ours on module_financial, tile, link, website, PIN, form, tags, counts, delete check, reference clearing, also used by, spend, history), credential|vendor and file|vendor link relations, tag module_ours for our own vendors, handlers add_vendor_template_to_clients, favorite_vendor, unfavorite_vendor and ajax vendor_set_notes, vendor_model.php removed
2026-10-14 22:49:05 -04:00

386 lines
21 KiB
PHP

<?php
/*
* Links between documents, files and the things they are about - the assets,
* contacts, licenses and vendors a document covers, the files attached to a
* document, the assets and contacts a file belongs to - and the seats on a
* license (the devices and users on it) and the credentials and files that
* go with it, the documents and files about a location (a floor plan, a
* site runbook), and a vendor's portal logins, documents and files (the
* contract, the escalation sheet).
*
* Every link is a row in a two-column table (asset_documents, document_files,
* contact_files...) and they all work the same way, so they are described once
* here and linked, unlinked, listed and offered by the same few functions -
* one link modal (agent/modals/link/link.php), one handler
* (agent/post/link.php) and one list (linkGroupsHtml()) on the document page
* and the file and document quick looks.
*
* A type with a 'view' module (credentials) is only offered, listed or
* linked for an agent who may see that module.
*
* A link only ever joins two records of the same client, and never to an
* archived one; linkAdd() checks both rather than trusting the form.
*/
/** The record types a link can join. */
const LINK_ENTITIES = [
'asset' => ['table' => 'assets', 'id' => 'asset_id', 'name' => 'asset_name', 'client' => 'asset_client_id', 'archived' => 'asset_archived_at', 'label' => 'asset', 'plural' => 'Assets', 'icon' => 'fa-desktop'],
'contact' => ['table' => 'contacts', 'id' => 'contact_id', 'name' => 'contact_name', 'client' => 'contact_client_id', 'archived' => 'contact_archived_at', 'label' => 'contact', 'plural' => 'Contacts', 'icon' => 'fa-user'],
'document' => ['table' => 'documents', 'id' => 'document_id', 'name' => 'document_name', 'client' => 'document_client_id', 'archived' => 'document_archived_at', 'label' => 'document', 'plural' => 'Documents', 'icon' => 'fa-file-alt'],
'file' => ['table' => 'files', 'id' => 'file_id', 'name' => 'file_name', 'client' => 'file_client_id', 'archived' => 'file_archived_at', 'label' => 'file', 'plural' => 'Files', 'icon' => 'fa-paperclip'],
'software' => ['table' => 'software', 'id' => 'software_id', 'name' => 'software_name', 'client' => 'software_client_id', 'archived' => 'software_archived_at', 'label' => 'license', 'plural' => 'Licenses', 'icon' => 'fa-cube'],
'location' => ['table' => 'locations', 'id' => 'location_id', 'name' => 'location_name', 'client' => 'location_client_id', 'archived' => 'location_archived_at', 'label' => 'location', 'plural' => 'Locations', 'icon' => 'fa-map-marker-alt', 'view' => 'module_client'],
'credential' => ['table' => 'credentials', 'id' => 'credential_id', 'name' => 'credential_name', 'client' => 'credential_client_id', 'archived' => 'credential_archived_at', 'label' => 'credential', 'plural' => 'Credentials', 'icon' => 'fa-key', 'view' => 'module_credential'],
'vendor' => ['table' => 'vendors', 'id' => 'vendor_id', 'name' => 'vendor_name', 'client' => 'vendor_client_id', 'archived' => 'vendor_archived_at', 'label' => 'vendor', 'plural' => 'Vendors', 'icon' => 'fa-building'],
];
/**
* Which pairs can be linked, keyed by the two types in alphabetical order, and
* the permission it takes. Linking a contact to a file has always been a
* contacts permission, a credential to a license a credentials one;
* everything else is documentation, a support one. A device or user linked
* to a license holds a seat on it.
*/
const LINK_RELATIONS = [
'asset|document' => ['table' => 'asset_documents', 'module' => 'module_support'],
'contact|document' => ['table' => 'contact_documents', 'module' => 'module_support'],
'document|file' => ['table' => 'document_files', 'module' => 'module_support'],
'document|software' => ['table' => 'software_documents', 'module' => 'module_support'],
'document|vendor' => ['table' => 'vendor_documents', 'module' => 'module_support'],
'asset|file' => ['table' => 'asset_files', 'module' => 'module_support'],
'contact|file' => ['table' => 'contact_files', 'module' => 'module_client'],
'asset|software' => ['table' => 'software_assets', 'module' => 'module_support'],
'contact|software' => ['table' => 'software_contacts', 'module' => 'module_support'],
'credential|software' => ['table' => 'software_credentials', 'module' => 'module_credential'],
'file|software' => ['table' => 'software_files', 'module' => 'module_support'],
'document|location' => ['table' => 'location_documents', 'module' => 'module_support'],
'file|location' => ['table' => 'location_files', 'module' => 'module_support'],
'credential|vendor' => ['table' => 'vendor_credentials', 'module' => 'module_credential'],
'file|vendor' => ['table' => 'vendor_files', 'module' => 'module_support'],
];
/** The relation between two types, or null when they cannot be linked. */
function linkRelation($a, $b) {
$pair = [(string) $a, (string) $b];
sort($pair);
return LINK_RELATIONS[implode('|', $pair)] ?? null;
}
/**
* What a $from record can be linked to, in the order the pages list them -
* a license's seats first - leaving out types this agent may not see.
*/
function linkTypesFor($from) {
$orders = [
'software' => ['asset', 'contact', 'credential', 'document', 'file'],
// A vendor's portal logins first - what is wanted on the phone to them
'vendor' => ['credential', 'document', 'file'],
];
$order = $orders[$from] ?? ['file', 'document', 'asset', 'contact', 'location', 'software', 'credential', 'vendor'];
return array_values(array_filter($order, function ($to) use ($from) {
$view = LINK_ENTITIES[$to]['view'] ?? null;
return $to !== $from && linkRelation($from, $to) && (!$view || lookupUserPermission($view) >= 1);
}));
}
/** May this agent change links between these two types? */
function linkCanEdit($from, $to) {
$relation = linkRelation($from, $to);
return $relation && lookupUserPermission($relation['module']) >= 2;
}
/** One record's id, name, client and archived date - or null if it does not exist. */
function linkRecord($type, $id) {
global $mysqli;
$entity = LINK_ENTITIES[$type] ?? null;
$id = intval($id);
if (!$entity || !$id) {
return null;
}
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT {$entity['id']} AS id, {$entity['name']} AS name,
{$entity['client']} AS client_id, {$entity['archived']} AS archived_at
FROM {$entity['table']} WHERE {$entity['id']} = $id LIMIT 1"));
return $row ?: null;
}
/** Extra columns each type brings to a linked row, beyond id and name. */
function linkDisplayColumns($type) {
return [
'asset' => 'asset_type, asset_make, asset_model',
'contact' => 'contact_title, contact_photo, contact_client_id',
'document' => 'document_description, document_updated_at, document_created_at',
'file' => 'file_ext, file_size, file_mime_type, file_description',
'software' => 'software_type, software_version, software_license_type, software_seats, software_expire, ' . softwareSeatsUsedSql('t.software_id') . ' AS seats_used',
'credential' => 'credential_description, credential_uri, credential_username, credential_otp_secret, credential_favorite',
'vendor' => 'vendor_description, vendor_account_number, vendor_phone, vendor_phone_country_code, vendor_extension',
'location' => 'location_address, location_city, location_state, location_zip, location_primary, location_photo, location_client_id',
][$type] ?? '';
}
/**
* The $to records linked to one $from record, ready to draw: each row has id
* and name plus linkDisplayColumns(). Archived records are left out - they
* come back when they are restored.
*/
function linkedRecords($from, $id, $to) {
global $mysqli;
$relation = linkRelation($from, $to);
$from_entity = LINK_ENTITIES[$from] ?? null;
$to_entity = LINK_ENTITIES[$to] ?? null;
if (!$relation || !$from_entity || !$to_entity) {
return [];
}
$id = intval($id);
$extra = linkDisplayColumns($to);
$sql = mysqli_query($mysqli, "SELECT t.{$to_entity['id']} AS id, t.{$to_entity['name']} AS name" . ($extra ? ", $extra" : '') . "
FROM {$relation['table']} l
JOIN {$to_entity['table']} t ON t.{$to_entity['id']} = l.{$to_entity['id']}
WHERE l.{$from_entity['id']} = $id AND t.{$to_entity['archived']} IS NULL
ORDER BY t.{$to_entity['name']} ASC");
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/** The client's $to records not yet linked to this $from record, and not archived. */
function linkCandidates($from, $id, $to, $client_id) {
global $mysqli;
$relation = linkRelation($from, $to);
$from_entity = LINK_ENTITIES[$from] ?? null;
$to_entity = LINK_ENTITIES[$to] ?? null;
if (!$relation || !$from_entity || !$to_entity) {
return [];
}
$id = intval($id);
$client_id = intval($client_id);
$extra = linkDisplayColumns($to);
// Files show their folder, so two files with the same name can be told apart
$folder_join = $to === 'file' ? 'LEFT JOIN folders ON folder_id = t.file_folder_id' : '';
$folder_column = $to === 'file' ? ', folder_name' : '';
$sql = mysqli_query($mysqli, "SELECT t.{$to_entity['id']} AS id, t.{$to_entity['name']} AS name" . ($extra ? ", $extra" : '') . "$folder_column
FROM {$to_entity['table']} t
$folder_join
LEFT JOIN {$relation['table']} l ON l.{$to_entity['id']} = t.{$to_entity['id']} AND l.{$from_entity['id']} = $id
WHERE t.{$to_entity['client']} = $client_id AND t.{$to_entity['archived']} IS NULL AND l.{$to_entity['id']} IS NULL
ORDER BY t.{$to_entity['name']} ASC");
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/** How many $to records each type has linked to this $from record - for tab and section counts. */
function linkCounts($from, $id) {
$counts = [];
foreach (linkTypesFor($from) as $to) {
$counts[$to] = count(linkedRecords($from, $id, $to));
}
return $counts;
}
/** The audit log entry a link change is filed under - the document, file or license side of it. */
function linkLogSide($from, $id, $to, $other_id) {
foreach (['document', 'file', 'software'] as $side) {
if ($from === $side) {
return [ucfirst($side), $id];
}
if ($to === $side) {
return [ucfirst($side), $other_id];
}
}
return [ucfirst($from), $id];
}
/**
* Link $ids ($to records) to one $from record. Only records of the same
* client that are not archived are linked; anything else in $ids is skipped,
* as is anything already linked. The caller has checked the permission and
* the client access of $from. Returns the names that were linked.
*/
function linkAdd($from, $id, $to, $ids) {
global $mysqli, $session_name;
$relation = linkRelation($from, $to);
$source = linkRecord($from, $id);
if (!$relation || !$source) {
return [];
}
$from_entity = LINK_ENTITIES[$from];
$to_entity = LINK_ENTITIES[$to];
$client_id = intval($source['client_id']);
$added = [];
foreach (array_unique(array_map('intval', (array) $ids)) as $other_id) {
$other = linkRecord($to, $other_id);
if (!$other || intval($other['client_id']) !== $client_id || $other['archived_at'] !== null) {
continue;
}
// The pair is the table's primary key, so a second link of the same two is a no-op rather than an error
mysqli_query($mysqli, "INSERT IGNORE INTO {$relation['table']} SET {$from_entity['id']} = " . intval($source['id']) . ", {$to_entity['id']} = $other_id");
if (mysqli_affected_rows($mysqli) < 1) {
continue;
}
$added[] = $other['name'];
[$log_type, $log_id] = linkLogSide($from, intval($source['id']), $to, $other_id);
$log_from = $log_type === ucfirst($from) ? $source['name'] : $other['name'];
$log_other = $log_type === ucfirst($from) ? $other['name'] : $source['name'];
$log_other_label = $log_type === ucfirst($from) ? $to_entity['label'] : $from_entity['label'];
logAudit($log_type, "Link", "$session_name linked $log_other_label " . escapeSql($log_other) . " to " . strtolower($log_type) . " " . escapeSql($log_from), $client_id, $log_id);
}
return $added;
}
/** Unlink one $to record from a $from record. Returns its name, or '' if there was no such link. */
function linkRemove($from, $id, $to, $other_id) {
global $mysqli, $session_name;
$relation = linkRelation($from, $to);
$source = linkRecord($from, $id);
$other = linkRecord($to, $other_id);
if (!$relation || !$source || !$other) {
return '';
}
$from_entity = LINK_ENTITIES[$from];
$to_entity = LINK_ENTITIES[$to];
mysqli_query($mysqli, "DELETE FROM {$relation['table']} WHERE {$from_entity['id']} = " . intval($source['id']) . " AND {$to_entity['id']} = " . intval($other['id']));
if (mysqli_affected_rows($mysqli) < 1) {
return '';
}
// A device or user off a license gives back any of its keys they had
foreach (['asset', 'contact'] as $holder) {
if ([$from, $to] === ['software', $holder]) {
softwareFreeKeys($source['id'], $holder, $other['id']);
} elseif ([$from, $to] === [$holder, 'software']) {
softwareFreeKeys($other['id'], $holder, $source['id']);
}
}
[$log_type, $log_id] = linkLogSide($from, intval($source['id']), $to, intval($other['id']));
$log_from = $log_type === ucfirst($from) ? $source['name'] : $other['name'];
$log_other = $log_type === ucfirst($from) ? $other['name'] : $source['name'];
$log_other_label = $log_type === ucfirst($from) ? $to_entity['label'] : $from_entity['label'];
logAudit($log_type, "Unlink", "$session_name unlinked $log_other_label " . escapeSql($log_other) . " from " . strtolower($log_type) . " " . escapeSql($log_from), intval($source['client_id']), $log_id);
return $other['name'];
}
/** Drop every link a record has - for when it is deleted for good. */
function linkRemoveAll($type, $id) {
global $mysqli;
$entity = LINK_ENTITIES[$type] ?? null;
if (!$entity) {
return;
}
$id = intval($id);
foreach (LINK_RELATIONS as $pair => $relation) {
if (in_array($type, explode('|', $pair), true)) {
mysqli_query($mysqli, "DELETE FROM {$relation['table']} WHERE {$entity['id']} = $id");
}
}
if ($type === 'document') {
mysqli_query($mysqli, "DELETE FROM service_documents WHERE document_id = $id");
}
}
/**
* How one linked record is drawn: its icon or avatar, its name linked to where
* it opens, and a quieter detail. Raw row in, HTML out.
*/
function linkRowHtml($to, $row, $client_id) {
$id = intval($row['id']);
$name = escapeHtml($row['name']);
switch ($to) {
case 'asset':
$lead = '<span class="itflow-asset-icon itflow-asset-icon-sm itflow-asset-tone-' . assetTypeTone($row['asset_type']) . '" aria-hidden="true"><i class="fas fa-fw fa-' . getAssetIcon($row['asset_type']) . '"></i></span>';
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/asset/asset.php?id=$id\">$name</a>";
$detail = trim($row['asset_make'] . ' ' . $row['asset_model']);
break;
case 'contact':
$lead = contactAvatar($row['contact_client_id'], $id, $row['name'], $row['contact_photo'], 'sm');
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/contact/contact.php?id=$id\">$name</a>";
$detail = $row['contact_title'];
break;
case 'file':
$lead = fileTypeTile($row['file_ext'], 'sm');
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/file/file.php?id=$id\">$name</a>";
$detail = fileTypeInfo($row['file_ext'])['label'] . ($row['file_size'] ? ' · ' . formatBytes(intval($row['file_size'])) : '');
break;
case 'document':
$lead = documentTile('sm');
$link = "<a class=\"text-truncate\" href=\"document.php?client_id=" . intval($client_id) . "&amp;document_id=$id\">$name</a>";
$detail = 'Updated ' . timeAgo($row['document_updated_at'] ?: $row['document_created_at']);
break;
case 'software':
$lead = softwareTile($row['software_type'], 'sm');
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/software/software.php?id=$id\">$name</a>";
$seats = softwareSeats($row['seats_used'] ?? 0, $row['software_seats'] ?? 0);
$renewal = softwareRenewal($row['software_expire'] ?? '');
$detail = implode(' · ', array_filter([
$row['software_license_type'] ?? '',
$seats['state'] === 'unlimited' ? '' : $seats['used'] . ' / ' . $seats['total'] . ' seats',
$renewal && $renewal['state'] !== 'active' ? $renewal['text'] : '',
]));
break;
case 'credential':
$lead = '<span class="itflow-link-icon" aria-hidden="true"><i class="fas fa-fw fa-key"></i></span>';
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-url=\"modals/credential/credential_edit.php?id=$id\">$name</a>";
$detail = $row['credential_description'] ?: $row['credential_uri'];
break;
case 'location':
$lead = locationTile($row, 'sm');
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/location/location.php?id=$id\">$name</a>";
$detail = locationAddressText($row);
break;
case 'vendor':
$lead = vendorTile($row['name'], 'sm');
$link = "<a class=\"ajax-modal text-truncate\" href=\"#\" data-modal-size=\"lg\" data-modal-url=\"modals/vendor/vendor.php?id=$id\">$name</a>";
$detail = implode(' · ', array_filter([$row['vendor_description'], $row['vendor_phone'] ? formatPhoneNumber($row['vendor_phone'], $row['vendor_phone_country_code']) : '']));
break;
default:
return '';
}
$detail = (string) $detail !== '' ? '<div class="small text-body-secondary text-truncate">' . escapeHtml($detail) . '</div>' : '';
return "$lead<div class=\"min-w-0 flex-grow-1\"><div class=\"d-flex\">$link</div>$detail</div>";
}
/**
* Everything linked to one document or file, grouped by type - the "Linked"
* card on the document page and the section in the file and document quick
* looks. Each group has its count and, for an agent who may change it, a
* Link button and an unlink button on each row (shown on hover, like the copy
* buttons). Empty groups collapse to a single "Link a ..." line, so the card
* says what can be linked without a wall of empty headings.
*/
function linkGroupsHtml($from, $id, $client_id, $archived = false) {
$id = intval($id);
$groups = '';
$empty = [];
foreach (linkTypesFor($from) as $to) {
$entity = LINK_ENTITIES[$to];
$rows = linkedRecords($from, $id, $to);
$can_edit = !$archived && linkCanEdit($from, $to);
$link_url = "modals/link/link.php?from=$from&amp;id=$id&amp;to=$to";
if (!$rows) {
if ($can_edit) {
$empty[] = "<a class=\"ajax-modal btn btn-xs btn-outline-secondary\" href=\"#\" data-modal-url=\"$link_url\"><i class=\"fas {$entity['icon']} me-1\"></i>{$entity['plural']}</a>";
}
continue;
}
$items = '';
foreach ($rows as $row) {
$unlink = $can_edit
? '<a class="itflow-link-remove confirm-link" href="post.php?unlink_item&amp;from=' . $from . '&amp;id=' . $id . '&amp;to=' . $to . '&amp;other=' . intval($row['id']) . '&amp;csrf_token=' . escapeHtml($_SESSION['csrf_token'] ?? '') . '" title="Unlink ' . $entity['label'] . '"><i class="fas fa-unlink"></i></a>'
: '';
$items .= '<li class="itflow-link-row">' . linkRowHtml($to, $row, $client_id) . $unlink . '</li>';
}
$add = $can_edit ? "<a class=\"ajax-modal ms-auto small\" href=\"#\" data-modal-url=\"$link_url\" title=\"Link {$entity['label']}\"><i class=\"fas fa-plus me-1\"></i>Link</a>" : '';
$groups .= "<div class=\"itflow-link-group\"><div class=\"itflow-link-group-head\"><i class=\"fas fa-fw {$entity['icon']} me-1\"></i>{$entity['plural']}<span class=\"badge rounded-pill ms-1\">" . count($rows) . "</span>$add</div><ul class=\"list-unstyled mb-0\">$items</ul></div>";
}
if ($empty) {
$groups .= '<div class="itflow-link-empty"><span class="small text-body-secondary me-1">Link</span>' . implode(' ', $empty) . '</div>';
}
if ($groups === '') {
$groups = '<div class="small text-body-secondary">Nothing linked.</div>';
}
return "<div class=\"itflow-links\">$groups</div>";
}