From 8585d4959dc6a60ecf2a10b7f3ffbfa3d566f8d8 Mon Sep 17 00:00:00 2001 From: Harrison O'Connor-Hoover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:14:05 -0400 Subject: [PATCH] refactor: rename CRM monitoring feature --- .env.example | 8 +- Dockerfile | 2 +- HANDOFF.md | 40 +-- README.md | 4 +- .../{watchdog => monitor}/route.ts | 36 +-- app/app/monitor/page.tsx | 5 + app/app/watchdog/page.tsx | 5 - components/duplicate-audit.tsx | 2 +- ...tchdog-console.tsx => monitor-console.tsx} | 70 ++--- components/operations-inbox.tsx | 8 +- components/self-host-console.tsx | 2 +- compose.yaml | 10 +- docs/decisions.md | 26 +- docs/{watchdog.md => monitor.md} | 59 ++-- docs/operations-inbox.md | 8 +- docs/slack-notifications.md | 10 +- lib/monitor-connection.ts | 32 ++ ...watchdog-hubspot.ts => monitor-hubspot.ts} | 48 +-- lib/monitor-migration.ts | 69 +++++ lib/{watchdog-rules.ts => monitor-rules.ts} | 20 +- ...og-salesforce.ts => monitor-salesforce.ts} | 38 +-- lib/monitor-store.ts | 274 ++++++++++++++++++ lib/{watchdog-types.ts => monitor-types.ts} | 48 +-- lib/{watchdog.ts => monitor.ts} | 142 ++++----- lib/operation-notifications.ts | 34 +-- lib/operations-inbox.ts | 20 +- lib/slack-notifications.ts | 5 +- lib/watchdog-connection.ts | 32 -- lib/watchdog-store.ts | 273 ----------------- package.json | 4 +- ...og-native.mjs => check-monitor-native.mjs} | 22 +- .../{check-watchdog.mjs => check-monitor.mjs} | 20 +- scripts/check-operations-inbox.mjs | 6 +- ...js => check-salesforce-monitor-native.mjs} | 18 +- scripts/{watchdog.mjs => monitor.mjs} | 22 +- ...watchdog-d1.test.ts => monitor-d1.test.ts} | 4 +- ...ubspot.test.ts => monitor-hubspot.test.ts} | 10 +- tests/monitor-migration.test.ts | 155 ++++++++++ ...og-route.test.ts => monitor-route.test.ts} | 44 +-- ...-runner.test.ts => monitor-runner.test.ts} | 14 +- ...rce.test.ts => monitor-salesforce.test.ts} | 6 +- tests/{watchdog.test.ts => monitor.test.ts} | 134 ++++----- tests/operation-notifications.test.ts | 38 +-- tests/operations-inbox.test.ts | 4 +- tests/slack-notifications.test.ts | 4 +- 45 files changed, 1047 insertions(+), 788 deletions(-) rename app/api/control-tower/{watchdog => monitor}/route.ts (64%) create mode 100644 app/app/monitor/page.tsx delete mode 100644 app/app/watchdog/page.tsx rename components/{watchdog-console.tsx => monitor-console.tsx} (78%) rename docs/{watchdog.md => monitor.md} (73%) create mode 100644 lib/monitor-connection.ts rename lib/{watchdog-hubspot.ts => monitor-hubspot.ts} (75%) create mode 100644 lib/monitor-migration.ts rename lib/{watchdog-rules.ts => monitor-rules.ts} (76%) rename lib/{watchdog-salesforce.ts => monitor-salesforce.ts} (83%) create mode 100644 lib/monitor-store.ts rename lib/{watchdog-types.ts => monitor-types.ts} (58%) rename lib/{watchdog.ts => monitor.ts} (57%) delete mode 100644 lib/watchdog-connection.ts delete mode 100644 lib/watchdog-store.ts rename scripts/{check-watchdog-native.mjs => check-monitor-native.mjs} (71%) rename scripts/{check-watchdog.mjs => check-monitor.mjs} (96%) rename scripts/{check-salesforce-watchdog-native.mjs => check-salesforce-monitor-native.mjs} (84%) rename scripts/{watchdog.mjs => monitor.mjs} (78%) rename tests/{watchdog-d1.test.ts => monitor-d1.test.ts} (56%) rename tests/{watchdog-hubspot.test.ts => monitor-hubspot.test.ts} (89%) create mode 100644 tests/monitor-migration.test.ts rename tests/{watchdog-route.test.ts => monitor-route.test.ts} (71%) rename tests/{watchdog-runner.test.ts => monitor-runner.test.ts} (90%) rename tests/{watchdog-salesforce.test.ts => monitor-salesforce.test.ts} (96%) rename tests/{watchdog.test.ts => monitor.test.ts} (68%) diff --git a/.env.example b/.env.example index d164edb..b44f6e9 100644 --- a/.env.example +++ b/.env.example @@ -21,12 +21,12 @@ CONTROL_TOWER_SYNC_KEY= # Direct comparison and writes require crm.objects.contacts.read and crm.objects.contacts.write. # Leave blank when using the n8n OAuth workflow instead. Never commit the real value. HUBSPOT_ACCESS_TOKEN= -# Optional unattended Watchdog runner; the app server keeps CRM credentials. -# Enable checks and choose repair policy in /app/watchdog before starting it. -CONTROL_TOWER_WATCHDOG_URL=http://127.0.0.1:3000 +# Optional unattended Monitor runner; the app server keeps CRM credentials. +# Enable checks and choose repair policy in /app/monitor before starting it. +CONTROL_TOWER_MONITOR_URL=http://127.0.0.1:3000 # Node infers configured providers when omitted. Docker defaults to HubSpot; # set this to salesforce or hubspot,salesforce to monitor Salesforce in Docker. -# CONTROL_TOWER_WATCHDOG_PROVIDERS=hubspot,salesforce +# CONTROL_TOWER_MONITOR_PROVIDERS=hubspot,salesforce # Optional Slack alerts. The incoming webhook determines the destination channel. # Keep this secret on the app server; never put it in browser variables or Git. CONTROL_TOWER_SLACK_WEBHOOK_URL= diff --git a/Dockerfile b/Dockerfile index 254f88a..3496a5a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,6 +24,6 @@ COPY --from=build /app/node_modules/scheduler /app/node_modules/scheduler COPY --from=build /app/node_modules/acorn-loose /app/node_modules/acorn-loose COPY --from=build /app/node_modules/neo-async /app/node_modules/neo-async COPY --from=build /app/node_modules/webpack-sources /app/node_modules/webpack-sources -COPY --from=build /app/scripts/watchdog.mjs /app/scripts/watchdog.mjs +COPY --from=build /app/scripts/monitor.mjs /app/scripts/monitor.mjs EXPOSE 3000 CMD ["node", "server.js"] diff --git a/HANDOFF.md b/HANDOFF.md index 916c44a..2dab274 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -2,38 +2,38 @@ ## Finished -- Consolidated Safe Import, HubSpot/Salesforce Watchdog, the Operations Inbox, Slack notifications, inbound routing, duplicate audit and Salesforce/Hollis intake work for main. -- Preserved both Git histories and all existing main functionality. Resolved 18 conflicts caused by mirrored commits using the verified newer source. -- Added explicit TypeScript checking to CI, excluded generated outputs from lint, clarified historical evidence and surfaced Safe Import in the README. -- Updated vulnerable dependencies and replaced two build tools' unpatched glob dependency with a small scoped, tested tinyglobby adapter. The existing audit gate remains intact. +- Renamed the feature to **CRM Monitor** across HubSpot and Salesforce screens, the Operations Inbox, Slack notifications, routes, commands, configuration, source files and guides. +- Added an automatic SQLite upgrade that preserves account settings, scan cursors, findings, repair history and uncertain repair holds. +- Updated queued Slack links and generated titles without changing notification identity or delivery state. Previously delivered messages remain historical records. +- Retained the Safe Import and other completed feature work already integrated into main. ## Try It -1. Follow the [README](README.md): `docker compose up --build` starts the persistent SQLite application. -2. Open `/app/work` for CSV requests and findings, `/app/lab` for saved Safe Import reviews, or `/app/watchdog` for provider monitoring and triage. -3. Configure your own CRM connections and operator key. Follow [Slack setup](docs/slack-notifications.md) for the channel webhook and private app URL; run `npm run watchdog` for background checks and delivery. +1. Existing installations: stop the previous runner, retain the SQLite directory, and follow the configuration update in [CRM Monitor](docs/monitor.md) before restarting. +2. Run `docker compose up --build`, then open `/app/monitor` or `/app/work`. +3. For background checks, use `npm run monitor` or the Compose `monitor` profile. Configure your own CRM connections and [Slack destination](docs/slack-notifications.md). ## Checks -- Full suite: 650 tests across 52 files passed. TypeScript, lint, doctor, application/public builds, Compose validation and diff checks passed. -- A separate clean `npm ci`, dependency resolution and real Next/Vite consumer tests passed. The dependency audit passes its high/critical threshold; production-only audit reports zero vulnerabilities. -- Secret scanning passed before publication. GitHub CI additionally checks the credential-free Docker installation, SQLite restart persistence and undo; consult the pull request check for that run's result. The local Docker daemon was unavailable. -- The public output remains static. No CRM writes, Slack messages, Salesforce metadata deployment or hosted operator deployment occurred during integration. +- 655 tests across 53 files passed, including five upgrade tests covering saved history, unknown repair holds, queued Slack messages, partial schemas and transactional rollback. +- Full TypeScript, lint, application/public builds, Compose configuration, runner help and diff checks passed. +- Mocked Monitor and Inbox browser checks passed on desktop/mobile, including both CRM providers and report downloads; zero external requests. Monitor desktop and Salesforce mobile were visually inspected. +- Secret scanning passed before publication. The pull request CI also checks the credential-free Docker installation and SQLite restart persistence. +- No CRM writes, Slack messages, native provider probes or application deployment occurred. ## Decisions -- Keep a normal merge with both histories; retain the unrelated author-rewrite backup separately. Every completed feature tip is included. -- Use the existing self-hosted SQLite runtime. Merging source does not qualify the whole-file D1 executor. -- The glob adapter supports the two existing build-tool consumers only; remove it when upstream dependencies replace their vulnerable chain. See [decisions](docs/decisions.md). +- Use CRM Monitor as the product name and `monitor` for technical identifiers. +- Keep retired storage identifiers only in the upgrade helper and regression fixtures so existing installations can migrate safely; preserve Git history. +- Update runner configuration and bookmarks during the upgrade; retired routes and commands are not aliases. ## Remaining -- Four moderate development-tool audit findings remain in Drizzle's nested esbuild chain; production dependencies are clear. -- Slack requires local destination configuration. Native Salesforce automatic writes and hosted Safe Import execution remain unqualified. -- Polling can miss intermediate CRM changes; team roles, Slack approvals and automatic OAuth refresh remain outside the completed slice. +- Slack needs a configured webhook and private app URL. Native Salesforce automatic writes and hosted Safe Import execution remain unqualified. +- Four moderate development-tool dependency findings remain from the prior integration; production dependencies are clear at that integration's audit. ## Review First -- [README](README.md), [Safe Import](docs/safe-import.md), and [Operations Inbox](docs/operations-inbox.md). -- `.github/workflows/ci.yml` and the pull request's CI results, including credential-free Docker startup and persistence checks. -- `vendor/fast-glob-compat`, its consumer tests, and the dependency lockfile. +- [Upgrade instructions](docs/monitor.md) and `.env.example`. +- `lib/monitor-migration.ts` and `tests/monitor-migration.test.ts`. +- The pull request's CI result and `/app/monitor`. diff --git a/README.md b/README.md index 5f6d7cf..1dd586a 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,11 @@ That original run used advisory approximate review. A later [native follow-up](d Contacts and Salesforce Leads/Contacts, with exact field decisions, identity holds, account-bound approval, durable execution and native readback. Unmatched rows never create records; populated CRM values are preserved. -- Provides a connected [CRM Watchdog](docs/watchdog.md) at `/app/watchdog` for +- Provides a connected [CRM Monitor](docs/monitor.md) at `/app/monitor` for HubSpot Contacts and Salesforce Leads/Contacts: recurring checks, a durable human triage queue, downloadable reports, and optional verified company/job-title whitespace repairs. A separate runner continues with the browser closed. Requires the self-hosted SQLite app. -- Combines incoming CSV lists and Watchdog findings in the [Operations Inbox](docs/operations-inbox.md) +- Combines incoming CSV lists and CRM Monitor findings in the [Operations Inbox](docs/operations-inbox.md) at `/app/work`, with saved ownership, exact-review links, and verified outcomes. - Sends optional [Slack notifications](docs/slack-notifications.md) for findings, repairs, import reviews/results and follow-up changes across both CRMs. The diff --git a/app/api/control-tower/watchdog/route.ts b/app/api/control-tower/monitor/route.ts similarity index 64% rename from app/api/control-tower/watchdog/route.ts rename to app/api/control-tower/monitor/route.ts index 022caf3..b0c8921 100644 --- a/app/api/control-tower/watchdog/route.ts +++ b/app/api/control-tower/monitor/route.ts @@ -1,15 +1,15 @@ -import { getWatchdogConnection } from '@/lib/watchdog-connection'; +import { getMonitorConnection } from '@/lib/monitor-connection'; import { operatorAccessError } from '@/lib/operator-auth'; -import { configureWatchdog, getWatchdogOverview, tickWatchdog, triageWatchdogFinding } from '@/lib/watchdog'; -import type { WatchdogConfig, WatchdogProvider } from '@/lib/watchdog-types'; -import { notifyWatchdogConnectionFailure } from '@/lib/operation-notifications'; +import { configureMonitor, getMonitorOverview, tickMonitor, triageMonitorFinding } from '@/lib/monitor'; +import type { MonitorConfig, MonitorProvider } from '@/lib/monitor-types'; +import { notifyMonitorConnectionFailure } from '@/lib/operation-notifications'; export async function GET(request: Request) { const denied = operatorAccessError(request); if (denied) return denied; try { - const connection = await getWatchdogConnection(parseProvider(new URL(request.url).searchParams.get('provider') ?? undefined)); - return Response.json({ watchdog: { ...await getWatchdogOverview(connection.scope), ...(connection.instanceUrl ? { instanceUrl: connection.instanceUrl } : {}) } }, { headers: { 'cache-control': 'no-store' } }); + const connection = await getMonitorConnection(parseProvider(new URL(request.url).searchParams.get('provider') ?? undefined)); + return Response.json({ monitor: { ...await getMonitorOverview(connection.scope), ...(connection.instanceUrl ? { instanceUrl: connection.instanceUrl } : {}) } }, { headers: { 'cache-control': 'no-store' } }); } catch (error) { return failure(error); } @@ -24,22 +24,22 @@ export async function POST(request: Request) { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(); body = value as Record; } catch { - return Response.json({ error: 'Send a valid watchdog action.' }, { status: 400 }); + return Response.json({ error: 'Send a valid monitor action.' }, { status: 400 }); } try { const provider = parseProvider(body.provider); if (body.action === 'tick' && body.force !== undefined && typeof body.force !== 'boolean') throw new InputError('Force must be true or false.'); - const connection = await getWatchdogConnection(provider).catch(async (error: unknown) => { - if (body.action === 'tick') await notifyWatchdogConnectionFailure(provider); + const connection = await getMonitorConnection(provider).catch(async (error: unknown) => { + if (body.action === 'tick') await notifyMonitorConnectionFailure(provider); throw error; }); const { scope: accountId, token } = connection; if (body.action === 'configure') { const config = parseConfig(body.config); - await configureWatchdog(accountId, config); + await configureMonitor(accountId, config); } else if (body.action === 'tick') { if (body.force !== undefined && typeof body.force !== 'boolean') throw new InputError('Force must be true or false.'); - await tickWatchdog(accountId, token, { force: body.force === true, ...(connection.provider === 'salesforce' ? { source: connection.source } : {}) }); + await tickMonitor(accountId, token, { force: body.force === true, ...(connection.provider === 'salesforce' ? { source: connection.source } : {}) }); } else if (body.action === 'triage') { if (typeof body.id !== 'string' || !body.id || body.id.length > 200 || !['open', 'in_progress', 'resolved'].includes(String(body.status)) @@ -47,27 +47,27 @@ export async function POST(request: Request) { || typeof body.note !== 'string' || body.note.length > 2000) { throw new InputError('Triage needs an issue ID, status, assignee (up to 100 characters), and note (up to 2,000 characters).'); } - await triageWatchdogFinding(accountId, { + await triageMonitorFinding(accountId, { id: body.id, status: body.status as 'open' | 'in_progress' | 'resolved', assignee: body.assignee.trim(), note: body.note.trim(), }); } else { throw new InputError('Choose configure, tick, or triage.'); } - return Response.json({ watchdog: { ...await getWatchdogOverview(accountId), ...(connection.instanceUrl ? { instanceUrl: connection.instanceUrl } : {}) } }, { headers: { 'cache-control': 'no-store' } }); + return Response.json({ monitor: { ...await getMonitorOverview(accountId), ...(connection.instanceUrl ? { instanceUrl: connection.instanceUrl } : {}) } }, { headers: { 'cache-control': 'no-store' } }); } catch (error) { return failure(error); } } -function parseProvider(value: unknown): WatchdogProvider { +function parseProvider(value: unknown): MonitorProvider { if (value === undefined || value === 'hubspot') return 'hubspot'; if (value === 'salesforce') return 'salesforce'; - throw new InputError('Choose HubSpot or Salesforce for Watchdog.'); + throw new InputError('Choose HubSpot or Salesforce for Monitor.'); } -function parseConfig(value: unknown): WatchdogConfig { - if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InputError('Watchdog settings are required.'); +function parseConfig(value: unknown): MonitorConfig { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InputError('Monitor settings are required.'); const config = value as Record; if (typeof config.enabled !== 'boolean' || typeof config.automaticRepairs !== 'boolean' || typeof config.intervalSeconds !== 'number' || !Number.isInteger(config.intervalSeconds) @@ -82,6 +82,6 @@ function parseConfig(value: unknown): WatchdogConfig { class InputError extends Error {} function failure(error: unknown) { - return Response.json({ error: error instanceof Error ? error.message : 'The watchdog request could not complete.' }, + return Response.json({ error: error instanceof Error ? error.message : 'The monitor request could not complete.' }, { status: error instanceof InputError ? 400 : 503, headers: { 'cache-control': 'no-store' } }); } diff --git a/app/app/monitor/page.tsx b/app/app/monitor/page.tsx new file mode 100644 index 0000000..3007bf3 --- /dev/null +++ b/app/app/monitor/page.tsx @@ -0,0 +1,5 @@ +import { MonitorConsole } from '@/components/monitor-console'; + +export default function MonitorPage() { + return ; +} diff --git a/app/app/watchdog/page.tsx b/app/app/watchdog/page.tsx deleted file mode 100644 index d6de5a4..0000000 --- a/app/app/watchdog/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { WatchdogConsole } from '@/components/watchdog-console'; - -export default function WatchdogPage() { - return ; -} diff --git a/components/duplicate-audit.tsx b/components/duplicate-audit.tsx index f13fe3d..16e5632 100644 --- a/components/duplicate-audit.tsx +++ b/components/duplicate-audit.tsx @@ -191,7 +191,7 @@ export function DuplicateAudit() {
GT

