Add Skyetel text messaging with two-way ticket texting, text to ticket for contacts, Text reply on tickets, a Texts inbox for unmatched numbers, verification codes and spam (codes masked, limited to credential access, every reveal audited, wiped after a day), STOP/START/HELP handling, a text log with retry and cancel in Maintenance > Text Messages, the Text Messages cron job, and texts to clients about their tickets, visits, visit reminders and invoices with per-contact opt-in from the contact, by text or in the client portal, quiet hours, and new settings in Tickets and Notifications (database 3.6.0 and 3.6.1)

This commit is contained in:
johnnyq
2026-11-06 00:08:10 -05:00
parent 87cdf79b28
commit b391e2ce6c
45 changed files with 4165 additions and 31 deletions

View File

@@ -0,0 +1,91 @@
<?php
/*
* ITFlow - Database update to version 3.6.0 (from 3.5.9)
* Included by admin/database_updates.php - do not access directly
*
* Text messaging (functions/sms.php):
* sms_providers - the texting service ITFlow sends and receives through (Skyetel), its
* keys, the number it texts from and the key in its inbound address
* sms_messages - every text sent and received, and what came of it: queued, sent or failed
* going out; on a ticket, in the Texts inbox (unmatched, a code, spam) or a keyword coming in
* sms_numbers - what is known about a number: a name for it, whether it replied STOP, and
* whether it is blocked as spam
* settings.config_ticket_sms_parse, config_ticket_sms_unknown_senders, config_ticket_sms_acknowledge -
* Settings > Tickets > Text to ticket
*
* Re-runnable: each table and column is made only where it isn't there yet.
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
mysqli_query($mysqli, "CREATE TABLE IF NOT EXISTS `sms_providers` (
`sms_provider_id` int(11) NOT NULL AUTO_INCREMENT,
`sms_provider_name` varchar(100) NOT NULL,
`sms_provider_description` varchar(255) DEFAULT NULL,
`sms_provider_username` varchar(255) NOT NULL DEFAULT '',
`sms_provider_secret` varchar(500) NOT NULL DEFAULT '',
`sms_provider_number` varchar(20) NOT NULL DEFAULT '',
`sms_provider_inbound_key` varchar(64) NOT NULL DEFAULT '',
`sms_provider_active` tinyint(1) NOT NULL DEFAULT 1,
`sms_provider_last_inbound_at` datetime DEFAULT NULL,
`sms_provider_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`sms_provider_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(),
PRIMARY KEY (`sms_provider_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
mysqli_query($mysqli, "CREATE TABLE IF NOT EXISTS `sms_messages` (
`sms_message_id` int(11) NOT NULL AUTO_INCREMENT,
`sms_message_direction` varchar(3) NOT NULL,
`sms_message_status` varchar(20) NOT NULL,
`sms_message_number` varchar(20) NOT NULL,
`sms_message_our_number` varchar(20) NOT NULL DEFAULT '',
`sms_message_body` text DEFAULT NULL,
`sms_message_media` text DEFAULT NULL,
`sms_message_code` varchar(20) DEFAULT NULL,
`sms_message_purpose` varchar(30) NOT NULL DEFAULT '',
`sms_message_attempts` tinyint(4) NOT NULL DEFAULT 0,
`sms_message_error` varchar(500) DEFAULT NULL,
`sms_message_provider_ref` varchar(100) DEFAULT NULL,
`sms_message_contact_id` int(11) NOT NULL DEFAULT 0,
`sms_message_client_id` int(11) NOT NULL DEFAULT 0,
`sms_message_ticket_id` int(11) NOT NULL DEFAULT 0,
`sms_message_ticket_reply_id` int(11) NOT NULL DEFAULT 0,
`sms_message_user_id` int(11) NOT NULL DEFAULT 0,
`sms_message_created_at` datetime NOT NULL DEFAULT current_timestamp(),
`sms_message_attempted_at` datetime DEFAULT NULL,
`sms_message_sent_at` datetime DEFAULT NULL,
`sms_message_handled_at` datetime DEFAULT NULL,
`sms_message_handled_by` int(11) NOT NULL DEFAULT 0,
PRIMARY KEY (`sms_message_id`),
KEY `sms_message_status` (`sms_message_status`,`sms_message_id`),
KEY `sms_message_number` (`sms_message_number`,`sms_message_created_at`),
KEY `sms_message_ticket_id` (`sms_message_ticket_id`),
KEY `sms_message_created_at` (`sms_message_created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
mysqli_query($mysqli, "CREATE TABLE IF NOT EXISTS `sms_numbers` (
`sms_number_phone` varchar(20) NOT NULL,
`sms_number_label` varchar(100) DEFAULT NULL,
`sms_number_opted_out_at` datetime DEFAULT NULL,
`sms_number_blocked_at` datetime DEFAULT NULL,
`sms_number_blocked_by` int(11) NOT NULL DEFAULT 0,
`sms_number_updated_at` datetime DEFAULT NULL ON UPDATE current_timestamp(),
PRIMARY KEY (`sms_number_phone`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci");
// Settings > Tickets > Text to ticket - after the email-to-ticket switches, in the order the page shows them
$itflow_sms_settings = [
'config_ticket_sms_parse' => "tinyint(1) NOT NULL DEFAULT 1 AFTER `config_ticket_email_parse_unknown_senders`",
'config_ticket_sms_unknown_senders' => "tinyint(1) NOT NULL DEFAULT 0 AFTER `config_ticket_sms_parse`",
'config_ticket_sms_acknowledge' => "tinyint(1) NOT NULL DEFAULT 1 AFTER `config_ticket_sms_unknown_senders`",
];
foreach ($itflow_sms_settings as $itflow_sms_column => $itflow_sms_definition) {
$itflow_column_exists = mysqli_query($mysqli, "SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'settings' AND COLUMN_NAME = '$itflow_sms_column' LIMIT 1");
if (!$itflow_column_exists || mysqli_num_rows($itflow_column_exists) === 0) {
mysqli_query($mysqli, "ALTER TABLE `settings` ADD `$itflow_sms_column` $itflow_sms_definition");
}
}
unset($itflow_sms_settings, $itflow_sms_column, $itflow_sms_definition, $itflow_column_exists);

View File

@@ -0,0 +1,45 @@
<?php
/*
* ITFlow - Database update to version 3.6.1 (from 3.6.0)
* Included by admin/database_updates.php - do not access directly
*
* Texts to clients (functions/sms.php):
* contacts.contact_sms_opt_in, contact_sms_opt_in_at, contact_sms_opt_in_by - whether a contact
* agreed to texts about their tickets, visits and invoices, when, and how (an agent, the
* client portal, or replying YES when asked by text). Off for everyone to start with.
* sms_messages.sms_message_send_after - a text that waits for the morning
* settings.config_sms_client_tickets, config_sms_client_visits, config_sms_visit_reminder_hours,
* config_sms_client_invoices - Settings > Notifications > Texts to clients
*
* Re-runnable: each column is made only where it isn't there yet.
*/
defined('FROM_DB_UPDATER') || die("Direct file access is not allowed");
$itflow_sms_columns = [
['contacts', 'contact_sms_opt_in', "tinyint(1) NOT NULL DEFAULT 0 AFTER `contact_company_ticket_emails`"],
['contacts', 'contact_sms_opt_in_at', "datetime DEFAULT NULL AFTER `contact_sms_opt_in`"],
['contacts', 'contact_sms_opt_in_by', "varchar(200) DEFAULT NULL AFTER `contact_sms_opt_in_at`"],
['sms_messages', 'sms_message_send_after', "datetime DEFAULT NULL AFTER `sms_message_created_at`"],
['settings', 'config_sms_client_tickets', "tinyint(1) NOT NULL DEFAULT 1 AFTER `config_ticket_sms_acknowledge`"],
['settings', 'config_sms_client_visits', "tinyint(1) NOT NULL DEFAULT 1 AFTER `config_sms_client_tickets`"],
['settings', 'config_sms_visit_reminder_hours', "int(11) NOT NULL DEFAULT 24 AFTER `config_sms_client_visits`"],
['settings', 'config_sms_client_invoices', "tinyint(1) NOT NULL DEFAULT 1 AFTER `config_sms_visit_reminder_hours`"],
];
foreach ($itflow_sms_columns as [$itflow_sms_table, $itflow_sms_column, $itflow_sms_definition]) {
$itflow_column_exists = mysqli_query($mysqli, "SELECT 1 FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '$itflow_sms_table' AND COLUMN_NAME = '$itflow_sms_column' LIMIT 1");
if (!$itflow_column_exists || mysqli_num_rows($itflow_column_exists) === 0) {
mysqli_query($mysqli, "ALTER TABLE `$itflow_sms_table` ADD `$itflow_sms_column` $itflow_sms_definition");
}
}
// Whether a reminder or a booking went for a ticket is looked up by what it was for and the ticket
$itflow_index_exists = mysqli_query($mysqli, "SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sms_messages' AND INDEX_NAME = 'sms_message_purpose' LIMIT 1");
if (!$itflow_index_exists || mysqli_num_rows($itflow_index_exists) === 0) {
mysqli_query($mysqli, "ALTER TABLE `sms_messages` ADD KEY `sms_message_purpose` (`sms_message_purpose`,`sms_message_ticket_id`,`sms_message_created_at`)");
}
unset($itflow_sms_columns, $itflow_sms_table, $itflow_sms_column, $itflow_sms_definition, $itflow_column_exists, $itflow_index_exists);

