Files
itflow/functions/document_template.php
johnnyq 32b5288338 Document templates stage 3, the client standard and reviews: a template can be required of every client or of clients with chosen tags (leads and archived clients not counted, a template whose tags are all deleted applying to none rather than every client), with the template page showing how many clients have one with a bar and the clients missing it each with a Make button, and Admin > Document Templates showing the count on each template plus a new Required tab; documents can be made for several clients at once, one per client filled in from its own client, for a picked list or every missing client in one click, with fill-ins answered once for all and clients that already have one skipped unless unticked; a client's Files shows the documents it should have along the top opening Document from template with that template picked, the client overview shows a Documents missing count, and Document from template lists missing templates first; a template can ask for its documents to be reviewed every 3 or 6 months, yearly or every 2 years, a document falling due when not edited or marked reviewed for that long, with the document page showing Review due or the next review and who did the last one plus a Mark reviewed button, a Review due tab in Files listing due documents across every folder, and due counts on the client overview, the template page and the list, documents from archived templates not counted; an older document written before its template can be linked to it from its page so it counts and follows the template's review schedule; migration 3.2.7; fixed tag merges dropping merged tags from ticket templates (merges now carry over ticket template and required template tags) and deleting a document template marking all its documents as updated; changelog updated
2026-10-18 19:49:20 -04:00

859 lines
48 KiB
PHP

