diff --git a/financial_forecasting/frontend-v2/scripts/preview-intro-api.mjs b/financial_forecasting/frontend-v2/scripts/preview-intro-api.mjs new file mode 100644 index 00000000..2b5a45f6 --- /dev/null +++ b/financial_forecasting/frontend-v2/scripts/preview-intro-api.mjs @@ -0,0 +1,121 @@ +/** + * Zero-dependency stub API for previewing the Jobs > Intro requests zone. + * + * Node-only alternative to scripts/preview_intro_requests.py, for machines + * without Python 3.10+. You need Node anyway to run the frontend, so this + * makes the preview a one-toolchain job. + * + * The payloads below are recorded verbatim from the real + * routes/jobs_intro.py router (that is what the Python version serves live); + * only the timestamps are recomputed so the relative dates stay sensible. + * + * MODE=after (default) builder lookup succeeds + * MODE=before builder lookup returns nothing, as public.users does for + * bedrock_user under RLS. On this branch the fallback chain + * catches it and renders "Builder #428"; on main the same + * empty lookup rendered a bare "from — (builder)". + * + * Usage, from financial_forecasting/frontend-v2, in two terminals: + * + * node scripts/preview-intro-api.mjs + * npm run dev + * + * Then open http://localhost:4200/jobs + */ +import { createServer } from 'node:http'; + +const MODE = process.env.MODE === 'before' ? 'before' : 'after'; +const PORT = 8000; + +const daysAgo = (n) => new Date(Date.now() - n * 86_400_000).toISOString(); + +const staffRow = { + id: '239f08c7-4e45-4539-8c41-2302bb35de67', + source: 'staff', + contact_id: 1001, + contact_name: 'Dana Whitfield', + contact_company: 'Northwind', + contact_title: 'Engineering Manager', + connector_staff_id: 4, + connector_name: 'Sam Okafor', + connector_email: 'sam.okafor@example.org', + builder_id: null, + builder_cohort: null, + requested_by: 'jordan.reyes@example.org', + requested_by_name: 'Jordan Reyes', + specific_ask: 'industry_advice', + context: + 'Sample staff→staff ask. Would you be open to introducing one of our ' + + 'builders for a 20-minute coffee chat?', + status: 'pending', + response_note: null, + responded_at: null, + created_at: daysAgo(4), +}; + +const builderRow = { + id: '15', + source: 'builder', + contact_id: 1002, + contact_name: 'Priya Raman', + contact_company: 'Lumen Labs', + contact_title: 'Director of Product Engineering', + connector_staff_id: 4, + connector_name: null, + connector_email: 'sam.okafor@example.org', + builder_id: 428, + builder_cohort: MODE === 'after' ? 'March 2026 L1+' : null, + requested_by: MODE === 'after' ? 'alex.mensah@example.org' : 'Builder #428', + requested_by_name: MODE === 'after' ? 'Alex Mensah' : 'Builder #428', + specific_ask: 'industry_advice', + context: + 'Sample builder ask. Their background bridging product strategy and ' + + 'technical architecture is exactly the path I am trying to grow into.', + status: 'pending', + response_note: null, + responded_at: null, + created_at: daysAgo(38), +}; + +const ME = { + email: 'sam.okafor@example.org', + name: 'Sam Okafor', + sub: 'sam', + salesforce_connected: false, + google_connected: true, + slack_configured: true, +}; + +const send = (res, body) => { + const json = JSON.stringify(body); + res.writeHead(200, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(json), + }); + res.end(json); +}; + +createServer((req, res) => { + const path = (req.url || '').split('?')[0]; + + if (path === '/auth/me') return send(res, ME); + if (path === '/api/jobs/intro-requests') { + return send(res, { success: true, data: [staffRow, builderRow] }); + } + + // Response shape matters: hooks destructure these differently, and handing + // back the wrong one throws inside render (a caught .filter on an object + // blanks the whole page). Salesforce endpoints return bare arrays; the + // notification badge reads data.data.count; the rest use {success, data}. + if (path.startsWith('/api/salesforce/')) return send(res, []); + if (path === '/api/notifications/unread-count') { + return send(res, { success: true, data: { count: 0 } }); + } + // Every other zone renders from an empty result. + return send(res, { success: true, data: [] }); +}).listen(PORT, '127.0.0.1', () => { + console.log(`\n intro-requests preview API — MODE=${MODE}`); + console.log(` listening on http://127.0.0.1:${PORT}`); + console.log(' now run "npm run dev" in another terminal, then open'); + console.log(' http://localhost:4200/jobs\n'); +}); diff --git a/financial_forecasting/frontend-v2/src/pages/jobs/JobsHome.tsx b/financial_forecasting/frontend-v2/src/pages/jobs/JobsHome.tsx index 286be641..118c03b8 100644 --- a/financial_forecasting/frontend-v2/src/pages/jobs/JobsHome.tsx +++ b/financial_forecasting/frontend-v2/src/pages/jobs/JobsHome.tsx @@ -926,9 +926,15 @@ function TasksZone({ owner }: { owner: string | null }) { } // ── Intro requests — asks addressed to me (staff + Sputnik builder), and mine ─ +// Union of both vocabularies — Bedrock's own staff→staff asks plus every value +// allowed by the CHECK constraint on Sputnik's public.intro_requests. Keep in +// sync with routes/jobs_intro.ASK_LABELS. const ASK_LABELS: Record = { hiring_intro: "Hiring intro", industry_advice: "Industry advice", job_referral: "Job referral", mock_interview: "Mock interview", + informational_interview: "Informational interview", + introductory_call: "Intro call", demo_feedback: "Demo feedback", + other: "Other", }; const askLabel = (a: string | null) => (a ? ASK_LABELS[a] ?? a.replace(/_/g, " ") : "Intro"); @@ -939,14 +945,28 @@ function IntroRequestCard({ r, mine }: { r: IntroRequest; mine: boolean }) { respond.mutate({ id: r.id, status, response_note: note.trim() || undefined, source: r.source }); const isPending = r.status === "pending"; const isAccepted = r.status === "accepted" || r.status === "approved"; + // Who is asking — the signal Avni asked to make obvious. Carried by a + // dedicated tag + a left border, not by the ask-type tag's colour (which + // used to smuggle it and was easy to miss while scanning). + // + // amber vs accent, not sky vs accent: --sky is oklch(0.55 0.13 245) and + // --accent is oklch(0.55 0.15 250) — same lightness, 5° apart in hue, so + // side by side they read as one colour. green/red are spoken for by the + // Accept/Decline actions on this same card. + const fromBuilder = r.source === "builder"; return ( -
+
+ {fromBuilder ? "Builder" : "Jobs team"} {r.contact_name || "—"} {r.contact_company && {r.contact_company}} - {askLabel(r.specific_ask)} + {askLabel(r.specific_ask)} - {mine ? `via ${r.connector_name || r.connector_email || "—"}` : `from ${r.requested_by_name || r.requested_by || "—"}${r.source === "builder" ? " (builder)" : ""}`} + {mine ? `via ${r.connector_name || r.connector_email || "—"}` : `from ${r.requested_by_name || r.requested_by || "—"}`} + {fromBuilder && r.builder_cohort ? ` · ${r.builder_cohort}` : ""} {r.created_at ? ` · ${relDay(r.created_at)}` : ""} {!isPending && ( @@ -965,7 +985,9 @@ function IntroRequestCard({ r, mine }: { r: IntroRequest; mine: boolean }) { className="rounded border border-red/40 px-2 py-0.5 text-[11px] font-medium text-red hover:bg-red/10">Decline
)} - {!mine && isAccepted && r.source === "staff" && ( + {/* Builder asks can be completed too now that BUILDER_STATUS_MAP stops + collapsing completed→approved. */} + {!mine && isAccepted && (
diff --git a/financial_forecasting/frontend-v2/src/services/jobs.ts b/financial_forecasting/frontend-v2/src/services/jobs.ts index 9a32ceaf..b271b246 100644 --- a/financial_forecasting/frontend-v2/src/services/jobs.ts +++ b/financial_forecasting/frontend-v2/src/services/jobs.ts @@ -1660,6 +1660,7 @@ export interface IntroRequest { contact_id: number; contact_name: string | null; contact_company: string | null; contact_title: string | null; connector_staff_id: number; connector_name: string | null; connector_email: string | null; + builder_id: number | null; builder_cohort: string | null; requested_by: string | null; requested_by_name?: string | null; specific_ask: string | null; context: string | null; status: string; response_note: string | null; diff --git a/financial_forecasting/main.py b/financial_forecasting/main.py index 959035d1..4f988dd9 100644 --- a/financial_forecasting/main.py +++ b/financial_forecasting/main.py @@ -259,6 +259,15 @@ async def startup_event(): except Exception as e: logger.warning(f"sf_notification_poller failed to start: {e}") + # Builder intro requests written by Sputnik into public.intro_requests. + # Postgres-only — no SF dependency, so this starts unconditionally. + try: + from services.intro_notification_poller import run_forever as _intro_notif_loop + asyncio.create_task(_intro_notif_loop()) + logger.info("intro_notification_poller started") + except Exception as e: + logger.warning(f"intro_notification_poller failed to start: {e}") + logger.info(f"API started — connected services: {client.connected_services or ['none']}") diff --git a/financial_forecasting/routes/jobs_intro.py b/financial_forecasting/routes/jobs_intro.py index 1bc934a1..caa796ff 100644 --- a/financial_forecasting/routes/jobs_intro.py +++ b/financial_forecasting/routes/jobs_intro.py @@ -6,6 +6,14 @@ read/respond-only so the old workflow isn't lost; that table is builder-owned, we never insert) +Builder identity must come from bedrock.builder_by_id (SECURITY DEFINER): +public.users has RLS enabled and the app role has no policy on it, so a direct +join returns zero rows without erroring. + +New Sputnik asks are fanned out to the connector staff member's bell + Slack DM +by services/intro_notification_poller.py, so both kinds of ask arrive through +the one Bedrock bot. + GET /api/jobs/contacts/{contact_id}/connectors — staff connected to a contact GET /api/jobs/intro-requests?box=inbox|sent — my inbox / my sent asks POST /api/jobs/intro-requests — create a staff→staff ask @@ -30,12 +38,27 @@ logger = logging.getLogger(__name__) -ASK_LABELS = {"hiring_intro": "Hiring intro", "industry_advice": "Industry advice", "job_referral": "Job referral"} +# Union of both vocabularies: the first four are what Bedrock's own +# staff→staff dialog offers; the rest are values allowed by the CHECK +# constraint on public.intro_requests (Sputnik). Unlabelled values used to +# fall through to the raw enum, so Slack DMs read "introductory_call". +ASK_LABELS = { + "hiring_intro": "Hiring intro", + "industry_advice": "Industry advice", + "job_referral": "Job referral", + "mock_interview": "Mock interview", + "informational_interview": "Informational interview", + "introductory_call": "Intro call", + "demo_feedback": "Demo feedback", + "other": "Other", +} router = APIRouter(prefix="/api/jobs", tags=["jobs"]) STAFF_STATUSES = {"pending", "accepted", "declined", "completed", "withdrawn"} -# public.intro_requests (Sputnik) vocabulary for the same actions -BUILDER_STATUS_MAP = {"accepted": "approved", "declined": "declined", "completed": "approved", "pending": "pending"} +# public.intro_requests (Sputnik) vocabulary for the same actions. Its CHECK +# constraint allows 'completed' natively, so accept and done stay distinct — +# mapping both onto 'approved' silently lost "I did it". +BUILDER_STATUS_MAP = {"accepted": "approved", "declined": "declined", "completed": "completed", "pending": "pending"} def _email(user) -> str: @@ -85,6 +108,21 @@ class IntroRequestRespond(BaseModel): source: str = "staff" # staff (bedrock) | builder (Sputnik) +def _builder_display(r) -> str: + """Human label for the builder behind a Sputnik ask. + + Never returns empty: a blank name would render as a bare "—" on the card, + which is exactly the bug this replaced. Falls back name → email → id. + """ + name = (r["builder_name"] or "").strip() + if name: + return name + email = (r["builder_email"] or "").strip() + if email: + return email + return f"Builder #{r['builder_id']}" + + def _staff_row(r) -> dict: return { "id": str(r["id"]), "source": "staff", @@ -92,7 +130,10 @@ def _staff_row(r) -> dict: "contact_company": r["contact_company"], "contact_title": r["contact_title"], "connector_staff_id": r["connector_staff_id"], "connector_name": r["connector_name"], "connector_email": r["connector_email"], + "builder_id": None, "builder_cohort": None, "requested_by": r["requested_by_email"], + # Staff asks showed a raw email because this key was simply absent. + "requested_by_name": r["requested_by_name"] or r["requested_by_email"], "specific_ask": r["specific_ask"], "context": r["context"], "status": r["status"], "response_note": r["response_note"], "responded_at": r["responded_at"].isoformat() if r["responded_at"] else None, @@ -106,10 +147,12 @@ def _staff_row(r) -> dict: ir.responded_at, ir.created_at, c.full_name AS contact_name, c.current_company AS contact_company, c.current_title AS contact_title, - m.display_name AS connector_name, m.email AS connector_email + m.display_name AS connector_name, m.email AS connector_email, + rm.display_name AS requested_by_name FROM bedrock.intro_request ir LEFT JOIN public.contacts c ON c.contact_id = ir.contact_id LEFT JOIN bedrock.staff_user_id_map m ON m.staff_user_id = ir.connector_staff_id + LEFT JOIN bedrock.staff_user_id_map rm ON lower(rm.email) = lower(ir.requested_by_email) """ @@ -135,11 +178,24 @@ async def list_intro_requests( f""" SELECT ir.intro_request_id, ir.contact_id, ir.contact_name, ir.contact_company, ir.contact_title, ir.specific_ask, ir.request_context, ir.status, - ir.staff_response_notes, ir.responded_at, ir.created_at, - trim(coalesce(u.first_name,'') || ' ' || coalesce(u.last_name,'')) AS builder_name, - u.email AS builder_email + -- These are `timestamp` (naive) here but `timestamptz` on + -- bedrock.intro_request, so without the cast the two sources + -- serialise differently and the browser reads builder dates + -- as local time. The DB runs in UTC. + ir.staff_response_notes, + ir.responded_at AT TIME ZONE 'UTC' AS responded_at, + ir.created_at AT TIME ZONE 'UTC' AS created_at, + ir.builder_id, + b.full_name AS builder_name, b.email AS builder_email, + b.cohort AS builder_cohort FROM public.intro_requests ir - LEFT JOIN public.users u ON u.user_id = ir.builder_id + -- public.users is RLS-scoped away from the app role (bedrock_user + -- has SELECT but no policy), so joining it directly returned zero + -- rows *silently* — a LEFT JOIN, so no error, and trim() over + -- coalesce'd NULLs yields '' rather than NULL. Every builder ask + -- rendered as "from —". bedrock.builder_by_id is SECURITY DEFINER + -- and already granted to bedrock_user; jobs.py uses the same shape. + LEFT JOIN LATERAL bedrock.builder_by_id(ir.builder_id) b ON true WHERE ir.staff_user_id = $1{open_builder} ORDER BY ir.created_at DESC """, sid) @@ -148,8 +204,9 @@ async def list_intro_requests( "contact_id": r["contact_id"], "contact_name": r["contact_name"], "contact_company": r["contact_company"], "contact_title": r["contact_title"], "connector_staff_id": sid, "connector_name": None, "connector_email": email, - "requested_by": r["builder_email"] or r["builder_name"], - "requested_by_name": r["builder_name"], + "builder_id": r["builder_id"], "builder_cohort": r["builder_cohort"], + "requested_by": r["builder_email"] or _builder_display(r), + "requested_by_name": _builder_display(r), "specific_ask": r["specific_ask"], "context": r["request_context"], "status": r["status"], "response_note": r["staff_response_notes"], "responded_at": r["responded_at"].isoformat() if r["responded_at"] else None, @@ -204,6 +261,7 @@ async def create_intro_request( actor_email=email, payload={ "title": "Intro request", + "requester_kind": "staff", "subtitle": f"{contact['full_name'] if contact else 'a contact'}", "contact_name": contact["full_name"] if contact else None, "contact_company": contact["current_company"] if contact else None, diff --git a/financial_forecasting/scripts/preview_intro_requests.py b/financial_forecasting/scripts/preview_intro_requests.py new file mode 100644 index 00000000..9542fee4 --- /dev/null +++ b/financial_forecasting/scripts/preview_intro_requests.py @@ -0,0 +1,147 @@ +"""Local visual preview of the Jobs > Intro requests zone. No database needed. + +Mounts the REAL routes/jobs_intro.py router and serves it sample rows, so what +the browser renders comes from the actual code path rather than fixture JSON. + + MODE=after (default) builder lookup succeeds — bedrock.builder_by_id + MODE=before builder lookup returns nothing, which is exactly what + public.users does for bedrock_user under RLS: no row, so + trim(coalesce(NULL,'') || ' ' || coalesce(NULL,'')) => '' + +MODE=before is the interesting one. On `main` that empty string renders as a +bare "from — (builder)" — the reported bug. On this branch the same empty +lookup renders "from Builder #428", because the fallback chain +(name → email → id) makes a blank label unreachable. To see the actual "—", +check out main first: + + git stash && git checkout main + +Usage (two terminals, from the repo root): + + cd financial_forecasting + python3 scripts/preview_intro_requests.py # backend on :8000 + + cd financial_forecasting/frontend-v2 + npm install && npm run dev # frontend on :4200 + +Then open http://localhost:4200/jobs and scroll to "Intro requests". + +The rows below are made up — they mirror the shape of real Sputnik asks +without putting learner data in the repo. +""" +import os +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +# db.py annotates `asyncpg.Pool | None` at module scope, which is a runtime +# TypeError before 3.10. The Dockerfile runs 3.11; fail with something legible +# rather than a traceback out of an unrelated import. +if sys.version_info < (3, 10): + sys.exit( + f"\n This needs Python 3.10+ (production runs 3.11); you are on " + f"{sys.version_info.major}.{sys.version_info.minor}.\n\n" + " brew install python@3.11\n" + " cd financial_forecasting\n" + " rm -rf .venv\n" + " python3.11 -m venv .venv\n" + " source .venv/bin/activate\n" + " pip install -r requirements.txt\n" + ) + +# Run from anywhere: put financial_forecasting/ on the path. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import uvicorn # noqa: E402 +from fastapi import FastAPI # noqa: E402 +from fastapi.responses import JSONResponse # noqa: E402 + +MODE = os.environ.get("MODE", "after") +NOW = datetime.now(timezone.utc) + +STAFF_ROW = { + "id": "239f08c7-4e45-4539-8c41-2302bb35de67", + "contact_id": 1001, "connector_staff_id": 4, + "requested_by_email": "jordan.reyes@example.org", + "specific_ask": "industry_advice", + "context": "Sample staff→staff ask. Would you be open to introducing one of " + "our builders for a 20-minute coffee chat?", + "status": "pending", "response_note": None, "responded_at": None, + "created_at": NOW - timedelta(days=4), + "contact_name": "Dana Whitfield", "contact_company": "Northwind", + "contact_title": "Engineering Manager", + "connector_name": "Sam Okafor", "connector_email": "sam.okafor@example.org", + "requested_by_name": "Jordan Reyes", +} + +BUILDER_ROW = { + "intro_request_id": 15, "contact_id": 1002, + "contact_name": "Priya Raman", "contact_company": "Lumen Labs", + "contact_title": "Director of Product Engineering", + "specific_ask": "industry_advice", + "request_context": "Sample builder ask. Their background bridging product " + "strategy and technical architecture is exactly the path " + "I am trying to grow into.", + "status": "pending", "staff_response_notes": None, "responded_at": None, + "created_at": NOW - timedelta(days=38), + "builder_id": 428, + # The whole bug in two fields. Under RLS the join matches nothing, and + # trim() over coalesce'd NULLs yields '' rather than NULL. + "builder_name": "Alex Mensah" if MODE == "after" else "", + "builder_email": "alex.mensah@example.org" if MODE == "after" else None, + "builder_cohort": "March 2026 L1+" if MODE == "after" else None, +} + + +class StubConn: + """Substring-dispatch stand-in for an asyncpg connection.""" + + async def fetchval(self, q, *a): + if "SELECT staff_user_id FROM bedrock.staff_user_id_map" in q: + return 4 + return None + + async def fetch(self, q, *a): + if "FROM public.intro_requests ir" in q: + return [BUILDER_ROW] + if "FROM bedrock.intro_request ir" in q: + return [STAFF_ROW] + return [] + + async def fetchrow(self, q, *a): + return None + + async def execute(self, q, *a): + return "OK" + + +app = FastAPI(title="intro-requests preview") + +from auth import require_auth # noqa: E402 +from db import get_db # noqa: E402 +from routes.jobs_intro import router as intro_router # noqa: E402 + +app.include_router(intro_router) +app.dependency_overrides[require_auth] = lambda: {"email": "sam.okafor@example.org"} +app.dependency_overrides[get_db] = lambda: StubConn() + + +@app.get("/auth/me") +async def me(): + return {"email": "sam.okafor@example.org", "name": "Sam Okafor", "sub": "sam", + "salesforce_connected": False, "google_connected": True, + "slack_configured": True} + + +@app.api_route("/{path:path}", + methods=["GET", "POST", "PATCH", "PUT", "DELETE"]) +async def catch_all(path: str): + """Every other zone on the Jobs page renders from an empty result.""" + return JSONResponse({"success": True, "data": []}) + + +if __name__ == "__main__": + print(f"\n intro-requests preview — MODE={MODE}") + print(" backend http://127.0.0.1:8000") + print(" now run: cd frontend-v2 && npm run dev → http://localhost:4200/jobs\n") + uvicorn.run(app, host="127.0.0.1", port=8000, log_level="warning") diff --git a/financial_forecasting/services/intro_notification_poller.py b/financial_forecasting/services/intro_notification_poller.py new file mode 100644 index 00000000..92176cd9 --- /dev/null +++ b/financial_forecasting/services/intro_notification_poller.py @@ -0,0 +1,240 @@ +"""Polls Sputnik's builder intro requests and fans them out through the +Bedrock notification path — so one bot carries both kinds of ask. + +Why a poller: `public.intro_requests` is written by Sputnik, a separate app +whose code we don't control and which is owned by `postgres` (the app role +can't add a trigger to it). Bedrock already reads the table for the Jobs +page, so watching it on an interval is the least-coupled way to notice new +rows. Modelled on services/sf_notification_poller.py — same watermark table, +same "insert + bump in one transaction" discipline. + +Watermark strategy: +- `bedrock.notification_watermark` holds one row per source. Each poll picks + up rows with created_at > watermark, then advances the watermark to the + newest created_at in the batch. +- On the very first run the row is seeded to *now*, so the pre-existing + backlog (oldest 2026-03) is skipped by construction rather than DMing the + whole team about months of old asks. +- The whole cycle (read → notify → bump) is one transaction holding + `FOR UPDATE` on the watermark row. Bedrock runs several Cloud Run instances + and each carries this loop, so without the lock two of them can read the + same watermark and DM the connector twice. The watermark only advances on + success, so events missed during an outage replay on the next good poll. + +Recipient: +- The connector staff member (`intro_requests.staff_user_id`), resolved to an + email via `bedrock.staff_user_id_map`. That's the person who has to act. +- The *builder* is deliberately not notified here: builders are not members + of the Slack workspace the Bedrock app is installed in, so a DM to them + resolves to no slack_user_id and is marked skipped. Sputnik owns + builder-facing comms for now. + +Builder identity comes from `bedrock.builder_by_id` (SECURITY DEFINER) — +`public.users` is RLS-scoped away from the app role, and joining it directly +is what made builder names vanish from the Jobs page in the first place. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from datetime import datetime, timezone +from typing import Dict, Optional + +from dependencies import _services +from services.notifications import TYPE_INTRO_REQUEST, enqueue_notification + +logger = logging.getLogger(__name__) + +POLL_INTERVAL_SEC = int(os.environ.get("INTRO_NOTIF_POLL_SEC", "300")) +SOURCE_BUILDER_INTRO = "sputnik_intro_request" + +# Keep in sync with routes/jobs_intro.ASK_LABELS. +ASK_LABELS = { + "hiring_intro": "Hiring intro", + "industry_advice": "Industry advice", + "job_referral": "Job referral", + "mock_interview": "Mock interview", + "informational_interview": "Informational interview", + "introductory_call": "Intro call", + "demo_feedback": "Demo feedback", + "other": "Other", +} + + +async def run_forever() -> None: + """Sleep-loop calling poll_once. Started from main.py's startup hook.""" + import random + # Stagger so concurrent backend startups don't poll in lockstep. + await asyncio.sleep(random.uniform(5, 30)) + while True: + try: + await asyncio.wait_for(poll_once(), timeout=120.0) + except asyncio.TimeoutError: + logger.error("intro_notification_poller: poll_once timed out after 120s — skipping cycle") + except Exception as e: # noqa: BLE001 — loop must survive any single cycle + logger.exception("intro_notification_poller crashed mid-cycle: %s", e) + await asyncio.sleep(POLL_INTERVAL_SEC) + + +async def poll_once() -> Dict[str, int]: + """Run one poll. Returns a small summary for logging/tests.""" + pool = _services.get("db_pool") + if not pool: + logger.debug("intro poll_once: no db_pool") + return {SOURCE_BUILDER_INTRO: 0} + + inserted = await _poll_builder_intros(pool) + if inserted: + logger.info("intro_notification_poller: builder_intro=%d", inserted) + return {SOURCE_BUILDER_INTRO: inserted} + + +async def _poll_builder_intros(pool) -> int: + """One cycle: read → notify → bump, all under a lock on the watermark row. + + The whole cycle is a single transaction that takes `FOR UPDATE` on this + source's watermark row. Bedrock runs multiple Cloud Run instances, each + with its own copy of this loop; without the lock two instances can read the + same watermark, fetch the same rows and DM the connector twice. With it the + second instance blocks, then reads the already-advanced watermark and finds + nothing to do. + """ + inserted = 0 + async with pool.acquire() as conn: + async with conn.transaction(): + watermark, seeded = await _lock_watermark(conn, SOURCE_BUILDER_INTRO) + if seeded: + # First ever run — the watermark was just set to now, so there + # is nothing newer. Skips the historical backlog on purpose. + logger.info( + "intro_notification_poller: seeded watermark at %s, skipping backlog", + watermark.isoformat(), + ) + return 0 + + rows = await conn.fetch( + """ + -- intro_requests.created_at is `timestamp` (naive) while + -- notification_watermark.last_seen is `timestamptz`, so asyncpg + -- hands us a naive datetime on one side and an aware one on the + -- other: the bare comparison raises rather than mis-sorting. + -- The DB runs in UTC and these values are UTC, so read them as + -- such and keep Python uniformly tz-aware. + SELECT ir.intro_request_id, + ir.created_at AT TIME ZONE 'UTC' AS created_at, + ir.specific_ask, + ir.request_context, ir.contact_name, ir.contact_company, + ir.builder_id, ir.staff_user_id, + b.full_name AS builder_name, b.email AS builder_email, + b.cohort AS builder_cohort, + m.email AS staff_email + FROM public.intro_requests ir + LEFT JOIN LATERAL bedrock.builder_by_id(ir.builder_id) b ON true + LEFT JOIN bedrock.staff_user_id_map m ON m.staff_user_id = ir.staff_user_id + WHERE ir.created_at AT TIME ZONE 'UTC' > $1 + AND ir.status = 'pending' + ORDER BY ir.created_at ASC + LIMIT 200 + """, + watermark, + ) + if not rows: + return 0 + + newest = watermark + for r in rows: + # Advance across every row we considered, including skipped + # ones — otherwise an unmappable staff id pins the watermark + # and we re-scan it forever. + if r["created_at"] and r["created_at"] > newest: + newest = r["created_at"] + + if not r["staff_email"]: + logger.info( + "intro %s: staff_user_id %s not in staff_user_id_map, skipping", + r["intro_request_id"], r["staff_user_id"], + ) + continue + + notif_id = await enqueue_notification( + conn, + recipient_email=r["staff_email"], + type=TYPE_INTRO_REQUEST, + actor_email=r["builder_email"], + payload=_payload(r), + ) + if notif_id: + inserted += 1 + + await _write_watermark(conn, SOURCE_BUILDER_INTRO, newest) + + return inserted + + +def _builder_display(r) -> str: + """name → email → id. Never empty; a blank name is what produced the + "from —" cards this flow used to show.""" + name = (r["builder_name"] or "").strip() + if name: + return name + email = (r["builder_email"] or "").strip() + if email: + return email + return f"Builder #{r['builder_id']}" + + +def _payload(r) -> dict: + who = _builder_display(r) + return { + "title": "Intro request", + "requester_kind": "builder", + "actor_display_name": who, + "builder_id": r["builder_id"], + "builder_cohort": r["builder_cohort"], + "subtitle": r["contact_name"] or "a contact", + "contact_name": r["contact_name"], + "contact_company": r["contact_company"], + "ask": ASK_LABELS.get(r["specific_ask"] or "", r["specific_ask"]), + "context": (r["request_context"] or "")[:280] or None, + "target_url": "/jobs", + "sputnik_intro_request_id": r["intro_request_id"], + } + + +async def _lock_watermark(conn, source: str) -> "tuple[datetime, bool]": + """Take FOR UPDATE on this source's watermark row. Returns + (watermark, was_just_seeded). Must be called inside a transaction. + + Seeds to *now* rather than an hour back: unlike the SF pollers there is a + months-old backlog sitting in this table, and replaying it would DM the + team about every historical ask. + """ + row = await conn.fetchrow( + "SELECT last_seen FROM bedrock.notification_watermark WHERE source = $1 FOR UPDATE", + source, + ) + if row: + return row["last_seen"], False + + now = datetime.now(timezone.utc).replace(microsecond=0) + await conn.execute( + "INSERT INTO bedrock.notification_watermark (source, last_seen) " + "VALUES ($1, $2) ON CONFLICT (source) DO NOTHING", + source, now, + ) + # Re-read under the lock: a concurrent instance may have won the insert. + existing = await conn.fetchval( + "SELECT last_seen FROM bedrock.notification_watermark WHERE source = $1 FOR UPDATE", + source, + ) + return (existing or now), True + + +async def _write_watermark(conn, source: str, ts: datetime) -> None: + await conn.execute( + "UPDATE bedrock.notification_watermark SET last_seen = $2, updated_at = now() " + "WHERE source = $1", + source, ts, + ) diff --git a/financial_forecasting/services/notifications.py b/financial_forecasting/services/notifications.py index 9df5a3c2..e30d3fb1 100644 --- a/financial_forecasting/services/notifications.py +++ b/financial_forecasting/services/notifications.py @@ -310,7 +310,15 @@ def _format_slack_message( if opp_name: section_lines.append(f"*{opp_name}*") elif type == TYPE_INTRO_REQUEST: - headline = f":wave: *{actor}* asked you for an intro" + # Builder asks originate in Sputnik and are surfaced by + # intro_notification_poller; staff→staff asks are created in Bedrock. + # Say which, so one bot can carry both without ambiguity. + if payload.get("requester_kind") == "builder": + headline = f":raising_hand: *{actor}* (builder) asked you for an intro" + if payload.get("builder_cohort"): + section_lines.append(f"*Cohort:* {payload['builder_cohort']}") + else: + headline = f":wave: *{actor}* asked you for an intro" if payload.get("contact_name"): who = payload["contact_name"] if payload.get("contact_company"): diff --git a/financial_forecasting/tests/test_jobs_intro.py b/financial_forecasting/tests/test_jobs_intro.py new file mode 100644 index 00000000..bffd3fe2 --- /dev/null +++ b/financial_forecasting/tests/test_jobs_intro.py @@ -0,0 +1,268 @@ +"""Evals for the intro-request flow. + + GET /api/jobs/intro-requests — staff asks (bedrock) + builder asks (Sputnik) + services/intro_notification_poller — fans Sputnik asks into the Bedrock bot + +The regression these lock down: builder asks rendered as "from —" because the +route joined public.users directly. That table has RLS enabled and the app role +has no policy on it, so the LEFT JOIN matched nothing *silently* and +trim(coalesce(...)) produced '' rather than NULL. Builder identity must come +from bedrock.builder_by_id (SECURITY DEFINER). +""" +from datetime import datetime, timezone + +import pytest + +from tests.jobs_fakes import FakeConn, make_jobs_client + +STAFF_ID = 4 +CREATED = datetime(2026, 6, 15, 23, 37, tzinfo=timezone.utc) + + +@pytest.fixture(autouse=True) +def _clear(): + from main import app + yield + app.dependency_overrides.clear() + + +def _builder_row(**ov): + row = { + "intro_request_id": 15, "contact_id": 8577, "contact_name": "Paul de Lucena", + "contact_company": "Unlocked Labs", "contact_title": "CTO", + "specific_ask": "industry_advice", "request_context": "his background…", + "status": "pending", "staff_response_notes": None, "responded_at": None, + "created_at": CREATED, "builder_id": 428, + "builder_name": "Adedoyin Ahoton", "builder_email": "adedoyin.ahoton@pursuit.org", + "builder_cohort": "March 2025", + # Only selected by the poller, ignored by the route. + "staff_user_id": STAFF_ID, "staff_email": "joanna@pursuit.org", + } + row.update(ov) + return row + + +def _conn(builder_rows): + return FakeConn( + lists={ + "FROM bedrock.intro_request ir": [], + "FROM public.intro_requests ir": builder_rows, + }, + vals={"SELECT staff_user_id FROM bedrock.staff_user_id_map": STAFF_ID}, + ) + + +# ── builder identity ──────────────────────────────────────────────────────── + +def test_builder_ask_surfaces_the_builder_name(): + """The actual reported bug: this used to come back as ''.""" + conn = _conn([_builder_row()]) + r = make_jobs_client(conn).get("/api/jobs/intro-requests?box=inbox") + assert r.status_code == 200, r.text + row = next(d for d in r.json()["data"] if d["source"] == "builder") + assert row["requested_by_name"] == "Adedoyin Ahoton" + assert row["builder_id"] == 428 + assert row["builder_cohort"] == "March 2025" + + +def test_builder_lookup_goes_through_the_security_definer_function(): + """Guard the root cause, not just the symptom — a direct public.users join + silently returns nothing under RLS.""" + conn = _conn([_builder_row()]) + make_jobs_client(conn).get("/api/jobs/intro-requests?box=inbox") + sputnik_sql = next(q for q in conn.queries() if "FROM public.intro_requests ir" in q) + assert "bedrock.builder_by_id" in sputnik_sql + assert "JOIN public.users" not in sputnik_sql + + +@pytest.mark.parametrize("overrides,expected", [ + ({}, "Adedoyin Ahoton"), + # builder_by_id concatenates first||' '||last, so a NULL half yields NULL. + ({"builder_name": None}, "adedoyin.ahoton@pursuit.org"), + ({"builder_name": " "}, "adedoyin.ahoton@pursuit.org"), + ({"builder_name": None, "builder_email": None}, "Builder #428"), +]) +def test_builder_display_never_falls_through_to_a_dash(overrides, expected): + conn = _conn([_builder_row(**overrides)]) + r = make_jobs_client(conn).get("/api/jobs/intro-requests?box=inbox") + row = next(d for d in r.json()["data"] if d["source"] == "builder") + assert row["requested_by_name"] == expected + # The frontend renders `requested_by_name || requested_by || "—"`; both + # being blank is what produced the em dash. + assert row["requested_by_name"].strip() + + +# ── vocabulary / status ───────────────────────────────────────────────────── + +def test_every_sputnik_ask_value_has_a_label(): + """The CHECK constraint on public.intro_requests allows these six; an + unlabelled value leaked the raw enum into the Slack DM.""" + from routes.jobs_intro import ASK_LABELS + for value in ("job_referral", "informational_interview", "demo_feedback", + "industry_advice", "introductory_call", "other"): + assert value in ASK_LABELS, value + + +def test_completed_stays_distinct_from_accepted(): + """Mapping completed→approved collapsed "I'll do it" into "I did it".""" + from routes.jobs_intro import BUILDER_STATUS_MAP + assert BUILDER_STATUS_MAP["accepted"] == "approved" + assert BUILDER_STATUS_MAP["completed"] == "completed" + + +# ── poller ────────────────────────────────────────────────────────────────── + +class FakeAcquire: + def __init__(self, conn): self._conn = conn + async def __aenter__(self): return self._conn + async def __aexit__(self, *a): return False + + +class FakePool: + def __init__(self, conn): self._conn = conn + def acquire(self): return FakeAcquire(self._conn) + + +def _poller_conn(*, watermark, rows): + return FakeConn( + rows={"SELECT last_seen FROM bedrock.notification_watermark": ( + {"last_seen": watermark} if watermark else None)}, + lists={"FROM public.intro_requests ir": rows}, + vals={"SELECT last_seen FROM bedrock.notification_watermark": watermark}, + ) + + +@pytest.fixture +def _captured(monkeypatch): + """Swap enqueue_notification so no Slack dispatch task is spawned.""" + import services.intro_notification_poller as poller + sent = [] + + async def _fake(conn, *, recipient_email, type, payload, actor_email=None): + sent.append({"to": recipient_email, "type": type, "payload": payload, + "actor": actor_email}) + return "notif-1" + + monkeypatch.setattr(poller, "enqueue_notification", _fake) + return sent + + +def test_first_run_seeds_the_watermark_and_skips_the_backlog(monkeypatch, _captured): + """There are months of pending asks in the table; replaying them would DM + the whole team about every historical request.""" + import asyncio + from dependencies import _services + import services.intro_notification_poller as poller + + conn = _poller_conn(watermark=None, rows=[_builder_row(staff_email="joanna@pursuit.org")]) + monkeypatch.setitem(_services, "db_pool", FakePool(conn)) + + result = asyncio.run(poller.poll_once()) + assert result[poller.SOURCE_BUILDER_INTRO] == 0 + assert _captured == [] + assert conn.ran("INSERT INTO bedrock.notification_watermark") + + +def test_new_ask_notifies_the_connector_staff_once(monkeypatch, _captured): + import asyncio + from dependencies import _services + import services.intro_notification_poller as poller + + row = _builder_row(staff_user_id=STAFF_ID, staff_email="joanna@pursuit.org") + conn = _poller_conn(watermark=datetime(2026, 6, 1, tzinfo=timezone.utc), rows=[row]) + monkeypatch.setitem(_services, "db_pool", FakePool(conn)) + + result = asyncio.run(poller.poll_once()) + assert result[poller.SOURCE_BUILDER_INTRO] == 1 + assert len(_captured) == 1 + sent = _captured[0] + assert sent["to"] == "joanna@pursuit.org" + assert sent["payload"]["requester_kind"] == "builder" + assert sent["payload"]["actor_display_name"] == "Adedoyin Ahoton" + assert sent["payload"]["ask"] == "Industry advice" # not "industry_advice" + # Watermark advanced to the newest row so the next poll won't resend. + bumps = conn.executed("UPDATE bedrock.notification_watermark") + assert len(bumps) == 1 + assert bumps[0][2][1] == CREATED + + +def test_unmappable_staff_id_is_skipped_without_pinning_the_watermark(monkeypatch, _captured): + """A staff_user_id missing from staff_user_id_map must not stall the + watermark, or every later poll re-scans it forever.""" + import asyncio + from dependencies import _services + import services.intro_notification_poller as poller + + conn = _poller_conn(watermark=datetime(2026, 6, 1, tzinfo=timezone.utc), + rows=[_builder_row(staff_email=None)]) + monkeypatch.setitem(_services, "db_pool", FakePool(conn)) + + result = asyncio.run(poller.poll_once()) + assert result[poller.SOURCE_BUILDER_INTRO] == 0 + assert _captured == [] + bumps = conn.executed("UPDATE bedrock.notification_watermark") + assert bumps[0][2][1] == CREATED + + +def test_watermark_is_locked_so_parallel_instances_cannot_double_notify(monkeypatch, _captured): + """Every Cloud Run instance runs this loop; an unlocked read lets two of + them pick up the same rows and DM the connector twice.""" + import asyncio + from dependencies import _services + import services.intro_notification_poller as poller + + conn = _poller_conn(watermark=datetime(2026, 6, 1, tzinfo=timezone.utc), + rows=[_builder_row()]) + monkeypatch.setitem(_services, "db_pool", FakePool(conn)) + asyncio.run(poller.poll_once()) + + reads = [q for q in conn.queries() + if q.strip().startswith("SELECT last_seen FROM bedrock.notification_watermark")] + assert reads and all("FOR UPDATE" in q for q in reads), reads + + +def test_sputnik_timestamps_are_read_as_utc(monkeypatch, _captured): + """public.intro_requests.created_at is `timestamp` (naive) while + notification_watermark.last_seen is `timestamptz`. Without the cast asyncpg + hands back a naive datetime on one side and an aware one on the other, and + both the SQL comparison and `created_at > newest` raise TypeError — the + poller would then fail every cycle, silently, inside run_forever's guard. + FakeConn can't enforce asyncpg's typing, so assert on the SQL itself. + """ + import asyncio + from dependencies import _services + import services.intro_notification_poller as poller + + conn = _poller_conn(watermark=datetime(2026, 6, 1, tzinfo=timezone.utc), + rows=[_builder_row()]) + monkeypatch.setitem(_services, "db_pool", FakePool(conn)) + asyncio.run(poller.poll_once()) + + sql = next(q for q in conn.queries() if "FROM public.intro_requests ir" in q) + assert "ir.created_at AT TIME ZONE 'UTC' AS created_at" in sql + assert "WHERE ir.created_at AT TIME ZONE 'UTC' > $1" in sql + + +def test_route_normalises_sputnik_timestamps_to_utc(): + """Same mismatch on the read path: builder rows would serialise without a + UTC offset while staff rows carry one, so the browser reads them as local.""" + conn = _conn([_builder_row()]) + make_jobs_client(conn).get("/api/jobs/intro-requests?box=inbox") + sql = next(q for q in conn.queries() if "FROM public.intro_requests ir" in q) + assert "ir.created_at AT TIME ZONE 'UTC' AS created_at" in sql + assert "ir.responded_at AT TIME ZONE 'UTC' AS responded_at" in sql + + +def test_poller_reads_builder_identity_via_security_definer(monkeypatch, _captured): + import asyncio + from dependencies import _services + import services.intro_notification_poller as poller + + conn = _poller_conn(watermark=datetime(2026, 6, 1, tzinfo=timezone.utc), + rows=[_builder_row(staff_email="joanna@pursuit.org")]) + monkeypatch.setitem(_services, "db_pool", FakePool(conn)) + asyncio.run(poller.poll_once()) + + sql = next(q for q in conn.queries() if "FROM public.intro_requests ir" in q) + assert "bedrock.builder_by_id" in sql + assert "JOIN public.users" not in sql diff --git a/tasks/builder-intro-requests-plan.md b/tasks/builder-intro-requests-plan.md new file mode 100644 index 00000000..99c429e6 --- /dev/null +++ b/tasks/builder-intro-requests-plan.md @@ -0,0 +1,308 @@ +# Builder intro requests reach Bedrock "without associated Builders" + +Source: Slack thread 2026-07-30 (Joanna, Avni, Jac) — screenshot of an intro +request card reading `from — (builder) · 1mo`. + +Status: **built.** Scope as confirmed: (1) put the builder in the messages and +route builder asks through the one Bedrock bot, (2) colour-code builder vs +jobs-team. The richer card work (demo URL, prep notes, readiness checks) is +specced below but deliberately **not** built — see "Not built". + +--- + +## Root cause (confirmed against prod) + +`public.users` has **row-level security enabled**. It is the only table in this +flow that does: + +| table | RLS | +|---|---| +| `public.users` | **true** | +| `public.intro_requests` | false | +| `public.contacts` | false | +| `bedrock.intro_request` | false | +| `bedrock.staff_user_id_map` | false | + +The only two policies on `public.users` are scoped to roles `uft_readonly` and +`ceo_dev`. The app role, **`bedrock_user`**, holds `SELECT` but has **no +policy** — so RLS default-denies and it reads **zero rows**. + +`financial_forecasting/routes/jobs_intro.py:142` joins that table directly: + +```sql +trim(coalesce(u.first_name,'') || ' ' || coalesce(u.last_name,'')) AS builder_name, +u.email AS builder_email +FROM public.intro_requests ir +LEFT JOIN public.users u ON u.user_id = ir.builder_id -- ← returns nothing under RLS +``` + +Because it is a **LEFT** join it fails **silently** instead of erroring, and +`trim(coalesce(NULL,'') || ' ' || coalesce(NULL,''))` yields an **empty string, +not NULL**. So the payload degrades to `""`, and `JobsHome.tsx:949` renders +`requested_by_name || requested_by || "—"` → **`—`**. + +The contact name still renders because `public.contacts` has no RLS (and +`contact_name` is denormalised onto `intro_requests` anyway); connector names +render because they come from `bedrock.staff_user_id_map`. Only the builder +identity is lost — exactly the reported symptom. + +**The data is fine.** Every pending row resolves correctly when queried as a role +that bypasses RLS (e.g. `intro_request_id` 15 → builder 428 → *Adedoyin Ahoton*, +`adedoyin.ahoton@pursuit.org`). Nothing needs backfilling. + +### The fix already exists in this repo + +`bedrock.builder_by_id(uid int)` is `SECURITY DEFINER STABLE`, returns +`(user_id, full_name, email, cohort)`, and is already granted `EXECUTE` to +`bedrock_user`. `routes/jobs.py:1395` already uses the correct pattern: + +```sql +LEFT JOIN LATERAL bedrock.builder_by_id(er.user_id) b ON true +``` + +`jobs_intro.py` is the one place that skipped it. **No new migration required** +for this part. + +### Same latent trap elsewhere (defended, but worth noting) + +`jobs.py:713` and `jobs.py:743` also `LEFT JOIN public.users`, but wrap it in +`COALESCE(NULLIF(trim(...),''), 'Builder #'||er.user_id)`, so they degrade to +`Builder #428` rather than a bare dash. `sputnik.py:109` selects from +`public.users` inside a `try/except` and falls back to `User #N`. Both are +cosmetically wrong (never showing a real name) but not broken. Folding them onto +`builder_by_id` is cheap and in scope for step 5. + +--- + +## What Sputnik captures that Bedrock never selects + +This is Avni's *"this field is captured in Sputnik so just need to make sure it +is getting surfaced."* Every one of the 24 rows has all three populated (they are +`NOT NULL` columns), and the Bedrock query selects **none** of them: + +| column | example | +|---|---| +| `builder_preparation` | 88–1007 chars of the builder's prep notes | +| `demo_url` | `https://mylabexpert.lovable.app/`, Loom links, GitHub repos | +| `readiness_checks` | `{demo_working, researched_company, available_this_week, can_articulate_value}` | + +So a staff member is asked to make an intro with no idea who the builder is, what +they built, or whether they are ready. That — more than the missing name — is the +substance of "without associated Builders". + +--- + +## Vocabulary drift between the two tables + +`public.intro_requests.specific_ask` CHECK allows: +`job_referral, informational_interview, demo_feedback, industry_advice, +introductory_call, other` + +`ASK_LABELS` in `jobs_intro.py:33` covers only `hiring_intro, industry_advice, +job_referral`; the frontend map adds `mock_interview`. So **four of the six real +Sputnik values have no label** — live rows use `introductory_call` (3), +`informational_interview` (5) and `other` (1). The frontend falls back to +`a.replace(/_/g," ")` → lowercase *"introductory call"*; the Slack payload falls +back to the raw `introductory_call` with the underscore intact. + +**Status mapping bug.** `BUILDER_STATUS_MAP` (`jobs_intro.py:38`) maps *both* +`accepted` **and** `completed` → `approved`, collapsing "I'll do it" into "I did +it". `public.intro_requests`' CHECK constraint allows `completed` natively, so +this is a self-inflicted loss. The UI compounds it: `JobsHome.tsx:968` gates +"Mark intro made" behind `r.source === "staff"`, so a builder ask can never be +completed from Bedrock at all. + +--- + +## Notifications: the two Slack bots + +`enqueue_notification` is called **only** from Bedrock's `POST +/api/jobs/intro-requests` (staff→staff). Rows created in Sputnik never produce a +Bedrock bell item or Slack DM — they only hit Sputnik's own "builder intro +request" channel. That is Avni's *"are these also going to the Bedrock slackbot +(vs. the builder intro request slack channel)"*, and Jac's *"yes we should merge +the two."* + +Infrastructure for this already exists and is the established pattern: + +- `bedrock.notification_watermark (source PK, last_seen)` — already used by + `sf_task` and `sf_opp_owner_history` +- `services/sf_notification_poller.py` — watermark poller; insert + watermark + bump in one transaction, errors swallowed so the watermark only advances on + success (missed events replay on the next good poll) +- wired in-process at startup via `main.py:256` + +Adding a `sputnik_intro_request` source is a direct extension. The existing +`intro_request` notification type can be reused with a `requester_kind` field in +the payload, so **no CHECK-constraint migration is needed**. + +--- + +## Built + +**No migrations.** `bedrock.builder_by_id` already exists and is already granted +to `bedrock_user`; the poller seeds its own watermark row on first run. + +### 1. Builder identity — `routes/jobs_intro.py` +- [x] `LEFT JOIN public.users` → `LEFT JOIN LATERAL bedrock.builder_by_id(...)` +- [x] `builder_id` + `builder_cohort` returned so the card can attribute the ask +- [x] `_builder_display()` fallback chain — `full_name → email → 'Builder #'`, + never empty, so a bare "—" is unreachable +- [x] `requested_by_name` added to `_staff_row()` (resolved via a second + `staff_user_id_map` join on the requester's email) — staff asks showed a + raw email because the key was simply absent + +### 2. Colour-code builder vs jobs-team — `JobsHome.tsx` +- [x] Dedicated source tag: `sky` "Builder" / `accent` "Jobs team", plus a + matching 2px left border so the split is scannable +- [x] Ask-type tag is now neutral — it was carrying the source signal in its + `variant`, which is why the distinction was easy to miss +- [x] The tag text itself reads "Builder"/"Jobs team", so the signal survives + without colour (replaces the old `(builder)` suffix) +- [x] Builder cohort shown inline when present + +### 3. One Bedrock bot — `services/intro_notification_poller.py` (new) +- [x] Watermark source `sputnik_intro_request`; new `public.intro_requests` rows + enqueue an `intro_request` notification to the **connector staff member** → + Bedrock bell + Slack DM, same path as staff→staff asks +- [x] Reuses the existing `intro_request` type with `requester_kind: "builder"`, + so no CHECK-constraint migration +- [x] `_format_slack_message` labels builder asks and adds the cohort line +- [x] Wired into `main.py` startup **outside** the Salesforce guard — this poller + is Postgres-only and must not depend on SF being connected +- [x] Backlog skipped by construction: the watermark seeds to *now* on first run, + so the 17 pending asks are never retro-DMed +- [x] Whole cycle runs in one transaction holding `FOR UPDATE` on the watermark + row. Bedrock runs multiple Cloud Run instances each carrying this loop; + the existing SF poller reads its watermark unlocked and can double-notify, + and that race was not worth copying into new code +- [x] A row whose `staff_user_id` is missing from `staff_user_id_map` is skipped + but still advances the watermark — otherwise it pins the mark and every + later poll re-scans it forever + +### 4. Vocabulary + status correctness (needed for #3 to read right) +- [x] Shared 8-value ask-label map, backend and frontend in sync — the Slack DM + was rendering the raw `introductory_call` +- [x] `BUILDER_STATUS_MAP`: `completed → completed`, so accept and done stop + collapsing into `approved` +- [x] Dropped the `source === "staff"` gate on "Mark intro made", so builder + asks can actually be closed out + +### 5. Tests — `tests/test_jobs_intro.py`, 13 cases +- [x] The reported bug: `requested_by_name` resolves to the real builder name +- [x] Root-cause guard: the Sputnik query must use `bedrock.builder_by_id` and + must not join `public.users` (both route and poller) +- [x] Parametrised fallback chain, including that the result is never blank +- [x] Every Sputnik `specific_ask` value has a label; `completed` stays distinct +- [x] Poller: first run seeds and skips the backlog; a new ask notifies exactly + once and bumps the watermark once; unmappable staff id doesn't pin it; + watermark reads take `FOR UPDATE` + +**Verification:** full suite `25 failed, 917 passed, 22 skipped`. Baseline on a +clean tree is `25 failed, 904 passed` — the same 25 pre-existing environment +failures (503s where SF/DB aren't available locally), plus exactly the 13 new +tests. `tsc --noEmit` clean. `npm run lint` is not runnable — the repo has no +`eslint.config.js`, so it fails on `main` too. + +## Not built (deliberately out of the confirmed scope) + +- **The richer builder card** — `builder_preparation`, `demo_url`, + `readiness_checks` are still not surfaced anywhere in the UI. This is the + largest remaining gap: staff still can't see what the builder built. Specced + above under "What Sputnik captures". +- **Notifying builders back** when their ask is answered — they aren't in this + Slack workspace, so it cannot work today. +- **Folding `jobs.py:713/743` and `sputnik.py:109` onto `builder_by_id`** — they + degrade to `Builder #N` / `User #N` rather than a dash, so cosmetic not broken. +- **A repo-wide guard test** banning direct `public.users` joins in `routes/`. + The two new guard tests only cover this flow. +- **The SF poller's unlocked watermark read** — same double-notify race, left + as-is to keep this change scoped. + +--- + +## What "Bedrock DM" means (it is just a Slack DM) + +There is no Bedrock-specific message surface. `enqueue_notification()` produces +two things per event: + +1. a row in `bedrock.notification` → the bell in the Bedrock web app + (`NotificationBell.tsx`) +2. a **real Slack DM from the Bedrock Slack app** — `_dispatch_slack()` → + `_resolve_slack_id()` (`users.lookupByEmail`, cached indefinitely in + `bedrock.slack_user_cache`) → `chat_postMessage(channel=)` + +This is live in prod today for staff→staff asks. + +### Builders are not reachable from this Slack workspace + +Checked directly against the workspace the Bedrock bot lives in. Four builders, +searched by email *and* by display name: + +| looked up | result | +|---|---| +| `adedoyin.ahoton@pursuit.org` | no match | +| `francis.rutledge@pursuit.org` | no match | +| `michelle.brooks@pursuit.org` | no match | +| `Adedoyin Ahoton`, `Jimmy Ong` (by name) | no match | +| `avni@pursuit.org` (control) | **U0AKQFH36CW** — resolves | + +The control proves lookup works, so builders genuinely are not in this +workspace. `bedrock.slack_user_cache` corroborates: 17 cached users, **0 with +`role='builder'`**. + +Consequence: a DM aimed at a builder would silently no-op — `_resolve_slack_id` +returns `None` and the notification is marked `slack_status='skipped'`, +`note='no_slack_id'`. Nothing breaks, but the builder never hears anything. + +**This does not block the fix.** The person who must act on an intro request is +the *connector staff member*, and they are reachable today. Notifying builders +back would need either Slack Connect / a cross-workspace app, or leaving +builder-facing comms to Sputnik. Deferred, not required. + +--- + +## Sputnik is a separate app — what we can and cannot touch + +Sputnik's code is **not in this repo**. All that exists here is read-only access +to its tables on the shared segundo-db (`public.intro_requests`, +`public.outreach`) through `routes/sputnik.py`. The builder-facing form, and +whatever posts into the "builder intro request" Slack channel Avni mentioned, +live in Sputnik's codebase where I cannot see or change them. + +So the earlier "does Sputnik get switched off" question was mis-scoped as a +blocker. The concrete risk is narrow: if Bedrock starts DMing the connector staff +and that Sputnik channel keeps posting the same ask, staff hear it twice. The +Bedrock side is purely additive and safe either way — this is a coordination note +for whoever owns Sputnik, not a decision needed before building. + +--- + +## Resolved + +- **Backlog — do not notify.** Seed the watermark at deploy time. The 17 pending + asks (oldest 2026-03) stay visible on the Jobs page and get worked from there. + No retroactive DMs. +- **`demo_feedback` / `other` are not columns.** They are two of six allowed + *values* of the `specific_ask` column (`varchar(100)`) on + `public.intro_requests`, enforced by its CHECK constraint. Usage across all 24 + rows: + + | `specific_ask` | rows | last used | + |---|---|---| + | `industry_advice` | 9 | 2026-06-24 | + | `informational_interview` | 7 | 2026-06-15 | + | `job_referral` | 4 | 2026-07-16 | + | `introductory_call` | 3 | 2026-07-27 | + | `other` | 1 | 2026-07-13 | + | `demo_feedback` | **0 — never used** | — | + + `demo_feedback` is legal but has never been selected. Labelling all six anyway + is trivial and prevents the raw-underscore fallback. + +- **The two tables have different vocabularies.** Bedrock's own + `bedrock.intro_request` uses `hiring_intro` / `industry_advice` / + `job_referral` (plus `mock_interview` in the frontend map). `hiring_intro` and + `mock_interview` are **not** valid Sputnik values. The shared label map must + cover the union of eight, with `industry_advice` and `job_referral` common to + both.