View File

@@ -121,6 +121,12 @@
<p>AI Providers</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/text_messaging.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'text_messaging.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-sms"></i>
<p>Text Messaging</p>
</a>
</li>
<li class="nav-item">
<a href="/admin/custom_links.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'custom_links.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-external-link-alt"></i>
@@ -228,6 +234,20 @@
</p>
</a>
</li>
<?php if (smsProvider()) { ?>
<li class="nav-item">
<a href="/admin/text_messages.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'text_messages.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-comment-dots"></i>
<p>
Text Messages
<?php $nav_sms_failed = smsFailedRecently(); ?>
<?php if ($nav_sms_failed) { ?>
<span class="right badge text-bg-danger" data-bs-toggle="tooltip" title="Failed to send in the last week" data-nav-sms-failed><?= $nav_sms_failed ?></span>
<?php } ?>
</p>
</a>
</li>
<?php } ?>
<li class="nav-item">
<a href="/admin/audit_logs.php" class="nav-link <?= (basename($_SERVER['PHP_SELF']) == 'audit_logs.php' ? 'active' : '') ?>">
<i class="nav-icon fas fa-history"></i>

View File

@@ -0,0 +1,89 @@
<?php
/*
* Admin > Text Messaging - Opted out and blocked: the numbers that replied STOP (nothing is
* texted to them until they reply START) and the ones blocked as spam (their texts go
* straight to Spam). Each can be put back - opting someone back in is for when they ask, by
* any other way than texting START (admin/post/sms_provider.php).
*/
require_once '../../includes/modal_header.php';
$rows = [];
try {
$sql = mysqli_query($mysqli, "SELECT sms_number_phone, sms_number_label, sms_number_opted_out_at, sms_number_blocked_at, user_name
FROM sms_numbers LEFT JOIN users ON user_id = sms_number_blocked_by
WHERE sms_number_opted_out_at IS NOT NULL OR sms_number_blocked_at IS NOT NULL
ORDER BY GREATEST(COALESCE(sms_number_opted_out_at, 0), COALESCE(sms_number_blocked_at, 0)) DESC LIMIT 500");
$rows = mysqli_fetch_all($sql, MYSQLI_ASSOC);
} catch (Throwable $e) {
// Before the 3.6.0 database update
}
$csrf = $_SESSION['csrf_token'];
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-ban me-2"></i>Opted out and blocked</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<?php if (!$rows) { ?>
<div class="text-center text-body-secondary py-5 px-3">
<i class="fas fa-ban fa-2x mb-2 d-block opacity-50"></i>
No number has replied STOP, and none is blocked as spam.
</div>
<?php } else { ?>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-nowrap">
<tr>
<th class="ps-3">Number</th>
<th>What</th>
<th></th>
</tr>
</thead>
<tbody>
<?php foreach ($rows as $row) {
$number = (string) $row['sms_number_phone'];
$label = trim((string) $row['sms_number_label']);
$contacts = smsContactsFor($number);
?>
<tr data-sms-number="<?= escapeHtml($number) ?>">
<td class="ps-3">
<div class="fw-semibold"><?= escapeHtml(smsNumberText($number)) ?></div>
<?php if ($label !== '' || $contacts) { ?>
<div class="small text-body-secondary"><?= escapeHtml($label !== '' ? $label : $contacts[0]['contact_name'] . ($contacts[0]['client_name'] ? ' - ' . $contacts[0]['client_name'] : '')) ?></div>
<?php } ?>
</td>
<td class="small">
<?php if ($row['sms_number_opted_out_at']) { ?>
<div><i class="fas fa-fw fa-hand-paper text-secondary me-1"></i>Replied STOP <?= escapeHtml(timeAgo($row['sms_number_opted_out_at'])) ?></div>
<?php } ?>
<?php if ($row['sms_number_blocked_at']) { ?>
<div><i class="fas fa-fw fa-ban text-secondary me-1"></i>Blocked as spam <?= escapeHtml(timeAgo($row['sms_number_blocked_at'])) ?><?= $row['user_name'] ? ' by ' . escapeHtml($row['user_name']) : '' ?></div>
<?php } ?>
</td>
<td class="text-end pe-3 text-nowrap">
<?php if ($row['sms_number_opted_out_at']) { ?>
<a class="btn btn-sm btn-light border confirm-link" href="post.php?sms_number_opt_in=<?= urlencode($number) ?>&csrf_token=<?= $csrf ?>"
data-confirm-title="Text <?= escapeHtml(smsNumberText($number)) ?> again?" data-confirm-button="Opt back in"
data-confirm-text="Only when they've asked for texts again - by phone, email or in person. Otherwise they text START themselves.">Opt back in</a>
<?php } ?>
<?php if ($row['sms_number_blocked_at']) { ?>
<a class="btn btn-sm btn-light border" href="post.php?sms_number_unblock=<?= urlencode($number) ?>&csrf_token=<?= $csrf ?>">Unblock</a>
<?php } ?>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php } ?>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Close</button>
</div>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -0,0 +1,9 @@
<?php
/*
* Admin > Text Messaging - Add. The same window as Edit (sms_provider_edit.php), with no
* provider yet.
*/
$_GET['id'] = 0;
require __DIR__ . '/sms_provider_edit.php';

View File

@@ -0,0 +1,211 @@
<?php
/*
* Admin > Text Messaging - one provider's window: Add (no id, or from sms_provider_add.php)
* or Edit. Its SID and secret - the secret never put back into the page, so left empty it
* stays - the number texts go from, and whether it is on. Save asks first (check_sms_provider,
* admin/post/sms_provider.php): what is wrong shows beside each field and the window stays
* open; once all is right, it saves.
*/
require_once '../../includes/modal_header.php';
$provider_id = intval($_GET['id'] ?? 0);
$row = $provider_id ? smsProviderRow($provider_id) : null;
if ($provider_id && !$row) {
echo json_encode(['error' => 'That texting provider no longer exists.']);
exit;
}
$kinds = smsProviderKinds();
$taken = [];
$sql = mysqli_query($mysqli, "SELECT sms_provider_name FROM sms_providers");
while ($taken_row = mysqli_fetch_row($sql)) {
$taken[$taken_row[0]] = true;
}
$free_kinds = array_diff_key($kinds, $taken);
$name = $row ? (string) $row['sms_provider_name'] : (string) (array_key_first($free_kinds) ?? array_key_first($kinds));
$kind = $kinds[$name] ?? reset($kinds);
$number = $row ? smsNumberText($row['sms_provider_number']) : '';
// Where what is wrong with a field shows
$error_for = function ($field) {
return '<div class="invalid-feedback" data-error-for="' . $field . '"></div>';
};
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-sms me-2"></i><?= $row ? '<strong>' . escapeHtml($name) . '</strong>' : 'Add Texting Provider' ?></h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off" novalidate data-sms-provider-form>
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="provider_id" value="<?= $provider_id ?>">
<div class="modal-body">
<?php if (!$row && !$free_kinds) { ?>
<div class="alert alert-info py-2 small mb-3"><i class="fas fa-fw fa-info-circle me-1"></i><?= escapeHtml(implode(' and ', array_keys($kinds))) ?> is set up already - edit that one.</div>
<?php } ?>
<?php if (!$row) { ?>
<div class="mb-3">
<label class="form-label" for="smsProviderName">Provider <strong class="text-danger">*</strong></label>
<div class="input-group has-validation">
<span class="input-group-text"><i class="fa fa-fw fa-sms"></i></span>
<select class="form-select" name="provider" id="smsProviderName">
<?php foreach ($kinds as $kind_name => $kind_info) { ?>
<option value="<?= escapeHtml($kind_name) ?>" <?= $kind_name === $name ? 'selected' : '' ?> <?= isset($taken[$kind_name]) ? 'disabled' : '' ?>><?= escapeHtml($kind_name) ?><?= isset($taken[$kind_name]) ? ' - set up already' : '' ?></option>
<?php } ?>
</select>
</div>
<?= $error_for('provider') ?>
<div class="form-text"><?= escapeHtml($kind['about']) ?></div>
</div>
<?php } else { ?>
<div class="mb-3">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" name="active" value="1" id="smsProviderActive" <?= intval($row['sms_provider_active']) ? 'checked' : '' ?>>
<label class="form-check-label" for="smsProviderActive">On - texts go out, and texts to the number come in</label>
</div>
<div class="form-text ps-5">Off, nothing is texted and texts to the number aren't taken in. Texts waiting to go wait.</div>
</div>
<?php } ?>
<div class="mb-3">
<label class="form-label" for="smsProviderDescription">Description</label>
<div class="input-group has-validation">
<span class="input-group-text"><i class="fa fa-fw fa-align-left"></i></span>
<input type="text" class="form-control" name="description" id="smsProviderDescription" maxlength="250" placeholder="What it is for, if you like" value="<?= escapeHtml($row['sms_provider_description'] ?? '') ?>">
</div>
<?= $error_for('description') ?>
</div>
<div class="mb-3">
<label class="form-label" for="smsProviderNumber">Number texts go from <strong class="text-danger">*</strong></label>
<div class="input-group has-validation">
<span class="input-group-text"><i class="fa fa-fw fa-phone"></i></span>
<input type="tel" class="form-control" name="number" id="smsProviderNumber" maxlength="30" placeholder="+1 412 555 0100" value="<?= escapeHtml($number) ?>">
</div>
<?= $error_for('number') ?>
<div class="form-text">SMS-enabled in <?= escapeHtml($name) ?>'s portal - and, for a US number, registered for texting there, or carriers block what it sends.</div>
</div>
<h6 class="fw-semibold mt-4 mb-1">Keys</h6>
<p class="small text-body-secondary mb-2">From <a href="<?= escapeHtml($kind['portal_url']) ?>" target="_blank" rel="noopener noreferrer"><?= escapeHtml($name) ?>'s portal <i class="fas fa-external-link-alt fa-xs"></i></a> - the gear at the top right, then API Keys.</p>
<div class="mb-3">
<label class="form-label" for="smsProviderUsername"><?= escapeHtml($kind['username_label']) ?> <strong class="text-danger">*</strong></label>
<div class="input-group has-validation">
<span class="input-group-text"><i class="fa fa-fw fa-id-badge"></i></span>
<input type="text" class="form-control font-monospace" name="username" id="smsProviderUsername" maxlength="250" spellcheck="false" value="<?= escapeHtml($row['sms_provider_username'] ?? '') ?>">
</div>
<?= $error_for('username') ?>
</div>
<div class="mb-3">
<label class="form-label" for="smsProviderSecret"><?= escapeHtml($kind['secret_label']) ?> <?= $row ? '' : '<strong class="text-danger">*</strong>' ?></label>
<div class="input-group has-validation">
<span class="input-group-text"><i class="fa fa-fw fa-key"></i></span>
<input type="password" class="form-control font-monospace" name="secret" id="smsProviderSecret" maxlength="500" spellcheck="false" autocomplete="new-password"
placeholder="<?= $row && trim((string) $row['sms_provider_secret']) !== '' ? 'Saved - type a new one to replace it' : '' ?>">
</div>
<?= $error_for('secret') ?>
</div>
<?php if ($row) { ?>
<h6 class="fw-semibold mt-4 mb-1">Texts coming back</h6>
<p class="small text-body-secondary mb-2">In <?= escapeHtml($name) ?>'s portal, open the number, then SMS: send texts to this Callback URL, by POST.</p>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-link"></i></span>
<input type="text" class="form-control font-monospace" readonly id="smsProviderInbound" value="<?= escapeHtml(smsInboundUrl($row)) ?>" aria-label="Where texts come back to">
<button type="button" class="btn btn-outline-secondary clipboardjs" data-clipboard-target="#smsProviderInbound" title="Copy"><i class="far fa-fw fa-copy"></i></button>
</div>
<?php } else { ?>
<div class="alert alert-light border small mb-0"><i class="fas fa-fw fa-info-circle me-1"></i>Once it's added, Text Messaging shows the address to paste into <?= escapeHtml($name) ?>'s portal, so texts to the number come back into ITFlow.</div>
<?php } ?>
</div>
<div class="modal-footer">
<button type="submit" name="<?= $row ? 'edit_sms_provider' : 'add_sms_provider' ?>" class="btn btn-primary text-bold" data-sms-provider-save <?= !$row && !$free_kinds ? 'disabled' : '' ?>>
<i class="fa fa-check me-2"></i><?= $row ? 'Save' : 'Add' ?>
</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<script>
(function () {
var form = document.querySelector('[data-sms-provider-form]:not([data-ready])');
if (!form) {
return;
}
form.setAttribute('data-ready', '1');
var save = form.querySelector('[data-sms-provider-save]');
var checked = false;
function clearErrors() {
form.querySelectorAll('.is-invalid').forEach(function (el) { el.classList.remove('is-invalid'); });
form.querySelectorAll('[data-error-for]').forEach(function (el) {
el.textContent = '';
el.classList.remove('d-block');
});
}
function showErrors(errors) {
var first = null;
Object.keys(errors).forEach(function (field) {
var input = form.querySelector('[name="' + field + '"]');
var box = form.querySelector('[data-error-for="' + field + '"]');
if (input) {
input.classList.add('is-invalid');
first = first || input;
}
if (box) {
box.textContent = errors[field];
box.classList.add('d-block');
}
});
if (first) {
first.focus();
}
}
form.addEventListener('submit', function (e) {
if (checked) {
return;
}
e.preventDefault();
var label = save.innerHTML;
save.disabled = true;
save.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Checking';
var data = new FormData(form);
data.append('check_sms_provider', '1');
fetch(form.getAttribute('action'), { method: 'POST', body: data, credentials: 'same-origin', headers: { 'Accept': 'application/json' } })
.then(function (response) { return response.json(); })
.then(function (answer) {
clearErrors();
save.disabled = false;
if (answer.ok) {
checked = true;
save.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving';
form.requestSubmit(save);
save.disabled = true;
return;
}
save.innerHTML = label;
showErrors(answer.errors || {});
})
.catch(function () {
// Couldn't ask - send it as it is, and the save says what is wrong
checked = true;
save.disabled = false;
save.innerHTML = label;
form.requestSubmit(save);
});
});
})();
</script>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -0,0 +1,53 @@
<?php
/*
* Admin > Text Messaging - Send a test text, to a number of your own, so the keys, the
* number and its registration are tried for real (send_sms_test, admin/post/sms_provider.php).
*/
require_once '../../includes/modal_header.php';
$row = smsProviderRow($_GET['id'] ?? 0);
if (!$row) {
echo json_encode(['error' => 'That texting provider no longer exists.']);
exit;
}
$company = trim((string) (mysqli_fetch_row(mysqli_query($mysqli, "SELECT company_name FROM companies WHERE company_id = 1"))[0] ?? ''));
ob_start();
?>
<div class="modal-header bg-dark">
<h5 class="modal-title"><i class="fa fa-fw fa-vial me-2"></i>Send a test text</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="provider_id" value="<?= intval($row['sms_provider_id']) ?>">
<div class="modal-body">
<p class="small text-body-secondary">From <strong><?= escapeHtml(smsNumberText($row['sms_provider_number'])) ?></strong> through <?= escapeHtml($row['sms_provider_name']) ?> - to your own mobile. Then text it back, and the reply shows in Texts on the agent side.</p>
<div class="mb-3">
<label class="form-label" for="smsTestNumber">To <strong class="text-danger">*</strong></label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-mobile-alt"></i></span>
<input type="tel" class="form-control" name="number" id="smsTestNumber" maxlength="30" placeholder="+1 412 555 0100" required autofocus>
</div>
</div>
<div class="mb-0">
<label class="form-label" for="smsTestText">Text</label>
<div class="input-group">
<span class="input-group-text"><i class="fa fa-fw fa-comment"></i></span>
<textarea class="form-control" name="text" id="smsTestText" rows="3" maxlength="<?= SMS_TEXT_LIMIT ?>"><?= escapeHtml(($company !== '' ? "$company: " : '') . 'a test text from ITFlow.') ?></textarea>
</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" name="send_sms_test" class="btn btn-primary text-bold"><i class="fa fa-paper-plane me-2"></i>Send</button>
<button type="button" class="btn btn-light" data-bs-dismiss="modal"><i class="fa fa-times me-2"></i>Cancel</button>
</div>
</form>
<?php
require_once '../../../includes/modal_footer.php';

View File

@@ -2,8 +2,8 @@
/*
* Admin > Settings > Notifications (admin/settings_notification.php):
* edit_notification_settings saves the domain alert and the emails clients
* get by themselves. Reminders aren't set here (they moved to Invoices and
* edit_notification_settings saves the domain alert, and the emails and texts
* clients get by themselves. Reminders aren't set here (they moved to Invoices and
* billing with their schedule), nor cron (Maintenance > Cron). A switch for a
* module that is off isn't on the page, so is kept as it is.
*/
@@ -14,7 +14,8 @@ if (isset($_POST['edit_notification_settings'])) {
validateCSRFToken();
$before = settingsCurrent(['config_enable_alert_domain_expire', 'config_ticket_client_general_notifications', 'config_recurring_auto_send_invoice']);
$sms_columns = smsColumnExists('settings', 'config_sms_client_tickets') ? ['config_sms_client_tickets', 'config_sms_client_visits', 'config_sms_visit_reminder_hours', 'config_sms_client_invoices'] : [];
$before = settingsCurrent(array_merge(['config_enable_alert_domain_expire', 'config_ticket_client_general_notifications', 'config_recurring_auto_send_invoice'], $sms_columns));
$after = ['config_enable_alert_domain_expire' => empty($_POST['config_enable_alert_domain_expire']) ? 0 : 1];
if ($config_module_enable_ticketing) {
@@ -23,6 +24,19 @@ if (isset($_POST['edit_notification_settings'])) {
if ($config_module_enable_accounting) {
$after['config_recurring_auto_send_invoice'] = empty($_POST['config_recurring_auto_send_invoice']) ? 0 : 1;
}
// Texts to clients - once the database has them; a choice not on the list keeps what it is
if ($sms_columns) {
if ($config_module_enable_ticketing) {
$tickets = intval($_POST['config_sms_client_tickets'] ?? $before['config_sms_client_tickets']);
$after['config_sms_client_tickets'] = in_array($tickets, [0, 1, 2], true) ? $tickets : intval($before['config_sms_client_tickets']);
$after['config_sms_client_visits'] = empty($_POST['config_sms_client_visits']) ? 0 : 1;
$hours = intval($_POST['config_sms_visit_reminder_hours'] ?? $before['config_sms_visit_reminder_hours']);
$after['config_sms_visit_reminder_hours'] = isset(smsReminderChoices()[$hours]) ? $hours : intval($before['config_sms_visit_reminder_hours']);
}
if ($config_module_enable_accounting) {
$after['config_sms_client_invoices'] = empty($_POST['config_sms_client_invoices']) ? 0 : 1;
}
}
settingsSave('notification', $before, $after);

View File

@@ -2,7 +2,7 @@
/*
* Admin > Settings > Tickets (admin/settings_ticket.php):
* edit_ticket_settings saves the numbering, email to ticket, who hears of a
* edit_ticket_settings saves the numbering, email and text to ticket, who hears of a
* new ticket and whether AI triages it, the timer and billing, closing and the
* tickets page. A next
* number already in use, an email address that isn't one or a closing time
@@ -20,7 +20,8 @@ if (isset($_POST['edit_ticket_settings'])) {
$before = settingsCurrent(array_merge(['config_ticket_prefix', 'config_ticket_next_number', 'config_ticket_email_parse', 'config_ticket_email_parse_unknown_senders',
'config_ticket_new_ticket_notification_email', 'config_ticket_timer_autostart', 'config_ticket_default_billable', 'config_ticket_autoclose_hours',
'config_ticket_default_view', 'config_ticket_ordering', 'config_ticket_moving_columns'], aiTriageReady() ? ['config_ticket_ai_triage'] : []));
'config_ticket_default_view', 'config_ticket_ordering', 'config_ticket_moving_columns'], aiTriageReady() ? ['config_ticket_ai_triage'] : [],
settingsColumnExists($mysqli, 'config_ticket_sms_parse') ? ['config_ticket_sms_parse', 'config_ticket_sms_unknown_senders', 'config_ticket_sms_acknowledge'] : []));
// What was sent, and what is saved for anything that wasn't - a switch not sent is off
$in = $_POST + array_map(function ($value) { return (string) $value; }, $before);
@@ -37,6 +38,12 @@ if (isset($_POST['edit_ticket_settings'])) {
foreach (['config_ticket_email_parse', 'config_ticket_email_parse_unknown_senders', 'config_ticket_timer_autostart', 'config_ticket_ordering', 'config_ticket_moving_columns'] as $switch) {
$after[$switch] = empty($_POST[$switch]) ? 0 : 1;
}
// Text to ticket - once the database has it
if (array_key_exists('config_ticket_sms_parse', $before)) {
foreach (['config_ticket_sms_parse', 'config_ticket_sms_unknown_senders', 'config_ticket_sms_acknowledge'] as $switch) {
$after[$switch] = empty($_POST[$switch]) ? 0 : 1;
}
}
if ($config_module_enable_accounting) {
$after['config_ticket_default_billable'] = empty($_POST['config_ticket_default_billable']) ? 0 : 1;
}

180
admin/post/sms_provider.php Normal file
View File

@@ -0,0 +1,180 @@
<?php
/*
* Admin > Text Messaging - Add and Edit (asked first by check_sms_provider, which answers in
* JSON, so what is wrong shows beside each field), turning a provider on and off, a new
* address for texts to come back to, Delete, Send a test text, and putting back a number
* that replied STOP or was blocked as spam (functions/sms.php). What changed is in the audit
* log - the secret only as changed.
*/
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
if (isset($_POST['check_sms_provider']) || isset($_POST['add_sms_provider']) || isset($_POST['edit_sms_provider'])) {
validateCSRFToken();
$checking = isset($_POST['check_sms_provider']);
$provider_id = intval($_POST['provider_id'] ?? 0);
$before = $provider_id ? smsProviderRow($provider_id) : null;
if ($provider_id && !$before) {
if ($checking) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => false, 'errors' => ['provider' => 'That texting provider no longer exists - close this and look again.']]);
exit;
}
flashAlert('That texting provider no longer exists.', 'error');
redirect('text_messaging.php');
}
[$after, $errors] = smsProviderCheck($_POST, $before);
if ($checking) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => !$errors, 'errors' => $errors]);
exit;
}
// Only reached without the window's script - say the first thing wrong
if ($errors) {
flashAlert('Not saved - ' . escapeHtml(reset($errors)), 'error');
redirect();
}
$name = $after['sms_provider_name'];
$number = smsNumberText($after['sms_provider_number']);
if (!$before) {
$provider_id = smsProviderWrite($after);
logAudit("Text Message", "Create", "$session_name added texting provider " . escapeSql("$name, texting from $number"), 0, $provider_id);
flashAlert('<strong>' . escapeHtml($name) . '</strong> added, texting from <strong>' . escapeHtml($number) . '</strong> - paste the address texts come back to into its portal, then send a test text');
redirect('text_messaging.php');
}
$lines = smsProviderChangeLines($before, $after);
if (!$lines) {
flashAlert('Nothing changed on <strong>' . escapeHtml($name) . '</strong>', 'info');
redirect();
}
smsProviderWrite($after, $provider_id);
logAudit("Text Message", "Edit", "$session_name changed texting provider " . escapeSql("$name: " . implode('; ', $lines)), 0, $provider_id);
flashAlert('<strong>' . escapeHtml($name) . '</strong> saved - ' . escapeHtml(count($lines) === 1 ? $lines[0] : count($lines) . ' changes'));
redirect();
}
// Turn a provider off - nothing is texted, and texts to the number aren't taken in - or on again
if (isset($_GET['turn_sms_provider_off']) || isset($_GET['turn_sms_provider_on'])) {
validateCSRFToken();
$on = isset($_GET['turn_sms_provider_on']);
$row = smsProviderRow($on ? $_GET['turn_sms_provider_on'] : $_GET['turn_sms_provider_off']);
if (!$row) {
flashAlert('That texting provider no longer exists.', 'error');
redirect('text_messaging.php');
}
$name = $row['sms_provider_name'];
if (intval($row['sms_provider_active']) === ($on ? 1 : 0)) {
flashAlert('<strong>' . escapeHtml($name) . '</strong> is ' . ($on ? 'on' : 'off') . ' already', 'info');
redirect();
}
mysqli_query($mysqli, "UPDATE sms_providers SET sms_provider_active = " . ($on ? 1 : 0) . " WHERE sms_provider_id = " . intval($row['sms_provider_id']));
logAudit("Text Message", "Edit", "$session_name turned texting provider " . escapeSql($name) . ($on ? ' on' : ' off'), 0, intval($row['sms_provider_id']));
flashAlert($on
? '<strong>' . escapeHtml($name) . '</strong> turned on - texts go out and come in again'
: '<strong>' . escapeHtml($name) . '</strong> turned off - nothing is texted, and texts to the number aren\'t taken in until it is on again');
redirect();
}
// A new address for texts to come back to - the old one stops working at once
if (isset($_GET['new_sms_inbound_key'])) {
validateCSRFToken();
$row = smsProviderRow($_GET['new_sms_inbound_key']);
if (!$row) {
flashAlert('That texting provider no longer exists.', 'error');
redirect('text_messaging.php');
}
mysqli_query($mysqli, "UPDATE sms_providers SET sms_provider_inbound_key = '" . escapeSql(randomString(40)) . "' WHERE sms_provider_id = " . intval($row['sms_provider_id']));
logAudit("Text Message", "Edit", "$session_name made a new inbound address for texting provider " . escapeSql($row['sms_provider_name']), 0, intval($row['sms_provider_id']));
flashAlert('New address made - paste it into <strong>' . escapeHtml($row['sms_provider_name']) . '</strong>\'s portal now, or texts to the number won\'t reach ITFlow', 'warning');
redirect();
}
// Delete a provider - the texts sent and received stay in the log and on their tickets
if (isset($_GET['delete_sms_provider'])) {
validateCSRFToken();
$row = smsProviderRow($_GET['delete_sms_provider']);
if (!$row) {
flashAlert('That texting provider no longer exists.', 'error');
redirect('text_messaging.php');
}
$provider_id = intval($row['sms_provider_id']);
mysqli_query($mysqli, "DELETE FROM sms_providers WHERE sms_provider_id = $provider_id");
// Texts waiting to go have nothing to go through now
mysqli_query($mysqli, "UPDATE sms_messages SET sms_message_status = 'cancelled', sms_message_error = 'Its texting provider was deleted' WHERE sms_message_status = 'queued'");
logAudit("Text Message", "Delete", "$session_name deleted texting provider " . escapeSql($row['sms_provider_name'] . ', which texted from ' . smsNumberText($row['sms_provider_number'])), 0, $provider_id);
flashAlert('<strong>' . escapeHtml($row['sms_provider_name']) . '</strong> deleted - nothing more is texted, and texts to the number no longer reach ITFlow', 'error');
redirect('text_messaging.php');
}
// Send a test text - to a number of your own, for real
if (isset($_POST['send_sms_test'])) {
validateCSRFToken();
$row = smsProviderRow($_POST['provider_id'] ?? 0);
if (!$row || !intval($row['sms_provider_active'])) {
flashAlert('Turn the texting provider on first.', 'error');
redirect();
}
$number = smsNumber((string) ($_POST['number'] ?? ''));
$text = trim((string) ($_POST['text'] ?? ''));
if ($number === '') {
flashAlert('That isn\'t a number a text can go to - with its dial code, like +1 412 555 0100.', 'error');
redirect();
}
if ($text === '') {
$text = 'A test text from ITFlow.';
}
$queued = smsQueue($number, $text, ['purpose' => 'test', 'user_id' => intval($session_user_id)]);
$sent = $queued['ids'] ? smsSendNow($queued['ids']) : ['sent' => 0, 'error' => $queued['error']];
$to = escapeHtml(smsNumberText($number));
if ($sent['sent'] && $sent['error'] === '') {
logAudit("Text Message", "Send", "$session_name sent a test text to " . escapeSql(smsNumberText($number)), 0, intval($row['sms_provider_id']));
flashAlert("Test text sent to <strong>$to</strong> - text it back to see the reply come into Texts");
} else {
$error = $sent['error'] !== '' ? $sent['error'] : $queued['error'];
logAudit("Text Message", "Send", "$session_name sent a test text to " . escapeSql(smsNumberText($number) . " - it didn't go: $error"), 0, intval($row['sms_provider_id']));
flashAlert("The test text to <strong>$to</strong> didn't go - " . escapeHtml($error), 'error');
}
redirect();
}
// Put a number back - opted back in, when they asked by some other way than texting START, or unblocked
if (isset($_GET['sms_number_opt_in']) || isset($_GET['sms_number_unblock'])) {
validateCSRFToken();
$opt_in = isset($_GET['sms_number_opt_in']);
$number = preg_replace('/\D/', '', (string) ($opt_in ? $_GET['sms_number_opt_in'] : $_GET['sms_number_unblock']));
if (!smsNumberRow($number)) {
flashAlert('That number isn\'t opted out or blocked.', 'error');
redirect();
}
smsNumberSet($number, $opt_in ? ['opted_out_at' => null] : ['blocked_at' => null, 'blocked_by' => 0]);
$number_text = smsNumberText($number);
logAudit("Text Message", "Edit", "$session_name " . ($opt_in ? 'opted ' . escapeSql($number_text) . ' back in to texts' : 'unblocked ' . escapeSql($number_text)));
flashAlert('<strong>' . escapeHtml($number_text) . '</strong> ' . ($opt_in ? 'can be texted again' : 'is unblocked - its texts come in as any other'));
redirect();
}