<?php
/*
* ITFlow - document templates (Admin > Document Templates): documents ready
* to start a client's from - a runbook, a build sheet, a plan - with their
* images, kept in uploads/document_templates/<id>/. A document made from one
* gets a copy of what it says and its images, and remembers which template it
* came from (documents.document_template_id, from database 3.2.5 on), so each
* template's use is counted.
*
* As a document is made it is filled in: placeholders from the client -
* [Client], [Site], [Contact], [Date] and the rest (DOCUMENT_TEMPLATE_PLACEHOLDERS),
* the site and contact the ones picked - tables of the client's records as
* they are then - [Networks], [Servers], [Domains] (DOCUMENT_TEMPLATE_TABLES) -
* and the answers to what it asks for: any other [word] is a fill-in, asked
* as plain text unless set up on the template's page as a date, a choice from
* a list or yes or no, required or with help (document_template_fill_ins,
* from database 3.2.6 on). One left unanswered stays in the document.
*
* A template can say who should have a document from it - every client, or
* the ones with a client tag - and how often the documents made from it are
* reviewed (database 3.2.7). Its page shows which clients are missing one and
* which documents are due a review; a client's Files and overview say the
* same for that client. A document is reviewed when it is edited or marked as
* reviewed (documents.document_reviewed_at), and can be linked to the template
* it came from after the fact, so one written by hand counts too.
*
* Shared by admin/document_templates.php, admin/document_template.php,
* admin/modals/document_template/*, admin/post/document_template.php and the
* New document from a template window and its handler on the agent side
* (agent/modals/document/document_add_from_template.php, agent/post/document.php).
*/
/** Where a template's images are kept, from the app's root - its folder under uploads/document_templates/. */
function documentTemplateDir($document_template_id) {
return dirname(__DIR__) . '/uploads/document_templates/' . intval($document_template_id);
}
/** One template, archived or not, with who made it and last changed it - or null. */
function documentTemplateRow($document_template_id) {
global $mysqli;
$document_template_id = intval($document_template_id);
if ($document_template_id <= 0) {
return null;
}
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT document_template_id, document_template_name, document_template_description, document_template_content,
document_template_required, document_template_review_months, document_template_created_at, document_template_updated_at, document_template_archived_at, document_template_created_by, document_template_updated_by,
creator.user_name AS created_by_name, updater.user_name AS updated_by_name
FROM document_templates
LEFT JOIN users creator ON creator.user_id = document_template_created_by AND document_template_created_by > 0
LEFT JOIN users updater ON updater.user_id = document_template_updated_by AND document_template_updated_by > 0
WHERE document_template_id = $document_template_id"));
return $row ?: null;
}
/*
* How the templates have been used - template id => count (documents made from
* it that are not archived), clients (how many clients have one) and last (when
* the latest was made). Ones not used aren't in it.
*/
function documentTemplateUsage(array $document_template_ids) {
global $mysqli;
$ids = array_values(array_filter(array_map('intval', $document_template_ids)));
$usage = [];
if (!$ids) {
return $usage;
}
$sql = mysqli_query($mysqli, "SELECT document_template_id, COUNT(*) AS n, COUNT(DISTINCT document_client_id) AS clients, MAX(document_created_at) AS last
FROM documents WHERE document_template_id IN (" . implode(',', $ids) . ") AND document_archived_at IS NULL GROUP BY document_template_id");
while ($row = mysqli_fetch_assoc($sql)) {
$usage[intval($row['document_template_id'])] = ['count' => intval($row['n']), 'clients' => intval($row['clients']), 'last' => $row['last']];
}
return $usage;
}
/*
* The template window's fields, checked - or null with $error saying why.
* $post: name, description (one line on what it is for) and content (the
* editor's HTML, kept as written - it is made safe where it is shown).
*/
function documentTemplateFields(array $post, &$error = '') {
$error = '';
$name = trim(preg_replace('/\s+/u', ' ', strip_tags((string) ($post['name'] ?? ''))));
if ($name === '') {
$error = 'A document template needs a name';
return null;
}
if (mb_strlen($name) > 200) {
$error = 'Keep the name to 200 characters';
return null;
}
$description = trim(preg_replace('/\s+/u', ' ', strip_tags((string) ($post['description'] ?? ''))));
if (mb_strlen($description) > 500) {
$error = 'Keep the summary to 500 characters';
return null;
}
return ['name' => $name, 'description' => $description, 'content' => (string) ($post['content'] ?? '')];
}
/*
* Save a template's fields (documentTemplateFields()) - a new one with
* $document_template_id 0. Pasted-in images are kept in its folder, and on a
* change the ones it no longer shows are taken away. Its id.
*/
function documentTemplateSave($document_template_id, array $f) {
global $mysqli, $session_user_id;
$document_template_id = intval($document_template_id);
$user_id = intval($session_user_id ?? 0);
$set = "document_template_name = '" . escapeSql($f['name']) . "', document_template_description = '" . escapeSql($f['description']) . "'";
$new = !$document_template_id;
if ($new) {
// Made first, so its images have a folder named for it
mysqli_query($mysqli, "INSERT INTO document_templates SET $set, document_template_content = '', document_template_created_by = $user_id");
$document_template_id = intval(mysqli_insert_id($mysqli));
}
$content = saveBase64Images($f['content'], dirname(__DIR__) . '/uploads/document_templates/', 'uploads/document_templates/', $document_template_id);
cleanupUnusedImages($content, documentTemplateDir($document_template_id), '/uploads/document_templates/' . $document_template_id);
// A new one hasn't been changed yet - its content going in isn't a change
mysqli_query($mysqli, "UPDATE document_templates SET $set, document_template_content = '" . mysqli_real_escape_string($mysqli, $content) . "',
" . ($new ? 'document_template_updated_at = NULL' : "document_template_updated_by = $user_id") . " WHERE document_template_id = $document_template_id");
return $document_template_id;
}
/*
* A template's HTML with its images pointed at another folder of copies - for a
* copy of the template, or a document made from it. $to_web is the new
* folder's web path ('/uploads/documents/12/').
*/
function documentTemplateCopyImages($document_template_id, $to_dir, $to_web, $html) {
$document_template_id = intval($document_template_id);
copyDirectory(documentTemplateDir($document_template_id), $to_dir);
return str_replace("/uploads/document_templates/$document_template_id/", $to_web, (string) $html);
}
/*
* A new template starting from this one - "Copy of", with copies of its images
* and its fill-ins, and how often its documents are reviewed. A copy isn't
* required of anyone until it is set up to be (its tags come along for that). Its id, or 0.
*/
function documentTemplateCopy($document_template_id) {
global $mysqli, $session_user_id;
$row = documentTemplateRow($document_template_id);
if (!$row) {
return 0;
}
$user_id = intval($session_user_id ?? 0);
mysqli_query($mysqli, "INSERT INTO document_templates SET document_template_name = '" . escapeSql(mb_substr('Copy of ' . $row['document_template_name'], 0, 200)) . "',
document_template_description = '" . escapeSql((string) $row['document_template_description']) . "', document_template_content = '',
document_template_review_months = " . intval($row['document_template_review_months']) . ", document_template_created_by = $user_id");
$new_id = intval(mysqli_insert_id($mysqli));
mysqli_query($mysqli, "INSERT INTO document_template_required_tags (document_template_id, tag_id)
SELECT $new_id, tag_id FROM document_template_required_tags WHERE document_template_id = " . intval($row['document_template_id']));
$content = documentTemplateCopyImages($row['document_template_id'], documentTemplateDir($new_id), "/uploads/document_templates/$new_id/", $row['document_template_content']);
mysqli_query($mysqli, "UPDATE document_templates SET document_template_content = '" . mysqli_real_escape_string($mysqli, $content) . "', document_template_updated_at = NULL WHERE document_template_id = $new_id");
// And what it asks for
mysqli_query($mysqli, "INSERT INTO document_template_fill_ins (document_fill_in_document_template_id, document_fill_in_name, document_fill_in_type, document_fill_in_options, document_fill_in_required, document_fill_in_help, document_fill_in_order)
SELECT $new_id, document_fill_in_name, document_fill_in_type, document_fill_in_options, document_fill_in_required, document_fill_in_help, document_fill_in_order
FROM document_template_fill_ins WHERE document_fill_in_document_template_id = " . intval($row['document_template_id']));
return $new_id;
}
/* Delete a template and its folder of images. The documents made from it stay, no longer linked to it. */
function documentTemplateDelete($document_template_id) {
global $mysqli;
$document_template_id = intval($document_template_id);
mysqli_query($mysqli, "DELETE FROM document_templates WHERE document_template_id = $document_template_id");
mysqli_query($mysqli, "UPDATE documents SET document_template_id = 0, document_updated_at = document_updated_at WHERE document_template_id = $document_template_id");
removeDirectory(documentTemplateDir($document_template_id));
}
/*
* Make a client's document from a template - what it says and its images,
* copied, in $folder_id, called $name (the template's when '') - remembering
* the template. Filled in for the client (documentTemplateFill()): its
* placeholders - the site and contact the ones picked ($location_id,
* $contact_id), or the client's primary ones - its tables of records, and
* the answers to its fill-ins ($answers, from ticketFillInAnswers()); the
* name and summary too. The new document's id, or 0 when the template is gone
* or archived.
*/
function documentTemplateMakeDocument($document_template_id, $client_id, $name = '', $description = '', $folder_id = 0, $location_id = 0, $contact_id = 0, array $answers = []) {
global $mysqli, $session_user_id, $session_name;
$row = documentTemplateRow($document_template_id);
if (!$row || $row['document_template_archived_at']) {
return 0;
}
$template_id = intval($row['document_template_id']);
$client_id = intval($client_id);
$values = documentTemplateValues($client_id, $location_id, $contact_id, stripslashes((string) ($session_name ?? '')));
foreach ($answers as $answer) {
$text = ticketFillInText($answer);
if ($text !== '') {
$values[strtolower($answer['name'])] = $values[strtolower($answer['name'])] ?? $text;
}
}
$name = mb_substr(ticketPlaceholderFill(trim((string) $name) !== '' ? trim((string) $name) : (string) $row['document_template_name'], $values), 0, 200);
$description = ticketPlaceholderFill(trim((string) $description) !== '' ? trim((string) $description) : (string) $row['document_template_description'], $values);
mysqli_query($mysqli, "INSERT INTO documents SET document_name = '" . escapeSql($name) . "', document_description = '" . escapeSql($description) . "',
document_content = '', document_content_raw = '', document_folder_id = " . intval($folder_id) . ", document_created_by = " . intval($session_user_id ?? 0) . ",
document_client_id = $client_id, document_template_id = $template_id");
$document_id = intval(mysqli_insert_id($mysqli));
// The template's images come along, and its HTML is pointed at the copies - then it is filled in
$html = documentTemplateCopyImages($template_id, dirname(__DIR__) . "/uploads/documents/$document_id", "/uploads/documents/$document_id/", $row['document_template_content']);
$html = documentTemplateFill($html, $client_id, $values);
mysqli_query($mysqli, "UPDATE documents SET document_content = '" . mysqli_real_escape_string($mysqli, $html) . "',
document_content_raw = '" . escapeSql(documentContentRaw($name, $html)) . "' WHERE document_id = $document_id");
return $document_id;
}
/* What a template says, as a line of plain text - for the lists. */
function documentTemplatePreview($html, $length = 160) {
$text = trim(preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags(str_replace('<', ' <', (string) $html)), ENT_QUOTES, 'UTF-8')));
return mb_strimwidth($text, 0, $length, '...');
}
/* The words in square brackets in a template's text - [Client], [Date of visit] - each once, as written first. */
function documentTemplateBracketsIn($html) {
$text = html_entity_decode(strip_tags(str_replace('<', ' <', (string) $html)), ENT_QUOTES, 'UTF-8');
preg_match_all('/\[([A-Za-z][A-Za-z0-9 _\-\/\x{00A0}]{0,40})\]/u', $text, $matches);
$found = [];
foreach ($matches[1] as $word) {
$word = trim(preg_replace('/[\s\x{00A0}]+/u', ' ', $word));
if ($word !== '' && !isset($found[mb_strtolower($word)])) {
$found[mb_strtolower($word)] = $word;
}
}
return array_values($found);
}
/*
* A template's HTML (already made safe) with its [words] marked, in its text
* only - for its page: a placeholder grey, a table of records teal, a fill-in blue.
*/
function documentTemplateMarkBrackets($html) {
$tones = [
'placeholder' => 'text-bg-light border',
'table' => 'text-info-emphasis bg-info-subtle border border-info-subtle',
'fill_in' => 'text-primary-emphasis bg-primary-subtle border border-primary-subtle',
];
$pieces = preg_split('/(<[^>]*>)/', (string) $html, -1, PREG_SPLIT_DELIM_CAPTURE);
foreach ($pieces as $i => $piece) {
if ($i % 2 === 0 && $piece !== '') {
$pieces[$i] = preg_replace_callback('/\[([A-Za-z][A-Za-z0-9 _\-\/]{0,40})\]/', function ($match) use ($tones) {
return '<span class="badge rounded-pill ' . $tones[documentTemplateWordKind($match[1])] . ' font-monospace fw-normal">[' . $match[1] . ']</span>';
}, $piece);
}
}
return implode('', $pieces);
}
/* ---------------------------------------------------------------- filled in as a document is made */
/** The placeholders filled in from the client as a document is made - what each becomes. */
const DOCUMENT_TEMPLATE_PLACEHOLDERS = [
'Client' => "the client's name",
'Website' => "the client's website",
'Site' => 'the site picked - its name',
'Address' => "the site's address, on one line",
'Site Phone' => "the site's phone number",
'Contact' => 'the contact picked - their name',
'First Name' => "the contact's first name",
'Contact Email' => "the contact's email",
'Contact Phone' => "the contact's phone number",
'Agent' => 'you, making it',
'Date' => "today's date",
'Month' => 'the month - October 2026',
'Quarter' => 'the quarter - Q4 2026',
'Year' => 'the year',
];
/** The placeholders that come from the site picked, and from the contact picked - lowercase. */
const DOCUMENT_TEMPLATE_SITE_PLACEHOLDERS = ['site', 'address', 'site phone'];
const DOCUMENT_TEMPLATE_CONTACT_PLACEHOLDERS = ['contact', 'first name', 'contact email', 'contact phone'];
/*
* The client's records that go in as a table, as they are when the document is
* made - what each holds. On a line of its own, [Networks] becomes the table;
* in a sentence, their names.
*/
const DOCUMENT_TEMPLATE_TABLES = [
'Contacts' => 'the contacts - name, title, email and phone',
'Locations' => 'the sites - name, address, phone and hours',
'Assets' => 'every asset - name, type, make and model, serial, site and who has it',
'Servers' => 'servers and virtual machines - name, operating system, IP address and site',
'Workstations' => 'desktops and laptops - name, make and model, serial and who has it',
'Network Equipment' => 'firewalls, routers, switches and access points - name, make and model, IP address and site',
'Printers' => 'printers - name, make and model, IP address and site',
'Networks' => 'networks - name, VLAN, network, gateway, DHCP range and site',
'Domains' => 'domains - name, registrar and when it expires',
'Certificates' => 'certificates - name, domain, issuer and when it expires',
'Licenses' => 'licenses - name, type, seats and when it expires',
'Vendors' => 'vendors - name, phone, email, website and account number',
'Services' => 'services - name, category and importance',
];
/*
* A fill-in's name, tidied - as it goes in square brackets - or '' when it
* can't be one: the placeholders and the tables of records are taken.
*/
function documentFillInName($name) {
$name = trim(preg_replace('/\s+/', ' ', strip_tags((string) $name)), " []");
$taken = array_change_key_case(DOCUMENT_TEMPLATE_PLACEHOLDERS + DOCUMENT_TEMPLATE_TABLES);
if (!preg_match('/^' . TICKET_PLACEHOLDER_NAME . '$/', $name) || isset($taken[strtolower($name)])) {
return '';
}
return $name;
}
/* What a [word] is in a document template - 'placeholder', 'table' or 'fill_in'. */
function documentTemplateWordKind($word) {
$word = strtolower(preg_replace('/\s+/', ' ', trim((string) $word)));
if (isset(array_change_key_case(DOCUMENT_TEMPLATE_PLACEHOLDERS)[$word])) {
return 'placeholder';
}
return isset(array_change_key_case(DOCUMENT_TEMPLATE_TABLES)[$word]) ? 'table' : 'fill_in';
}
/*
* The fill-ins set up on templates - [template id => [['id', 'name', 'type',
* 'options' => [..], 'required' => bool, 'help'], ...]], in their order.
*/
function documentTemplateFillInRowsFor(array $document_template_ids) {
global $mysqli;
$ids = array_values(array_filter(array_map('intval', $document_template_ids)));
$rows = array_fill_keys($ids, []);
if (!$ids) {
return $rows;
}
$sql = mysqli_query($mysqli, "SELECT document_fill_in_id, document_fill_in_document_template_id, document_fill_in_name, document_fill_in_type, document_fill_in_options, document_fill_in_required, document_fill_in_help
FROM document_template_fill_ins WHERE document_fill_in_document_template_id IN (" . implode(',', $ids) . ") ORDER BY document_fill_in_order ASC, document_fill_in_id ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[intval($row['document_fill_in_document_template_id'])][] = [
'id' => intval($row['document_fill_in_id']),
'name' => (string) $row['document_fill_in_name'],
'type' => isset(TICKET_FILL_IN_TYPES[$row['document_fill_in_type']]) ? $row['document_fill_in_type'] : 'text',
'options' => ticketFillInOptions($row['document_fill_in_options']),
'required' => (bool) intval($row['document_fill_in_required']),
'help' => (string) $row['document_fill_in_help'],
];
}
return $rows;
}
/* The words in square brackets in a template's name, summary and what it says (documentTemplateBracketsIn()). */
function documentTemplateBracketsInAll(array $template) {
return documentTemplateBracketsIn(escapeHtml($template['document_template_name'] ?? '') . "\n" . escapeHtml($template['document_template_description'] ?? '') . "\n" . ($template['document_template_content'] ?? ''));
}
/*
* Everything a template asks for when a document is made, in order - the ones
* set up (their rows), then the other bracketed words in its name, summary
* and what it says that aren't placeholders or tables, as plain optional
* text. Each ['id' (0 when not set up), 'name', 'type', 'options', 'required',
* 'help', 'in_text'].
*/
function documentTemplateFillIns(array $template, array $rows) {
$in_text = [];
foreach (documentTemplateBracketsInAll($template) as $name) {
if (documentTemplateWordKind($name) === 'fill_in') {
$in_text[strtolower($name)] = $name;
}
}
$fill_ins = [];
foreach ($rows as $row) {
$fill_ins[strtolower($row['name'])] = $row + ['in_text' => isset($in_text[strtolower($row['name'])])];
}
foreach ($in_text as $key => $name) {
$fill_ins[$key] = $fill_ins[$key] ?? ['id' => 0, 'name' => $name, 'type' => 'text', 'options' => [], 'required' => false, 'help' => '', 'in_text' => true];
}
return array_values($fill_ins);
}
/** Everything one template asks for (documentTemplateFillIns()) - [] when it is gone. */
function documentTemplateFillInsFor($document_template_id) {
$template = documentTemplateRow($document_template_id);
if (!$template) {
return [];
}
$id = intval($template['document_template_id']);
return documentTemplateFillIns($template, documentTemplateFillInRowsFor([$id])[$id]);
}
/*
* A fill-in renamed: its [Name] in the template's name, summary and what it
* says goes with it. The new name holds nothing HTML could misread
* (documentFillInName()).
*/
function documentTemplateRenameFillIn($document_template_id, $old, $new) {
global $mysqli;
$template = documentTemplateRow($document_template_id);
if (!$template || $old === $new) {
return;
}
$rename = [strtolower($old) => "[$new]"];
mysqli_query($mysqli, "UPDATE document_templates SET
document_template_name = '" . escapeSql(ticketPlaceholderFill($template['document_template_name'], $rename)) . "',
document_template_description = '" . escapeSql(ticketPlaceholderFill($template['document_template_description'], $rename)) . "',
document_template_content = '" . mysqli_real_escape_string($mysqli, ticketPlaceholderFill($template['document_template_content'], $rename)) . "'
WHERE document_template_id = " . intval($template['document_template_id']));
}
/* The placeholders, tables and fill-ins a template uses, in its name, summary and what it says - lowercase name => name, by kind. */
function documentTemplateWordsUsed(array $template) {
$used = ['placeholder' => [], 'table' => [], 'fill_in' => []];
foreach (documentTemplateBracketsInAll($template) as $word) {
$used[documentTemplateWordKind($word)][strtolower($word)] = $word;
}
return $used;
}
/** A client's current sites, the primary one first - [id => row]. */
function documentTemplateClientLocations($client_id) {
global $mysqli;
$rows = [];
$sql = mysqli_query($mysqli, "SELECT location_id, location_name, location_address, location_city, location_state, location_zip, location_country, location_phone_country_code, location_phone, location_hours, location_primary
FROM locations WHERE location_client_id = " . intval($client_id) . " AND location_archived_at IS NULL ORDER BY location_primary DESC, location_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[intval($row['location_id'])] = $row;
}
return $rows;
}
/** A client's current contacts, the primary one first - [id => row]. */
function documentTemplateClientContacts($client_id) {
global $mysqli;
$rows = [];
$sql = mysqli_query($mysqli, "SELECT contact_id, contact_name, contact_title, contact_email, contact_phone_country_code, contact_phone, contact_extension, contact_mobile_country_code, contact_mobile, contact_primary
FROM contacts WHERE contact_client_id = " . intval($client_id) . " AND contact_archived_at IS NULL ORDER BY contact_primary DESC, contact_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$rows[intval($row['contact_id'])] = $row;
}
return $rows;
}
/*
* What the placeholders become for a client - lowercase name => value, raw;
* '' for what it doesn't have. The site and contact are the ones picked when
* they are the client's, none for -1 (- None - picked), else its primary
* ones. $agent_name: who is making it.
*/
function documentTemplateValues($client_id, $location_id, $contact_id, $agent_name) {
global $mysqli;
$client = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT client_name, client_website FROM clients WHERE client_id = " . intval($client_id)));
if (!$client) {
return [];
}
$locations = documentTemplateClientLocations($client_id);
$location = intval($location_id) < 0 ? null : ($locations[intval($location_id)] ?? (reset($locations) ?: null));
$contacts = documentTemplateClientContacts($client_id);
$contact = intval($contact_id) < 0 ? null : ($contacts[intval($contact_id)] ?? (reset($contacts) ?: null));
$month = intval(date('n'));
$contact_name = trim((string) ($contact['contact_name'] ?? ''));
return [
'client' => (string) $client['client_name'],
'website' => (string) $client['client_website'],
'site' => (string) ($location['location_name'] ?? ''),
'address' => $location ? formatAddress($location['location_address'], $location['location_city'], $location['location_state'], $location['location_zip'], $location['location_country'], ', ') : '',
'site phone' => $location && trim((string) $location['location_phone']) !== '' ? (string) formatPhoneNumber($location['location_phone'], $location['location_phone_country_code']) : '',
'contact' => $contact_name,
'first name' => $contact_name !== '' ? preg_split('/\s+/u', $contact_name)[0] : '',
'contact email' => (string) ($contact['contact_email'] ?? ''),
'contact phone' => $contact && trim((string) $contact['contact_phone']) !== '' ? (string) formatPhoneNumber($contact['contact_phone'], $contact['contact_phone_country_code'])
: ($contact && trim((string) $contact['contact_mobile']) !== '' ? (string) formatPhoneNumber($contact['contact_mobile'], $contact['contact_mobile_country_code']) : ''),
'agent' => (string) $agent_name,
'date' => date('M j, Y'),
'month' => date('F Y'),
'quarter' => 'Q' . intval(ceil($month / 3)) . ' ' . date('Y'),
'year' => date('Y'),
];
}
/*
* One of DOCUMENT_TEMPLATE_TABLES for a client, as it is now - ['head' =>
* [column labels], 'rows' => [[cell text, ...], ...], 'names' => [the
* records' names]]. Raw text. Null for no such table.
*/
function documentTemplateTableData($table, $client_id) {
global $mysqli;
$client_id = intval($client_id);
$date = function ($value) {
return $value && strtotime($value) ? date('M j, Y', strtotime($value)) : '';
};
$phone = function ($number, $code) {
return trim((string) $number) !== '' ? (string) formatPhoneNumber($number, $code) : '';
};
$assets = function (array $types, array $columns) use ($mysqli, $client_id) {
$where = $types ? " AND asset_type IN ('" . implode("','", array_map('escapeSql', $types)) . "')" : '';
$sql = mysqli_query($mysqli, "SELECT asset_name, asset_type, asset_make, asset_model, asset_serial, asset_os, location_name, contact_name,
(SELECT interface_ip FROM asset_interfaces WHERE interface_asset_id = asset_id AND interface_archived_at IS NULL ORDER BY interface_primary DESC, interface_id ASC LIMIT 1) AS ip
FROM assets
LEFT JOIN locations ON location_id = asset_location_id AND asset_location_id > 0
LEFT JOIN contacts ON contact_id = asset_contact_id AND asset_contact_id > 0
WHERE asset_client_id = $client_id AND asset_archived_at IS NULL$where ORDER BY asset_name ASC");
$labels = ['name' => 'Name', 'type' => 'Type', 'model' => 'Make and model', 'serial' => 'Serial', 'os' => 'Operating system', 'ip' => 'IP address', 'site' => 'Site', 'who' => 'Assigned to'];
$data = ['head' => array_map(function ($column) use ($labels) {
return $labels[$column];
}, $columns), 'rows' => [], 'names' => []];
while ($row = mysqli_fetch_assoc($sql)) {
$cells = ['name' => $row['asset_name'], 'type' => $row['asset_type'], 'model' => trim($row['asset_make'] . ' ' . $row['asset_model']), 'serial' => $row['asset_serial'],
'os' => $row['asset_os'], 'ip' => $row['ip'], 'site' => $row['location_name'], 'who' => $row['contact_name']];
$data['rows'][] = array_map(function ($column) use ($cells) {
return (string) ($cells[$column] ?? '');
}, $columns);
$data['names'][] = (string) $row['asset_name'];
}
return $data;
};
$rows = function ($sql, array $head, callable $cells, $name_key) use ($mysqli) {
$data = ['head' => $head, 'rows' => [], 'names' => []];
$result = mysqli_query($mysqli, $sql);
while ($row = mysqli_fetch_assoc($result)) {
$data['rows'][] = array_map('strval', $cells($row));
$data['names'][] = (string) $row[$name_key];
}
return $data;
};
switch ($table) {
case 'contacts':
return $rows("SELECT contact_name, contact_title, contact_email, contact_phone, contact_phone_country_code, contact_mobile, contact_mobile_country_code, contact_primary FROM contacts
WHERE contact_client_id = $client_id AND contact_archived_at IS NULL ORDER BY contact_primary DESC, contact_name ASC", ['Name', 'Title', 'Email', 'Phone', 'Mobile'], function ($r) use ($phone) {
return [$r['contact_name'] . (intval($r['contact_primary']) ? ' (primary)' : ''), $r['contact_title'], $r['contact_email'], $phone($r['contact_phone'], $r['contact_phone_country_code']), $phone($r['contact_mobile'], $r['contact_mobile_country_code'])];
}, 'contact_name');
case 'locations':
return $rows("SELECT location_name, location_address, location_city, location_state, location_zip, location_country, location_phone, location_phone_country_code, location_hours, location_primary FROM locations
WHERE location_client_id = $client_id AND location_archived_at IS NULL ORDER BY location_primary DESC, location_name ASC", ['Site', 'Address', 'Phone', 'Hours'], function ($r) use ($phone) {
return [$r['location_name'] . (intval($r['location_primary']) ? ' (primary)' : ''), formatAddress($r['location_address'], $r['location_city'], $r['location_state'], $r['location_zip'], $r['location_country'], ', '),
$phone($r['location_phone'], $r['location_phone_country_code']), $r['location_hours']];
}, 'location_name');
case 'assets':
return $assets([], ['name', 'type', 'model', 'serial', 'site', 'who']);
case 'servers':
return $assets(['Server', 'Virtual Machine'], ['name', 'type', 'os', 'ip', 'site']);
case 'workstations':
return $assets(['Desktop', 'Laptop'], ['name', 'type', 'model', 'serial', 'who']);
case 'network equipment':
return $assets(['Firewall/Router', 'Switch', 'Access Point'], ['name', 'type', 'model', 'ip', 'site']);
case 'printers':
return $assets(['Printer'], ['name', 'model', 'ip', 'site']);
case 'networks':
return $rows("SELECT network_name, network_vlan, network, network_gateway, network_dhcp_range, location_name FROM networks
LEFT JOIN locations ON location_id = network_location_id AND network_location_id > 0
WHERE network_client_id = $client_id AND network_archived_at IS NULL ORDER BY network_name ASC", ['Name', 'VLAN', 'Network', 'Gateway', 'DHCP range', 'Site'], function ($r) {
return [$r['network_name'], $r['network_vlan'], $r['network'], $r['network_gateway'], $r['network_dhcp_range'], $r['location_name']];
}, 'network_name');
case 'domains':
return $rows("SELECT domain_name, vendor_name, domain_expire FROM domains
LEFT JOIN vendors ON vendor_id = domain_registrar AND domain_registrar > 0
WHERE domain_client_id = $client_id AND domain_archived_at IS NULL ORDER BY domain_name ASC", ['Domain', 'Registrar', 'Expires'], function ($r) use ($date) {
return [$r['domain_name'], $r['vendor_name'], $date($r['domain_expire'])];
}, 'domain_name');
case 'certificates':
return $rows("SELECT certificate_name, certificate_domain, certificate_issued_by, certificate_expire FROM certificates
WHERE certificate_client_id = $client_id AND certificate_archived_at IS NULL ORDER BY certificate_name ASC", ['Name', 'Domain', 'Issued by', 'Expires'], function ($r) use ($date) {
return [$r['certificate_name'], $r['certificate_domain'], $r['certificate_issued_by'], $date($r['certificate_expire'])];
}, 'certificate_name');
case 'licenses':
return $rows("SELECT software_name, software_type, software_license_type, software_seats, software_expire FROM software
WHERE software_client_id = $client_id AND software_archived_at IS NULL ORDER BY software_name ASC", ['Name', 'Type', 'License', 'Seats', 'Expires'], function ($r) use ($date) {
return [$r['software_name'], $r['software_type'], $r['software_license_type'], $r['software_seats'], $date($r['software_expire'])];
}, 'software_name');
case 'vendors':
return $rows("SELECT vendor_name, vendor_phone, vendor_phone_country_code, vendor_email, vendor_website, vendor_account_number FROM vendors
WHERE vendor_client_id = $client_id AND vendor_archived_at IS NULL ORDER BY vendor_name ASC", ['Name', 'Phone', 'Email', 'Website', 'Account number'], function ($r) use ($phone) {
return [$r['vendor_name'], $phone($r['vendor_phone'], $r['vendor_phone_country_code']), $r['vendor_email'], $r['vendor_website'], $r['vendor_account_number']];
}, 'vendor_name');
case 'services':
return $rows("SELECT service_name, service_category, service_importance FROM services
WHERE service_client_id = $client_id AND service_archived_at IS NULL ORDER BY service_name ASC", ['Name', 'Category', 'Importance'], function ($r) {
return [$r['service_name'], $r['service_category'], $r['service_importance']];
}, 'service_name');
}
return null;
}
/* A table of records (documentTemplateTableData()) as HTML - with one empty row to fill in when there are none. */
function documentTemplateTableHtml(array $data) {
$html = '<table style="width:100%" border="1"><tbody><tr>';
foreach ($data['head'] as $label) {
$html .= '<th>' . escapeHtml($label) . '</th>';
}
$html .= '</tr>';
foreach ($data['rows'] ?: [array_fill(0, count($data['head']), '')] as $cells) {
$html .= '<tr>';
foreach ($cells as $cell) {
$html .= '<td>' . escapeHtml($cell) . '</td>';
}
$html .= '</tr>';
}
return $html . '</tbody></table>';
}
/*
* What a template says, filled in for a client: its tables of records - a
* [Networks] on a line of its own becomes the table, one in a sentence their
* names - then its placeholders and the fill-ins' answers ($values, lowercase
* name => value). A [word] with nothing to put in stays as it is.
*/
function documentTemplateFill($html, $client_id, array $values) {
$html = (string) $html;
$space = '(?:\s|&nbsp;|\x{00A0})';
foreach (array_keys(DOCUMENT_TEMPLATE_TABLES) as $table) {
$words = implode($space . '+', array_map(function ($word) {
return preg_quote($word, '/');
}, explode(' ', $table)));
if (!preg_match('/\[' . $words . '\]/iu', $html)) {
continue;
}
$data = documentTemplateTableData(strtolower($table), $client_id);
// On a line of its own - a paragraph or heading holding nothing else - the table
$html = preg_replace_callback('/<(p|div|h[1-6])\b[^>]*>' . $space . '*\[' . $words . '\]' . $space . '*<\/\1>/iu', function () use ($data) {
return documentTemplateTableHtml($data);
}, $html);
// In a sentence, the names
$html = preg_replace_callback('/\[' . $words . '\]/iu', function () use ($data) {
return escapeHtml($data['names'] ? implode(', ', $data['names']) : 'none');
}, $html);
}
return ticketPlaceholderFill($html, $values, true);
}
/* ---------------------------------------------------------------- who should have one, and reviews */
/** Who should have a document from a template (document_template_required). */
const DOCUMENT_TEMPLATE_REQUIRED_NONE = 0;
const DOCUMENT_TEMPLATE_REQUIRED_EVERY = 1;
const DOCUMENT_TEMPLATE_REQUIRED_TAGGED = 2;
/** How often the documents made from a template are reviewed - months => what it says; 0 never. */
const DOCUMENT_TEMPLATE_REVIEW_MONTHS = [0 => 'Never', 3 => 'Every 3 months', 6 => 'Every 6 months', 12 => 'Every year', 24 => 'Every 2 years'];
/** The client tags templates are required by - [template id => [tag id => tag name]], in name order. */
function documentTemplateRequiredTags(array $document_template_ids) {
global $mysqli;
$ids = array_values(array_filter(array_map('intval', $document_template_ids)));
$tags = array_fill_keys($ids, []);
if (!$ids) {
return $tags;
}
$sql = mysqli_query($mysqli, "SELECT document_template_id, tags.tag_id, tag_name FROM document_template_required_tags
JOIN tags ON tags.tag_id = document_template_required_tags.tag_id
WHERE document_template_id IN (" . implode(',', $ids) . ") ORDER BY tag_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$tags[intval($row['document_template_id'])][intval($row['tag_id'])] = (string) $row['tag_name'];
}
return $tags;
}
/*
* Set who should have a document from a template - $required one of the
* DOCUMENT_TEMPLATE_REQUIRED_ values, $tag_ids the client tags for TAGGED -
* and how often its documents are reviewed. Not a change to what it says, so
* when it last changed stays as it was.
*/
function documentTemplateSetRequired($document_template_id, $required, array $tag_ids, $review_months) {
global $mysqli;
$document_template_id = intval($document_template_id);
$required = in_array(intval($required), [DOCUMENT_TEMPLATE_REQUIRED_EVERY, DOCUMENT_TEMPLATE_REQUIRED_TAGGED], true) ? intval($required) : DOCUMENT_TEMPLATE_REQUIRED_NONE;
$review_months = isset(DOCUMENT_TEMPLATE_REVIEW_MONTHS[intval($review_months)]) ? intval($review_months) : 0;
mysqli_query($mysqli, "UPDATE document_templates SET document_template_required = $required, document_template_review_months = $review_months,
document_template_updated_at = document_template_updated_at WHERE document_template_id = $document_template_id");
// Client tags only - any others are left out
$tag_ids = array_values(array_unique(array_filter(array_map('intval', $tag_ids))));
mysqli_query($mysqli, "DELETE FROM document_template_required_tags WHERE document_template_id = $document_template_id");
if ($tag_ids) {
mysqli_query($mysqli, "INSERT INTO document_template_required_tags (document_template_id, tag_id)
SELECT $document_template_id, tag_id FROM tags WHERE tag_type = 1 AND tag_id IN (" . implode(',', $tag_ids) . ")");
}
}
/*
* The clients a template is required of, as SQL on clients: current clients,
* not leads - every one, or the ones with any of its tags (none when it has
* no tags left, or isn't required).
*/
function documentTemplateScopeSql($required, array $tag_ids) {
$sql = "clients.client_archived_at IS NULL AND clients.client_lead = 0";
if (intval($required) === DOCUMENT_TEMPLATE_REQUIRED_EVERY) {
return $sql;
}
$tag_ids = array_values(array_filter(array_map('intval', $tag_ids)));
if (intval($required) !== DOCUMENT_TEMPLATE_REQUIRED_TAGGED || !$tag_ids) {
return '0';
}
return "$sql AND EXISTS (SELECT 1 FROM client_tags WHERE client_tags.client_id = clients.client_id AND client_tags.tag_id IN (" . implode(',', $tag_ids) . "))";
}
/** Who a template is required of, in words - '' when it isn't. */
function documentTemplateScopeText($required, array $tags) {
if (intval($required) === DOCUMENT_TEMPLATE_REQUIRED_EVERY) {
return 'Every client';
}
if (intval($required) !== DOCUMENT_TEMPLATE_REQUIRED_TAGGED) {
return '';
}
return $tags ? 'Clients tagged ' . implode(' or ', $tags) : 'Clients with a tag - none picked';
}
/*
* How far a required template reaches - ['clients' => how many it is
* required of, 'covered' => how many of them have a current document from
* it, 'missing' => [client id => name] the rest, by name].
*/
function documentTemplateCoverage($document_template_id, $required, array $tag_ids) {
global $mysqli;
$document_template_id = intval($document_template_id);
$coverage = ['clients' => 0, 'covered' => 0, 'missing' => []];
$sql = mysqli_query($mysqli, "SELECT client_id, client_name, EXISTS (SELECT 1 FROM documents WHERE document_client_id = clients.client_id
AND document_template_id = $document_template_id AND document_archived_at IS NULL) AS covered
FROM clients WHERE " . documentTemplateScopeSql($required, $tag_ids) . " ORDER BY client_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$coverage['clients']++;
if (intval($row['covered'])) {
$coverage['covered']++;
} else {
$coverage['missing'][intval($row['client_id'])] = (string) $row['client_name'];
}
}
return $coverage;
}
/*
* The required templates a client has no current document from - [template
* id => name]. None for an archived client or a lead.
*/
function documentTemplatesMissingFor($client_id) {
global $mysqli;
$client_id = intval($client_id);
$missing = [];
$sql = mysqli_query($mysqli, "SELECT document_template_id, document_template_name FROM document_templates
WHERE document_template_archived_at IS NULL AND document_template_required IN (" . DOCUMENT_TEMPLATE_REQUIRED_EVERY . ", " . DOCUMENT_TEMPLATE_REQUIRED_TAGGED . ")
AND EXISTS (SELECT 1 FROM clients WHERE clients.client_id = $client_id AND clients.client_archived_at IS NULL AND clients.client_lead = 0)
AND (document_template_required = " . DOCUMENT_TEMPLATE_REQUIRED_EVERY . " OR EXISTS (SELECT 1 FROM document_template_required_tags rt
JOIN client_tags ON client_tags.tag_id = rt.tag_id AND client_tags.client_id = $client_id
WHERE rt.document_template_id = document_templates.document_template_id))
AND NOT EXISTS (SELECT 1 FROM documents WHERE document_client_id = $client_id AND documents.document_template_id = document_templates.document_template_id AND document_archived_at IS NULL)
ORDER BY document_template_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$missing[intval($row['document_template_id'])] = (string) $row['document_template_name'];
}
return $missing;
}
/* When a document was last looked at - made, edited or marked as reviewed - as SQL on documents. */
function documentReviewedAtSql() {
return "GREATEST(documents.document_created_at, COALESCE(documents.document_updated_at, documents.document_created_at), COALESCE(documents.document_reviewed_at, documents.document_created_at))";
}
/*
* A document due a review, as SQL on documents joined to document_templates:
* its template - a current one - says how often, and it has been longer than
* that since it was last looked at.
*/
function documentReviewDueSql() {
return "document_templates.document_template_archived_at IS NULL AND document_templates.document_template_review_months > 0
AND " . documentReviewedAtSql() . " < NOW() - INTERVAL document_templates.document_template_review_months MONTH";
}
/*
* The current documents due a review, the longest overdue first - for one
* template, or one client ($where: SQL on documents, document_templates and
* clients). Each with document_id, document_name, document_client_id,
* client_name, document_template_id, document_template_name, reviewed_at and
* document_template_review_months.
*/
function documentsReviewDue($where) {
global $mysqli;
$due = [];
$sql = mysqli_query($mysqli, "SELECT documents.document_id, document_name, document_client_id, client_name, documents.document_template_id, document_template_name,
" . documentReviewedAtSql() . " AS reviewed_at, document_template_review_months
FROM documents
JOIN document_templates ON document_templates.document_template_id = documents.document_template_id
JOIN clients ON clients.client_id = documents.document_client_id AND clients.client_archived_at IS NULL
WHERE documents.document_archived_at IS NULL AND " . documentReviewDueSql() . " AND ($where)
ORDER BY reviewed_at ASC, documents.document_id ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$due[intval($row['document_id'])] = $row;
}
return $due;
}
/** How many current documents from each template are due a review - [template id => count]; ones with none aren't in it. */
function documentTemplateDueCounts(array $document_template_ids) {
global $mysqli;
$ids = array_values(array_filter(array_map('intval', $document_template_ids)));
$counts = [];
if (!$ids) {
return $counts;
}
$sql = mysqli_query($mysqli, "SELECT documents.document_template_id, COUNT(*) FROM documents
JOIN document_templates ON document_templates.document_template_id = documents.document_template_id
JOIN clients ON clients.client_id = documents.document_client_id AND clients.client_archived_at IS NULL
WHERE documents.document_template_id IN (" . implode(',', $ids) . ") AND documents.document_archived_at IS NULL AND " . documentReviewDueSql() . "
GROUP BY documents.document_template_id");
while ($row = mysqli_fetch_row($sql)) {
$counts[intval($row[0])] = intval($row[1]);
}
return $counts;
}
/*
* Where a document stands for review, from its row (document_created_at,
* document_updated_at, document_reviewed_at, and its template's
* document_template_review_months and document_template_archived_at) -
* ['last' => when it was last looked at, 'reviewed' => whether that was a
* review rather than an edit, 'next' => when it is due, 'due' => bool], or
* null when its template doesn't ask for reviews.
*/
function documentReviewState(array $row) {
$months = intval($row['document_template_review_months'] ?? 0);
if ($months <= 0 || !empty($row['document_template_archived_at'])) {
return null;
}
$times = array_filter([$row['document_created_at'] ?? null, $row['document_updated_at'] ?? null, $row['document_reviewed_at'] ?? null]);
$last = max(array_map('strtotime', $times));
$next = strtotime("+$months months", $last);
return [
'last' => date('Y-m-d H:i:s', $last),
'reviewed' => !empty($row['document_reviewed_at']) && strtotime($row['document_reviewed_at']) === $last,
'next' => date('Y-m-d H:i:s', $next),
'due' => $next < time(),
];
}
/*
* Documents from a template for several clients at once, each filled in from
* its own client (its primary site and contact), the answers to the fill-ins
* the same for all ($answers, from ticketFillInAnswers()), at the top level of
* its Files. With $skip_existing, a client with a current document from it is
* passed over. ['made' => [client id => document id], 'skipped' => [client id
* => name]] - only current clients.
*/
function documentTemplateMakeForClients($document_template_id, array $client_ids, array $answers, $skip_existing = true) {
global $mysqli;
$document_template_id = intval($document_template_id);
$result = ['made' => [], 'skipped' => []];
$client_ids = array_values(array_unique(array_filter(array_map('intval', $client_ids))));
if (!$client_ids) {
return $result;
}
$sql = mysqli_query($mysqli, "SELECT client_id, client_name, EXISTS (SELECT 1 FROM documents WHERE document_client_id = client_id
AND document_template_id = $document_template_id AND document_archived_at IS NULL) AS has_one
FROM clients WHERE client_archived_at IS NULL AND client_id IN (" . implode(',', $client_ids) . ") ORDER BY client_name ASC");
while ($row = mysqli_fetch_assoc($sql)) {
$client_id = intval($row['client_id']);
if ($skip_existing && intval($row['has_one'])) {
$result['skipped'][$client_id] = (string) $row['client_name'];
continue;
}
$document_id = documentTemplateMakeDocument($document_template_id, $client_id, '', '', 0, 0, 0, $answers);
if (!$document_id) {
break;
}
$result['made'][$client_id] = $document_id;
}
return $result;
}