['table' => 'assets', 'id' => 'asset_id', 'name' => 'asset_name', 'client' => 'asset_client_id', 'archived' => 'asset_archived_at', 'label' => 'asset', 'plural' => 'Assets', 'icon' => 'fa-desktop', 'view' => 'module_support'], 'contact' => ['table' => 'contacts', 'id' => 'contact_id', 'name' => 'contact_name', 'client' => 'contact_client_id', 'archived' => 'contact_archived_at', 'label' => 'contact', 'plural' => 'Contacts', 'icon' => 'fa-user', 'view' => 'module_client'], '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', 'view' => 'module_support'], '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', 'view' => 'module_client'], 'service' => ['table' => 'services', 'id' => 'service_id', 'name' => 'service_name', 'client' => 'service_client_id', 'archived' => 'service_archived_at', 'label' => 'service', 'plural' => 'Services', 'icon' => 'fa-stream', 'view' => 'module_support'], 'domain' => ['table' => 'domains', 'id' => 'domain_id', 'name' => 'domain_name', 'client' => 'domain_client_id', 'archived' => 'domain_archived_at', 'label' => 'domain', 'plural' => 'Domains', 'icon' => 'fa-globe', 'view' => 'module_support'], 'certificate' => ['table' => 'certificates', 'id' => 'certificate_id', 'name' => 'certificate_name', 'client' => 'certificate_client_id', 'archived' => 'certificate_archived_at', 'label' => 'certificate', 'plural' => 'Certificates', 'icon' => 'fa-lock', 'view' => 'module_support'], ]; /** * 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'], // What a service depends on - support documentation, its logins a credentials permission 'asset|service' => ['table' => 'service_assets', 'module' => 'module_support'], 'certificate|service' => ['table' => 'service_certificates', 'module' => 'module_support'], 'contact|service' => ['table' => 'service_contacts', 'module' => 'module_support'], 'credential|service' => ['table' => 'service_credentials', 'module' => 'module_credential'], 'document|service' => ['table' => 'service_documents', 'module' => 'module_support'], 'domain|service' => ['table' => 'service_domains', 'module' => 'module_support'], 'service|vendor' => ['table' => 'service_vendors', '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', 'service'], // What it runs on first, then what it needs to work, then who to call 'service' => SERVICE_LINK_TYPES, ]; $order = $orders[$from] ?? ['file', 'document', 'asset', 'contact', 'location', 'software', 'credential', 'vendor', 'service']; 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, credential_password_changed_at', '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', 'service' => 'service_description, service_category, service_importance, service_client_id', 'domain' => 'domain_description, domain_expire, domain_auto_renew', 'certificate' => 'certificate_domain, certificate_expire, certificate_issued_at, certificate_check_status, certificate_checked_at', ][$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; } /** The audit log entry a link change is filed under - the service, document, file or license side of it. */ function linkLogSide($from, $id, $to, $other_id) { foreach (['service', '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"); } } } /** A candidate as an option in a picker (itflow_select.js) - its icon or avatar, and a line under it. Rows as linkCandidates() gives them. */ function linkOptionData($to, $row, $client_id) { switch ($to) { case 'asset': return selectOptionAsset($row['asset_type'], trim($row['asset_make'] . ' ' . $row['asset_model'])); case 'contact': return selectOptionContact($row['contact_client_id'], $row['name'], $row['contact_photo'], $row['contact_title'], $row['id']); case 'file': $info = fileTypeInfo($row['file_ext']); return selectOptionData(['icon' => 'fas fa-' . $info['icon'], 'description' => ($row['folder_name'] ?: 'Top level') . ' · ' . formatBytes(intval($row['file_size'])), 'keywords' => $row['file_description']]); case 'document': return selectOptionData(['icon' => 'fas fa-file-alt', 'description' => $row['document_description'] ?: 'Updated ' . timeAgo($row['document_updated_at'] ?: $row['document_created_at'])]); case 'software': // How many seats are left, so a full license is seen before it is picked $seats = softwareSeats($row['seats_used'], $row['software_seats']); $seat_text = $seats['state'] === 'unlimited' ? '' : ($seats['state'] === 'over' || $seats['state'] === 'full' ? 'Full - ' . $seats['used'] . ' of ' . $seats['total'] . ' seats' : $seats['free'] . ' of ' . $seats['total'] . ' seats free'); return selectOptionData(['icon' => 'fas ' . softwareTypeStyle($row['software_type'])['icon'], 'description' => implode(' · ', array_filter([$row['software_license_type'], $seat_text, $row['software_version']]))]); case 'credential': return selectOptionCredential(['credential_name' => $row['name'], 'credential_description' => $row['credential_description'], 'credential_uri' => $row['credential_uri']], $row['credential_description'] ?: $row['credential_uri']); case 'location': return selectOptionData(['icon' => 'fas fa-' . (intval($row['location_primary']) ? 'building' : 'map-marker-alt'), 'description' => implode(' · ', array_filter([intval($row['location_primary']) ? 'Primary' : '', locationAddressText($row)]))]); case 'vendor': return selectOptionVendor($row['name'], $row['vendor_description']); case 'service': return selectOptionService($row); case 'domain': $expiry = domainExpiry($row['domain_expire'], $row['domain_auto_renew']); return selectOptionData(['icon' => 'fas fa-globe', 'description' => implode(' · ', array_filter([$row['domain_description'], $expiry ? $expiry['text'] : '']))]); case 'certificate': return selectOptionCertificate(['certificate_name' => $row['name']] + $row); } return ''; } /** * 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']); $extra = ''; switch ($to) { case 'asset': $lead = ''; $link = "$name"; $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 = "$name"; $detail = $row['contact_title']; break; case 'file': $lead = fileTypeTile($row['file_ext'], 'sm'); $link = "$name"; $detail = fileTypeInfo($row['file_ext'])['label'] . ($row['file_size'] ? ' · ' . formatBytes(intval($row['file_size'])) : ''); break; case 'document': $lead = documentTile('sm'); $link = "$name"; $detail = 'Updated ' . timeAgo($row['document_updated_at'] ?: $row['document_created_at']); break; case 'software': $lead = softwareTile($row['software_type'], 'sm'); $link = "$name"; $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': $credential = ['credential_name' => $row['name'], 'credential_description' => $row['credential_description'] ?? '', 'credential_uri' => $row['credential_uri'] ?? '', 'credential_username' => $row['credential_username'] ?? '']; $lead = credentialTile($credential, 'sm'); $link = "$name" . (intval($row['credential_favorite'] ?? 0) ? '' : ''); $detail = $row['credential_description'] ?: $row['credential_uri']; // The login itself - username to copy, the password and 2FA code on asking $username = credentialUsername($credential); $extra = '