Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
40 changes: 20 additions & 20 deletions HANDOFF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Expand All @@ -24,50 +24,50 @@ export async function POST(request: Request) {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error();
body = value as Record<string, unknown>;
} 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))
|| typeof body.assignee !== 'string' || body.assignee.length > 100
|| 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<string, unknown>;
if (typeof config.enabled !== 'boolean' || typeof config.automaticRepairs !== 'boolean'
|| typeof config.intervalSeconds !== 'number' || !Number.isInteger(config.intervalSeconds)
Expand All @@ -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' } });
}
5 changes: 5 additions & 0 deletions app/app/monitor/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { MonitorConsole } from '@/components/monitor-console';

export default function MonitorPage() {
return <MonitorConsole />;
}
5 changes: 0 additions & 5 deletions app/app/watchdog/page.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion components/duplicate-audit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ export function DuplicateAudit() {
<div className="relative mx-auto max-w-[1500px] px-5 sm:px-8 lg:px-12">
<header className="flex flex-wrap items-center justify-between gap-5 border-b border-white/10 py-5">
<Link href="/" className="flex items-center gap-3"><span className="grid h-10 w-10 place-items-center rounded-xl bg-[#d8ff67] font-mono text-xs font-black text-[#06100d]">GT</span><div><p className="font-mono text-[9px] uppercase tracking-[0.22em] text-[#7e968b]">GTM Control Tower</p><p className="font-semibold">Identity operations</p></div></Link>
<nav className="flex flex-wrap gap-2 text-xs"><Link href="/app/work" className="rounded-full border border-[#d8ff67]/20 px-4 py-2 text-[#d8ff67]">Operations Inbox</Link><Link href="/app/watchdog" className="rounded-full border border-[#d8ff67]/20 px-4 py-2 text-[#d8ff67]">CRM Watchdog</Link><Link href="/app/inbound" className="rounded-full border border-[#d8ff67]/20 px-4 py-2 text-[#d8ff67]">Inbound routing lab</Link><Link href="/app/lab" className="rounded-full border border-white/10 px-4 py-2 text-[#9fb2a8]">CSV cleanup lab</Link><Link href="/setup" className="rounded-full border border-white/10 px-4 py-2 text-[#9fb2a8]">Setup</Link><Link href="/runs" className="rounded-full border border-white/10 px-4 py-2 text-[#9fb2a8]">Run evidence</Link></nav>
<nav className="flex flex-wrap gap-2 text-xs"><Link href="/app/work" className="rounded-full border border-[#d8ff67]/20 px-4 py-2 text-[#d8ff67]">Operations Inbox</Link><Link href="/app/monitor" className="rounded-full border border-[#d8ff67]/20 px-4 py-2 text-[#d8ff67]">CRM Monitor</Link><Link href="/app/inbound" className="rounded-full border border-[#d8ff67]/20 px-4 py-2 text-[#d8ff67]">Inbound routing lab</Link><Link href="/app/lab" className="rounded-full border border-white/10 px-4 py-2 text-[#9fb2a8]">CSV cleanup lab</Link><Link href="/setup" className="rounded-full border border-white/10 px-4 py-2 text-[#9fb2a8]">Setup</Link><Link href="/runs" className="rounded-full border border-white/10 px-4 py-2 text-[#9fb2a8]">Run evidence</Link></nav>
</header>

<section className="grid gap-7 py-10 lg:grid-cols-[1fr_auto] lg:items-end">
Expand Down
Loading
Loading