Files
itflow/functions/notifications.php
johnnyq 9ba21cf352 Assets: add NAS, UPS, PDU, Modem/ONT and NVR/DVR types from a new registry in functions/asset_type.php that sets each type's icon, tone, group, fields and details; the asset form shows only the fields a type uses (fields that already hold a value stay shown) and adds a Details section per type (CPU/RAM/storage, VM host/vCPUs, switch ports/SFP/PoE/management, NAS drives/capacity/RAID, UPS capacity/output/runtime/outlets/battery dates, PDU kind/outlets/input, modem circuit ID/ISP account, printer, phone, mobile, display, camera and NVR details) stored in a new asset_specs table (database 3.4.9); new or changed port and SFP counts add the missing interfaces, continuing the asset's numbering, skipping Wi-Fi, VLAN and management ports and never deleting any; the asset page and quick look show the details, a server or NAS lists the VMs that run on it, and Assigned and Warranty show only on types that use them; the list gets Servers & NAS and Power tabs, an optional Details column, a UPS battery badge, an Assigned picker only on assigned types, and New buttons named for what they add; nightly UPS Battery Due notifications at 45, 7 and 1 days as a Support notification; copy leaves out per-unit details, Transfer to Client carries details and interface types, export adds per-detail columns, import reads detail columns after the standard twelve (VM hosts by name, resolved after every row is in) and reports values it could not keep, and the API reads and writes asset_specs; racks, client portal, client export, overview, document templates and AI lists group the new types; fix asset import carrying physical location and notes into the next row, saving 0000-00-00 for a missing purchase date and warning on an unknown contact or location, save API and import types in the list's spelling, and keep interface types on transfer; changelog updated
2026-10-27 15:59:03 -04:00

224 lines
8.3 KiB
PHP