View File

@@ -0,0 +1,60 @@
<?php
/*
* Maintenance > Text Messages - retry a text that failed (or was cancelled), cancel one
* still waiting, delete one - one at a time or several. A retried text goes at the Text
* Messages job's next run.
*/
defined('FROM_POST_HANDLER') || die("Direct file access is not allowed");
/* Do one thing to texts - how many it was done to */
function textMessagesAct(array $ids, string $action): int
{
global $mysqli;
$done = 0;
foreach (array_unique(array_filter(array_map('intval', $ids))) as $id) {
$row = smsMessageRow($id);
if (!$row || !smsLogCan($row, $action)) {
continue;
}
if ($action === 'retry') {
mysqli_query($mysqli, "UPDATE sms_messages SET sms_message_status = 'queued', sms_message_attempts = 0, sms_message_attempted_at = NULL, sms_message_error = NULL WHERE sms_message_id = $id");
} elseif ($action === 'cancel') {
mysqli_query($mysqli, "UPDATE sms_messages SET sms_message_status = 'cancelled', sms_message_error = 'Cancelled' WHERE sms_message_id = $id AND sms_message_status = 'queued'");
} else {
mysqli_query($mysqli, "DELETE FROM sms_messages WHERE sms_message_id = $id AND sms_message_status <> 'sending'");
}
$done += mysqli_affected_rows($mysqli) > 0 ? 1 : 0;
}
return $done;
}
foreach (['retry' => 'retry_text', 'cancel' => 'cancel_text', 'delete' => 'delete_text'] as $action => $key) {
if (isset($_GET[$key])) {
validateCSRFToken();
$done = textMessagesAct([$_GET[$key]], $action);
if (!$done) {
flashAlert('That text can\'t be ' . ['retry' => 'retried', 'cancel' => 'cancelled', 'delete' => 'deleted'][$action] . ' - it may have gone already, or be going now.', 'error');
redirect();
}
logAudit("Text Message", ['retry' => 'Edit', 'cancel' => 'Edit', 'delete' => 'Delete'][$action], "$session_name " . ['retry' => 'retried', 'cancel' => 'cancelled', 'delete' => 'deleted'][$action] . " text " . intval($_GET[$key]));
flashAlert(['retry' => 'Text queued again - it goes at the next run of the Text Messages job', 'cancel' => 'Text cancelled - it won\'t be sent', 'delete' => 'Text deleted'][$action], $action === 'delete' ? 'error' : 'success');
redirect();
}
}
foreach (['retry' => 'bulk_retry_texts', 'cancel' => 'bulk_cancel_texts', 'delete' => 'bulk_delete_texts'] as $action => $key) {
if (isset($_POST[$key])) {
validateCSRFToken();
$ids = (array) ($_POST['sms_message_ids'] ?? []);
$done = textMessagesAct($ids, $action);
$verb = ['retry' => 'retried', 'cancel' => 'cancelled', 'delete' => 'deleted'][$action];
if ($done) {
logAudit("Text Message", $action === 'delete' ? 'Delete' : 'Edit', "$session_name $verb $done text" . ($done === 1 ? '' : 's'));
}
$skipped = count(array_unique(array_filter(array_map('intval', $ids)))) - $done;
flashAlert("<strong>$done</strong> text" . ($done === 1 ? '' : 's') . " $verb" . ($skipped ? " - $skipped couldn't be" : ''), $done ? ($action === 'delete' ? 'error' : 'success') : 'warning');
redirect();
}
}

