Skip to content

feat(notion): Ops Copilot ← Notion Command Center bridge + gated lead sync#201

Merged
arigatoexpress merged 10 commits into
mainfrom
feat/notion-ops-bridge
Jun 16, 2026
Merged

feat(notion): Ops Copilot ← Notion Command Center bridge + gated lead sync#201
arigatoexpress merged 10 commits into
mainfrom
feat/notion-ops-bridge

Conversation

@arigatoexpress

Copy link
Copy Markdown
Owner

Why

Make the in-app Ops Copilot (the Vertex/Gemini staff bot replacing Telegram/Mira) source live operational status from the Notion Command Center, and optionally mirror website leads into the Notion Lead Pipeline — PII-safe by construction.

What (10 commits)

  • Command Center reader (tools/notion_client.py) — config-driven (_db_id(db_key)), fetch_status_counts is PII-free by construction (counts/status only), whitespace-tolerant prop matching, NOTION_COMMAND_CENTER=off default.
  • Ops Copilot wiringget_business_snapshot() sources installations (Delivery Tracker), feedback (CS-survey), and count-by-status for title/collections/insurance/service-warranty; flag-off = byte-identical (regression-locked).
  • Config DB ids wired into config.yaml; pagination fix (full count, not capped at 100).
  • GET /api/admin/ops-snapshot — read-only, PII-free Ops dashboard data (admin-gated, 401 without auth).
  • Lead writer (gated NOTION_LEAD_SYNC=off) — mirrors website leads into the live Lead Pipeline, schema-exact (incl. the trailing-space Email field), avoids the 3 dirty phone columns + SSN; failures stay server-side (no public disclosure); lead_id join stamp.
  • GCP activation runbook + status-label PII guard.

Safety

  • All Notion integration is OFF by default (NOTION_COMMAND_CENTER=off, NOTION_LEAD_SYNC=off) → merging is a no-op until the operator sets the token + flags. PII (names/SSN/phone) never crosses to Notion; only counts/status/ratings.
  • Reads NOTION_TOKEN + per-DB ids from env/Secret Manager (prod already has NOTION_TOKENnotion-id + NOTION_COMMAND_CENTER=on staged, inert until this merges; deploy.yml uses --update-* so it survives).

Tests

Activation (operator-gated, after merge+deploy)

  1. Share the 7 Command Center DBs with the integration token.
  2. On merge, the staged NOTION_COMMAND_CENTER=on activates the Ops Copilot's live Notion read.
  3. Optionally set NOTION_LEAD_SYNC=on after adding clean "Lead Source"/"Date Received" fields to the Lead Pipeline.

🤖 Generated with Claude Code

arigatoexpress and others added 10 commits June 16, 2026 13:28
Integration increment 2. Generalizes tools/notion_client.py from the two
hard-coded Mira DB ids into a reader over the whole Notion ops hub, so the
in-app Ops Copilot can answer from the staff's real system (the functional Mira
replacement). Purely additive — existing fetch_installations/fetch_feedback
untouched.

- _db_id(db_key): resolves a Command Center DB id from config.yaml
  notion.databases.<key>, env fallback NOTION_<KEY>_DB_ID. DB ids are business
  config, not secrets; NOTION_TOKEN stays the only credential.
- fetch_status_counts(db_key): count-by-status reader, PII-FREE BY CONSTRUCTION
  -- reads ONLY the status/phase column (alias-matched), never any other
  property, so no customer identity / dollar figure / free text can enter the
  result, however the operator names or adds columns.
- is_command_center_enabled(): NOTION_COMMAND_CENTER master flag (default off);
  the consumer gates on it -> one-env-var flip + instant revert (INVENTORY_SOURCE
  pattern).
- _find_prop now strips whitespace, tolerating dirty Notion schemas
  (trailing-space / duplicate column names).

Tests: +5 (count correctness, PII never in result, dirty/aliased status columns,
env resolver, unconfigured -> {}). 15 green, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d Center

Integration increment 3 -- the functional Mira replacement. Teaches
get_business_snapshot() to source installations (Delivery Tracker), feedback
(CS-survey), and count-by-status for the post-funding ops (service & warranty,
title, collections, insurance) from Notion via increment 2's reader when
NOTION_COMMAND_CENTER=on; otherwise the exact Firestore behavior (regression
locked by the existing aggregation test). One env var flips it; revert instant.

- PII-free: every Notion-sourced section is count-by-status only.
- Fault-isolated: a failing Notion read degrades to {} / Firestore fallback and
  never sinks the snapshot; _call_model stays the only external seam.
- +2 HELP_TOPICS (where-is-my-delivery, title/collections/insurance/service).