GTM Control Tower

Identity operations

- +
diff --git a/components/watchdog-console.tsx b/components/monitor-console.tsx similarity index 78% rename from components/watchdog-console.tsx rename to components/monitor-console.tsx index 61a1a24..9d55ffc 100644 --- a/components/watchdog-console.tsx +++ b/components/monitor-console.tsx @@ -2,28 +2,28 @@ import Link from 'next/link'; import { useEffect, useMemo, useRef, useState } from 'react'; -import type { WatchdogConfig, WatchdogFinding, WatchdogOverview, WatchdogRule, WatchdogStatus } from '@/lib/watchdog-types'; +import type { MonitorConfig, MonitorFinding, MonitorOverview, MonitorRule, MonitorStatus } from '@/lib/monitor-types'; const KEY_STORAGE = 'gtm-control-tower-operator-key'; const inputStyle = 'w-full rounded-xl border border-white/15 bg-[#06100d] px-3 py-2.5 text-sm text-[#edf8f2] outline-none focus:border-[#d8ff67]/60 focus:ring-2 focus:ring-[#d8ff67]/15'; const secondaryButton = 'rounded-full border border-white/15 px-4 py-2.5 text-xs font-semibold text-[#c0d0c7] transition hover:border-white/30 hover:bg-white/[0.04] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#d8ff67] disabled:cursor-not-allowed disabled:opacity-40'; const primaryButton = 'rounded-full bg-[#d8ff67] px-5 py-2.5 text-xs font-bold text-[#06100d] transition hover:bg-[#e4ff95] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#d8ff67] disabled:cursor-not-allowed disabled:opacity-40'; -const ruleNames: Record = { +const ruleNames: Record = { email_missing: 'Missing email', email_invalid: 'Invalid email', owner_missing: 'Missing owner', lifecycle_regression: 'Lifecycle moved backward', text_whitespace: 'Extra spaces in text', }; -const statusNames: Record = { open: 'Open', in_progress: 'In progress', resolved: 'Resolved', fixed: 'Fixed', unknown: 'Verify outcome' }; +const statusNames: Record = { open: 'Open', in_progress: 'In progress', resolved: 'Resolved', fixed: 'Fixed', unknown: 'Verify outcome' }; type Provider = 'hubspot' | 'salesforce'; -type QueueFilter = 'needs_attention' | 'all' | WatchdogStatus; +type QueueFilter = 'needs_attention' | 'all' | MonitorStatus; type Triage = { id: string; status: 'open' | 'in_progress' | 'resolved'; assignee: string; note: string }; -export function WatchdogConsole() { +export function MonitorConsole() { const [provider, setProvider] = useState('hubspot'); const providerLabel = provider === 'salesforce' ? 'Salesforce' : 'HubSpot'; - const [overview, setOverview] = useState(null); + const [overview, setOverview] = useState(null); const [accessKey, setAccessKey] = useState(''); const keyRef = useRef(''); const [loading, setLoading] = useState(true); @@ -49,7 +49,7 @@ export function WatchdogConsole() { setSelectedId(params.get('finding')); if (params.has('finding')) setFilter('all'); try { - const next = await requestWatchdog(storedKey, initialProvider, undefined, controller.signal); + const next = await requestMonitor(storedKey, initialProvider, undefined, controller.signal); if (!cancelled) { setOverview(next); setLoadedAt(new Date().toISOString()); @@ -68,7 +68,7 @@ export function WatchdogConsole() { if (!accountId || busy) return; const controller = new AbortController(); const timer = window.setInterval(() => { - void requestWatchdog(keyRef.current, provider, undefined, controller.signal).then((next) => { + void requestMonitor(keyRef.current, provider, undefined, controller.signal).then((next) => { if (!controller.signal.aborted) { setOverview(next); setLoadedAt(new Date().toISOString()); @@ -87,7 +87,7 @@ export function WatchdogConsole() { url.searchParams.set('provider', next); url.searchParams.delete('finding'); window.history.replaceState(null, '', url); try { - setOverview(await requestWatchdog(keyRef.current, next)); + setOverview(await requestMonitor(keyRef.current, next)); setLoadedAt(new Date().toISOString()); } catch (caught) { setError(errorMessage(caught)); } finally { setLoading(false); } @@ -105,7 +105,7 @@ export function WatchdogConsole() { setError(null); setNotice(null); try { - setOverview(await requestWatchdog(keyRef.current, provider)); + setOverview(await requestMonitor(keyRef.current, provider)); setLoadedAt(new Date().toISOString()); } catch (caught) { setError(errorMessage(caught)); @@ -119,7 +119,7 @@ export function WatchdogConsole() { setError(null); setNotice(null); try { - const next = await requestWatchdog(keyRef.current, provider, { action, ...payload }); + const next = await requestMonitor(keyRef.current, provider, { action, ...payload }); setOverview(next); setLoadedAt(new Date().toISOString()); setNotice(action === 'configure' ? 'Monitoring settings saved.' : action === 'triage' ? 'Review decision saved.' : 'Check returned. Review its result in recent runs.'); @@ -139,7 +139,7 @@ export function WatchdogConsole() {
- GTGTM Control TowerCRM Watchdog + GTGTM Control TowerCRM Monitor
@@ -152,18 +152,18 @@ export function WatchdogConsole() {
Operator access key -

Use your self-hosted operator key if configured. It stays in this tab's session storage and is excluded from reports.

+

Use your self-hosted operator key if configured. It stays in this tab's session storage and is excluded from reports.

{error &&
{error}{overview &&

The results below are the last loaded snapshot. Refresh after resolving the error.

}
} {notice &&

{notice}

} {loading &&
Loading connected {providerLabel} monitoring…
} - {!loading && !overview &&

Connection required

Connect {providerLabel} to start monitoring.

Watchdog needs a verified {providerLabel} account and persistent storage on your server. Complete connector setup, then refresh this page to read its actual findings.

Open connector setup
} + {!loading && !overview &&

Connection required

Connect {providerLabel} to start monitoring.

CRM Monitor needs a verified {providerLabel} account and persistent storage on your server. Complete connector setup, then refresh this page to read its actual findings.

Open connector setup
} {overview && <>

Monitoring {overview.config.enabled ? 'enabled' : 'paused'}

{providerLabel} {overview.accountId}

Snapshot loaded {dateTime(loadedAt)}

-

Scheduled checks require npm run watchdog on your server. Enabled is a saved setting; this page does not verify that the runner is online. Closing this page does not stop a running server process. Results refresh every 30 seconds.

+

Scheduled checks require npm run monitor on your server. Enabled is a saved setting; this page does not verify that the runner is online. Closing this page does not stop a running server process. Results refresh every 30 seconds.

{overview.phase === 'baseline' ? 'Building the baseline. ' : 'Watching record changes. '}{overview.coverageNote}

0} />
@@ -171,11 +171,11 @@ export function WatchdogConsole() { void mutate('configure', { config })} /> -
-

Triage and repair evidence

Review queue

Recent findings returned by the server. Totals may include older findings outside this list.

+
+

Triage and repair evidence

Review queue

Recent findings returned by the server. Totals may include older findings outside this list.

-
-

Recent runs

Each row reports one scan batch. Reports and exports contain the recent results available here.

- {overview.runs.length ?
{['Started', 'Phase', 'Result', 'Scanned', 'New findings', 'Fixed', 'Review'].map((label) => )}{overview.runs.map((run) => )}
Recent Watchdog scan batches, with counts and provider errors
{label}

{dateTime(run.startedAt)}

{run.finishedAt ? `Finished ${dateTime(run.finishedAt)}` : 'No completion recorded'}

{run.error &&

{run.error}

}
{run.phase === 'baseline' ? 'Baseline' : 'Changes'}{run.status === 'running' ? 'Started; awaiting result' : run.status}{run.scanned.toLocaleString()}{run.opened.toLocaleString()}{run.fixed.toLocaleString()}{run.needsReview.toLocaleString()}
:

No runs recorded. Enable monitoring and save settings, then run a check or start the server runner.

} +
+

Recent runs

Each row reports one scan batch. Reports and exports contain the recent results available here.

+ {overview.runs.length ?
{['Started', 'Phase', 'Result', 'Scanned', 'New findings', 'Fixed', 'Review'].map((label) => )}{overview.runs.map((run) => )}
Recent CRM Monitor scan batches, with counts and provider errors
{label}

{dateTime(run.startedAt)}

{run.finishedAt ? `Finished ${dateTime(run.finishedAt)}` : 'No completion recorded'}

{run.error &&

{run.error}

}
{run.phase === 'baseline' ? 'Baseline' : 'Changes'}{run.status === 'running' ? 'Started; awaiting result' : run.status}{run.scanned.toLocaleString()}{run.opened.toLocaleString()}{run.fixed.toLocaleString()}{run.needsReview.toLocaleString()}
:

No runs recorded. Enable monitoring and save settings, then run a check or start the server runner.

}
}
@@ -195,10 +195,10 @@ export function WatchdogConsole() { ); } -function Configuration({ provider, config, disabled, saving, onSave }: { provider: Provider; config: WatchdogConfig; disabled: boolean; saving: boolean; onSave: (config: WatchdogConfig) => void }) { +function Configuration({ provider, config, disabled, saving, onSave }: { provider: Provider; config: MonitorConfig; disabled: boolean; saving: boolean; onSave: (config: MonitorConfig) => void }) { const [draft, setDraft] = useState(config); const dirty = JSON.stringify(draft) !== JSON.stringify(config); - return

Monitoring settings

Scan record changes at a regular interval. The first checks build a baseline. Enable monitoring and save settings before running a manual check.

{dirty ? 'Unsaved changes' : 'Saved settings'}
+ return

Monitoring settings

Scan record changes at a regular interval. The first checks build a baseline. Enable monitoring and save settings before running a manual check.

{dirty ? 'Unsaved changes' : 'Saved settings'}
{ event.preventDefault(); onSave(draft); }}>
@@ -207,14 +207,14 @@ function Configuration({ provider, config, disabled, saving, onSave }: { provide
; } -function FindingDetail({ provider, instanceUrl, finding, disabled, saving, onTriage }: { provider: Provider; instanceUrl?: string; finding: WatchdogFinding; disabled: boolean; saving: boolean; onTriage: (triage: Triage) => void }) { +function FindingDetail({ provider, instanceUrl, finding, disabled, saving, onTriage }: { provider: Provider; instanceUrl?: string; finding: MonitorFinding; disabled: boolean; saving: boolean; onTriage: (triage: Triage) => void }) { const [assignee, setAssignee] = useState(finding.assignee); const [note, setNote] = useState(finding.note); const [status, setStatus] = useState(finding.status === 'in_progress' || finding.status === 'resolved' ? finding.status : 'open'); const nativeLink = nativeRecordLink(provider, instanceUrl, finding); const fields = [...new Set([...Object.keys(finding.before), ...Object.keys(finding.after)])]; return

{finding.contactLabel || `${finding.objectType ?? 'Contact'} ${finding.contactId}`}

{ruleNames[finding.rule]} · {finding.severity} severity

{nativeLink && Open in {provider === 'salesforce' ? 'Salesforce' : 'HubSpot'} ↗}

{finding.summary}

{finding.detail}

- {finding.status === 'unknown' &&

The repair outcome is uncertain. Inspect the native record before resolving this item; Watchdog will not blindly replay this repair.

} + {finding.status === 'unknown' &&

The repair outcome is uncertain. Inspect the native record before resolving this item; CRM Monitor will not blindly replay this repair.

} {finding.repairError &&

Repair detail: {finding.repairError}

}
{fields.length ? fields.map((field) => ) : }
Observed record fields and proposed or applied changes
Field{finding.rule === 'lifecycle_regression' ? 'Previously observed' : 'Observed'}{finding.rule === 'lifecycle_regression' ? 'Current value' : finding.status === 'fixed' ? 'Verified after repair' : 'Proposed value'}
{field === 'jobtitle' || field === 'Title' ? 'Job title' : field === 'hubspot_owner_id' ? 'Owner ID' : field === 'lifecyclestage' ? 'Lifecycle' : field}{propertyValue(finding.before[field])}{Object.hasOwn(finding.after, field) ? propertyValue(finding.after[field]) : 'Human decision'}
No field values recorded.
{finding.resolvedAt && }
@@ -222,7 +222,7 @@ function FindingDetail({ provider, instanceUrl, finding, disabled, saving, onTri
; } -function StatusPill({ status }: { status: WatchdogStatus }) { +function StatusPill({ status }: { status: MonitorStatus }) { const tone = status === 'fixed' ? 'bg-[#d8ff67]/10 text-[#d8ff67]' : status === 'unknown' ? 'bg-[#e6bd68]/10 text-[#e6bd68]' : status === 'in_progress' ? 'bg-[#83bcff]/10 text-[#a3cfff]' : 'bg-white/[0.06] text-[#b3c7bb]'; return {statusNames[status]}; } @@ -234,16 +234,16 @@ function Metric({ label, value, accent = false, warning = false }: { label: stri function StatusDetail({ label, value }: { label: string; value: string }) { return
{label}
{value}
; } function dateTime(value: string | null) { if (!value) return 'Not recorded'; const date = new Date(value); return Number.isNaN(date.getTime()) ? value : date.toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }); } function propertyValue(value: string | undefined) { return value === undefined || value === '' ? '(empty)' : JSON.stringify(value); } -function errorMessage(caught: unknown) { return caught instanceof Error ? caught.message : 'Watchdog could not complete the request.'; } +function errorMessage(caught: unknown) { return caught instanceof Error ? caught.message : 'CRM Monitor could not complete the request.'; } -async function requestWatchdog(accessKey: string, provider: Provider, body?: Record, signal?: AbortSignal): Promise { - const response = await fetch(`/api/control-tower/watchdog?provider=${provider}`, { method: body ? 'POST' : 'GET', cache: 'no-store', signal, headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(accessKey ? { 'x-control-tower-key': accessKey } : {}) }, ...(body ? { body: JSON.stringify({ ...body, provider }) } : {}) }); - const result = await response.json() as { watchdog?: WatchdogOverview; error?: string }; - if (!response.ok || !result.watchdog) throw new Error(result.error ?? `Watchdog request failed (${response.status}).`); - return result.watchdog; +async function requestMonitor(accessKey: string, provider: Provider, body?: Record, signal?: AbortSignal): Promise { + const response = await fetch(`/api/control-tower/monitor?provider=${provider}`, { method: body ? 'POST' : 'GET', cache: 'no-store', signal, headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(accessKey ? { 'x-control-tower-key': accessKey } : {}) }, ...(body ? { body: JSON.stringify({ ...body, provider }) } : {}) }); + const result = await response.json() as { monitor?: MonitorOverview; error?: string }; + if (!response.ok || !result.monitor) throw new Error(result.error ?? `CRM Monitor request failed (${response.status}).`); + return result.monitor; } -function nativeRecordLink(provider: Provider, instanceUrl: string | undefined, finding: WatchdogFinding): string | null { +function nativeRecordLink(provider: Provider, instanceUrl: string | undefined, finding: MonitorFinding): string | null { if (provider === 'hubspot') return `https://app.hubspot.com/contacts/${encodeURIComponent(finding.accountId)}/contact/${encodeURIComponent(finding.contactId)}`; if (!instanceUrl) return null; try { @@ -262,9 +262,9 @@ function download(name: string, text: string, type: string) { URL.revokeObjectURL(url); } -function exportFindings(overview: WatchdogOverview) { +function exportFindings(overview: MonitorOverview) { const headers = ['provider', 'object_type', 'account_id', 'contact_id', 'contact', 'rule', 'severity', 'status', 'assignee', 'note', 'first_seen', 'last_seen', 'closed_at', 'occurrences', 'before', 'after', 'repair_error']; const rows = overview.findings.map((finding) => [finding.provider ?? overview.provider ?? 'hubspot', finding.objectType ?? 'Contact', finding.accountId, finding.contactId, finding.contactLabel, finding.rule, finding.severity, finding.status, finding.assignee, finding.note, finding.firstSeenAt, finding.lastSeenAt, finding.resolvedAt ?? '', String(finding.occurrences), JSON.stringify(finding.before), JSON.stringify(finding.after), finding.repairError ?? '']); const csv = [headers, ...rows].map((row) => row.map((value) => `"${(/^[\s]*[=+@-]/u.test(value) ? `'${value}` : value).replace(/"/gu, '""')}"`).join(',')).join('\r\n'); - download(`watchdog-recent-findings-${new Date().toISOString().slice(0, 10)}.csv`, csv, 'text/csv;charset=utf-8'); + download(`monitor-recent-findings-${new Date().toISOString().slice(0, 10)}.csv`, csv, 'text/csv;charset=utf-8'); } diff --git a/components/operations-inbox.tsx b/components/operations-inbox.tsx index c5ef3b4..7fe3cc7 100644 --- a/components/operations-inbox.tsx +++ b/components/operations-inbox.tsx @@ -56,7 +56,7 @@ export function OperationsInboxConsole() {

Control Tower / Private operations

Operations Inbox

One place for incoming lists and CRM findings. Review exact changes, follow up on uncertain records, and see verified results.

- +
{ event.preventDefault(); keyRef.current = accessKey; setSubmittedKey(accessKey); window.sessionStorage.setItem(keyStorage, accessKey); void refresh().catch((caught) => setError(message(caught))); }}> @@ -68,9 +68,9 @@ export function OperationsInboxConsole() {
{!data && !error &&

Loading saved work…

} - {data && visible.length === 0 &&

No work in this view

Upload a list or enable Watchdog for a connected CRM. Completed items are available using the filter above.

} + {data && visible.length === 0 &&

No work in this view

Upload a list or enable CRM Monitor for a connected CRM. Completed items are available using the filter above.

}
{visible.map((item) =>
-

{item.provider === 'salesforce' ? 'Salesforce' : 'HubSpot'} · {item.kind === 'import' ? 'Safe Import' : 'Watchdog'}

{names[item.status]}
+

{item.provider === 'salesforce' ? 'Salesforce' : 'HubSpot'} · {item.kind === 'import' ? 'Safe Import' : 'CRM Monitor'}

{names[item.status]}

{item.title}

{item.source}{item.accountId ? ` · Account ${item.accountId}` : ' · Account verified when review is prepared'}

{item.summary}

{item.counts &&

{item.counts.unchanged} unchanged · {item.counts.skipped} deliberately skipped. Held and unmatched rows remain unresolved work.

}
{item.assignee || 'Unassigned'}{item.kind === 'import' ? 'Open review workspace' : 'Investigate finding'} →
@@ -86,7 +86,7 @@ export function OperationsInboxConsole() {
-

Connected monitoring

{data?.connections.map((connection) =>

{connection.provider === 'salesforce' ? 'Salesforce' : 'HubSpot'}

{connection.error ?? `Account ${connection.accountId}`}

)}

Automatic fixes follow each account’s Watchdog setting. Imports always use their saved approval review.

+

Connected monitoring

{data?.connections.map((connection) =>

{connection.provider === 'salesforce' ? 'Salesforce' : 'HubSpot'}

{connection.error ?? `Account ${connection.accountId}`}

)}

Automatic fixes follow each account’s monitoring setting. Imports always use their saved approval review.

diff --git a/components/self-host-console.tsx b/components/self-host-console.tsx index c9e093e..f5e78af 100644 --- a/components/self-host-console.tsx +++ b/components/self-host-console.tsx @@ -245,7 +245,7 @@ export function SelfHostConsole({ return (
-

Keep an eye on connected CRM records between imports.

Open CRM Watchdog
+

Keep an eye on connected CRM records between imports.

Open CRM Monitor
{catalog?.accessKeyRequired &&

Kept only in this browser tab and required for private CRM reads and writes.

}