diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b28ecbf..7f063402a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.3.5) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.3.6) in the same pass. Nothing else is required. -This is a big release, with fifty-six database steps in one go, so take a backup before you update. +This is a big release, with fifty-seven database steps in one go, so take a backup before you update. The update also clears out data that deletes in older versions left behind, all of it pointing at something that no longer exists: @@ -51,7 +51,7 @@ Document templates keep their names, summaries, what they say and their images. License templates keep everything they hold. A license called exactly what one license template is called - as the New License form named it when started from one - counts as made from that template, so a template's use starts out counted; where two templates share a name, neither is given its licenses. Other licenses don't know a template, so they count from the update. No template sets seats, a renewal term, a vendor or a product until you give it one, and none is required of anyone until you set it up on its page. Maintenance > Starter Content has a new License Templates pack to add. -Vendor templates keep everything they hold, and the vendors made from them stay linked to them. Who made each and last changed it is taken from the audit log, where it has a record of it. A vendor made from one that has since been changed by hand shows as out of step with it, and nothing on it changes until you bring it in line. Vendors made by hand stay unlinked - a template's page lists the ones that look like it, to link. No template is required of anyone until you set it up on its page. +Vendor templates keep everything they hold, and the vendors made from them stay linked to them. Who made each and last changed it is taken from the audit log, where it has a record of it. A vendor made from one that has since been changed by hand shows as out of step with it, and nothing on it changes until you bring it in line. Vendors made by hand stay unlinked - a template's page lists the ones that look like it, to link. No template is required of anyone until you set it up on its page. Vendors and their templates have a status page and a support portal from this version, empty until you fill them in, and templates keep the account manager's line and email as vendors do. The starter vendor templates you added from Maintenance > Starter Content stay as they are; Update on that page gives them their support site, status page, portal and what support asks for. The app logs are kept for as many days as the audit log, as before, and each can now be set on its own in Admin > Settings > Logs, which is where the log retention went from Settings > Security. A retention of 0 now keeps a log forever (see Breaking Changes, Logs). Nothing is sent to a syslog server or SIEM until you set one up there. @@ -592,6 +592,11 @@ If you are coming from 26.08 or earlier, the database update still has to be run - The list has Required and Out of step tabs, and shows how many of the clients a required one is for have it, and how many of its vendors are out of step. - A client's vendor says which fields differ from its template, with Update from template, and has Unlink from template in its menu. One made by hand that looks like a template - the same name or support site - offers Link to template. - A client's Vendors list the vendors it should have and doesn't, each a click from New Vendor started from its template, and its overview counts them as Vendors missing. + - A status page and a support portal on vendors and vendor templates - where the vendor posts outages, and where cases are opened. A vendor's page has Status page beside Support site, and its quick look, the vendors list and the tickets it is on link its status page, to see at a glance whether it is them. Vendors made from a template are kept in step with both. + - A template keeps the account manager's line and email too, for a rep every client shares. A vendor made from it starts with them, but they are never changed on it afterwards - nor its account number or PIN. + - Export and Import on Admin > Vendor Templates, and Export on a template's page and menu - a file to copy templates to another ITFlow, keep or share. Never an account number or PIN, and the account manager only when asked. A template already here by name can be left alone, added as well or replaced - replaced, it keeps its account number, PIN and who should have it, and the vendors made from it that still said what it did change with it. + - Maintenance > Starter Content: the starter vendor templates come with their support site, status page and the portal cases are opened in where they have one, and what support asks for - the tenant, the service tag, the serial number. Update brings the ones added before up to date: only those fields change, and the vendors made from them that still said what they did change with them. Phone numbers, hours and SLAs differ by region and contract, so they stay yours. + - A license template's file carries its vendor's status page and portal. - Templates can be copied, archived - not offered on New Vendor or Add to Clients, while the vendors made from them stay - restored and deleted. Deleted, its vendors and the license templates that used it stay, no longer linked to it. - Make a template from it, in the menu of a client's vendor for an admin, starts a template from that vendor - leaving its account number, PIN and account manager, which are the client's own - and links the vendor to it, so other clients can have the same one. - A vendor made from a template links to it from its page, for an admin. @@ -1123,6 +1128,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - License templates: the type picker showed a stray "<" at the top. - Vendor templates: Delete was only offered to the built-in Administrator role, not to other admin roles. - Vendor templates: Add to clients skipped a client whose vendor from the template had been archived, as if it still had it. +- Vendors: a support site saved with a scheme other than http or https is no longer made a link. - App Logs: the category filter did nothing - it read a misspelt field - and the date filter was slow on a large log. - App logs: an entry over 1,000 characters with an accented letter or a symbol where it was cut could fail to save, stopping what was logging it. - Admin > Settings > Defaults: Payment Method and Expense Payment Method listed the payment method categories that moved to Admin > Payment Methods in 2.3.1, so a new install had nothing to pick. They list Admin > Payment Methods now. @@ -1664,6 +1670,11 @@ If you are coming from 26.08 or earlier, the database update still has to be run - `admin/post/vendor_template.php`: `save_vendor_template_required` (`vendor_template_id`, `required`, `tags[]`), logged as Vendor Template Edit. New `admin/modals/vendor_template/vendor_template_required.php` (`id`). `admin/vendor_template.php` takes `view` (`out` or `account`) and loads `js/bulk_actions.js` (`vendor_ids[]`); `admin/vendor_templates.php` takes `show=required` and `show=out`. - `agent/post/vendor.php`: `update_vendors_from_template` (`vendor_template_id`, `vendor_ids[]`, `fields[]` - keys of `VENDOR_TEMPLATE_SHARED`), `link_vendors_to_template` (`vendor_template_id`, `vendor_ids[]`, `bring`; not to an archived template) and GET `unlink_vendor_template`, each only for vendors the agent may edit (`vendorsForRequest()`), logged as Vendor Edit on each vendor with its client. `add_vendor_template_to_clients` counts only a current vendor from the template as having it. New `agent/modals/vendor/vendor_template_update.php` (`vendor_id`, or `template_id` and `vendor_ids[]`); `vendor_template_clients.php` takes `client_ids[]` and `missing=1` to start with clients picked. - `agent/vendors.php` with `client_id` shows `vendorTemplatesMissingFor()` (`.itflow-vendors-missing`), and `clientOverviewAttention()` adds Vendors missing for agents with `module_client`. + - Database 3.3.6: `vendors.vendor_status_page` and `vendor_portal`, and `vendor_templates.vendor_template_status_page` and `vendor_template_portal` (after the website), and `vendor_template_contact_phone_country_code`, `vendor_template_contact_phone` and `vendor_template_contact_email` (after the contact name), each checked first. + - `vendorTemplateFieldLabels()` and `vendorTemplateColumns()` have `status_page`, `portal`, `contact_phone` (country code and number) and `contact_email`; `VENDOR_TEMPLATE_SHARED` adds `status_page` and `portal`. `vendorTemplateFields()` reads them (a bad `contact_email` is refused), `vendorTemplateRowFields()` is built from the columns and takes a missing row as blank, and `vendorTemplateValueHtml()` links the sites. `vendorTemplateFormFields()` and `vendorFormSet()` (`status_page`, `portal`) carry them to vendors; the website, status page and portal are saved without their scheme or a trailing slash. `vendorWebsiteUrl()` returns '' for a scheme other than http or https. + - New `functions/vendor_template_transfer.php`: `VENDOR_TEMPLATE_FILE_FORMAT` (`itflow-vendor-templates`), `VENDOR_TEMPLATE_FILE_VERSION` (1), `VENDOR_TEMPLATE_FILE_FIELDS` and `VENDOR_TEMPLATE_FILE_MANAGER_FIELDS`, `vendorTemplatesExport($ids, $archived, $manager)`, `vendorTemplatesExportFileName()`, `vendorTemplateFileValue()`, `vendorTemplatesImportRead($contents, &$error)` (up to 1000), `vendorTemplatesImport($templates, $existing, $only_existing, $only_fields)` (`added`, `replaced`, `skipped`, `ids`, `followed`), `vendorTemplatesImportLogFollowed()`, `vendorTemplatesImportFollowedCount()` and `vendorTemplatesImportSummary()`. `admin/post/vendor_template.php`: `export_vendor_templates` (`vendor_template_ids[]`, `archived`, `manager`) and GET `export_vendor_template`, and `import_vendor_templates` (`file`, `existing` - `skip`, `copy` or `replace`), logged as Vendor Template Export and Import, and Vendor Edit on each vendor that followed. New `admin/modals/vendor_template/vendor_template_export.php` and `vendor_template_import.php`. + - `SOFTWARE_TEMPLATE_FILE_VENDOR_FIELDS` adds `status_page` and `portal`. The starter vendor templates (`starterContentVendorTemplates()`) are in the file's shape and load through the import; `update_starter_vendor_templates` (`starterUpdateVendorTemplates()`) replaces only `description`, `website`, `status_page`, `portal` and `notes`, logged as Starter Content Edit. + - API: `vendors/create.php` and `update.php` take `vendor_status_page` and `vendor_portal`. The vendors export has Status Page and Support Portal columns (off by default), and the client export's vendors have them. - Empty states: every list page sets `$empty_state` before including `includes/filter_footer.php`, and `includes/inc_empty_state.php` draws it with `emptyStateHtml()` (`functions/format.php`) when the list has nothing - `thing`, `icon`, `title`, `text`, `actions` (`label`, `modal` or `href`, `size`, `icon`; the first is the main one), `archived`, `view`, `filtered`, `views`, `match_title`, `match_text` and `clear_url`. `emptyStateFiltered($views)` reads the GET string for a search or filter, leaving out paging, sorting, `client_id`, `archived` and the page's views, and `emptyStateClearUrl($views)` is the page without them. `emptyStatePill($pills, $key, $default, $good)` builds a pill's own state from the page's pill array (`label`, `icon`, `title`, `alarm`), with a tick for `alarm` pills and the keys in `$good`. Files, notifications and racks call `emptyStateHtml()` in their own layout; files and notifications set `$empty_state_skip`. A page that sets nothing still gets one, worded from `$page_title`. The pages' own empty blocks (`.itflow-asset-empty` under a list) are gone. CSS: `.itflow-empty-state` with `-icon` (a grey round tile), `-title`, `-text` and `-actions`. - Removed unused helpers and files: - `credentialLink()`, `projectLink()`, `linkCounts()`, `assetContactAvatar()`, `clientOverviewPersonInitials()`, and `getMonthlyTax()` and `getQuarterlyTax()`, which looked a tax up by name diff --git a/admin/database_updates/3.3.6.php b/admin/database_updates/3.3.6.php new file mode 100644 index 000000000..31d5c36ba --- /dev/null +++ b/admin/database_updates/3.3.6.php @@ -0,0 +1,31 @@ + $from_vendor['vendor_sla'], 'email' => $from_vendor['vendor_email'], 'website' => $from_vendor['vendor_website'], + 'status_page' => $from_vendor['vendor_status_page'], + 'portal' => $from_vendor['vendor_portal'], 'notes' => $from_vendor['vendor_notes'], ]; } diff --git a/admin/modals/vendor_template/vendor_template_edit.php b/admin/modals/vendor_template/vendor_template_edit.php index 85f575240..ed01be10c 100644 --- a/admin/modals/vendor_template/vendor_template_edit.php +++ b/admin/modals/vendor_template/vendor_template_edit.php @@ -2,7 +2,8 @@ /* * Edit a vendor template - the fields are vendor_template_form.php, with - * the fields ticked changed on the vendors made from it too. + * the vendors made from it following what changes, where they still said + * what it did (vendorTemplateFollow()). */ require_once '../../includes/modal_header.php'; diff --git a/admin/modals/vendor_template/vendor_template_export.php b/admin/modals/vendor_template/vendor_template_export.php new file mode 100644 index 000000000..a5001d584 --- /dev/null +++ b/admin/modals/vendor_template/vendor_template_export.php @@ -0,0 +1,61 @@ + + + +
+ + + + + +
+ + @@ -99,12 +100,29 @@ $phone_value = ($vt['phone'] ?? '') !== '' ? escapeHtml(formatPhoneNumber($vt['p
- +
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
Where they post outages, and where you sign in to open a case - every client's vendor gets them.
+
@@ -135,6 +153,24 @@ $phone_value = ($vt['phone'] ?? '') !== '' ? escapeHtml(formatPhoneNumber($vt['p +
+ +
+ + + +
+
+
+ +
+ + +
+
+
+
A vendor added from it starts with its account manager - a client's own rep goes on their vendor, and is never changed from here.
+
diff --git a/admin/modals/vendor_template/vendor_template_import.php b/admin/modals/vendor_template/vendor_template_import.php new file mode 100644 index 000000000..656c4b144 --- /dev/null +++ b/admin/modals/vendor_template/vendor_template_import.php @@ -0,0 +1,54 @@ + + + +
+ + + + + +
+ +{$counts['replaced']} starter vendor templates up to date" . ($followed ? " - $followed vendor" . ($followed === 1 ? '' : 's') . " made from them changed with them" : '')); + } else { + flashAlert("None of the starter vendor templates are here to update - add them instead", 'info'); + } + + redirect(); + +} + if (isset($_POST['update_starter_license_templates'])) { // The starter license templates already here, brought up to this version diff --git a/admin/post/starter_content_model.php b/admin/post/starter_content_model.php index 3cd321b82..780f6ff88 100644 --- a/admin/post/starter_content_model.php +++ b/admin/post/starter_content_model.php @@ -59,7 +59,11 @@ function starterContentPacks() { 'vendor_templates' => [ 'label' => 'Vendor Templates', 'icon' => 'fa-building', - 'description' => 'Vendors most clients end up with. Account numbers and support numbers are left blank.', + 'description' => "Vendors most clients end up with - each with its support site, status page and the portal cases are opened in, and what their support asks for. Phone numbers, hours and SLAs differ by region and contract, and account numbers and PINs by account, so they are yours to add.", + 'update' => [ + 'button' => 'update_starter_vendor_templates', + 'confirm' => 'Bring the %d starter vendor templates you have up to date? What they are, their support site, status page, portal and notes are replaced with this version - changes you made to those are lost, and the vendors made from them that still said what they did change with them. Their support line, hours, SLA, email, account details and who should have them stay as they are.', + ], ], 'document_templates' => [ 'label' => 'Document Templates', @@ -390,27 +394,32 @@ function starterLoadProjectTemplates($mysqli, $dry_run = false) { // ------------------------------ // starterLoadVendorTemplates +// Through the vendor template import. One whose name a template here already +// has, archived or not, is left alone. // ------------------------------ function starterLoadVendorTemplates($mysqli, $dry_run = false) { - $existing = starterExistingNames($mysqli, 'vendor_templates', 'vendor_template_name'); - $added = 0; - - foreach (starterContentVendorTemplates() as $vendor_template) { - if (isset($existing[mb_strtolower($vendor_template[0])])) { - continue; - } - $added++; - if ($dry_run) { - continue; - } - starterInsert($mysqli, 'vendor_templates', [ - 'vendor_template_name' => $vendor_template[0], - 'vendor_template_description' => $vendor_template[1], - 'vendor_template_website' => $vendor_template[2], - ]); + $templates = vendorTemplatesImportRead(['format' => VENDOR_TEMPLATE_FILE_FORMAT, 'version' => VENDOR_TEMPLATE_FILE_VERSION, 'templates' => starterContentVendorTemplates()]); + if ($dry_run) { + $existing = starterExistingNames($mysqli, 'vendor_templates', 'vendor_template_name'); + return count(array_filter($templates, function ($template) use ($existing) { + return !isset($existing[mb_strtolower($template['name'])]); + })); } + return vendorTemplatesImport($templates, 'skip')['added']; +} - return $added; +// ------------------------------ +// starterUpdateVendorTemplates +// The starter vendor templates already here, brought up to this library's +// version - only what this library says: what they are, their support site, +// status page, portal and notes. Their support line, hours, SLA, email, +// account details and who should have them stay; the vendors made from them +// that still said what they did change with them (vendorTemplateFollow()). +// Returns the import's counts (vendorTemplatesImport()). +// ------------------------------ +function starterUpdateVendorTemplates() { + $templates = vendorTemplatesImportRead(['format' => VENDOR_TEMPLATE_FILE_FORMAT, 'version' => VENDOR_TEMPLATE_FILE_VERSION, 'templates' => starterContentVendorTemplates()]); + return vendorTemplatesImport($templates, 'replace', true, ['description', 'website', 'status_page', 'portal', 'notes']); } // ------------------------------ @@ -1446,50 +1455,91 @@ function starterContentProjectTemplates() { // ------------------------------ // starterContentVendorTemplates -// name, description, website. Phone and account fields differ per region and -// per account, so they are left for whoever adds the vendor to a client. +// In the vendor templates file's shape (functions/vendor_template_transfer.php): +// name, what they are, their support site, status page and the portal cases +// are opened in where they have one, and what support asks for. Phone +// numbers, hours and SLAs differ by region and by contract, and account +// numbers and PINs per account, so they are left for whoever adds the vendor +// to a client. // ------------------------------ function starterContentVendorTemplates() { - $vendor_templates = [ - ['Microsoft', 'Cloud, productivity and operating system vendor', 'https://www.microsoft.com'], - ['Google', 'Workspace, cloud and domain services', 'https://workspace.google.com'], - ['Amazon Web Services', 'Cloud infrastructure and hosting', 'https://aws.amazon.com'], - ['Dell', 'Workstation, laptop and server hardware', 'https://www.dell.com'], - ['HP', 'Workstation, laptop and printer hardware', 'https://www.hp.com'], - ['Lenovo', 'Workstation and laptop hardware', 'https://www.lenovo.com'], - ['Supermicro', 'Server and storage hardware', 'https://www.supermicro.com'], - ['Apple', 'Workstation, laptop and mobile hardware', 'https://www.apple.com'], - ['Synology', 'Network attached storage and backup appliances', 'https://www.synology.com'], - ['QNAP', 'Network attached storage appliances', 'https://www.qnap.com'], - ['Ubiquiti', 'Networking, wireless and surveillance hardware', 'https://www.ui.com'], - ['MikroTik', 'Routing and switching hardware', 'https://mikrotik.com'], - ['Cisco', 'Networking, wireless and collaboration hardware', 'https://www.cisco.com'], - ['Fortinet', 'Firewall and network security appliances', 'https://www.fortinet.com'], - ['SonicWall', 'Firewall and network security appliances', 'https://www.sonicwall.com'], - ['APC by Schneider Electric', 'Uninterruptible power supplies and power distribution', 'https://www.apc.com'], - ['Brother', 'Printer and scanner hardware', 'https://www.brother.com'], - ['Ingram Micro', 'Hardware and software distribution', 'https://www.ingrammicro.com'], - ['TD SYNNEX', 'Hardware and software distribution', 'https://www.tdsynnex.com'], - ['Veeam', 'Backup, replication and recovery software', 'https://www.veeam.com'], - ['Acronis', 'Backup and cyber protection software', 'https://www.acronis.com'], - ['Backblaze', 'Cloud backup and object storage', 'https://www.backblaze.com'], - ['Wasabi', 'Cloud object storage', 'https://wasabi.com'], - ['Bitdefender', 'Endpoint protection and security software', 'https://www.bitdefender.com'], - ['ESET', 'Endpoint protection and security software', 'https://www.eset.com'], - ['Proofpoint', 'Email security and filtering', 'https://www.proofpoint.com'], - ['Bitwarden', 'Password management', 'https://bitwarden.com'], - ['Cloudflare', 'DNS, content delivery and security', 'https://www.cloudflare.com'], - ['Namecheap', 'Domain registration and hosting', 'https://www.namecheap.com'], - ['GoDaddy', 'Domain registration and hosting', 'https://www.godaddy.com'], - ['Adobe', 'Creative and document software', 'https://www.adobe.com'], - ['Intuit', 'Accounting and payroll software', 'https://www.intuit.com'], - ['Zoom', 'Video conferencing and collaboration', 'https://zoom.us'], - ['RingCentral', 'Hosted voice and unified communications', 'https://www.ringcentral.com'], - ['Stripe', 'Card and online payment processing', 'https://stripe.com'], - ]; + $template = function ($name, $description, $website, $status_page = '', $portal = '', $notes = '') { + return ['name' => $name, 'description' => $description, 'website' => $website, 'status_page' => $status_page, 'portal' => $portal, 'notes' => $notes]; + }; - return $vendor_templates; + return [ + $template('Microsoft', 'Cloud, productivity and operating system vendor', 'support.microsoft.com', 'status.cloud.microsoft', 'admin.microsoft.com', + "Open a service request from the Microsoft 365 admin center (Support > Help & support), signed in as an admin of the client's tenant - or from Partner Center as their partner.\n\nHave the tenant's domain and the users affected ready. Service health in the admin center shows what Microsoft already knows about; the status page is for when the admin center itself is down."), + $template('Google', 'Workspace, cloud and domain services', 'support.google.com/a', 'www.google.com/appsstatus/dashboard', 'admin.google.com', + "Contact support from the Admin console (Help > Contact support), signed in as a super admin of the client's Workspace.\n\nHave the primary domain and the customer ID ready - Account > Account settings in the Admin console."), + $template('Amazon Web Services', 'Cloud infrastructure and hosting', 'aws.amazon.com/premiumsupport', 'health.aws.amazon.com/health/status', 'console.aws.amazon.com/support', + "Open a case from the Support Center in the console of the client's AWS account. How fast they answer depends on its support plan - Basic covers account and billing questions only.\n\nHave the account ID and the region ready."), + $template('Dell', 'Workstation, laptop and server hardware', 'www.dell.com/support', '', 'techdirect.dell.com', + "Have the service tag ready - it decides the warranty and what support covers. Partners open cases and dispatch parts from TechDirect."), + $template('HP', 'Workstation, laptop and printer hardware', 'support.hp.com', '', '', + "Have the serial number and product number ready - on the label, or from HP Support Assistant."), + $template('Lenovo', 'Workstation and laptop hardware', 'support.lenovo.com', '', '', + "Have the machine type and serial number ready - on the label, or from Lenovo Vantage."), + $template('Supermicro', 'Server and storage hardware', 'www.supermicro.com', '', '', + "Have the system or motherboard serial number ready, and the BMC and BIOS versions."), + $template('Apple', 'Workstation, laptop and mobile hardware', 'support.apple.com', 'www.apple.com/support/systemstatus', '', + "Have the device's serial number ready. The system status page covers iCloud, the App Store and Apple's other services."), + $template('Synology', 'Network attached storage and backup appliances', 'www.synology.com', '', 'account.synology.com', + "Open a ticket from the Synology Account the device is registered to. Have the serial number and DSM version ready - they usually ask for the debug logs from Support Center on the device."), + $template('QNAP', 'Network attached storage appliances', 'www.qnap.com', '', '', + "Have the serial number and QTS or QuTS version ready."), + $template('Ubiquiti', 'Networking, wireless and surveillance hardware', 'help.ui.com', 'status.ui.com', '', + "Support is by chat and ticket from the help center, signed in with the UI account the devices are on. Have the device's MAC address and firmware version ready."), + $template('MikroTik', 'Routing and switching hardware', 'help.mikrotik.com', '', '', + "Attach a supout.rif from the device to the ticket - Make Supout.rif in WinBox, or /system sup-output - and say which RouterOS version it runs."), + $template('Cisco', 'Networking, wireless and collaboration hardware', 'www.cisco.com', '', '', + "A case needs a support contract on the device - have the serial number and contract number ready."), + $template('Fortinet', 'Firewall and network security appliances', 'www.fortinet.com', '', 'support.fortinet.com', + "Devices are registered, and tickets opened, in FortiCare. Have the serial number ready."), + $template('SonicWall', 'Firewall and network security appliances', 'www.sonicwall.com/support', '', 'www.mysonicwall.com', + "Appliances are registered, and cases opened, in MySonicWall. Have the serial number ready."), + $template('APC by Schneider Electric', 'Uninterruptible power supplies and power distribution', 'www.apc.com', '', '', + "Have the model and serial number ready, and when the batteries were last replaced."), + $template('Brother', 'Printer and scanner hardware', 'support.brother.com', '', '', + "Have the model and serial number ready."), + $template('Ingram Micro', 'Hardware and software distribution', 'www.ingrammicro.com', '', '', + "Your reseller account - quotes, orders and returns go through it and your account rep."), + $template('TD SYNNEX', 'Hardware and software distribution', 'www.tdsynnex.com', '', '', + "Your reseller account - quotes, orders and returns go through it and your account rep."), + $template('Veeam', 'Backup, replication and recovery software', 'www.veeam.com/support.html', '', 'my.veeam.com', + "Open a case in My Veeam with the license or support ID, and attach the logs the console exports for support."), + $template('Acronis', 'Backup and cyber protection software', 'www.acronis.com', '', '', + "Have the account or tenant name ready, and the agent version."), + $template('Backblaze', 'Cloud backup and object storage', 'help.backblaze.com', 'status.backblaze.com', 'secure.backblaze.com', + "Have the account's email address ready, and the bucket or computer the case is about."), + $template('Wasabi', 'Cloud object storage', 'docs.wasabi.com', '', 'console.wasabisys.com', + "Have the account, the bucket and its region ready."), + $template('Bitdefender', 'Endpoint protection and security software', 'www.bitdefender.com', '', '', + "Have the GravityZone company the client is under ready, and the endpoint's name."), + $template('ESET', 'Endpoint protection and security software', 'www.eset.com', '', '', + "Have the license or ESET business account ready, and the product version."), + $template('Proofpoint', 'Email security and filtering', 'www.proofpoint.com', '', '', + "Have the organization or domain ready, and the message ID of the email the case is about."), + $template('Bitwarden', 'Password management', 'bitwarden.com/help', 'status.bitwarden.com', 'vault.bitwarden.com', + "Have the organization's name and the email address of the account the case is about ready."), + $template('Cloudflare', 'DNS, content delivery and security', 'developers.cloudflare.com', 'www.cloudflarestatus.com', 'dash.cloudflare.com', + "Cases are opened from the dashboard of the client's Cloudflare account - what support is offered depends on its plan. Have the domain ready."), + $template('Namecheap', 'Domain registration and hosting', 'www.namecheap.com/support', 'www.namecheap.com/status-updates', '', + "Have the domain and the account's username ready."), + $template('GoDaddy', 'Domain registration and hosting', 'www.godaddy.com/help', '', '', + "Have the customer number and support PIN ready - support asks for them before it helps."), + $template('Adobe', 'Creative and document software', 'helpx.adobe.com', 'status.adobe.com', 'adminconsole.adobe.com', + "Admins open cases from the Admin Console (Support). Have the organization and the user's email address ready."), + $template('Intuit', 'Accounting and payroll software', 'www.intuit.com', '', '', + "Have the product, the company file or account, and the license number for desktop products ready."), + $template('Zoom', 'Video conferencing and collaboration', 'support.zoom.com', 'status.zoom.us', '', + "Have the account number ready, and the meeting ID the case is about."), + $template('RingCentral', 'Hosted voice and unified communications', 'support.ringcentral.com', '', 'service.ringcentral.com', + "Have the account's main number ready - admins manage it in the admin portal."), + $template('Stripe', 'Card and online payment processing', 'support.stripe.com', 'status.stripe.com', 'dashboard.stripe.com', + "Support is from the Dashboard of the client's Stripe account. Have the account ID (acct_...) ready, and the payment or payout the case is about."), + ]; } @@ -1916,7 +1966,7 @@ function starterContentLicenseTemplates() { $vendors = []; foreach (starterContentVendorTemplates() as $vendor) { - $vendors[$vendor[0]] = ['name' => $vendor[0], 'description' => $vendor[1], 'website' => $vendor[2]]; + $vendors[$vendor['name']] = $vendor; } $template = function ($name, $description, $type, $license_type, $term, $vendor, $product, array $notes) use ($vendors) { return [ diff --git a/admin/post/vendor_template.php b/admin/post/vendor_template.php index b5910b69a..c4b659e09 100644 --- a/admin/post/vendor_template.php +++ b/admin/post/vendor_template.php @@ -3,8 +3,9 @@ /* * Vendor templates (Admin > Vendor Templates) - making, changing (the * vendors made from it that still said what it did changing with it), - * copying, archiving, restoring and deleting them, and saying who should have - * one. The work is in functions/vendor_template.php; a vendor is made from + * copying, archiving, restoring and deleting them, saying who should have + * one, and taking them to and from a file (functions/vendor_template_transfer.php). + * The work is in functions/vendor_template.php; a vendor is made from * one by add_vendor, add_vendor_from_template and * add_vendor_template_to_clients, and brought in line with one, linked to one * or unlinked by update_vendors_from_template, link_vendors_to_template and @@ -152,3 +153,68 @@ if (isset($_POST['save_vendor_template_required'])) { redirect(); } + +if (isset($_POST['export_vendor_templates']) || isset($_GET['export_vendor_template'])) { + + // A file of templates to import into another ITFlow - every current one, the ones picked, or one + validateCSRFToken(); + + if (isset($_GET['export_vendor_template'])) { + $row = vendorTemplateForRequest($_GET['export_vendor_template']); + $export = vendorTemplatesExport([intval($row['vendor_template_id'])]); + } else { + $export = vendorTemplatesExport((array) ($_POST['vendor_template_ids'] ?? []), !empty($_POST['archived']), !empty($_POST['manager'])); + } + if (!$export['templates']) { + flashAlert('There are no vendor templates to export.', 'error'); + redirect(); + } + + $count = count($export['templates']); + logAudit("Vendor Template", "Export", "$session_name exported $count vendor template" . ($count === 1 ? ' ' . escapeSql($export['templates'][0]['name']) : 's') + . (!empty($_POST['manager']) ? ' with their account managers' : '')); + + header('Content-Type: application/json; charset=utf-8'); + header('Content-Disposition: attachment; filename="' . vendorTemplatesExportFileName($export) . '"'); + header('Cache-Control: no-store'); + echo json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + exit; + +} + +if (isset($_POST['import_vendor_templates'])) { + + // Templates from a file exported by ITFlow + validateCSRFToken(); + + $file = $_FILES['file'] ?? null; + if ($file && in_array($file['error'], [UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE], true)) { + flashAlert('That file is bigger than this server takes in an upload - export a few at a time.', 'error'); + redirect('vendor_templates.php'); + } + if (!$file || $file['error'] !== UPLOAD_ERR_OK || !is_uploaded_file($file['tmp_name'])) { + flashAlert('Pick the file to import.', 'error'); + redirect('vendor_templates.php'); + } + if ($file['size'] > 10 * 1024 * 1024) { + flashAlert('That file is too big to be vendor templates.', 'error'); + redirect('vendor_templates.php'); + } + $templates = vendorTemplatesImportRead(file_get_contents($file['tmp_name']), $import_error); + if ($templates === null) { + flashAlert(escapeHtml($import_error), 'error'); + redirect('vendor_templates.php'); + } + $existing = in_array($_POST['existing'] ?? '', ['skip', 'copy', 'replace'], true) ? $_POST['existing'] : 'skip'; + $counts = vendorTemplatesImport($templates, $existing); + vendorTemplatesImportLogFollowed($counts); + $followed = vendorTemplatesImportFollowedCount($counts); + + logAudit("Vendor Template", "Import", "$session_name imported vendor templates from " . escapeSql(mb_substr((string) $file['name'], 0, 100)) . " - {$counts['added']} added, {$counts['replaced']} replaced, {$counts['skipped']} skipped" + . ($followed ? ", $followed vendors changed with them" : '')); + + flashAlert(vendorTemplatesImportSummary($counts), $counts['added'] || $counts['replaced'] ? 'success' : 'info'); + + redirect('vendor_templates.php'); + +} diff --git a/admin/starter_content.php b/admin/starter_content.php index a217cd759..dcac32240 100644 --- a/admin/starter_content.php +++ b/admin/starter_content.php @@ -43,8 +43,8 @@ foreach ($starter_content_status as $pack_status) { Adding never overwrites or deletes anything. Anything already present under the same name is skipped, so a pack can be added again later to pick up entries you removed by mistake, and adding twice never produces duplicates. Everything added is editable and deletable - afterwards like any other record. Ticket templates, canned responses, document templates and license templates added - from an earlier version can be brought up to this one with Update - that one does replace what they hold. + afterwards like any other record. Ticket templates, canned responses, vendor templates, document templates and license + templates added from an earlier version can be brought up to this one with Update - that one does replace what they hold.

diff --git a/admin/vendor_template.php b/admin/vendor_template.php index b40340039..b3b40d00e 100644 --- a/admin/vendor_template.php +++ b/admin/vendor_template.php @@ -2,9 +2,9 @@ /* * One vendor template - what a vendor made from it starts with (its name, - * what they are, the support line, hours, SLA, support email and site, any - * account number, PIN and account manager every client shares, and its - * notes), who made it and last changed it, and its use: the clients with a + * what they are, the support line, hours, SLA, support email and site, its + * status page and support portal, any account number, PIN and account + * manager every client shares, and its notes), who made it and last changed it, and its use: the clients with a * vendor made from it - each with its own account number and PIN, or not yet, * and in step with it or saying something else (views, and bringing one or * several in line) - the vendors made by hand that look like it (linking @@ -89,17 +89,28 @@ $in_line_url = "$update_url?template_id=$vendor_template_id"; // What a vendor from it starts with - the ones it has $t = $template; -$website = vendorWebsiteLabel($t['vendor_template_website']); +$link = function ($site) { + $url = vendorWebsiteUrl($site); + return $url !== '' ? '' . escapeHtml(vendorWebsiteLabel($site)) . '' : ''; +}; +// The account manager every client shares - their name, with their line and email under it +$manager = array_filter([ + escapeHtml(trim((string) $t['vendor_template_contact_name'])), + trim((string) $t['vendor_template_contact_phone']) !== '' ? '' . contactPhoneLink($t['vendor_template_contact_phone'], $t['vendor_template_contact_phone_country_code']) : '', + trim((string) $t['vendor_template_contact_email']) !== '' ? '' . escapeHtml($t['vendor_template_contact_email']) . '' : '', +]); $facts = array_filter([ 'What they are' => escapeHtml((string) $t['vendor_template_description']), 'Support line' => vendorTemplatePhoneHtml($t['vendor_template_phone'], $t['vendor_template_phone_country_code'], $t['vendor_template_extension']), 'Support hours' => escapeHtml((string) $t['vendor_template_hours']), 'SLA' => escapeHtml((string) $t['vendor_template_sla']), 'Support email' => trim((string) $t['vendor_template_email']) !== '' ? '' . escapeHtml($t['vendor_template_email']) . '' : '', - 'Support site' => $website !== '' ? '' . escapeHtml($website) . '' : '', + 'Support site' => $link($t['vendor_template_website']), + 'Status page' => $link($t['vendor_template_status_page']), + 'Support portal' => $link($t['vendor_template_portal']), 'Account number' => escapeHtml(trim((string) $t['vendor_template_account_number'])), 'PIN' => vendorSecretHtml($t['vendor_template_code']), - 'Account manager' => escapeHtml(trim((string) $t['vendor_template_contact_name'])), + 'Account manager' => $manager ? '
' . implode('
', $manager) . '
' : '', ], function ($value) { return trim($value) !== ''; }); @@ -149,6 +160,7 @@ foreach (vendorTemplateFieldLabels() as $field) {
diff --git a/admin/vendor_templates.php b/admin/vendor_templates.php index bc2420a91..029e6346e 100644 --- a/admin/vendor_templates.php +++ b/admin/vendor_templates.php @@ -10,7 +10,8 @@ * step with it (the Required and Out of step tabs). Each opens its page * (vendor_template.php). A template can be added to several clients at once, * changed, copied as the start of another, archived - not offered for new - * vendors, while the vendors made from it stay - or deleted. The work is in + * vendors, while the vendors made from it stay - or deleted, and they go to + * and from a file (Export and Import). The work is in * functions/vendor_template.php; the handlers are * admin/post/vendor_template.php. */ @@ -60,12 +61,13 @@ if (!isset($sortable_columns[$sort])) { $phone_search = preg_replace('/[^0-9]/', '', (string) ($_GET['q'] ?? '')); $search = "(vendor_template_name LIKE '%$q%' OR vendor_template_description LIKE '%$q%' OR vendor_template_website LIKE '%$q%' OR vendor_template_email LIKE '%$q%' + OR vendor_template_status_page LIKE '%$q%' OR vendor_template_portal LIKE '%$q%' OR vendor_template_contact_email LIKE '%$q%' OR vendor_template_hours LIKE '%$q%' OR vendor_template_sla LIKE '%$q%' OR vendor_template_contact_name LIKE '%$q%' OR vendor_template_account_number LIKE '%$q%' OR vendor_template_notes LIKE '%$q%'" . ($phone_search !== '' ? " OR vendor_template_phone LIKE '%$phone_search%'" : '') . ")"; // Its use is the current vendors made from it for current clients - as vendorTemplateUsage() counts them $sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS vendor_templates.vendor_template_id, vendor_template_name, vendor_template_description, vendor_template_phone_country_code, - vendor_template_phone, vendor_template_extension, vendor_template_email, vendor_template_website, vendor_template_hours, vendor_template_sla, + vendor_template_phone, vendor_template_extension, vendor_template_email, vendor_template_website, vendor_template_status_page, vendor_template_hours, vendor_template_sla, vendor_template_created_at, vendor_template_updated_at, vendor_template_archived_at, vendor_template_required, COALESCE(vendor_template_updated_at, vendor_template_created_at) AS changed_at, COALESCE(updater.user_name, creator.user_name) AS changed_by, COALESCE(used.used_count, 0) AS used_count, used.last_used @@ -112,6 +114,8 @@ $pill_url = function ($show) { $add_url = 'modals/vendor_template/vendor_template_add.php'; $clients_url = '/agent/modals/vendor/vendor_template_clients.php'; +$import_url = 'modals/vendor_template/vendor_template_import.php'; +$export_url = 'modals/vendor_template/vendor_template_export.php'; $csrf = $_SESSION['csrf_token']; ?> @@ -128,6 +132,13 @@ $csrf = $_SESSION['csrf_token']; Add one to clients + + Import + + + Export + + Starter vendor templates @@ -141,7 +152,7 @@ $csrf = $_SESSION['csrf_token'];
- +
@@ -179,6 +190,7 @@ $csrf = $_SESSION['csrf_token']; $page_url = "vendor_template.php?vendor_template_id=$template_id"; $phone = vendorTemplatePhoneHtml($row['vendor_template_phone'], $row['vendor_template_phone_country_code'], $row['vendor_template_extension']); $website = vendorWebsiteLabel($row['vendor_template_website']); + $status_url = vendorWebsiteUrl($row['vendor_template_status_page']); // How to reach their support $support = array_filter([ $phone !== '' ? '' . $phone : '', @@ -195,10 +207,11 @@ $csrf = $_SESSION['csrf_token'];
- +
+ Status
@@ -264,6 +277,9 @@ $csrf = $_SESSION['csrf_token']; Copy + + Export + @@ -296,7 +312,8 @@ $csrf = $_SESSION['csrf_token']; 'view' => ['required' => ['title' => 'No required templates', 'text' => 'Set one up from its page - Who should have it - to see which clients are missing a vendor from it.'], 'out' => ['title' => 'Every vendor is in step', 'text' => 'Every client\'s vendor made from a template says what the template says.']][$show] ?? null, 'text' => 'A vendor your clients use again and again - an ISP, a hardware maker, the software they run - set up once with its support line, hours and site, ready to add to any client. Agents start one from New Vendor on a client\'s Vendors.', - 'actions' => [['label' => 'New Vendor Template', 'modal' => $add_url, 'size' => 'lg'], ['label' => 'Starter vendor templates', 'href' => 'starter_content.php', 'icon' => 'fa-seedling']], + 'actions' => [['label' => 'New Vendor Template', 'modal' => $add_url, 'size' => 'lg'], ['label' => 'Import', 'modal' => $import_url, 'icon' => 'fa-file-upload'], + ['label' => 'Starter vendor templates', 'href' => 'starter_content.php', 'icon' => 'fa-seedling']], ]; require_once "../includes/filter_footer.php"; ?> diff --git a/agent/modals/vendor/vendor.php b/agent/modals/vendor/vendor.php index d5d5a6e00..7f6c89993 100644 --- a/agent/modals/vendor/vendor.php +++ b/agent/modals/vendor/vendor.php @@ -28,6 +28,8 @@ $vendor_phone = contactPhoneLink($vendor['vendor_phone'], $vendor['vendor_phone_ $vendor_dial = vendorDialNumber($vendor['vendor_phone'], $vendor['vendor_phone_country_code']); $vendor_email = escapeHtml($vendor['vendor_email']); $vendor_website_url = vendorWebsiteUrl($vendor['vendor_website']); +$vendor_status_url = vendorWebsiteUrl($vendor['vendor_status_page']); +$vendor_portal_url = vendorWebsiteUrl($vendor['vendor_portal']); $vendor_account = escapeHtml($vendor['vendor_account_number']); $vendor_pin = vendorSecretHtml($vendor['vendor_code']); $vendor_notes = escapeHtml($vendor['vendor_notes']); @@ -125,6 +127,18 @@ ob_start();
+ +
+ + +
+ + +
+ + +
+
diff --git a/agent/modals/vendor/vendor_add.php b/agent/modals/vendor/vendor_add.php index 5fca9b300..6d8206def 100644 --- a/agent/modals/vendor/vendor_add.php +++ b/agent/modals/vendor/vendor_add.php @@ -21,7 +21,7 @@ $vendor_form_mode = 'add'; $vendor_id = 0; $vendor_name = $vendor_description = $vendor_account_number = $vendor_code = ''; $vendor_contact_name = $vendor_contact_phone_country_code = $vendor_contact_phone = $vendor_contact_email = ''; -$vendor_phone_country_code = $vendor_phone = $vendor_extension = $vendor_email = $vendor_website = ''; +$vendor_phone_country_code = $vendor_phone = $vendor_extension = $vendor_email = $vendor_website = $vendor_status_page = $vendor_portal = ''; $vendor_hours = $vendor_sla = $vendor_notes = ''; $vendor_favorite = 0; $vendor_template_id = 0; diff --git a/agent/modals/vendor/vendor_edit.php b/agent/modals/vendor/vendor_edit.php index e2a987ff9..f7d46816a 100644 --- a/agent/modals/vendor/vendor_edit.php +++ b/agent/modals/vendor/vendor_edit.php @@ -26,6 +26,8 @@ $vendor_phone = escapeHtml(formatPhoneNumber($vendor['vendor_phone'], $vendor['v $vendor_extension = escapeHtml($vendor['vendor_extension']); $vendor_email = escapeHtml($vendor['vendor_email']); $vendor_website = escapeHtml($vendor['vendor_website']); +$vendor_status_page = escapeHtml($vendor['vendor_status_page']); +$vendor_portal = escapeHtml($vendor['vendor_portal']); $vendor_hours = escapeHtml($vendor['vendor_hours']); $vendor_sla = escapeHtml($vendor['vendor_sla']); $vendor_notes = escapeHtml($vendor['vendor_notes']); diff --git a/agent/modals/vendor/vendor_form.php b/agent/modals/vendor/vendor_form.php index efb7fdf76..9621fe2dd 100644 --- a/agent/modals/vendor/vendor_form.php +++ b/agent/modals/vendor/vendor_form.php @@ -8,7 +8,8 @@ * (functions/vendor.php) reads. * * Tabs: Details (name, star, what they are, tags, template), Support (the - * number to ring, hours, SLA, support email and site), Account (the account + * number to ring, hours, SLA, support email and site, the status page and + * the portal cases are opened in), Account (the account * number and PIN they ask for, the account manager) and Notes. A new vendor * can start from a template, which fills every field in. * @@ -185,12 +186,28 @@ $form_id = 'vendorForm' . ($vendor_form_mode === 'edit' ? $vendor_id : 'New');
- +
-
Link the portal login itself from the vendor's Logins tab.
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
Where they post outages, and where you sign in to open a case - link the login itself from the vendor's Logins tab.
@@ -302,10 +319,11 @@ $form_id = 'vendorForm' . ($vendor_form_mode === 'edit' ? $vendor_id : 'New'); if (!fields) { return; } - ['name', 'description', 'account_number', 'code', 'contact_name', 'email', 'website', 'hours', 'sla', 'notes', 'extension'].forEach(function (name) { + ['name', 'description', 'account_number', 'code', 'contact_name', 'contact_email', 'email', 'website', 'status_page', 'portal', 'hours', 'sla', 'notes', 'extension'].forEach(function (name) { setField(name, fields[name]); }); setPhone('phone', fields.phone_country_code, fields.phone); + setPhone('contact_phone', fields.contact_phone_country_code, fields.contact_phone); }); }); diff --git a/agent/post/vendor.php b/agent/post/vendor.php index e22723721..d7a48e9f4 100644 --- a/agent/post/vendor.php +++ b/agent/post/vendor.php @@ -482,7 +482,7 @@ if (isExportRequest('export_vendors')) { LEFT JOIN clients ON client_id = vendor_client_id WHERE $archive_query AND (vendor_name LIKE '%$q%' OR vendor_description LIKE '%$q%' OR vendor_account_number LIKE '%$q%' OR vendor_website LIKE '%$q%' - OR vendor_contact_name LIKE '%$q%' OR vendor_email LIKE '%$q%' OR vendor_contact_email LIKE '%$q%' OR vendor_phone LIKE '%$q%' OR vendor_notes LIKE '%$q%') + OR vendor_status_page LIKE '%$q%' OR vendor_portal LIKE '%$q%' OR vendor_contact_name LIKE '%$q%' OR vendor_email LIKE '%$q%' OR vendor_contact_email LIKE '%$q%' OR vendor_phone LIKE '%$q%' OR vendor_notes LIKE '%$q%') $client_query $tag_query " . clientScopeSql('vendor_client_id') . " diff --git a/agent/ticket.php b/agent/ticket.php index aed9e4e0d..b9f9b9006 100644 --- a/agent/ticket.php +++ b/agent/ticket.php @@ -1562,6 +1562,11 @@ if (isset($_GET['ticket_id'])) {
+ + +
Status page
+ diff --git a/agent/vendor.php b/agent/vendor.php index 53cdf88cc..4f61d1f55 100644 --- a/agent/vendor.php +++ b/agent/vendor.php @@ -51,6 +51,8 @@ if (!$vendor || (isset($_GET['client_id']) && $vendor_client_id !== intval($clie $vendor_email = escapeHtml($vendor['vendor_email']); $vendor_website_url = vendorWebsiteUrl($vendor['vendor_website']); $vendor_website_label = escapeHtml(vendorWebsiteLabel($vendor['vendor_website'])); + $vendor_status_url = vendorWebsiteUrl($vendor['vendor_status_page']); + $vendor_portal_url = vendorWebsiteUrl($vendor['vendor_portal']); $vendor_hours = escapeHtml($vendor['vendor_hours']); $vendor_sla = escapeHtml($vendor['vendor_sla']); $vendor_account = escapeHtml($vendor['vendor_account_number']); @@ -277,6 +279,9 @@ if (!$vendor || (isset($_GET['client_id']) && $vendor_client_id !== intval($clie Support site + + Status page + diff --git a/agent/vendors.php b/agent/vendors.php index 2a449d74a..73322c017 100644 --- a/agent/vendors.php +++ b/agent/vendors.php @@ -81,7 +81,7 @@ $sql = mysqli_query( $mysqli, "SELECT SQL_CALC_FOUND_ROWS vendors.vendor_id, vendor_name, vendor_description, vendor_account_number, vendor_code, vendor_contact_name, vendor_contact_phone, vendor_contact_phone_country_code, vendor_contact_email, vendor_phone, vendor_phone_country_code, vendor_extension, - vendor_email, vendor_website, vendor_hours, vendor_sla, vendor_favorite, vendor_archived_at, vendor_client_id, vendor_template_name, + vendor_email, vendor_website, vendor_status_page, vendor_hours, vendor_sla, vendor_favorite, vendor_archived_at, vendor_client_id, vendor_template_name, client_id, client_name FROM vendors LEFT JOIN clients ON client_id = vendor_client_id @@ -89,7 +89,7 @@ $sql = mysqli_query( WHERE $archive_query $tag_query AND (vendor_name LIKE '%$q%' OR vendor_description LIKE '%$q%' OR vendor_account_number LIKE '%$q%' OR vendor_website LIKE '%$q%' - OR vendor_contact_name LIKE '%$q%' OR vendor_email LIKE '%$q%' OR vendor_contact_email LIKE '%$q%' OR vendor_notes LIKE '%$q%' + OR vendor_status_page LIKE '%$q%' OR vendor_portal LIKE '%$q%' OR vendor_contact_name LIKE '%$q%' OR vendor_email LIKE '%$q%' OR vendor_contact_email LIKE '%$q%' OR vendor_notes LIKE '%$q%' OR vendor_phone LIKE '%$phone_query%' OR vendor_contact_phone LIKE '%$phone_query%'" . ($view === 'clients' ? " OR client_name LIKE '%$q%'" : '') . " OR vendors.vendor_id IN (SELECT vendor_id FROM vendor_tags JOIN tags USING (tag_id) WHERE tag_name LIKE '%$q%')) $client_query @@ -324,6 +324,7 @@ $missing_templates = $view === 'client' && !$archived ? vendorTemplatesMissingFo $vendor_phone_text = $row['vendor_phone'] ? escapeHtml(formatPhoneNumber($row['vendor_phone'], $row['vendor_phone_country_code'])) : ''; $vendor_email = escapeHtml($row['vendor_email']); $vendor_website_url = vendorWebsiteUrl($row['vendor_website']); + $vendor_status_url = vendorWebsiteUrl($row['vendor_status_page']); $vendor_support_when = escapeHtml(implode(' · ', array_filter([$row['vendor_hours'], $row['vendor_sla']]))); $vendor_account = escapeHtml($row['vendor_account_number']); $vendor_pin = vendorSecretHtml($row['vendor_code']); @@ -367,13 +368,14 @@ $missing_templates = $view === 'client' && !$archived ? vendorTemplatesMissingFo
- +
+
- - + - diff --git a/api/v1/vendors/create.php b/api/v1/vendors/create.php index 9689a357b..d5fb73758 100644 --- a/api/v1/vendors/create.php +++ b/api/v1/vendors/create.php @@ -13,7 +13,7 @@ $insert_id = false; if (!empty($name)) { // Insert vendor - $insert_sql = mysqli_query($mysqli, "INSERT INTO vendors SET vendor_name = '$name', vendor_description = '$description', vendor_contact_name = '$contact_name', vendor_contact_phone_country_code = '$contact_phone_country_code', vendor_contact_phone = '$contact_phone', vendor_contact_email = '$contact_email', vendor_phone_country_code = '$phone_country_code', vendor_phone = '$phone', vendor_extension = '$extension', vendor_email = '$email', vendor_website = '$website', vendor_hours = '$hours', vendor_sla = '$sla', vendor_code = '$code', vendor_account_number = '$account_number', vendor_notes = '$notes', vendor_client_id = $client_id"); + $insert_sql = mysqli_query($mysqli, "INSERT INTO vendors SET vendor_name = '$name', vendor_description = '$description', vendor_contact_name = '$contact_name', vendor_contact_phone_country_code = '$contact_phone_country_code', vendor_contact_phone = '$contact_phone', vendor_contact_email = '$contact_email', vendor_phone_country_code = '$phone_country_code', vendor_phone = '$phone', vendor_extension = '$extension', vendor_email = '$email', vendor_website = '$website', vendor_status_page = '$status_page', vendor_portal = '$portal', vendor_hours = '$hours', vendor_sla = '$sla', vendor_code = '$code', vendor_account_number = '$account_number', vendor_notes = '$notes', vendor_client_id = $client_id"); // Check insert & get insert ID if ($insert_sql) { diff --git a/api/v1/vendors/update.php b/api/v1/vendors/update.php index ecd87b061..c520697ac 100644 --- a/api/v1/vendors/update.php +++ b/api/v1/vendors/update.php @@ -18,7 +18,7 @@ if (!empty($vendor_id)) { require_once 'vendor_model.php'; if ($vendor_row) { - $update_sql = mysqli_query($mysqli, "UPDATE vendors SET vendor_name = '$name', vendor_description = '$description', vendor_contact_name = '$contact_name', vendor_contact_phone_country_code = '$contact_phone_country_code', vendor_contact_phone = '$contact_phone', vendor_contact_email = '$contact_email', vendor_phone_country_code = '$phone_country_code', vendor_phone = '$phone', vendor_extension = '$extension', vendor_email = '$email', vendor_website = '$website', vendor_hours = '$hours', vendor_sla = '$sla', vendor_code = '$code', vendor_account_number = '$account_number', vendor_notes = '$notes' WHERE vendor_id = $vendor_id AND vendor_client_id = $client_id AND vendor_archived_at IS NULL LIMIT 1"); + $update_sql = mysqli_query($mysqli, "UPDATE vendors SET vendor_name = '$name', vendor_description = '$description', vendor_contact_name = '$contact_name', vendor_contact_phone_country_code = '$contact_phone_country_code', vendor_contact_phone = '$contact_phone', vendor_contact_email = '$contact_email', vendor_phone_country_code = '$phone_country_code', vendor_phone = '$phone', vendor_extension = '$extension', vendor_email = '$email', vendor_website = '$website', vendor_status_page = '$status_page', vendor_portal = '$portal', vendor_hours = '$hours', vendor_sla = '$sla', vendor_code = '$code', vendor_account_number = '$account_number', vendor_notes = '$notes' WHERE vendor_id = $vendor_id AND vendor_client_id = $client_id AND vendor_archived_at IS NULL LIMIT 1"); // Check update & get affected rows if ($update_sql) { diff --git a/api/v1/vendors/vendor_model.php b/api/v1/vendors/vendor_model.php index 3c2f8d36b..04c46f8d9 100644 --- a/api/v1/vendors/vendor_model.php +++ b/api/v1/vendors/vendor_model.php @@ -98,6 +98,22 @@ if (isset($_POST['vendor_website'])) { $website = ''; } +if (isset($_POST['vendor_status_page'])) { + $status_page = preg_replace("(^https?://)", "", escapeSql($_POST['vendor_status_page'])); +} elseif ($vendor_row) { + $status_page = mysqli_real_escape_string($mysqli, (string) $vendor_row['vendor_status_page']); +} else { + $status_page = ''; +} + +if (isset($_POST['vendor_portal'])) { + $portal = preg_replace("(^https?://)", "", escapeSql($_POST['vendor_portal'])); +} elseif ($vendor_row) { + $portal = mysqli_real_escape_string($mysqli, (string) $vendor_row['vendor_portal']); +} else { + $portal = ''; +} + if (isset($_POST['vendor_hours'])) { $hours = escapeSql($_POST['vendor_hours']); } elseif ($vendor_row) { diff --git a/db.sql b/db.sql index 6f5e25ab8..5b82828bf 100644 --- a/db.sql +++ b/db.sql @@ -4017,11 +4017,16 @@ CREATE TABLE `vendor_templates` ( `vendor_template_name` varchar(200) NOT NULL, `vendor_template_description` varchar(200) DEFAULT NULL, `vendor_template_contact_name` varchar(200) DEFAULT NULL, + `vendor_template_contact_phone_country_code` varchar(10) DEFAULT NULL, + `vendor_template_contact_phone` varchar(200) DEFAULT NULL, + `vendor_template_contact_email` varchar(200) DEFAULT NULL, `vendor_template_phone_country_code` varchar(10) DEFAULT NULL, `vendor_template_phone` varchar(200) DEFAULT NULL, `vendor_template_extension` varchar(200) DEFAULT NULL, `vendor_template_email` varchar(200) DEFAULT NULL, `vendor_template_website` varchar(200) DEFAULT NULL, + `vendor_template_status_page` varchar(200) DEFAULT NULL, + `vendor_template_portal` varchar(200) DEFAULT NULL, `vendor_template_hours` varchar(200) DEFAULT NULL, `vendor_template_sla` varchar(200) DEFAULT NULL, `vendor_template_code` varchar(200) DEFAULT NULL, @@ -4057,6 +4062,8 @@ CREATE TABLE `vendors` ( `vendor_extension` varchar(200) DEFAULT NULL, `vendor_email` varchar(200) DEFAULT NULL, `vendor_website` varchar(200) DEFAULT NULL, + `vendor_status_page` varchar(200) DEFAULT NULL, + `vendor_portal` varchar(200) DEFAULT NULL, `vendor_hours` varchar(200) DEFAULT NULL, `vendor_sla` varchar(200) DEFAULT NULL, `vendor_code` varchar(200) DEFAULT NULL, diff --git a/functions.php b/functions.php index ca2437a74..76b77db05 100644 --- a/functions.php +++ b/functions.php @@ -64,6 +64,7 @@ require_once __DIR__ . '/functions/canned_response_transfer.php'; require_once __DIR__ . '/functions/document_template.php'; require_once __DIR__ . '/functions/document_template_transfer.php'; require_once __DIR__ . '/functions/software_template_transfer.php'; +require_once __DIR__ . '/functions/vendor_template_transfer.php'; require_once __DIR__ . '/functions/project.php'; require_once __DIR__ . '/functions/service.php'; require_once __DIR__ . '/functions/client_export.php'; diff --git a/functions/client_export.php b/functions/client_export.php index d3fcc82b5..abc205775 100644 --- a/functions/client_export.php +++ b/functions/client_export.php @@ -275,7 +275,7 @@ function clientExportDatasets() { ] + $archived], 'vendors' => ['section' => 'vendors', 'label' => 'Vendors', 'columns' => [ 'id' => 'ID', 'name' => 'Name', 'description' => 'Description', 'account_number' => 'Account number', 'code' => 'Code / PIN', 'phone' => 'Phone', - 'email' => 'Email', 'website' => 'Website', 'hours' => 'Hours', 'sla' => 'SLA', 'contact_name' => 'Account manager', 'contact_phone' => 'Account manager phone', + 'email' => 'Email', 'website' => 'Website', 'status_page' => 'Status page', 'portal' => 'Support portal', 'hours' => 'Hours', 'sla' => 'SLA', 'contact_name' => 'Account manager', 'contact_phone' => 'Account manager phone', 'contact_email' => 'Account manager email', 'tags' => 'Tags', 'notes' => 'Notes', ] + $archived], 'networks' => ['section' => 'networks', 'label' => 'Networks', 'columns' => [ @@ -527,7 +527,7 @@ function clientExportRows($dataset, $client_id, $options) { $id = intval($row['vendor_id']); $rows[] = ['id' => $id, 'name' => $row['vendor_name'], 'description' => $row['vendor_description'], 'account_number' => $row['vendor_account_number'], 'code' => $row['vendor_code'], 'phone' => clientExportPhone($row['vendor_phone'], $row['vendor_phone_country_code'], $row['vendor_extension']), - 'email' => $row['vendor_email'], 'website' => $row['vendor_website'], 'hours' => $row['vendor_hours'], 'sla' => $row['vendor_sla'], + 'email' => $row['vendor_email'], 'website' => $row['vendor_website'], 'status_page' => $row['vendor_status_page'], 'portal' => $row['vendor_portal'], 'hours' => $row['vendor_hours'], 'sla' => $row['vendor_sla'], 'contact_name' => $row['vendor_contact_name'], 'contact_phone' => clientExportPhone($row['vendor_contact_phone'], $row['vendor_contact_phone_country_code']), 'contact_email' => $row['vendor_contact_email'], 'tags' => $tags[$id] ?? '', 'notes' => $row['vendor_notes'], 'created' => $row['vendor_created_at'], 'archived' => clientExportDate($row['vendor_archived_at'])]; diff --git a/functions/export.php b/functions/export.php index 1eccdf1ba..2208585f1 100644 --- a/functions/export.php +++ b/functions/export.php @@ -111,6 +111,8 @@ function getExportColumns($export_type) { 'vendor_extension' => ['label' => 'Extension', 'default' => false], 'vendor_email' => ['label' => 'Support Email', 'weight' => 2], 'vendor_website' => ['label' => 'Website', 'weight' => 2], + 'vendor_status_page' => ['label' => 'Status Page', 'weight' => 2, 'default' => false], + 'vendor_portal' => ['label' => 'Support Portal', 'weight' => 2, 'default' => false], 'vendor_hours' => ['label' => 'Hours'], 'vendor_sla' => ['label' => 'SLA', 'default' => false], 'vendor_account_number' => ['label' => 'Account Number'], diff --git a/functions/software_template_transfer.php b/functions/software_template_transfer.php index 9531e5f94..39f47fef9 100644 --- a/functions/software_template_transfer.php +++ b/functions/software_template_transfer.php @@ -10,8 +10,8 @@ * "exported_at", "templates": [...]}. Each template carries what a license * made from it starts with - its name, version, summary, type, license type, * notes, seats and renewal term - its vendor, as the vendor template it comes - * from (its name, summary, phone, email, website, hours, SLA and notes, never - * an account number or code), and the name of the product it is sold as. Not + * from (its name, summary, phone, email, website, status page, portal, hours, + * SLA and notes, never an account number or code), and the name of the product it is sold as. Not * who should have one: that is the call of the install it goes into - a new * one comes in required of no one, and one replaced keeps its own. Coming in, * a vendor template is matched by name, and made from the file when there is @@ -22,7 +22,8 @@ const SOFTWARE_TEMPLATE_FILE_FORMAT = 'itflow-license-templates'; const SOFTWARE_TEMPLATE_FILE_VERSION = 1; /** The vendor template fields that go in a file - its own account numbers and codes don't. */ -const SOFTWARE_TEMPLATE_FILE_VENDOR_FIELDS = ['description' => 200, 'phone_country_code' => 10, 'phone' => 200, 'extension' => 200, 'email' => 200, 'website' => 200, 'hours' => 200, 'sla' => 200, 'notes' => 0]; +const SOFTWARE_TEMPLATE_FILE_VENDOR_FIELDS = ['description' => 200, 'phone_country_code' => 10, 'phone' => 200, 'extension' => 200, 'email' => 200, 'website' => 200, 'status_page' => 200, 'portal' => 200, + 'hours' => 200, 'sla' => 200, 'notes' => 0]; /* * Templates as a file's contents (an array, for json_encode()) - the ones in @@ -159,8 +160,8 @@ function softwareTemplateImportVendor(array $vendor, &$made = false) { if (in_array($field, ['phone_country_code', 'phone', 'extension'], true)) { $value = preg_replace('/[^0-9]/', '', $value); } - if ($field === 'website') { - $value = preg_replace('#^https?://#i', '', $value); + if (in_array($field, ['website', 'status_page', 'portal'], true)) { + $value = rtrim(preg_replace('#^https?://#i', '', $value), '/'); } $set[] = "vendor_template_$field = '" . escapeSql($value) . "'"; } diff --git a/functions/vendor.php b/functions/vendor.php index f8fd25331..6aaa74e02 100644 --- a/functions/vendor.php +++ b/functions/vendor.php @@ -121,7 +121,11 @@ function vendorWebsiteUrl($website) { if ($website === '') { return ''; } - return preg_match('#^[a-z][a-z0-9+.-]*://#i', $website) ? $website : "https://$website"; + // Only a web address is a link - one saved with any other scheme isn't + if (preg_match('#^([a-z][a-z0-9+.-]*)://#i', $website, $scheme)) { + return in_array(strtolower($scheme[1]), ['http', 'https'], true) ? $website : ''; + } + return "https://$website"; } /** The website without its scheme or a trailing slash - how it reads on a page. Raw in, raw out. */ @@ -173,7 +177,8 @@ function vendorRow($vendor_id) { /** * The vendor fields a New / Edit form posts, as a SQL SET list - phone - * numbers as digits, the website without its scheme, only a template that + * numbers as digits, the website, status page and portal without their + * scheme or a trailing slash, only a template that * exists. Null when there is no name. Fields missing from the post are blank. */ function vendorFormSet($post) { @@ -194,7 +199,9 @@ function vendorFormSet($post) { if ($template_id && !mysqli_num_rows(mysqli_query($mysqli, "SELECT 1 FROM vendor_templates WHERE vendor_template_id = $template_id"))) { $template_id = 0; } - $website = mb_substr(preg_replace('#^https?://#i', '', trim((string) ($post['website'] ?? ''))), 0, 200); + $site = function ($field) use ($post) { + return "'" . escapeSql(mb_substr(rtrim(preg_replace('#^https?://#i', '', trim((string) ($post[$field] ?? ''))), '/'), 0, 200)) . "'"; + }; $fields = [ 'vendor_name' => $text('name'), @@ -209,7 +216,9 @@ function vendorFormSet($post) { 'vendor_phone' => $digits('phone'), 'vendor_extension' => $digits('extension'), 'vendor_email' => $text('email'), - 'vendor_website' => "'" . escapeSql($website) . "'", + 'vendor_website' => $site('website'), + 'vendor_status_page' => $site('status_page'), + 'vendor_portal' => $site('portal'), 'vendor_hours' => $text('hours'), 'vendor_sla' => $text('sla'), 'vendor_notes' => $text('notes', 0), @@ -252,6 +261,11 @@ function vendorTemplateFormFields($template) { 'extension' => $template['vendor_template_extension'] ?? '', 'email' => $template['vendor_template_email'] ?? '', 'website' => $template['vendor_template_website'] ?? '', + 'status_page' => $template['vendor_template_status_page'] ?? '', + 'portal' => $template['vendor_template_portal'] ?? '', + 'contact_phone_country_code' => $template['vendor_template_contact_phone_country_code'] ?? '', + 'contact_phone' => $template['vendor_template_contact_phone'] ?? '', + 'contact_email' => $template['vendor_template_contact_email'] ?? '', 'hours' => $template['vendor_template_hours'] ?? '', 'sla' => $template['vendor_template_sla'] ?? '', 'notes' => $template['vendor_template_notes'] ?? '', diff --git a/functions/vendor_template.php b/functions/vendor_template.php index 37347e2b9..b13f535dc 100644 --- a/functions/vendor_template.php +++ b/functions/vendor_template.php @@ -13,6 +13,11 @@ * * From database 3.3.4 on a template says who made it and last changed it. * + * From database 3.3.6 on a template - and a vendor - has a status page and + * the support portal cases are opened in, and a template the account + * manager's line and email, as a vendor has them. Templates go to and from a + * file in functions/vendor_template_transfer.php. + * * From database 3.3.5 on a template says who should have it - every client, * or the clients with one of its tags - so the clients missing it show on its * page, on their Vendors and on their overview. The vendors made from it are @@ -45,16 +50,21 @@ function vendorTemplateFieldLabels() { 'sla' => ['label' => 'SLA', 'icon' => 'fa-handshake'], 'email' => ['label' => 'Support email', 'icon' => 'fa-envelope'], 'website' => ['label' => 'Support site', 'icon' => 'fa-globe'], + 'status_page' => ['label' => 'Status page', 'icon' => 'fa-heartbeat'], + 'portal' => ['label' => 'Support portal', 'icon' => 'fa-door-open'], 'account_number' => ['label' => 'Account number', 'icon' => 'fa-fingerprint'], 'code' => ['label' => 'PIN', 'icon' => 'fa-key'], 'contact_name' => ['label' => 'Account manager', 'icon' => 'fa-user-tie'], + 'contact_phone' => ['label' => "Account manager's line", 'icon' => 'fa-phone-alt'], + 'contact_email' => ['label' => "Account manager's email", 'icon' => 'fa-at'], 'notes' => ['label' => 'Notes', 'icon' => 'fa-sticky-note'], ]; } /* * Each field's columns - on the template, and on a vendor made from it. The - * support line is three: country code, number and extension. + * support line is three: country code, number and extension; the account + * manager's line two. */ function vendorTemplateColumns() { return [ @@ -65,9 +75,13 @@ function vendorTemplateColumns() { 'sla' => [['vendor_template_sla', 'vendor_sla']], 'email' => [['vendor_template_email', 'vendor_email']], 'website' => [['vendor_template_website', 'vendor_website']], + 'status_page' => [['vendor_template_status_page', 'vendor_status_page']], + 'portal' => [['vendor_template_portal', 'vendor_portal']], 'account_number' => [['vendor_template_account_number', 'vendor_account_number']], 'code' => [['vendor_template_code', 'vendor_code']], 'contact_name' => [['vendor_template_contact_name', 'vendor_contact_name']], + 'contact_phone' => [['vendor_template_contact_phone_country_code', 'vendor_contact_phone_country_code'], ['vendor_template_contact_phone', 'vendor_contact_phone']], + 'contact_email' => [['vendor_template_contact_email', 'vendor_contact_email']], 'notes' => [['vendor_template_notes', 'vendor_notes']], ]; } @@ -113,8 +127,10 @@ function vendorTemplateUsage(array $vendor_template_ids) { /* * The window's fields, checked - or null with $error saying why. Raw values: * name, description, phone_country_code, phone and extension (digits), - * hours, sla, email, website (without its scheme), account_number, code, - * contact_name and notes - as vendorFormSet() reads a vendor's. + * hours, sla, email, website, status_page and portal (without their scheme + * or a trailing slash), account_number, code, contact_name, + * contact_phone_country_code and contact_phone (digits), contact_email and + * notes - as vendorFormSet() reads a vendor's. */ function vendorTemplateFields(array $post, &$error = '') { $error = ''; @@ -139,7 +155,16 @@ function vendorTemplateFields(array $post, &$error = '') { $error = "The support email isn't an email address"; return null; } + $contact_email = $line('contact_email'); + if ($contact_email !== '' && !filter_var($contact_email, FILTER_VALIDATE_EMAIL)) { + $error = "The account manager's email isn't an email address"; + return null; + } + $site = function ($key) use ($line) { + return mb_substr(rtrim(preg_replace('#^https?://#i', '', $line($key)), '/'), 0, 200); + }; $phone = $digits('phone'); + $contact_phone = $digits('contact_phone'); return [ 'name' => $name, 'description' => $line('description'), @@ -149,31 +174,28 @@ function vendorTemplateFields(array $post, &$error = '') { 'hours' => $line('hours'), 'sla' => $line('sla'), 'email' => $email, - 'website' => mb_substr(rtrim(preg_replace('#^https?://#i', '', $line('website')), '/'), 0, 200), + 'website' => $site('website'), + 'status_page' => $site('status_page'), + 'portal' => $site('portal'), 'account_number' => $line('account_number'), 'code' => $line('code'), 'contact_name' => $line('contact_name'), + 'contact_phone_country_code' => $contact_phone !== '' ? $digits('contact_phone_country_code', 10) : '', + 'contact_phone' => $contact_phone, + 'contact_email' => $contact_email, 'notes' => is_scalar($post['notes'] ?? null) ? trim((string) $post['notes']) : '', ]; } -/** A template's fields as vendorTemplateFields() gives them - from its row, for comparing and copying. */ +/** A template's fields as vendorTemplateFields() gives them - from its row (blank for none), for comparing and copying. */ function vendorTemplateRowFields(array $row) { - return [ - 'name' => (string) $row['vendor_template_name'], - 'description' => (string) $row['vendor_template_description'], - 'phone_country_code' => (string) $row['vendor_template_phone_country_code'], - 'phone' => (string) $row['vendor_template_phone'], - 'extension' => (string) $row['vendor_template_extension'], - 'hours' => (string) $row['vendor_template_hours'], - 'sla' => (string) $row['vendor_template_sla'], - 'email' => (string) $row['vendor_template_email'], - 'website' => (string) $row['vendor_template_website'], - 'account_number' => (string) $row['vendor_template_account_number'], - 'code' => (string) $row['vendor_template_code'], - 'contact_name' => (string) $row['vendor_template_contact_name'], - 'notes' => (string) $row['vendor_template_notes'], - ]; + $fields = []; + foreach (vendorTemplateColumns() as $pairs) { + foreach ($pairs as [$template_column]) { + $fields[substr($template_column, strlen('vendor_template_'))] = (string) ($row[$template_column] ?? ''); + } + } + return $fields; } /* Save a template's fields (vendorTemplateFields()) - a new one with $vendor_template_id 0. Its id. */ @@ -305,14 +327,14 @@ function vendorTemplatePhoneHtml($phone, $country_code, $extension) { /* ---------------------------------------------------------------- in step */ /** The fields a vendor keeps in step with its template. Its account number, PIN and account manager are its client's own. */ -const VENDOR_TEMPLATE_SHARED = ['name', 'description', 'phone', 'hours', 'sla', 'email', 'website', 'notes']; +const VENDOR_TEMPLATE_SHARED = ['name', 'description', 'phone', 'hours', 'sla', 'email', 'website', 'status_page', 'portal', 'notes']; /* * A field of a row as it compares - $side 'template' for a template's * columns, 'vendor' for a vendor's. Text by its words, whatever the case and - * spacing; the email by its address; the site without its scheme, www or a - * trailing slash; notes by their lines; the support line as [country code, - * number, extension] digits. + * spacing; the email by its address; the site, status page and portal + * without their scheme, www or a trailing slash; notes by their lines; the + * support line as [country code, number, extension] digits. */ function vendorTemplateCompareValue($key, array $row, $side) { $index = $side === 'template' ? 0 : 1; @@ -326,6 +348,8 @@ function vendorTemplateCompareValue($key, array $row, $side) { return preg_replace('/[^0-9]/', '', $value); }, $values); case 'website': + case 'status_page': + case 'portal': return mb_strtolower(rtrim(preg_replace(['#^[a-z][a-z0-9+.-]*://#i', '#^www\.#i'], '', $values[0]), '/')); case 'notes': return trim(preg_replace("/[ \t]+\n/", "\n", str_replace(["\r\n", "\r"], "\n", $values[0]))); @@ -386,6 +410,10 @@ function vendorTemplateValueHtml($key, array $row, $side) { return vendorTemplatePhoneHtml($values[1], $values[0], $values[2]); } $value = trim((string) ($row[$columns[0][$index]] ?? '')); + if ($value !== '' && in_array($key, ['website', 'status_page', 'portal'], true)) { + $url = vendorWebsiteUrl($value); + return $url !== '' ? '' . escapeHtml(vendorWebsiteLabel($value)) . '' : escapeHtml($value); + } return $value === '' ? '' : ($key === 'notes' ? '' . escapeHtml($value) . '' : escapeHtml($value)); } diff --git a/functions/vendor_template_transfer.php b/functions/vendor_template_transfer.php new file mode 100644 index 000000000..3060f3a0d --- /dev/null +++ b/functions/vendor_template_transfer.php @@ -0,0 +1,228 @@ + Vendor Templates exports them + * (export_vendor_templates) and imports them (import_vendor_templates); the + * Vendor Templates pack on Maintenance > Starter Content is loaded and + * brought up to date the same way. + * + * The file is JSON: {"format": "itflow-vendor-templates", "version": 1, + * "exported_at", "templates": [...]}. Each template carries what a vendor + * made from it starts with - its name, what they are, the support line, + * hours, SLA, support email, site, status page and portal, and its notes - + * and, when asked for, the account manager. Never an account number or PIN: + * those stay in the install they belong to, and one coming in keeps its own. + * Not who should have one either: a new one comes in required of no one, and + * one replaced keeps its own. A template replaced from a file changes the + * vendors made from it that still said what it did (vendorTemplateFollow()). + */ + +const VENDOR_TEMPLATE_FILE_FORMAT = 'itflow-vendor-templates'; +const VENDOR_TEMPLATE_FILE_VERSION = 1; + +/** The fields that go in a file - key => longest it may be (0 for notes). Never the account number or PIN. */ +const VENDOR_TEMPLATE_FILE_FIELDS = ['description' => 200, 'phone_country_code' => 10, 'phone' => 200, 'extension' => 200, 'hours' => 200, 'sla' => 200, 'email' => 200, + 'website' => 200, 'status_page' => 200, 'portal' => 200, 'notes' => 0]; + +/** ...and the account manager's, when asked for. */ +const VENDOR_TEMPLATE_FILE_MANAGER_FIELDS = ['contact_name' => 200, 'contact_phone_country_code' => 10, 'contact_phone' => 200, 'contact_email' => 200]; + +/* + * Templates as a file's contents (an array, for json_encode()) - the ones in + * $ids, or every current one when $ids is empty, the archived ones too with + * $archived, and their account manager with $manager. + */ +function vendorTemplatesExport(array $ids = [], $archived = false, $manager = false) { + global $mysqli; + $ids = array_values(array_filter(array_map('intval', $ids))); + $where = $ids ? 'vendor_template_id IN (' . implode(',', $ids) . ')' : ($archived ? '1 = 1' : 'vendor_template_archived_at IS NULL'); + $fields = VENDOR_TEMPLATE_FILE_FIELDS + ($manager ? VENDOR_TEMPLATE_FILE_MANAGER_FIELDS : []); + $templates = []; + $sql = mysqli_query($mysqli, "SELECT * FROM vendor_templates WHERE $where ORDER BY vendor_template_name ASC, vendor_template_id ASC"); + while ($row = mysqli_fetch_assoc($sql)) { + $template = ['name' => (string) $row['vendor_template_name']]; + foreach (array_keys($fields) as $field) { + $template[$field] = (string) $row["vendor_template_$field"]; + } + $templates[] = $template; + } + return ['format' => VENDOR_TEMPLATE_FILE_FORMAT, 'version' => VENDOR_TEMPLATE_FILE_VERSION, 'exported_at' => date('c'), 'templates' => $templates]; +} + +/** A file name for an export - its one template's name, or the date. */ +function vendorTemplatesExportFileName(array $export) { + $templates = $export['templates'] ?? []; + if (count($templates) === 1) { + $slug = trim(preg_replace('/[^a-z0-9]+/', '-', strtolower($templates[0]['name'])), '-'); + return 'vendor-template-' . ($slug !== '' ? substr($slug, 0, 60) : 'export') . '.json'; + } + return 'vendor-templates-' . date('Y-m-d') . '.json'; +} + +/* + * One field from a file, made safe as the template window would save it - + * text tidied (softwareTemplateFileText() and softwareTemplateFileNotes(), + * shared with the license templates file), phone numbers as digits, sites + * without their scheme or a trailing slash, an email that isn't one dropped. + */ +function vendorTemplateFileValue($field, $value, $max) { + if (!$max) { + return softwareTemplateFileNotes($value); + } + $value = softwareTemplateFileText($value, $max); + if (in_array($field, ['phone_country_code', 'phone', 'extension', 'contact_phone_country_code', 'contact_phone'], true)) { + return substr(preg_replace('/[^0-9]/', '', $value), 0, $max); + } + if (in_array($field, ['website', 'status_page', 'portal'], true)) { + return mb_substr(rtrim(preg_replace('#^https?://#i', '', $value), '/'), 0, $max); + } + if (in_array($field, ['email', 'contact_email'], true) && $value !== '' && !filter_var($value, FILTER_VALIDATE_EMAIL)) { + return ''; + } + return $value; +} + +/* + * A file's templates, read and checked - from what export wrote, or the + * starter pack. Each made safe (vendorTemplateFileValue()), with only the + * fields a file carries: an account number or PIN in it is left out. The + * account manager only when the file has one - 'manager' says whether it + * does. Null with $error saying why the file can't be used. + */ +function vendorTemplatesImportRead($contents, &$error = '') { + $data = is_array($contents) ? $contents : json_decode((string) $contents, true); + if (!is_array($data) || ($data['format'] ?? '') !== VENDOR_TEMPLATE_FILE_FORMAT || !isset($data['templates']) || !is_array($data['templates'])) { + $error = "That isn't a vendor templates file - export one from Admin > Vendor Templates."; + return null; + } + if (intval($data['version'] ?? 0) > VENDOR_TEMPLATE_FILE_VERSION) { + $error = 'That file was made by a newer version of ITFlow - update this one first.'; + return null; + } + if (count($data['templates']) > 1000) { + $error = 'That file holds more than 1000 vendor templates - split it up.'; + return null; + } + + $templates = []; + foreach ($data['templates'] as $t) { + $name = is_array($t) ? softwareTemplateFileText($t['name'] ?? '', 200) : ''; + if ($name === '') { + continue; + } + $template = ['name' => $name]; + foreach (VENDOR_TEMPLATE_FILE_FIELDS as $field => $max) { + $template[$field] = vendorTemplateFileValue($field, $t[$field] ?? '', $max); + } + if ($template['phone'] === '') { + $template['phone_country_code'] = ''; + } + $template['manager'] = false; + foreach (array_keys(VENDOR_TEMPLATE_FILE_MANAGER_FIELDS) as $field) { + $template['manager'] = $template['manager'] || array_key_exists($field, $t); + } + if ($template['manager']) { + foreach (VENDOR_TEMPLATE_FILE_MANAGER_FIELDS as $field => $max) { + $template[$field] = vendorTemplateFileValue($field, $t[$field] ?? '', $max); + } + } + $templates[] = $template; + } + if (!$templates) { + $error = 'There are no vendor templates in that file.'; + return null; + } + return $templates; +} + +/* + * Bring in templates read by vendorTemplatesImportRead(). One whose name is + * already here is skipped ($existing 'skip'), added under a name of its own + * ('copy') or has what the file says put in its place ('replace') - every + * field the file carries, or only the ones in $only_fields (the starter + * pack's update). A template replaced keeps its own account number and PIN, + * its account manager unless the file has one, and who should have it; the + * vendors made from it that still said what it did change with it. A new one + * is required of no one. $only_existing: bring in only the ones already here + * (to update them). ['added', 'replaced', 'skipped', 'ids' of those added or + * replaced, 'followed' - [template name => vendorTemplateFollow()'s vendors]]. + */ +function vendorTemplatesImport(array $templates, $existing = 'skip', $only_existing = false, array $only_fields = []) { + global $mysqli; + $counts = ['added' => 0, 'replaced' => 0, 'skipped' => 0, 'ids' => [], 'followed' => []]; + $here = []; + $sql = mysqli_query($mysqli, "SELECT vendor_template_id, vendor_template_name FROM vendor_templates ORDER BY vendor_template_archived_at IS NOT NULL, vendor_template_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $key = mb_strtolower($row['vendor_template_name']); + $here[$key] = $here[$key] ?? intval($row['vendor_template_id']); + } + + foreach ($templates as $t) { + $id = $here[mb_strtolower($t['name'])] ?? 0; + if (($only_existing && !$id) || ($id && $existing === 'skip')) { + $counts['skipped']++; + continue; + } + $name = $t['name']; + if ($id && $existing === 'copy') { + for ($n = 2; isset($here[mb_strtolower($name)]); $n++) { + $name = mb_substr($t['name'], 0, 190) . " ($n)"; + } + $id = 0; + } + + // What it has now - nothing, for a new one - with the file's fields put in + $before = $id ? vendorTemplateRowFields(vendorTemplateInfo($id)) : vendorTemplateRowFields([]); + $fields = $before; + $fields['name'] = $name; + $from_file = array_keys(VENDOR_TEMPLATE_FILE_FIELDS + (!empty($t['manager']) ? VENDOR_TEMPLATE_FILE_MANAGER_FIELDS : [])); + foreach ($only_fields ? array_intersect($from_file, $only_fields) : $from_file as $field) { + $fields[$field] = (string) ($t[$field] ?? ''); + } + if ($fields['phone'] === '') { + $fields['phone_country_code'] = ''; + } + + $saved = vendorTemplateSave($id, $fields); + $here[mb_strtolower($name)] = $saved; + $counts[$id ? 'replaced' : 'added']++; + $counts['ids'][] = $saved; + if ($id) { + $followed = vendorTemplateFollow($saved, $before, $fields); + if ($followed) { + $counts['followed'][$name] = $followed; + } + } + } + return $counts; +} + +/* Each vendor that changed with a template brought in from a file, in its own history. */ +function vendorTemplatesImportLogFollowed(array $counts) { + global $session_name; + foreach ($counts['followed'] as $template_name => $vendors) { + foreach ($vendors as $vendor_id => $vendor) { + logAudit("Vendor", "Edit", "$session_name updated vendor " . escapeSql($vendor['name']) . " from template " . escapeSql($template_name) . " - " . escapeSql(mb_strtolower(implode(', ', $vendor['fields']))), $vendor['client_id'], $vendor_id); + } + } +} + +/* How many vendors changed with the templates brought in. */ +function vendorTemplatesImportFollowedCount(array $counts) { + return array_sum(array_map('count', $counts['followed'])); +} + +/* What an import did, in words - for its message. HTML. */ +function vendorTemplatesImportSummary(array $counts) { + $parts = []; + foreach (['added' => 'added', 'replaced' => 'replaced', 'skipped' => 'already here, left as they were'] as $key => $word) { + if ($counts[$key]) { + $parts[] = '' . $counts[$key] . ' ' . $word; + } + } + $followed = vendorTemplatesImportFollowedCount($counts); + return ucfirst(implode(', ', $parts) ?: 'nothing brought in') + . ($followed ? '. ' . $followed . ' vendor' . ($followed === 1 ? '' : 's') . ' made from them changed with them' : '') + . '.'; +}