{"openapi":"3.1.0","info":{"title":"Bid Router","description":"B&B Concrete's bid-router: ingests project leads from **ConstructConnect** (cron pull, hardened filters), **Microsoft Outlook** (Graph webhook), and **manual entry**, runs each through **Gemini 2.0 Flash** for structured extraction, enriches with **Google Distance Matrix**, and persists to the `est_opportunity` Supabase table. Estimators triage via the Bid Tracker UI in `app.bandbconcrete.com`; promoted leads sync as **Pipedrive** deals.\n\n## The `/opportunities/*` surface\nEverything an estimator or the Bid Tracker UI calls. Filterable by state, scope, source, mailbox (`?mine=true` / `?email=` / `?all=true`), free-text search, project value, and bid-due-date range. Multi-state filtering via `?states=new,triaged,dismissed`.\n\n## Auth\nMost endpoints require an Entra-issued **ID token** (not access token) passed as `Authorization: Bearer <jwt>`. Admin endpoints additionally require membership in the `BidRouterAdmins` security group, surfaced via the `groups` claim.\n\nMicrosoft Graph webhook endpoints (`/webhooks/graph/*`) are unauthenticated but verify each notification's `clientState` HMAC against the value stored at subscription create time.\n\n## Test in the browser\nFrom the internal-app console while logged in:\n```js\nconst r = await msalInstance.acquireTokenSilent({\n  scopes: ['openid','profile'],\n  account: msalInstance.getActiveAccount(),\n});\nconsole.log(r.idToken);\n```\nPaste the printed token into the **Authorize** button at the top right and every protected endpoint becomes callable from this page.\n\n## Companion docs\n- [`PRD-phase1-ingestion-triage.md`](https://github.com/BandBconcrete/Lead-ingestion/blob/main/PRD-phase1-ingestion-triage.md) — design rationale\n- [`integration-helper.md`](https://github.com/BandBconcrete/Lead-ingestion/blob/main/integration-helper.md) — internal-app contract\n- [`docs/webhook-setup.md`](https://github.com/BandBconcrete/Lead-ingestion/blob/main/docs/webhook-setup.md) — Outlook webhook setup\n- [`docs/coolify-deploy.md`](https://github.com/BandBconcrete/Lead-ingestion/blob/main/docs/coolify-deploy.md) — production deployment","version":"1.0.0"},"paths":{"/auth/connect":{"get":{"tags":["OAuth"],"summary":"Begin OAuth — return a Microsoft consent URL","description":"First leg of the authorization-code flow.\n\n**What this does**\n1. Generates a 32-byte URL-safe `state` (CSRF + replay defense)\n2. Persists `(state, oid)` in `bid_router_oauth_state` with a 5-minute TTL\n3. Returns the Microsoft authorize URL the frontend should redirect the\n   browser to\n\n**Scopes requested:** `openid profile offline_access Mail.ReadWrite`\n(`Mail.Send` is needed for Phase 8 notifications and is granted at the app\nregistration level, not requested in this URL).\n\n**`prompt=consent`** is set today so users always see a fresh consent\nscreen — drop it after pilot to make reconnects silent.\n\n**Frontend usage**\n```js\nconst r = await fetch(`${BASE}/auth/connect`, {\n  headers: { Authorization: `Bearer ${idToken}` }\n});\nconst { authorization_url } = await r.json();\nwindow.location.href = authorization_url;\n```","operationId":"connect_auth_connect_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/auth/v1/callback":{"get":{"tags":["OAuth"],"summary":"OAuth redirect target — exchange code, persist tokens, create subscription","description":"Second leg of the authorization-code flow. **Microsoft is the caller.**\n\nThe Entra app registration must have `{PUBLIC_URL}/auth/v1/callback` registered\nexactly under **Authentication → Web** redirect URIs.\n\n**Steps**\n1. `db.consume_oauth_state(state)` — atomic, one-time, 5-minute TTL.\n   400 if missing, expired, or already used.\n2. POST to `/oauth2/v2.0/token` with `grant_type=authorization_code` to\n   receive `{access_token, refresh_token, expires_in}`.\n3. GET `/me` with the access token → capture `id` (Graph user id) +\n   `mail || userPrincipalName`.\n4. `db.upsert_inbox(...)` — encrypts the refresh token with Fernet,\n   inserts/updates `bid_router_inbox` keyed on `email`, sets\n   `status='connected'`, clears `disconnected_at`.\n5. **Phase 4 wiring:** create a Microsoft Graph subscription against\n   `/me/mailFolders('Inbox')/messages` with `changeType=created`,\n   60-hour expiry, the encrypted-at-rest `clientState`, pointing\n   `notificationUrl` and `lifecycleNotificationUrl` at this service's\n   webhook routes.\n6. Insert `bid_router_subscription` row, log `inbox_connected`.\n7. Redirect to `{INTERNAL_APP_URL}/bid-router/me?status=connected`.\n\n**Failure paths**\n- `400 Invalid or expired state` — replay or stale state\n- `400 Token exchange failed` — Microsoft rejected the code (logged with body)\n- Subscription create exception → redirect with `?status=error`. The inbox\n  row stays so the user can retry without re-consenting.","operationId":"callback_auth_v1_callback_get","parameters":[{"name":"code","in":"query","required":true,"schema":{"type":"string","title":"Code"}},{"name":"state","in":"query","required":true,"schema":{"type":"string","title":"State"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/inboxes/me":{"get":{"tags":["Inboxes"],"summary":"Get the caller's inbox connection state","description":"Returns the current state of the caller's connected mailbox.\n\n**Possible responses**\n\n| `status`         | Meaning |\n|------------------|---------|\n| `not_connected`  | No `bid_router_inbox` row for this user |\n| `connected`      | Active. Subscription exists; classifications happen on new mail. |\n| `expired`        | Refresh token revoked or rejected. User must reconnect via `/auth/connect`. |\n| `disconnected`   | User (or admin) explicitly disconnected. |\n\n`last_classification_at` reflects the most recent successful worker run\nagainst this inbox; it is `null` until the first email is classified.","operationId":"my_inbox_inboxes_me_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","title":"Response My Inbox Inboxes Me Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/inboxes/me/disconnect":{"post":{"tags":["Inboxes"],"summary":"Self-service disconnect (deletes Graph subscription + clears token)","description":"Tears down the caller's connection.\n\n**Steps**\n1. Lookup `bid_router_inbox` by `owner_user_id`. Returns `not_connected`\n   if no row exists.\n2. **Best-effort Graph cleanup:** for every row in `bid_router_subscription`\n   belonging to this inbox, call `DELETE /subscriptions/{id}`. Failures are\n   logged but do not abort — Graph subscriptions also auto-expire.\n   `TokenRevoked` is silently caught (the token is already dead).\n3. Delete each `bid_router_subscription` row locally.\n4. Update `bid_router_inbox`: `status='disconnected'`,\n   `refresh_token_encrypted=NULL`, `disconnected_at=now()`.\n\nIdempotent — calling on an already-disconnected inbox does no harm.","operationId":"disconnect_me_inboxes_me_disconnect_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Disconnect Me Inboxes Me Disconnect Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/webhooks/graph/notifications":{"post":{"tags":["Webhooks"],"summary":"Microsoft Graph change notification endpoint (dual-mode)","description":"**Public** endpoint Microsoft Graph posts to whenever a watched mailbox\nreceives new mail.\n\n## Two modes\n\n### 1. Validation handshake (one-shot, at subscription create)\nGraph sends a request like:\n```\nPOST /webhooks/graph/notifications?validationToken=<random>\n```\nThis service must echo `<random>` back as `text/plain` within ~10 seconds\nor Graph rejects the subscription create call.\n\n### 2. Notification delivery\nGraph posts a JSON envelope:\n```json\n{\n  \"value\": [\n    {\n      \"subscriptionId\": \"...\",\n      \"clientState\": \"...\",\n      \"resource\": \"users/.../messages/...\",\n      \"resourceData\": { \"id\": \"<message-graph-id>\", \"@odata.type\": \"...\" },\n      \"changeType\": \"created\"\n    }\n  ]\n}\n```\n\nFor each item:\n- Lookup the expected `clientState` from `bid_router_subscription` by\n  `subscriptionId`\n- **`hmac.compare_digest`** mismatches → drop silently\n  (logged `clientstate_mismatch`)\n- Schedule `process_notification(n)` as an asyncio task — fire-and-forget so\n  we return 202 within Graph's tight 30-second budget\n\nAlways returns **202 Accepted**, even when items are dropped, so Graph\ndoes not retry.\n\nThe downstream worker handles classification, deduplication\n(via `internetMessageId`), folder management, and copy-to-`estimating@`.","operationId":"notifications_webhooks_graph_notifications_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/webhooks/graph/lifecycle":{"post":{"tags":["Webhooks"],"summary":"Microsoft Graph lifecycle event endpoint (dual-mode)","description":"**Public** endpoint Graph posts to for subscription lifecycle events.\n\nSame dual-mode shape as `/notifications`:\n- Validation handshake: `?validationToken=` → echo back as `text/plain`\n- Event delivery: JSON envelope with `lifecycleEvent` per item\n\n### Events handled\n\n| `lifecycleEvent`           | Action |\n|----------------------------|--------|\n| `reauthorizationRequired`  | Immediately call `tasks.reauthorize_subscription(sub_id)` to renew. Most often fires ~1 hour before expiry. |\n| `subscriptionRemoved`      | Mark the inbox `expired` and delete the local subscription row. The user will need to reconnect. |\n| `missed`                   | No-op. The hourly `delta_sync_all` job re-scans the inbox via `$filter=receivedDateTime gt last_classification_at`. |\n\nAlways returns 202 — Graph treats non-2xx as a retry signal.","operationId":"lifecycle_webhooks_graph_lifecycle_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/admin/inboxes":{"get":{"tags":["Admin"],"summary":"List every inbox known to the system","description":"Admin-only roll-up of every `bid_router_inbox` row.\n\nReturned columns: `id, owner_user_id, email, status, connected_at,\ndisconnected_at, last_classification_at, is_destination, is_system_sender`.\n\nUse this to spot users who have never connected, are stuck `expired`, or\nhaven't received traffic recently (`last_classification_at` lag).\n\nSystem mailboxes are flagged:\n- `is_destination=true` → `estimating@` (recipient of routed copies)\n- `is_system_sender=true` → `tech@` (sender of expiry alerts + weekly summaries)","operationId":"list_inboxes_admin_inboxes_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object"},"title":"Response List Inboxes Admin Inboxes Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/classifications":{"get":{"tags":["Admin"],"summary":"Browse classifications (anonymized by default; identified view audited)","description":"Admin-only view of `bid_router_classification` rows.\n\n## Two modes\n\n### `identified=false` (default — privacy-preserving)\n`sender` is reduced to `sender_domain` (`alice@gc.com` → `gc.com`).\n`subject` is replaced with `subject_hash` (sha256 truncated to 12 chars).\nNo audit row is written.\n\n### `identified=true`\nReturns the full `sender` and `subject` values.\n\n**Side effect:** writes a `bid_router_admin_audit` row with\n`action='view_user_classifications'`. The response includes a banner\nstring: *\"You are viewing identified data. This view is logged.\"*\n\n## Filters\n- `classification` — exact match against the rules-engine label\n  (`bid_invite`, `addendum`, `rfi`, etc.)\n- `min_confidence` — float floor `[0.0, 1.0]`\n- `limit` — max rows (default 100, no enforced cap)\n\nRows are ordered newest-first by `created_at`.","operationId":"list_classifications_admin_classifications_get","parameters":[{"name":"identified","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Identified"}},{"name":"classification","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Classification"}},{"name":"min_confidence","in":"query","required":false,"schema":{"type":"number","default":0.0,"title":"Min Confidence"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":100,"title":"Limit"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","title":"Response List Classifications Admin Classifications Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/inboxes/{inbox_id}/disconnect":{"post":{"tags":["Admin"],"summary":"Force-disconnect any user's inbox (audited)","description":"Admin override of a user's connection.\n\n**Body:** `{\"reason\": \"<free-form string>\"}` — surfaces in the audit log\nand any future user-facing notification.\n\n**Steps**\n1. 404 if `inbox_id` not found.\n2. Best-effort `DELETE /subscriptions/{id}` against Graph for each\n   linked subscription (errors logged, not raised).\n3. Delete local subscription rows.\n4. Set `bid_router_inbox.status='disconnected'`,\n   `refresh_token_encrypted=NULL`, `disconnected_at=now()`.\n5. Write `bid_router_admin_audit` row with\n   `action='force_disconnect'`, `target_inbox_id=<inbox_id>`,\n   `reason=<body.reason>`.\n\nUse cases: revoking a former employee's connection, breaking a\nlooping/misbehaving inbox, complying with a privacy request.","operationId":"force_disconnect_admin_inboxes__inbox_id__disconnect_post","parameters":[{"name":"inbox_id","in":"path","required":true,"schema":{"type":"string","title":"Inbox Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","title":"Body"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"string"},"title":"Response Force Disconnect Admin Inboxes  Inbox Id  Disconnect Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/kill-switch":{"post":{"tags":["Admin"],"summary":"Globally pause/resume all classification + routing (audited)","description":"Flips the global pause flag in `bid_router_system_state` (singleton row id=1).\n\n**Body:** `{\"enabled\": true|false, \"reason\": \"<string>\"}`\n\nWhile `enabled=true`:\n- The worker logs `kill_switch_active_skipping` and exits before\n  classifying any incoming notification\n- **Webhooks are still received and ACKed** (Graph stays happy)\n- Subscription renewal still runs (so we don't drop subs during the pause)\n- The retry queue still processes — flipping the switch off resumes\n  classification on whatever Graph delivers next\n\nWrites audit `kill_switch_on` or `kill_switch_off`. Use during incidents\n(e.g. classifier mis-routing all bids, Graph quota exhaustion, accidental\nconsent leak).\n\nReturns the resulting state: `{\"kill_switch_enabled\": true|false}`.","operationId":"toggle_kill_switch_admin_kill_switch_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","title":"Body"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"boolean"},"title":"Response Toggle Kill Switch Admin Kill Switch Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/admin/audit":{"get":{"tags":["Admin"],"summary":"Recent admin-action audit trail","description":"Append-only log of every privileged action.\n\nCurrently captures:\n- `view_user_classifications` — `GET /admin/classifications?identified=true`\n- `force_disconnect` — `POST /admin/inboxes/{inbox_id}/disconnect`\n- `kill_switch_on` / `kill_switch_off` — `POST /admin/kill-switch`\n\nEach row carries `admin_user_id` (Entra `oid`), `action`,\n`target_inbox_id` (where applicable), `reason`, and `created_at`.\nNewest first, default `limit=200`.\n\nThere is intentionally no DELETE or UPDATE surface — rows are immutable\nfrom the application layer.","operationId":"list_audit_admin_audit_get","parameters":[{"name":"limit","in":"query","required":false,"schema":{"type":"integer","default":200,"title":"Limit"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object"},"title":"Response List Audit Admin Audit Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/notifications/me":{"get":{"tags":["Notifications"],"summary":"The caller's last 50 in-app notifications","description":"Returns up to 50 most recent rows from `bid_router_notification`\nscoped to the caller's `oid`.\n\nEach row: `{id, user_id, type, payload, read_at, created_at}`.\n\n### Notification types\n\n| `type`               | Trigger | `payload` shape |\n|----------------------|---------|-----------------|\n| `connection_expired` | `db.mark_inbox_expired()` (token revoked / refresh failed) | `{\"email\": \"...\"}` |\n| `high_priority_bid`  | Worker classifies a bid_invite/addendum with a due date inside 72h. Pushed to admins + the affected user. | `{\"subject\": \"...\", \"sender\": \"...\"}` |\n\nNewest first. Use `read_at IS NULL` client-side to compute an unread count.","operationId":"list_mine_notifications_me_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object"},"title":"Response List Mine Notifications Me Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/notifications/{nid}/read":{"post":{"tags":["Notifications"],"summary":"Mark a single notification as read","description":"Sets `read_at = now()` on the notification.\n\nThe update is **scoped to the caller's `oid`** so users cannot mark each\nother's notifications read. If `nid` doesn't exist or doesn't belong to\nthe caller, the update affects 0 rows and the response is still\n`{\"ok\": true}` (idempotent).","operationId":"mark_read_notifications__nid__read_post","parameters":[{"name":"nid","in":"path","required":true,"schema":{"type":"string","title":"Nid"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":{"type":"boolean"},"title":"Response Mark Read Notifications  Nid  Read Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/leads":{"post":{"tags":["Leads"],"summary":"Manually log a bid lead (phone call, missed email, backfill)","description":"Insert a row in `bid_router_classification` for a lead that did not\ncome through Microsoft Graph.\n\n**Behavior**\n- The caller must have a connected inbox (`bid_router_inbox` row);\n  400 otherwise. The new classification is associated with that inbox\n  so it shows up in the caller's dashboards and weekly summary.\n- `internet_message_id` is auto-generated as `manual:<32-byte-token>`\n  so it's globally unique and won't collide with real Graph IDs.\n- `action_taken` is `manual_add` (distinct from `copied`,\n  `review_queued`, `queued_for_retry`).\n- `confidence` is hardcoded to `1.0` because a human classified it.\n- `reason` is `notes` (or `manual_add` if blank).\n- **No copy to `estimating@` is performed.** This endpoint records the\n  lead; routing the underlying message is the user's responsibility\n  since there often isn't a Graph message at all.\n\n**Failure paths**\n- `400 No connected inbox` — the caller has not connected via\n  `/auth/connect` yet\n- `400 Invalid classification` — value not in\n  `app.actions.CLASSIFICATIONS_TO_ROUTE`","operationId":"add_manual_lead_leads_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ManualLead"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","title":"Response Add Manual Lead Leads Post"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/opportunities":{"get":{"tags":["Opportunities"],"summary":"List opportunities for the Bid Tracker feed","description":"Paginated triage feed.\n\nFilters compose with AND semantics. Use `?mine=true` to auto-scope to\ninboxes connected by the calling user. `?email=` is the explicit\nequivalent — useful for admin or for users with multiple connected\nmailboxes.","operationId":"list_opportunities_opportunities_get","parameters":[{"name":"state","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Single state filter: new | triaged | promoted | dismissed. Use `states` (comma-separated) for multi-state OR filtering. When neither is passed, ALL states are returned (including dismissed).","title":"State"},"description":"Single state filter: new | triaged | promoted | dismissed. Use `states` (comma-separated) for multi-state OR filtering. When neither is passed, ALL states are returned (including dismissed)."},{"name":"states","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Multi-state OR filter, comma-separated. Example: `states=new,triaged,dismissed`. Wins over `state` if both set.","title":"States"},"description":"Multi-state OR filter, comma-separated. Example: `states=new,triaged,dismissed`. Wins over `state` if both set."},{"name":"scope_relevance","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"concrete_div3 | general | other_trade","title":"Scope Relevance"},"description":"concrete_div3 | general | other_trade"},{"name":"pipedrive_status","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"none | active | won | lost | na","title":"Pipedrive Status"},"description":"none | active | won | lost | na"},{"name":"source","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"construct_connect | outlook | manual","title":"Source"},"description":"construct_connect | outlook | manual"},{"name":"mailbox","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter to opportunities that landed in this mailbox. Matches `found_in_mailbox` array (Gemini-extracted). Use `email` for an exact source-inbox match instead.","title":"Mailbox"},"description":"Filter to opportunities that landed in this mailbox. Matches `found_in_mailbox` array (Gemini-extracted). Use `email` for an exact source-inbox match instead."},{"name":"email","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Filter to opportunities whose source inbox is this email (case-insensitive). Looks up bid_router_inbox by email and filters source_inbox_id.","title":"Email"},"description":"Filter to opportunities whose source inbox is this email (case-insensitive). Looks up bid_router_inbox by email and filters source_inbox_id."},{"name":"mine","in":"query","required":false,"schema":{"type":"boolean","description":"Scope the feed to inboxes connected by the calling user's email claim. Equivalent to ?email=<your-jwt-email> if you only have one inbox connected. Overridden by `?all=true`.","default":false,"title":"Mine"},"description":"Scope the feed to inboxes connected by the calling user's email claim. Equivalent to ?email=<your-jwt-email> if you only have one inbox connected. Overridden by `?all=true`."},{"name":"all","in":"query","required":false,"schema":{"type":"boolean","description":"Explicit 'all inboxes' flag. The default behaviour (no filter) is already all-inboxes, so this is sugar for UI toggles. When true, overrides `mine` and `email` (useful for admin 'switch to global view' buttons).","default":false,"title":"All"},"description":"Explicit 'all inboxes' flag. The default behaviour (no filter) is already all-inboxes, so this is sugar for UI toggles. When true, overrides `mine` and `email` (useful for admin 'switch to global view' buttons)."},{"name":"sender","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Exact match on the parsed From-header address.","title":"Sender"},"description":"Exact match on the parsed From-header address."},{"name":"gc","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Substring match on gc_name.","title":"Gc"},"description":"Substring match on gc_name."},{"name":"q","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Free-text search across project_name + source_subject + gc_name.","title":"Q"},"description":"Free-text search across project_name + source_subject + gc_name."},{"name":"min_value","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Minimum project value (USD, raw_payload->>projectValue).","title":"Min Value"},"description":"Minimum project value (USD, raw_payload->>projectValue)."},{"name":"max_value","in":"query","required":false,"schema":{"anyOf":[{"type":"integer"},{"type":"null"}],"description":"Maximum project value (USD).","title":"Max Value"},"description":"Maximum project value (USD)."},{"name":"due_after","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Bid due date >= this ISO timestamp.","title":"Due After"},"description":"Bid due date >= this ISO timestamp."},{"name":"due_before","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Bid due date <= this ISO timestamp.","title":"Due Before"},"description":"Bid due date <= this ISO timestamp."},{"name":"include_deleted","in":"query","required":false,"schema":{"type":"boolean","default":false,"title":"Include Deleted"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","maximum":500,"minimum":1,"default":100,"title":"Limit"}},{"name":"cursor","in":"query","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"description":"Opaque cursor — pass the previous response's `next_cursor` to fetch the next page. Internally an ISO date_found of the last row.","title":"Cursor"},"description":"Opaque cursor — pass the previous response's `next_cursor` to fetch the next page. Internally an ISO date_found of the last row."},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpportunitiesListResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/opportunities/{opportunity_id}":{"get":{"tags":["Opportunities"],"summary":"Full opportunity detail","description":"Returns the full row including `raw_payload` and Gemini's raw response.\n\nUseful for the detail drawer in the Bid Tracker UI and for re-extract\ndebugging.","operationId":"get_opportunity_opportunities__opportunity_id__get","parameters":[{"name":"opportunity_id","in":"path","required":true,"schema":{"type":"string","title":"Opportunity Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpportunityOut"}}}},"404":{"description":"Opportunity not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}},"patch":{"tags":["Opportunities"],"summary":"Estimator edits / triage","operationId":"patch_opportunity_opportunities__opportunity_id__patch","parameters":[{"name":"opportunity_id","in":"path","required":true,"schema":{"type":"string","title":"Opportunity Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpportunityPatch"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpportunityOut"}}}},"404":{"description":"Opportunity not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/opportunities/{opportunity_id}/promote":{"post":{"tags":["Opportunities"],"summary":"Promote to Pipedrive deal","operationId":"promote_opportunities__opportunity_id__promote_post","parameters":[{"name":"opportunity_id","in":"path","required":true,"schema":{"type":"string","title":"Opportunity Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpportunityOut"}}}},"404":{"description":"Opportunity not found"},"409":{"description":"Conflict — already in this state"},"502":{"description":"Pipedrive returned an error"},"503":{"description":"Pipedrive token not configured / unreachable"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/opportunities/{opportunity_id}/dismiss":{"post":{"tags":["Opportunities"],"summary":"Mark dismissed","operationId":"dismiss_opportunities__opportunity_id__dismiss_post","parameters":[{"name":"opportunity_id","in":"path","required":true,"schema":{"type":"string","title":"Opportunity Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DismissIn"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpportunityOut"}}}},"404":{"description":"Opportunity not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/opportunities/{opportunity_id}/delete":{"post":{"tags":["Opportunities"],"summary":"Soft delete","operationId":"soft_delete_opportunities__opportunity_id__delete_post","parameters":[{"name":"opportunity_id","in":"path","required":true,"schema":{"type":"string","title":"Opportunity Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteIn"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpportunityOut"}}}},"404":{"description":"Opportunity not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/opportunities/{opportunity_id}/folder":{"post":{"tags":["Opportunities"],"summary":"Record project folder URL","operationId":"record_folder_opportunities__opportunity_id__folder_post","parameters":[{"name":"opportunity_id","in":"path","required":true,"schema":{"type":"string","title":"Opportunity Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/FolderIn"}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpportunityOut"}}}},"404":{"description":"Opportunity not found"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/opportunities/{opportunity_id}/reextract":{"post":{"tags":["Opportunities"],"summary":"Admin: re-run Gemini against raw_payload","operationId":"reextract_opportunities__opportunity_id__reextract_post","parameters":[{"name":"opportunity_id","in":"path","required":true,"schema":{"type":"string","title":"Opportunity Id"}},{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpportunityOut"}}}},"404":{"description":"Opportunity not found"},"400":{"description":"Row has no raw_payload to re-extract"},"403":{"description":"Caller is not in the BidRouterAdmins group"},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/opportunities/manual":{"post":{"tags":["Opportunities"],"summary":"Manual entry — bypasses Gemini","description":"Create an est_opportunity row from typed fields. Skips Gemini.\n\nDrive-time enrichment uses the most precise location available:\n1. lat+lng (passed directly to Distance Matrix — no geocoding ambiguity)\n2. city + state (\"City, ST\" string — Google geocodes it)\n3. freeform `location` field — last resort","operationId":"manual_entry_opportunities_manual_post","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ManualOpportunityIn"},"examples":{"exact-coords":{"summary":"Lat/lng (most precise — no geocoding)","value":{"project_name":"Bridge Replacement — County Rd 25","city":"Newark","state":"NJ","latitude":40.7484,"longitude":-73.9857,"bid_type":"bid_invite","gc_name":"Skanska USA","scope_relevance":"concrete_div3"}},"city-state":{"summary":"City + state (Google geocodes)","value":{"project_name":"Library Renovation","city":"Trenton","state":"NJ","bid_type":"rfp","scope_relevance":"general"}},"freeform":{"summary":"Freeform location string (back-compat)","value":{"project_name":"School District Repaving","location":"Hoboken, NJ","bid_type":"rfq"}},"minimal":{"summary":"No location at all (drive-time skipped)","value":{"project_name":"Quick lead from a phone call"}}}}}},"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OpportunityOut"}}}},"422":{"description":"Invalid input. Common cases: latitude provided without longitude, city without state, lat/lng out of range."}}}},"/healthz":{"get":{"tags":["Health"],"summary":"Liveness probe","description":"Public, unauthenticated. Used by Coolify and the Docker `HEALTHCHECK`.\n\nReturns a constant `{\"status\": \"ok\", \"version\": \"<app.version>\"}`. Does\n**not** verify Supabase or Graph reachability — those checks live in the\nbackground worker logs and Sentry.","operationId":"healthz_healthz_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"additionalProperties":{"type":"string"},"type":"object","title":"Response Healthz Healthz Get"}}}}}}},"/whoami":{"get":{"tags":["Health"],"summary":"Echo the caller's JWT identity (debug)","description":"Confirms JWT validation works end-to-end.\n\nReturns the decoded token's `oid` (Entra Object ID) and `preferred_username`.\n\n**Failure modes**\n- `401 Missing bearer token` — no `Authorization` header\n- `401 Invalid token` — bad signature, wrong audience/issuer, expired\n- `401 Token missing oid claim` — token is valid but lacks `oid`\n\nDrop or gate behind `require_admin` before pilot.","operationId":"whoami_whoami_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","title":"Response Whoami Whoami Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/whoami/admin":{"get":{"tags":["Health"],"summary":"Verify admin group membership (debug)","description":"Returns 200 only if the caller's JWT contains the `BidRouterAdmins`\nObject ID in its `groups` claim.\n\n**Common reasons this returns 403 even for an admin:**\n- The Entra app's **Token configuration** is missing the optional `groups`\n  claim on the ID token\n- The user is in the group but the token was issued before they were added\n  (sign out and back in)\n- `BID_ROUTER_ADMINS_GROUP_ID` env var doesn't match the real group's\n  Object ID","operationId":"whoami_admin_whoami_admin_get","parameters":[{"name":"authorization","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Authorization"}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","title":"Response Whoami Admin Whoami Admin Get"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}}},"components":{"schemas":{"BidType":{"type":"string","enum":["bid_invite","rfp","rfq","rfi","addendum","prequalification","walkthrough","award","change_order","other"],"title":"BidType"},"DeleteIn":{"properties":{"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason"}},"type":"object","title":"DeleteIn","description":"Body for POST /opportunities/{id}/delete (soft-delete).","example":{"reason":"Duplicate of opportunity 86db9573-…"}},"DismissIn":{"properties":{"reason":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Reason"}},"type":"object","title":"DismissIn","description":"Body for POST /opportunities/{id}/dismiss.","example":{"reason":"Out of geographic range"}},"ExtractionStatus":{"type":"string","enum":["pending","ok","failed","manual"],"title":"ExtractionStatus"},"FolderIn":{"properties":{"folder_url":{"type":"string","title":"Folder Url"}},"type":"object","required":["folder_url"],"title":"FolderIn","description":"Body for POST /opportunities/{id}/folder — record the file-share\nfolder URL after the estimator creates one out-of-band.","example":{"folder_url":"https://bbconcrete.sharepoint.com/sites/estimating/Bids/2026-Q2/Mount-Olive-Middle-School"}},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"HasProjectFiles":{"type":"string","enum":["yes","no","unknown"],"title":"HasProjectFiles"},"ManualLead":{"properties":{"sender":{"anyOf":[{"type":"string","format":"email"},{"type":"string"}],"title":"Sender","description":"The originating sender. Email address when known (`alice@gc-example.com`); free text is allowed for non-email channels (`phone:555-0100`, `in-person:John from Acme`).","examples":["alice@gc-example.com","phone:555-0100"]},"subject":{"type":"string","maxLength":998,"minLength":1,"title":"Subject","description":"Short description of the bid / opportunity.","examples":["RFP - Concrete pour, Bldg 7"]},"classification":{"type":"string","title":"Classification","description":"One of the routable labels: `bid_invite`, `addendum`, `rfi`, `bid_results`, `award_notification`, `prequalification_request`, `change_order`, `meeting_invite_walkthrough`.","examples":["bid_invite"]},"notes":{"anyOf":[{"type":"string","maxLength":2000},{"type":"null"}],"title":"Notes","description":"Optional free-text note. Stored in the row's `reason` field."},"high_priority":{"type":"boolean","title":"High Priority","description":"Set to true if this bid is due within ~72h.","default":false}},"type":"object","required":["sender","subject","classification"],"title":"ManualLead","description":"Body schema for `POST /leads`."},"ManualOpportunityIn":{"properties":{"project_name":{"type":"string","title":"Project Name"},"location":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Location"},"bid_type":{"anyOf":[{"$ref":"#/components/schemas/BidType"},{"type":"null"}]},"gc_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gc Name"},"gc_platform":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gc Platform"},"bid_due_date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Bid Due Date"},"bid_docs_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bid Docs Url"},"has_project_files":{"$ref":"#/components/schemas/HasProjectFiles","default":"unknown"},"scope_relevance":{"$ref":"#/components/schemas/ScopeRelevance","default":"general"},"found_in_mailbox":{"items":{"type":"string"},"type":"array","title":"Found In Mailbox"},"city":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"City","description":"Project city, e.g. \"Newark\""},"state":{"anyOf":[{"type":"string","maxLength":2,"minLength":2},{"type":"null"}],"title":"State","description":"2-letter US state code, e.g. NJ. Case-insensitive."},"latitude":{"anyOf":[{"type":"number","maximum":90.0,"minimum":-90.0},{"type":"null"}],"title":"Latitude","description":"Decimal latitude. Use with longitude for an exact point — bypasses Google's geocoder ambiguity."},"longitude":{"anyOf":[{"type":"number","maximum":180.0,"minimum":-180.0},{"type":"null"}],"title":"Longitude","description":"Decimal longitude. Must be paired with latitude."}},"type":"object","required":["project_name"],"title":"ManualOpportunityIn","description":"Manual lead entry. Geocoding precedence for drive-time:\nlatitude+longitude > city+state > freeform `location` string."},"OpportunitiesListResponse":{"properties":{"items":{"items":{"$ref":"#/components/schemas/OpportunityOut"},"type":"array","title":"Items"},"next_cursor":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Next Cursor"}},"type":"object","required":["items"],"title":"OpportunitiesListResponse","description":"Paginated wrapper returned by GET /opportunities.\n\n`next_cursor` is an opaque string — pass it back as the `cursor`\nquery param to fetch the next page. Internally it's the\n`date_found` of the last row, but treat it as opaque to allow the\nserver to change pagination strategy in the future.","example":{"items":[{"bid_due_date":"2026-05-28T18:00:00Z","bid_type":"bid_invite","created_at":"2026-04-29T00:27:30Z","date_found":"2026-04-29T00:27:29Z","deleted":false,"est_distance_mi":28,"est_drive_time_min":36,"extraction_status":"ok","folder_created":false,"gc_name":"Mount Olive Township","gc_platform":"ConstructConnect","has_project_files":"yes","id":"86db9573-d771-4b5c-9f0d-e2cd712b1ca1","location":"Budd Lake, NJ","pipedrive_status":"none","project_name":"Mount Olive Middle School Classrooms","scope_relevance":"concrete_div3","source":"construct_connect","state":"new","updated_at":"2026-04-29T00:27:30Z"}],"next_cursor":"2026-04-29T00:27:29Z"}},"OpportunityOut":{"properties":{"id":{"type":"string","format":"uuid","title":"Id"},"source":{"$ref":"#/components/schemas/Source"},"source_inbox_id":{"anyOf":[{"type":"string","format":"uuid"},{"type":"null"}],"title":"Source Inbox Id"},"source_email":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Email"},"source_subject":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Source Subject"},"state":{"$ref":"#/components/schemas/State"},"extraction_status":{"$ref":"#/components/schemas/ExtractionStatus"},"project_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Name"},"location":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Location"},"bid_type":{"anyOf":[{"$ref":"#/components/schemas/BidType"},{"type":"null"}]},"gc_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gc Name"},"gc_platform":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gc Platform"},"bid_due_date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Bid Due Date"},"date_found":{"type":"string","format":"date-time","title":"Date Found"},"bid_docs_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bid Docs Url"},"pipedrive_status":{"$ref":"#/components/schemas/PipedriveStatus"},"pipedrive_deal_id":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Pipedrive Deal Id"},"scope_relevance":{"$ref":"#/components/schemas/ScopeRelevance"},"has_project_files":{"$ref":"#/components/schemas/HasProjectFiles"},"est_distance_mi":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Est Distance Mi"},"est_drive_time_min":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Est Drive Time Min"},"folder_created":{"type":"boolean","title":"Folder Created"},"folder_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Folder Url"},"deleted":{"type":"boolean","title":"Deleted"},"created_at":{"type":"string","format":"date-time","title":"Created At"},"updated_at":{"type":"string","format":"date-time","title":"Updated At"}},"additionalProperties":true,"type":"object","required":["id","source","state","extraction_status","date_found","pipedrive_status","scope_relevance","has_project_files","folder_created","deleted","created_at","updated_at"],"title":"OpportunityOut","description":"Wide read shape — passed straight from Supabase row.\n\n`extra=\"allow\"` keeps `raw_payload`, `gemini_raw_response`, and any\nother Supabase-side columns visible in the JSON response without\nneeding to model every field. The Bid Tracker UI relies on this for\nthe row-detail drawer.","example":{"bid_docs_url":"https://app.constructconnect.com/project/6133729/p?sourceType=3","bid_due_date":"2026-05-28T18:00:00Z","bid_type":"bid_invite","created_at":"2026-04-29T00:27:30Z","date_found":"2026-04-29T00:27:29Z","deleted":false,"est_distance_mi":28,"est_drive_time_min":36,"extraction_status":"ok","folder_created":false,"gc_name":"Mount Olive Township","gc_platform":"ConstructConnect","has_project_files":"yes","id":"86db9573-d771-4b5c-9f0d-e2cd712b1ca1","location":"Budd Lake, NJ","pipedrive_status":"none","project_name":"Temporary Classroom Units at Mount Olive Middle School","scope_relevance":"concrete_div3","source":"construct_connect","state":"new","updated_at":"2026-04-29T00:27:30Z"}},"OpportunityPatch":{"properties":{"project_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Project Name"},"location":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Location"},"bid_type":{"anyOf":[{"$ref":"#/components/schemas/BidType"},{"type":"null"}]},"gc_name":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gc Name"},"gc_platform":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Gc Platform"},"bid_due_date":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Bid Due Date"},"bid_docs_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Bid Docs Url"},"has_project_files":{"anyOf":[{"$ref":"#/components/schemas/HasProjectFiles"},{"type":"null"}]},"scope_relevance":{"anyOf":[{"$ref":"#/components/schemas/ScopeRelevance"},{"type":"null"}]},"found_in_mailbox":{"anyOf":[{"items":{"type":"string"},"type":"array"},{"type":"null"}],"title":"Found In Mailbox"},"triage_notes":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Triage Notes"}},"type":"object","title":"OpportunityPatch","description":"Partial update — every field optional. Calling PATCH with any\nfield implicitly flips `state` from `new` → `triaged`.","example":{"gc_name":"Skanska USA","gc_platform":"BuildingConnected","scope_relevance":"concrete_div3","triage_notes":"GC confirmed need for Div 03 sub."}},"PipedriveStatus":{"type":"string","enum":["none","active","won","lost","na"],"title":"PipedriveStatus"},"ScopeRelevance":{"type":"string","enum":["concrete_div3","general","other_trade"],"title":"ScopeRelevance"},"Source":{"type":"string","enum":["construct_connect","outlook","manual"],"title":"Source"},"State":{"type":"string","enum":["new","triaged","promoted","dismissed"],"title":"State"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}}},"tags":[{"name":"Health","description":"Liveness + JWT smoke probes."},{"name":"OAuth","description":"Two-leg authorization-code flow that captures a delegated refresh token for the caller's mailbox."},{"name":"Inboxes","description":"Self-service inbox state + disconnect for the calling user."},{"name":"Webhooks","description":"Microsoft Graph notification + lifecycle endpoints. Public, but every payload must carry a matching `clientState`."},{"name":"Admin","description":"Operational surface for `BidRouterAdmins`. Every privileged read or destructive action writes `bid_router_admin_audit`."},{"name":"Notifications","description":"Per-user in-app notification feed."},{"name":"Leads","description":"Manual lead entry for bids that didn't come through Outlook (phone calls, in-person, missed emails, onboarding backfill)."},{"name":"Opportunities","description":"**The main surface.** Bid Tracker feed backed by `est_opportunity` — the single source of truth for every potential bid B&B is aware of (CC + Outlook + manual). Triage (PATCH), promote-to-Pipedrive, dismiss, soft-delete, manual entry with structured city/state or lat/lng input, and admin re-extract.\n\nFilter combinations supported on `GET /opportunities`:\n- `?mine=true` — opportunities for the caller's connected inboxes\n- `?all=true` — explicit all-inboxes (default behaviour)\n- `?email=2@bandbconcrete.com` — specific mailbox owner\n- `?states=new,triaged,dismissed` — multi-state OR\n- `?scope_relevance=concrete_div3` — Gemini-classified scope\n- `?q=Bridge` — free-text on project_name + subject + gc_name\n- `?min_value=1000000` — project value floor\n- `?due_after=2026-06-01T00:00:00Z` — bid-date window"}]}