mirror of
https://github.com/itflow-org/itflow
synced 2026-11-11 07:01:16 +00:00
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:
91
admin/database_updates/3.6.0.php
Normal file
91
admin/database_updates/3.6.0.php
Normal 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);
|
||||
45
admin/database_updates/3.6.1.php
Normal file
45
admin/database_updates/3.6.1.php
Normal 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);
|
||||
@@ -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>
|
||||
|
||||
89
admin/modals/sms_provider/sms_numbers.php
Normal file
89
admin/modals/sms_provider/sms_numbers.php
Normal 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';
|
||||
9
admin/modals/sms_provider/sms_provider_add.php
Normal file
9
admin/modals/sms_provider/sms_provider_add.php
Normal 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';
|
||||
211
admin/modals/sms_provider/sms_provider_edit.php
Normal file
211
admin/modals/sms_provider/sms_provider_edit.php
Normal 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';
|
||||
53
admin/modals/sms_provider/sms_test.php
Normal file
53
admin/modals/sms_provider/sms_test.php
Normal 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';
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
180
admin/post/sms_provider.php
Normal 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();
|
||||
|
||||
}
|
||||
60
admin/post/text_messages.php
Normal file
60
admin/post/text_messages.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
266
admin/text_messages.php
Normal 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 > 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) . '&sort=' . $column . '&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']) . '&' : '' ?>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 ? ' · 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
195
admin/text_messaging.php
Normal 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 > 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) . '&sort=' . $column . '&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 > SMS > 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";
|
||||
Reference in New Issue
Block a user