Tests: +4 (Notion-sourced snapshot incl. ops sections + PII-free assertion,
flag-off regression lock, reader-raises degradation, delivery help topic).
21 green, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Activation now reduces to the NOTION_TOKEN secret + NOTION_COMMAND_CENTER=on --
the 7 ops-DB ids (Delivery Tracker, CS-survey, Service & Warranty, Title,
Collections, Insurance, Lead Pipeline) live in config.yaml notion.databases.*
(identifiers, not secrets; each still env-overridable via NOTION_<KEY>_DB_ID).

Tests updated for config-then-env resolution. 36 green, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Audited prod state + the exact, ordered, copy-paste gcloud commands for the
gated activation (inventory read-flip, Notion token secret + IAM + flag), with
the --update-* (never --set-*) safety rule and the PII/no-auto-trade notes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…done

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ed, PII-safe)

Integration increment 4. After a website contact-form lead is saved to Firestore
(system of record), optionally mirror it into the staff's Notion Lead Pipeline so
salespeople see web leads in the system they work from. Server-side, gated behind
NOTION_LEAD_SYNC (default off), fire-and-forget (never blocks the visitor).

- tools/notion_lead_writer.py: schema-exact write to the LIVE Lead Pipeline --
  Customer Name / 'Email ' (trailing space!) / Phone Number / Pipeline Stage=
  New Lead / Notes (source+date stamp); deliberately AVOIDS the two dirty
  duplicate phone columns and never writes 'SSN / Tax ID'. Reuses the NOTION_TOKEN
  http core; db id from config.yaml.
- main.py POST /api/contact: fire-and-forget call after create_lead; failure ->
  'notion_lead_sync_failed' warning, visitor still gets success.

Tests: 8 (off-by-default, exact columns incl. trailing-space Email, avoids
dirty/SSN columns, omit-when-absent, posts to /pages, swallows errors). ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…at 100)

All-hands audit: _query_database sent a single page (<=100 rows) with no
has_more/next_cursor loop, so fetch_status_counts silently undercounted any DB
over 100 rows -> a confidently-wrong staff answer once the flag flips. Now it
follows Notion's cursor up to _MAX_QUERY_PAGES (20 = 2000 rows), bounded by the
requested limit, preserving the 8s/never-raise contract (returns partial on
error). fetch_status_counts default limit raised to 2000. +1 two-page test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…isclosure)

All-hands audit (low): /api/contact echoed 'notion_lead_sync_failed' in the
public warnings[] returned to anonymous visitors, disclosing that leads are
mirrored to Notion. Now logged server-side only (event field preserved for a
Cloud Run log alert); not surfaced externally. The integration stays invisible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- fetch_status_counts: collapse a status VALUE that is overlong or matches
  phone/email/SSN to 'OTHER', so operator free-text can't ride the counts-only
  channel into the LLM prompt (all-hands optional hardening).
- notion_lead_writer + /api/contact: stamp the Firestore lead_id into the Lead
  Pipeline row's Notes as 'ref=<id>' -- the cross-system join key (no dedicated
  'Lead ID' field exists yet; app stays SoR for the raw lead). +2 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…board data

Next-level (high value): the in-app, GCP-native read surface that replaces the
Telegram/Mira status pushes. Admin-gated, read-only, returns get_business_snapshot()
-- counts only (leads/appointments/inventory/deals/installations/feedback + the
Notion Command Center ops counts when NOTION_COMMAND_CENTER=on), never PII/dollars,
never 500s. +2 tests (gate + shape).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f0f0b01e7e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread tools/ops_copilot.py
if _command_center_enabled():
install_counts = _notion_status_counts("delivery_tracker")
snapshot["installations"] = {
"by_status": install_counts or _count_by_status("service_requests")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve zero Notion counts instead of falling back

When NOTION_COMMAND_CENTER is on, an empty Delivery Tracker returns {} from fetch_status_counts, the same value used for Notion failures. Because this treats {} as false and falls back to Firestore, a legitimate zero-row/zero-status Command Center database is reported using stale service_requests counts instead of zero; the feedback fallback below has the same ambiguity. Please distinguish a failed/unconfigured Notion read from a successful empty result before falling back.

Useful? React with 👍 / 👎.

Comment thread main.py
# write_lead is fire-and-forget and logs notion_lead_sync_failed
# itself on failure. Do NOT surface that to the anonymous visitor
# in `warnings` — keep the integration invisible externally.
write_lead(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move Notion lead writes off the request path

When NOTION_LEAD_SYNC is enabled, this call performs httpx.post(..., timeout=8) synchronously inside the async public contact handler. If Notion is slow or unreachable, the visitor's submit waits for that timeout and the event loop is blocked, so this is not actually fire-and-forget and can stall unrelated requests under concurrent submissions. Please run the write in a background task/threadpool or use an async client.

Useful? React with 👍 / 👎.

@arigatoexpress arigatoexpress merged commit 3687999 into main Jun 16, 2026
2 checks passed
@arigatoexpress arigatoexpress deleted the feat/notion-ops-bridge branch June 16, 2026 22:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant