## Summary - add tenant triage review-state persistence, fingerprinting, resolver logic, service layer, and migration for current affected-set tracking - surface review-state and affected-set progress across tenant registry, tenant dashboard arrival continuity, and workspace overview - extend RBAC, audit/badge support, specs, and test coverage for portfolio triage review-state workflows - suppress expected hidden-page background transport failures in the global unhandled rejection logger while keeping visible-page failures logged ## Validation - targeted Pest coverage added for tenant registry, workspace overview, arrival context, RBAC authorization, badges, fingerprinting, resolver behavior, and logger asset behavior - code formatted with `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` ## Notes - full suite was not re-run in this final step - branch includes the spec artifacts under `specs/189-portfolio-triage-review-state/` Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #220
174 lines
4.8 KiB
JavaScript
174 lines
4.8 KiB
JavaScript
(() => {
|
|
if (typeof window === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
if (window.__tenantpilotUnhandledRejectionLoggerApplied) {
|
|
return;
|
|
}
|
|
|
|
window.__tenantpilotUnhandledRejectionLoggerApplied = true;
|
|
|
|
const recentKeys = new Map();
|
|
|
|
const cleanupRecentKeys = (nowMs) => {
|
|
for (const [key, timestampMs] of recentKeys.entries()) {
|
|
if (nowMs - timestampMs > 5_000) {
|
|
recentKeys.delete(key);
|
|
}
|
|
}
|
|
};
|
|
|
|
const isTransportEnvelope = (value) => {
|
|
return value !== null
|
|
&& typeof value === 'object'
|
|
&& Object.prototype.hasOwnProperty.call(value, 'status')
|
|
&& Object.prototype.hasOwnProperty.call(value, 'body')
|
|
&& Object.prototype.hasOwnProperty.call(value, 'json')
|
|
&& Object.prototype.hasOwnProperty.call(value, 'errors');
|
|
};
|
|
|
|
const isCancellationReason = (reason) => {
|
|
if (!isTransportEnvelope(reason)) {
|
|
return false;
|
|
}
|
|
|
|
return reason.status === null
|
|
&& reason.body === null
|
|
&& reason.json === null
|
|
&& reason.errors === null;
|
|
};
|
|
|
|
const isPageHiddenOrInactive = () => {
|
|
if (document.visibilityState !== 'visible') {
|
|
return true;
|
|
}
|
|
|
|
return typeof document.hasFocus === 'function'
|
|
? document.hasFocus() === false
|
|
: false;
|
|
};
|
|
|
|
const isExpectedBackgroundTransportFailure = (reason) => {
|
|
if (isCancellationReason(reason)) {
|
|
return true;
|
|
}
|
|
|
|
if (!isTransportEnvelope(reason) || !isPageHiddenOrInactive()) {
|
|
return false;
|
|
}
|
|
|
|
return (reason.status === 419 && typeof reason.body === 'string' && reason.body.includes('Page Expired'))
|
|
|| (reason.status === 404 && typeof reason.body === 'string' && reason.body.includes('Not Found'));
|
|
};
|
|
|
|
const normalizeReason = (value, depth = 0) => {
|
|
if (depth > 3) {
|
|
return '[max-depth-reached]';
|
|
}
|
|
|
|
if (value instanceof Error) {
|
|
return {
|
|
type: 'Error',
|
|
name: value.name,
|
|
message: value.message,
|
|
stack: value.stack,
|
|
};
|
|
}
|
|
|
|
if (value === null || value === undefined) {
|
|
return value;
|
|
}
|
|
|
|
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
|
return value;
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.slice(0, 10).map((item) => normalizeReason(item, depth + 1));
|
|
}
|
|
|
|
if (typeof value === 'object') {
|
|
const result = {};
|
|
const allowedKeys = [
|
|
'message',
|
|
'stack',
|
|
'name',
|
|
'type',
|
|
'status',
|
|
'body',
|
|
'json',
|
|
'errors',
|
|
'reason',
|
|
'code',
|
|
];
|
|
|
|
for (const key of allowedKeys) {
|
|
if (Object.prototype.hasOwnProperty.call(value, key)) {
|
|
result[key] = normalizeReason(value[key], depth + 1);
|
|
}
|
|
}
|
|
|
|
if (Object.keys(result).length > 0) {
|
|
return result;
|
|
}
|
|
|
|
const stringTag = Object.prototype.toString.call(value);
|
|
|
|
return {
|
|
type: stringTag,
|
|
value: String(value),
|
|
};
|
|
}
|
|
|
|
return String(value);
|
|
};
|
|
|
|
const toStableJson = (payload) => {
|
|
try {
|
|
return JSON.stringify(payload);
|
|
} catch {
|
|
return JSON.stringify({
|
|
source: payload.source,
|
|
href: payload.href,
|
|
timestamp: payload.timestamp,
|
|
reason: '[unserializable]',
|
|
});
|
|
}
|
|
};
|
|
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|
const normalizedReason = normalizeReason(event.reason);
|
|
const payload = {
|
|
source: 'window.unhandledrejection',
|
|
href: window.location.href,
|
|
timestamp: new Date().toISOString(),
|
|
reason: normalizedReason,
|
|
};
|
|
|
|
if (isExpectedBackgroundTransportFailure(normalizedReason)) {
|
|
event.preventDefault();
|
|
|
|
return;
|
|
}
|
|
|
|
const dedupeKey = toStableJson({
|
|
source: payload.source,
|
|
href: payload.href,
|
|
reason: payload.reason,
|
|
});
|
|
|
|
const payloadJson = toStableJson(payload);
|
|
const nowMs = Date.now();
|
|
|
|
cleanupRecentKeys(nowMs);
|
|
|
|
if (recentKeys.has(dedupeKey)) {
|
|
return;
|
|
}
|
|
|
|
recentKeys.set(dedupeKey, nowMs);
|
|
|
|
console.error(`TenantPilot unhandled promise rejection ${payloadJson}`);
|
|
});
|
|
})(); |