View File

@@ -3,7 +3,8 @@
/*
* Admin > Settings > Notifications - the alerts ITFlow raises for your team,
* and the emails it sends clients by itself: about their tickets, and the
* invoices recurring invoices make. Payment reminders and statements are set
* invoices recurring invoices make - and the texts, to those who agreed
* (functions/sms.php). Payment reminders and statements are set
* with their schedule in Invoices and billing - shown here, not set. Saved by
* edit_notification_settings (admin/post/settings_notification.php).
*/
@@ -43,6 +44,23 @@ echo settingsPageStart('notification');
<?php } ?>
<?= settingsSectionEnd() ?>
<?php if (smsColumnExists('settings', 'config_sms_client_tickets')) {
$sms_client = smsClientSettings(); ?>
<?= settingsSectionStart('texts', 'Texts to clients', 'What ITFlow texts your clients by itself - only to contacts who agreed to texts, on their contact or in the client portal. Texts that would land between ' . date('ga', mktime(SMS_QUIET_FROM, 0)) . ' and ' . date('ga', mktime(SMS_QUIET_UNTIL, 0)) . ' wait for the morning. A reply to one about a ticket goes on the ticket.') ?>
<?php if (!smsReady()) { ?>
<div class="alert alert-light border py-2 small" data-notification-no-texting><i class="fas fa-info-circle me-2"></i>Texting isn't set up, so none of these go. <a href="text_messaging.php">Set up texting</a></div>
<?php } ?>
<?php if ($config_module_enable_ticketing) { ?>
<?= settingsSelect('config_sms_client_tickets', $sms_client['config_sms_client_tickets'], settingsFields()['config_sms_client_tickets']['options'], ['icon' => 'fa-life-ring']) ?>
<?= settingsSwitch('config_sms_client_visits', $sms_client['config_sms_client_visits'], ['help' => 'When a visit or remote work is booked on their ticket, and when it is cancelled.']) ?>
<?= settingsSelect('config_sms_visit_reminder_hours', $sms_client['config_sms_visit_reminder_hours'], smsReminderChoices(), ['icon' => 'fa-bell']) ?>
<?php } ?>
<?php if ($config_module_enable_accounting) { ?>
<?= settingsSwitch('config_sms_client_invoices', $sms_client['config_sms_client_invoices'], ['help' => 'Texted with the email, to the people it is emailed to who agreed to texts.']) ?>
<?php } ?>
<?= settingsSectionEnd() ?>
<?php } ?>
<?= settingsSaveBar('edit_notification_settings') ?>
</div>
</div>

