Skip to content
Open
121 changes: 121 additions & 0 deletions financial_forecasting/frontend-v2/scripts/preview-intro-api.mjs
Original file line number Diff line number Diff line change
@@ -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');
});
30 changes: 26 additions & 4 deletions financial_forecasting/frontend-v2/src/pages/jobs/JobsHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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");

Expand All @@ -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 (
<div className="flex flex-col gap-1.5 border-t border-border-strong px-3 py-2 text-[12.5px]">
<div className={cn(
"flex flex-col gap-1.5 border-t border-l-2 border-border-strong px-3 py-2 text-[12.5px]",
fromBuilder ? "border-l-amber" : "border-l-accent",
)}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5">
<Tag variant={fromBuilder ? "amber" : "accent"}>{fromBuilder ? "Builder" : "Jobs team"}</Tag>
<Link to={`/jobs/contacts/${r.contact_id}`} className="font-medium text-ink hover:text-accent">{r.contact_name || "—"}</Link>
{r.contact_company && <span className="text-[11.5px] text-ink-3">{r.contact_company}</span>}
<Tag variant={r.source === "builder" ? "default" : "accent"}>{askLabel(r.specific_ask)}</Tag>
<Tag>{askLabel(r.specific_ask)}</Tag>
<span className="text-[11px] text-ink-4">
{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)}` : ""}
</span>
{!isPending && (
Expand All @@ -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</button>
</div>
)}
{!mine && isAccepted && r.source === "staff" && (
{/* Builder asks can be completed too now that BUILDER_STATUS_MAP stops
collapsing completed→approved. */}
{!mine && isAccepted && (
<div>
<button type="button" disabled={respond.isPending} onClick={() => act("completed")}
className="rounded border border-border-strong px-2 py-0.5 text-[11px] font-medium text-ink-3 hover:border-accent hover:text-accent">Mark intro made</button>
Expand Down
1 change: 1 addition & 0 deletions financial_forecasting/frontend-v2/src/services/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions financial_forecasting/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']}")


Expand Down
78 changes: 68 additions & 10 deletions financial_forecasting/routes/jobs_intro.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -85,14 +108,32 @@
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",
"contact_id": r["contact_id"], "contact_name": r["contact_name"],
"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,
Expand All @@ -106,10 +147,12 @@
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)
"""


Expand All @@ -132,24 +175,38 @@
# Builder asks from Sputnik targeted at me (respond-only surface)
open_builder = "" if include_closed else " AND ir.status = 'pending'"
brows = await conn.fetch(
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)

Check failure on line 201 in financial_forecasting/routes/jobs_intro.py

View check run for this annotation

Claude / Claude Code Review

Accepted builder intro asks vanish from inbox before Mark intro made is reachable

Accepted builder intro asks disappear from the default inbox before the new 'Mark intro made' action can be used, because the builder-row filter (`open_builder`) only keeps `status = 'pending'`, unlike the staff-row filter (`open_staff`) which also keeps `'accepted'`.
Comment on lines 178 to 201

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Accepted builder intro asks disappear from the default inbox before the new 'Mark intro made' action can be used, because the builder-row filter (open_builder) only keeps status = 'pending', unlike the staff-row filter (open_staff) which also keeps 'accepted'.

Extended reasoning...

routes/jobs_intro.py builds two different "still open" filters for the two sources that feed the intro-requests inbox. For staff→staff asks (bedrock.intro_request), line ~176 defines:

open_staff = "" if include_closed else " AND ir.status IN ('pending','accepted')"

For Sputnik builder asks (public.intro_requests), a few lines later:

open_builder = "" if include_closed else " AND ir.status = 'pending'"

open_staff deliberately keeps 'accepted' rows visible in the default (non-closed) view, because an accepted-but-not-yet-completed ask is still an actionable, open item — the connector still has to make the intro. open_builder only kept 'pending' — an asymmetry that was harmless before this PR because a builder ask could never be marked complete anyway (see below).

This PR changes that. It fixes BUILDER_STATUS_MAP so completed maps to completed instead of collapsing into approved (previously both accepted and completed mapped to approved, losing the distinction), and it removes the r.source === "staff" gate on the "Mark intro made" button in JobsHome.tsx, using isAccepted = r.status === "accepted" || r.status === "approved" to decide when to show it. The stated intent (in the PR description) is explicit: "builder asks can now be marked intro-made."

But the moment a staff member accepts a builder ask, respond_intro_request maps acceptedBUILDER_STATUS_MAP["accepted"] = "approved" and writes status='approved' into public.intro_requests. On the very next refetch of the default inbox (useIntroRequests('all', showClosed=false)include_closed=false → the open_builder clause), that row no longer matches ir.status = 'pending', so it's excluded from the response entirely. The card disappears from the staff member's "For you" list — along with the newly-enabled "Mark intro made" button that would let them close the loop.

The only way to reach the button again is to toggle "show closed", but that flips include_closed=true and pulls in every declined/completed ask too, defeating the purpose of a scoped default view. So the feature this PR set out to ship (staff can accept a builder's ask and later mark the intro made) is unreachable along its intended path — a staff member who accepts an ask immediately loses the ability to act on it from the default screen, and has to know to dig through the closed list to find it again.

Step-by-step proof:

  1. A builder ask arrives with status = 'pending' in public.intro_requests. It's fetched by open_builder's 'pending' check and shows up under "For you".
  2. Staff clicks Accept → frontend calls PATCH /api/jobs/intro-requests/{id} with status: 'accepted', source: 'builder'.
  3. respond_intro_request looks up BUILDER_STATUS_MAP['accepted']'approved', and runs UPDATE public.intro_requests SET status='approved' ....
  4. React Query invalidates ["jobs", "intro-requests"] and refetches with the same box='all', include_closed=false params used by IntroRequestsZone.
  5. The backend's builder query runs with open_builder = " AND ir.status = 'pending'"; the row's status is now 'approved', so it fails this predicate and is dropped from data.
  6. The card vanishes from both "For you" and the count. The "Mark intro made" button — which the frontend would otherwise render because isAccepted includes 'approved' — never gets a chance to render, because the row isn't in the payload at all.

This is a real functional gap introduced by this PR's own feature (it didn't exist before because builder asks couldn't be completed at all, so there was nothing to lose visibility of). The fix is small and mirrors the existing staff-side pattern: include 'approved' alongside 'pending' in open_builder, e.g. " AND ir.status IN ('pending','approved')", so an accepted-but-not-completed builder ask stays visible in the default view exactly like an accepted staff ask does.

out += [{
"id": str(r["intro_request_id"]), "source": "builder",
"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,
Expand Down Expand Up @@ -204,6 +261,7 @@
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,
Expand Down
Loading