mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 15:07:24 +00:00
347 lines
18 KiB
PHP
347 lines
18 KiB
PHP
<?php
|
|
|
|
/*
|
|
* Get started - the walk through what is left to set up once ITFlow is
|
|
* installed (admin/getting_started.php, its steps in admin/getting_started/,
|
|
* saved by admin/post/getting_started.php): cron, email, backups, billing,
|
|
* support, the team, starter content and a first client - each a short form
|
|
* of its own, or Skip for now.
|
|
*
|
|
* A step is done when the setting it is about is: cron heard from and on, a
|
|
* way to send email, backups scheduled, an hourly rate, default SLAs, a
|
|
* second agent, starter content, a client. So it ticks itself off however it
|
|
* was set - here or on the settings page - and a step skipped can be done
|
|
* later.
|
|
*
|
|
* On a new install (settings.config_getting_started, on in db.sql) the first
|
|
* admin to sign in is taken to it once (config_getting_started_opened_at),
|
|
* and the dashboard shows how far it has got until every step is done or
|
|
* skipped, or an admin dismisses it. An install updated to 3.4.7 has it off,
|
|
* and an admin opens it from Admin > Get started.
|
|
*/
|
|
|
|
/** The steps, in order - key => nav label, title, icon and what it is for. Billing and Support go with their modules. */
|
|
const GETTING_STARTED_STEPS = [
|
|
'cron' => ['label' => 'Cron', 'title' => 'Background jobs', 'icon' => 'fa-clock',
|
|
'about' => 'Everything that happens without someone clicking - email going out, mail turned into tickets, invoices made, backups taken - is run by cron, once a minute.'],
|
|
'mail' => ['label' => 'Email', 'title' => 'Email', 'icon' => 'fa-envelope',
|
|
'about' => 'The mailbox ITFlow sends from - ticket updates, invoices, quotes and alerts - and, if you like, a support mailbox it turns into tickets.'],
|
|
'backups' => ['label' => 'Backups', 'title' => 'Backups', 'icon' => 'fa-save',
|
|
'about' => 'A backup every night - the database and your files, locked with your backup key - and how many to keep.'],
|
|
'billing' => ['label' => 'Billing', 'title' => 'Billing', 'icon' => 'fa-file-invoice-dollar', 'module' => 'accounting',
|
|
'about' => 'What you charge an hour, when invoices are due, how invoices and quotes are numbered, your tax, and card payments.'],
|
|
'support' => ['label' => 'Support', 'title' => 'Support', 'icon' => 'fa-life-ring', 'module' => 'ticketing',
|
|
'about' => 'Your business hours, how fast you answer each priority, how tickets are numbered, and the client portal.'],
|
|
'team' => ['label' => 'Team', 'title' => 'Your team', 'icon' => 'fa-users',
|
|
'about' => 'The people who work tickets, send invoices and look after clients with you.'],
|
|
'content' => ['label' => 'Content', 'title' => 'Starter content', 'icon' => 'fa-seedling',
|
|
'about' => 'Ticket and project templates, canned responses, documents, vendors, licenses and products to start from - all yours to change.'],
|
|
'client' => ['label' => 'First client', 'title' => 'Your first client', 'icon' => 'fa-user-friends',
|
|
'about' => 'A client to raise tickets and send invoices for - or the demo data, to look around first.'],
|
|
];
|
|
|
|
/** The standard response times the Support step offers - business minutes to respond and to resolve, by priority. */
|
|
const GETTING_STARTED_SLAS = [
|
|
'Urgent' => [60, 240],
|
|
'High' => [120, 480],
|
|
'Medium' => [240, 960],
|
|
'Low' => [480, 2400],
|
|
];
|
|
|
|
/** Has the 3.4.7 update run? Until it has, Get started isn't shown anywhere. */
|
|
function gettingStartedReady() {
|
|
global $mysqli;
|
|
static $ready = null;
|
|
if ($ready === null) {
|
|
if (defined('CURRENT_DATABASE_VERSION')) {
|
|
$version = CURRENT_DATABASE_VERSION;
|
|
} else {
|
|
$result = mysqli_query($mysqli, "SELECT config_current_database_version FROM settings WHERE company_id = 1");
|
|
$version = $result && ($row = mysqli_fetch_row($result)) ? (string) $row[0] : '0';
|
|
}
|
|
$ready = version_compare($version, '3.4.7', '>=');
|
|
}
|
|
return $ready;
|
|
}
|
|
|
|
/** Whether it is offered, the steps put off for now and when it was first opened - ['on', 'skipped' => [keys], 'opened_at']. */
|
|
function gettingStartedState($refresh = false) {
|
|
global $mysqli;
|
|
static $state = null;
|
|
if ($state === null || $refresh) {
|
|
$state = ['on' => false, 'skipped' => [], 'opened_at' => null];
|
|
if (gettingStartedReady()) {
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_getting_started, config_getting_started_skipped, config_getting_started_opened_at FROM settings WHERE company_id = 1")) ?: [];
|
|
$state = [
|
|
'on' => intval($row['config_getting_started'] ?? 0) === 1,
|
|
'skipped' => array_values(array_intersect(array_keys(GETTING_STARTED_STEPS), explode(',', (string) ($row['config_getting_started_skipped'] ?? '')))),
|
|
'opened_at' => $row['config_getting_started_opened_at'] ?? null,
|
|
];
|
|
}
|
|
}
|
|
return $state;
|
|
}
|
|
|
|
/** Where a step is - or the page itself, or the summary ('done'). */
|
|
function gettingStartedUrl($step = '') {
|
|
return '/admin/getting_started.php' . ($step !== '' ? '?step=' . rawurlencode($step) : '');
|
|
}
|
|
|
|
/** The crontab line ITFlow needs. */
|
|
function gettingStartedCronCommand() {
|
|
return '* * * * * php ' . dirname(__DIR__) . '/cron/cron.php >/dev/null';
|
|
}
|
|
|
|
/** The backup job's row - seeded from the registry, as the dispatcher would, when cron hasn't met it yet. */
|
|
function gettingStartedBackupJob() {
|
|
global $mysqli;
|
|
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT cron_job_enabled, cron_job_daily_at FROM cron_jobs WHERE cron_job_name = 'backup' LIMIT 1"));
|
|
if ($row) {
|
|
return ['enabled' => intval($row['cron_job_enabled']) === 1, 'daily_at' => substr((string) ($row['cron_job_daily_at'] ?? '02:00'), 0, 5) ?: '02:00', 'seeded' => true];
|
|
}
|
|
return ['enabled' => false, 'daily_at' => '02:00', 'seeded' => false];
|
|
}
|
|
|
|
/**
|
|
* The steps this install has, in order, each with whether it is done - from
|
|
* the settings themselves - skipped, and a line on where it stands.
|
|
* key => GETTING_STARTED_STEPS' fields + done, skipped, status.
|
|
*/
|
|
function gettingStartedSteps() {
|
|
global $mysqli, $config_module_enable_accounting, $config_module_enable_ticketing;
|
|
$state = gettingStartedState();
|
|
$settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_enable_cron, config_cron_last_dispatch_at, config_smtp_provider, config_mail_from_email,
|
|
config_default_hourly_rate, config_default_net_terms, company_currency FROM settings JOIN companies ON companies.company_id = settings.company_id WHERE settings.company_id = 1")) ?: [];
|
|
$count = function ($sql) use ($mysqli) {
|
|
$result = mysqli_query($mysqli, $sql);
|
|
return $result ? intval(mysqli_fetch_row($result)[0] ?? 0) : 0;
|
|
};
|
|
$steps = [];
|
|
foreach (GETTING_STARTED_STEPS as $key => $step) {
|
|
$module = $step['module'] ?? '';
|
|
if (($module === 'accounting' && !intval($config_module_enable_accounting)) || ($module === 'ticketing' && !intval($config_module_enable_ticketing))) {
|
|
continue;
|
|
}
|
|
$done = false;
|
|
$status = '';
|
|
switch ($key) {
|
|
case 'cron':
|
|
$heard = $settings['config_cron_last_dispatch_at'] ?? null;
|
|
$running = $heard && time() - strtotime($heard) < 300;
|
|
$on = intval($settings['config_enable_cron'] ?? 0) === 1;
|
|
$done = $running && $on;
|
|
$status = !$heard ? 'Not heard from yet' : ($running ? 'Ran ' . timeAgo($heard) : 'Last ran ' . timeAgo($heard)) . ($on ? '' : ' - switched off');
|
|
break;
|
|
case 'mail':
|
|
$provider = (string) ($settings['config_smtp_provider'] ?? '');
|
|
$done = $provider !== '' && trim((string) ($settings['config_mail_from_email'] ?? '')) !== '';
|
|
$status = $done ? 'Sends as ' . $settings['config_mail_from_email'] : ($provider !== '' ? 'No address to send from' : 'Nothing is emailed yet');
|
|
break;
|
|
case 'backups':
|
|
$job = gettingStartedBackupJob();
|
|
$done = $job['enabled'];
|
|
$status = $done ? 'Every night at ' . date('g:i A', strtotime($job['daily_at'])) : 'Not scheduled';
|
|
break;
|
|
case 'billing':
|
|
$rate = floatval($settings['config_default_hourly_rate'] ?? 0);
|
|
$done = $rate > 0;
|
|
$status = $done ? billingMoney($rate, (string) ($settings['company_currency'] ?? '')) . ' an hour, ' . (intval($settings['config_default_net_terms'] ?? 0) ? 'due in ' . intval($settings['config_default_net_terms']) . ' days' : 'due on receipt') : 'No hourly rate yet';
|
|
break;
|
|
case 'support':
|
|
$slas = $count("SELECT COUNT(*) FROM sla_assignments JOIN slas ON sla_id = sla_assignment_sla_id WHERE sla_assignment_client_id = 0 AND sla_archived_at IS NULL");
|
|
$done = $slas > 0;
|
|
$status = $done ? 'Response times for ' . $slas . ' ' . ($slas === 1 ? 'priority' : 'priorities') : 'No response times yet';
|
|
break;
|
|
case 'team':
|
|
$agents = $count("SELECT COUNT(*) FROM users WHERE user_type = 1 AND user_status = 1 AND user_archived_at IS NULL");
|
|
$done = $agents > 1;
|
|
$status = $agents === 1 ? 'Just you' : "$agents people";
|
|
break;
|
|
case 'content':
|
|
$templates = $count("SELECT (SELECT COUNT(*) FROM ticket_templates) + (SELECT COUNT(*) FROM canned_responses) + (SELECT COUNT(*) FROM document_templates)");
|
|
$done = $templates > 0;
|
|
$status = $done ? 'Templates to start from' : 'Nothing added yet';
|
|
break;
|
|
case 'client':
|
|
$clients = $count("SELECT COUNT(*) FROM clients WHERE client_archived_at IS NULL");
|
|
$done = $clients > 0;
|
|
$status = $clients ? $clients . ' ' . ($clients === 1 ? 'client' : 'clients') : 'No clients yet';
|
|
break;
|
|
}
|
|
$steps[$key] = $step + ['key' => $key, 'done' => $done, 'skipped' => !$done && in_array($key, $state['skipped'], true), 'status' => $status];
|
|
}
|
|
return $steps;
|
|
}
|
|
|
|
/** How far it has got - done (or skipped), of how many, the next step to do ('' when none is left) and whether it is finished. */
|
|
function gettingStartedProgress(array $steps = null) {
|
|
$steps = $steps ?? gettingStartedSteps();
|
|
$done = 0;
|
|
$next = '';
|
|
foreach ($steps as $key => $step) {
|
|
if ($step['done'] || $step['skipped']) {
|
|
$done++;
|
|
} elseif ($next === '') {
|
|
$next = $key;
|
|
}
|
|
}
|
|
return ['done' => $done, 'total' => count($steps), 'next' => $next, 'finished' => $next === ''];
|
|
}
|
|
|
|
/** The step after this one, in order - 'done' (the summary) after the last. */
|
|
function gettingStartedAfter($key, array $steps = null) {
|
|
$keys = array_keys($steps ?? gettingStartedSteps());
|
|
$at = array_search($key, $keys, true);
|
|
return $at === false || !isset($keys[$at + 1]) ? 'done' : $keys[$at + 1];
|
|
}
|
|
|
|
/** Put a step off for now, or take it back. */
|
|
function gettingStartedSkip($key, $skip = true) {
|
|
global $mysqli;
|
|
if (!isset(GETTING_STARTED_STEPS[$key]) || !gettingStartedReady()) {
|
|
return;
|
|
}
|
|
$skipped = gettingStartedState(true)['skipped'];
|
|
$skipped = $skip ? array_values(array_unique(array_merge($skipped, [$key]))) : array_values(array_diff($skipped, [$key]));
|
|
mysqli_query($mysqli, "UPDATE settings SET config_getting_started_skipped = '" . escapeSql(implode(',', $skipped)) . "' WHERE company_id = 1");
|
|
gettingStartedState(true);
|
|
}
|
|
|
|
/** Note that it was opened - it opens by itself on signing in only until then. */
|
|
function gettingStartedOpened() {
|
|
global $mysqli;
|
|
if (gettingStartedReady()) {
|
|
mysqli_query($mysqli, "UPDATE settings SET config_getting_started_opened_at = NOW() WHERE company_id = 1 AND config_getting_started_opened_at IS NULL");
|
|
}
|
|
}
|
|
|
|
/** Stop offering it - on the dashboard and on signing in. It stays in Admin. */
|
|
function gettingStartedDismiss() {
|
|
global $mysqli;
|
|
if (gettingStartedReady()) {
|
|
mysqli_query($mysqli, "UPDATE settings SET config_getting_started = 0 WHERE company_id = 1");
|
|
gettingStartedState(true);
|
|
}
|
|
}
|
|
|
|
/** Should signing in take this user to Get started - an admin, on an install that offers it, before anyone has opened it? */
|
|
function gettingStartedFirstVisit($user_id) {
|
|
global $mysqli;
|
|
if (!gettingStartedReady()) {
|
|
return false;
|
|
}
|
|
$state = gettingStartedState(true);
|
|
if (!$state['on'] || $state['opened_at'] !== null) {
|
|
return false;
|
|
}
|
|
$row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT role_is_admin FROM users JOIN user_roles ON role_id = user_role_id WHERE user_id = " . intval($user_id) . " AND user_type = 1 LIMIT 1"));
|
|
return intval($row[0] ?? 0) === 1;
|
|
}
|
|
|
|
/** Is the dashboard to show how far it has got - to an admin, while it is offered and not finished? ['steps', 'progress'] or null. */
|
|
function gettingStartedCard() {
|
|
global $session_is_admin;
|
|
if (empty($session_is_admin) || !gettingStartedReady() || !gettingStartedState()['on']) {
|
|
return null;
|
|
}
|
|
$steps = gettingStartedSteps();
|
|
$progress = gettingStartedProgress($steps);
|
|
return $progress['finished'] ? null : ['steps' => $steps, 'progress' => $progress];
|
|
}
|
|
|
|
/*
|
|
* ---------- what the steps save ----------
|
|
*/
|
|
|
|
/**
|
|
* Schedule the nightly backup, or stop it - the backup job's row (made with
|
|
* the registry's defaults when cron hasn't met it yet), and what it takes and
|
|
* keeps. What changed, in words.
|
|
*/
|
|
function gettingStartedBackupSave($enabled, $daily_at, $type, $keep_count, $keep_days) {
|
|
global $mysqli;
|
|
require_once dirname(__DIR__) . '/includes/cron_jobs.php';
|
|
$registry = cronJobRegistryByName()['backup'] ?? ['schedule' => 'Daily', 'daily_at' => '02:00', 'enabled' => 0];
|
|
$before = gettingStartedBackupJob();
|
|
$settings = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT config_backup_cron_type, config_backup_retention_count, config_backup_retention_days FROM settings WHERE company_id = 1")) ?: [];
|
|
mysqli_query($mysqli, "INSERT IGNORE INTO cron_jobs SET cron_job_name = 'backup', cron_job_enabled = " . intval($registry['enabled'] ?? 0) . ",
|
|
cron_job_schedule = 'Daily', cron_job_interval_minutes = 1, cron_job_daily_at = '" . escapeSql($registry['daily_at'] ?? '02:00') . ":00'");
|
|
mysqli_query($mysqli, "UPDATE cron_jobs SET cron_job_enabled = " . ($enabled ? 1 : 0) . ", cron_job_schedule = 'Daily', cron_job_daily_at = '" . escapeSql($daily_at) . ":00' WHERE cron_job_name = 'backup'");
|
|
mysqli_query($mysqli, "UPDATE settings SET config_backup_cron_type = '" . escapeSql($type) . "', config_backup_retention_count = " . intval($keep_count) . ",
|
|
config_backup_retention_days = " . intval($keep_days) . " WHERE company_id = 1");
|
|
|
|
$lines = [];
|
|
if ($before['enabled'] !== (bool) $enabled) {
|
|
$lines[] = $enabled ? 'nightly backups on' : 'nightly backups off';
|
|
}
|
|
if ($enabled && $before['daily_at'] !== $daily_at) {
|
|
$lines[] = "at $daily_at";
|
|
}
|
|
if ((string) ($settings['config_backup_cron_type'] ?? '') !== $type) {
|
|
$lines[] = (function_exists('backupTypeLabel') ? strtolower(backupTypeLabel($type)) : $type);
|
|
}
|
|
if (intval($settings['config_backup_retention_count'] ?? 0) !== intval($keep_count) || intval($settings['config_backup_retention_days'] ?? 0) !== intval($keep_days)) {
|
|
$lines[] = 'keeping ' . intval($keep_count) . ($keep_days ? ' for up to ' . intval($keep_days) . ' days' : '');
|
|
}
|
|
return $lines;
|
|
}
|
|
|
|
/**
|
|
* The standard response times as the default SLAs, for each priority that
|
|
* has none - an SLA named "Standard - High" and so on, or one already there
|
|
* with the same minutes - and open tickets stamped with them. The SLAs given,
|
|
* priority => id.
|
|
*/
|
|
function gettingStartedApplySlas() {
|
|
global $mysqli;
|
|
$ids = [];
|
|
foreach (GETTING_STARTED_SLAS as $priority => [$response, $resolution]) {
|
|
$assigned = mysqli_fetch_row(mysqli_query($mysqli, "SELECT 1 FROM sla_assignments JOIN slas ON sla_id = sla_assignment_sla_id
|
|
WHERE sla_assignment_client_id = 0 AND sla_assignment_priority = '$priority' AND sla_archived_at IS NULL LIMIT 1"));
|
|
if ($assigned) {
|
|
continue;
|
|
}
|
|
$existing = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT sla_id FROM slas WHERE sla_archived_at IS NULL AND sla_response_minutes = $response AND sla_resolution_minutes = $resolution ORDER BY sla_id LIMIT 1"))[0] ?? 0);
|
|
if (!$existing) {
|
|
$name = escapeSql("Standard - $priority");
|
|
$description = escapeSql('Respond within ' . formatSlaMinutes($response) . ', resolve within ' . formatSlaMinutes($resolution));
|
|
mysqli_query($mysqli, "INSERT INTO slas SET sla_name = '$name', sla_description = '$description', sla_response_minutes = $response, sla_resolution_minutes = $resolution");
|
|
$existing = intval(mysqli_insert_id($mysqli));
|
|
}
|
|
$ids[$priority] = $existing;
|
|
mysqli_query($mysqli, "DELETE FROM sla_assignments WHERE sla_assignment_client_id = 0 AND sla_assignment_priority = '$priority'");
|
|
mysqli_query($mysqli, "INSERT INTO sla_assignments SET sla_assignment_client_id = 0, sla_assignment_priority = '$priority', sla_assignment_sla_id = $existing");
|
|
}
|
|
if (!$ids) {
|
|
return $ids;
|
|
}
|
|
$sql = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_closed_at IS NULL AND ticket_archived_at IS NULL");
|
|
while ($row = mysqli_fetch_row($sql)) {
|
|
applyTicketSla(intval($row[0]));
|
|
}
|
|
return $ids;
|
|
}
|
|
|
|
/** Business days as words - "Mon-Fri", "Mon, Wed, Fri". $days are ISO weekdays, 1 Monday. */
|
|
function gettingStartedDaysText(array $days) {
|
|
$names = [1 => 'Mon', 2 => 'Tue', 3 => 'Wed', 4 => 'Thu', 5 => 'Fri', 6 => 'Sat', 7 => 'Sun'];
|
|
$days = array_values(array_unique(array_map('intval', $days)));
|
|
sort($days);
|
|
if (!$days) {
|
|
return 'no days';
|
|
}
|
|
$runs = [];
|
|
$start = $prev = $days[0];
|
|
foreach (array_slice($days, 1) as $day) {
|
|
if ($day !== $prev + 1) {
|
|
$runs[] = [$start, $prev];
|
|
$start = $day;
|
|
}
|
|
$prev = $day;
|
|
}
|
|
$runs[] = [$start, $prev];
|
|
return implode(', ', array_map(function ($run) use ($names) {
|
|
return $run[0] === $run[1] ? $names[$run[0]] : ($run[1] - $run[0] === 1 ? $names[$run[0]] . ', ' . $names[$run[1]] : $names[$run[0]] . '-' . $names[$run[1]]);
|
|
}, $runs));
|
|
}
|