Files
dynamics-userscripts/non-compliant-alert.user.js
2026-10-25 05:20:16 +00:00

313 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==UserScript==
// @name CRM User Enhancements
// @namespace https://git.home-brightview.com/matt.rb
// @version 2026.10.25.1520
// @author matt.rb
// @match https://*.dynamics.com/*
// @run-at document-idle
// @grant none
// @noframes
// @updateURL https://code.home-brightview.com/matt.rb/dynamics-userscripts/raw/branch/dist/non-compliant-alert.meta.js
// @downloadURL https://code.home-brightview.com/matt.rb/dynamics-userscripts/raw/branch/dist/non-compliant-alert.user.js
// ==/UserScript==
// Data comes from the Dataverse Web API with the signed-in user's own session,
// same approach as GAMS_Change_Tracking (src/api/dataverse.js) — no DOM scraping.
//
// Tags are the N:N "Tags" subgrid on the Application form:
// msnfp_request <-> cgms_ref_tag (label in cgms_name), verified on PRD.
// If navProperties is emptied, the script falls back to auto-discovery:
// 1. every field on the Application record (lookups, choices, multi-select), then
// 2. every 1:N / N:N relationship from msnfp_request to a table whose logical
// name contains "tag".
// Check the console for [NCA] lines to see what it found.
(function () {
'use strict';
const CONFIG = {
entity: 'msnfp_request',
entitySet: 'msnfp_requests',
tagValue: 'Non-Compliant',
// Where the tag lives. Empty both to fall back to auto-discovery.
fields: [], // logical names on msnfp_request to check as well
navProperties: ['cgms_msnfp_request_cgms_ref_Tag_cgms_ref_Tag'],
tagNameAttribute: 'cgms_name', // label column on cgms_ref_tag
pollMs: 30000, // re-check while the record stays open (catches saves)
debug: true,
};
const API = '/api/data/v9.2';
const FORMATTED = 'OData.Community.Display.V1.FormattedValue';
const HEADERS = {
Accept: 'application/json',
'OData-MaxVersion': '4.0',
'OData-Version': '4.0',
Prefer: 'odata.include-annotations="*"',
};
const GUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const log = (...a) => CONFIG.debug && console.log('[NCA]', ...a);
const warn = (...a) => console.warn('[NCA]', ...a);
// ---------------------------------------------------------------- API
async function get(path) {
const res = await fetch(`${location.origin}${API}${path}`, {
headers: HEADERS,
credentials: 'include',
});
if (!res.ok) {
let detail = '';
try { detail = (await res.json())?.error?.message || ''; } catch { /* non-JSON */ }
throw new Error(`HTTP ${res.status}${detail ? `: ${detail}` : ''} — ${path}`);
}
return res.json();
}
// ---------------------------------------------------------------- matching
// "Non-Compliant", "non compliant", "Non–Compliant" all compare equal.
const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
const TARGET = norm(CONFIG.tagValue);
// Whole-value match only, so a description that merely mentions
// "non-compliant" in a sentence doesn't trigger the alert. Multi-select
// choices format as "A; B", so values are split on ; and , first.
function valueMatches(v) {
if (typeof v !== 'string' || !v || GUID_RE.test(v)) return false;
return v.split(/[;,]/).some((part) => norm(part) === TARGET);
}
// Returns the attribute keys on a record whose value (raw or formatted) matches.
function matchingKeys(record, onlyFields) {
const hits = [];
for (const [key, val] of Object.entries(record)) {
const base = key.split('@')[0];
if (base.startsWith('@')) continue;
if (key.includes('@') && !key.endsWith(`@${FORMATTED}`)) continue;
if (onlyFields && !onlyFields.some((f) => base === f || base === `_${f}_value`)) continue;
if (valueMatches(val)) hits.push(base);
}
return [...new Set(hits)];
}
// Inactive link rows (statecode 1) are treated as removed tags.
const isActive = (row) => row.statecode === undefined || row.statecode === 0;
// ---------------------------------------------------------------- discovery
const CACHE_KEY = `nca:navprops:${location.host}:${CONFIG.entity}`;
async function tagNavProperties() {
if (CONFIG.navProperties.length) return CONFIG.navProperties;
try {
const cached = sessionStorage.getItem(CACHE_KEY);
if (cached) return JSON.parse(cached);
} catch { /* storage blocked */ }
const meta = await get(
`/EntityDefinitions(LogicalName='${CONFIG.entity}')?$select=LogicalName`
+ '&$expand=OneToManyRelationships($select=ReferencingEntity,ReferencedEntityNavigationPropertyName),'
+ 'ManyToManyRelationships($select=Entity1LogicalName,Entity2LogicalName,'
+ 'Entity1NavigationPropertyName,Entity2NavigationPropertyName)',
);
// "stage" contains "tag" — exclude it so BPF stage tables aren't swept in.
const looksLikeTag = (n) => /tag/i.test(n) && !/stage/i.test(n);
const props = [];
for (const r of meta.OneToManyRelationships || []) {
if (looksLikeTag(r.ReferencingEntity)) props.push(r.ReferencedEntityNavigationPropertyName);
}
for (const r of meta.ManyToManyRelationships || []) {
if (r.Entity1LogicalName === CONFIG.entity && looksLikeTag(r.Entity2LogicalName)) {
props.push(r.Entity1NavigationPropertyName);
} else if (r.Entity2LogicalName === CONFIG.entity && looksLikeTag(r.Entity1LogicalName)) {
props.push(r.Entity2NavigationPropertyName);
}
}
const unique = [...new Set(props.filter(Boolean))];
log('tag relationships discovered:', unique.length ? unique : '(none)');
try { sessionStorage.setItem(CACHE_KEY, JSON.stringify(unique)); } catch { /* storage blocked */ }
return unique;
}
// Returns a list of human-readable places the tag was found; empty = no tag.
async function findTag(recordGuid) {
const found = [];
const path = `/${CONFIG.entitySet}(${recordGuid})`;
const pinned = CONFIG.navProperties.length > 0;
// With the relationship pinned, the record itself is only read when extra
// fields are configured; otherwise one small request per check.
if (!pinned || CONFIG.fields.length) {
const record = await get(path);
const onlyFields = CONFIG.fields.length ? CONFIG.fields : null;
for (const key of matchingKeys(record, onlyFields)) found.push(`field ${key}`);
}
// Pinned: fetch just the label + state and compare only the label column.
const query = pinned ? `?$select=${CONFIG.tagNameAttribute},statecode` : '?$top=250';
const rowFields = pinned ? [CONFIG.tagNameAttribute] : undefined;
let navProps = [];
try {
navProps = await tagNavProperties();
} catch (err) {
warn('relationship metadata lookup failed:', err.message);
}
await Promise.all(navProps.map(async (nav) => {
try {
const data = await get(`${path}/${nav}${query}`);
for (const row of data.value || []) {
if (isActive(row) && matchingKeys(row, rowFields).length) {
found.push(`related ${nav}`);
return;
}
}
} catch (err) {
warn(`related fetch failed for ${nav}:`, err.message);
}
}));
return found;
}
// ---------------------------------------------------------------- page context
// UCI is a SPA: watch the URL, fall back to Xrm when the query string lacks ids.
function currentRecord() {
const params = new URL(location.href).searchParams;
let etn = (params.get('etn') || '').toLowerCase();
let id = (params.get('id') || '').replace(/[{}]/g, '').toLowerCase();
const pagetype = (params.get('pagetype') || '').toLowerCase();
if (!GUID_RE.test(id) || !etn) {
try {
const input = window.Xrm?.Utility?.getPageContext?.()?.input;
if (input?.pageType === 'entityrecord') {
etn = String(input.entityName || '').toLowerCase();
id = String(input.entityId || '').replace(/[{}]/g, '').toLowerCase();
}
} catch { /* Xrm not ready */ }
} else if (pagetype && pagetype !== 'entityrecord') {
return null;
}
return etn === CONFIG.entity && GUID_RE.test(id) ? id : null;
}
// ---------------------------------------------------------------- UI
const CSS = `
:host { all: initial; }
.bubble {
position: fixed; top: 60px; right: 16px; z-index: 2147483000;
display: flex; align-items: flex-start; gap: 12px;
max-width: 340px; padding: 14px 14px 14px 16px;
background: #a4262c; color: #fff;
border-radius: 10px; border-left: 6px solid #ffb900;
box-shadow: 0 8px 24px rgba(0,0,0,.28), 0 2px 6px rgba(0,0,0,.2);
font: 14px/1.35 "Segoe UI", system-ui, -apple-system, sans-serif;
animation: nca-in .25s ease-out, nca-pulse 1.6s ease-in-out 3 .3s;
}
.icon {
flex: none; width: 28px; height: 28px; border-radius: 50%;
background: #ffb900; color: #3b0a0d;
display: grid; place-items: center; font-weight: 800; font-size: 18px;
}
.title { font-weight: 700; font-size: 15px; letter-spacing: .2px; }
.body { margin-top: 2px; opacity: .92; }
.btn {
flex: none; margin-left: 4px; width: 24px; height: 24px; padding: 0;
border: 0; border-radius: 4px; background: transparent; color: #fff;
font: inherit; font-size: 16px; line-height: 24px; cursor: pointer; opacity: .8;
}
.btn:hover, .btn:focus-visible { background: rgba(255,255,255,.18); opacity: 1; outline: none; }
/* Minimised: a pill that stays on screen — it can't be dismissed entirely. */
.bubble.min { padding: 6px 10px 6px 8px; gap: 8px; align-items: center; cursor: pointer; animation: none; }
.bubble.min .icon { width: 22px; height: 22px; font-size: 14px; }
.bubble.min .body, .bubble.min .btn { display: none; }
.bubble.min .title { font-size: 13px; }
@keyframes nca-in { from { opacity: 0; transform: translateY(-8px); } }
@keyframes nca-pulse {
50% { box-shadow: 0 0 0 6px rgba(255,185,0,.45), 0 8px 24px rgba(0,0,0,.28); }
}
@media (prefers-reduced-motion: reduce) { .bubble { animation: none; } }
`;
let host = null;
function showAlert() {
if (host?.isConnected) return;
host = document.createElement('div');
host.id = 'nca-alert-host';
const root = host.attachShadow({ mode: 'open' });
root.innerHTML = `
<style>${CSS}</style>
<div class="bubble" role="alert" aria-live="assertive">
<div class="icon" aria-hidden="true">!</div>
<div>
<div class="title">${CONFIG.tagValue}</div>
<div class="body">This application is tagged ${CONFIG.tagValue}. Check before proceeding.</div>
</div>
<button class="btn" type="button" title="Minimise" aria-label="Minimise alert">&#x2013;</button>
</div>`;
const bubble = root.querySelector('.bubble');
root.querySelector('.btn').addEventListener('click', (e) => {
e.stopPropagation();
bubble.classList.add('min');
bubble.title = 'Click to expand';
});
bubble.addEventListener('click', () => {
if (bubble.classList.contains('min')) {
bubble.classList.remove('min');
bubble.title = '';
}
});
document.body.appendChild(host);
}
function hideAlert() {
host?.remove();
host = null;
}
// ---------------------------------------------------------------- orchestration
let activeId = null;
let checkSeq = 0;
async function check() {
const id = activeId;
const seq = ++checkSeq;
if (!id) { hideAlert(); return; }
try {
const found = await findTag(id);
if (seq !== checkSeq || id !== activeId) return; // navigated away mid-check
if (found.length) {
log(`"${CONFIG.tagValue}" found on ${id}:`, found);
showAlert();
} else {
log(`no "${CONFIG.tagValue}" tag on ${id}`);
hideAlert();
}
} catch (err) {
warn('check failed:', err.message);
}
}
function onLocation() {
const id = currentRecord();
if (id === activeId) return;
activeId = id;
hideAlert();
check();
}
// currentRecord() is cheap and only triggers a fetch when the id changes, so
// polling it also covers Xrm becoming ready after the URL stopped changing.
setInterval(onLocation, 800);
onLocation();
setInterval(() => { if (activeId && !document.hidden) check(); }, CONFIG.pollMs);
document.addEventListener('visibilitychange', () => { if (!document.hidden && activeId) check(); });
window.addEventListener('popstate', onLocation);
})();