View File

@@ -2,7 +2,8 @@
/*
* Admin > Settings > Tickets - how tickets are numbered, made from email
* (cron/ticket_email_parser.php, reading the mailbox in Mail > Receiving),
* (cron/ticket_email_parser.php, reading the mailbox in Mail > Receiving) and
* from texts (functions/sms.php, once texting is set up in Text Messaging),
* timed and billed, who hears about new ones, when resolved ones close, and
* how the tickets page opens, and whether AI triages new tickets
* (functions/ai_ticket.php). Saved by edit_ticket_settings
@@ -35,6 +36,20 @@ echo settingsPageStart('ticket');
</div>
<?= settingsSectionEnd() ?>
<?php if (settingsColumnExists($mysqli, 'config_ticket_sms_parse')) {
$sms_settings = smsTicketSettings(); ?>
<?= settingsSectionStart('text', 'Text to ticket', 'A text to your number from one of your contacts becomes a ticket, and a text on a ticket goes on it - texts from anyone else, verification codes and spam wait in Texts instead. Texting is set up in <a href="text_messaging.php">Text Messaging</a>.') ?>
<?php if (!smsProvider()) { ?>
<div class="alert alert-light border py-2 small" data-ticket-no-texting><i class="fas fa-info-circle me-2"></i>Texting isn't set up yet, so no texts come in.</div>
<?php } ?>
<?= settingsSwitch('config_ticket_sms_parse', intval($sms_settings['config_ticket_sms_parse']), ['help' => 'Off, every text waits in Texts for an agent - STOP, START and HELP are still answered.']) ?>
<div data-settings-depends-on="config_ticket_sms_parse">
<?= settingsSwitch('config_ticket_sms_unknown_senders', intval($sms_settings['config_ticket_sms_unknown_senders']), ['help' => 'From a number that isn\'t a contact\'s, as a ticket for no client - otherwise those wait in Texts, where an agent opens a ticket from one. Codes and spam wait there either way.']) ?>
<?= settingsSwitch('config_ticket_sms_acknowledge', intval($sms_settings['config_ticket_sms_acknowledge']), ['help' => 'The first text of a new ticket is answered with its number, so they know it landed and that replying adds to it.']) ?>
</div>
<?= settingsSectionEnd() ?>
<?php } ?>
<?= settingsSectionStart('new', 'New tickets', 'Who hears about a ticket as it is raised - in the portal, by email or on a schedule - and whether AI sorts it.') ?>
<?= settingsInput('config_ticket_new_ticket_notification_email', $config_ticket_new_ticket_notification_email, ['icon' => 'fa-bell', 'type' => 'email', 'maxlength' => 200,
'placeholder' => 'support@company.com', 'help' => 'A shared mailbox or list works best. Empty, nobody is emailed.']) ?>

266
admin/text_messages.php Normal file
View File

@@ -0,0 +1,266 @@
<?php
/*
* Maintenance > Text Messages - every text ITFlow sent and received (functions/sms.php), and
* what came of it: waiting, sent or failed going out; on a ticket, in Texts (unmatched, a
* code, spam) or a keyword coming in. Verification codes are masked here - Texts > Codes
* reveals them, logged. A text can be retried, cancelled or deleted, one at a time or several
* (admin/post/text_messages.php).
*/
// Default Column Sortby Filter
$sort = "sms_message_created_at";
$order = "DESC";
require_once "includes/inc_all_admin.php";
$page_title = 'Text Messages';
try {
mysqli_query($mysqli, "SELECT 1 FROM sms_messages LIMIT 1");
} catch (Throwable $e) { ?>
<div class="alert alert-warning"><i class="fas fa-fw fa-exclamation-triangle me-2"></i>Text messaging arrives with a database update - run it in <a href="update.php">Maintenance &gt; Update</a> first.</div>
<?php
require_once "../includes/footer.php";
exit;
}
$tabs = smsLogTabs();
$show = (string) ($_GET['show'] ?? '');
$show = isset($tabs[$show]) ? $show : '';
// Only these sort - an unknown column would take the page down
$sortable_columns = [
'sms_message_created_at' => "sms_message_created_at $order",
'sms_message_number' => "sms_message_number $order, sms_message_created_at DESC",
'sms_message_status' => "sms_message_status $order, sms_message_created_at DESC",
];
if (!isset($sortable_columns[$sort])) {
$sort = 'sms_message_created_at';
}
$search = "(sms_message_number LIKE '%$phone_query%' OR (sms_message_status <> 'code' AND sms_message_body LIKE '%$q%') OR sms_message_error LIKE '%$q%' OR contact_name LIKE '%$q%' OR client_name LIKE '%$q%'
OR CONCAT(ticket_prefix, ticket_number) LIKE '%$q%')
AND DATE(sms_message_created_at) BETWEEN '$dtf' AND '$dtt'";
$joins = "LEFT JOIN contacts ON contact_id = sms_message_contact_id AND sms_message_contact_id > 0
LEFT JOIN clients ON client_id = sms_message_client_id AND sms_message_client_id > 0
LEFT JOIN tickets ON ticket_id = sms_message_ticket_id AND sms_message_ticket_id > 0
LEFT JOIN users ON user_id = sms_message_user_id AND sms_message_user_id > 0";
$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS sms_messages.*, contact_name, client_name, ticket_prefix, ticket_number, ticket_client_id, user_name
FROM sms_messages $joins
WHERE $search AND {$tabs[$show][2]}
ORDER BY {$sortable_columns[$sort]}, sms_message_id DESC
LIMIT $record_from, $record_to");
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
// How many in each tab, as searched
$count_sql = [];
foreach ($tabs as $key => [, , $where]) {
$count_sql[] = "COALESCE(SUM($where), 0)";
}
$counts = array_combine(array_keys($tabs), array_map('intval', mysqli_fetch_row(mysqli_query($mysqli, "SELECT " . implode(', ', $count_sql) . " FROM sms_messages $joins WHERE $search"))));
$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) {
return '<a class="text-reset text-decoration-none" href="?' . escapeHtml($url_query_strings_sort) . '&amp;sort=' . $column . '&amp;order=' . $disp . '">'
. $label . ($sort === $column ? " $order_icon" : '') . '</a>';
};
$pill_url = function ($show) {
return '?' . http_build_query(array_filter(['q' => $_GET['q'] ?? '', 'show' => $show, 'canned_date' => $_GET['canned_date'] ?? '', 'dtf' => $_GET['dtf'] ?? '', 'dtt' => $_GET['dtt'] ?? '']));
};
$csrf = $_SESSION['csrf_token'];
$filtered_dates = isset($_GET['dtf']) && $_GET['dtf'] !== '' && $_GET['dtf'] !== '1970-01-01';
$ready = smsReady();
?>
<div class="card">
<div class="card-header bg-dark py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-sms me-2"></i>Text Messages</h3>
<div class="card-tools">
<a class="btn btn-outline-light" href="text_messaging.php"><i class="fas fa-fw fa-cog"></i><span class="d-none d-lg-inline ms-2">Text Messaging</span></a>
</div>
</div>
<?php if (!$ready) { ?>
<div class="alert alert-warning rounded-0 border-0 border-bottom mb-0 d-flex flex-wrap align-items-center gap-2" data-sms-health="off">
<i class="fas fa-fw fa-exclamation-triangle"></i>
<div class="me-auto"><strong>Texting isn't on</strong> - nothing goes out, and texts to your number aren't taken in.</div>
<a class="btn btn-sm btn-warning" href="text_messaging.php">Text Messaging</a>
</div>
<?php } ?>
<div class="card-header py-3">
<form autocomplete="off">
<?php if ($show !== '') { ?><input type="hidden" name="show" value="<?= escapeHtml($show) ?>"><?php } ?>
<div class="row g-2 align-items-center">
<div class="col-lg-5 col-md-6">
<div class="input-group">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search text messages..." title="The number, the words, the contact or client, the ticket, or why it failed">
<button class="btn btn-secondary" title="When" type="button" data-bs-toggle="collapse" data-bs-target="#advancedFilter"><i class="fas fa-calendar-alt"></i></button>
<button class="btn btn-dark" title="Search"><i class="fa fa-search"></i></button>
</div>
</div>
<div class="col-lg-7 col-md-6">
<div class="d-flex justify-content-end gap-2">
<div class="dropdown" id="bulkActionButton" hidden>
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="fas fa-fw fa-layer-group me-2"></i>Bulk Action (<span id="selectedCount">0</span>)
</button>
<div class="dropdown-menu dropdown-menu-end">
<button class="dropdown-item" type="submit" form="bulkActions" name="bulk_retry_texts" title="Failed or cancelled ones - at the next run">
<i class="fas fa-fw fa-redo-alt text-secondary me-2"></i>Retry
</button>
<button class="dropdown-item" type="submit" form="bulkActions" name="bulk_cancel_texts" title="Ones still waiting to go">
<i class="fas fa-fw fa-ban text-secondary me-2"></i>Cancel
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger confirm-link" type="submit" form="bulkActions" name="bulk_delete_texts"
data-confirm-title="Delete these texts?" data-confirm-text="Any waiting to go never will. Replies on tickets stay on their tickets." data-confirm-button="Delete">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</button>
</div>
</div>
</div>
</div>
</div>
<div class="collapse mt-3 <?= $filtered_dates ? 'show' : '' ?>" id="advancedFilter">
<div class="row g-3">
<div class="col-md-4 col-lg-3">
<label class="form-label" for="dateFilter">When</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-fw fa-calendar-alt"></i></span>
<input type="text" id="dateFilter" class="form-control" autocomplete="off">
</div>
<input type="hidden" name="canned_date" id="canned_date" value="<?= escapeHtml($_GET['canned_date'] ?? '') ?>">
<input type="hidden" name="dtf" id="dtf" value="<?= escapeHtml($dtf ?? '') ?>">
<input type="hidden" name="dtt" id="dtt" value="<?= escapeHtml($dtt ?? '') ?>">
</div>
</div>
</div>
</form>
<nav class="nav nav-pills itflow-asset-tabs flex-nowrap overflow-auto mt-2" aria-label="Which texts">
<?php foreach ($tabs as $key => [$label, $icon]) {
$alert = $show !== $key && $counts[$key] && $key === 'failed' ? 'text-bg-danger' : ''; ?>
<a class="nav-link text-nowrap <?= $show === $key ? 'active' : '' ?>" href="<?= escapeHtml($pill_url($key)) ?>" data-sms-log-tab="<?= $key === '' ? 'all' : $key ?>">
<i class="fas fa-fw <?= $icon ?> me-1"></i><?= $label ?> <span class="badge rounded-pill ms-1 <?= $alert ?>"><?= $counts[$key] ?></span>
</a>
<?php } ?>
</nav>
</div>
<form id="bulkActions" action="post.php" method="post">
<input type="hidden" name="csrf_token" value="<?= $csrf ?>">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-nowrap <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th class="checkbox-column border-end">
<div class="form-check">
<input class="form-check-input" id="selectAllCheckbox" type="checkbox" title="Select all on this page">
</div>
</th>
<th class="d-none d-sm-table-cell"><?= $sort_link('sms_message_status', 'Status') ?></th>
<th><?= $sort_link('sms_message_number', 'With') ?></th>
<th class="d-none d-md-table-cell">Text</th>
<th class="d-none d-lg-table-cell"><?= $sort_link('sms_message_created_at', 'When') ?></th>
<th></th>
</tr>
</thead>
<tbody>
<?php while ($row = mysqli_fetch_assoc($sql)) {
$message_id = intval($row['sms_message_id']);
$state = smsLogState($row);
$out = $row['sms_message_direction'] === 'out';
$number = (string) $row['sms_message_number'];
$label = smsNumberLabel($number);
$who = trim((string) $row['contact_name']) !== '' ? $row['contact_name'] : $label;
$body = smsLogBody($row);
$media = $row['sms_message_media'] ? (json_decode($row['sms_message_media'], true) ?: []) : [];
?>
<tr data-sms-message-id="<?= $message_id ?>" data-sms-state="<?= escapeHtml($state['key']) ?>">
<td class="checkbox-column bg-light border-end">
<?php if (smsLogCan($row, 'delete')) { ?>
<div class="form-check">
<input class="form-check-input bulk-select" type="checkbox" name="sms_message_ids[]" value="<?= $message_id ?>" aria-label="Select text <?= $message_id ?>">
</div>
<?php } ?>
</td>
<td class="text-nowrap d-none d-sm-table-cell">
<span class="badge <?= $state['badge'] ?>" data-sms-status><i class="fas fa-fw <?= $state['icon'] ?> me-1"></i><?= $state['label'] ?></span>
<?php if ($state['error'] !== '') { ?>
<div class="small text-wrap <?= in_array($state['key'], ['failed', 'blocked'], true) ? 'text-danger-emphasis' : 'text-body-secondary' ?>" style="max-width: 18rem;" title="<?= escapeHtml($state['error']) ?>" data-sms-error><?= escapeHtml(mb_strimwidth($state['error'], 0, 160, '...')) ?></div>
<?php } ?>
</td>
<td class="min-w-0">
<div class="text-nowrap">
<i class="fas fa-fw <?= $out ? 'fa-arrow-right text-primary' : 'fa-arrow-left text-success' ?> me-1" title="<?= $out ? 'Sent' : 'Received' ?>"></i><?= escapeHtml(smsNumberText($number)) ?>
</div>
<?php if ($who !== '' || $row['client_name']) { ?>
<div class="small text-body-secondary text-break"><?= escapeHtml(trim($who . ($row['client_name'] ? ' - ' . $row['client_name'] : ''), ' -')) ?></div>
<?php } ?>
<?php if ($row['ticket_number']) { ?>
<div class="small"><a href="/agent/ticket.php?<?= intval($row['ticket_client_id']) ? 'client_id=' . intval($row['ticket_client_id']) . '&amp;' : '' ?>ticket_id=<?= intval($row['sms_message_ticket_id']) ?>"><i class="fas fa-fw fa-life-ring me-1"></i><?= escapeHtml($row['ticket_prefix'] . $row['ticket_number']) ?></a></div>
<?php } ?>
<!-- On smaller screens the text comes along under who it is with -->
<div class="d-md-none small text-break mt-1"><?= nl2br(escapeHtml(mb_strimwidth($body, 0, 200, '...'))) ?></div>
<div class="d-sm-none small mt-1"><span class="badge <?= $state['badge'] ?>"><?= $state['label'] ?></span></div>
</td>
<td class="d-none d-md-table-cell min-w-0" style="max-width: 32rem;">
<div class="text-break small"><?= nl2br(escapeHtml(mb_strimwidth($body, 0, 400, '...'))) ?></div>
<?php if ($media) { ?>
<div class="small text-body-secondary mt-1"><i class="fas fa-fw fa-paperclip me-1"></i><?= count($media) ?> photo<?= count($media) === 1 ? '' : 's' ?> or file<?= count($media) === 1 ? '' : 's' ?></div>
<?php } ?>
<?php if ($out && $row['user_name']) { ?>
<div class="small text-body-secondary">By <?= escapeHtml($row['user_name']) ?></div>
<?php } elseif ($out && $row['sms_message_purpose'] !== '' && $row['sms_message_purpose'] !== 'reply') { ?>
<div class="small text-body-secondary"><?= escapeHtml(smsPurposeText((string) $row['sms_message_purpose'])) ?></div>
<?php } ?>
</td>
<td class="d-none d-lg-table-cell text-nowrap small" title="<?= escapeHtml(date('M j, Y g:i:s A', strtotime($row['sms_message_created_at']))) ?>">
<?= escapeHtml(date('M j, g:i A', strtotime($row['sms_message_created_at']))) ?>
<div class="text-body-secondary">#<?= $message_id ?><?= intval($row['sms_message_attempts']) > 1 ? ' &middot; tried ' . intval($row['sms_message_attempts']) . ' times' : '' ?></div>
</td>
<td class="text-end">
<?php if (smsLogCan($row, 'retry') || smsLogCan($row, 'cancel') || smsLogCan($row, 'delete')) { ?>
<div class="dropdown dropstart">
<button class="btn btn-sm btn-light" title="More actions" type="button" data-bs-toggle="dropdown"><i class="fas fa-fw fa-ellipsis-v"></i></button>
<div class="dropdown-menu">
<?php if (smsLogCan($row, 'retry')) { ?>
<a class="dropdown-item" href="post.php?retry_text=<?= $message_id ?>&csrf_token=<?= $csrf ?>" data-sms-retry><i class="fas fa-fw fa-redo-alt text-secondary me-2"></i>Retry</a>
<?php } ?>
<?php if (smsLogCan($row, 'cancel')) { ?>
<a class="dropdown-item" href="post.php?cancel_text=<?= $message_id ?>&csrf_token=<?= $csrf ?>" data-sms-cancel><i class="fas fa-fw fa-ban text-secondary me-2"></i>Cancel</a>
<?php } ?>
<?php if (smsLogCan($row, 'delete')) { ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger confirm-link" href="post.php?delete_text=<?= $message_id ?>&csrf_token=<?= $csrf ?>" data-sms-delete
data-confirm-title="Delete this text?" data-confirm-text="<?= $out && $row['sms_message_status'] === 'queued' ? 'It won\'t be sent.' : 'It comes off the log - a reply on a ticket stays on the ticket.' ?>" data-confirm-button="Delete"><i class="fas fa-fw fa-trash me-2"></i>Delete</a>
<?php } ?>
</div>
</div>
<?php } ?>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
</form>
<?php
$empty_state = [
'thing' => 'texts',
'icon' => 'fa-sms',
'title' => $show !== '' ? 'Nothing ' . strtolower($tabs[$show][0]) : 'No texts yet',
'text' => 'Every text ITFlow sends and receives is kept here for a year - spam and dismissed texts for ' . SMS_SPAM_KEEP_DAYS . ' days, and a verification code\'s words for a day.',
];
?>
<?php require_once "../includes/filter_footer.php"; ?>
</div>
<script src="../js/bulk_actions.js"></script>
<?php
require_once "../includes/footer.php";

195
admin/text_messaging.php Normal file
View File

@@ -0,0 +1,195 @@
<?php
/*
* Admin > Text Messaging - the texting service ITFlow texts through (functions/sms.php), one
* number for every client. Each row says whether it is on, the number, the address its texts
* come back to (pasted into the service's portal), and its texts in the last 30 days. Send a
* test text tries it; Opted out and blocked lists the numbers that replied STOP or were
* marked spam. Add and Edit are one window (modals/sms_provider/sms_provider_edit.php); the
* rest are admin/post/sms_provider.php.
*/
// Default Column Sortby Filter
$sort = "sms_provider_name";
$order = "ASC";
require_once "includes/inc_all_admin.php";
// The tables arrive with the 3.6.0 database update
try {
mysqli_query($mysqli, "SELECT 1 FROM sms_providers LIMIT 1");
} catch (Throwable $e) { ?>
<div class="alert alert-warning"><i class="fas fa-fw fa-exclamation-triangle me-2"></i>Text messaging arrives with a database update - run it in <a href="update.php">Maintenance &gt; Update</a> first.</div>
<?php
require_once "../includes/footer.php";
exit;
}
// Only these sort - an unknown column would take the page down
$sortable_columns = [
'sms_provider_name' => "sms_provider_name $order",
'sms_provider_number' => "sms_provider_number $order, sms_provider_name ASC",
'sms_provider_last_inbound_at' => "sms_provider_last_inbound_at IS NULL, sms_provider_last_inbound_at $order, sms_provider_name ASC",
];
if (!isset($sortable_columns[$sort])) {
$sort = 'sms_provider_name';
}
$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS * FROM sms_providers
WHERE (sms_provider_name LIKE '%$q%' OR sms_provider_description LIKE '%$q%' OR sms_provider_number LIKE '%$phone_query%')
ORDER BY {$sortable_columns[$sort]}, sms_provider_id ASC
LIMIT $record_from, $record_to");
$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
$set_up = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(DISTINCT sms_provider_name) FROM sms_providers"))[0]);
$can_add = $set_up < count(smsProviderKinds());
$activity = smsProviderActivity();
$numbers = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(SUM(sms_number_opted_out_at IS NOT NULL), 0), COALESCE(SUM(sms_number_blocked_at IS NOT NULL), 0) FROM sms_numbers"));
$opted_out = intval($numbers[0]);
$blocked = intval($numbers[1]);
$sort_link = function ($column, $label) use ($url_query_strings_sort, $sort, $disp, $order_icon) {
return '<a class="text-reset text-decoration-none" href="?' . escapeHtml($url_query_strings_sort) . '&amp;sort=' . $column . '&amp;order=' . $disp . '">'
. $label . ($sort === $column ? " $order_icon" : '') . '</a>';
};
$add_url = 'modals/sms_provider/sms_provider_add.php';
$csrf = $_SESSION['csrf_token'];
?>
<div class="card">
<div class="card-header bg-dark py-2">
<h3 class="card-title mt-2"><i class="fas fa-fw fa-sms me-2"></i>Text Messaging</h3>
<div class="card-tools">
<a class="btn btn-outline-light me-1 ajax-modal" href="#" data-modal-url="modals/sms_provider/sms_numbers.php" title="Numbers that replied STOP, and numbers blocked as spam">
<i class="fas fa-fw fa-ban"></i><span class="d-none d-lg-inline ms-2">Opted out and blocked</span><?php if ($opted_out + $blocked) { ?> <span class="badge text-bg-light ms-1"><?= $opted_out + $blocked ?></span><?php } ?>
</a>
<a class="btn btn-outline-light me-1" href="text_messages.php"><i class="fas fa-fw fa-list"></i><span class="d-none d-lg-inline ms-2">Text Messages</span></a>
<?php if ($can_add) { ?>
<button type="button" class="btn btn-primary ajax-modal" data-modal-url="<?= $add_url ?>"><i class="fas fa-plus"></i><span class="d-none d-lg-inline ms-2">Add Provider</span></button>
<?php } ?>
</div>
</div>
<div class="card-header py-3">
<form autocomplete="off">
<div class="row g-2 align-items-center">
<div class="col-lg-5 col-md-6">
<div class="input-group">
<input type="search" class="form-control" name="q" value="<?php if (isset($q)) { echo stripslashes(escapeHtml($q)); } ?>" placeholder="Search texting providers..." title="Name, description or number">
<button class="btn btn-dark" title="Search"><i class="fa fa-search"></i></button>
</div>
</div>
<div class="col-lg-7 col-md-6 small text-body-secondary text-md-end" data-sms-activity>
In the last 30 days: <?= $activity['sent'] ?> sent<?php if ($activity['failed']) { ?>, <a class="text-danger-emphasis" href="text_messages.php?show=failed"><?= $activity['failed'] ?> failed</a><?php } ?>, <?= $activity['received'] ?> received
</div>
</div>
</form>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light text-nowrap <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr>
<th class="ps-3"><?= $sort_link('sms_provider_name', 'Provider') ?></th>
<th class="d-none d-md-table-cell"><?= $sort_link('sms_provider_number', 'Number') ?></th>
<th class="d-none d-lg-table-cell">Texts come back to</th>
<th class="d-none d-md-table-cell"><?= $sort_link('sms_provider_last_inbound_at', 'Last text in') ?></th>
<th></th>
</tr>
</thead>
<tbody>
<?php while ($row = mysqli_fetch_assoc($sql)) {
$provider_id = intval($row['sms_provider_id']);
$name = (string) $row['sms_provider_name'];
$kind = smsProviderKinds()[$name] ?? null;
$on = intval($row['sms_provider_active']) === 1;
$edit_url = "modals/sms_provider/sms_provider_edit.php?id=$provider_id";
$inbound_url = smsInboundUrl($row);
?>
<tr data-sms-provider-id="<?= $provider_id ?>" class="<?= $on ? '' : 'text-body-secondary' ?>">
<td class="ps-3">
<div class="d-flex align-items-center gap-3">
<span class="d-none d-sm-inline-flex itflow-asset-icon itflow-asset-tone-other" aria-hidden="true"><i class="<?= escapeHtml($kind['icon'] ?? 'fas fa-sms') ?>"></i></span>
<div class="min-w-0">
<a class="fw-semibold text-body ajax-modal" href="#" data-modal-url="<?= $edit_url ?>"><?= escapeHtml($name) ?></a>
<?php if (!$on) { ?>
<span class="badge text-bg-secondary ms-1" data-sms-provider-state="off" title="No texts go out, and texts sent to the number aren't taken in">Off</span>
<?php } ?>
<?php if (trim((string) $row['sms_provider_description']) !== '') { ?>
<div class="small text-body-secondary"><?= escapeHtml($row['sms_provider_description']) ?></div>
<?php } ?>
<!-- On smaller screens the number comes along under the name -->
<div class="d-md-none small text-body-secondary"><?= escapeHtml(smsNumberText($row['sms_provider_number'])) ?></div>
</div>
</div>
</td>
<td class="d-none d-md-table-cell text-nowrap"><?= escapeHtml(smsNumberText($row['sms_provider_number'])) ?></td>
<td class="d-none d-lg-table-cell small" style="max-width: 28rem;">
<div class="input-group input-group-sm">
<input type="text" class="form-control font-monospace" readonly value="<?= escapeHtml($inbound_url) ?>" id="smsInbound<?= $provider_id ?>" aria-label="Where texts come back to">
<button type="button" class="btn btn-outline-secondary clipboardjs" data-clipboard-target="#smsInbound<?= $provider_id ?>" title="Copy"><i class="far fa-fw fa-copy"></i></button>
</div>
<div class="text-body-secondary mt-1">In <?= escapeHtml($name) ?>'s portal: the number &gt; SMS &gt; Callback URL, by POST.</div>
</td>
<td class="d-none d-md-table-cell small text-nowrap" data-sms-last-in>
<?php if ($row['sms_provider_last_inbound_at']) { ?>
<span title="<?= escapeHtml($row['sms_provider_last_inbound_at']) ?>"><?= escapeHtml(timeAgo($row['sms_provider_last_inbound_at'])) ?></span>
<?php } else { ?>
<span class="text-body-secondary">None yet - text the number to try it</span>
<?php } ?>
</td>
<td class="text-end pe-3">
<div class="dropdown dropstart">
<button class="btn btn-sm btn-light" title="More actions" type="button" data-bs-toggle="dropdown"><i class="fas fa-fw fa-ellipsis-v"></i></button>
<div class="dropdown-menu">
<a class="dropdown-item ajax-modal" href="#" data-modal-url="<?= $edit_url ?>">
<i class="fas fa-fw fa-edit text-secondary me-2"></i>Edit
</a>
<?php if ($on) { ?>
<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/sms_provider/sms_test.php?id=<?= $provider_id ?>">
<i class="fas fa-fw fa-vial text-secondary me-2"></i>Send a test text
</a>
<?php } ?>
<a class="dropdown-item confirm-link" href="post.php?new_sms_inbound_key=<?= $provider_id ?>&csrf_token=<?= $csrf ?>"
data-confirm-title="Make a new address for texts to come back to?" data-confirm-button="Make a new one"
data-confirm-text="The one <?= escapeHtml($name) ?> has stops working - paste the new one into its portal, or texts to the number won't reach ITFlow.">
<i class="fas fa-fw fa-sync-alt text-secondary me-2"></i>New inbound address
</a>
<?php if ($on) { ?>
<a class="dropdown-item confirm-link" href="post.php?turn_sms_provider_off=<?= $provider_id ?>&csrf_token=<?= $csrf ?>"
data-confirm-title="Turn <?= escapeHtml($name) ?> off?" data-confirm-text="No texts go out, and texts to the number aren't taken in until it is on again. Waiting texts wait." data-confirm-button="Turn off">
<i class="fas fa-fw fa-toggle-off text-secondary me-2"></i>Turn off
</a>
<?php } else { ?>
<a class="dropdown-item" href="post.php?turn_sms_provider_on=<?= $provider_id ?>&csrf_token=<?= $csrf ?>">
<i class="fas fa-fw fa-toggle-on text-secondary me-2"></i>Turn on
</a>
<?php } ?>
<div class="dropdown-divider"></div>
<a class="dropdown-item text-danger confirm-link" href="post.php?delete_sms_provider=<?= $provider_id ?>&csrf_token=<?= $csrf ?>"
data-confirm-title="Delete <?= escapeHtml($name) ?>?" data-confirm-button="Delete"
data-confirm-text="Nothing more is texted, and texts to the number stop reaching ITFlow. The texts already sent and received stay in the log and on their tickets.">
<i class="fas fa-fw fa-trash me-2"></i>Delete
</a>
</div>
</div>
</td>
</tr>
<?php } ?>
</tbody>
</table>
</div>
<?php
$empty_state = [
'thing' => 'texting providers',
'icon' => 'fa-sms',
'text' => 'Set up Skyetel and your agents can reply to tickets by text, texts from your clients open tickets, and verification codes texted to your number land in Texts.',
'actions' => $can_add ? [['label' => 'Add Provider', 'modal' => $add_url]] : [],
];
?>
<?php require_once "../includes/filter_footer.php"; ?>
</div>
<?php
require_once "../includes/footer.php";