From 28edc4ff53e29a3f434289da7e73007721f797eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 20:32:34 +0000 Subject: [PATCH 1/9] docs(jobs): plan for builder intro requests losing builder identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-caused the "from — (builder)" cards Joanna flagged: public.users has RLS enabled and bedrock_user has no matching policy, so the naive LEFT JOIN in jobs_intro.py reads zero rows and fails silently. trim() over coalesce'd NULLs yields an empty string rather than NULL, which the card renders as an em dash. The repo already has bedrock.builder_by_id() (SECURITY DEFINER, granted to bedrock_user) and jobs.py:1395 already uses it via LEFT JOIN LATERAL — jobs_intro.py is the one call site that skipped the pattern. Plan also covers the rich Sputnik fields never selected (builder_preparation, demo_url, readiness_checks), builder-vs-jobs-team colour coding, merging the Sputnik notification path onto the Bedrock poller, and the ask-label/status vocabulary drift between the two tables. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XthvkMYPKTHgm86akR5zBB --- tasks/builder-intro-requests-plan.md | 203 +++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 tasks/builder-intro-requests-plan.md diff --git a/tasks/builder-intro-requests-plan.md b/tasks/builder-intro-requests-plan.md new file mode 100644 index 00000000..c865f9e2 --- /dev/null +++ b/tasks/builder-intro-requests-plan.md @@ -0,0 +1,203 @@ +# 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: **plan for review — nothing built yet.** + +--- + +## 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**. + +--- + +## Plan + +### 1. Fix the builder identity (P0 — the actual reported bug) +- [ ] `jobs_intro.py`: replace the `LEFT JOIN public.users` with + `LEFT JOIN LATERAL bedrock.builder_by_id(ir.builder_id) b ON true` +- [ ] Return `builder_id` in the payload so the card can link to the builder +- [ ] Fallback chain that can never render a bare dash: + `full_name → email → 'Builder #'` +- [ ] Add `requested_by_name` to `_staff_row()` too — staff asks currently show a + raw email because the key is simply absent + +### 2. Surface what Sputnik captured +- [ ] Select `builder_preparation`, `demo_url`, `readiness_checks`, `cohort` +- [ ] Extend the `IntroRequest` TS interface +- [ ] Card: builder name links to their profile; `demo_url` as a labelled link; + prep notes collapsed behind a disclosure; readiness as a compact check row + +### 3. Colour-code builder vs jobs-team (Avni's ask) +- [ ] Dedicated source `Tag` — `sky` = "Builder", `accent` = "Jobs team" — + distinct from the ask-type tag, which currently smuggles the source signal + into its `variant` (semantically wrong and easy to miss) +- [ ] Left border accent on the card so the split is visible while scanning +- [ ] Keep the `(builder)` text as the non-colour fallback (accessibility) + +### 4. Merge the notification paths (Avni + Jac) +- [ ] `services/intro_notification_poller.py` — watermark source + `sputnik_intro_request`, enqueue `intro_request` to the connector staff for + new `public.intro_requests` rows +- [ ] Payload carries `requester_kind: "builder"`, builder name, demo URL +- [ ] `_format_slack_message`: distinguish builder vs staff asks in the DM +- [ ] Wire into `main.py` startup alongside the SF poller +- [ ] Seed the watermark at deploy time so the first poll doesn't DM the team + about all 17 pending backlog rows + +### 5. Vocabulary + status correctness +- [ ] Single shared ask-label map covering all six Sputnik values, backend and + frontend in sync +- [ ] `BUILDER_STATUS_MAP`: `completed → completed` (allowed by the CHECK + constraint), so accept and done stop collapsing +- [ ] Drop the `source === "staff"` gate on "Mark intro made" +- [ ] Fold `jobs.py:713/743` and `sputnik.py:109` onto `builder_by_id` + +### 6. Tests +- [ ] Regression test asserting the builder name resolves — the current bug would + have been caught by one assertion that `requested_by_name` is non-empty for + a builder-sourced row +- [ ] A guard test that fails if any query in `routes/` joins `public.users` + directly, so this trap cannot be reintroduced +- [ ] Poller test: watermark advances once, no duplicate notification + +--- + +## Open questions for Avni / Jac + +1. **Should builders get Bedrock Slack DMs when their ask is answered?** + Responding to a builder ask currently notifies nobody. If Bedrock becomes the + single bot, the builder should hear back — but that means DMing builders from + the Bedrock app, and Sputnik may already do this, in which case we'd + double-notify. Needs confirming before wiring. +2. **Does the Sputnik-side channel get turned off** once Bedrock notifies, or do + both run during a transition? +3. **Backlog:** 17 pending builder asks, oldest from 2026-03. Notify on the first + poll, or seed the watermark to now and let the backlog be worked from the page + only? (Plan currently assumes the latter.) +4. **`demo_feedback` and `other`** — still live ask types in the Sputnik UI, or + legacy? Affects whether they need first-class labels. From 05210ca6c3507d7e535d1fffa916273f12ce9d63 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 20:41:08 +0000 Subject: [PATCH 2/9] docs(jobs): resolve open questions on the builder intro request plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Clarify that a "Bedrock DM" is just a Slack DM from the Bedrock app; the bell row and the DM are the two surfaces enqueue_notification produces. - Builders are NOT in the Bedrock bot's Slack workspace: four builders miss by both email and display name while avni@pursuit.org resolves, and slack_user_cache holds 0 builders. DMs to builders would silently mark skipped/no_slack_id. Notifying builders back is deferred, not required — the connector staff who must act is reachable today. - Scope Sputnik correctly: separate app, not in this repo; we only read its tables. The duplicate-notification risk is a coordination note, not a blocker. - Backlog decided: seed the watermark at deploy, no retroactive DMs. - demo_feedback/other are specific_ask enum values, not columns. demo_feedback has never been used in 24 rows. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XthvkMYPKTHgm86akR5zBB --- tasks/builder-intro-requests-plan.md | 99 ++++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 14 deletions(-) diff --git a/tasks/builder-intro-requests-plan.md b/tasks/builder-intro-requests-plan.md index c865f9e2..bd9a6963 100644 --- a/tasks/builder-intro-requests-plan.md +++ b/tasks/builder-intro-requests-plan.md @@ -187,17 +187,88 @@ the payload, so **no CHECK-constraint migration is needed**. --- -## Open questions for Avni / Jac - -1. **Should builders get Bedrock Slack DMs when their ask is answered?** - Responding to a builder ask currently notifies nobody. If Bedrock becomes the - single bot, the builder should hear back — but that means DMing builders from - the Bedrock app, and Sputnik may already do this, in which case we'd - double-notify. Needs confirming before wiring. -2. **Does the Sputnik-side channel get turned off** once Bedrock notifies, or do - both run during a transition? -3. **Backlog:** 17 pending builder asks, oldest from 2026-03. Notify on the first - poll, or seed the watermark to now and let the backlog be worked from the page - only? (Plan currently assumes the latter.) -4. **`demo_feedback` and `other`** — still live ask types in the Sputnik UI, or - legacy? Affects whether they need first-class labels. +## 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. From 2fb03693111d5a9b445b32bf36f6d2b62418067c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 21:05:39 +0000 Subject: [PATCH 3/9] fix(jobs): restore builder identity on intro requests and route them through the Bedrock bot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builder intro requests from Sputnik were reaching Bedrock with no builder attached — every card read "from — (builder)". Root cause: public.users has RLS enabled and the app role (bedrock_user) has SELECT but no policy, so it reads zero rows. jobs_intro.py joined that table directly, and because it was a LEFT JOIN it failed silently; trim() over coalesce'd NULLs yields '' rather than NULL, which the card rendered as an em dash. The data was never wrong — every row resolves under a role that bypasses RLS. bedrock.builder_by_id (SECURITY DEFINER, already granted) is the established pattern and jobs.py already used it; this call site had skipped it. - Builder identity via LEFT JOIN LATERAL bedrock.builder_by_id, plus a name → email → "Builder #" fallback so a blank label is unreachable. - Staff asks gain requested_by_name too; they were showing raw emails because the key was absent from _staff_row. - New intro_notification_poller fans Sputnik asks into the existing Bedrock notification path (bell + Slack DM) so one bot carries both kinds of ask. Watermark seeds to now on first run, so the 17-row backlog is not retro-DMed. The cycle holds FOR UPDATE on the watermark row — several Cloud Run instances each run this loop, and an unlocked read lets two of them notify twice. Started outside the Salesforce guard since it is Postgres-only. - Requester is now shown by a dedicated Builder/Jobs team tag and a left border, instead of being smuggled into the ask-type tag's colour variant. The tag text carries the signal without relying on colour. - Ask labels cover all eight values across both tables; the Slack DM was rendering the raw introductory_call. BUILDER_STATUS_MAP no longer collapses completed into approved, and builder asks can now be marked intro-made. No migrations required. 13 new tests, including guards that this flow must not join public.users directly. Full suite 917 passed against a 904-passed baseline, same 25 pre-existing env failures; tsc clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XthvkMYPKTHgm86akR5zBB --- .../frontend-v2/src/pages/jobs/JobsHome.tsx | 25 +- .../frontend-v2/src/services/jobs.ts | 1 + financial_forecasting/main.py | 9 + financial_forecasting/routes/jobs_intro.py | 70 +++++- .../services/intro_notification_poller.py | 232 +++++++++++++++++ .../services/notifications.py | 10 +- .../tests/test_jobs_intro.py | 235 ++++++++++++++++++ tasks/builder-intro-requests-plan.md | 134 ++++++---- 8 files changed, 652 insertions(+), 64 deletions(-) create mode 100644 financial_forecasting/services/intro_notification_poller.py create mode 100644 financial_forecasting/tests/test_jobs_intro.py diff --git a/financial_forecasting/frontend-v2/src/pages/jobs/JobsHome.tsx b/financial_forecasting/frontend-v2/src/pages/jobs/JobsHome.tsx index 286be641..1db8f95e 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,23 @@ 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). + 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 +980,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..349ce7f4 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) """ @@ -136,10 +179,17 @@ async def list_intro_requests( 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 + 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 +198,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 +255,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/services/intro_notification_poller.py b/financial_forecasting/services/intro_notification_poller.py new file mode 100644 index 00000000..56bc9d17 --- /dev/null +++ b/financial_forecasting/services/intro_notification_poller.py @@ -0,0 +1,232 @@ +"""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( + """ + SELECT ir.intro_request_id, ir.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 > $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..2003bdd9 --- /dev/null +++ b/financial_forecasting/tests/test_jobs_intro.py @@ -0,0 +1,235 @@ +"""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 "notification_watermark" in q and "SELECT" in q] + assert reads and all("FOR UPDATE" in q for q in reads), reads + + +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 index bd9a6963..99c429e6 100644 --- a/tasks/builder-intro-requests-plan.md +++ b/tasks/builder-intro-requests-plan.md @@ -3,7 +3,10 @@ Source: Slack thread 2026-07-30 (Joanna, Avni, Jac) — screenshot of an intro request card reading `from — (builder) · 1mo`. -Status: **plan for review — nothing built yet.** +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". --- @@ -135,55 +138,86 @@ the payload, so **no CHECK-constraint migration is needed**. --- -## Plan - -### 1. Fix the builder identity (P0 — the actual reported bug) -- [ ] `jobs_intro.py`: replace the `LEFT JOIN public.users` with - `LEFT JOIN LATERAL bedrock.builder_by_id(ir.builder_id) b ON true` -- [ ] Return `builder_id` in the payload so the card can link to the builder -- [ ] Fallback chain that can never render a bare dash: - `full_name → email → 'Builder #'` -- [ ] Add `requested_by_name` to `_staff_row()` too — staff asks currently show a - raw email because the key is simply absent - -### 2. Surface what Sputnik captured -- [ ] Select `builder_preparation`, `demo_url`, `readiness_checks`, `cohort` -- [ ] Extend the `IntroRequest` TS interface -- [ ] Card: builder name links to their profile; `demo_url` as a labelled link; - prep notes collapsed behind a disclosure; readiness as a compact check row - -### 3. Colour-code builder vs jobs-team (Avni's ask) -- [ ] Dedicated source `Tag` — `sky` = "Builder", `accent` = "Jobs team" — - distinct from the ask-type tag, which currently smuggles the source signal - into its `variant` (semantically wrong and easy to miss) -- [ ] Left border accent on the card so the split is visible while scanning -- [ ] Keep the `(builder)` text as the non-colour fallback (accessibility) - -### 4. Merge the notification paths (Avni + Jac) -- [ ] `services/intro_notification_poller.py` — watermark source - `sputnik_intro_request`, enqueue `intro_request` to the connector staff for - new `public.intro_requests` rows -- [ ] Payload carries `requester_kind: "builder"`, builder name, demo URL -- [ ] `_format_slack_message`: distinguish builder vs staff asks in the DM -- [ ] Wire into `main.py` startup alongside the SF poller -- [ ] Seed the watermark at deploy time so the first poll doesn't DM the team - about all 17 pending backlog rows - -### 5. Vocabulary + status correctness -- [ ] Single shared ask-label map covering all six Sputnik values, backend and - frontend in sync -- [ ] `BUILDER_STATUS_MAP`: `completed → completed` (allowed by the CHECK - constraint), so accept and done stop collapsing -- [ ] Drop the `source === "staff"` gate on "Mark intro made" -- [ ] Fold `jobs.py:713/743` and `sputnik.py:109` onto `builder_by_id` - -### 6. Tests -- [ ] Regression test asserting the builder name resolves — the current bug would - have been caught by one assertion that `requested_by_name` is non-empty for - a builder-sourced row -- [ ] A guard test that fails if any query in `routes/` joins `public.users` - directly, so this trap cannot be reintroduced -- [ ] Poller test: watermark advances once, no duplicate notification +## 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. --- From 651094f44a5f4c01ed6e59a26ed0a04fb912177e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 00:13:44 +0000 Subject: [PATCH 4/9] fix(jobs): read Sputnik intro timestamps as UTC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit public.intro_requests.created_at is `timestamp` (naive) while bedrock.notification_watermark.last_seen and bedrock.intro_request.created_at are `timestamptz`. asyncpg therefore returns a naive datetime on one side and an aware one on the other. In the poller this was fatal: both `WHERE ir.created_at > $1` (aware param against a naive column) and the `r["created_at"] > newest` comparison raise TypeError, so every cycle would have died inside run_forever's guard and no builder ask would ever have been delivered. The fakes could not catch it — FakeConn does not enforce asyncpg's typing — so the guard is an assertion on the SQL instead. On the read path it was cosmetic but wrong: builder rows serialised with no UTC offset while staff rows carried one, so the browser parsed builder dates as local time and relative days could be off near boundaries. The database runs in UTC and these values are UTC, so both sites now read them with AT TIME ZONE 'UTC' and Python stays uniformly tz-aware. Verified the cast against production. 15 tests; full suite 919 passed against a 904 baseline. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XthvkMYPKTHgm86akR5zBB --- financial_forecasting/routes/jobs_intro.py | 8 ++++- .../services/intro_notification_poller.py | 12 +++++-- .../tests/test_jobs_intro.py | 35 ++++++++++++++++++- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/financial_forecasting/routes/jobs_intro.py b/financial_forecasting/routes/jobs_intro.py index 349ce7f4..caa796ff 100644 --- a/financial_forecasting/routes/jobs_intro.py +++ b/financial_forecasting/routes/jobs_intro.py @@ -178,7 +178,13 @@ 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, + -- 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 diff --git a/financial_forecasting/services/intro_notification_poller.py b/financial_forecasting/services/intro_notification_poller.py index 56bc9d17..92176cd9 100644 --- a/financial_forecasting/services/intro_notification_poller.py +++ b/financial_forecasting/services/intro_notification_poller.py @@ -116,7 +116,15 @@ async def _poll_builder_intros(pool) -> int: rows = await conn.fetch( """ - SELECT ir.intro_request_id, ir.created_at, ir.specific_ask, + -- 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, @@ -125,7 +133,7 @@ async def _poll_builder_intros(pool) -> int: 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 > $1 + WHERE ir.created_at AT TIME ZONE 'UTC' > $1 AND ir.status = 'pending' ORDER BY ir.created_at ASC LIMIT 200 diff --git a/financial_forecasting/tests/test_jobs_intro.py b/financial_forecasting/tests/test_jobs_intro.py index 2003bdd9..bffd3fe2 100644 --- a/financial_forecasting/tests/test_jobs_intro.py +++ b/financial_forecasting/tests/test_jobs_intro.py @@ -216,10 +216,43 @@ def test_watermark_is_locked_so_parallel_instances_cannot_double_notify(monkeypa monkeypatch.setitem(_services, "db_pool", FakePool(conn)) asyncio.run(poller.poll_once()) - reads = [q for q in conn.queries() if "notification_watermark" in q and "SELECT" in q] + 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 From 479b9e9fd9775157735921fdd5a8e8426f3163b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 20:51:45 +0000 Subject: [PATCH 5/9] fix(jobs): make the builder/jobs-team tag colours actually distinguishable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught rendering the card locally: --sky is oklch(0.55 0.13 245) and --accent is oklch(0.55 0.15 250) — identical lightness, five degrees apart in hue. Side by side the two source tags read as the same blue, which defeats the point of colour-coding them. green and red are spoken for by the Accept/Decline actions on the same card, so builder asks now use amber. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XthvkMYPKTHgm86akR5zBB --- .../frontend-v2/src/pages/jobs/JobsHome.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/financial_forecasting/frontend-v2/src/pages/jobs/JobsHome.tsx b/financial_forecasting/frontend-v2/src/pages/jobs/JobsHome.tsx index 1db8f95e..118c03b8 100644 --- a/financial_forecasting/frontend-v2/src/pages/jobs/JobsHome.tsx +++ b/financial_forecasting/frontend-v2/src/pages/jobs/JobsHome.tsx @@ -948,14 +948,19 @@ function IntroRequestCard({ r, mine }: { r: IntroRequest; mine: boolean }) { // 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"} + {fromBuilder ? "Builder" : "Jobs team"} {r.contact_name || "—"} {r.contact_company && {r.contact_company}} {askLabel(r.specific_ask)} From 2dede9e6750351fdb94302310e86ef4f8c11d8c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 22:22:43 +0000 Subject: [PATCH 6/9] chore(jobs): add a no-database local preview for the intro-requests zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts the real routes/jobs_intro.py router behind a stub connection so the Jobs > Intro requests card can be rendered locally without a database, and MODE=before/after shows the RLS failure mode against the fix. Rows are invented rather than copied from production — the real ones carry a builder's email and the text of their intro request, which does not belong in the repo. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XthvkMYPKTHgm86akR5zBB --- .../scripts/preview_intro_requests.py | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 financial_forecasting/scripts/preview_intro_requests.py diff --git a/financial_forecasting/scripts/preview_intro_requests.py b/financial_forecasting/scripts/preview_intro_requests.py new file mode 100644 index 00000000..adee4799 --- /dev/null +++ b/financial_forecasting/scripts/preview_intro_requests.py @@ -0,0 +1,132 @@ +"""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 + +# 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") From 7401eca6930b794743f9a2e7d840fe480aa9a28e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 23:09:35 +0000 Subject: [PATCH 7/9] chore(jobs): fail the intro preview legibly on Python 3.9 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit db.py annotates asyncpg.Pool | None at module scope, so anything importing it dies with a TypeError from an unrelated line on 3.9 — macOS's default python3. Check the version up front and print the fix instead. Production runs 3.11 per the Dockerfile. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XthvkMYPKTHgm86akR5zBB --- .../scripts/preview_intro_requests.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/financial_forecasting/scripts/preview_intro_requests.py b/financial_forecasting/scripts/preview_intro_requests.py index adee4799..9542fee4 100644 --- a/financial_forecasting/scripts/preview_intro_requests.py +++ b/financial_forecasting/scripts/preview_intro_requests.py @@ -34,6 +34,21 @@ 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)) From ebf11593d571129b6942180f97f06a338b596b95 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 23:13:05 +0000 Subject: [PATCH 8/9] chore(jobs): Node-only stub for the intro-requests preview The Python preview needs 3.10+ (db.py annotates asyncpg.Pool | None at module scope) which stock macOS does not ship, so previewing a frontend change meant installing a second toolchain. Node is already required for the frontend, so this serves the same payloads with no dependencies. Payloads are recorded verbatim from the real router rather than invented; only the timestamps are recomputed so relative dates stay sensible. Verified it renders identically to the Python version. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XthvkMYPKTHgm86akR5zBB --- .../frontend-v2/scripts/preview-intro-api.mjs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 financial_forecasting/frontend-v2/scripts/preview-intro-api.mjs 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..d7890514 --- /dev/null +++ b/financial_forecasting/frontend-v2/scripts/preview-intro-api.mjs @@ -0,0 +1,112 @@ +/** + * 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] }); + } + // Every other zone on the Jobs page 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'); +}); From 4f0b8dd8ef5c1aad94c4f5f7da341f37f8155e60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 23:22:11 +0000 Subject: [PATCH 9/9] fix(jobs): stop the preview stub blanking the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landing on http://localhost:4200 redirects to /dashboard, which threw "(q.data ?? []).filter is not a function" and rendered a white page — the stub returned a {success, data} envelope for every route, but the Salesforce endpoints return bare arrays and the notification badge reads data.data.count. Shape the catch-all per endpoint family so any entry point renders instead of only /jobs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XthvkMYPKTHgm86akR5zBB --- .../frontend-v2/scripts/preview-intro-api.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/financial_forecasting/frontend-v2/scripts/preview-intro-api.mjs b/financial_forecasting/frontend-v2/scripts/preview-intro-api.mjs index d7890514..2b5a45f6 100644 --- a/financial_forecasting/frontend-v2/scripts/preview-intro-api.mjs +++ b/financial_forecasting/frontend-v2/scripts/preview-intro-api.mjs @@ -102,7 +102,16 @@ createServer((req, res) => { if (path === '/api/jobs/intro-requests') { return send(res, { success: true, data: [staffRow, builderRow] }); } - // Every other zone on the Jobs page renders from an empty result. + + // 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}`);