<?php
/*
* In-app notifications - reading and marking them.
*
* Writing them is appNotify() in functions/logging.php. Everything that reads
* them goes through here: the bell panel in the top bar (js/notifications.js,
* via agent/ajax.php) and the full page, agent/notifications.php. Both use the
* same visibility rule, so the badge count, the panel and the page always agree.
*
* "Read" is notification_dismissed_at being set. The column predates the read /
* unread wording; nightly_tasks.php still clears read notifications after 90 days.
*
* Values come back RAW, not HTML-escaped. The panel renders them as text, the
* page escapes them at output.
*/
/*
* appNotify() sends to every active agent, so a notification about a client's
* invoice reaches people who cannot open that invoice. These are the types that
* belong to a module; a user without read access to the module does not see them.
* Types not listed (mail failures, backups, updates, settings) stay visible to all,
* as before.
*/
function notificationTypeModules() {
return [
'module_sales' => [
'Invoice', 'Invoice Paid', 'Invoice Viewed', 'Invoice Overdue', 'Invoice Late Charge',
'Draft Invoices', 'Recurring Sent', 'Recurring Generated',
'Quote Viewed', 'Quote Accepted', 'Quote Declined', 'Quote File', 'Low Stock',
],
'module_financial' => ['Expense', 'Expense Created'],
'module_support' => [
'Ticket', 'Ticket SLA', 'Pending Tickets', 'Feedback', 'Recurring Ticket', 'Project',
'Domain Expiring', 'Certificate Expiring', 'Asset Warranty Expiring', 'UPS Battery Due', 'Service Review',
// Shared links - they open Shared Links (agent/shares.php), which is Support's
'Shared link opened', 'Shared link locked', 'Login sent back', 'Share Viewed',
],
];
}
/**
* The WHERE conditions for "notifications this user may see": their own, for a
* client they can access (or no client), and of a type their role can read.
*/
function notificationVisibilitySql() {
global $session_user_id;
$sql = 'notification_user_id = ' . intval($session_user_id) . clientScopeSql('notification_client_id');
foreach (notificationTypeModules() as $module => $types) {
if (lookupUserPermission($module) < 1) {
$sql .= " AND notification_type NOT IN ('" . implode("','", array_map('escapeSql', $types)) . "')";
}
}
return $sql;
}
/** Icon and colour for a notification type, matched on its wording. */
function notificationTypeStyle($type) {
$t = strtolower((string) $type);
$rules = [
// [words in the type, icon, tone]
[['sla'], 'stopwatch', 'danger'],
[['failed', 'fail'], 'exclamation-circle', 'danger'],
[['declined'], 'times-circle', 'danger'],
[['feedback'], 'thumbs-down', 'danger'],
[['paid'], 'check-circle', 'success'],
[['accepted'], 'check-circle', 'success'],
[['overdue', 'late charge'], 'exclamation-triangle', 'warning'],
[['expiring'], 'hourglass-half', 'warning'],
[['battery'], 'car-battery', 'warning'],
[['stock'], 'boxes', 'warning'],
[['viewed'], 'eye', 'info'],
[['quote'], 'comment-dollar', 'primary'],
[['invoice', 'recurring sent', 'recurring generated'], 'file-invoice-dollar', 'primary'],
[['expense'], 'receipt', 'secondary'],
[['ticket'], 'life-ring', 'primary'],
[['project'], 'project-diagram', 'primary'],
[['service'], 'clipboard-check', 'info'],
[['mail'], 'envelope', 'danger'],
[['backup'], 'database', 'secondary'],
[['master key'], 'key', 'warning'],
[['update'], 'download', 'info'],
[['share'], 'share-square', 'info'],
[['settings'], 'cog', 'secondary'],
];
foreach ($rules as [$words, $icon, $tone]) {
foreach ($words as $word) {
if (strpos($t, $word) !== false) {
return ['icon' => $icon, 'tone' => $tone];
}
}
}
return ['icon' => 'bell', 'tone' => 'secondary'];
}
/*
* Where clicking a notification goes. Stored actions are a mix of
* "/agent/ticket.php?..." and a bare "ticket.php?..." that only worked from
* pages under /agent/. Only same-site paths are returned - anything with a
* scheme or host comes back as null, which renders as "no link".
*/
function notificationActionUrl($action) {
$action = trim((string) $action);
if ($action === '' || $action === '#' || strtoupper($action) === 'NULL') {
return null;
}
if (preg_match('#^[a-z][a-z0-9+.-]*:#i', $action) || strpos($action, '//') === 0 || strpos($action, '\\') !== false) {
return null;
}
if ($action[0] !== '/') {
$action = '/agent/' . $action;
}
return $action;
}
function notificationItem($row) {
$style = notificationTypeStyle($row['notification_type']);
// Backups, updates and the like share one type for good news and bad;
// the wording is what says it went wrong
if (preg_match('/\bfail(ed|ure|s)?\b/i', (string) $row['notification'])) {
$style['tone'] = 'danger';
}
$timestamp = strtotime($row['notification_timestamp']);
return [
'id' => intval($row['notification_id']),
'type' => $row['notification_type'],
'text' => $row['notification'],
'url' => notificationActionUrl($row['notification_action']),
'client' => $row['client_name'] ?: null,
'client_id' => intval($row['notification_client_id']),
'unread' => $row['notification_dismissed_at'] === null,
'time' => $timestamp ? date('c', $timestamp) : null,
'time_full' => $timestamp ? date('D, M j Y g:i A', $timestamp) : '',
'icon' => $style['icon'],
'tone' => $style['tone'],
];
}
function notificationUnreadCount() {
global $mysqli;
$row = mysqli_fetch_row(mysqli_query(
$mysqli,
"SELECT COUNT(*) FROM notifications WHERE " . notificationVisibilitySql() . " AND notification_dismissed_at IS NULL"
));
return intval($row[0]);
}
/** Newest first. $view is 'unread' or 'all'. */
function notificationsFetch($view = 'unread', $limit = 20) {
global $mysqli;
$limit = max(1, min(100, intval($limit)));
$unread_only = $view === 'unread' ? 'AND notification_dismissed_at IS NULL' : '';
$sql = mysqli_query(
$mysqli,
"SELECT notification_id, notification_type, notification, notification_action, notification_timestamp,
notification_dismissed_at, notification_client_id, client_name
FROM notifications
LEFT JOIN clients ON notification_client_id = client_id
WHERE " . notificationVisibilitySql() . " $unread_only
ORDER BY notification_id DESC
LIMIT $limit"
);
$items = [];
while ($row = mysqli_fetch_assoc($sql)) {
$items[] = notificationItem($row);
}
return $items;
}
/**
* Mark the given notifications read (or unread again). Only the current user's
* own rows are touched, whatever ids are sent. Returns the number changed.
*/
function notificationsMark($ids, $read = true) {
global $mysqli, $session_user_id;
$ids = array_values(array_filter(array_map('intval', (array) $ids)));
if (!$ids) {
return 0;
}
$user_id = intval($session_user_id);
$id_list = implode(',', $ids);
if ($read) {
mysqli_query($mysqli, "UPDATE notifications SET notification_dismissed_at = NOW(), notification_dismissed_by = $user_id
WHERE notification_user_id = $user_id AND notification_dismissed_at IS NULL AND notification_id IN ($id_list)");
} else {
mysqli_query($mysqli, "UPDATE notifications SET notification_dismissed_at = NULL, notification_dismissed_by = NULL
WHERE notification_user_id = $user_id AND notification_dismissed_at IS NOT NULL AND notification_id IN ($id_list)");
}
return mysqli_affected_rows($mysqli);
}
/** Every unread notification of the current user, including any their role hides. */
function notificationsMarkAllRead() {
global $mysqli, $session_user_id;
$user_id = intval($session_user_id);
mysqli_query($mysqli, "UPDATE notifications SET notification_dismissed_at = NOW(), notification_dismissed_by = $user_id
WHERE notification_user_id = $user_id AND notification_dismissed_at IS NULL");
return mysqli_affected_rows($mysqli);
}