Compare commits
288 Commits
pre-ai-han
...
8ce6bc80fa
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ce6bc80fa | |||
| 1b7aedb204 | |||
| 503b243ed4 | |||
| 267e748647 | |||
| 076a9ec98d | |||
| c547d2f834 | |||
| ad9c4c8cd6 | |||
| 3e23a837d4 | |||
| f483196e91 | |||
| df7150fc29 | |||
| 03e87488b0 | |||
| 7c25b42fb0 | |||
| 04b5511bdd | |||
| 1d3f9d0a8a | |||
| 04d2cfb9a5 | |||
| c3d50069cc | |||
| b57089d523 | |||
| 633a208742 | |||
| af3b1c0123 | |||
| cc41f20668 | |||
| e3da5b7502 | |||
| 80771b86b1 | |||
| 68a4b99246 | |||
| 0facf2f8c9 | |||
| e1112a9a36 | |||
| c6e37ce83c | |||
| 4b0d2e6b1c | |||
| 0796874376 | |||
| 9a5cbc35ae | |||
| 16b9abf2e2 | |||
| 87236b57d2 | |||
| 0c5bd9734f | |||
| d5d4405ac2 | |||
| 16a07e1682 | |||
| 84dc9b07bf | |||
| 5c38fb8904 | |||
| 23dbcec86e | |||
| f62712d11c | |||
| 5b58702b20 | |||
| 57d28ac08e | |||
| 890cb80bef | |||
| aca1360164 | |||
| 4c83cebfca | |||
| 1d92893573 | |||
| 5bfbc2c096 | |||
| 83d1f4cecd | |||
| 2f2f4eea29 | |||
| 02db15f118 | |||
| 60b1e654f8 | |||
| b5d8e82f64 | |||
| 3fde3369c8 | |||
| f436def20e | |||
| 067574ad6a | |||
| 457f77eeb0 | |||
| e8ca15d245 | |||
| 7882b4723b | |||
| 10b5d4e9b0 | |||
| 6937bcaabd | |||
| 1acc780359 | |||
| d3fd9143d7 | |||
| c0bddc289e | |||
| 4e9610c252 | |||
| d0561be6a1 | |||
| fbe25b3d68 | |||
| 4586010b87 | |||
| 465b8ff880 | |||
| e5bcf3b28e | |||
| 96973c7968 | |||
| 054e9da49b | |||
| e803a78ded | |||
| 6e7c4afc7d | |||
| 44a000a723 | |||
| 7a36aeb410 | |||
| e15897c76f | |||
| 7056ed9e6d | |||
| 8010da8745 | |||
| 47ff8ad2b5 | |||
| 02fc47c832 | |||
| 874dee7263 | |||
| 960ea71a20 | |||
| 394f729595 | |||
| c576c6609e | |||
| 8bad2fe945 | |||
| c977196206 | |||
| 8cf6a66154 | |||
| d40cb834b1 | |||
| 07a29f630a | |||
| d1cf77cd41 | |||
| 93ce0490e0 | |||
| f9f98b1a65 | |||
| 86163a69aa | |||
| 13f527c4ad | |||
| 41f5519916 | |||
| 05646465b8 | |||
| b1ee46656e | |||
| 3cea0f23ee | |||
| 3a35121578 | |||
| fe0e6923d5 | |||
| e5b26245ca | |||
| dc88797469 | |||
| cbb4b25671 | |||
| 8d79dd93b8 | |||
| 1106f79611 | |||
| c7cd711859 | |||
| aad554bb9c | |||
| cabd745a2b | |||
| 8cfaef6a9d | |||
| b21d2fc234 | |||
| d6a02ee8da | |||
| 2375948b7a | |||
| 92fa3bc6ab | |||
| dc22aa0ff0 | |||
| e50a2150d5 | |||
| 3a3844b68e | |||
| ba45cfeec1 | |||
| 3f04911070 | |||
| dad5e1f546 | |||
| f1be3abcc5 | |||
| f918b766b0 | |||
| fbb41e789c | |||
| 97d36dd400 | |||
| f26f468878 | |||
| 79942c3fd3 | |||
| 4768ae0648 | |||
| e54d6c586a | |||
| 86893562b9 | |||
| b0708ed650 | |||
| 2ef2350de7 | |||
| f4606f073a | |||
| 9b709488d9 | |||
| 18180bc57f | |||
| f683bb5720 | |||
| 9851d56633 | |||
| 519c7eb5ce | |||
| 9ec208f6e7 | |||
| cfe0e6cae6 | |||
| e3f5ed4985 | |||
| 5105eaf529 | |||
| 974b188c1e | |||
| a28b635b19 | |||
| 50e7763380 | |||
| b3ed76c203 | |||
| 453ba3fefc | |||
| 143c979975 | |||
| ab0d40c1e2 | |||
| 278b9342b4 | |||
| a8b22cfa0b | |||
| b544a7a462 | |||
| 07a3f01184 | |||
| 86120423da | |||
| 0f90c0e199 | |||
| 93fa4eac5c | |||
| dc71d5873b | |||
| 307a6285e6 | |||
| 5e10005276 | |||
| d3a9031e23 | |||
| 708e8b977f | |||
| 8b0358af3b | |||
| 0156aae684 | |||
| 4d8b107121 | |||
| a21fe93454 | |||
| 595844de0b | |||
| b74d3cf584 | |||
| 50ddacdb66 | |||
| a5e2dcf43f | |||
| 3ba4532675 | |||
| 15042af6e2 | |||
| 5bee264d70 | |||
| 7cee7228dc | |||
| 00663a4734 | |||
| ac42f971fc | |||
| f10649abc2 | |||
| ab5e0deaf7 | |||
| f601a0db58 | |||
| dc69c9ddfb | |||
| db717b0b3f | |||
| fb2dc222fd | |||
| 0d1b305619 | |||
| b7d7ff06d2 | |||
| 665530f812 | |||
| 0f00ee5e01 | |||
| 8914391336 | |||
| e8ba74ed6d | |||
| aca915b047 | |||
| e910bcc67d | |||
| 5085bb47c2 | |||
| 029680ab2d | |||
| 2a2329ad19 | |||
| 641853a002 | |||
| c194ba4a43 | |||
| 8e9d22e0e0 | |||
| f65b65790c | |||
| b8627f4180 | |||
| 02d5c6c08c | |||
| 9bdd9959a8 | |||
| fff8338bf2 | |||
| bc15952857 | |||
| ba46fc5644 | |||
| 87bd0b7c56 | |||
| a283d0d3fd | |||
| 9f0bfd44f9 | |||
| 07d0db9579 | |||
| 7a5b853b3b | |||
| 52f6d0308f | |||
| d51e95cdfa | |||
| c0ed6d9840 | |||
| 8f818a7c71 | |||
| 68fcdc6122 | |||
| 11fe32f4c6 | |||
| 43eed720d9 | |||
| 1559feb759 | |||
| b56da2facd | |||
| 87bb20b8f0 | |||
| 1e3a6cfa01 | |||
| ede6eebf9a | |||
| 261814ae65 | |||
| 6656ebdead | |||
| 69f2a37591 | |||
| 7f714363dd | |||
| 1bd43abb8f | |||
| c203b70ef9 | |||
| f27e3b44b0 | |||
| fe632c9194 | |||
| e976fb4e87 | |||
| 0aefaa78eb | |||
| 49f88569da | |||
| 208ec996d5 | |||
| 8f7df2c0ef | |||
| f27f671fe6 | |||
| d6218f2e07 | |||
| 920a246d77 | |||
| b7f8e70be2 | |||
| 857d73e3d0 | |||
| 406ee0ef97 | |||
| 32fae2c693 | |||
| a45915fbbc | |||
| 06593a40d9 | |||
| 9737d90f1b | |||
| 1c904373f8 | |||
| 16060d2235 | |||
| 9330ce4782 | |||
| d68131a865 | |||
| 875bd924a9 | |||
| 49c6c8fd00 | |||
| a77e8ea578 | |||
| 90252bc98f | |||
| 036431aef8 | |||
| b3be1e0749 | |||
| b3506b5e73 | |||
| b14a16a1ab | |||
| 9c8ba296a8 | |||
| bee8690056 | |||
| 995a0c1d2e | |||
| f6a24ea4e1 | |||
| 04ff2ea301 | |||
| 60851b400a | |||
| bea34229d6 | |||
| 294b309faa | |||
| fb7690485b | |||
| 6044d5a88b | |||
| 00cd8b7c55 | |||
| fded959b5e | |||
| 5f5b9e5b23 | |||
| b2ee1a2150 | |||
| 08909aa884 | |||
| 070d2383bc | |||
| d7b1fe6645 | |||
| a3f8bb3427 | |||
| f050afc2f7 | |||
| 849e1c16e2 | |||
| 5310cd3fff | |||
| d2689afa53 | |||
| 9d88c8456c | |||
| 506aac609d | |||
| 7fa81f69a6 | |||
| 6e0188d0b4 | |||
| 24ab1908a6 | |||
| e2cdfac1c3 | |||
| a5e9615666 | |||
| 66cca70588 | |||
| e714088a2b | |||
| ff0ec143e2 | |||
| 8d964e64e4 | |||
| 44634b1145 | |||
| 001438008b | |||
| c8b68ad26d | |||
| 2b3d52ad77 | |||
| 52b369680b |
46
.ai/CURRENT_TASK.md
Normal file
46
.ai/CURRENT_TASK.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# CURRENT_TASK.md
|
||||
|
||||
**Active task:** Phase O cutover for self-serve signup. All code blockers are now closed on `main`. Only user-side manual ops remain: apex DNS fix at Namecheap, Stripe Dashboard live-mode config (with the new `/contact` and `/policies` URLs surfaced in the business profile), Railway prod env vars, internal validation pass, public flag flip. See `.ai/HANDOFF.md` for the resume point.
|
||||
|
||||
## Recently shipped
|
||||
|
||||
- **2026-05-14 — PR #168** Session expiration policy + dashboard onboarding-CTA fix + welcome step-2 PSA CTA reshape. Merge-committed into main as `3a35121`. Three threads bundled on one branch (`feat/session-expiration-policy`):
|
||||
- **Session expiration policy** (original branch scope): 3d idle / 14d absolute, per-account override, bulk revoke. New `AccountSecuritySettingsPage`, `RevokeSessionsModal`, `SessionExpiryToast`, `useAuthSessionExpiry` hook; backend dependencies in `accountSecurity.ts`.
|
||||
- **Dashboard onboarding CTA fix** (`8d79dd9`): The "Start a session" CTAs on `NextStepCard` and `SetupChecklist` used to `<Link to="/">` while themselves rendered on `/`, so clicks were silent no-ops. Replaced with a `FOCUS_START_SESSION_EVENT` window event that `StartSessionInput` listens for — scrolls itself into view (top of viewport), focuses the textarea, pulses a blue ring 900ms. `NextStepCard` hides itself locally on click so the prompt doesn't linger while the user types.
|
||||
- **Welcome step-2 PSA CTA reshape** (`dc88797`): Selecting a real PSA now swaps `[Continue] [Skip]` for `[Connect <PSA> now] [Connect later] [Skip this step]`. Primary blue button saves `primary_psa` and routes to `/account/integrations`; "Connect later" saves and continues to step 3. **Pre-existing bug fixed**: the old subtle "Connect now →" link never persisted `primary_psa` before navigating. Now it does. "No PSA yet" / no-selection states still show the original single Continue.
|
||||
- **2026-05-14 — PR #166** Docs/handoff doc updates carrying forward PR #164/#165 state and EIN blocker. Squash-merged into main as `fe0e692`.
|
||||
- **2026-05-12 — PR #167** `backend/scripts/create_site_admin.py` site-wide super-admin bootstrap script. Squash-merged into main as `e50a215`. Idempotent CLI, three modes (`--send-reset`, `--print-reset`, `--promote-only`). Uses `ADMIN_DATABASE_URL` (BYPASSRLS). User confirmed end-to-end success against prod via `railway ssh` 2026-05-12 evening.
|
||||
- **2026-05-12 — PR #165** Legal/contact pages for Stripe site review. Squash-merged into main as `ba45cfe`. Three new SPA pages: `/policies` (consolidated Customer Policies — refunds, cancellation, U.S. legal/export restrictions, promotional terms; anchor IDs per subsection), `/contact` (phone (470) 949-4131, support/sales/billing/security inboxes, response-time SLAs), `/promotions` (stub satisfying Policies §6.2). New `MarketingFooter` component (`components/common/MarketingFooter.tsx`) extracted from inline landing footer; mounted on `/landing`, `/pricing`, `/contact-sales` so all four legal links (Privacy/Terms/Policies/Contact) are reachable from every marketing surface. Component reuses existing `landing-footer*` CSS — must be inside a `.landing-page` wrapper (documented in JSX comment). Privacy and Terms closing sections updated to point at `/contact` + `/policies` with correct per-area inboxes; stale `hello@` mailto removed everywhere. Mailing address left as TODO comments in both `ContactPage.tsx` and `PoliciesPage.tsx`, rendered publicly as "available on request" until P.O. Box is purchased. tsc + eslint clean.
|
||||
- **2026-05-08 — PR #164** Plan taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist + Stripe sync script + page-title fix + frontend taxonomy followups + doc refresh. 5 commits on `feat/billing-plan-taxonomy` from main (`dad5e1f`); HEAD `2c9f5e9`. Migration `4ce3e594cb87` renames `plan_limits.plan='team'` → `'enterprise'` and adds `starter` row (caps interpolated between free and pro: `max_trees=10`, `sessions=75`, `ai=15/mo`). Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. New `backend/scripts/sync_stripe_plan_ids.py` upserts `plan_billing` rows from Stripe products by exact name match — annual fields stay NULL by design (user explicitly skipping annual pricing for exit flexibility). `Settings.is_internal_tester` + `is_self_serve_active_for` centralize the allowlist + global-flag check; new `get_current_user_optional` dep; `/config/public` honors allowlist for authenticated callers; `/auth/register` allows allowlisted emails without invite code. LandingPage page-title bug — `—` inside JSX attribute strings was rendering as 6 literal characters in browser tabs; replaced with literal em dash. PageMeta default tagline updated from "Decision Tree Platform" to "AI-Powered Troubleshooting for MSPs". 86/86 passing across subscription/billing/plan/invite/admin sweep; tsc + lint clean. See `.ai/DECISIONS.md` for the two architectural entries (taxonomy reconciliation, allowlist).
|
||||
- **2026-05-06 — PR #163** Seed test users marked email-verified. Squash-merged into main as `dad5e1f`.
|
||||
- **2026-05-06 — PR #162** Self-serve signup Phase 2 (frontend cutover). 18 commits across Tasks 27–44 of the plan. Backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Squash-merged into main as `f1be3ab`. Single alembic head was `c6cbfc534fad` (no new migrations in Phase 2; PR #164 adds `4ce3e594cb87`).
|
||||
- **2026-05-02 — PR #159** In-product User Guides rewrite. Merged into `main`. Replaced 15 feature-dump guides with 43 problem-oriented Diátaxis how-tos grouped under 10 categories. Dropped Maintenance Flows / AI Assistant / Flow Assist Sparkles (UI no longer exists). Renamed Step Library → Solutions Library. Authored 14 net-new how-tos for FlowPilot-era surfaces (tasklane keyboard flow, what-we-know, resolve, escalate, record-fix-outcome, post-docs-to-ticket, share-update, pause-and-leave, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate, etc.). Schema additions: `category`, optional `relatedSlugs`; hub renders category sections; detail page renders related-guides footer. Fixed rendering bug where `**bold**` in `step.tip` rendered literally. Killed misleading "N sections" subtitle on guide cards. Browser-verified against engineer + owner login (sidebar labels, account sub-pages, pilot-screen header buttons, Tasks panel, integration form). Two unverified items intentionally deferred: change-teammate-role (requires non-owner test member to inspect role-change control) and detailed Resolve / Escalate modal contents (Resolve gated by 6 pending tasks in test data). tsc and Vite build clean.
|
||||
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Merged into `main` as `5e10005`.
|
||||
- **Impeccable pass** (5 sub-passes — distill / quieter / layout / typeset / polish): score 24/40 → 33/40. Removed the duplicate "Suggested checks" chip strip; added an inline `Next steps · N pending in Tasks` cue above the latest action-bearing AI bubble; consolidated the desktop session header to Resolve + Escalate + ⋯ kebab (Context / New Ticket / Update Ticket / Pause now under the kebab, mobile kebab gained Context + New Ticket parity); centered the messages column to `max-w-3xl` to match the composer; bubbles dropped to `rounded-xl`. Decoration sweep: dropped 3px side stripes (TaskLane done states, all 6 ProposalBanner modes, WhatWeKnowItem rows), gradient backgrounds (WhatWeKnow + every banner), accent borderTop on TaskLane header, backdrop-blur on handoff overlay, animate-pulse-amber ring in VerifyingBanner, bordered avatar boxes in banners. Type sweep: 14 distinct sizes → 5-step scale (10/11/12/13/14px). Icon disambiguation: `MessageCircleQuestion` split into `Pencil` (Answer CTA) + `HelpCircle` (per-check explainer). Dead `font-sans` audit (12 sites) and double `text-xs` cleanups.
|
||||
- **TaskLane keyboard-first flow** (real feature): Enter submits + auto-advances to next pending task, Shift+Enter newline, Esc cancels, focus jumps to Send Responses after the last submission. Mouse path also auto-advances. Subtle hint row teaches the shortcut.
|
||||
- **Banner ↔ script panel linked**: collapsing or dismissing the ProposalBanner now also hides the InlineNoTemplateDialog / TemplateMatchPanel; recording any outcome closes both surfaces.
|
||||
- **WhatWeKnow collapsible**: per-session preference in `sessionStorage` (`rf-whatweknow-collapsed:{sessionId}`); auto-collapses on first render at ≥5 facts.
|
||||
- **Side fix**: `ParameterizationPreview.tokenize()` word-boundary guard prevents over-eager highlighting of short values like `"D"` (no longer lights up every capital D in `Get-ADUser`).
|
||||
- Validation: tsc clean, ESLint clean, Vite build clean. Type-check + lint passed at every commit boundary.
|
||||
- **2026-05-01 — PR #156** Suggested-fix `applied_pending` non-terminal outcome. Merged into `main` as `3ba4532`. Adds:
|
||||
- Schema/API: `FixStatus="applied_pending"`, `pending_reason` Text column, migration `c0f3a4b7e91d`. `PATCH /suggested-fixes/{id}/outcome` accepts pending, requires notes, stamps `applied_at` only.
|
||||
- UI: `PendingBanner` (info-tone, worked / didn't / update reason / dismiss). "Waiting to verify…" overflow option in `VerifyingBanner`. Nudge "Still checking" records pending with a reason. Page-level Resolve auto-patches pending → success before resolution flow; page-level Escalate intercepts pending the same way verifying/partial does.
|
||||
- Generators: `resolution_note_generator` and `escalation_package_generator` system prompts handle the new status without real-looking examples.
|
||||
- Tests: 4 new in `test_fix_outcome_endpoint.py` (21/21 suite green); prompt anti-parrot guardrail green; tsc + Vite build clean.
|
||||
- QA report: `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md` (5/7 scripted checks PASS with concrete evidence; 2 entry-path checks deferred — same handlers verified via tested transitions).
|
||||
- **2026-04-30 — PR #155** Escalation Mode wedge merged as `ac42f97`. Senior-tech magic-moment screen. Plan: [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md).
|
||||
|
||||
## Two-metric framing (Escalation Mode — read before quoting numbers)
|
||||
|
||||
The in-product `GET /analytics/flowpilot/escalations` endpoint measures *post-claim time-to-first-action*. The "minutes recovered" sales claim is `manual_baseline − in_product_metric`. Manual baseline comes from the founder's stopwatch on the next 5 escalations. Don't roll the in-product number alone into "minutes recovered" — that's the apples-to-oranges miscount Codex caught.
|
||||
|
||||
## Kill-switch (Escalation Mode)
|
||||
|
||||
Week 8: if 0 of 3 pilots produce a verifiable hours-saved-per-week number above 1.0, revisit the wedge.
|
||||
|
||||
## Notes for next session
|
||||
|
||||
- Drive checks 1 (VerifyingBanner overflow → "Waiting to verify…") and 5 (nudge "Still checking" with 3+ post-apply messages) in real pilot usage to close the QA gap left by `/qa` (the tested handlers cover the same mutations, but the entry-path UI rendering wasn't exercised end-to-end).
|
||||
- Consider monitoring how often pending fixes get parked vs resolved — if engineers report losing track across sessions, revisit the cross-session "Follow-ups" dashboard rollup that was scoped out.
|
||||
- After PR #158 lands in real ticket flow, eyeball the keyboard-hint contrast and the WhatWeKnow auto-collapse-at-5 threshold — both were judgment calls (5 was a guess; the contrast bump from `/70` to full muted-foreground was based on my read, not real screen testing). Adjust if the 5-fact threshold feels too aggressive or too lenient mid-session.
|
||||
- Two follow-ups logged in `.ai/TODO.md` from the impeccable pass: `ConcludeSessionModal` paused/escalated step should allow multi-select (Ticket Notes + Client Update + Email Draft simultaneously) — real feature work; `bg-card-hover` Tailwind class doesn't resolve in `CommandPalette` — two-line fix.
|
||||
262
.ai/DECISIONS.md
Normal file
262
.ai/DECISIONS.md
Normal file
@@ -0,0 +1,262 @@
|
||||
# DECISIONS.md
|
||||
|
||||
> Append-only architectural decision log. Newest entries at the top.
|
||||
> Entry format:
|
||||
>
|
||||
> ```
|
||||
> ## YYYY-MM-DD — <short title>
|
||||
> **Context:** why this came up
|
||||
> **Decision:** what we chose
|
||||
> **Rejected:** what we didn't choose and why
|
||||
> **Consequences:** what this means going forward
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-29 — Single source of truth for plan-tier taxonomy (derive admin UI + validation from `plan_limits`)
|
||||
|
||||
**Context:** A prod report ("AI sessions aren't working") traced to the owner account having no paid plan (AI is plan-gated), compounded by a real bug: the admin "Change Plan" dropdown ([`AccountDetailPage.tsx:443-445`](../frontend/src/pages/admin/AccountDetailPage.tsx)) still offered the dead `team` slug (renamed to `enterprise` in migration `4ce3e594cb87`, 2026-05-07) and omitted `starter`/`enterprise`. Selecting "Team" 400s against the hardcoded allow-list in [`admin.py:994`](../backend/app/api/endpoints/admin.py#L994). The dropdown was missed during the 2026-05-07 taxonomy reconciliation because the allowed-plan list is hand-duplicated across ≥6 backend + frontend sites. Second taxonomy-drift incident.
|
||||
|
||||
**Decision:** Option B — make `plan_limits` the single source of truth: admin dropdown + pricing/checkout derive plan options from a plans endpoint (filter `is_public`, order by `sort_order`, label from `display_name`), and backend validation checks against actual `plan_limits` rows rather than a hardcoded tuple. Implementation deferred (active work is on another branch); fully specced in [TODO.md](TODO.md). A trivial dropdown-options fix may land first to unblock the admin tool.
|
||||
|
||||
**Rejected:** Option A (patch only the `AccountDetailPage` dropdown). Fixes the symptom but leaves the duplication that has now caused two drift incidents — and there is no outage forcing a minimal diff (bug is admin-only and was already worked around via direct Pro assignment). Conflicts with the repo principle "prefer correct architecture over minimal diff."
|
||||
|
||||
**Consequences:** New plan tiers become a data change (a `plan_limits` row) instead of a multi-file code edit; UI and validation can no longer drift from the catalog. Requires a public-plans read endpoint (or extending billing state) consumed by the admin UI + pricing page. The `'team'` visibility string (`Tree.visibility` / `StepLibrary.visibility`) is a separate domain and is explicitly out of scope.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-28 — Scope Anthropic structured outputs to flat-array JSON only
|
||||
|
||||
**Context:** Optimizing the existing Claude API usage (no model change). The Anthropic path in `generate_json` (`ai_provider.py`) had no equivalent to the Gemini path's `response_mime_type="application/json"` — it prompted for JSON and relied on downstream defenses: `_strip_markdown_fences` (ai_fix), `parse_llm_json` (knowledge_flywheel), and `_try_repair_json` (kb_conversion, which balances unclosed braces on truncated output). Anthropic structured outputs (`output_config.format` with a JSON schema) guarantee valid, parseable JSON and would eliminate those band-aids. The question was which of the four `generate_json` call sites can adopt it.
|
||||
|
||||
Structured outputs has hard schema limits: **no recursive schemas**, and **every object must set `additionalProperties: false`** (so the schema must enumerate exactly the fields the model emits — a superset is impossible, an omission makes a field unproducible). Tracing the call sites against those limits:
|
||||
|
||||
- **kb_conversion** → output is `{title, description, nodes: [...]}` / `{...steps[], intake_form[]}` — **flat arrays**, references by `next_node_id`/id, no nesting. Expressible.
|
||||
- **ai_fix** → returns a fixed *node that is itself a subtree*; `_find_node_by_id` recurses `node["children"]` and the prompt requires decision nodes to have ≥2 children. **Recursive, arbitrary depth.**
|
||||
- **knowledge_flywheel flow-gen** → emits `tree_structure`, a decision-tree root with nested `children`/`options`, persisted as an opaque blob.
|
||||
- **knowledge_flywheel enhancement** → flat `new_nodes[] + modified_options[]`; expressible but low-frequency and only fence-stripped.
|
||||
|
||||
**Decision:** Apply structured outputs to **flat-array outputs only** — i.e. `kb_conversion`. Wired via an optional `schema=` param on `AIProvider.generate_json` (`None` = legacy prompt-only behavior; Anthropic maps it to `output_config.format`, Gemini ignores it), with the two KB schemas + `_schema_for_target_type()` in `kb_conversion_service.py`, gated behind `settings.AI_KB_CONVERT_STRUCTURED_OUTPUT` (default **False**) pending a live constrained-decoding smoke-test in staging. The robustness fixes that motivated the work — `_extract_text_from_response` (skip non-text blocks, log `max_tokens`/`refusal`, raise on no-text) — live in the shared provider, so **all four** callers already benefit regardless of schema adoption.
|
||||
|
||||
**Rejected:**
|
||||
- **Forcing schemas on ai_fix / flow-gen.** Their outputs are recursive/nested decision trees; a bounded-depth schema would reject valid deeper trees and break generation. Wrong architecture for marginal/zero benefit (flow-gen's tree is stored as a blob, never schema-validated downstream).
|
||||
- **Wiring the flywheel enhancement site.** Flat and technically expressible, but low call frequency and only fence-stripping today — marginal benefit against the risk of a blind (un-live-tested) `additionalProperties: false` schema.
|
||||
- **Deleting the fence-strip / repair helpers now.** `_strip_markdown_fences` / `parse_llm_json` must stay — they protect the recursive paths that can't use schemas. Only `_try_repair_json` (kb-only) becomes removable, and only *after* the flag is validated in staging.
|
||||
|
||||
**Consequences:**
|
||||
- Structured outputs is the tool for flat JSON; recursive decision-tree outputs are excluded by design. New flat-JSON `generate_json` callers can opt in via `schema=`; recursive ones should not.
|
||||
- `AI_KB_CONVERT_STRUCTURED_OUTPUT` must be smoke-tested against the live model (both target types) before production enablement. Open risk: whether Anthropic accepts optional (non-`required`) fields — if not, the schemas need every field in `required` with nullable types. The flag makes this fully reversible.
|
||||
- Deferred cleanup: once the flag is validated, remove only `_try_repair_json` from the kb_conversion Anthropic path; leave the fence-strippers.
|
||||
- Work lives on branch `feat/ai-structured-outputs` (commits `84a02a5`, `1388357`), based on `design/l1-workspace`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-13 — Session expiration policy: 3d idle / 14d absolute defaults + per-account override
|
||||
|
||||
**Context:** User report: "I login to ResolutionFlow and never have to log back in." Investigation found refresh tokens at `REFRESH_TOKEN_EXPIRE_DAYS=7` with JTI rotation (`security.py:36`) — every `/auth/refresh` minted a fresh 7-day window. Net effect: a sliding 7-day session with no absolute cap. Visit once a week, logged in forever. Acceptable for pilot but not for MSP buyers whose SOC2 / cyber-insurance auditors require enforced session timeouts. Required for the same Phase O launch readiness as the other gates already in flight.
|
||||
|
||||
**Decision:** Two-window model snapshotted into the refresh JWT at login. Defaults to Strict (3-day idle, 14-day absolute), bounded by env-var system min/max. Per-account override via two new `accounts` columns (NULL = use system default). Owner-only `GET/PATCH /accounts/me/security` endpoint with effective-value validation (partial-override case caught at the app layer because the DB CHECK can't see Settings). Sibling `POST /accounts/me/security/revoke-sessions` for `all|others`-scoped bulk revocation. Frontend: Strict/Standard/Custom presets, active-users list (name + email + last-login-ago), differentiated SessionExpiryToast (idle = warning amber with "Stay signed in" → `/auth/refresh`; absolute = info cyan, informational only), cyan info-tone banner on `/login?reason=session_expired`, auto-redirect after scope=all bulk-revoke. Error-detail taxonomy on the wire: `session_expired_idle`, `session_expired_absolute`, `invalid_refresh_token`. Grandfather path: legacy refresh tokens (no `auth_time` claim) get one free rotation under the new policy. Atomic-revoke-then-check on `/auth/refresh` so absolute-expired tokens can't be replayed.
|
||||
|
||||
8 commits on `feat/session-expiration-policy` branch (`92fa3bc` → `c7cd711`), ~1300 LoC backend + frontend including 28 backend tests. Plan + design review at `docs/plans/2026-05-13-session-expiration-policy.md` (initial design score 4/10 → final 9/10 via `/plan-design-review`; 7 design decisions locked).
|
||||
|
||||
**Rejected:**
|
||||
- **Idle-only or absolute-only enforcement.** Idle without absolute is the current broken state (sliding forever). Absolute without idle is too strict — kicks users out daily.
|
||||
- **Hard cutover on deploy (SECRET_KEY rotation).** Forces every pilot to log in again immediately; high support cost. Grandfather path is friendlier and adds ~50 lines of code.
|
||||
- **Distinguish `session_revoked_by_admin` from `invalid_refresh_token` on the wire** for users whose sessions were killed via bulk-revoke. Requires tracking revocation reason per `refresh_tokens` row. Not worth the complexity for v1 — affected users see they're logged out, same as any other revoke.
|
||||
- **Per-user device list with per-device revoke.** Refresh tokens don't carry device/user-agent metadata today. Account-wide bulk revoke covers the breach-response use case; per-device is a follow-up if pilots ask.
|
||||
- **"Loose" preset (90d).** Strict default suggests we shouldn't ship a one-click loose option. Owners who want a loose policy can use Custom and own the choice explicitly.
|
||||
- **Always-required `idle_minutes`+`absolute_minutes` (XOR-NULL invariant).** Forces owners who only want to override idle to also re-declare the absolute window, leaking the system default into account data. Partial overrides allowed; validated at the app layer against current defaults.
|
||||
- **Reveal-on-Custom UI for the minute inputs.** Hidden-by-default-reveal-on-radio shifts page layout when Custom is selected. Always-visible-but-disabled is more stable and previews the Custom interaction.
|
||||
- **Modal-stays-open-success-state for scope=all bulk-revoke.** User preferred auto-redirect-with-toast (more standard SaaS pattern); the toast acts as the success acknowledgment before /login loads.
|
||||
|
||||
**Consequences:**
|
||||
- "Logged in forever" is fixed. Every user sees a hard 14-day re-auth at minimum (3-day idle in practice for typical usage).
|
||||
- Account owners get a complete self-service surface for policy + bulk session control. New `/account/security` route, owner-gated.
|
||||
- Audit-log entries on both mutations: `account.session_policy_update` and `account.sessions_revoked_bulk`. SOC2-ready.
|
||||
- Frontend `idle_expires_at` + `absolute_expires_at` flow through the entire auth surface (`Token`, `OAuthCallbackResponse`, `authStore`, persistence). `useAuthSessionExpiry` hook is the single source for "is the session about to end."
|
||||
- Future improvements (filed as follow-ups in plan §9): per-user device list (requires `refresh_tokens.last_used_at` column), super-admin global ceiling UI, per-user policy. None block current shipping.
|
||||
- Cyan info-tone banner on `/login` is the first of its kind in the app; sets precedent for future neutral system messages.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-07 — Per-email allowlist (`INTERNAL_TESTER_EMAILS`) for self-serve soft cutover
|
||||
|
||||
**Context:** Phase O Task 46 ("internal validation pass") needed a way to exercise the full self-serve flow against the prod backend before flipping `SELF_SERVE_ENABLED=true` for everyone. The plan doc described the mechanism but the backend support was never built — flagged in `SESSION_LOG.md` as a code blocker. Stripe live-mode setup is also gated on having a working internal-tester path in prod test mode.
|
||||
|
||||
**Decision:** Comma-separated allowlist `INTERNAL_TESTER_EMAILS` parsed by a Pydantic field_validator into a normalized lowercase list. Two helpers on `Settings`: `is_internal_tester(email)` (case-insensitive membership check) and `is_self_serve_active_for(email)` (returns `SELF_SERVE_ENABLED OR is_internal_tester(email)`). Both endpoints that gate on the global flag now call the helper:
|
||||
- `/config/public` accepts optional auth via new `get_current_user_optional` dep; returns `self_serve_enabled=true` for allowlisted authenticated callers; anonymous calls always see the global flag.
|
||||
- `/auth/register` allows allowlisted emails to register without an invite code.
|
||||
|
||||
**Rejected:**
|
||||
- **Custom header `X-Internal-Tester-Email` for anonymous flows.** Spoofable. The auth/register-payload checks are sufficient because the user has to OWN the email to register or log in.
|
||||
- **Separate allowlists per surface (`INTERNAL_PRICING_TESTERS`, `INTERNAL_OAUTH_TESTERS`).** Premature splitting. The Phase O use case is "this small set of people can see the new flow"; one variable handles it. If finer granularity emerges, split then.
|
||||
- **Database table for the allowlist.** Env var matches the spec from the plan doc and fits the soft-cutover lifecycle — list is small, changes infrequently, lives alongside other deployment-time config.
|
||||
|
||||
**Consequences:**
|
||||
- Stripe internal validation can run end-to-end in prod test mode without flipping the global flag.
|
||||
- Anonymous callers always see the global flag — the allowlist never leaks via unauthenticated request content. Three regression tests in `test_config_public.py` enforce this.
|
||||
- `INTERNAL_TESTER_EMAILS` plumbed through `docker-compose.dev.yml` and documented in `backend/.env.example`. Railway prod env will need the same var set during Phase O cutover.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-07 — Reconcile plan tier taxonomy (rename `team` → `enterprise`, add `starter`)
|
||||
|
||||
**Context:** PR #162 left a real architectural gap. Marketing surface (PricingPage, Stripe products) was wired for `Starter / Pro / Enterprise` while backend was on `free / pro / team`. `plan_billing.plan` FK referenced `plan_limits.plan` so the `BillingPlan` schema's `Literal["pro", "starter", "team", "enterprise"]` could accept values that violated the FK. `plan_billing` was unseeded in dev, so no checkout could complete. `Subscription.plan.in_(["pro", "team"])` paid-plan checks wouldn't recognize `enterprise`. Self-serve cutover was blocked at the data layer.
|
||||
|
||||
**Decision:** Reconcile to a single taxonomy — backend slugs become `free / pro / starter / enterprise`, matching the marketing surface and Stripe products. Migration `4ce3e594cb87`:
|
||||
1. Defensive `UPDATE subscriptions SET plan='enterprise' WHERE plan='team'` (dev had zero such rows; safety for any prod stragglers).
|
||||
2. Rename the `plan_limits.plan='team'` row to `'enterprise'`.
|
||||
3. Insert a `starter` row with caps interpolated between free and pro: `max_trees=10`, `max_sessions=75`, `max_users=1`, `max_ai_builds_per_month=15`, no KB Accelerator, no custom branding, no priority support.
|
||||
|
||||
Code rename across schemas, `Subscription` paid-plan/`has_pro_entitlement` checks, admin endpoints, frontend `useSubscription.isPaidPlan`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched — that string means "shared with my account" and has nothing to do with the subscription tier.
|
||||
|
||||
New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match (`ResolutionFlow Starter / Pro / Enterprise`). Picks the active monthly recurring price for tiers that have one. Annual fields stay NULL by design — annual pricing is intentionally out of scope for the soft cutover ("want to be able to exit if necessary without breaching any terms").
|
||||
|
||||
**Rejected:**
|
||||
- **Map marketing names to existing slugs (Option A from the discussion).** Smallest diff but means PricingPage cards have to translate `enterprise` → `team` at render time, and "Starter" can't exist as a real backend tier — it'd have to be hidden or dropped. Kicks the can.
|
||||
- **Add `starter` only, keep `team` slug as cosmetic enterprise (Option C).** Mixed taxonomy across layers — slug-vs-display-name divergence guarantees confusion in 6 months. Compromise that's worse than either pure choice.
|
||||
- **Annual pricing in this iteration.** User's explicit constraint: skip annual to keep exit-flexibility. Schema columns (`annual_price_cents`, `stripe_annual_price_id`) preserved as nullable for future re-enable.
|
||||
- **Auto-archive the existing Enterprise `$500/mo` test-mode price.** Done manually via Stripe MCP after un-setting the product's `default_price` first. Spec says Enterprise is sales-led with no catalog price.
|
||||
|
||||
**Consequences:**
|
||||
- `plan_billing` table is now seedable and seeded. Test-mode `plan_billing` populated for all 3 tiers via `sync_stripe_plan_ids.py`. Live mode runs the same script after manual Dashboard setup of products + prices.
|
||||
- New consumers of `Subscription.plan` literal must use `("free", "pro", "starter", "enterprise")`. Three call sites already updated. Backend-wide grep is the safety net for new ones.
|
||||
- `Subscription.is_paid` and `has_pro_entitlement` now include `starter` — Starter is a paid tier with a real $19.99/mo price.
|
||||
- 86/86 passing across the subscription/billing/plan/invite/admin sweep after the rename.
|
||||
- Test fixtures: `conftest.py` plan_limits seed updated to the new taxonomy. `_seed_plan_limits` helper in `test_plans_public.py` is now a true upsert so tests can override `max_users` even when conftest seeded the canonical value.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-07 — Standardize backend Python on 3.12
|
||||
|
||||
**Context:** Runtime facts had drifted from docs. The backend Dockerfiles and running dev container were already on Python 3.12, GitHub CI had just been updated to 3.12, but project docs still said Python 3.11 and Gitea CI relied on the runner's ambient Python.
|
||||
|
||||
**Decision:** Treat Python 3.12 as the backend standard. Pin local pyenv via `.python-version` to 3.12.13, matching the current `python:3.12-slim` container patch level. Add explicit Python 3.12 setup to Gitea CI and keep GitHub CI on Python 3.12.
|
||||
|
||||
**Rejected:** Moving Docker/runtime back to Python 3.11. The application was already building and running on 3.12, so reverting the runtime would add churn without a product or dependency reason.
|
||||
|
||||
**Consequences:** Native backend work should use `backend/venv` created from Python 3.12.13. Future docs/CI/runtime changes should preserve Python 3.12 unless a deliberate upgrade decision is recorded.
|
||||
|
||||
## 2026-04-30 — Add `applied_pending` non-terminal status to suggested fixes
|
||||
|
||||
**Context:** The verifying banner forces a synchronous verdict — worked / didn't / partial — but a lot of real MSP fixes are async. Engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. With only the existing outcomes, the engineer either leaves the banner stale (eroding the verifying signal) or guesses wrong (corrupting outcome data). User flagged the gap directly. Today's `NudgeBanner` "Still checking" button just silences the nudge — it doesn't tell the system anything.
|
||||
|
||||
**Decision:** Add a fourth, non-terminal outcome `applied_pending`, parallel to `applied_partial`. Required `pending_reason` Text column stores the "what are you waiting on?" reason. Outcome endpoint allows pending → {success, failed, partial, dismissed} transitions; pending stamps `applied_at` but NOT `verified_at` (it's parked, not verified). Resolution-note generator frames the fix as provisional (no closure language); escalation-package generator surfaces pending verification as the leading hypothesis with a reference to what's being waited on. Frontend exposes the state via a new `PendingBanner` component (info-tone, mirrors `PartialBanner`) plus a "Waiting to verify…" overflow option in the verifying banner. `NudgeBanner` "Still checking" now records pending with a reason instead of just silencing.
|
||||
|
||||
**Rejected:**
|
||||
|
||||
- **Reuse `applied_partial`.** Semantically wrong — partial means "I did some of it." Pending means "I did all of it, just can't tell if it worked." Generators write different prose for each, and conflating them would lose the distinction in the customer-facing resolution note and the next-engineer escalation handoff.
|
||||
- **Add a `pending_reason` column without a new status.** The status field is what the dashboard, banner, and generators all branch on. Hiding pending state in a separate column would proliferate `IF pending_reason IS NOT NULL` checks across every consumer.
|
||||
- **Cross-session "Follow-ups" dashboard rollup in v1.** Per-session `PendingBanner` is the chat-anchored reminder. Add the dashboard surface only if engineers report losing track across multiple pending sessions in pilot use.
|
||||
- **Optional follow-up timer ("remind me in 30m").** Out of scope; nice-to-have but not the wedge.
|
||||
|
||||
**Consequences:**
|
||||
|
||||
- Engineers can park a fix honestly without losing the verifying signal. The state survives across sessions because it's persisted server-side.
|
||||
- `pending_reason` is preserved as audit trail when the engineer advances pending → success/failed/dismissed; it is not auto-cleared. Intentional — it tells the next reader "we waited for X, then it worked."
|
||||
- New consumers of `FixStatus` must handle the `applied_pending` case. Currently three: the banner derivation in `AssistantChatPage`, the resolution-note generator, and the escalation-package generator. All three updated in this change.
|
||||
- Migration `c0f3a4b7e91d` is reversible — downgrade rewrites pending rows back to `applied_partial` and copies `pending_reason` into `partial_notes` if the partial slot was empty, then drops the column.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-30 — Allow `escalated_to_id` to send chat messages in claimed sessions
|
||||
|
||||
**Context:** During browser QA, clicking "Get AI analysis" on the magic-moment screen returned `POST /ai-sessions/{id}/chat → 400`. The senior tech who claimed the session is stored as `escalated_to_id` on `AISession`, not `user_id` (which remains the junior who created the session). `unified_chat_service.send_chat_message` queried `WHERE ai_sessions.user_id = :user_id`, so the senior's ID never matched and the endpoint rejected the request.
|
||||
|
||||
**Decision:** Extend the ownership check in `send_chat_message` to `OR ai_sessions.escalated_to_id = :user_id` using SQLAlchemy `or_()`. This is the minimal, correct fix: the session model already has a semantically valid "also owns" field for the claiming senior; extending the WHERE clause makes that ownership real.
|
||||
|
||||
**Rejected:**
|
||||
|
||||
- **Transfer `user_id` to the senior on claim.** Breaks the audit trail — `user_id` is the originating engineer throughout the session lifecycle. Any query scoped to "sessions this engineer worked on" would silently lose the junior's history.
|
||||
- **A separate `can_send_message` service method.** Adds indirection with no benefit for v1. One `or_()` line in the existing query is sufficient.
|
||||
- **Checking a role/permission flag instead.** Role gating (engineer/admin) already happens at the claim endpoint. The chat-send check is about session ownership, not role. Mixing the two concerns would be confusing.
|
||||
|
||||
**Consequences:**
|
||||
- Seniors can send AI briefings and continue chat work in sessions they have claimed. Core escalation pickup flow unblocked.
|
||||
- Any future caller of `send_chat_message` should be aware that "user_id or escalated_to_id" is the ownership rule. The service-level check is the single enforcement point.
|
||||
- `user_id` remains the originating engineer for all audit, history, and analytics queries. No data migration needed.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-29 — Consolidate the three per-escalation AI calls into one structured generation
|
||||
|
||||
**Context:** A single user-initiated escalation currently triggers three separate Sonnet calls, all summarizing the same source material (session state, steps taken, "what we know") from slightly different angles:
|
||||
|
||||
1. `_build_escalation_package_enhanced` — runs in the background `enrich_escalation_async` task, builds a rich JSON payload that's saved to `ai_session.escalation_package`.
|
||||
2. `_generate_ai_assessment` — also background, returns the magic-moment screen fields (`likely_cause`, `suggested_steps[]`, `confidence`).
|
||||
3. `generate_status_update` — engineer-triggered when they click "Ticket Notes" / "Client Update" / "Email Draft" in the conclude modal, generates audience-specific PSA prose.
|
||||
|
||||
The user surfaced the smell: the engineer is *typically* generating a status update during the escalate flow, so the AI assessment work is being done twice with overlapping context and the engineer's PSA prose is being thrown away. Live test on 2026-04-29 also showed that bumping the assessment timeout 15s → 45s did NOT fix the empty-placeholder bug — meaning the architectural smell is also a demo blocker.
|
||||
|
||||
**Decision:** ONE structured AI call per escalation that produces a single payload covering both the magic-moment screen's diagnostic fields AND the PSA-ready prose. Persist to `SessionHandoff`. The conclude modal's "Ticket Notes" button reads from the saved prose instead of calling the model. "Client Update" and "Email Draft" buttons trigger a cheap Haiku transformation over the saved prose (tone shift only, not a re-summarization).
|
||||
|
||||
Proposed payload shape (final form decided during implementation):
|
||||
|
||||
```json
|
||||
{
|
||||
"summary_prose": "<PSA-flavored ticket-notes paragraph>",
|
||||
"what_we_know": ["<one-liner>"],
|
||||
"likely_cause": "<one sentence>",
|
||||
"suggested_steps": ["<short step>"],
|
||||
"confidence": "low | medium | high",
|
||||
"audience_variants": {"client_update": null, "email_draft": null}
|
||||
}
|
||||
```
|
||||
|
||||
`audience_variants` filled lazily on first user request, cached.
|
||||
|
||||
**Rejected:**
|
||||
|
||||
- **Just bumping the timeout further.** Already tried 5s → 15s → 45s. The architectural redundancy is the real cost — even if Sonnet completed reliably, three calls per escalation is wasteful and creates three places where state can diverge.
|
||||
- **Reusing the engineer's status update content as the AI assessment.** User's first instinct, but: status updates aren't always generated (engineer has to click), they're audience-specific (so you'd pick which one to copy), and they're prose without the structured fields the magic-moment screen needs. The right consolidation is the OTHER direction — generate ONE structured payload that the status-update buttons consume.
|
||||
- **Switching the assessment to Haiku for speed.** Faster but solves only the latency symptom, not the redundancy. Doesn't help the conclude modal's status-update buttons.
|
||||
|
||||
**Consequences:**
|
||||
|
||||
- Magic-moment screen populates in ~5s instead of 25s+ (work happens in the foreground escalate path, not in a background task that races with the senior's pickup).
|
||||
- Token spend per escalation drops by ~60% — one Sonnet call replaces two; the third (audience variants) becomes Haiku.
|
||||
- Engineer's "Ticket Notes" button is instant — no model round-trip.
|
||||
- Schema enforcement matters. The current `_generate_ai_assessment` returns freeform prose that the frontend stuffs into `assessment_text` because the structured fields aren't reliably parseable. The new call must use Anthropic's structured output / tool-use to enforce the schema.
|
||||
- Migration concern: `ai_session.escalation_package` JSON column has live data on existing sessions. Keep it READABLE for backward compatibility; just stop *writing* the enhanced payload from `enrich_escalation_async`. If downstream queue summaries depend on it, dual-write the basic snapshot.
|
||||
- Test fixtures (`test_handoff_manager.py`, `test_session_handoffs_api.py`) currently stub `_generate_ai_assessment` via `AsyncMock`. Updating the stubs is part of the rename.
|
||||
- The frontend SSE assessment-ready subscription (added in `0f00ee5`) stays as-is — it just listens for the new event payload.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-28 — Tag the task-lane state with an owner chatId
|
||||
|
||||
**Context:** A recurring bug — every time the user returned to test escalation work, creating a new session would flash the previous session's task-lane data (questions, actions, "Tasks" pill counts) before the new session's AI response landed. The first attempt to fix it (`8914391`) added initializer-time guards (`incomingPrefill || isPickup`) that skipped the sessionStorage restore on mount. That covered exactly two entry paths and missed every other case: in-place URL navigation, mid-flight pickup, HMR re-runs, and the gap between `setActiveChatId(B)` and the AI response that finally populates B's questions/actions. The persistence effect made it worse by writing `{chatId: activeChatId, questions: activeQuestions}` — at any moment where activeChatId had flipped before the questions were updated, sessionStorage was stamped with `{chatId: B, questions: [A's data]}` and a subsequent restore would happily render A's data for B.
|
||||
|
||||
The root cause was that `activeQuestions` / `activeActions` / `showTaskLane` were three independent state slices implicitly assumed to be in sync with `activeChatId`. The synchronization was by convention, not by structure. Every code path that mutated them had to remember to call `resetSessionDerivedState` first; missing one created stale UI.
|
||||
|
||||
**Decision:** Add a `taskLaneOwnerChatId` state that records *which chatId the in-memory questions/actions belong to*, set at every site that populates them (sendPrefill, selectChat, handleSend, handleTaskSubmit, handleResumeNew, refreshFacts, handleApplyFix), cleared in `resetSessionDerivedState`. The persistence effect writes ownerChatId as the chatId tag. Render is gated on `taskLaneOwnerChatId === activeChatId` and ANDed into all three render conditions (toolbar Tasks button, narrow-viewport floating drawer, main side panel). The mount-time `skipTaskLaneRestore` guard stays as belt-and-braces for the prefill/pickup entry-flash window, which the owner-gate alone doesn't cover.
|
||||
|
||||
**Rejected:**
|
||||
- **More entry-path guards.** That's whack-a-mole — the next path nobody anticipated will reproduce the bug. The owner-gate makes the bug structurally impossible regardless of which path triggers it.
|
||||
- **Combining the four state slices into a single tagged object.** Cleaner long-term but a bigger refactor with more touch points. The owner-tracking approach gets the structural guarantee with a minimal diff and keeps the existing setState patterns.
|
||||
- **Inlining the comparison at every render site.** Works but proliferates the comparison; one named derived value (`taskLaneIsForActiveChat`) reads better and groups the gate with the persistence-effect / state declarations as a named concept.
|
||||
|
||||
**Consequences:**
|
||||
- Stale task-lane data is structurally unable to display. The lane is hidden during any window where `ownerChatId !== activeChatId`, no matter what mutation path got you there.
|
||||
- Adding new sites that populate `activeQuestions` / `activeActions` requires also setting `taskLaneOwnerChatId`. The pattern is documented in the commit message and visible in every existing populate site as a paired call.
|
||||
- The mount-time `skipTaskLaneRestore` guard is now redundant in steady-state but kept for the few-hundred-ms flash window between component mount and the first sendPrefill / selectChat effect. Deleting it would re-introduce a (smaller) flash without strong reason.
|
||||
- Future task-lane state slices (e.g. `facts`, `activeFix`) follow the same pattern: gate their visibility on the owner check via the existing render conditions. Tagging more slices with their own `*OwnerChatId` is a future refactor if the slices diverge.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-24 — Adopt dual-agent handoff system (`.ai/` + `CLAUDE.md` + `AGENTS.md`)
|
||||
|
||||
**Context:** Claude Code hits session and weekly usage limits. Work stalls when the primary agent is locked out. Needed a structured way for OpenAI Codex to resume where Claude left off without losing architectural truth or drifting across sessions.
|
||||
|
||||
**Decision:** Split the old CLAUDE.md into `.ai/PROJECT_CONTEXT.md` (stable repo truth), agent-specific root files (`CLAUDE.md`, `AGENTS.md`) with a shared protocol block, and a small handoff toolkit (`CURRENT_TASK.md`, `HANDOFF.md`, `TODO.md`, `DECISIONS.md`, `SESSION_LOG.md`, `README.md`). Previous CLAUDE.md snapshotted in commit `e110fed` before the migration.
|
||||
|
||||
**Rejected:**
|
||||
- Single symlinked CLAUDE.md/AGENTS.md — diverges silently, hides agent-specific tooling differences.
|
||||
- Putting GitNexus/gstack content in AGENTS.md — Codex doesn't have those tools; would mislead the resume agent.
|
||||
- Keeping the old CLAUDE.md as-is and adding AGENTS.md alongside it — duplicated truth, drift guaranteed.
|
||||
|
||||
**Consequences:**
|
||||
- First read for either agent: `.ai/PROJECT_CONTEXT.md` + `.ai/CURRENT_TASK.md` + `.ai/HANDOFF.md`.
|
||||
- Architectural changes in the repo require updating PROJECT_CONTEXT.md, not the root agent files.
|
||||
- Git trailers differ per agent (`Claude Opus 4.7` vs `Codex`) — preserved in each root file.
|
||||
- Legacy `SESSION-HANDOFF.md` deleted in the same commit; superseded by `.ai/HANDOFF.md`.
|
||||
106
.ai/HANDOFF.md
Normal file
106
.ai/HANDOFF.md
Normal file
@@ -0,0 +1,106 @@
|
||||
<!-- Keep under ~2K tokens. Old handoffs live in SESSION_LOG.md. Do not let this file accumulate history. -->
|
||||
|
||||
# HANDOFF.md
|
||||
|
||||
**Last updated:** 2026-05-30
|
||||
|
||||
**Active task:** Executing the **L1 AI Tree Builder Phase 2A** plan
|
||||
(`docs/superpowers/plans/2026-05-29-l1-ai-tree-builder-phase-2a.md`, 19 tasks) via
|
||||
subagent-driven-development on branch `feat/l1-ai-tree-builder-phase-2a`
|
||||
(branched from `main` @ `87236b5`; **not pushed**, `main` untouched).
|
||||
|
||||
## ⚠️ Tooling note (read first — why this session stopped at Task 16)
|
||||
The harness's **Bash output channel became intermittently unreliable** — returning
|
||||
stale/cached output (a Bash command that wrote `/tmp/perm.txt` instead returned a
|
||||
PRIOR command's `/tmp/vc.txt` content; a `cat` returned the wrong commit SHA). The
|
||||
Write/Edit channel stayed reliable; Read mostly reliable but occasionally served a
|
||||
stale temp file. Work stopped at Task 16 because wiring a new route/nav requires
|
||||
accurately reading `router.tsx` + `AccountSettingsPage.tsx` then editing them, and
|
||||
read-then-edit against stale reads is exactly what produced the broken Tasks 14–15
|
||||
earlier this session. **On resume: confirm the shell is reliable first** — write a
|
||||
unique sentinel to a file and read it back; cross-check any Read against a fresh
|
||||
`grep`; never commit without a sentinel-wrapped `tsc -b`/pytest verification whose
|
||||
unique sentinel you can see in the same output.
|
||||
|
||||
Earlier-this-session gotcha that cost ~an hour: pytest `-p no:cov` conflicts with the
|
||||
`--cov` baked into `pytest.ini` addopts → pytest exits before running → `&& echo PASS`
|
||||
chains mislabel. Always use `--override-ini="addopts="`, never `-p no:cov`.
|
||||
|
||||
Backend test invocation that works:
|
||||
`docker exec resolutionflow_backend pytest <path> --override-ini="addopts=" -q`
|
||||
Do **NOT** use `-p no:cov` — `pytest.ini` bakes `--cov` into `addopts`; disabling the
|
||||
cov plugin makes `--cov` unrecognized so pytest exits before running, silently turning
|
||||
`&& echo PASS || echo FAIL` chains into false FAILs (this cost ~an hour of confusion).
|
||||
Frontend gate via file-redirect:
|
||||
`docker exec -w /app resolutionflow_frontend sh -c 'npx tsc -b > /app/_o.txt 2>&1; echo EXIT=$? >> /app/_o.txt'`
|
||||
then Read `frontend/_o.txt` (frontend is bind-mounted at /app).
|
||||
|
||||
## Status: Tasks 1–15 DONE & committed. Tasks 16–19 remain (all frontend + final).
|
||||
|
||||
**Backend (Tasks 1–12)** — 17 commits `16b9abf`…`04b5511` + handoff `fdac72e`.
|
||||
Last full run: **114 passed** across all 11 Phase 2A backend test files. 3 alembic
|
||||
migrations applied; head `1fd88a68b145`. Shipped: `ai_build` session kind;
|
||||
`accounts.enabled_l1_categories`; `FlowProposal.l1_session_id` (+ nullable
|
||||
source_session_id + exactly-one CHECK + schema made optional); `l1_category_service`;
|
||||
`ai_tree_builder` (constrained gen, validate, depth cap, `normalize_walked_path`,
|
||||
**skips `meta` entries**); `match_or_build` (bands; flow_id→str); session-service
|
||||
`start_ai_build_session`/`advance_ai_build` (stores `node_text`)/flywheel capture in
|
||||
`resolve`/engineer notify in `escalate`; `l1.session.escalated` notification (+ link
|
||||
`/escalations` + `_resolve_recipients` honors explicit empty list); API
|
||||
`/l1/intake` (dispatch; build seeds hidden `{"node_type":"meta","category":...}`
|
||||
walked_path entry), `POST /l1/sessions/{id}/next-node`, `GET /l1/escalations`,
|
||||
`GET|PATCH /accounts/me/l1-categories`, `require_account_owner_or_admin` dep.
|
||||
|
||||
**Frontend (Tasks 13–15) — committed; whole-project `tsc -b` + eslint clean. VERIFIED HEAD `076a9ec`, tree clean.**
|
||||
- `03e8748` Task 13 — `types/l1.ts` (+ai_build, IntakeOutcome/Result, NearMiss, TreeNode,
|
||||
NextNodeRequest/Result, L1Categories) + `api/l1.ts` (intake→IntakeResult; nextNode,
|
||||
escalations, getCategories, setCategories). nextNode body carries `node_text`.
|
||||
- Tasks 14/15 took THREE commits because the flaky shell caused two broken commits
|
||||
(`df7150f`, `f483196` had missing-export/props errors; `ad9c4c8` was committed with
|
||||
TSC_EXIT=2 because I batched the commit with its own failing verification). The REAL
|
||||
working fix is **`076a9ec`** — confirmed via single-value commands: committed
|
||||
`L1WalkTreeVariant.tsx` has `advanceNode` (grep -c = 3), committed `L1Dashboard.tsx`
|
||||
has `useSuggestedFlow` (= 2); and a sentinel-wrapped `npx tsc -b` returned TSC=0,
|
||||
eslint=0 on the on-disk files before commit. What landed:
|
||||
- `L1Dashboard.tsx`: outcome dispatch on the REAL page (matched/build→walker;
|
||||
suggest→use-flow/build-new; out_of_scope→escalate-without-walk). Original
|
||||
PageMeta/greeting/inputs/open-tickets layout preserved.
|
||||
- `L1WalkTreeVariant.tsx`: real props `{session,onSessionUpdate,onDone}` +
|
||||
ResolveModal/EscalateModal + header + transcript sidebar kept; added ai_build branch
|
||||
that walks nodes via /next-node (passes node_text), disclaimer banner (`bg-warning/10`
|
||||
— NOTE: `*-dim` tokens are NOT `--color-*-dim`; use `/10` opacity), terminal→modals.
|
||||
flow/proposal keep the Phase-1 synthetic path.
|
||||
- `L1WalkPage.tsx` unchanged (already routes ai_build → tree variant).
|
||||
NOT browser-verified (chromium can't launch here).
|
||||
- **SHELL DISCIPLINE for resume:** single-value Bash commands (`grep -c`, `wc -l`,
|
||||
`git rev-parse --short`, `git log -1 --format=%s`) are RELIABLE; multi-line
|
||||
`{ echo; … } > file` blocks get GARBLED/interleaved. NEVER batch a commit with its
|
||||
own verification — verify in a separate step and READ the result before committing.
|
||||
|
||||
## Resume point — Tasks 16–19
|
||||
|
||||
16. **`pages/account/L1CategoriesPage.tsx`** (does NOT exist yet) — checkbox list of
|
||||
`available` toggling `enabled` via `l1Api.getCategories/setCategories`; read-only
|
||||
hard-floor list. Register lazy route under the `account` children in `router.tsx`
|
||||
(the L1CategoriesPage import is NOT yet there — verify) and add a link card in
|
||||
`AccountSettingsPage.tsx` (AccountLayout has no sidebar nav — see CLAUDE.md
|
||||
"Account sub-page"). Gate visibility to owner/admin via `usePermissions`.
|
||||
17. **`ProposalDetail.tsx`** — branch on `l1_session_id` to show an L1-source block
|
||||
instead of the `/pilot/{source_session_id}` link (add `l1_session_id?: string|null`
|
||||
to its proposal type). **`EscalationQueuePage.tsx`** — add an "L1 escalations"
|
||||
section via `l1Api.escalations()`.
|
||||
18. **`frontend/e2e/l1-workspace.spec.ts`** — network-stubbed AI-build flow; rely on CI
|
||||
to run it (chromium can't launch here).
|
||||
19. **Final:** full backend suite + `tsc -b`/`npm run lint`/`npm run build`; migration
|
||||
downgrade/upgrade roundtrip (head `1fd88a68b145`, down 3); push branch + open PR to
|
||||
`main` listing deferred items (KB grounding/connectors, PSA reassign, escalation
|
||||
package, AI chat handoff, proposal-matching). Then run requesting-code-review +
|
||||
finishing-a-development-branch per the subagent-driven-development skill.
|
||||
|
||||
**Working tree:** clean except this HANDOFF.md edit (committing now). Temp `_*.txt`
|
||||
files under `frontend/` were scratch — delete any that remain.
|
||||
|
||||
## Carry-forward (Phase O — separate, user-side, gated on EIN)
|
||||
Phase O self-serve cutover (Stripe live-mode, apex DNS, Railway prod env, flag flip)
|
||||
remains the prior active task; all code blockers closed, blocked on user's EIN. Not
|
||||
touched this session.
|
||||
263
.ai/PROJECT_CONTEXT.md
Normal file
263
.ai/PROJECT_CONTEXT.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# PROJECT_CONTEXT.md — ResolutionFlow
|
||||
|
||||
> SaaS troubleshooting platform for MSPs. Stable architectural truth. Updated only when the repo's shape changes.
|
||||
|
||||
---
|
||||
|
||||
## Product & naming
|
||||
|
||||
Canonical product name is **ResolutionFlow**. `patherly` is the legacy internal name — still present in DB name (`patherly` on Railway, `resolutionflow` locally), some Railway service names, and historical paths. Treat as aliases, not canonical. Docker containers are `resolutionflow_*`.
|
||||
|
||||
**User terminology:** "Flows" (not Trees), "Projects" (not Procedures), "Solutions Library" (not Step Library). Maintenance flows hidden from pilot UI (backend retains them). DB column `tree_type` values unchanged.
|
||||
|
||||
---
|
||||
|
||||
## SaaS shape
|
||||
|
||||
Multi-tenant by account. Primary role hierarchy: `super_admin` > `owner` > `engineer` > `viewer` — driven by `is_super_admin` + `account_role`. Never `role=='admin'` — use `is_super_admin`. Separate team-scoped admin gate exists orthogonally to the role hierarchy: `is_team_admin=True` + valid `team_id`, enforced by `require_team_admin`. Backend deps in `app/api/deps.py`: `get_current_active_user`, `require_engineer_or_admin`, `require_admin`, `require_account_owner`, `require_team_admin`. Frontend: `usePermissions()` hook. Central logic in `backend/app/core/permissions.py` + `frontend/src/hooks/usePermissions.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
Go-to-Market Validation (pre-PMF). Backend feature-complete (55+ endpoints, 100+ tests). Phase 0.5 FlowPilot telemetry baseline accruing. See [CURRENT-STATE.md](../CURRENT-STATE.md) for live status, [03-DEVELOPMENT-ROADMAP.md](../03-DEVELOPMENT-ROADMAP.md) for phases.
|
||||
|
||||
---
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Backend:** Python 3.12 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
|
||||
- **Frontend:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config in `index.css`), Zustand (immer + zundo), React Router v7, Axios (token-refresh interceptor), Lucide.
|
||||
- **DB:** PostgreSQL 16 (RLS enabled Phase 4, pgvector).
|
||||
|
||||
---
|
||||
|
||||
## Project structure
|
||||
|
||||
```
|
||||
resolutionflow/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # FastAPI entry
|
||||
│ │ ├── api/endpoints/ # 50+ routers registered in api/router.py — auth/admin, trees/sessions, AI/chat, scripts, integrations, uploads, accounts, FlowPilot, etc.
|
||||
│ │ ├── api/deps.py # auth deps (incl. require_team_admin)
|
||||
│ │ ├── api/router.py # registration
|
||||
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
|
||||
│ │ ├── models/ # SQLAlchemy (incl. FlowProposal)
|
||||
│ │ ├── schemas/ # Pydantic
|
||||
│ │ ├── services/psa/ # PSA provider pattern (base, connectwise/, autotask/, halopsa/, cache, encryption, exceptions, registry, ticket_context, types)
|
||||
│ │ ├── services/knowledge_flywheel.py + _scheduler.py
|
||||
│ │ └── services/knowledge_gap_service.py
|
||||
│ ├── alembic/versions/ # 001-070 sequential, then hex hash
|
||||
│ ├── scripts/ # seed_data, seed_trees, seed_test_users
|
||||
│ └── tests/ # pytest integration
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # Axios client + endpoint modules
|
||||
│ │ ├── components/ # common, layout, dashboard, tree-editor, session, procedural, procedural-editor, library, step-library, ui, flowpilot
|
||||
│ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts
|
||||
│ │ ├── pages/
|
||||
│ │ ├── store/ # Zustand (auth, treeEditor, proceduralEditor, userPreferences, scriptGeneratorStore)
|
||||
│ │ └── types/
|
||||
│ └── (Tailwind v4 CSS-only config in src/index.css)
|
||||
├── docs/plans/archive/ # pre-March 2026 plans
|
||||
├── docs/connectwise/ # CW API reference + best-practices guides
|
||||
├── docs/LESSONS-ARCHIVE.md # archived lessons (fixes in code)
|
||||
├── .ai/ # dual-agent handoff system (see .ai/README.md)
|
||||
├── CLAUDE.md · AGENTS.md · CURRENT-STATE.md · DESIGN-SYSTEM.md · DEV-ENV.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dev commands
|
||||
|
||||
Full setup in [DEV-ENV.md](../DEV-ENV.md) (host-agnostic, with homelab Proxmox reference topology). Day-to-day:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d # start stack
|
||||
cd backend && source venv/bin/activate && uvicorn app.main:app --reload
|
||||
cd frontend && npm run dev
|
||||
pytest --override-ini="addopts=" # tests (first time: CREATE DATABASE resolutionflow_test)
|
||||
cd backend && alembic upgrade head # migrate
|
||||
cd backend && alembic revision -m "desc" # manual migration (preferred per Lesson 77)
|
||||
cd backend && alembic revision --autogenerate -m "desc" # picks up drift; review carefully
|
||||
cd frontend && npm run build # stricter than tsc --noEmit — final check
|
||||
cd frontend && npx tsc -b # TS-only check when dist/ has EACCES
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
||||
python -m scripts.seed_trees # seed (from backend/)
|
||||
```
|
||||
|
||||
**Never pass `--rev-id`** to alembic — let it generate the hex hash.
|
||||
|
||||
**On hosts without native `python`/`node`/`npm`** (e.g. the code-server LXC), run commands inside the already-running containers instead:
|
||||
|
||||
```bash
|
||||
docker exec resolutionflow_backend pytest --override-ini="addopts="
|
||||
docker exec resolutionflow_backend alembic upgrade head
|
||||
docker exec -w /app resolutionflow_frontend npm run build
|
||||
docker exec -w /app resolutionflow_frontend npx tsc -b
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## URLs & test users
|
||||
|
||||
**URLs:** Frontend <http://localhost:5173>, backend <http://localhost:8000>, API docs <http://localhost:8000/api/docs>.
|
||||
|
||||
**Test users** (all password `TestPass123!`): `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com`, `engineer@resolutionflow.example.com`, `pro@resolutionflow.example.com`.
|
||||
|
||||
---
|
||||
|
||||
## CI
|
||||
|
||||
Gitea (`gitea.resolutionflow.com/chihlasm/resolutionflow/actions`). `gh` CLI works for issues/PRs on the GitHub mirror, but not CI runs.
|
||||
|
||||
---
|
||||
|
||||
## Deployment (Railway)
|
||||
|
||||
- **Prod:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend).
|
||||
- Auto-deploy: Gitea push → GitHub mirror → Railway follows GitHub `main`.
|
||||
- PR environments auto-created; need manual domain generation + `VITE_API_URL` with `https://` prefix.
|
||||
- `ALLOW_RAILWAY_ORIGINS=true` for `*.up.railway.app` CORS.
|
||||
- Shared Variables (Railway project-level) auto-propagate to PR envs — use for secrets like `ANTHROPIC_API_KEY`.
|
||||
- Super admin utility: `backend/make_superadmin_simple.py list|<email>`.
|
||||
|
||||
---
|
||||
|
||||
## ConnectWise PSA
|
||||
|
||||
Reference: `docs/connectwise/` — start with `CONNECTWISE-API-REFERENCE.md`, then the `best-practices/` guides. Extracted OpenAPI spec in `connectwise-psa-resolutionflow-reference.json` (670 endpoints, v2025.16); full spec in `connectwise-psa-openapi-full.json`.
|
||||
|
||||
- **Auth:** API Key (Base64 `companyId+publicKey:privateKey`) + `clientId` header every request. `clientId` is server-side (`CW_CLIENT_ID` in `config.py`) — identifies ResolutionFlow, not per-tenant. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
|
||||
- **Architecture:** `services/psa/` provider pattern — `PSAProvider` base, `ConnectWiseProvider` impl, `PsaProviderRegistry` for multi-PSA dispatch. Credentials encrypted at rest via `services/psa/encryption.py` (Fernet). Per-team credentials, never per-user. Endpoints in `api/endpoints/integrations.py`. In-memory TTL cache in `services/psa/cache.py`.
|
||||
- **Integration flows:** session docs → ticket notes (`POST /service/tickets/{id}/notes`, markdown supported); ticket context → FlowPilot; callbacks via `/system/callbacks` with HMAC verification.
|
||||
- **API rules:** pin version via Accept header `application/vnd.connectwise.com+json; version=2025.16`. Paginate ≤1000/page. Dynamic base URL via `/login/companyinfo/{companyId}`. Request minimal permissions (MY, not ALL).
|
||||
|
||||
---
|
||||
|
||||
## Coding standards
|
||||
|
||||
- **Python:** type hints everywhere, async/await for DB, Pydantic v2, `DateTime(timezone=True)` always.
|
||||
- **TypeScript:** interfaces for all data, `const` over `let`, functional components + hooks, shared logic in custom hooks.
|
||||
- **Git:** feature branch before committing (`git checkout -b feat/feature-name`). Commit format: `type: description` (feat/fix/refactor/docs/test/chore). Large features: commit per phase with `npm run build` validation. Push to Gitea — auto-mirrors to GitHub (`.gitea/workflows/mirror-to-github.yml`); never push GitHub directly. (Agent-specific `Co-Authored-By` trailers live in CLAUDE.md / AGENTS.md.)
|
||||
|
||||
**After shipping:** update [CURRENT-STATE.md](../CURRENT-STATE.md) + [03-DEVELOPMENT-ROADMAP.md](../03-DEVELOPMENT-ROADMAP.md), `gh issue close #N` for resolved issues, add lessons only for non-obvious traps (otherwise let the code speak).
|
||||
|
||||
---
|
||||
|
||||
## Common tasks
|
||||
|
||||
- **New endpoint:** `endpoints/` → `router.py` → `schemas/` → tests → frontend API client.
|
||||
- **New page:** `pages/` → route in `router.tsx` → nav in `AppLayout.tsx`.
|
||||
- **New public route:** top-level in `router.tsx` alongside `/login`, not inside `ProtectedRoute`.
|
||||
- **New frontend API module:** types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`.
|
||||
- **Schema change:** update model → `alembic revision -m "desc"` → review → `alembic upgrade head`.
|
||||
- **New `VITE_*` env var:** add as `ARG` + `ENV` in `frontend/Dockerfile` for Railway builds (Lesson 60 — Railway env vars are runtime-only, Vite bakes at build time).
|
||||
- **Account sub-page:** add route in `router.tsx` under `account` children + add link card in `AccountSettingsPage.tsx` — `AccountLayout` has NO sidebar nav.
|
||||
|
||||
---
|
||||
|
||||
## Design system
|
||||
|
||||
**Source of truth: [DESIGN-SYSTEM.md](../DESIGN-SYSTEM.md).** Read before any visual change.
|
||||
|
||||
- Flat high-contrast dark theme, Sentry/PostHog-inspired. **No** glass, backdrop blur, ambient orbs, gradient surfaces.
|
||||
- Accent **electric blue** (#60a5fa dark / #2563eb light) — ≤5% of UI, interactive elements only. Warning amber (#fbbf24), info cyan (#67e8f9), success green (#34d399), danger red (#f87171). Each with `-dim` at 10% opacity.
|
||||
- Backgrounds: `bg-sidebar` (#0e1016) → `bg-page` (#16181f) → `bg-card` (#1e2028) → `bg-elevated` (#2a2d38). Borders `border-default` / `border-hover`.
|
||||
- Text: `text-heading` → `text-primary` → `text-muted-foreground` → `text-muted`.
|
||||
- Fonts: IBM Plex Sans (body), Bricolage Grotesque (heading, 700 weight for logo), JetBrains Mono (code).
|
||||
- Logo: 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque. Assets in `brand-assets/`, `frontend/src/assets/brand/`, `frontend/public/icons/`.
|
||||
- Mockups: `docs/mockups/` (HTML).
|
||||
- **Deprecated — do not use:** glass-card, glass-stat, `bg-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange as accent, cyan as accent (cyan is info only).
|
||||
|
||||
---
|
||||
|
||||
## Frontend patterns
|
||||
|
||||
- **Component basics:** `cn()` from `@/lib/utils`, Lucide icons, `Modal.tsx` for modals (mobile-responsive `items-end sm:items-center` + `max-w-full sm:max-w-lg`).
|
||||
- **Types:** Create in `types/`, export from `types/index.ts`, `import type { T } from '@/types'`.
|
||||
- **Routing:** `getTreeNavigatePath()` / `getTreeEditorPath()` from `@/lib/routing`. Tree editor is `/trees/new`. All dashboard session clicks → `/pilot/:id` regardless of `session_type`.
|
||||
- **Lazy routes:** `lazyWithRetry` from `@/lib/lazyWithRetry.ts`, not `React.lazy` (auto-reload on stale chunks).
|
||||
- **Public pages:** raw `fetch()` with full URL, NOT `apiClient` (which requires auth tokens).
|
||||
- **Toast:** `toast.warning()` not `toast.warn()`. Import from `@/lib/toast` — methods: `success`, `error`, `warning`, `info`.
|
||||
- **Assistant chat:** uses local React `useState`, not Zustand. All three send paths (`handleSend`, `sendPrefill`, `handleResumeNew`) must call `setShowTaskLane(true)` when response has actions/questions.
|
||||
- **Chat backend wiring:** `aiSessionsApi.sendChatMessage` → `/ai-sessions/{id}/chat` → `unified_chat_service.py`. NOT `assistant_chat_service.py` (removed except retention settings).
|
||||
- **FlowPilot:** Actions live in page header (Resolve/Escalate/Share Update + overflow). `useBlocker` for active-session nav guard. "Pause & Leave" auto-pauses.
|
||||
- **AI markers:** `[QUESTIONS]`, `[ACTIONS]`, `[FORK]`, `[DELTA]...[/DELTA]` (editor), `[TREE_UPDATE]` (troubleshooting builder), `[STEPS_UPDATE]` (procedural builder), `[METADATA]`. Parsed in `unified_chat_service.py`; conversation history stores stripped `display_content`. If markers disappear: check system-prompt final reminder + per-user-message `[SYSTEM: ...]` injection in `_call_anthropic_cached()`.
|
||||
- **Image uploads:** paste/attach → Railway S3 via `uploadsApi.upload()` → resized by `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64 → Claude multimodal blocks. Max 3/msg. Images NOT stored in history.
|
||||
- **Async select-load-apply:** guard with a ref (pattern in `AssistantChatPage` `currentChatRef`). Update synchronously on every selection change; after every `await`, bail out if `ref.current !== thisId`.
|
||||
- **Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI`. Ghost nodes via `_suggestion: true`. Route actions via `settings.get_model_for_action()`.
|
||||
- **Script Builder:** `/script-builder`, chat-style. Backend `ScriptBuilderSession`, `script_builder_service.py`, endpoints `/scripts/builder/`. FlowPilot handoff via `action_type: "open_script_builder"` + `sessionStorage`.
|
||||
- **Intake form field schema:** `variable_name` + `field_type` (NOT `name` / `type`).
|
||||
- **Node field priority** (copilot, summaries): `title` → `question` → `description` → `content` → `label`.
|
||||
- **Procedural sessions auto-start** on page load (no intake/Start screen). Troubleshooting flows DO have a start screen.
|
||||
|
||||
---
|
||||
|
||||
## Critical lessons
|
||||
|
||||
> Lessons 1-40 archived to [docs/LESSONS-ARCHIVE.md](../docs/LESSONS-ARCHIVE.md) — fixes baked into the codebase. **Grep the archive when an error message or symptom is unfamiliar, or after two failed attempts at resolving an issue.** Don't pre-load for routine work.
|
||||
|
||||
### Backend / data
|
||||
|
||||
- **APScheduler interval jobs always `max_instances=1`** — without it, overlapping runs reprocess records (TOCTOU).
|
||||
- **`get_db` rolls back on exception** — never remove the `await session.rollback()`, or one failed request poisons the connection with `InFailedSQLTransaction` cascading.
|
||||
- **Startup routines on tenant-isolated tables must use `_admin_session_factory()`, not `get_db()`.** Phase 4 RLS has no `app.current_account_id` set at startup. `get_service_account_id` is safe (reads cached `app.state`).
|
||||
- **Backfill migrations adding `account_id`:** grep ALL `ModelClass(` sites in service code to verify `account_id=` is passed. SQLAlchemy accepts `None` silently — Phase 4 RLS WITH CHECK surfaces the problem at runtime as `InsufficientPrivilegeError: new row violates row-level security policy`.
|
||||
- **`tree_shares.account_id = tree.account_id`**, never `current_user.account_id`. A super_admin sharing another tenant's tree must produce the share in the tree owner's tenant, or it becomes invisible post-RLS.
|
||||
- **Global tables (no `account_id`, never in RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts`. Scan at class level — one `.py` file can hold multiple classes with different columns (e.g. `ScriptCategory` vs `ScriptTemplate`).
|
||||
- **`ai_sessions.status` is VARCHAR(30)** — fits `requesting_escalation` (23 chars). Migration `f0aad74ea51b` widened from 20.
|
||||
- **PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg** — cast to `int()` before Pydantic `dict[str, Any]`.
|
||||
- **Enhancement / branch_addition proposals need `modified_flow_data` via "Edit & Publish"** — backend 400 on direct approve. Only `new_flow` supports direct approve.
|
||||
- **Adding email types:** static async method on `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail the request).
|
||||
|
||||
### AI / FlowPilot
|
||||
|
||||
- **Anthropic SDK `max_retries=1`** — default of 2 can take 3× the timeout.
|
||||
- **Model tier routing:** `settings.get_model_for_action(action_type)`. Always alias form (`claude-sonnet-4-6`).
|
||||
- **FlowPilot must ask GUI-vs-script before suggesting either** when both are viable — see `FLOWPILOT_SYSTEM_PROMPT` in `flowpilot_engine.py`.
|
||||
- **Telemetry events to grep:** `anthropic.cache` (prompt-cache hit/create), `mcp.turn` (per-turn MCP availability), `mcp.fallback` (MCP silent-retry fired).
|
||||
- **Don't put literal payloads in system prompts.** Bit us twice in one day: a worked `[QUESTIONS]` example with literal "Outlook + jsmith" content, and a full DNS troubleshooting tree, both caused Claude to recite that content on unrelated tickets — the symptom looked like task-lane state leaking across chats. The fix is structural: every output example in a system prompt uses `<placeholder>` syntax (`{"text": "<one short, specific question>"}`), never literal field values. Real-looking format examples live in few-shot messages (separate file, separate code path), not system prompts. Guardrail: `tests/test_prompt_anti_parrot.py` scans every `*_PROMPT`/`*_SCHEMA`/`*_PROTOCOL`/`*_FORMAT` constant in `app/services/` and `app/core/`; CI fails when a marker block contains a literal JSON value or when a known leaked token (jsmith, DC01, ADSync, Dnscache, etc.) appears anywhere in a prompt.
|
||||
|
||||
### Frontend / UI
|
||||
|
||||
- **Flex height chain:** every ancestor from `app-shell` grid to React Flow canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`. Missing `flex` collapses to 0. Same rule for FlowPilot action bar and any tall scroller.
|
||||
- **React Flow CSS in Tailwind v4:** import in `index.css`, not component JS. Override dark theme via `--xy-*` CSS vars.
|
||||
- **`text-secondary` renders invisible on dark** — Tailwind v4 maps it to `--color-secondary` (a surface color). Use `text-muted-foreground` for readable secondary text. Avoid `text-muted` for body — labels only.
|
||||
- **`bg-accent` is electric blue — never for code/kbd.** Use `bg-white/[0.12] border border-white/[0.06]` for inline code, `bg-white/[0.08]` for kbd. Accent reserved for interactive elements.
|
||||
- **`landing.css` uses self-contained `--lp-*` vars** — never `var(--color-*)` theme tokens (they resolve incorrectly outside the app shell).
|
||||
- **Never `transition: all`** — list properties explicitly, or layout props animate and jank.
|
||||
- **Date range filter end dates:** `setHours(23, 59, 59, 999)` before sending, or the day's items are excluded. For string-based date inputs, append `T23:59:59.999Z`.
|
||||
- **TopBar search:** full bar `hidden sm:block`, icon button `sm:hidden` — both open CommandPalette.
|
||||
- **Hover pop-out cards:** scrim `pointer-events-none`, expanded card has its own click handler at `z-50`, dismiss via `onMouseLeave` on wrapper. Never put handlers on the scrim.
|
||||
- **`tsc -b` in Dockerfile is stricter than `tsc --noEmit`** — enforces `noUnusedLocals` / `noUnusedParameters` as hard errors. Check IDE yellow squiggles before pushing.
|
||||
- **Dashboard prefill auto-submits** via `useEffect` + `prefillHandledRef` guard — no double-enter.
|
||||
- **Global Axios 5xx interceptor fires before component `.catch()`** — fix optional-data endpoints at the source (return `[]` / `{}` on provider failure), not in the component.
|
||||
- **Playwright strict mode:** scope selectors to avoid sidebar/main ambiguity. Use `getByRole('heading', { name })` or `.animate-scale-in` locators, not bare `getByText()`.
|
||||
|
||||
### Env / infra
|
||||
|
||||
- **Node 20.19+ required** (Vite 7). `nvm use 20` or `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
|
||||
- **Railway backend service is `patherly`, DB name `railway`.** Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
|
||||
- **Railway Object Storage bucket `resolutionflow-uploads`.** Env vars `STORAGE_*`. boto3 in `storage_service.py`. Dockerfile needs Pillow + `libjpeg-dev` / `zlib1g-dev`.
|
||||
- **PostHog:** `PostHogProvider` + `posthog.init()` in `main.tsx`. Helpers in `lib/analytics.ts`. Env: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout.
|
||||
- **bun PATH on devserver01:** `BUN_INSTALL="$HOME/.bun"`, `PATH="$BUN_INSTALL/bin:$PATH"`. Playwright Chromium needs `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
|
||||
- **Full-stack change:** trace schema → endpoint → API client → hook → store → UI. Don't assume one end proves the other.
|
||||
- **Dev env** — see [DEV-ENV.md](../DEV-ENV.md) for current topology, `REPO_ROOT` requirement when compose runs inside a container, Vite `allowedHosts`, linuxserver.io `group_add` + custom-cont-init.d workaround, `docker compose up` no-op-on-unchanged-hash gotcha.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| Detailed status | [CURRENT-STATE.md](../CURRENT-STATE.md) |
|
||||
| Roadmap | [03-DEVELOPMENT-ROADMAP.md](../03-DEVELOPMENT-ROADMAP.md) |
|
||||
| Design system | [DESIGN-SYSTEM.md](../DESIGN-SYSTEM.md) |
|
||||
| Dev env | [DEV-ENV.md](../DEV-ENV.md) |
|
||||
| Archived lessons | [docs/LESSONS-ARCHIVE.md](../docs/LESSONS-ARCHIVE.md) |
|
||||
| ConnectWise API | `docs/connectwise/` |
|
||||
| GitHub issues | `gh issue list --state open` |
|
||||
| Local API docs | <http://localhost:8000/api/docs> |
|
||||
| Handoff system | [.ai/README.md](README.md) |
|
||||
42
.ai/README.md
Normal file
42
.ai/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# .ai/ — dual-agent handoff system
|
||||
|
||||
ResolutionFlow uses two coding agents: **Claude Code** (primary) and **OpenAI Codex** (resume when Claude hits session or weekly limits). This directory holds the shared state that lets either agent start a session with full context.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Holds | Written when | Read when |
|
||||
|---|---|---|---|
|
||||
| [PROJECT_CONTEXT.md](PROJECT_CONTEXT.md) | Stable repo truth: stack, structure, SaaS shape, ConnectWise, coding standards, frontend patterns, critical lessons | Only when the repo's shape changes | Every session start |
|
||||
| [CURRENT_TASK.md](CURRENT_TASK.md) | The single active task: goal, DoD, assumptions, out-of-scope | On task start; status updates during work | Every session start |
|
||||
| [HANDOFF.md](HANDOFF.md) | Exact resume point: branch, where you left off, next steps, blockers | On session end / context-window limit | Every session start (most important) |
|
||||
| [TODO.md](TODO.md) | Backlog of work NOT currently active | When deferring or queueing work | Only when `CURRENT_TASK.md` is `complete` |
|
||||
| [DECISIONS.md](DECISIONS.md) | Append-only architectural decision log | When an architectural choice is made | Skim top entries each session |
|
||||
| [SESSION_LOG.md](SESSION_LOG.md) | Append-only chronological history | On session end | Only when broader context is needed |
|
||||
|
||||
Agent-specific tooling lives at the repo root:
|
||||
- [../CLAUDE.md](../CLAUDE.md) — Claude Code's tooling (GitNexus, gstack slash commands, Claude trailer)
|
||||
- [../AGENTS.md](../AGENTS.md) — OpenAI Codex's tooling (grep/rg fallbacks, Codex trailer)
|
||||
|
||||
Both root files contain an **identical shared-protocol block**. If you edit one, edit the other.
|
||||
|
||||
## The handoff ritual
|
||||
|
||||
At session end (limit hit, task complete, or user stop): update `HANDOFF.md` to reflect the new resume point, update `CURRENT_TASK.md` status if it changed, append to `DECISIONS.md` if you made an architectural call, append a session entry to `SESSION_LOG.md`, and WIP-commit any dirty working tree with `wip(handoff): <one-line>` unless told otherwise. Don't push.
|
||||
|
||||
## How to invoke a resume
|
||||
|
||||
Tell the agent:
|
||||
|
||||
> Read CLAUDE.md (or AGENTS.md) and follow its instructions.
|
||||
|
||||
The agent will read its root file, which directs it to `.ai/PROJECT_CONTEXT.md`, `.ai/CURRENT_TASK.md`, and `.ai/HANDOFF.md` before doing anything else.
|
||||
|
||||
## Recovery
|
||||
|
||||
The previous monolithic CLAUDE.md is recoverable via:
|
||||
|
||||
```bash
|
||||
git show pre-ai-handoff:CLAUDE.md
|
||||
```
|
||||
|
||||
(Tag `pre-ai-handoff` on commit `e110fed` — the snapshot taken before this migration.)
|
||||
467
.ai/SESSION_LOG.md
Normal file
467
.ai/SESSION_LOG.md
Normal file
@@ -0,0 +1,467 @@
|
||||
# SESSION_LOG.md
|
||||
|
||||
> Append-only chronological record. Newest entries at the top. Skim when broader context is needed.
|
||||
> Entry format:
|
||||
>
|
||||
> ```
|
||||
> ## YYYY-MM-DD HH:MM <timezone> — <agent> — <one-line summary>
|
||||
> - What was accomplished
|
||||
> - What was left for next session
|
||||
> - Files touched
|
||||
> ```
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-14 ~04:00 UTC — Claude — PR #166 + #168 merged; dashboard CTA bug fixed; welcome step-2 PSA CTA reshaped
|
||||
|
||||
**Accomplished:**
|
||||
|
||||
- User reported the "Start a session" CTA on the dashboard onboarding card doing nothing after completing the welcome wizard. Root cause: `NextStepCard.tsx:80-82` had `ctaPath: '/'` and the card itself only renders on the dashboard at `/`. Clicking `<Link to="/">` while already on `/` is a react-router no-op. Same dead-link in `SetupChecklist.tsx` for the `ran_session` row.
|
||||
- Designed and built the fix collaboratively (user wanted scroll-to-input + visual pulse rather than auto-navigate to `/pilot` or just hiding the card):
|
||||
- Added `FOCUS_START_SESSION_EVENT = 'rf:focus-start-session'` window event exported from `StartSessionInput.tsx`. The component listens via `useEffect`, on dispatch calls `wrapperRef.current?.scrollIntoView({behavior:'smooth', block:'start'})`, focuses the textarea with `preventScroll:true` (so it doesn't fight the smooth scroll), and sets a 900ms `nudge` state that swaps the inner wrapper's `focus-within:` ring classes for a louder `ring-2 ring-[rgba(96,165,250,0.35)] shadow-[0_0_0_6px_rgba(96,165,250,0.12)]`. Added `scroll-mt-6` to the outer ref'd div so the input doesn't hug the very top edge.
|
||||
- `NextStepCard.tsx` — branched on `next.key === 'ran_session'`. Render a `<button>` that dispatches the event AND sets a new `locallyHidden` useState so the card disappears immediately on click (without calling the persisting `dismissOnboarding` API — that would kill all future onboarding nudges). All other CTAs keep the original `Link` element. Tests pass without changes (assertions only check text + testid).
|
||||
- `SetupChecklist.tsx` — same `ran_session` branch (the checklist had the same dead-link bug if the user expanded "Show all setup steps").
|
||||
- User then asked about the welcome wizard PSA flow — "is it supposed to take me to set up ConnectWise if I keep clicking next after picking it?" Read `WelcomeStep2.tsx`: the spec was intentionally "just pick what you use, we'll wire it up later" with a `text-xs text-muted-foreground` "Connect now →" link as the only credential-setup entry. The link was visually near-invisible AND had a bug: it was a `<Link to="/account/integrations">` that navigated WITHOUT calling `onboardingApi.updateStep`, so `primary_psa` was never persisted if the user clicked it.
|
||||
- Proposed three fix options; user picked option 2 (explicit two-button branch). Implemented in `WelcomeStep2.tsx`:
|
||||
- New `handleConnectNow` handler that calls `onboardingApi.updateStep({step:2, action:'complete', data:{primary_psa}})` then `navigate('/account/integrations')`. New `submitting === 'connect-now'` state value.
|
||||
- When `showConnectNow` (real PSA selected): action row renders `[Connect <PSA> now (primary)] [Connect later (secondary)] [Skip this step (tertiary)]`. Reused the old `welcome-step-2-connect-now` testid on the new primary button. "Connect later" reuses the `welcome-step-2-continue` testid + handleContinue. PSA label derived dynamically from `PSA_OPTIONS`.
|
||||
- When 'none' or no selection: original `[Continue] [Skip this step]` preserved.
|
||||
- Removed the import of `Link` from `react-router-dom` and the entire `showConnectNow && <Link>` block.
|
||||
- All existing tests pass unchanged (`tsc --noEmit` clean, locally; vitest blocked by root-owned `node_modules/.vite-temp` — same env issue noted previously; CI ran the suite green on the PR).
|
||||
- Committed in two logical commits onto current branch (`feat/session-expiration-policy`): `feat(welcome): two-button PSA CTA in step-2` (`dc88797`) and `docs: add architecture reports, public-landing routing plan, build-a-page tutorial, self-serve signup phase-2 design` (`e5b2624`). Pushed. PR #168 CI ran green across `CI/backend`, `CI/frontend`, `CI/e2e`. PR #166 merged first (HTTP 200), then PR #168 once CI cleared (HTTP 200). `main` now at `3a35121`.
|
||||
- Filed two issues for session leftovers:
|
||||
- **#171** — Test coverage for the new `welcome-step-2-connect-now` path (existing tests still pass but don't exercise the new save + redirect behavior).
|
||||
- **#172** — Repo hygiene: add `core.[0-9]*` and `**/.remember/` to `.gitignore`, delete the three 20MB core dumps + `docs/architecture/.remember/`.
|
||||
|
||||
**Left for next session:**
|
||||
|
||||
- Confirm with user whether the "bug-pending-capture" item from 2026-05-12 HANDOFF was one of the two fixes above (dashboard CTA dead-click, welcome step-2 ConnectWise confusion) or a third bug still pending. Likely covered, but worth asking.
|
||||
- Phase O cutover remains gated on EIN — check status of 2026-05-13 IRS.gov application.
|
||||
- Issues #171 and #172 sitting in the backlog when there's time.
|
||||
|
||||
**Files touched (all merged to main via PR #168 `3a35121` and PR #166 `fe0e692`):**
|
||||
|
||||
- `frontend/src/components/dashboard/StartSessionInput.tsx` (event listener, scroll/focus/nudge ring)
|
||||
- `frontend/src/components/dashboard/NextStepCard.tsx` (event-dispatch button branch, `locallyHidden` state)
|
||||
- `frontend/src/components/dashboard/SetupChecklist.tsx` (event-dispatch button branch for `ran_session` row)
|
||||
- `frontend/src/pages/welcome/WelcomeStep2.tsx` (two-button PSA CTA + `handleConnectNow`)
|
||||
- `docs/plans/2026-05-13-public-landing-routing-refactor.md` (new, untouched by Claude this session — user-authored)
|
||||
- `docs/architecture/{god-node-map-2026-05-06.canvas, god-node-report-2026-05-06.md, workflows-analysis.html, workflows.html, workflows.json}` (new, generated reports)
|
||||
- `docs/tutorials/build-a-page.md` (new, user-authored)
|
||||
- `abc-feat-self-serve-signup-phase-2-design-20260507-112020.md` (root, office-hours design doc — committed as-is from prior local state)
|
||||
- `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md` (this update)
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-12 ~06:30 UTC — Claude — PR #167 (site-admin bootstrap script) merged; bug pending capture
|
||||
|
||||
**Accomplished:**
|
||||
|
||||
- User reported being unable to log into prod with `admin@resolutionflow.example.com` — that's the dev seed email (`.example.com` is a documentation TLD), only present in dev. Prod has no admin user at all because `seed_test_users.py` doesn't run in prod, self-serve is still gated, and even when it flips on signup creates `owner` roles not `super_admin`.
|
||||
- Designed and built `backend/scripts/create_site_admin.py` — idempotent CLI script for creating or promoting a site-wide super-admin on any environment. Three modes: `--send-reset` (mails reset link), `--print-reset` (stdout reset link), `--promote-only` (promote existing user without creating). Creates an `Account` first, then a `User` with `is_super_admin=true`, `account_role='owner'`, `email_verified_at` stamped at creation, `password_hash=NULL` (forces the reset flow on first login). Uses `ADMIN_DATABASE_URL` (BYPASSRLS) — required because `users` is RLS-enabled and the script has no tenant context at bootstrap. Reset token mints via existing `create_password_reset_token` helper, hashes JTI into `password_reset_tokens` row matching the `/auth/password/forgot` shape.
|
||||
- Smoke-tested all three paths in the dev container before pushing: fresh create on a new email (Account + User + reset URL emitted), idempotent re-run on same email (SKIP message + new reset URL), `--promote-only` on a user with `password_hash=NULL` (promotes + issues reset). Cleaned up the dev test row + account afterwards.
|
||||
- Initial bug: had `used: false` in the `password_reset_tokens` INSERT — actual column is `used_at` (nullable timestamp, NULL means "not used"). Fixed before pushing.
|
||||
- PR #167 opened, CI green, squash-merged into main as `e50a215`. Remote branch `feat/site-admin-script` auto-deleted.
|
||||
- User confirmed end-to-end success on prod via `railway ssh --service=<backend>` then `python -m scripts.create_site_admin ...` ("we're good now"). Specific service name not captured. First prod super-admin row now exists in the prod DB.
|
||||
- Stripe live-mode activation block traced to EIN, not code (user does not yet have an EIN for ResolutionFlow, LLC). Applying via IRS.gov 2026-05-13. Mailing-address decision: home address into Stripe's **private** business profile temporarily so live-mode isn't blocked on the P.O. Box; public `ContactPage`/`PoliciesPage` stays "available on request". Stripe accepts address update later without re-verification.
|
||||
- PR #166 (docs handoff for PR #164/#165 merges + EIN decision) still open from earlier in this same session — was never merged. This entry rebases the docs branch onto current main (which now includes PR #167) and adds the PR #167 narrative + bug-pending state so a fresh session has the full picture in one merge.
|
||||
- User reported finding a bug in a UI surface but did not provide details — planning to send a screenshot via the VS Code extension GUI in the next session (CLI is unreliable for them). Next session: ask for the screenshot at session start, then triage.
|
||||
|
||||
**Left for next session:**
|
||||
|
||||
- Get the bug screenshot from the user, triage, fix or scope.
|
||||
- Otherwise everything that was on the prior entry's left-for-next-session still stands: EIN application Tuesday 2026-05-13, then Stripe live-mode setup, apex DNS at Namecheap, Railway prod env vars, internal validation, flag flip.
|
||||
|
||||
**Files touched (all merged to main via PR #167 squash `e50a215`):** `backend/scripts/create_site_admin.py` (new, ~270 lines including docstring). Plus `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md` on `docs/handoff-pr-165-merge` (PR #166, awaiting merge).
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-12 05:30 UTC — Claude — PR #164 + #165 merged; Stripe activation reported blocked
|
||||
|
||||
**Accomplished:**
|
||||
|
||||
- Resumed from compacted context. Confirmed PR #164 (`feat/billing-plan-taxonomy`, head `2c9f5e9`) was already CI-green at session start and squash-merged into main as `3f04911` earlier in the session (occurred pre-compaction; reflected in the prior HANDOFF revision). Branch auto-deleted on remote.
|
||||
- User raised the legal/contact pages question in conversation. Verified existing state of `frontend/src/pages/{PrivacyPage,TermsPage}.tsx` — both already contain real, dated content (last updated 2026-03-21) but are SPA-rendered. Discussed Stripe's site-review needs with the user and agreed to build a consolidated Customer Policies page plus a Contact page (now that the user has a business phone number) plus a Promotions stub to satisfy Policies §6.2 cross-reference. User authorized the work.
|
||||
- Built PR #165 (`feat/stripe-legal-pages`, head `545b2ad`):
|
||||
- **`/policies` — `frontend/src/pages/PoliciesPage.tsx`** (new). Consolidated Customer Policies doc, 8 sections with anchor IDs per subsection so Stripe (or a support email) can deep-link: customer service contact (with phone (470) 949-4131), return policy (n/a — SaaS), refund / dispute policy, cancellation policy, U.S. legal and export restrictions (Georgia governing law, OFAC / BIS compliance, sanctioned-jurisdiction exclusion), promotional terms (general + cross-ref to `/promotions`), changes-to-policies, relationship-to-other-agreements. Mailing address left as in-source `TODO` comment, rendered publicly as "available on request — email support@" until P.O. Box is purchased.
|
||||
- **`/contact` — `frontend/src/pages/ContactPage.tsx`** (new). Phone **(470) 949-4131**, all four inboxes (`support@`, `sales@`, `billing@`, `security@`), response-time SLAs, mailing-address placeholder, link to `/contact-sales` for the lead-gen Calendly flow (distinct surface — kept both routes intentionally).
|
||||
- **`/promotions` — `frontend/src/pages/PromotionsPage.tsx`** (new). One-paragraph stub stating no promotions currently active. Will be appended to when offers run; satisfies Policies §6.2's cross-reference.
|
||||
- Routes wired in `frontend/src/router.tsx` as 3 new public lazy-loaded routes alongside existing `/privacy`, `/terms`, `/pricing`, `/contact-sales`.
|
||||
- **`MarketingFooter` — `frontend/src/components/common/MarketingFooter.tsx`** (new, second commit). Extracted from the inline landing footer (26 lines → 1 line at the call site). Mounted on `/landing`, `/pricing`, `/contact-sales` so all four legal links (Privacy / Terms / Policies / Contact) are reachable from every marketing surface — including the page Stripe's reviewer spends the most time on (`/pricing`). Reuses existing `landing-footer*` CSS in `frontend/src/styles/landing.css` — must be rendered inside a `.landing-page` wrapper because `--lp-*` vars are scoped there (documented in a JSX comment). All three current call sites already wrap in `.landing-page`, so landing renders pixel-identically and the two new mount sites match.
|
||||
- **Privacy and Terms closing sections** updated to point at `/contact` + `/policies` with correct per-area inboxes (`security@` for Privacy, `support@` for Terms). Stale `hello@resolutionflow.com` mailto removed everywhere.
|
||||
- `tsc --project tsconfig.app.json --noEmit` clean, `eslint` clean. Local `vite build` and `tsc -b` blocked by root-owned `node_modules/.tmp` and `node_modules/.vite-temp` cache directories — CI rebuilds from a clean env and was green.
|
||||
- PR #165 opened at `gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/165`, CI passed, squash-merged into main as `ba45cfe`. Remote branch `feat/stripe-legal-pages` auto-deleted.
|
||||
- User reports continued trouble activating Stripe live mode. After follow-up: the real blocker is the EIN — ResolutionFlow, LLC does not have one yet, and Stripe requires a tax ID before it will activate live mode. User is applying via IRS.gov on 2026-05-13. Updated HANDOFF.md to remove the earlier speculation list and record EIN as the named blocker, with the P.O. Box / mailing address called out as the likely-next blocker (Stripe live-mode also requires a business mailing address). Apex DNS at Namecheap is still pending but only matters after the business profile is accepted (site verification is a downstream step).
|
||||
- Mailing-address decision: user is going with the home-address-temporarily approach for Stripe so live-mode isn't blocked on the P.O. Box. Home address goes into Stripe's **private** business profile only — the **public** `TODO: replace with full mailing address` in `ContactPage.tsx` and `PoliciesPage.tsx` stays as "available on request" until the P.O. Box is purchased. Stripe accepts updating the address later without re-verification, so swapping in the P.O. Box when it arrives is non-disruptive.
|
||||
|
||||
**Left for next session:**
|
||||
|
||||
- Check in on whether the EIN application went through and whether the P.O. Box / mailing address is sorted. Both are pure user-side ops; no code work to do until Stripe accepts the business profile.
|
||||
- Once Stripe is activated: Stripe Dashboard live-mode product/price/webhook setup, Railway prod env vars, `railway run python -m scripts.sync_stripe_plan_ids` against prod, 9-scenario internal validation, flag flip.
|
||||
- Apex DNS at Namecheap (still missing; only matters once Stripe runs its site-verification step).
|
||||
- Mailing address TODO in `ContactPage.tsx` and `PoliciesPage.tsx` (one each) — fill in when P.O. Box is purchased.
|
||||
|
||||
**Files touched (all merged to main via PR #165 squash `ba45cfe`):** `frontend/src/pages/ContactPage.tsx` (new), `frontend/src/pages/PoliciesPage.tsx` (new), `frontend/src/pages/PromotionsPage.tsx` (new), `frontend/src/components/common/MarketingFooter.tsx` (new), `frontend/src/router.tsx`, `frontend/src/pages/LandingPage.tsx`, `frontend/src/pages/PricingPage.tsx`, `frontend/src/pages/ContactSalesPage.tsx`, `frontend/src/pages/PrivacyPage.tsx`, `frontend/src/pages/TermsPage.tsx`. Plus `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md` on the `docs/handoff-pr-165-merge` branch (this entry).
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-08 03:30 UTC — Claude — PR #164 self-serve cutover code blockers, doc refresh, page-title bug, DNS triage
|
||||
|
||||
**Accomplished:**
|
||||
|
||||
- Merged PR #162 (self-serve Phase 2 frontend) and PR #163 (seed users email-verified) into main via Gitea API squash merge. Created branch `feat/billing-plan-taxonomy` off the new main; pushed 5 commits closing the last code blockers for Phase O cutover. PR #164 opened at gitea pulls/164.
|
||||
- Plan taxonomy reconciliation. Discovered the marketing surface (PricingPage, Stripe products) was wired for `Starter / Pro / Enterprise` while backend was on `free / pro / team`; `BillingPlan` schema's `Literal["pro","starter","team","enterprise"]` could accept FK-violating values; `plan_billing` was unseeded. Migration `4ce3e594cb87` renames `plan_limits.plan='team'` → `'enterprise'` (defensive update of any subscriptions on the old slug; dev had zero), adds `starter` row with caps interpolated between free and pro (`max_trees=10`, `sessions=75`, `users=1`, `ai=15/mo`, no KB Accelerator, no custom branding, no priority support). Code rename across schemas (`invite_code`, `billing`, `admin`, `subscription`), `Subscription` paid-plan/`has_pro_entitlement` checks, `admin_dashboard.py`, `admin.py`, frontend `useSubscription.isPaidPlan`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain (means "shared with my account") and intentionally untouched. 86/86 passing across subscription/billing/plan/invite/admin sweep after the rename. Conftest plan_limits seed + `_seed_plan_limits` helper made a true upsert.
|
||||
- New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert from Stripe products by exact name match (`ResolutionFlow Starter / Pro / Enterprise`), picks active monthly recurring price, leaves annual fields NULL by design. Works against test or live keys via `STRIPE_SECRET_KEY`. Run against test mode populated `plan_billing` for all 3 tiers in dev DB. Annual pricing intentionally skipped per user's exit-flexibility constraint.
|
||||
- Stripe MCP work (test mode, `livemode=false`): archived leftover Enterprise `$500/mo` test price (had to clear the product's `default_price` first — Stripe blocks archive otherwise). Verified test-mode product set: Starter $19.99/mo, Pro $29.99/mo, Enterprise no price (sales-led).
|
||||
- `INTERNAL_TESTER_EMAILS` allowlist. Phase O Task 46 needed it as a code blocker (flagged in prior SESSION_LOG as "backend support is NOT yet built"). `Settings.is_internal_tester` (case-insensitive membership) + `is_self_serve_active_for(email)` (returns global flag OR allowlist hit) centralize the check. New `get_current_user_optional` dep — best-effort auth that returns `None` instead of 401, used by `/config/public` so the same endpoint serves anonymous and authed. `/config/public` returns `self_serve_enabled=true` for authenticated allowlist members; `/auth/register` allows allowlisted emails without invite code. 5 regression tests including "anonymous callers always see the global flag" (prevents leak via unauthenticated request content).
|
||||
- Stripe env passthrough: `docker-compose.dev.yml` now wires `STRIPE_*` + `SELF_SERVE_ENABLED` + `INTERNAL_TESTER_EMAILS` into the backend container. New repo-root `.env.example`. `backend/.env.example` updated with the self-serve cutover vars.
|
||||
- Page-title bug fix on `LandingPage.tsx`. Two JSX attribute strings (`title="..."`, `description="..."`) had `—` (six literal characters) — JSX attribute strings don't process JS escape sequences, so the browser tab and OG description rendered the literal text instead of an em dash. Replaced with the literal em dash character. Verified by grep — every other `\u...` in the codebase is inside a real JS string (`'...'` literal or `{...}` JSX expression) where escapes resolve at compile time. PageMeta default tagline updated from stale "Decision Tree Platform" to "AI-Powered Troubleshooting for MSPs" (matches index.html and brand positioning).
|
||||
- Frontend taxonomy followups (caught by tsc -b after rebuild). The earlier taxonomy commit didn't propagate through frontend types: `types/account.ts`, `types/admin.ts`, `types/billing.ts`, `admin/AccountsPage.tsx` (state type, select onChange cast, `<option value="team">` rendered UI), `admin/InviteCodesPage.tsx` (PLAN_OPTIONS array, state type, onChange cast), `AccountSettingsPage.tsx` (`plan !== 'team'` check + CheckoutButton prop), `subscription/CheckoutButton.tsx` (prop type + planLabels). All updated to `'free' | 'pro' | 'starter' | 'enterprise'`. tsc clean. Lint clean (3 warnings only in auto-generated `coverage/`).
|
||||
- Doc refresh commit (`docs: refresh CURRENT-STATE, ROADMAP, README, DECISIONS for self-serve cutover`). CURRENT-STATE bumped to 2026-05-07; added entries for PR #159–164; refreshed What's In Progress / What's Next around Phase O. ROADMAP got a "Status as of 2026-05-07" preamble (months-stale historical content kept underneath as record); In Progress and What's Next sections updated. README fixed legacy `patherly_postgres` Docker command, project-tree path, `UI-DESIGN-SYSTEM.md` reference; added `AGENTS.md`, `PROJECT_CONTEXT.md`, `PRODUCT.md` to docs table. DECISIONS appended two entries (taxonomy reconciliation, allowlist).
|
||||
- Office-hours session ran via `/office-hours` skill earlier in this session. Design doc saved at `~/.gstack/projects/chihlasm-resolutionflow/abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`. Captured the "documentation builder" thesis — cut branching Flows from pilot UI, focus product around FlowPilot + Day 1 onboarding checklist as navigational frame + 3 deep-capture procedures (M365 tenant build, Windows server build, credential vault) + Hudu/IT Glue/ConnectWise output. Founder is a Director-of-Onboarding at his own MSP (Andrea Henry); pre-build assignment is 3 cold calls with external Directors of Onboarding before scoping. NOT yet adopted as roadmap.
|
||||
- DNS / cert triage: `www.resolutionflow.com` was unreachable (Railway "train hasn't arrived" page) — user added it as a custom domain in Railway, cert provisioned at 2026-05-08 01:40 UTC, `www` now serves 200 with valid Let's Encrypt SAN. Apex `resolutionflow.com` separately discovered to have NO A/CNAME at authoritative DNS (Namecheap per SOA `dns1.registrar-servers.com.`). When user reconfigured `www`, the apex record dropped from the zone. From Railway-edge IP both names work fine when DNS is forced (proven by `curl --resolve` returning 200 OK from user's box) — so the apex cert is also valid; the failure mode is purely DNS-level absence. User asked for HSTS clearance steps in Edge — provided `edge://net-internals/#hsts`, `#dns`, `#sockets` walkthrough plus Linux DNS flush options.
|
||||
|
||||
**Left for next session:**
|
||||
|
||||
- Verify PR #164 CI green, then squash-merge.
|
||||
- Phase O manual ops sequence (Stripe Dashboard live-mode setup, Railway prod env vars including `INTERNAL_TESTER_EMAILS`, run `sync_stripe_plan_ids.py` against prod, internal validation Task 46, flag flip Task 47, PostHog dashboards, Sentry alert).
|
||||
- User-side: re-add apex DNS record at Namecheap (ALIAS `@` → `c9g7uku8.up.railway.app`, or re-add apex in Railway), clear Edge HSTS state.
|
||||
|
||||
**Files touched (all on `feat/billing-plan-taxonomy`, all pushed):** `backend/alembic/versions/4ce3e594cb87_add_starter_rename_team_to_enterprise.py` (new), `backend/scripts/sync_stripe_plan_ids.py` (new), `backend/app/{schemas/{billing,invite_code,admin,subscription}.py, models/subscription.py, api/{deps.py, endpoints/{auth.py, admin.py, admin_dashboard.py, config.py}}, core/config.py}`, `frontend/src/{components/{common/PageMeta.tsx, subscription/CheckoutButton.tsx}, hooks/useSubscription.ts, pages/{LandingPage.tsx, AccountSettingsPage.tsx, admin/{AccountsPage.tsx, InviteCodesPage.tsx}}, types/{account.ts, admin.ts, billing.ts}}`, `backend/tests/{conftest.py, test_admin_plan_limits.py, test_invite_plan.py, test_plans_public.py, test_config_public.py}`, `docker-compose.dev.yml`, `.env.example` (new), `backend/.env.example`, `CURRENT-STATE.md`, `03-DEVELOPMENT-ROADMAP.md`, `README.md`, `.ai/{DECISIONS.md, HANDOFF.md, CURRENT_TASK.md, SESSION_LOG.md}`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-07 11:45 EDT — Codex — Push PR #162 CI runner setup fixes
|
||||
|
||||
- Inspected Gitea PR #162 via public API. PR head was `380fcf7` and all CI jobs failed quickly; pushed local commits through `4a37a47`, including Python 3.12 setup for Gitea backend/e2e jobs.
|
||||
- New run on `4a37a47` showed frontend still failed quickly while backend/e2e remained pending. Root cause likely same class of runner drift: Gitea frontend/e2e jobs used `npm` without setting up Node.
|
||||
- Added explicit `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs. This keeps CI from relying on runner ambient Node/npm.
|
||||
- Files touched: `.gitea/workflows/ci.yml`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-07 11:30 EDT — Codex — Standardize backend Python on 3.12
|
||||
|
||||
- Standardized repo declarations around Python 3.12: added `.python-version` pinned to 3.12.13, updated stale Python 3.11 docs, and added explicit Python 3.12 setup steps to Gitea CI. GitHub CI was already updated to Python 3.12 by the user.
|
||||
- Installed pyenv Python 3.12.13 and created `backend/venv` from that interpreter. Installed `backend/requirements-dev.txt` into the venv.
|
||||
- Verified native `python --version` and venv `python --version` both report 3.12.13. Verified native `pytest 8.4.2` and `alembic 1.18.3` with explicit safe test env vars; plain pytest import still depends on local `.env` values being valid.
|
||||
- Rebuilt and restarted the dev backend container with `docker compose -f docker-compose.dev.yml build backend` and `up -d backend`; confirmed `docker exec resolutionflow_backend python --version` reports 3.12.13.
|
||||
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `README.md`, `DEV-ENV.md`, `.ai/PROJECT_CONTEXT.md`, `.ai/DECISIONS.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-07 11:14 EDT — Codex — Recheck native Python availability
|
||||
|
||||
- Re-ran the startup ritual and checked the host Python state after the user reported fixing the missing native Python issue.
|
||||
- Verified `python` and `python3` resolve to `/config/.pyenv/shims/*` and run Python 3.12.10. `pip` and `pip3` are available as pip 25.0.1 under the same pyenv install.
|
||||
- Confirmed there is no native `python3.11`, pyenv currently lists only `3.12.10`, no repo virtualenv exists under `backend/venv`, `backend/.venv`, or root `.venv`, and `python -m pytest --version` from `backend/` fails with `No module named pytest`.
|
||||
- Conclusion: native Python is present, but it is not yet a ready backend dev/test environment for ResolutionFlow. Docker remains the reliable path for pytest/alembic until a Python 3.11 virtualenv with `backend/requirements*.txt` is installed.
|
||||
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-06 — Claude — Self-serve signup Phase 2 (frontend + cutover code) shipped on `feat/self-serve-signup-phase-2`
|
||||
|
||||
- Executed Tasks 27–44 of `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md` via `superpowers:subagent-driven-development`. 18 commits on `feat/self-serve-signup-phase-2` (off `main` `f918b76`); HEAD `c75ce0c`. Each task: dispatched implementer subagent with full task text + curated context, then spec-compliance + code-quality review subagents; review issues either fixed in-flight via `git commit --amend` or noted as deferred scope.
|
||||
- Backend (Phase I, Tasks 27–31): `BillingService.open_customer_portal` + `GET /billing/portal-session`; `PATCH /users/me/onboarding-step` + dismiss-rest sibling; public `POST /sales-leads` (5/hr/IP); `/admin/plan-limits` GET/PUT round-trips `plan_billing` in one transaction with NOT-NULL guards on `display_name|is_public|is_archived|sort_order`; `BillingService.invalidate_billing_cache` no-op stub; `GET /config/public` (`{self_serve_enabled, oauth_providers}`); `auth/register` invite-code gate now `REQUIRE_INVITE_CODE and not SELF_SERVE_ENABLED and not invite_code`. Also (T36): `GET /accounts/invites/{code}/lookup` (public, joinedload account+inviter); OAuth callback honors `account_invite_code+invited_email`, rejects existing-email user with `email_already_registered_use_login`. Also (T42, T44): `GET /plans/public`; `POST /beta-signup` returns 307 to `${FRONTEND_URL}/register?from=beta`. `OnboardingStatus` extended with `email_verified`+`shop_setup_done`; `UserResponse` exposes `onboarding_step_completed`+`onboarding_dismissed`.
|
||||
- Frontend (Phases J–N, Tasks 32–44): `useBillingStore` Zustand store + `useBillingPoll` mounted in `AppLayout`; `useFeature` / `useFeatureLimit` (60s module cache, lazy `/usage/{field}` fetch with silent fallback — endpoint deferred) / `useTrialBanner` (fractional-day boundary so 24h = warning); `FeatureGate` / `UpgradePrompt` (inline `FEATURE_CATALOG`) / `EmailVerificationGate` (mounted in AppLayout around `<ViewTransitionOutlet />`). `RegisterPage` redesign with OAuth buttons + invite-code conditional; `OAuthCallbackPage` with CSRF state validation + UTF-8-safe base64url state encoding (factored into `lib/oauthState.ts`); `useAppConfig` hook. `AcceptInvitePage` at `/accept-invite` with locked email; `EmailVerificationBanner` refactored to design-system tokens; `EmailVerificationWall` polished; `VerifyEmailPage` at `/verify-email` with single-fire ref guard; `WelcomeRouter` + `WelcomeStep1/2/3` at `/welcome*`; `TrialPill` in topbar (8 stages); `NextStepCard` + `SetupChecklist` (replace orphaned `OnboardingChecklist`); `PricingPage` at `/pricing`; `ContactSalesPage` at `/contact-sales`; `LandingPage` got "See pricing" CTA + replaced beta-signup form with `<Link>`.
|
||||
- Final cross-cutting review caught one real bug — relative `/beta-signup` 307 target landing on API origin instead of frontend — fixed via amend (HEAD `c75ce0c`).
|
||||
- Tests: ~165+ new tests across backend pytest + frontend vitest. Sweep at end-of-branch all-green; tsc -b clean.
|
||||
- Phase O (Tasks 45–47) is explicit manual operations: Stripe live-mode setup, internal validation via `INTERNAL_TESTER_EMAILS` per-email allowlist (backend support for that allowlist is NOT yet built), feature-flag flip + week-1 monitoring. Surfaced as the resume point in HANDOFF.md.
|
||||
- Working tree was dirty before this session (`.ai/HANDOFF.md`, `.env.example`s, `core.*` core dumps, `docs/architecture/`, `docs/tutorials/`); intentionally not staged into Phase 2 commits. Files touched: see `git log --oneline f918b76..HEAD` on `feat/self-serve-signup-phase-2`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-02 ~01:00 UTC — Claude — In-product User Guides Diátaxis rewrite shipped (PR #159)
|
||||
|
||||
- Audited the in-product `/guides` collection against live UI via `/browse` (engineer + owner test users). Existing 15 guides predated the FlowPilot pivot — every "click X in the sidebar" reference was wrong (Dashboard → Home, All Flows → Flows, Sessions → History, Exports gone, etc.). Three guides described surfaces that no longer exist: Maintenance Flows, AI Assistant page, Flow Assist Sparkles button. Findings written to `/tmp/guides-audit.md`.
|
||||
- Rebuilt `frontend/src/data/guides.ts` from scratch as 43 problem-oriented Diátaxis how-tos under 10 categories. Single-outcome each, terse imperative steps, real UI labels (Create New, Sign in, Manage, Build New Script, Send Invite, Save Settings, Create Category, etc.). Added `category: CategoryId` and optional `relatedSlugs?: string[]` to the `Guide` interface; new `Category` type and `categories` const drive the hub layout. `GuidesHubPage` now renders category sections (auto-hides empty); `GuideDetailPage` renders a Related guides footer; `GuideCard` lost its misleading "N sections" subtitle.
|
||||
- Fixed `GuideSection.tsx`: `step.tip` was rendered as plain text so `**bold**` markdown in tips rendered literally. Applied the same regex replacement used on `step.instruction`. Verified against `/guides/start-a-session` tip block.
|
||||
- Authored 14 net-new how-tos for FlowPilot-era surfaces with no prior coverage: tasklane-keyboard-flow, view-what-we-know, ask-ai-mid-session, pause-and-leave-session, resolve-a-session, record-suggested-fix-outcome, escalate-a-session, post-docs-to-ticket, send-client-update, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate. Dropped change-teammate-role from scope — couldn't verify the role-change UI control without a non-owner test member.
|
||||
- Verified owner-only surfaces with `pro@resolutionflow.example.com`: Membership inline form on `/account` (not a separate `/team-members` route), `/account/categories` real button is **Create Category** (not Add), `/account/chat-retention` real fields are **Retention Period (days)** + **Max Conversations** + **Save Settings**, `/account/integrations` form fields confirmed. Three guides corrected post-audit.
|
||||
- Smoke-tested all 43 detail pages — every slug renders, no "Guide Not Found" fallthroughs.
|
||||
- Added `100.64.78.44 docker-01` entry to `/etc/hosts` (user ran `sudo tee` from a normal terminal because the LXC `!` shell prefix can't drive interactive sudo). Should now persist across `/browse` sessions on this LXC.
|
||||
- `docker exec -w /app resolutionflow_frontend npx tsc -b` clean.
|
||||
- Files touched: `frontend/src/data/guides.ts`, `frontend/src/pages/GuidesHubPage.tsx`, `frontend/src/pages/GuideDetailPage.tsx`, `frontend/src/components/guides/GuideCard.tsx`, `frontend/src/components/guides/GuideSection.tsx`, `CHANGELOG.md`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`. Working tree dirty — user not yet asked to commit.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-01 21:55 UTC — Claude — Session-screen impeccable pass + tasklane keyboard flow shipped (PR #158)
|
||||
|
||||
- Ran the `/impeccable` skill against the assistant chat session screen (chat history / chat bar / TaskLane). Initial design-health score: 24/40 with explicit DESIGN-SYSTEM violations (gradient surfaces in WhatWeKnow + ProposalBanner, side stripes in TaskLane done states + every banner mode, accent borderTop on lane header, backdrop blur on handoff overlay).
|
||||
- Walked through all 5 impeccable sub-passes (distill, quieter, layout, typeset, polish). Score after pass: 33/40 (+9). Biggest gains in Aesthetic & Minimalist (1→3), Consistency & Standards (1→3), Recognition Rather Than Recall (2→4).
|
||||
- Inline iterations on top of the impeccable steps: linked banner ↔ script-panel lifecycle (collapse hides both, dismiss closes both, any outcome closes both); collapsible WhatWeKnow with `sessionStorage` memory + auto-collapse-at-5-facts; full keyboard flow on TaskLane (Enter submits + auto-advances, Shift+Enter newline, Esc cancels, focus jumps to Send Responses after the last task).
|
||||
- Side fix: `ParameterizationPreview` was over-highlighting short parameter values (a `"D"` lit up every capital D in `Get-ADUser`/`Add-Type`/etc.). Added a word-boundary guard, conditional on whether the value itself starts/ends with a word character so values with leading punctuation (`"D:\\Folder"`) still match cleanly.
|
||||
- Followups logged in `.ai/TODO.md`: `ConcludeSessionModal` multi-select for paused/escalated outcomes (real feature work — engineers often need ≥2 of Ticket Notes / Client Update / Email Draft), and `bg-card-hover` Tailwind drift in `CommandPalette` (silently broken classes — two-line fix).
|
||||
- Branched as `feat/session-distill-quieter`, 4 commits (impeccable pass, parameterize fix, TODO followups, hint contrast + font-sans audit). PR #158 created via Gitea API (`$GITEA_TOKEN` env, no `gh` on this LXC). Merged into `main` as `5e10005`. Local branch deleted.
|
||||
- Validation at every commit boundary: `docker exec -w /app resolutionflow_frontend npx tsc -b`, `npm run lint`, and `npm run build` all clean.
|
||||
- Files touched: 14 frontend files (TaskLane, AssistantChatPage, ChatMessage, ProposalBanner, WhatWeKnow, WhatWeKnowItem, SuggestedFlowCard, ChatSidebar, ConcludeSessionModal, ChatTabStrip, ActionCardGroup, AddNoteButton, ParameterizationPreview), `.ai/TODO.md`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `CHANGELOG.md`, `CURRENT-STATE.md`.
|
||||
|
||||
## 2026-05-01 07:20 UTC — Codex — Start issue cleanup plan sections 1 and 2
|
||||
|
||||
- Started `docs/plans/2026-05-01-issue-cleanup-plan.md` sections 1 and 2.
|
||||
- Cleaned frontend lint to zero warnings by removing stale lint disables, tightening hook dependencies, and adding justified comments where effects are intentionally keyed to route or owner identity.
|
||||
- Added e2e selectors for session history controls and the FlowPilot command-palette entry.
|
||||
- Added `AssistantChatPage` observability for unexpected `currentChatRef` stale async discards.
|
||||
- Added `TaskLane` diagnostic help affordances for common command categories and documented #128 as "keep the existing responsive side-panel/bottom-drawer behavior until pilot feedback says otherwise."
|
||||
- Verified `npm run lint`, `npx tsc -b`, and `npm run build` in `resolutionflow_frontend`; build only reported the existing Vite large-chunk warning.
|
||||
- Files touched: frontend lint-cleanup files, `frontend/src/components/assistant/TaskLane.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/pages/SessionHistoryPage.tsx`, `frontend/src/components/layout/CommandPalette.tsx`, `docs/plans/2026-05-01-issue-cleanup-plan.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-01 06:05 UTC — Codex — Clean stale TODOs and add issue cleanup plan
|
||||
|
||||
- Removed the resolved pytest-xdist item from `.ai/TODO.md` and reset "Up next" to no selected task.
|
||||
- Removed the resolved "Add role gate to handoff claim endpoint" backlog item from `.ai/TODO.md`.
|
||||
- Updated the frontend lint cleanup TODO from 23 warnings to the current `npm run lint` result: 24 warnings, 0 errors.
|
||||
- Tried to close Gitea #127 through the API, but this environment has no Gitea token; API returned `401 token is required`.
|
||||
- Added `docs/plans/2026-05-01-issue-cleanup-plan.md` with safe tracker actions and a recommended order for clearing remaining issues.
|
||||
- Files touched: `.ai/TODO.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `docs/plans/2026-05-01-issue-cleanup-plan.md`.
|
||||
|
||||
## 2026-05-01 05:40 UTC — Codex — Audit TODO backlog and Gitea issue validity
|
||||
|
||||
- Compared `.ai/TODO.md`, inline code TODOs, and open Gitea issues against current `main`.
|
||||
- Verified pytest-xdist is already shipped (`backend/requirements-dev.txt`, `backend/tests/conftest.py`, `.gitea/workflows/ci.yml`) so the `.ai/TODO.md` xdist item is stale. Ran frontend lint in Docker; current state is `0 errors, 24 warnings`, so the lint cleanup item remains valid but its count is stale.
|
||||
- Verified Gitea issue status: #58, #60, #128, #129, #130 remain valid; #66 is partially resolved by current `.rfflow` import/export and should be narrowed to template packs/marketplace; #127 is mostly resolved by current UI copy and prompt boundaries unless an always-visible scope badge is still wanted. Open PR #124 is stale/unmergeable against current `main`.
|
||||
- Verified inline TODOs still valid: post-session contextual feedback prompt, FlowPilot analytics domain/time-entry placeholders, prompt-cache verification note unless live telemetry has confirmed it, proposal `modify` flow editor wiring, and procedural ghost-step accept/dismiss buttons.
|
||||
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-01 03:45 UTC — Claude Opus 4.7 — QA, merge, and ship PR #156 pending-verification
|
||||
|
||||
- Committed two logical units of pending work on `feat/fix-pending-verification`: prior session's local review fixes as `5bee264` (Codex-attributed, 5 source files + 3 `.ai/` notes) and this session's docker-exec docs as `15042af` (Claude-attributed, `.ai/PROJECT_CONTEXT.md` + `AGENTS.md`). Cleaned up a 20MB `core.22120` Chromium dump left behind by an earlier sandbox crash.
|
||||
- Resolved a tooling gap surfaced by Codex's prior session ("npm/python/python3 are not on the host path") by documenting that this code-server LXC uses bun + docker for the toolchain. The `docker exec resolutionflow_{backend,frontend}` form is now the canonical command pattern in `.ai/PROJECT_CONTEXT.md`.
|
||||
- Got `$B`/Playwright Chromium running in the code-server LXC. After the user's restart cleared the AppArmor unprivileged-userns block, Chromium still aborted at the deeper `sandbox/linux/services/credentials.cc` layer because of the LXC namespace constraint. Workaround: launch browse with `CONTAINER=1` so it auto-adds `--no-sandbox`. Also added `100.64.78.44 docker-01` to code-server's `/etc/hosts` (via `docker exec -u 0`) so the headless browser could resolve the bake-in `VITE_API_URL`.
|
||||
- Drove `/qa` against the dev stack at `http://100.64.78.44:5173`. No naturally-occurring `applied_pending` fix existed in the DB, so seeded session `4a558056-bcbd-4b51-925b-248d70eb318d` and fix `cd4ff2fd-751a-4bcb-8cfa-3c77b4864fb2` into the test state (un-resolved session, swapped supersession on the two fixes). Saved a restore script first; verified DB matches pre-test state after teardown.
|
||||
- QA result: 5/7 scripted checks PASS with concrete DB + UI evidence. Banner renders correctly ("Awaiting verification" header, "Parked" tag, fix title + pending_reason, 4 actions). "Update reason" updates server-side. "It worked" → `applied_success` with `verified_at` stamped. "Dismiss" → `dismissed` with no terminal timestamp. Page-level Resolve auto-patches `applied_pending` → `applied_success` before the resolution flow opens. Page-level Escalate fires `EscalateInterceptDialog` with the generalized "still needs an outcome" copy. 2 entry-path checks (VerifyingBanner overflow, nudge "Still checking") deferred because they require live AI-generated chat state to drive; the mutating handlers behind those entry paths are verified via the tested transitions. Report at `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md`.
|
||||
- Pushed `feat/fix-pending-verification`. Polled Gitea actions runs 161; required `CI / frontend` and `CI / backend` plus `CI / e2e` all green. Merged via Gitea API as a merge commit (`3ba4532`).
|
||||
- Post-merge cleanup: fast-forwarded local `main`, deleted `feat/fix-pending-verification` locally and on the remote. Wrote handoff updates on `chore/post-156-handoff` matching the prior `chore/post-153-handoff` pattern.
|
||||
- Files touched (this session): `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/PROJECT_CONTEXT.md`, `.ai/SESSION_LOG.md`, `AGENTS.md`, `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md`, `.gstack/qa-reports/screenshots/01-08*.png`. Plus the two prior-session-authored commits committed by this session (5 source + 3 `.ai/` notes).
|
||||
|
||||
## 2026-05-01 02:24 UTC — Codex — Review-fix PR #156 pending-verification flow
|
||||
|
||||
- Reviewed PR #156 for bugs and found three actionable gaps: pending fixes could be resolved from the page-level Resolve path without updating the fix outcome, the PendingBanner lacked the dismiss action described in the PR body, and new system-prompt examples used real-looking pending reasons contrary to the prompt anti-parrot lesson.
|
||||
- Applied fixes locally on `feat/fix-pending-verification`: page-level Resolve now patches `applied_pending` to `applied_success`; page-level Escalate now intercepts `applied_pending` before handoff; PendingBanner now has Dismiss; escalation intercept copy no longer says only "Verifying state"; generator prompts no longer include real-looking pending examples.
|
||||
- Verified via running containers: prompt anti-parrot guardrail `2 passed`, suggested-fix outcome suite `21 passed`, frontend `npx tsc -b` clean, frontend `npm run build` clean except the existing Vite large-chunk warning, and `git diff --check` clean.
|
||||
- Left for next session: browser QA PR #156 using CURRENT_TASK.md checklist, then commit/push local review fixes and merge.
|
||||
- Files touched: `backend/app/services/resolution_note_generator.py`, `backend/app/services/escalation_package_generator.py`, `frontend/src/components/pilot/ProposalBanner.tsx`, `frontend/src/components/pilot/EscalateInterceptDialog.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-04-30 — Claude Code — Land PR #155, ship pending-verification feature on PR #156
|
||||
|
||||
- Committed Codex's review-pass changes (atomic conditional `UPDATE` for `claim_session`, self-claim 403, queue self-exclusion, pre-flush handoff UUID, frontend dead-code removal) as `f10649a` on `feat/escalation-metric-endpoint`.
|
||||
- Pushed `feat/escalation-metric-endpoint`, un-drafted PR #155, retitled it (stripped "WIP:"), and merged via Gitea API as a merge commit (`ac42f97`). 4/4 CI checks green at merge.
|
||||
- Picked up follow-up work surfaced by the user: the suggested-fix verifying banner forces a synchronous verdict, but real fixes are often async (waiting on client power-cycle, AD replication, license sync). Added a fourth, non-terminal outcome.
|
||||
- Designed the model: new `FixStatus="applied_pending"` parallel to `applied_partial`. Distinct semantics — partial = "did some of it"; pending = "did all of it, can't verify yet." Distinct prose in the resolution-note + escalation-package generators.
|
||||
- Implemented on a fresh branch `feat/fix-pending-verification` off main:
|
||||
- Backend: extended `FixStatus`/`FixOutcome` literals, added `pending_reason` Text column and CHECK constraint update via Alembic migration `c0f3a4b7e91d`. `patch_outcome` accepts pending, requires notes, stamps `applied_at` only (NOT `verified_at`); pending in/out transitions allowed.
|
||||
- Frontend: new `BannerMode='pending'` + `PendingBanner` component (info-tone, mirrors `PartialBanner`). "Waiting to verify…" added to `VerifyingBanner` overflow menu. `NudgeBanner` "Still checking" button now records `applied_pending` with a reason instead of just silencing for the session — closes the loop semantically. `AssistantChatPage` banner-mode derivation maps the new status.
|
||||
- Tests: 4 new integration tests in `test_fix_outcome_endpoint.py` covering notes-required, reason-storage with applied_at-not-verified_at semantics, pending→success transition, and pending_reason update on re-PATCH. 21/21 pass.
|
||||
- Validation: `tsc --noEmit -p tsconfig.app.json` exit 0; `alembic upgrade heads` applied cleanly.
|
||||
- Single-commit PR #156 opened: https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/156. Branch rebased onto post-merge main.
|
||||
- Cleanup: removed 10 stray `core.*` dumps from the worktree; deleted merged `feat/escalation-metric-endpoint` locally and on the remote.
|
||||
- Files touched: `backend/app/models/session_suggested_fix.py`, `backend/app/schemas/session_suggested_fix.py`, `backend/app/api/endpoints/session_suggested_fixes.py`, `backend/app/services/resolution_note_generator.py`, `backend/app/services/escalation_package_generator.py`, `backend/tests/test_fix_outcome_endpoint.py`, `backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py`, `frontend/src/api/sessionSuggestedFixes.ts`, `frontend/src/components/pilot/ProposalBanner.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-30 06:25 UTC — Codex — Apply Escalation Mode review fixes
|
||||
|
||||
- Reviewed the recent Escalation Mode wedge work and fixed the actionable findings before PR #155 is marked ready.
|
||||
- Reworked `HandoffManager.claim_session` from read-then-write to an atomic conditional update, preserving idempotent same-user retries and returning a typed conflict for a different claimant.
|
||||
- Blocked original engineers from claiming their own handoffs and filtered their own escalated sessions out of `/ai-sessions/escalation-queue`, preventing the post-escalation dashboard from showing a junior their own handoff.
|
||||
- Fixed the compatibility payload so `session.escalation_package["handoff_id"]` is populated from a preassigned UUID before flush.
|
||||
- Removed unused legacy frontend pickup state (`claiming`, `handleStartHere`, unused `onStartHere` destructuring) that made `tsc -b` fail under `noUnusedLocals`.
|
||||
- Added regression coverage for pre-flush handoff IDs, conflict handling, self-claim rejection, successful non-owner claim, and own-escalation queue exclusion.
|
||||
- Verified `git diff --check`; focused backend tests passed (`28 passed in 42.23s`); frontend `tsc --noEmit` checks passed for app and node configs. Full Vite/build script remains blocked by root-owned generated directories under `frontend/node_modules` / `frontend/dist` in this workspace, not by TypeScript errors.
|
||||
- Files touched: `backend/app/services/handoff_manager.py`, `backend/app/api/endpoints/ai_sessions.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `frontend/src/components/flowpilot/HandoffContextScreen.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-04-30 — Claude Code — Browser QA pass complete; chat ownership bug found and fixed; PR #155 ready
|
||||
|
||||
- Ran full browser QA pass on the escalation mode feature using gstack `/qa` skill.
|
||||
- **Critical bug found and fixed (commit `dc69c9d`):** `POST /ai-sessions/{id}/chat → 400` when senior clicked "Get AI analysis" on the magic-moment screen. Root cause: `unified_chat_service.send_chat_message` checked `AISession.user_id == user_id` only; senior is stored as `escalated_to_id`, not `user_id`. Fix: `or_(AISession.user_id == user_id, AISession.escalated_to_id == user_id)` in the WHERE clause.
|
||||
- **All 7 QA scenarios passed:**
|
||||
- Post-escalation redirect: junior routed to `/` with "Session escalated" toast.
|
||||
- Magic-moment screen: header, metadata, two-column AI assessment, 2-option CTA rendered correctly.
|
||||
- "I'll take it from here": claim → dismiss overlay → composer focused.
|
||||
- "Get AI analysis": claim → briefing sent → AI responded → task lane populated (after `dc69c9d` fix).
|
||||
- Task lane copy button: toast + checkmark visual feedback.
|
||||
- Chip expansion: inline detail card + "Open in Tasks panel" scroll.
|
||||
- Post-claim toolbar re-open: dismissible mode with Close-only CTA.
|
||||
- **Known non-blockers:** "Continue where X left off" path untestable on first pickup (`hasTaskLane=false` is correct v1 behavior). 409 race condition untestable with one senior account; backend logic code-reviewed and correct.
|
||||
- Backend tests: 17/17 pass.
|
||||
- Updated `HANDOFF.md` to reflect QA complete; updated `CURRENT_TASK.md` status to engineering+QA complete; appended architectural decision to `DECISIONS.md`.
|
||||
- Branch `feat/escalation-metric-endpoint` is ready for PR #155 to be marked ready-for-review.
|
||||
- **Files touched this session:** `backend/app/services/unified_chat_service.py`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/DECISIONS.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-29 04:30 EDT — Claude Code — Live QA bash, pickup bug fixes, AI summary consolidation surfaced
|
||||
|
||||
- User on a freshly swapped computer ran the live QA flow. Identified two bugs missed by static analysis from the previous session:
|
||||
- **Pickup landed on a blank chat surface.** Root cause: commit `8914391` had made `activeChatId` initialize from `urlSessionId`, which broke the selectChat-gating effect in `AssistantChatPage` (`urlSessionId === activeChatId` short-circuited fresh mounts). Symptom was `selectChat` never firing post-claim; messages, conversation history, and pickup-flow correctness all silently broken.
|
||||
- **Picked-up session missing from sidebar.** Root cause: `loadChats` runs once at mount; pre-claim the session's `escalated_to_id` is null (the junior didn't specify a target), so `listSessions` doesn't return it. Post-claim `claim_session` sets `escalated_to_id` to teamadmin, but the sidebar list never refreshes.
|
||||
- Fixes (commit `0d1b305`):
|
||||
- Replaced the `urlSessionId === activeChatId` gate with a `loadedChatIdsRef` set so selectChat fires once per URL session per page lifecycle, regardless of whether activeChatId already matches.
|
||||
- Added `loadChats()` call in `handleStartHere` after the claim succeeds so the sidebar reflects ownership.
|
||||
- Three additional pieces folded into `0d1b305` from the same QA bash:
|
||||
- **Enter-to-submit on the escalate forms.** Chat-input convention: plain Enter submits, Shift+Enter inserts a newline. Added optional `onSubmit` prop to `RichTextInput` (used by `EscalateModal`) and inline `onKeyDown` on the plain textarea in `ConcludeSessionModal`. The user explicitly asked for this — they want to type the reason and hit Enter without reaching for the mouse.
|
||||
- **Dashboard `PendingEscalations` rows expand to preview.** Click a row to reveal escalation reason + step count + confidence tier + PSA ticket number. Pick Up button click-stops to still go directly to magic moment. Single expansion at a time.
|
||||
- **`ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS` bumped 15 → 45.** Backend logs showed Sonnet hitting the 15s timeout in field testing. Background-task architecture (e8ba74e) means this no longer blocks the user — only bounds before publishing `has_assessment: false`. **Did NOT fix the live demo.** Assessment placeholder still permanent in user's test.
|
||||
- Surfaced an architectural smell: the escalation flow makes **three** Sonnet calls — `_build_escalation_package_enhanced`, `_generate_ai_assessment`, and `generate_status_update` (engineer-triggered) — all summarizing the same source material from slightly different angles. User correctly observed: status update is typically generated during the escalate flow anyway; reusing that content would consolidate.
|
||||
- Decided the right consolidation: ONE structured AI call per escalation that returns both the magic-moment diagnostic fields (`likely_cause`, `suggested_steps[]`, `confidence`) AND PSA-ready prose. Magic moment populates immediately. Status update buttons become tone-shift transformations (Haiku) of the saved prose, not fresh summarizations. Drops to 1 call (~60% token reduction), eliminates the AI-summary placeholder bug because the work happens in the foreground escalate path. Full implementation plan written into CURRENT_TASK.md and DECISIONS.md.
|
||||
- Session ended pre-consolidation: user is updating Claude Code CLI and starting a fresh session for clean context window. All work pushed to origin (`0d1b305`). PR #155 still draft.
|
||||
- Test users for the next session (Acme MSP shared account, password `TestPass123!`): `engineer@` (junior) and `teamadmin@` (senior).
|
||||
- Files touched: `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/components/common/RichTextInput.tsx`, `frontend/src/components/flowpilot/EscalateModal.tsx`, `frontend/src/components/assistant/ConcludeSessionModal.tsx`, `frontend/src/components/dashboard/PendingEscalations.tsx`, `backend/app/core/config.py`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
|
||||
|
||||
## 2026-04-28 02:00 EDT — Claude Code — Plan-locked wedge polish + structural task-lane fix
|
||||
|
||||
- Audited `docs/plans/2026-04-27-escalation-mode-wedge-design.md` against the branch and identified four locked-design / Codex-correction items not yet shipped: live AI assessment refresh, suggested-step chips, unread 6px dot on queue cards, and race-condition toast on claim conflict.
|
||||
- Shipped all four in commit `0f00ee5`:
|
||||
- **Live AI assessment refresh.** New `HandoffAssessmentReadyEvent` type and `onAssessmentReady` handler on `streamEscalations`. `AssistantChatPage` opens a scoped SSE subscription whenever it tracks a handoff missing its AI assessment; on a matching event it calls `handoffsApi.listHandoffs(sessionId)`, finds the handoff by id, and replaces both `magicHandoff` and `overlayHandoff` in place. Closes the loop on the async-assessment commit `e8ba74e` — without this, the senior had to manually reopen the Context overlay to see the AI assessment when the background task finished.
|
||||
- **Suggested-step chips.** New `chipsHidden` state in `AssistantChatPage`; chip strip renders above the composer when the magic-moment dissolves and `magicHandoff?.ai_assessment_data?.suggested_steps[]` is non-empty. Click prefills input and focuses; first send via `handleSend` flips `setChipsHidden(true)`; explicit X button also hides. Per-session lifetime by design (Codex correction locked).
|
||||
- **Unread 6px dot.** localStorage-backed seen set (`rf-escalation-seen`, capped at 200 entries) hydrated in `EscalationQueue`. Card render adds a 6px `bg-accent` dot when not in the seen set. `markSeen` called on Pick Up click AND on card body click (the "open" affordance). Hover deliberately doesn't clear (Codex correction). Pick Up button's onClick now calls `e.stopPropagation()` so it doesn't double-fire the card-open path.
|
||||
- **Race-condition toast on claim conflict.** New `HandoffAlreadyClaimedError` exception class in `handoff_manager.py`. `claim_session` now eager-loads `claimed_by_user` via `selectinload`, rejects different-user re-claims (idempotent for same-user double-clicks), and raises with `claimed_by_id` / `claimed_by_name` / `claimed_at`. The endpoint translates to HTTP 409 with structured `detail = {error: 'already_claimed', claimed_by_id, claimed_by_name, claimed_at}`. `AssistantChatPage.handleStartHere` extracts via `axios.isAxiosError`, formats `"Already claimed by {name} {time_ago}."` using the existing `timeAgo()` helper, drops `?pickup=true`, and dismisses the magic-moment so the loser flows back to the queue. Backed by 2 new unit tests (`test_claim_session_conflict_raises_already_claimed`, `test_claim_session_idempotent_for_same_user`).
|
||||
- User then reported that the task-lane stale-flash bug was still happening despite the prior fix `8914391` — "every time we work on something that's related to this, when we go back to test we create a new session and then the task lane shows unrelated session data." The previous fix only covered mount-time entry paths (prefill + pickup); any in-place transition still flashed.
|
||||
- Shipped structural fix in commit `665530f`. Introduced `taskLaneOwnerChatId` state that explicitly tags which chatId the in-memory `activeQuestions` / `activeActions` / `showTaskLane` values belong to. Set at every populate site (sendPrefill, selectChat, handleSend, handleTaskSubmit, handleResumeNew, refreshFacts, handleApplyFix). Cleared in `resetSessionDerivedState`. Persistence effect now writes `chatId: taskLaneOwnerChatId` (was `activeChatId` — that was the original write-side bug). Render gate `taskLaneIsForActiveChat = ownerChatId === activeChatId` ANDed into all three render conditions. The lane is structurally unable to display data tagged with a different chat. See DECISIONS entry. **Not yet verified in a real browser** — user is swapping computers and asked for the handoff first.
|
||||
- The two commits `0f00ee5` and `665530f` are **local-only** at session end. The user did not explicitly authorize a push, so per the handoff rule the branch was left unpushed. First action on resume is `git push`.
|
||||
- Tests: full handoff + escalation suite (`test_handoff_manager.py`, `test_session_handoffs_api.py`, `test_escalation_bus.py`, `test_flowpilot_analytics_escalations.py`) → 34 passed in 68.89s. Frontend `tsc -b` exit 0 after each commit.
|
||||
- Files touched: `frontend/src/api/aiSessions.ts`, `frontend/src/components/flowpilot/EscalationQueue.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/types/ai-session.ts`, `backend/app/api/endpoints/session_handoffs.py`, `backend/app/services/handoff_manager.py`, `backend/tests/test_handoff_manager.py`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
|
||||
|
||||
## 2026-04-27 22:30 EDT — Claude Code — Escalation Mode: unify /escalate through HandoffManager
|
||||
|
||||
- User pushed back on the dual-path proposal: "why would we want two different escalation methods? Should the new one just be the way we escalate regardless if we're using a PSA or not using a PSA?" Right answer. Unified everything through `HandoffManager`.
|
||||
- Backend changes (commit `029680a`):
|
||||
- `HandoffCreateRequest` gains optional `target_user_id`; rejects self-targeting.
|
||||
- `HandoffManager.create_handoff` for intent='escalate' now does what the legacy `flowpilot_engine.escalate_session` used to: sets `session.escalation_reason` and `escalated_to_id`, builds the legacy AI-enhanced `escalation_package` via Sonnet (`_build_escalation_package_enhanced` lazy-imported with graceful fallback), and merges handoff metadata (`intent`, `handoff_id`, `snapshot`, `engineer_notes`) into it. Eager-loads `session.steps` + `session.user` via `selectinload` to dodge async lazy-load `MissingGreenlet` errors.
|
||||
- New `HandoffManager.finalize_escalation`: generates `SessionDocumentation`, pushes to PSA, and runs `notify()` (bell-icon AppNotification + Slack/Teams external channels) — all pre-commit so persistent state lands atomically with the handoff. Pulls engineer name via a separate User query rather than relying on `session.user` lazy access.
|
||||
- `dispatch_escalation_notifications` keeps only the fire-and-forget IO (bus publish + per-user emails) post-commit. Found and fixed an in-flight bug: had originally put `notify()` inside dispatch (post-commit), which left `Notification` rows uncommitted — moved into `finalize_escalation` (pre-commit).
|
||||
- `/handoff` endpoint passes `target_user_id` through and calls `finalize_escalation` pre-commit.
|
||||
- `/escalate` is now a thin shim: owner-only session lookup → `create_handoff(intent='escalate')` → `finalize_escalation` → commit → `dispatch_escalation_notifications` → return `SessionCloseResponse`. `flowpilot_engine.escalate_session` is no longer called by any endpoint.
|
||||
- `pickup_session` accepts both `requesting_escalation` (legacy in-flight) and `escalated` (new canonical) so existing queue items migrate seamlessly.
|
||||
- Escalation queue list (`/escalation-queue`) and sidebar count match either status.
|
||||
- Frontend: `useFlowPilotSession` optimistic update flips status to `escalated` instead of `requesting_escalation` so the page state matches the unified backend response.
|
||||
- Verified end-to-end live against the running dev stack: a single legacy `/escalate` call from `engineer@` produced status=`escalated`, a `SessionHandoff` row (`ea9b375a…`, intent='escalate'), a `SessionDocumentation`, a PSA push attempt (`no_psa` since no ticket), AND an `AppNotification` for `teamadmin@` with title "Session escalated by Jordan Tech" and link `/pilot/{session_id}?pickup=true`. Backend test suite: `1103 passed in 259.63s` with `-n auto`. Frontend `tsc -b` clean.
|
||||
- The legacy `SessionBriefing` render branch in `FlowPilotSessionPage.tsx` is now effectively dead for any new escalation (magic-moment takes over via the handoff record), but stays in place during the transition for legacy in-flight `requesting_escalation` sessions. Slated for cleanup after pilots run a couple of weeks on the unified path. `flowpilot_engine.escalate_session` is similarly orphaned and can be deleted at the same time.
|
||||
- Files touched: `backend/app/api/endpoints/ai_sessions.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/app/api/endpoints/sidebar.py`, `backend/app/schemas/session_handoff.py`, `backend/app/services/flowpilot_engine.py`, `backend/app/services/handoff_manager.py`, `frontend/src/hooks/useFlowPilotSession.ts`.
|
||||
|
||||
## 2026-04-27 21:50 EDT — Claude Code — Escalation Mode: bell-icon notification fix; push + draft PR
|
||||
|
||||
- User ran a live escalation test via the EscalateModal (legacy `/escalate` path) and reported that clicking the bell-icon notification "just clears the notification instead of taking me to the session". Diagnosed: navigation IS happening, but the notification link template was `/pilot/{session_id}` without `?pickup=true`, so the senior landed on `FlowPilotSessionPage` with no pickup mode. `loadSession` then hit `GET /ai-sessions/{id}` which 404'd because the senior wasn't owner / `escalated_to_id` / picked-up handler. The user perceived the resulting error state as the action having done nothing.
|
||||
- Two-part backend fix shipped in `641853a`. (1) `_build_notification_link` for `session.escalated` now ends with `?pickup=true` so notification clicks route through the senior-pickup flow (handoff-based or legacy SessionBriefing). (2) `GET /ai-sessions/{id}` access policy: any account member can now read a session's detail when status is `requesting_escalation` or `escalated`. Tenant boundary enforced by RLS — the owner-only guard was overly restrictive for explicitly-shared in-transit states. After-pickup access (handler / `escalated_to_id`) checks still apply for active/resolved sessions.
|
||||
- Verified end-to-end live: re-login as senior engineer (non-owner, non-target) and `GET /ai-sessions/{escalated-session-id}` returns 200 with full detail. Backend regression with broader subset (`test_escalation_bus`, `test_handoff_manager`, `test_session_handoffs_api`, `test_flowpilot_analytics_escalations`, `test_sessions`, `test_session_sharing`) → 94 passed in 43.26s.
|
||||
- Pushed `feat/escalation-metric-endpoint` to Gitea. Opened **draft PR #155** against `main` via Gitea API ([gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155](https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155)). Title prefixed `WIP:` so Gitea marks it `draft: true`. PR body links the design + test-plan artifacts and mirrors the test plan as a checklist with visual QA + e2e demo flow as the unchecked items.
|
||||
- Open question for next session: EscalateModal still calls the legacy `/escalate` endpoint, not the new `/handoff` path. The wedge demo flow (junior escalates → magic-moment renders) is cleaner if EscalateModal goes through `/handoff`. Legacy path does PSA documentation push that the handoff path doesn't, so a parallel path (legacy escalate also creates a handoff record) is probably the right call rather than full migration.
|
||||
- Files touched: `backend/app/api/endpoints/ai_sessions.py`, `backend/app/services/notification_service.py`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-04-27 21:30 EDT — Claude Code — Escalation Mode: magic-moment handoff-context screen on pickup
|
||||
|
||||
- Continued the same session that shipped the live-arrival SSE subscription. Added the magic-moment screen on top.
|
||||
- New `frontend/src/components/flowpilot/HandoffContextScreen.tsx`: presentational 4-section view (header with problem summary + domain + step count + escalated-time + priority badge; "What's been tried" with engineer notes + step-count affordance; "AI assessment" with likely_cause / suggested_steps / confidence badge; "Start here" CTA). Confidence badge accepts both numeric (0..1) and string ("low"/"medium"/"high") shapes — backend emits the latter, the frontend type says `number`, runtime handles both. Renders an explicit "assessment unavailable — model didn't respond in time" branch when `ai_assessment_data` is null (the 5s timeout from `9bdd995` fired). `prefers-reduced-motion` swaps `animate-slide-up` for `animate-fade-in`. ARIA `role=dialog` + `aria-modal=true` + focus on primary CTA on mount + Esc dismiss when used as a re-openable overlay.
|
||||
- Integration in `frontend/src/pages/FlowPilotSessionPage.tsx`: on `/pilot/:id?pickup=true`, fetch the handoff list via `handoffsApi.listHandoffs` (account-scoped via RLS, no claim required) and find the latest unclaimed escalate handoff. If found, render the screen and skip `loadSession` (the senior would 404 pre-claim because they aren't yet `escalated_to_id`). "Start here" calls `handoffsApi.claimHandoff`, drops the `?pickup=true` query, and dismisses the screen — the existing `loadSession` effect then fires because the senior is now `escalated_to_id`. New "Context" toolbar button on active sessions (visible only when the senior arrived via the magic-moment flow this session — handoff lookup on demand) re-opens the screen as a dismissible overlay.
|
||||
- Verified end-to-end against the running dev stack: `listHandoffs` returns the unclaimed handoff with full payload (engineer_notes, snapshot keys); `claimHandoff` flips session status from `escalated` → `active` and sets `escalated_to_id`; subsequent `GET /ai-sessions/{id}` succeeds. `tsc -b` exit 0. No backend changes; backend tests still `32 passed in 18.91s`.
|
||||
- Deferred to TODOs in `CURRENT_TASK.md`: suggested-step chips below the chat input (Codex correction; threads through to `FlowPilotMessageBar`); `HandoffManager._generate_snapshot` expansion to include the recent diagnostic timeline pre-claim (today's snapshot is just `problem_summary, problem_domain, status, step_count, confidence_tier`); toolbar "Context" button visibility on revisited active sessions; owner-facing `/analytics/escalations` page; Playwright e2e for the GTM Loom demo path.
|
||||
- Branch state: 3 new commits (`b8627f4` SSE subscription, `f65b657` handoff doc bump, `8e9d22e` magic-moment screen). Branch is unpushed — next session pushes + opens draft PR.
|
||||
- Files touched this slice: `frontend/src/components/flowpilot/HandoffContextScreen.tsx` (new), `frontend/src/components/flowpilot/index.ts`, `frontend/src/pages/FlowPilotSessionPage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-04-27 21:00 EDT — Claude Code — Escalation Mode: frontend SSE subscription in EscalationQueue
|
||||
|
||||
- Picked up `feat/escalation-metric-endpoint` after the Codex test-stabilization pass. Confirmed green starting state: focused backend subset `32 passed in 18.78s` with `-n auto`.
|
||||
- Implemented the live-arrival frontend slice. Added `streamEscalations(handlers, signal)` to `frontend/src/api/aiSessions.ts` — fetch-based `ReadableStream` reader (native `EventSource` can't send auth headers) that parses SSE frames (event/data/comment lines), buffers partial frames across chunks, ignores `: keepalive` heartbeats, dispatches `ready` and `handoff_created` events. Added `HandoffCreatedEvent` and `EscalationStreamHandlers` types in `frontend/src/types/ai-session.ts` mirroring the backend bus payload.
|
||||
- Rewrote `frontend/src/components/flowpilot/EscalationQueue.tsx`. SSE subscription with `AbortController` + exponential-backoff reconnect (1s → 30s cap, attempt counter resets on `ready`). On `handoff_created` the component refetches the queue, diffs against the previous IDs via a `sessionsRef`, prepends new arrivals (newest-first) above established cards (oldest-first preserved). New IDs are tagged for 800ms so the locked 200ms slide-in animation plays before cleanup. Tab-title flash: captures `document.title` at mount, prefixes `(N)` while `document.hidden`, clears on `focus` / `visibilitychange`, restores on unmount. `prefers-reduced-motion: reduce` swaps `animate-slide-in-bottom` for `animate-fade-in`. ARIA: `role="region"` + `aria-live="polite"` on the list, `aria-label="N escalations awaiting pickup"` on the heading; Pick Up button bumped to `py-2.5` to clear the 44px touch floor.
|
||||
- Verified end-to-end against the running dev stack. `tsc -b` exit 0. Vite HMR'd the new component without errors. Raw SSE handshake against `/api/v1/ai-sessions/escalations/stream` returned 200 with `text/event-stream; charset=utf-8` plus the locked headers (`cache-control: no-cache`, `x-accel-buffering: no`). Subscriber received the `ready` frame on connect; after posting a handoff via the API, the subscriber received the `handoff_created` frame with the full payload — wire format matches the parser exactly. Backend regression: same focused subset still `32 passed in 18.91s`.
|
||||
- Not yet verified (would need a real browser session): the slide-in animation visually plays, the tab title actually updates, the reduced-motion media-query path, AbortController cancellation on unmount, backoff after a real network blip. Wire contract is confirmed; these are visual/timing-dependent and follow from correct parser + state machine.
|
||||
- Smoke-test artifact: a single test handoff (`0f6149db…` on session `50ea20d4…`) is sitting in the engineer's queue from the verification step. Harmless; useful as visual demo data.
|
||||
- Left for next session: the magic-moment handoff-context screen — 4 sections (problem summary / what's been tried / AI assessment / Start here CTA), loads on Pick Up, dissolves into the regular FlowPilot session view. Must render gracefully when `ai_assessment` is `None` (per the 5s assessment timeout from Codex's earlier fix).
|
||||
- Files touched: `frontend/src/api/aiSessions.ts`, `frontend/src/types/ai-session.ts`, `frontend/src/components/flowpilot/EscalationQueue.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-04-27 EDT — Claude Code — Escalation Mode wedge: design through SSE backend (8 commits)
|
||||
|
||||
- One long session that produced the entire planning artifact stack and most of the backend for the Escalation Mode wedge. Output of `/office-hours` (8 founder-signal session, top-tier YC archetype indicators), `/plan-eng-review` (scope reduced from "2-3 weeks greenfield" to "~6-9 days integration + metric + polish" once the existing handoff_manager surface was inventoried), `/plan-design-review` (6/10 → 9/10 with magic-moment screen, hero metric placement, and real-time arrival visual locked), and `/codex review` (12 findings, 6 applied — two-metric framing, notification routing, claim auth gate moved in-scope, unread-state fix, "Start here" CTA reframe, per-channel delivery model; 5 rejected including the full-scope reduction Codex pushed for).
|
||||
- Branched `feat/escalation-metric-endpoint` off `main` @ `c0ed6d9`. Stack at session end: `d51e95c` plan + test-plan artifacts; `52f6d03` `GET /analytics/flowpilot/escalations` endpoint with 9 tests including multi-tenant isolation; `7a5b853` claim-endpoint role gate; `07d0db9` email dispatch on escalate with graceful-degradation regression; `9f0bfd4` `EscalationMetricCard` mounted above the queue list; `a283d0d` mid-flight `.ai/` refresh; `87bd0b7` WIP commit for SSE pub/sub bus + endpoint + 7 bus unit tests + 1 dispatcher integration test + 2 endpoint tests; `ba46fc5` paused-for-Codex-review handoff. Codex picked up from `ba46fc5` and added `bc15952` / `fff8338` / `9bdd995` (test stabilization + assessment latency bound).
|
||||
- Pause was forced by a runaway local test loop: multiple stale `pytest` processes were left inside `resolutionflow_backend` after several aborted runs and contended on the same Postgres test schema. Codex diagnosed and fixed (see entry above).
|
||||
- Frontend: thin slice — added `getEscalationMetrics` to `flowpilotAnalyticsApi`, the `EscalationMetricCard` component (loading / error / zero-data states + avg + median + conversion-rate + the inline two-metric disclaimer), and mounted it above `EscalationQueue`. `tsc -b` clean.
|
||||
- Plan-stage UI decisions locked into the design doc and the codebase: dedicated 4-section magic-moment screen on Pick Up that dissolves into FlowPilot; queue stat-card + dedicated owner analytics page for the hero metric (in two places, not one); 200ms slide-in + tab-title flash on real-time arrival, no sound, respects `prefers-reduced-motion`; unread dot clears on open/claim/dismiss, NOT on hover (Codex correction). Claim role gate moved in-scope per Codex (not deferred to TODO).
|
||||
- Two TODOs added: peer-tech escalation (deferred to v2 once a pilot asks); mobile/responsive design (also v2; pre-PMF wedge demo targets desktop). Claim role gate's TODO entry was struck through in the same session because it shipped in `7a5b853`.
|
||||
- Plan and test-plan artifacts copied into `docs/plans/` under the `YYYY-MM-DD-name-design.md` / `-test-plan.md` convention so they live alongside the existing project plans, not just in `~/.gstack/projects/`.
|
||||
- Left for next session: frontend SSE subscription in `EscalationQueue.tsx` (fetch-based ReadableStream — native EventSource can't send auth headers; match `streamDocumentation` in `frontend/src/api/aiSessions.ts`), then the magic-moment handoff-context screen, then push + draft PR. Default Claude Code model is being switched from Opus 4.7 1M-context to Opus 4.7 (200k) for the next session — the resume docs are sized to be self-sufficient under the smaller window.
|
||||
- Files touched (committed): `docs/plans/2026-04-27-escalation-mode-wedge-design.md`, `docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md`, `backend/app/api/endpoints/flowpilot_analytics.py`, `backend/app/schemas/flowpilot_analytics.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/app/services/handoff_manager.py`, `backend/app/core/escalation_bus.py` (new), `backend/tests/test_flowpilot_analytics_escalations.py` (new), `backend/tests/test_escalation_bus.py` (new), `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `frontend/src/types/flowpilot-analytics.ts`, `frontend/src/api/flowpilotAnalytics.ts`, `frontend/src/components/flowpilot/EscalationMetricCard.tsx` (new), `frontend/src/components/flowpilot/index.ts`, `frontend/src/pages/EscalationQueuePage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/TODO.md`.
|
||||
|
||||
## 2026-04-27 19:50 EDT — Codex — Stabilize Escalation Mode SSE backend tests
|
||||
|
||||
- Diagnosed slow backend tests on `feat/escalation-metric-endpoint`. Multiple stale pytest processes were still alive inside `resolutionflow_backend` and held `resolutionflow_test` transactions open, blocking later per-test schema resets on `DROP SCHEMA public CASCADE`.
|
||||
- Reproduced a deterministic hang in `test_escalations_stream_returns_sse_content_type`: HTTPX `ASGITransport` buffers the full response body before returning, so an infinite SSE response never yielded the initial chunk and kept the auth DB dependency transaction open.
|
||||
- Fixed `stream_escalations` to release auth dependencies before the long-lived stream body with `Depends(..., scope="function")`.
|
||||
- Reworked the SSE handshake test to call `stream_escalations()` directly and consume one generator yield, then close it; kept viewer role-gate coverage through the API client.
|
||||
- Stubbed `_generate_ai_assessment()` in handoff manager/API tests so escalation handoff tests no longer wait on the real AI path.
|
||||
- Normalized account IDs inside `EscalationBus` so string UUIDs and `UUID` objects hit the same subscriber bucket; added a regression test.
|
||||
- Verified focused backend subset: serial `31 passed in 46.95s`; xdist `31 passed in 17.80s`. Confirmed no lingering pytest processes or test DB sessions afterward.
|
||||
- Follow-up in the same session: fixed the product latency risk by adding `ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS` (default 5s) around escalation AI assessment generation. If the optional assessment times out, handoff creation continues with no assessment. Added regression coverage; focused xdist subset now `32 passed in 17.77s`.
|
||||
- Left for next session: continue frontend SSE subscription in `EscalationQueue.tsx`, then the magic-moment handoff-context screen.
|
||||
- Files touched: `backend/app/api/endpoints/session_handoffs.py`, `backend/app/core/config.py`, `backend/app/core/escalation_bus.py`, `backend/app/services/handoff_manager.py`, `backend/tests/test_escalation_bus.py`, `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/TODO.md`.
|
||||
|
||||
## 2026-04-26 03:50 EDT — Claude Code — Ship AssistantChatPage prefill `currentChatRef` fix; close out PR #150
|
||||
|
||||
- User reported a troubleshooting-session bug: after answering a subset of task-lane questions and clicking *Send N of M Responses*, no AI response appeared. Traced to `AssistantChatPage`: the dashboard prefill effect set `activeChatId` after creating a new chat session but never updated `currentChatRef.current`. The `currentChatRef.current !== sentForChatId` guard in `handleSend` and `handleTaskSubmit` then bailed silently on every later request and discarded the AI's reply. The user message was already pushed to the chat before the await, so the user saw their answers but nothing else.
|
||||
- Fix: one-line addition mirroring `handleNewChat` and `handleResumeNew` — assign `currentChatRef.current = session.session_id` immediately after `setActiveChatId(session.session_id)` in the prefill effect. Branched off `origin/main` as `fix/tasklane-prefill-ref`; PR #153 opened on Gitea.
|
||||
- Authored a Playwright regression test `frontend/e2e/assistant-chat-prefill.spec.ts` that drives the real dashboard prefill flow against the real backend, stubs `/ai-sessions/*/chat` with `page.route` for deterministic turn-1/turn-2 responses, and asserts the second AI message renders. Confirmed the test fails on unfixed code at the exact assertion (`Got it — based on your answer…` never appears) and passes once the fix is restored.
|
||||
- Verified locally inside `mcr.microsoft.com/playwright:v1.58.2-noble` against the running dev stack: new spec passes, adjacent `flowpilot-chat` spec still passes, `tsc -b` clean. `resume.spec` and `history.spec` failures observed are pre-existing real-backend fixture collisions, unrelated to this change.
|
||||
- First CI run on PR #153 failed on infrastructure issues already addressed by PR #150: backend hit `Bind for 0.0.0.0:5432 failed: port is already allocated`, frontend hit `actions/upload-artifact@v4 not supported on GHES`. PR #150 was already merged (commit `87bb20b` on `main`). Rebased `fix/tasklane-prefill-ref` onto new `main` (force-push `1a8cb06` → `1559feb`), resolved a `.ai/TODO.md` conflict by keeping both backlog item sets, kicked off CI on the rebased SHA.
|
||||
- Confirmed `CI / backend (pull_request)` is now in branch protection's required-status-checks list (added during PR #150 close-out). `CI / e2e (pull_request)` left as not-required pending one more clean PR run as the threshold.
|
||||
- Recorded the broader silent-return concern in TODO backlog: the `currentChatRef.current !== sentForChatId` guard is applied across `handleSend`, `handleTaskSubmit`, `selectChat`, `refreshFacts`, `refreshActiveFix`, and `refreshPreview`. PR #153 fixes one symptom but the same pattern can mask other drift. Either log a Sentry breadcrumb on the mismatch path or distinguish "expected stale" (chat switch) from "unexpected stale" (ref never updated) so the latter alerts.
|
||||
- First CI run on the rebased SHA passed backend and frontend but failed e2e: the new prefill regression test couldn't render the task-lane question text. Diagnosed via the job log: `POST /api/v1/ai-sessions` calls `_require_ai_enabled()` and returns 503 when no provider key is set. The e2e CI job had neither `ANTHROPIC_API_KEY` nor `GOOGLE_AI_API_KEY` in env. Locally the dev backend has a real key, hence the local pass. The Playwright `page.route` stub on `/chat` was correct but never had a chance to fire because the upstream session-creation call was 503-ing.
|
||||
- Fix: added a stub `ANTHROPIC_API_KEY: ci-stub-key-not-used-by-tests` to the e2e job env in `.gitea/workflows/ci.yml`. The Playwright stub still intercepts the actual `/chat` call in the browser, so the backend never contacts Anthropic — the gate just needs to clear. Documented the convention in a workflow comment so future AI-touching e2e tests know what to expect. Pushed `11fe32f`; CI went all-green.
|
||||
- Merged PR #153 as `68fcdc6` on `main`. Local feature branch and remote both deleted via Gitea's `delete_branch_after_merge`.
|
||||
- Opened a small follow-up `chore/post-153-handoff` PR to refresh the now-stale `.ai/` files (this entry, plus `CURRENT_TASK.md` rolling forward to "no active task — pick from `TODO.md`" and `HANDOFF.md` updating to the post-merge home position). The `data-testid` audit at the top of `TODO.md` "Up next" or the `currentChatRef` silent-return audit added in this session's backlog are the natural next pickups.
|
||||
- Files touched: `frontend/src/pages/AssistantChatPage.tsx` (the one-line fix + comment), `frontend/e2e/assistant-chat-prefill.spec.ts` (new regression test), `.gitea/workflows/ci.yml` (stub `ANTHROPIC_API_KEY` for e2e), `.ai/TODO.md` (silent-return follow-up entry, plus conflict resolution preserving PR #150's backlog additions), `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md` (this entry).
|
||||
|
||||
## 2026-04-25 16:41 EDT — Codex — Stabilize PR #150 e2e selectors
|
||||
|
||||
- Investigated the remaining PR #150 failure after backend and frontend CI were green. The e2e resume smoke test was not failing because of product behavior; it used `.bg-card` plus text filtering and matched the tree filter `<select>` before the intended session card.
|
||||
- Added stable test IDs to flow session, tree, and share cards, then updated affected e2e tests to target those cards instead of Tailwind class names.
|
||||
- Hardened the CI workflow by making Postgres healthchecks authenticate as `postgres` and baking `VITE_API_URL="${PLAYWRIGHT_API_ORIGIN}"` into the e2e frontend build.
|
||||
- Verified with `git diff --check`, frontend build in Docker, no remaining `.bg-card` e2e selectors, and focused Playwright runs in an Actions-like Ubuntu container: resume spec passed, then history/library/library-start/resume/shares passed (`6 passed`).
|
||||
- Left for next session: push this WIP commit to PR #150, watch CI, merge when all three jobs are green, then enable backend branch protection and consider the e2e gate after a reliable green run.
|
||||
- Files touched: `.gitea/workflows/ci.yml`, `frontend/e2e/history.spec.ts`, `frontend/e2e/library-start.spec.ts`, `frontend/e2e/library.spec.ts`, `frontend/e2e/resume.spec.ts`, `frontend/e2e/shares.spec.ts`, `frontend/src/components/library/TreeGridView.tsx`, `frontend/src/components/library/TreeListView.tsx`, `frontend/src/pages/MySharesPage.tsx`, `frontend/src/pages/SessionHistoryPage.tsx`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-04-25 12:00 America/New_York — Claude Code — Mock final AI-provider test, cache CI deps, parallelize backend with pytest-xdist
|
||||
|
||||
- Diagnosed why CI was still red despite Codex's local 1076 passed: a single test (`test_record_decision_persists_and_bumps_state_version`) needed `ANTHROPIC_API_KEY` because the `decision: draft_template` path calls `TemplateExtractionService` → AI provider. Patched `_extract_template_parameters` with an `AsyncMock` so the test no longer depends on AI availability. Verified.
|
||||
- Pushed Codex's WIP commit `49f8856` to PR #150 (had been local-only per handoff protocol).
|
||||
- PR #150 (`fix/ci-workflow-config`) extended with cheap CI wins: `actions/cache@v3` for pip + npm in all three jobs; dropped `--cov-report=term-missing` (the custom display step parses JSON); added `--maxfail=10` so structural breakage exits fast.
|
||||
- PR #151 (`fix/ci-pytest-xdist`) opened, stacked on #150: pytest-xdist with per-worker DB isolation. `conftest.py` reads `PYTEST_XDIST_WORKER`, computes a per-worker DB URL like `…_gw0`, and synchronously CREATEs the DB on first import. The per-test `DROP SCHEMA public CASCADE` then operates on the worker's isolated DB. Verified locally: backend suite went from 22m 27s serial → 4m 28s parallel (8 workers), 1076 passed in both cases. ~5× speedup.
|
||||
- Decided NOT to do per-test transactional rollback (bigger refactor); captured for future TODO consideration.
|
||||
- Left for next session: watch CI on both PRs, merge in order (#150 first, #151 second), then enable `CI / backend (pull_request)` as a required status check on main.
|
||||
- Files touched: `backend/tests/test_session_suggested_fixes_api.py`, `backend/tests/conftest.py`, `backend/requirements-dev.txt`, `.gitea/workflows/ci.yml`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/TODO.md`.
|
||||
|
||||
## 2026-04-25 06:12 EDT — Codex — Fix backend suite to green
|
||||
|
||||
- Fixed the real backend failures left after the CI-infra cleanup: tenant-scoped seed drift, missing production `account_id` writes, public route mounting for survey/share links, Script Builder library saves, resolution output async loading, AI search schema metadata, disabled-AI fixture leakage, and prompt marker guardrails.
|
||||
- Added backend CI/dev system packages required by WeasyPrint PDF export.
|
||||
- Stabilized the pytest harness for pytest-asyncio/asyncpg teardown ResourceWarnings under `filterwarnings = error`.
|
||||
- Verified `pytest --override-ini="addopts=" -q` inside `resolutionflow_backend`: `1076 passed, 35 deselected in 1347.41s`.
|
||||
- Left for next session: commit/push if needed, check and merge PR #150 when Gitea CI is green, add backend CI as a required branch-protection check, and rerun frontend lint if final DoD requires it.
|
||||
- Files touched: `.gitea/workflows/ci.yml`, `backend/Dockerfile.dev`, `backend/app/api/endpoints/folders.py`, `backend/app/api/endpoints/script_builder.py`, `backend/app/api/endpoints/shares.py`, `backend/app/api/router.py`, `backend/app/models/ai_session.py`, `backend/app/schemas/user.py`, `backend/app/services/assistant_chat_service.py`, `backend/app/services/resolution_output_generator.py`, `backend/app/services/script_builder_service.py`, `backend/pytest.ini`, `backend/tests/conftest.py`, and focused backend tests.
|
||||
|
||||
## 2026-04-25 02:00 America/New_York — Claude Code — Land FlowPilot + PSA, recover CI from 488 errors to ~4
|
||||
|
||||
- Started session by completing pending FlowPilot Phase 9 QA: ran `/qa` against the seeded fixtures, found and fixed four latent layout/state bugs (`ResolutionNotePreview` off-screen, `TemplateMatchPanel` deadlock when TaskLane closed, `EscalateInterceptDialog` clipped above viewport, `seed_test_users.py` `cancel_at_period_end` NOT NULL crash). Added a new fixture seeder `backend/scripts/seed_phase9_qa_fixtures.py` that pre-bakes the four backend states the AI orchestrator needs to emit, so future QA can exercise all 7 conditional Phase 9 components without depending on stochastic AI behavior.
|
||||
- Discovered PR #141 (PSA ticket management) and `feat/flowpilot-migration` had 5 overlapping files but only 2 real conflicts (`CLAUDE.md`, `AssistantChatPage.tsx`). Conflicts were both additive — concatenated rather than chose-a-side.
|
||||
- Merged PSA first (PR #141), then merged FlowPilot (PR #147), each through Gitea API. `tsc -b` clean and visual smoke-test confirmed PSA's Tickets sidebar coexists with Phase 9 ProposalBanner.
|
||||
- Discovered main had been merging through a broken CI gate for several merges. Initially recommended "stop the line, fix CI before shipping." After scoping the actual rot (~50% of tests red, ~600 errors on a clean run), reversed the recommendation: ship the queue first because FlowPilot itself carried significant test-infra repairs that would be duplicated work on a fresh recovery branch.
|
||||
- PR #148: two surgical fixes to main (network_diagrams JSONB `server_default` triple-quote bug, deprecated session-scoped `event_loop` fixture in conftest). +78 passing / -114 errors.
|
||||
- PR #149: frontend lint `20 errors → 0`, `requirements-dev.txt` pytest pin bumped to satisfy `pytest-asyncio==0.24.0`'s `pytest>=8.2`, and a one-line `from app import models as _models` in conftest that registers all ~60 models with `Base.metadata` before `create_all`. The conftest fix collapsed 484 of the remaining 488 backend errors. `1018 passed / 4 errors / 54 failed` after.
|
||||
- Enabled Gitea branch protection on `main`: PR-only merges, `CI / frontend (pull_request)` required, force-push blocked, no review required.
|
||||
- Discovered CI on the merge commit STILL showed red despite local pytest being mostly green. Root cause: workflow only set `DATABASE_URL`, but conftest reads only `DATABASE_TEST_URL` (per `dab740d`'s safety hardening). 638 connection-refused errors on every fixture setup. Plus `actions/upload-artifact@v4` not supported by Gitea Actions. PR #150 fixes both.
|
||||
- Left for next session: merge PR #150 once CI confirms green, add `CI / backend (pull_request)` to required status checks, then root-cause and fix the 54 real backend test failures (one sample seen — `test_user` fixture leaking across calls causing duplicate-email violations).
|
||||
- Files touched (committed): `backend/scripts/seed_test_users.py`, `backend/scripts/seed_phase9_qa_fixtures.py` (new), `backend/app/models/network_diagram.py`, `backend/tests/conftest.py`, `backend/requirements-dev.txt`, `frontend/src/components/pilot/ResolutionNotePreview.tsx`, `frontend/src/components/pilot/EscalateInterceptDialog.tsx`, `frontend/src/components/pilot/ScriptBuilderTab.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/pages/FlowPilotSessionPage.tsx`, `frontend/src/pages/TicketsPage.tsx`, `frontend/src/hooks/useFlowPilotSession.ts`, `frontend/src/hooks/useMediaQuery.ts`, `frontend/src/components/dashboard/TicketQueue.tsx`, `frontend/src/components/network/nodes/DeviceNode.tsx`, `frontend/src/components/network/nodes/GroupNode.tsx`, `frontend/src/components/routing/AssistantSessionRedirect.tsx` (new), `frontend/src/router.tsx`, `.gitea/workflows/ci.yml`, `.claude/settings.json` (new), `.claude/hooks/check-gstack.sh` (new), `.gitignore`, `CLAUDE.md`, `.gstack/qa-reports/phase9-*/` (QA artifacts).
|
||||
- Net merges to main: PR #141 (PSA), PR #147 (FlowPilot), PR #148 (CI fixes part 1), PR #149 (CI fixes part 2). PR #150 still open at session end.
|
||||
|
||||
## 2026-04-24 — Claude Code — Migrate to dual-agent handoff system
|
||||
|
||||
- Split CLAUDE.md into `.ai/PROJECT_CONTEXT.md` + shared-protocol root files (`CLAUDE.md`, `AGENTS.md`).
|
||||
- Seeded `CURRENT_TASK.md`, `HANDOFF.md`, `TODO.md`, `DECISIONS.md`, `SESSION_LOG.md`, `README.md`.
|
||||
- Deleted legacy `SESSION-HANDOFF.md` (superseded).
|
||||
- Left for next session: first real feature task should replace the seed `CURRENT_TASK.md` and update `HANDOFF.md` with real resume state.
|
||||
- Files touched: `.ai/*.md` (created), `CLAUDE.md` (rewritten), `AGENTS.md` (created), `SESSION-HANDOFF.md` (deleted).
|
||||
- Follow-up (same day): Codex review pass flagged stale SaaS-role claim and incomplete file-listings carried over from the pre-migration CLAUDE.md. Verified against `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`, `backend/app/api/deps.py`, `backend/app/api/router.py`, and `backend/app/services/psa/`. Corrected PROJECT_CONTEXT.md role hierarchy (`super_admin > owner > engineer > viewer`, not `team_admin`), added `require_account_owner` / `require_team_admin` to deps list, replaced stale endpoint comment with a summary pointing at `api/router.py`, added `exceptions.py` + `ticket_context.py` to the PSA file list. Also replaced seed-example content in `CURRENT_TASK.md` and `TODO.md` with clearer empty-state sentinels.
|
||||
- Branch cleanup (same day): committed pending test-isolation work as `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`, new Phase 9 review doc as `b3506b5 docs(pilot): phase 9 review issues`, and `.remember/` gitignore entry as `b3be1e0 chore: ignore .remember/ skill runtime state`. Deleted `docs/landing-handoff/` (prepared for external design work, not meant to live in the repo). Working tree clean; 3 cleanup commits unpushed.
|
||||
|
||||
## 2026-05-07 UTC — Codex — Resolve PR #162 CI failures
|
||||
|
||||
- Investigated Gitea PR #162 failing checks for `feat/self-serve-signup-phase-2`. Public status metadata was available, but job logs required Gitea login and no token was present.
|
||||
- Standardized backend development/CI Python on 3.12.13 to match the Docker image: added `.python-version`, updated Gitea CI Python setup, rebuilt the local backend virtualenv, and verified native `pytest` / `alembic` command availability with explicit local env.
|
||||
- Added explicit Node 20 setup to Gitea frontend and e2e jobs so CI no longer depends on the runner's ambient Node installation.
|
||||
- Reproduced the remaining frontend failure locally. Lint failed on Phase 2 React code because the current eslint stack flags exported pure helpers, render-time `Date.now()`, and effect-driven state synchronization.
|
||||
- Patched the affected frontend surfaces narrowly: dashboard helper exports, app-config cache handling, feature-limit cache/fetch state, trial-banner time capture, invite/OAuth route error state, pricing loading state, and OAuth authorize URL helper export.
|
||||
- Verified sequential frontend CI locally in Docker: `npm run lint` passed, `npm run test:coverage` passed (`198` tests), and `npm run build` passed with only Vite chunk-size warnings.
|
||||
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `.ai/*`, `README.md`, `DEV-ENV.md`, and the frontend lint-fix files under `frontend/src/components/dashboard`, `frontend/src/hooks`, and `frontend/src/pages`.
|
||||
27
.ai/TODO.md
Normal file
27
.ai/TODO.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# TODO.md
|
||||
|
||||
> Backlog of work NOT currently active. Read only when `CURRENT_TASK.md` status is `complete`.
|
||||
> Format: `- [ ] short description — optional link to issue/PR`
|
||||
|
||||
## Up next
|
||||
|
||||
None selected. Pick from the backlog below or `03-DEVELOPMENT-ROADMAP.md`.
|
||||
|
||||
## Backlog
|
||||
|
||||
- [ ] **Frontend lint warnings cleanup.** `npm run lint` currently reports 24 warnings (0 errors): mostly `react-hooks/exhaustive-deps` plus a few unused eslint-disable directives. Either fix them or audit known-safe ones and add/remove eslint-disable comments intentionally. Not blocking CI today.
|
||||
- [ ] **Audit `filterwarnings` ignores added in `wip(handoff): restore backend suite to green`.** Codex added narrow `ResourceWarning` filters for unclosed socket/transport/event-loop noise from pytest-asyncio teardown. Worth periodically reviewing whether those are still needed (e.g. when bumping pytest-asyncio) — if a real warning appears in those forms it would be silenced.
|
||||
- [ ] **Add `data-testid` attributes to e2e-critical interactive elements.** PR #152 fixed five Playwright tests by chasing UI-text changes (`Sessions` → `Session History`, `Account Settings` → `Account Management`, `/assistant` → `/pilot`, "Flow Sessions" tab, Resume button on session cards). Each was a one-line selector update, but every UI churn re-breaks them. Adding stable `data-testid` attributes on the targeted elements (page heading wrappers, tab nav, primary action buttons) and switching tests to `getByTestId` would make these immune to copy/route renames. Scope it small — start with `SessionHistoryPage` heading, the AI/Flow Sessions tab buttons, the per-session `Resume` button, and the command-palette FlowPilot option.
|
||||
- [ ] **Per-test transactional rollback in `test_db` fixture.** Bigger engineering than xdist (which we already shipped). Instead of `DROP SCHEMA public CASCADE` per test, wrap each test in a savepoint and rollback at teardown. ~30-40% additional speedup on top of xdist for test-DB-heavy tests. Real refactor; only worth it if the suite gets significantly larger or runs more frequently.
|
||||
- [ ] **Consider `pytest-testmon` for PR-time test selection.** Tracks which tests touched which source files and only re-runs affected ones. Best for small PRs touching ~few files. Adds cache-invalidation complexity; only worth it if the suite stays painfully long even after xdist.
|
||||
- [ ] **AssistantChatPage `currentChatRef` guard is a silent return** — `handleSend`, `handleTaskSubmit`, `selectChat`, `refreshFacts`, `refreshActiveFix`, and `refreshPreview` all bail with `if (currentChatRef.current !== sentForChatId) return` when stale. This is by design for chat switching, but it also silently masked the prefill-ref bug fixed in PR #153 — the user just saw "no AI response" with no log, no toast, no Sentry event. Either (a) log a `console.warn`/Sentry breadcrumb on the mismatch path so future drift is visible, or (b) split "expected stale" (chat switch) from "unexpected stale" (ref never updated) so only the latter alerts. Pair with an audit of every `currentChatRef.current = ...` assignment vs every `setActiveChatId(...)` call to make sure they're paired everywhere.
|
||||
|
||||
- [ ] **Allow peer-tech to escalate a colleague's session.** Today `POST /ai-sessions/{session_id}/handoff` in [endpoints/session_handoffs.py:48](backend/app/api/endpoints/session_handoffs.py#L48) filters by `AISession.user_id == current_user.id`, so only the session owner can escalate. Real MSP shops have peer hand-offs: Junior A is on lunch, Junior B sees the session is stuck and should be able to escalate it. Auth tweak: switch from session-owner check to `require_engineer_or_admin` + same-account scope. Add a `handed_off_by` audit column (already exists on `SessionHandoff`) so the original-owner-vs-actual-escalator distinction is preserved. Surfaced from /plan-eng-review on the Escalation-Mode wedge plan; v1 wedge demo doesn't need this (solo-founder pilot), but capture for v2 once 3+ pilots are live and a peer-claim need surfaces.
|
||||
|
||||
- [ ] **Mobile/responsive design for EscalationQueue + handoff-context screen.** Pre-PMF wedge demo targets desktop only — MSP techs work on laptops/desktops in shop environments. Once 3+ paying customers exist and a tech requests mobile (likely on-call use case), spec the responsive behavior: stacked card layout below `sm:` breakpoint, full-bleed handoff-context overlay on mobile, swipe-to-claim gesture instead of Pick Up button. Surfaced from /plan-design-review on the Escalation-Mode wedge plan.
|
||||
|
||||
- [ ] **`bg-card-hover` Tailwind class doesn't resolve.** [`frontend/src/components/layout/CommandPalette.tsx:450-451`](../frontend/src/components/layout/CommandPalette.tsx) uses `bg-card-hover` as a Tailwind utility, but Tailwind v4 generates `bg-{token}` from `--color-{token}` — and the token in [`frontend/src/index.css:15`](../frontend/src/index.css) is `--color-bg-card-hover`, which generates `bg-bg-card-hover`, not `bg-card-hover`. So those classes silently produce nothing. Other call sites (KnowledgeBaseCards, TeamSummary, ProposalBanner) use the explicit `hover:bg-[var(--color-bg-card-hover)]` form which works. Fix: change the CommandPalette classes to the explicit-var form, OR add a `--color-card-hover` semantic mapping in index.css alongside `--color-card`. Surfaced 2026-05-01 during impeccable polish sweep.
|
||||
|
||||
- [ ] **`ConcludeSessionModal` paused/escalated step forces single-artifact choice — should allow multi-select.** [`frontend/src/components/assistant/ConcludeSessionModal.tsx`](../frontend/src/components/assistant/ConcludeSessionModal.tsx) ~lines 430-474 ("Paused/Escalated: status update options"). Today the engineer clicks ONE of Ticket Notes / Client Update / Email Draft, the buttons disappear, and the result replaces them. Real MSP escalations almost always need at least two: technical notes for the next engineer's PSA AND a non-technical client update. Same for pause (client update + ticket notes for context when resuming). Recommended shape: multi-select with smart defaults — three checkboxes (`☑ Ticket Notes ☑ Client Update ☐ Email Draft`); for `escalated` pre-check Ticket Notes + Client Update; for `paused` pre-check Client Update only. One "Generate" button fires all selected in parallel via existing `aiSessionsApi.generateStatusUpdate(...)` (already supports the three `audience` values: `ticket_notes`, `client_update`, `email_draft`). Each result renders in its own card with its own Copy / Post-to-PSA / Send-Email action. Surfaced 2026-05-01. Feature work, not polish — touches streaming wiring for parallel calls.
|
||||
|
||||
- [ ] **Centralize plan-tier taxonomy — derive admin plan dropdown (and validation) from `plan_limits`, not hardcoded lists.** Chose **Option B** over a one-line patch (see [DECISIONS.md](DECISIONS.md) 2026-05-29). *Surfaced by a prod bug (2026-05-28):* the admin "Change Plan" dropdown at [`AccountDetailPage.tsx:443-445`](../frontend/src/pages/admin/AccountDetailPage.tsx) still offered `free / pro / team` — the dead `team` slug (renamed to `enterprise` in migration `4ce3e594cb87`, 2026-05-07) and missing `starter`/`enterprise`. Selecting "Team" sends `{plan:"team"}` to `PUT /admin/accounts/{id}/subscription/plan`, which 400s on `if data.plan not in ("free","pro","starter","enterprise")` ([admin.py:994](../backend/app/api/endpoints/admin.py#L994), duplicated at [:975](../backend/app/api/endpoints/admin.py#L975)). The 400 detail was swallowed by a generic `toast.error('Failed to update plan')` ([AccountDetailPage.tsx:196](../frontend/src/pages/admin/AccountDetailPage.tsx)), so it presented as "AI sessions are down" (real cause: owner account had no paid plan; AI is plan-gated). **Root cause of the root cause:** the allowed-plan list is hand-duplicated across ≥6 sites and drifted (2nd such incident). **Duplication sites to consolidate:** backend [`admin.py:975`](../backend/app/api/endpoints/admin.py#L975) + [`:994`](../backend/app/api/endpoints/admin.py#L994) (tuple, twice), [`schemas/admin.py:128`](../backend/app/schemas/admin.py) (`AdminAccountCreate.plan` Literal), frontend `AccountDetailPage.tsx` dropdown, `AccountsPage.tsx` create-account dropdown, `types/admin.ts` + `types/account.ts` + `types/billing.ts`, `hooks/useSubscription.ts` (`isPaidPlan`), `components/subscription/CheckoutButton.tsx` (`planLabels`). **Source of truth:** the `plan_limits` table (rows: free/starter/pro/enterprise) — `PlanLimitWithBillingResponse` already exposes `is_public` + `sort_order` + `display_name` for ordering/labels. **End state (B):** admin dropdown + pricing/checkout derive options from a plans endpoint backed by `plan_limits` (filter `is_public`, order by `sort_order`, label from `display_name`); backend validation checks against actual `plan_limits` rows instead of a hardcoded tuple. **Trivial first commit (land anytime to unblock the admin tool):** fix the `AccountDetailPage` dropdown to `Free / Starter / Pro / Enterprise` and surface the backend error detail in the toast. ⚠️ The `'team'` string in `Tree.visibility` / `StepLibrary.visibility` is a *separate domain* (shared-with-account) — do NOT touch it.
|
||||
20
.claude/hooks/check-gstack.sh
Executable file
20
.claude/hooks/check-gstack.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Block skill usage when gstack is not installed globally.
|
||||
|
||||
if [ ! -d "$HOME/.claude/skills/gstack/bin" ]; then
|
||||
cat >&2 <<'MSG'
|
||||
BLOCKED: gstack is not installed globally.
|
||||
|
||||
gstack is required for AI-assisted work in this repo.
|
||||
|
||||
Install it:
|
||||
git clone --depth 1 https://github.com/garrytan/gstack.git ~/.claude/skills/gstack
|
||||
cd ~/.claude/skills/gstack && ./setup --team
|
||||
|
||||
Then restart your AI coding tool.
|
||||
MSG
|
||||
echo '{"permissionDecision":"deny","message":"gstack is required but not installed. See stderr for install instructions."}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo '{}'
|
||||
15
.claude/settings.json
Normal file
15
.claude/settings.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Skill",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/check-gstack.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
12
.env.example
Normal file
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
||||
REPO_ROOT=/opt/docker/code-server/workspace/resolutionflow
|
||||
POSTGRES_PORT=5433
|
||||
SECRET_KEY=
|
||||
ANTHROPIC_API_KEY=
|
||||
GOOGLE_AI_API_KEY=
|
||||
|
||||
STRIPE_SECRET_KEY=sk_test_
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_
|
||||
STRIPE_WEBHOOK_SECRET=whsec_
|
||||
VITE_STRIPE_PUBLISHABLE_KEY=pk_test_
|
||||
|
||||
INTERNAL_TESTER_EMAILS=internaltest@resolutionflow.com
|
||||
@@ -17,10 +17,13 @@ jobs:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: resolutionflow_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
# No host port mapping. Tests connect to `postgres:5432` (the service
|
||||
# container's docker-network DNS name), not `localhost:5432`. With
|
||||
# multiple Gitea runners on the same homelab box, host-port mapping
|
||||
# would race — two backend/e2e jobs both binding 0.0.0.0:5432 → the
|
||||
# second fails with "port is already allocated".
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
@@ -28,6 +31,12 @@ jobs:
|
||||
env:
|
||||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
|
||||
DATABASE_URL_SYNC: postgresql://postgres:postgres@postgres:5432/resolutionflow_test
|
||||
# conftest.py reads DATABASE_TEST_URL only (DATABASE_URL is intentionally
|
||||
# not consulted after the dab740d test-isolation hardening). The CI test
|
||||
# DB is the same postgres service, so point DATABASE_TEST_URL at it
|
||||
# explicitly — without this, conftest falls back to localhost:5432 and
|
||||
# all tests fail at fixture setup with "connection refused".
|
||||
DATABASE_TEST_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/resolutionflow_test
|
||||
SECRET_KEY: ci-test-secret-key-not-for-production
|
||||
DEBUG: "true"
|
||||
APP_NAME: ResolutionFlow
|
||||
@@ -37,6 +46,24 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements.txt', 'backend/requirements-dev.txt') }}
|
||||
restore-keys: |
|
||||
pip-${{ runner.os }}-
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y libpango1.0-dev libcairo2-dev libgdk-pixbuf-2.0-dev libffi-dev libjpeg-dev zlib1g-dev
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
|
||||
@@ -47,7 +74,15 @@ jobs:
|
||||
run: cd backend && python scripts/check_tenant_filters.py
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: cd backend && python -m pytest --override-ini="addopts=" --cov=app --cov-report=term-missing --cov-report=json:coverage.json --cov-fail-under=50
|
||||
# `-n auto` parallelizes across all runner cores via pytest-xdist.
|
||||
# conftest.py creates a per-worker DB (resolutionflow_test_gw0,
|
||||
# resolutionflow_test_gw1, …) so the per-test DROP SCHEMA doesn't
|
||||
# race across workers. Master/serial runs keep the base DB.
|
||||
# term-missing dropped — the custom "Display coverage summary" step
|
||||
# below parses coverage.json and prints the same info more concisely.
|
||||
# --maxfail=10 short-circuits on structural breakage so we don't burn
|
||||
# 25 minutes when a fixture explodes.
|
||||
run: cd backend && python -m pytest --override-ini="addopts=" -n auto --maxfail=10 --cov=app --cov-report=json:coverage.json --cov-fail-under=50
|
||||
|
||||
- name: Display coverage summary
|
||||
if: always()
|
||||
@@ -75,6 +110,19 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
npm-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
@@ -87,15 +135,14 @@ jobs:
|
||||
- name: Build
|
||||
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" npm run build
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
retention-days: 1
|
||||
# Build artifact intentionally NOT uploaded. The e2e job below builds
|
||||
# its own frontend rather than downloading one from this job, so there
|
||||
# is no need for the cross-job artifact handoff (which previously broke
|
||||
# on actions/upload-artifact@v4 GHES support and forced a v3 pin).
|
||||
# Decoupling also lets e2e start immediately rather than waiting for
|
||||
# this job to finish — important on a multi-runner setup.
|
||||
|
||||
e2e:
|
||||
needs: [frontend]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
services:
|
||||
@@ -105,10 +152,13 @@ jobs:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: resolutionflow_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
# No host port mapping. Tests connect to `postgres:5432` (the service
|
||||
# container's docker-network DNS name), not `localhost:5432`. With
|
||||
# multiple Gitea runners on the same homelab box, host-port mapping
|
||||
# would race — two backend/e2e jobs both binding 0.0.0.0:5432 → the
|
||||
# second fails with "port is already allocated".
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-cmd "pg_isready -U postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
@@ -121,21 +171,55 @@ jobs:
|
||||
PLAYWRIGHT_SECRET_KEY: ci-playwright-secret-key
|
||||
PLAYWRIGHT_TEST_EMAIL: teamadmin@resolutionflow.example.com
|
||||
PLAYWRIGHT_TEST_PASSWORD: TestPass123!
|
||||
# AI-touching endpoints (POST /ai-sessions, /chat, /respond, etc.) are
|
||||
# gated by `_require_ai_enabled()`, which returns 503 when no provider
|
||||
# key is set. Tests that exercise those flows stub the AI calls in the
|
||||
# browser via `page.route`, so the backend never actually contacts
|
||||
# Anthropic — but the gate still has to pass. A stub value is enough.
|
||||
ANTHROPIC_API_KEY: ci-stub-key-not-used-by-tests
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-${{ runner.os }}-${{ hashFiles('backend/requirements.txt', 'backend/requirements-dev.txt') }}
|
||||
restore-keys: |
|
||||
pip-${{ runner.os }}-
|
||||
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: npm-${{ runner.os }}-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
npm-${{ runner.os }}-
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: pip install --break-system-packages -r backend/requirements.txt -r backend/requirements-dev.txt
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: cd frontend && npm ci
|
||||
|
||||
- name: Download frontend build
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: frontend-dist
|
||||
path: frontend/dist
|
||||
- name: Build frontend
|
||||
# Building inline (instead of downloading an artifact from the
|
||||
# frontend job) drops the cross-job dependency, so e2e can start
|
||||
# immediately on a free runner. Adds ~1-2 min of build time, but
|
||||
# eliminates the artifact-upload mechanism entirely (no more
|
||||
# v3/v4 GHES headaches) and saves ~5 min of waiting.
|
||||
run: cd frontend && NODE_OPTIONS="--max-old-space-size=4096" VITE_API_URL="${PLAYWRIGHT_API_ORIGIN}" npm run build
|
||||
|
||||
- name: Install Playwright browser
|
||||
run: cd frontend && npx playwright install --with-deps chromium
|
||||
@@ -145,7 +229,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
|
||||
@@ -15,5 +15,8 @@ jobs:
|
||||
git clone --mirror https://gitea.resolutionflow.com/chihlasm/resolutionflow.git repo
|
||||
cd repo
|
||||
git remote add github https://x-access-token:${{ secrets.GH_MIRROR_TOKEN }}@github.com/${{ secrets.GH_MIRROR_REPO }}
|
||||
git push github --all --force
|
||||
git push github --tags --force
|
||||
# --all + --tags scopes the push to refs/heads/* and refs/tags/*,
|
||||
# avoiding refs/pull/* (which GitHub refuses with "deny updating a
|
||||
# hidden ref"). --prune makes deletions on the Gitea side propagate.
|
||||
git push github --all --prune --force
|
||||
git push github --tags --prune --force
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -37,10 +37,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python 3.11
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
backend/requirements.txt
|
||||
@@ -143,10 +143,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python 3.11
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
backend/requirements.txt
|
||||
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -207,7 +207,11 @@ marimo/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Claude Code (local config, agents, settings)
|
||||
.claude/
|
||||
.claude/*
|
||||
!.claude/settings.json
|
||||
!.claude/hooks/
|
||||
.claude/hooks/*
|
||||
!.claude/hooks/check-gstack.sh
|
||||
.agents/
|
||||
|
||||
# Database dumps
|
||||
@@ -233,8 +237,18 @@ package.json
|
||||
package-lock.json
|
||||
.worktrees/
|
||||
.gstack/
|
||||
|
||||
# Core dumps from crashed processes (e.g. core.12345)
|
||||
core.[0-9]*
|
||||
**/core.[0-9]*
|
||||
.gitnexus
|
||||
|
||||
# graphify knowledge graph outputs
|
||||
graphify-out/
|
||||
.graphify_python
|
||||
|
||||
# remember skill runtime state (hook logs, PIDs)
|
||||
.remember/
|
||||
|
||||
# MCP server config (per-machine, references local env vars for auth)
|
||||
.mcp.json
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12.13
|
||||
@@ -1,11 +1,25 @@
|
||||
# Development Roadmap
|
||||
|
||||
> **Last Updated:** March 18, 2026
|
||||
> **Product:** ResolutionFlow (repo: patherly)
|
||||
> **Last Updated:** May 7, 2026
|
||||
> **Product:** ResolutionFlow (repo path: `resolutionflow/`; `patherly` is the legacy internal name)
|
||||
> **Target Market:** MSP companies — IT service providers managing infrastructure and support for multiple clients
|
||||
|
||||
---
|
||||
|
||||
## Status as of 2026-05-07
|
||||
|
||||
The historical phase content below (Phase 1 through Phase 5) is preserved as a factual record. **This section is the live status overlay — read it first.**
|
||||
|
||||
**Where we are:** Pre-PMF, Go-to-Market Validation. Backend feature-complete (50+ endpoints, 100+ tests). FlowPilot session UX is the daily-driver surface and recently went through PR #155 (escalation wedge), #156 (`applied_pending` non-terminal status), #158 (impeccable pass + tasklane keyboard flow), #159 (Diátaxis User Guides), #160 (sidebar IA + account redesign).
|
||||
|
||||
**Currently in flight:** Self-serve signup cutover. Phase 1 backend (#161) and Phase 2 frontend (#162) merged. PR #164 (open) closes the last code blockers — plan taxonomy reconciliation (`team` → `enterprise`, add `starter`) and `INTERNAL_TESTER_EMAILS` allowlist for the soft cutover. After merge, remaining work is **manual operations only**: Stripe Dashboard live-mode setup, Railway prod env vars, internal validation pass, public flag flip. See `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md` Phase O for the checklist.
|
||||
|
||||
**Product thesis being tested:** "We're not a documentation app. We are the documentation builders." Captured in `~/.gstack/projects/chihlasm-resolutionflow/abc-feat-self-serve-signup-phase-2-design-20260507-112020.md` (office-hours design doc). Pre-build assignment: 3 calls with external Directors of Onboarding (cold, no friendly contacts) to validate the framing before adopting it as the public positioning.
|
||||
|
||||
**What's not yet decided:** Whether to formally cut branching Flows from the pilot UI surface in favor of a Project (linear procedure) + FlowPilot + Documentation-Builder positioning. Discussed in /office-hours but no implementation work scheduled — gated on the 3 external validation calls.
|
||||
|
||||
---
|
||||
|
||||
## Completed Work
|
||||
|
||||
### Phase 1: MVP
|
||||
@@ -72,13 +86,26 @@
|
||||
|
||||
| Task | Status | Notes |
|
||||
|------|--------|-------|
|
||||
| ConnectWise PSA Integration (Advanced) | In Progress | Core done — ticket linking, note posting, member mapping. Remaining: callback webhooks, deeper ticket context in sessions |
|
||||
| PR #114 Merge | In Progress | Empty states, onboarding, PDF exports, branding, supporting data — ready for review |
|
||||
| Self-serve signup cutover (Phase O) | In Progress | PR #164 merge → Stripe live-mode Dashboard setup → Railway prod env vars → internal validation → public flag flip. Code blockers cleared by #164 (taxonomy + `INTERNAL_TESTER_EMAILS` allowlist). |
|
||||
| External validation of documentation-builder thesis | Not started | 3 calls with external Directors of Onboarding (cold). Decision gate before scoping a "Day 1 onboarding checklist" build. |
|
||||
| ConnectWise PSA Integration (Advanced) | Deferred | Core complete — ticket linking, note posting, member mapping, ticket context retrieval. Callback webhooks deferred until pilot signal demands them. |
|
||||
|
||||
---
|
||||
|
||||
## What's Next
|
||||
|
||||
### Phase O Cutover (Weeks 0-1)
|
||||
|
||||
| Step | Status |
|
||||
|---|---|
|
||||
| Merge PR #164 (taxonomy reconciliation + allowlist) | Open, CI green |
|
||||
| Stripe Dashboard live-mode setup (Products + Prices for Starter/Pro, no Prices on Enterprise, Customer Portal config, webhook endpoint with 5 events) | Manual op |
|
||||
| Railway prod env vars (`sk_live_*`, `whsec_*`, `INTERNAL_TESTER_EMAILS`, prod Google + Microsoft OAuth credentials, `OAUTH_REDIRECT_BASE`, `STRIPE_PUBLISHABLE_KEY`, `VITE_STRIPE_PUBLISHABLE_KEY` for frontend redeploy) | Manual op |
|
||||
| Run `python -m scripts.sync_stripe_plan_ids` against prod backend; verify `plan_billing` has `sk_live_*` price IDs | Manual op |
|
||||
| Internal validation pass (9 scenarios from Phase O Task 46) | Manual op |
|
||||
| Email pilots about complimentary status, flip `SELF_SERVE_ENABLED=true` (frontend redeploy required for `VITE_SELF_SERVE_ENABLED`) | Manual op |
|
||||
| PostHog signup-funnel dashboard + Sentry alert at >1/hour Stripe webhook errors | Manual op |
|
||||
|
||||
### Near-Term Priorities (from Stack Priorities Plan)
|
||||
|
||||
| Feature | Status | Description |
|
||||
@@ -86,7 +113,7 @@
|
||||
| Coverage gates in CI | ✅ Complete | Backend enforced at 80%, frontend coverage reporting enabled |
|
||||
| Security headers | ✅ Complete | HSTS, CSP (report-only), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy |
|
||||
| Web Vitals / performance budgets | ✅ Complete | LCP, INP, CLS, FCP, TTFB reported to PostHog via web-vitals |
|
||||
| Search and recall improvements | ⬜ Not started | Search sessions by flow, tag, client, ticket context |
|
||||
| Search and recall improvements | ✅ Complete | Structured filters + FTS + Voyage AI semantic search shipped (see CURRENT-STATE.md "Search & Recall" section) |
|
||||
|
||||
### 3A: Quick Wins & UX (Priority: Medium)
|
||||
|
||||
|
||||
61
AGENTS.md
Normal file
61
AGENTS.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# AGENTS.md — ResolutionFlow
|
||||
|
||||
You are OpenAI Codex, the resume agent for ResolutionFlow. Claude Code is the primary coding agent; you step in when Claude hits session or weekly limits.
|
||||
|
||||
The first thing to do every session: read [`.ai/PROJECT_CONTEXT.md`](.ai/PROJECT_CONTEXT.md), [`.ai/CURRENT_TASK.md`](.ai/CURRENT_TASK.md), and [`.ai/HANDOFF.md`](.ai/HANDOFF.md). The ritual is spelled out below.
|
||||
|
||||
> The protocol section below is byte-identical to the shared block in CLAUDE.md. If you edit one, edit the other.
|
||||
|
||||
## Shared protocol
|
||||
|
||||
### Startup ritual (every session)
|
||||
|
||||
1. Read `.ai/PROJECT_CONTEXT.md` — architectural truth for this repo.
|
||||
2. Read `.ai/CURRENT_TASK.md` — what we're actively working on.
|
||||
3. Read `.ai/HANDOFF.md` — exact resume point.
|
||||
4. Skim `.ai/DECISIONS.md` for recent entries relevant to the current task.
|
||||
5. Run `git log --oneline -15` and `git status`.
|
||||
6. Before taking action, state back in two sentences: the current goal and your proposed next action.
|
||||
|
||||
### Handoff ritual (session end — limit hit, task complete, or user stop)
|
||||
|
||||
1. Update `.ai/HANDOFF.md` to reflect new state. Keep it under ~2K tokens.
|
||||
2. If `CURRENT_TASK.md` status changed, update it.
|
||||
3. If you made an architectural decision, append to `.ai/DECISIONS.md`.
|
||||
4. Append a session entry to `.ai/SESSION_LOG.md`.
|
||||
5. If working tree is dirty, commit WIP with `wip(handoff): <one-line summary>`. Do not push unless explicitly asked.
|
||||
|
||||
### Writing rules for .ai/ files
|
||||
|
||||
- Use model-neutral voice in `HANDOFF.md`, `SESSION_LOG.md`, `DECISIONS.md` ("previous session did X", NOT "Claude did X" or "Codex did X"). Exception: `SESSION_LOG.md` entries include an `<agent>` field in the header.
|
||||
- Do not duplicate content between files. `CURRENT_TASK.md` holds the goal, `HANDOFF.md` holds the resume point, `TODO.md` holds the backlog. If unsure where something goes, check `.ai/README.md`.
|
||||
- Don't invent facts about the repo. If you're uncertain, write `TODO: confirm` and flag it.
|
||||
|
||||
### Project principle
|
||||
|
||||
Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs for review before taking them.
|
||||
|
||||
## Codex-specific notes
|
||||
|
||||
### Tooling you do NOT have
|
||||
|
||||
- **No GitNexus tools.** Use `grep -r`, `rg`, `git grep`, or `find` for code search. For blast-radius reasoning, grep call sites manually and read the files.
|
||||
- **No gstack slash commands** (`/review`, `/ship`, `/qa`, `/browse`, `/investigate`, `/design-review`, `/plan-*`). Run the equivalent work directly: `pytest` for tests, `npm run build` for frontend validation, manual PR description for review flow. If `python`/`npm` aren't on PATH, the host runs services in Docker — use the `docker exec resolutionflow_{backend,frontend} …` form documented in `.ai/PROJECT_CONTEXT.md` rather than installing toolchains.
|
||||
- **No `/codex` second-opinion command.** You are Codex.
|
||||
|
||||
### Git trailer
|
||||
|
||||
Every commit: `Co-Authored-By: Codex <noreply@openai.com>`
|
||||
|
||||
### Model selection
|
||||
|
||||
Handled on OpenAI's side. Do not attempt to set Anthropic model aliases for your own runtime. (The repo's application code still uses Anthropic aliases like `claude-sonnet-4-6` via `settings.get_model_for_action()` — that's runtime config for the product, not your agent.)
|
||||
|
||||
### Reviewing Claude's work
|
||||
|
||||
When you resume from a Claude session, assume some decisions may have been informed by GitNexus queries or gstack commands whose output isn't in the handoff. If a decision looks unverified from the `.ai/` files alone, either:
|
||||
|
||||
- re-verify with `grep`/`rg`/file reads, or
|
||||
- flag it in `HANDOFF.md` under "Open questions" so Michael or Claude can confirm on the next handoff.
|
||||
|
||||
Do not assume tooling output that isn't written down.
|
||||
36
CHANGELOG.md
36
CHANGELOG.md
@@ -2,9 +2,40 @@
|
||||
|
||||
All notable changes to ResolutionFlow are documented here.
|
||||
|
||||
## [Unreleased]
|
||||
## [0.1.0.0] - 2026-04-16
|
||||
|
||||
### Added
|
||||
- **PSA Ticket Management** — dedicated `/tickets` page with URL-param filter state (board, status, priority, company, assignment, closed), paginated ticket list, and slide-in detail panel
|
||||
- **TicketDetailPanel** — full ticket view with notes feed, configurations, related tickets, and resource manager; optimistic status updates via dropdown
|
||||
- **NewTicketModal** — two-tab ticket creation: "Quick Create (AI)" parses natural language into a pre-filled form via Claude, "Full Form" for manual entry; validates required fields before submitting to CW
|
||||
- **AiTicketParseForm** — natural language → structured ticket data using Claude; resolves board and assignee automatically, flags fields needing manual selection
|
||||
- **TicketResourceManager** — add/remove CW members as ticket resources with member search autocomplete
|
||||
- **Spin-off ticket creation from ResolutionAssist** — AI can detect when a new ticket should be created mid-session and surface the NewTicketModal pre-filled with session context
|
||||
- **TicketQueue improvements** — dashboard widget now detects member mapping, caps at 5 items, shows "View All" link to `/tickets`
|
||||
- **Board statuses endpoint** — `GET /integrations/boards/{board_id}/statuses` for direct status lookup without a ticket context
|
||||
- **Paginated ticket search** — `search_tickets` returns `{items, total, page, page_size}`; parallel CW count fetch for accurate totals
|
||||
- **Ticket service layer** — `ticket_service.py` wraps all PSA mutations (create, update status, list/add/remove resources)
|
||||
- **Priority lookup endpoint** — `GET /integrations/tickets/priorities` for form dropdowns
|
||||
- **PSA error surfacing** — `/tickets` page shows inline error banner with specific guidance when CW returns a permissions error (replaces silent empty state)
|
||||
|
||||
### Fixed
|
||||
- CW query injection: sanitize search `query` string to strip single quotes before interpolation into CW conditions
|
||||
- `company_id` filter now correctly applied to CW ticket search conditions (was silently ignored)
|
||||
- `linkedTicket` fetch in ResolutionAssist guarded with `currentChatRef` to prevent race condition on session switch
|
||||
- Members endpoint auth gate no longer rejects engineers without a PSA mapping
|
||||
- Board fallback: ticket list derives available boards from ticket data when the boards API returns empty (permissions)
|
||||
- Assignment search and "Load More" removed from resource manager in favor of direct member list
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
- **In-product User Guides rewrite** — replaced 15 feature-dump guides with 43 problem-oriented Diátaxis how-tos grouped under 10 categories (Getting started, Working a pilot session, Closing out a session, Documentation & sharing, Authoring flows, Reusable assets, AI assistance, PSA integrations, Account & team admin, Analytics). Dropped three deprecated guides (Maintenance Flows, AI Assistant page, Flow Assist sparkle button — UI no longer exists). Renamed Step Library → Solutions Library to match canonical product terminology. Corrected sidebar entry-path references throughout (Dashboard → Home, All Flows → Flows, Sessions → History, Analytics → Data, etc.). Added `category` and optional `relatedSlugs` to the Guide schema; `GuidesHubPage` now renders category sections; `GuideDetailPage` shows a "Related guides" footer when set. Authored 14 net-new how-tos covering FlowPilot-era surfaces with no prior coverage: tasklane keyboard flow, what-we-know panel, ask-the-AI mid-session, pause-and-leave, resolve a session, record a suggested-fix outcome, escalate (Escalation Mode), post docs to a ConnectWise ticket, share a client update mid-session, build a script with Script Builder, open an AI-suggested flow, pin a flow, and invite a teammate. Fixed a long-standing rendering bug where `**bold**` markdown in `step.tip` rendered literally instead of bolded — the same regex replacement now runs on tips as on instructions. Killed the misleading "N sections" subtitle on guide cards (single-section how-tos make the count noise).
|
||||
|
||||
### Added
|
||||
- **TaskLane keyboard-first answer flow** (#158) — Enter submits and auto-advances to the next pending task; Shift+Enter inserts a newline; Esc cancels; after the last task, focus jumps to the Send Responses button so the engineer can fire the whole batch with one more keystroke. Mouse path also auto-advances. Subtle hint row (`⏎ submit · ⇧⏎ newline`) under each open input teaches the shortcut.
|
||||
- **Collapsible "What we know" section** (#158) — TaskLane's facts list is now a collapsible section with per-session memory in `sessionStorage`. Auto-collapses on first render at ≥5 facts so Questions and Diagnostic Checks stay above the fold; engineer's explicit toggle always wins.
|
||||
- **Escalation Mode wedge** (#155) — when an engineer escalates, the senior tech who claims the session lands on a magic-moment handoff-context screen with the structured briefing visible in seconds (no scrolling, no chat re-read). Live SSE pushes new arrivals to anyone watching the queue, atomic claim resolves race conditions, the queue auto-excludes the claimed session, the claiming user retains chat ownership for AI briefings, and a new analytics endpoint tracks post-claim time-to-first-action so you can see real minutes recovered (paired with a manual baseline — see CURRENT_TASK.md two-metric framing).
|
||||
- **Suggested-fix "Awaiting verification" outcome** (#156) — when a fix needs external confirmation (client power-cycle, AD replication, license sync) you can park it in `applied_pending` instead of forcing a worked / didn't / partial verdict. The new PendingBanner shows the parked status with worked / didn't / update reason / dismiss actions. The "Still checking" nudge records pending with a reason instead of just silencing. Page-level Resolve auto-patches pending → success before the resolution flow opens; page-level Escalate intercepts pending the same way it intercepts verifying/partial. Resolution notes and escalation packages frame the pending state honestly (provisional fix; leading hypothesis with what's being waited on).
|
||||
- Tree Templates + Import/Export marketplace (#66)
|
||||
- Recurring Issue Detection — client-specific pattern alerts (#60)
|
||||
- Step Feedback Flag — "This Step is Wrong" reporting (#58)
|
||||
@@ -18,6 +49,8 @@ All notable changes to ResolutionFlow are documented here.
|
||||
- **Image support in Assistant Chat** — paste/attach images in chat input, uploaded to S3, resized for vision model, displayed in conversation history
|
||||
|
||||
### Changed
|
||||
- **Assistant Chat session screen — UX overhaul** (#158, "impeccable" pass) — removed the duplicate "Suggested checks" chip strip in favor of the TaskLane as the single source of truth; added an inline `Next steps · N pending` cue above the latest action-bearing AI bubble; consolidated the session header to two visible primary actions (Resolve + Escalate) plus a kebab for Context / New Ticket / Update Ticket / Pause; centered the messages column to `max-w-3xl` to match the composer; unified chat-bubble radii to `rounded-xl`; dropped every banned decoration (3px side stripes, gradient surfaces, accent borderTop, backdrop blur, pulse rings, bordered avatar boxes) for a single decoration channel per surface; unified 14 distinct text sizes into a 5-step scale (10/11/12/13/14px); split the ambiguous `MessageCircleQuestion` icon into `Pencil` (write affordance for question Answer CTA) and `HelpCircle` (universal help icon for the per-check explainer); audited and dropped redundant `font-sans` classes across the screen.
|
||||
- **Suggested-fix banner ↔ script panel are now linked** (#158) — collapsing the ProposalBanner now also hides the InlineNoTemplateDialog / TemplateMatchPanel; dismissing the banner closes both surfaces. Recording any outcome on a fix (Dismiss, It worked, Didn't work, Mark partial, Waiting to verify) closes the script panel alongside the banner state transition.
|
||||
- **Edit Procedure page** — layout overhaul and color system refinements for better visual hierarchy
|
||||
- **Flows sidebar navigation** — collapsed to reduce visual noise; session recovery removed from library view
|
||||
- **Account settings page** — audit fixes for improved consistency and usability
|
||||
@@ -28,6 +61,7 @@ All notable changes to ResolutionFlow are documented here.
|
||||
- **Tenant data boundaries** — all session and tree endpoints now return 404 (not 403) for cross-tenant access attempts to avoid confirming resource existence
|
||||
|
||||
### Fixed
|
||||
- **`ParameterizationPreview` over-highlight on short parameter values** (#158) — the tokenizer matched highlight values via raw substring with no word-boundary check, so a single-char value like `"D"` (a drive letter) lit up every capital D in identifiers like `Get-ADUser`, `Add-Type`, `Disable-`. Added a word-boundary guard that's conditional on whether the value itself starts/ends with a word character, so values with leading/trailing punctuation (e.g. `"D:\\Folder"`) still match cleanly when adjacent to whitespace.
|
||||
- **CRITICAL: Copilot tree query isolation** (#131) — user could access any tree UUID if known, exposing full tree structure to AI. Now scoped to current account with 404 for inaccessible trees.
|
||||
- **AI session search isolation** — search endpoint leaked other users' sessions via OR(user_id, account_id). Now restricted to current user only.
|
||||
- **Analytics endpoint isolation** — GET `/analytics/flows/{tree_id}` exposed session counts for any tree UUID. Now returns 404 if tree doesn't belong to requesting account.
|
||||
|
||||
267
CLAUDE.md
267
CLAUDE.md
@@ -1,215 +1,43 @@
|
||||
# CLAUDE.md — ResolutionFlow
|
||||
|
||||
> SaaS troubleshooting platform for MSPs. Last reviewed 2026-04-19.
|
||||
You are Claude Code, the primary coding agent for ResolutionFlow. OpenAI Codex is the resume agent when you hit session or weekly limits.
|
||||
|
||||
**Naming:** Canonical product name is **ResolutionFlow**. `patherly` is the legacy internal name — still present in DB name (`patherly` on Railway, `resolutionflow` locally), some Railway service names, and historical paths. Treat as aliases, not canonical. Docker containers are `resolutionflow_*`.
|
||||
The first thing to do every session: read [`.ai/PROJECT_CONTEXT.md`](.ai/PROJECT_CONTEXT.md), [`.ai/CURRENT_TASK.md`](.ai/CURRENT_TASK.md), and [`.ai/HANDOFF.md`](.ai/HANDOFF.md). The ritual is spelled out below.
|
||||
|
||||
**User terminology:** "Flows" (not Trees), "Projects" (not Procedures), "Solutions Library" (not Step Library). Maintenance flows hidden from pilot UI (backend retains them). DB column `tree_type` values unchanged.
|
||||
> The protocol section below is byte-identical to the shared block in AGENTS.md. If you edit one, edit the other.
|
||||
|
||||
**SaaS shape:** Multi-tenant by account. Roles: `super_admin` > `team_admin` > `engineer` > `viewer`. Team admin = `role='engineer'` + `is_team_admin=True` + valid `team_id`. Never `role=='admin'` — use `is_super_admin`. Backend deps in `app/api/deps.py`: `get_current_active_user`, `require_engineer_or_admin`, `require_admin`. Frontend: `usePermissions()` hook. Central logic in `backend/app/core/permissions.py` + `frontend/src/hooks/usePermissions.ts`.
|
||||
## Shared protocol
|
||||
|
||||
**Status:** Go-to-Market Validation (pre-PMF). Backend feature-complete (55+ endpoints, 100+ tests). Phase 0.5 FlowPilot telemetry baseline accruing. See `CURRENT-STATE.md` for live status, `03-DEVELOPMENT-ROADMAP.md` for phases.
|
||||
### Startup ritual (every session)
|
||||
|
||||
**Principle:** Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs for review before taking them.
|
||||
1. Read `.ai/PROJECT_CONTEXT.md` — architectural truth for this repo.
|
||||
2. Read `.ai/CURRENT_TASK.md` — what we're actively working on.
|
||||
3. Read `.ai/HANDOFF.md` — exact resume point.
|
||||
4. Skim `.ai/DECISIONS.md` for recent entries relevant to the current task.
|
||||
5. Run `git log --oneline -15` and `git status`.
|
||||
6. Before taking action, state back in two sentences: the current goal and your proposed next action.
|
||||
|
||||
---
|
||||
### Handoff ritual (session end — limit hit, task complete, or user stop)
|
||||
|
||||
## Tech stack
|
||||
1. Update `.ai/HANDOFF.md` to reflect new state. Keep it under ~2K tokens.
|
||||
2. If `CURRENT_TASK.md` status changed, update it.
|
||||
3. If you made an architectural decision, append to `.ai/DECISIONS.md`.
|
||||
4. Append a session entry to `.ai/SESSION_LOG.md`.
|
||||
5. If working tree is dirty, commit WIP with `wip(handoff): <one-line summary>`. Do not push unless explicitly asked.
|
||||
|
||||
- **Backend:** Python 3.11 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
|
||||
- **Frontend:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config in `index.css`), Zustand (immer + zundo), React Router v7, Axios (token-refresh interceptor), Lucide.
|
||||
- **DB:** PostgreSQL 16 (RLS enabled Phase 4, pgvector).
|
||||
### Writing rules for .ai/ files
|
||||
|
||||
---
|
||||
- Use model-neutral voice in `HANDOFF.md`, `SESSION_LOG.md`, `DECISIONS.md` ("previous session did X", NOT "Claude did X" or "Codex did X"). Exception: `SESSION_LOG.md` entries include an `<agent>` field in the header.
|
||||
- Do not duplicate content between files. `CURRENT_TASK.md` holds the goal, `HANDOFF.md` holds the resume point, `TODO.md` holds the backlog. If unsure where something goes, check `.ai/README.md`.
|
||||
- Don't invent facts about the repo. If you're uncertain, write `TODO: confirm` and flag it.
|
||||
|
||||
## Project structure
|
||||
### Project principle
|
||||
|
||||
```
|
||||
resolutionflow/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # FastAPI entry
|
||||
│ │ ├── api/endpoints/ # auth, trees, sessions, admin, steps, survey, copilot, assistant_chat, integrations, flow_proposals, flowpilot_analytics
|
||||
│ │ ├── api/deps.py # auth deps (incl. require_team_admin)
|
||||
│ │ ├── api/router.py # registration
|
||||
│ │ ├── core/ # config, database, permissions, security, audit, rate_limit
|
||||
│ │ ├── models/ # SQLAlchemy (incl. FlowProposal)
|
||||
│ │ ├── schemas/ # Pydantic
|
||||
│ │ ├── services/psa/ # PSA provider pattern (base, connectwise/, autotask/, halopsa/, cache, encryption, registry, types)
|
||||
│ │ ├── services/knowledge_flywheel.py + _scheduler.py
|
||||
│ │ └── services/knowledge_gap_service.py
|
||||
│ ├── alembic/versions/ # 001-070 sequential, then hex hash
|
||||
│ ├── scripts/ # seed_data, seed_trees, seed_test_users
|
||||
│ └── tests/ # pytest integration
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # Axios client + endpoint modules
|
||||
│ │ ├── components/ # common, layout, dashboard, tree-editor, session, procedural, procedural-editor, library, step-library, ui, flowpilot
|
||||
│ │ ├── hooks/ # usePermissions, useSessionTimer, useKeyboardShortcuts
|
||||
│ │ ├── pages/
|
||||
│ │ ├── store/ # Zustand (auth, treeEditor, proceduralEditor, userPreferences, scriptGeneratorStore)
|
||||
│ │ └── types/
|
||||
│ └── (Tailwind v4 CSS-only config in src/index.css)
|
||||
├── docs/plans/archive/ # pre-March 2026 plans
|
||||
├── docs/connectwise/ # CW API reference + best-practices guides
|
||||
├── docs/LESSONS-ARCHIVE.md # archived lessons (fixes in code)
|
||||
├── CLAUDE.md · CURRENT-STATE.md · DESIGN-SYSTEM.md · DEV-ENV.md
|
||||
```
|
||||
Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs for review before taking them.
|
||||
|
||||
---
|
||||
## Claude-specific tooling
|
||||
|
||||
## Design system
|
||||
|
||||
**Source of truth: [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md).** Read before any visual change.
|
||||
|
||||
- Flat high-contrast dark theme, Sentry/PostHog-inspired. **No** glass, backdrop blur, ambient orbs, gradient surfaces.
|
||||
- Accent **electric blue** (#60a5fa dark / #2563eb light) — ≤5% of UI, interactive elements only. Warning amber (#fbbf24), info cyan (#67e8f9), success green (#34d399), danger red (#f87171). Each with `-dim` at 10% opacity.
|
||||
- Backgrounds: `bg-sidebar` (#0e1016) → `bg-page` (#16181f) → `bg-card` (#1e2028) → `bg-elevated` (#2a2d38). Borders `border-default` / `border-hover`.
|
||||
- Text: `text-heading` → `text-primary` → `text-muted-foreground` → `text-muted`.
|
||||
- Fonts: IBM Plex Sans (body), Bricolage Grotesque (heading, 700 weight for logo), JetBrains Mono (code).
|
||||
- Logo: 30px gradient square (ember orange) + "ResolutionFlow" in Bricolage Grotesque. Assets in `brand-assets/`, `frontend/src/assets/brand/`, `frontend/public/icons/`.
|
||||
- Mockups: `docs/mockups/` (HTML).
|
||||
- **Deprecated — do not use:** glass-card, glass-stat, `bg-gradient-brand`, `backdrop-filter: blur()`, ambient orbs, purple gradients, ember orange as accent, cyan as accent (cyan is info only).
|
||||
|
||||
---
|
||||
|
||||
## ConnectWise PSA
|
||||
|
||||
Reference: `docs/connectwise/` — start with `CONNECTWISE-API-REFERENCE.md`, then the `best-practices/` guides. Extracted OpenAPI spec in `connectwise-psa-resolutionflow-reference.json` (670 endpoints, v2025.16); full spec in `connectwise-psa-openapi-full.json`.
|
||||
|
||||
- **Auth:** API Key (Base64 `companyId+publicKey:privateKey`) + `clientId` header every request. `clientId` is server-side (`CW_CLIENT_ID` in `config.py`) — identifies ResolutionFlow, not per-tenant. Per-connection: `company_id`, `public_key`, `private_key`, `server_url`.
|
||||
- **Architecture:** `services/psa/` provider pattern — `PSAProvider` base, `ConnectWiseProvider` impl, `PsaProviderRegistry` for multi-PSA dispatch. Credentials encrypted at rest via `services/psa/encryption.py` (Fernet). Per-team credentials, never per-user. Endpoints in `api/endpoints/integrations.py`. In-memory TTL cache in `services/psa/cache.py`.
|
||||
- **Integration flows:** session docs → ticket notes (`POST /service/tickets/{id}/notes`, markdown supported); ticket context → FlowPilot; callbacks via `/system/callbacks` with HMAC verification.
|
||||
- **API rules:** pin version via Accept header `application/vnd.connectwise.com+json; version=2025.16`. Paginate ≤1000/page. Dynamic base URL via `/login/companyinfo/{companyId}`. Request minimal permissions (MY, not ALL).
|
||||
|
||||
---
|
||||
|
||||
## Dev commands
|
||||
|
||||
Full setup in [DEV-ENV.md](DEV-ENV.md) (host-agnostic, with homelab Proxmox reference topology). Day-to-day:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.dev.yml up -d # start stack
|
||||
cd backend && source venv/bin/activate && uvicorn app.main:app --reload
|
||||
cd frontend && npm run dev
|
||||
pytest --override-ini="addopts=" # tests (first time: CREATE DATABASE resolutionflow_test)
|
||||
cd backend && alembic upgrade head # migrate
|
||||
cd backend && alembic revision -m "desc" # manual migration (preferred per Lesson 77)
|
||||
cd backend && alembic revision --autogenerate -m "desc" # picks up drift; review carefully
|
||||
cd frontend && npm run build # stricter than tsc --noEmit — final check
|
||||
cd frontend && npx tsc -b # TS-only check when dist/ has EACCES
|
||||
docker exec -it resolutionflow_postgres psql -U postgres -d resolutionflow
|
||||
python -m scripts.seed_trees # seed (from backend/)
|
||||
```
|
||||
|
||||
**URLs:** Frontend <http://localhost:5173>, backend <http://localhost:8000>, API docs <http://localhost:8000/api/docs>.
|
||||
|
||||
**Test users** (all password `TestPass123!`): `admin@resolutionflow.example.com` (super_admin), `teamadmin@resolutionflow.example.com`, `engineer@resolutionflow.example.com`, `pro@resolutionflow.example.com`.
|
||||
|
||||
**CI:** Gitea (`gitea.resolutionflow.com/chihlasm/resolutionflow/actions`). `gh` CLI works for issues/PRs on the GitHub mirror, but not CI runs.
|
||||
|
||||
**Never pass `--rev-id`** to alembic — let it generate the hex hash.
|
||||
|
||||
---
|
||||
|
||||
## Common tasks
|
||||
|
||||
- **New endpoint:** `endpoints/` → `router.py` → `schemas/` → tests → frontend API client.
|
||||
- **New page:** `pages/` → route in `router.tsx` → nav in `AppLayout.tsx`.
|
||||
- **New public route:** top-level in `router.tsx` alongside `/login`, not inside `ProtectedRoute`.
|
||||
- **New frontend API module:** types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`.
|
||||
- **Schema change:** update model → `alembic revision -m "desc"` → review → `alembic upgrade head`.
|
||||
- **New `VITE_*` env var:** add as `ARG` + `ENV` in `frontend/Dockerfile` for Railway builds (Lesson 60 — Railway env vars are runtime-only, Vite bakes at build time).
|
||||
- **Account sub-page:** add route in `router.tsx` under `account` children + add link card in `AccountSettingsPage.tsx` — `AccountLayout` has NO sidebar nav.
|
||||
|
||||
---
|
||||
|
||||
## Coding standards
|
||||
|
||||
- **Python:** type hints everywhere, async/await for DB, Pydantic v2, `DateTime(timezone=True)` always.
|
||||
- **TypeScript:** interfaces for all data, `const` over `let`, functional components + hooks, shared logic in custom hooks.
|
||||
- **Git:** feature branch before committing (`git checkout -b feat/feature-name`). Format: `type: description` (feat/fix/refactor/docs/test/chore). Always `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`. Large features: commit per phase with `npm run build` validation. Push to Gitea — auto-mirrors to GitHub (`.gitea/workflows/mirror-to-github.yml`); never push GitHub directly.
|
||||
|
||||
**After shipping:** update `CURRENT-STATE.md` + `03-DEVELOPMENT-ROADMAP.md`, `gh issue close #N` for resolved issues, add lessons here only for non-obvious traps (otherwise let the code speak).
|
||||
|
||||
---
|
||||
|
||||
## Frontend patterns
|
||||
|
||||
- **Component basics:** `cn()` from `@/lib/utils`, Lucide icons, `Modal.tsx` for modals (mobile-responsive `items-end sm:items-center` + `max-w-full sm:max-w-lg`).
|
||||
- **Types:** Create in `types/`, export from `types/index.ts`, `import type { T } from '@/types'`.
|
||||
- **Routing:** `getTreeNavigatePath()` / `getTreeEditorPath()` from `@/lib/routing`. Tree editor is `/trees/new`. All dashboard session clicks → `/pilot/:id` regardless of `session_type`.
|
||||
- **Lazy routes:** `lazyWithRetry` from `@/lib/lazyWithRetry.ts`, not `React.lazy` (auto-reload on stale chunks).
|
||||
- **Public pages:** raw `fetch()` with full URL, NOT `apiClient` (which requires auth tokens).
|
||||
- **Toast:** `toast.warning()` not `toast.warn()`. Import from `@/lib/toast` — methods: `success`, `error`, `warning`, `info`.
|
||||
- **Assistant chat:** uses local React `useState`, not Zustand. All three send paths (`handleSend`, `sendPrefill`, `handleResumeNew`) must call `setShowTaskLane(true)` when response has actions/questions.
|
||||
- **Chat backend wiring:** `aiSessionsApi.sendChatMessage` → `/ai-sessions/{id}/chat` → `unified_chat_service.py`. NOT `assistant_chat_service.py` (removed except retention settings).
|
||||
- **FlowPilot:** Actions live in page header (Resolve/Escalate/Share Update + overflow). `useBlocker` for active-session nav guard. "Pause & Leave" auto-pauses.
|
||||
- **AI markers:** `[QUESTIONS]`, `[ACTIONS]`, `[FORK]`, `[DELTA]...[/DELTA]` (editor), `[TREE_UPDATE]` (troubleshooting builder), `[STEPS_UPDATE]` (procedural builder), `[METADATA]`. Parsed in `unified_chat_service.py`; conversation history stores stripped `display_content`. If markers disappear: check system-prompt final reminder + per-user-message `[SYSTEM: ...]` injection in `_call_anthropic_cached()`.
|
||||
- **Image uploads:** paste/attach → Railway S3 via `uploadsApi.upload()` → resized by `storage_service.resize_image_for_vision()` (Pillow, 1568px max, PNG→JPEG) → base64 → Claude multimodal blocks. Max 3/msg. Images NOT stored in history.
|
||||
- **Async select-load-apply:** guard with a ref (pattern in `AssistantChatPage` `currentChatRef`). Update synchronously on every selection change; after every `await`, bail out if `ref.current !== thisId`.
|
||||
- **Editor-Embedded Flow Assist:** `EditorAIPanel` (320px side panel) + `useEditorAI`. Ghost nodes via `_suggestion: true`. Route actions via `settings.get_model_for_action()`.
|
||||
- **Script Builder:** `/script-builder`, chat-style. Backend `ScriptBuilderSession`, `script_builder_service.py`, endpoints `/scripts/builder/`. FlowPilot handoff via `action_type: "open_script_builder"` + `sessionStorage`.
|
||||
- **Intake form field schema:** `variable_name` + `field_type` (NOT `name` / `type`).
|
||||
- **Node field priority** (copilot, summaries): `title` → `question` → `description` → `content` → `label`.
|
||||
- **Procedural sessions auto-start** on page load (no intake/Start screen). Troubleshooting flows DO have a start screen.
|
||||
|
||||
---
|
||||
|
||||
## Critical lessons
|
||||
|
||||
> Lessons 1-40 archived to `docs/LESSONS-ARCHIVE.md` — fixes baked into the codebase. **Grep the archive when an error message or symptom is unfamiliar, or after two failed attempts at resolving an issue.** Don't pre-load for routine work.
|
||||
|
||||
### Backend / data
|
||||
|
||||
- **APScheduler interval jobs always `max_instances=1`** — without it, overlapping runs reprocess records (TOCTOU).
|
||||
- **`get_db` rolls back on exception** — never remove the `await session.rollback()`, or one failed request poisons the connection with `InFailedSQLTransaction` cascading.
|
||||
- **Startup routines on tenant-isolated tables must use `_admin_session_factory()`, not `get_db()`.** Phase 4 RLS has no `app.current_account_id` set at startup. `get_service_account_id` is safe (reads cached `app.state`).
|
||||
- **Backfill migrations adding `account_id`:** grep ALL `ModelClass(` sites in service code to verify `account_id=` is passed. SQLAlchemy accepts `None` silently — Phase 4 RLS WITH CHECK surfaces the problem at runtime as `InsufficientPrivilegeError: new row violates row-level security policy`.
|
||||
- **`tree_shares.account_id = tree.account_id`**, never `current_user.account_id`. A super_admin sharing another tenant's tree must produce the share in the tree owner's tenant, or it becomes invisible post-RLS.
|
||||
- **Global tables (no `account_id`, never in RLS migrations):** `script_categories`, `platform_steps`, `template_trees`, `plan_feature_defaults`, `accounts`. Scan at class level — one `.py` file can hold multiple classes with different columns (e.g. `ScriptCategory` vs `ScriptTemplate`).
|
||||
- **`ai_sessions.status` is VARCHAR(30)** — fits `requesting_escalation` (23 chars). Migration `f0aad74ea51b` widened from 20.
|
||||
- **PostgreSQL `func.sum(case(...))` returns `Decimal` via asyncpg** — cast to `int()` before Pydantic `dict[str, Any]`.
|
||||
- **Enhancement / branch_addition proposals need `modified_flow_data` via "Edit & Publish"** — backend 400 on direct approve. Only `new_flow` supports direct approve.
|
||||
- **Adding email types:** static async method on `EmailService` in `core/email.py`. Fire-and-forget from endpoints (log errors, don't fail the request).
|
||||
|
||||
### AI / FlowPilot
|
||||
|
||||
- **Anthropic SDK `max_retries=1`** — default of 2 can take 3× the timeout.
|
||||
- **Model tier routing:** `settings.get_model_for_action(action_type)`. Always alias form (`claude-sonnet-4-6`).
|
||||
- **FlowPilot must ask GUI-vs-script before suggesting either** when both are viable — see `FLOWPILOT_SYSTEM_PROMPT` in `flowpilot_engine.py`.
|
||||
- **Telemetry events to grep:** `anthropic.cache` (prompt-cache hit/create), `mcp.turn` (per-turn MCP availability), `mcp.fallback` (MCP silent-retry fired).
|
||||
- **Don't put literal payloads in system prompts.** Bit us twice in one day: a worked `[QUESTIONS]` example with literal "Outlook + jsmith" content, and a full DNS troubleshooting tree, both caused Claude to recite that content on unrelated tickets — the symptom looked like task-lane state leaking across chats. The fix is structural: every output example in a system prompt uses `<placeholder>` syntax (`{"text": "<one short, specific question>"}`), never literal field values. Real-looking format examples live in few-shot messages (separate file, separate code path), not system prompts. Guardrail: `tests/test_prompt_anti_parrot.py` scans every `*_PROMPT`/`*_SCHEMA`/`*_PROTOCOL`/`*_FORMAT` constant in `app/services/` and `app/core/`; CI fails when a marker block contains a literal JSON value or when a known leaked token (jsmith, DC01, ADSync, Dnscache, etc.) appears anywhere in a prompt.
|
||||
|
||||
### Frontend / UI
|
||||
|
||||
- **Flex height chain:** every ancestor from `app-shell` grid to React Flow canvas needs `flex` + `flex-1` + `min-h-0` or `h-full`. Missing `flex` collapses to 0. Same rule for FlowPilot action bar and any tall scroller.
|
||||
- **React Flow CSS in Tailwind v4:** import in `index.css`, not component JS. Override dark theme via `--xy-*` CSS vars.
|
||||
- **`text-secondary` renders invisible on dark** — Tailwind v4 maps it to `--color-secondary` (a surface color). Use `text-muted-foreground` for readable secondary text. Avoid `text-muted` for body — labels only.
|
||||
- **`bg-accent` is electric blue — never for code/kbd.** Use `bg-white/[0.12] border border-white/[0.06]` for inline code, `bg-white/[0.08]` for kbd. Accent reserved for interactive elements.
|
||||
- **`landing.css` uses self-contained `--lp-*` vars** — never `var(--color-*)` theme tokens (they resolve incorrectly outside the app shell).
|
||||
- **Never `transition: all`** — list properties explicitly, or layout props animate and jank.
|
||||
- **Date range filter end dates:** `setHours(23, 59, 59, 999)` before sending, or the day's items are excluded. For string-based date inputs, append `T23:59:59.999Z`.
|
||||
- **TopBar search:** full bar `hidden sm:block`, icon button `sm:hidden` — both open CommandPalette.
|
||||
- **Hover pop-out cards:** scrim `pointer-events-none`, expanded card has its own click handler at `z-50`, dismiss via `onMouseLeave` on wrapper. Never put handlers on the scrim.
|
||||
- **`tsc -b` in Dockerfile is stricter than `tsc --noEmit`** — enforces `noUnusedLocals` / `noUnusedParameters` as hard errors. Check IDE yellow squiggles before pushing.
|
||||
- **Dashboard prefill auto-submits** via `useEffect` + `prefillHandledRef` guard — no double-enter.
|
||||
- **Global Axios 5xx interceptor fires before component `.catch()`** — fix optional-data endpoints at the source (return `[]` / `{}` on provider failure), not in the component.
|
||||
- **Playwright strict mode:** scope selectors to avoid sidebar/main ambiguity. Use `getByRole('heading', { name })` or `.animate-scale-in` locators, not bare `getByText()`.
|
||||
|
||||
### Env / infra
|
||||
|
||||
- **Node 20.19+ required** (Vite 7). `nvm use 20` or `PATH="$HOME/.nvm/versions/node/v20.19.0/bin:$PATH"`.
|
||||
- **Railway backend service is `patherly`, DB name `railway`.** Public Postgres proxy: `interchange.proxy.rlwy.net:45797`.
|
||||
- **Railway Object Storage bucket `resolutionflow-uploads`.** Env vars `STORAGE_*`. boto3 in `storage_service.py`. Dockerfile needs Pillow + `libjpeg-dev` / `zlib1g-dev`.
|
||||
- **PostHog:** `PostHogProvider` + `posthog.init()` in `main.tsx`. Helpers in `lib/analytics.ts`. Env: `VITE_PUBLIC_POSTHOG_KEY`, `VITE_PUBLIC_POSTHOG_HOST`. `identifyUser()` in `authStore.fetchUser()`, `resetAnalytics()` on logout.
|
||||
- **bun PATH on devserver01:** `BUN_INSTALL="$HOME/.bun"`, `PATH="$BUN_INSTALL/bin:$PATH"`. Playwright Chromium needs `libatk1.0-0 libatk-bridge2.0-0 libcups2 libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2`.
|
||||
- **Full-stack change:** trace schema → endpoint → API client → hook → store → UI. Don't assume one end proves the other.
|
||||
- **Dev env** — see DEV-ENV.md for current topology, `REPO_ROOT` requirement when compose runs inside a container, Vite `allowedHosts`, linuxserver.io `group_add` + custom-cont-init.d workaround, `docker compose up` no-op-on-unchanged-hash gotcha.
|
||||
|
||||
---
|
||||
|
||||
## GitNexus code intelligence
|
||||
### GitNexus code intelligence
|
||||
|
||||
Indexed as `resolutionflow`. Earns its cost on cross-cutting work only.
|
||||
|
||||
@@ -224,42 +52,23 @@ Indexed as `resolutionflow`. Earns its cost on cross-cutting work only.
|
||||
|
||||
Re-indexes automatically on commit (PostToolUse hook). Manual refresh if stale: `npx gitnexus analyze`.
|
||||
|
||||
---
|
||||
### gstack skills
|
||||
|
||||
## gstack skills
|
||||
Always use `/browse` for web, never `mcp__claude-in-chrome__*`.
|
||||
|
||||
Always use `/browse` for web, never `mcp__claude-in-chrome__*`. Most-used:
|
||||
Available commands:
|
||||
|
||||
- `/review` — pre-land PR review
|
||||
- `/ship` — tests + review + PR creation
|
||||
- `/browse` + `/qa` / `/qa-only` — headless browser testing (setup: Lesson 82)
|
||||
- `/design-review` — visual QA
|
||||
- `/investigate` — systematic debug with root cause
|
||||
- `/codex` — OpenAI Codex second opinion
|
||||
- `/plan-eng-review` / `/plan-design-review` / `/plan-ceo-review` — plan critiques
|
||||
- **Planning & review:** `/autoplan`, `/plan-eng-review`, `/plan-design-review`, `/plan-ceo-review`, `/plan-devex-review`, `/devex-review`, `/review`, `/cso`, `/office-hours`
|
||||
- **Design:** `/design-consultation`, `/design-shotgun`, `/design-html`, `/design-review`
|
||||
- **Browser & QA:** `/browse`, `/connect-chrome`, `/qa`, `/qa-only`, `/setup-browser-cookies`
|
||||
- **Ship & deploy:** `/ship`, `/land-and-deploy`, `/canary`, `/benchmark`, `/setup-deploy`, `/document-release`
|
||||
- **Debug & investigate:** `/investigate`, `/careful`, `/freeze`, `/guard`, `/unfreeze`
|
||||
- **Other:** `/codex` (OpenAI second opinion), `/setup-gbrain`, `/retro`, `/learn`, `/gstack-upgrade`
|
||||
|
||||
---
|
||||
### Git trailer
|
||||
|
||||
## Deployment (Railway)
|
||||
Every commit: `Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>`
|
||||
|
||||
- **Prod:** `resolutionflow.com` (frontend), `api.resolutionflow.com` (backend).
|
||||
- Auto-deploy: Gitea push → GitHub mirror → Railway follows GitHub `main`.
|
||||
- PR environments auto-created; need manual domain generation + `VITE_API_URL` with `https://` prefix.
|
||||
- `ALLOW_RAILWAY_ORIGINS=true` for `*.up.railway.app` CORS.
|
||||
- Shared Variables (Railway project-level) auto-propagate to PR envs — use for secrets like `ANTHROPIC_API_KEY`.
|
||||
- Super admin utility: `backend/make_superadmin_simple.py list|<email>`.
|
||||
### Model aliases
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| Detailed status | [CURRENT-STATE.md](CURRENT-STATE.md) |
|
||||
| Roadmap | [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) |
|
||||
| Design system | [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) |
|
||||
| Dev env | [DEV-ENV.md](DEV-ENV.md) |
|
||||
| Archived lessons | [docs/LESSONS-ARCHIVE.md](docs/LESSONS-ARCHIVE.md) |
|
||||
| ConnectWise API | `docs/connectwise/` |
|
||||
| GitHub issues | `gh issue list --state open` |
|
||||
| Local API docs | <http://localhost:8000/api/docs> |
|
||||
Always use alias form (`claude-sonnet-4-6`, `claude-opus-4-6`, etc.) via `settings.get_model_for_action()`. Never hardcode a dated model ID.
|
||||
|
||||
@@ -2,11 +2,35 @@
|
||||
|
||||
> **Purpose:** Quick-reference file showing exactly where the project stands.
|
||||
> **For Claude Code:** Read this first to understand what's done and what's next.
|
||||
> **Last Updated:** April 12, 2026
|
||||
> **Last Updated:** May 7, 2026
|
||||
|
||||
---
|
||||
|
||||
## Active Phase: Go-to-Market Validation (Pre-PMF)
|
||||
## Active Phase: Go-to-Market Validation (Pre-PMF) — Self-serve cutover (Phase O) in flight
|
||||
|
||||
Self-serve signup backend (Phase 1) and frontend (Phase 2) are merged. Cutover (Phase O) is gated on manual ops: live-mode Stripe Dashboard config, Railway prod env vars, internal validation pass against prod test mode, then the public flag flip. Plan: `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md`.
|
||||
|
||||
---
|
||||
|
||||
## Recently shipped (post-0.1.0.0)
|
||||
|
||||
- **2026-05-13 — `feat/session-expiration-policy` (open)** Session expiration policy series — 8 commits, fixes the "logged in forever" bug and adds owner-side controls. Migration `b269a1add160` adds `accounts.session_idle_minutes` + `session_absolute_minutes` (NULL = use system default, defaults Strict 3d/14d via `Settings.SESSION_*_MINUTES_DEFAULT`). Refresh-token JWT carries `auth_time` + `idle_max` + `abs_max` claims (seconds) snapshotted at every login entry point (`/auth/login`, `/auth/login/json`, both OAuth callbacks). `/auth/refresh` enforces absolute cap (`now >= auth_time + abs_max` → 401 `session_expired_absolute`), atomic-revoke-then-check prevents replay. Error-detail taxonomy on the wire distinguishes `session_expired_idle` / `session_expired_absolute` / `invalid_refresh_token`. New owner-only `GET/PATCH /accounts/me/security` returns `{idle_minutes, absolute_minutes, effective_*, *_min/max, active_users}` with audit logging on PATCH. `POST /accounts/me/security/revoke-sessions` bulk-revokes refresh tokens for the account (`scope: "all" | "others"`), audited. Frontend: new `/account/security` page (Strict/Standard/Custom presets, active-users list with name + email + last-login-ago, count-aware revoke buttons + confirmation modal), `useAuthSessionExpiry` hook + top-of-app `SessionExpiryToast` (differentiated by idle vs absolute), cyan info-tone banner on `/login?reason=session_expired`. Plan + design review in `docs/plans/2026-05-13-session-expiration-policy.md` (initial 4/10 → 9/10 via `/plan-design-review`). 28 backend tests; tsc clean. Pending: open PR, merge, document follow-up issues (per-user device list, super-admin global ceiling UI).
|
||||
|
||||
- **2026-05-07 — PR #164 (open)** Plan taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist. Marketing surface (PricingPage, Stripe products) used `Starter / Pro / Enterprise` while backend was on `free / pro / team`, leaving `plan_billing` unseeded and `BillingPlan` schema accepting a literal that violated the FK. Migration `4ce3e594cb87`: rename `team` → `enterprise` in `plan_limits`, add `starter` row (caps interpolated between free and pro: `max_trees=10`, `sessions=75`, `ai=15/mo`), defensive update of any subscriptions on the `team` slug. Code rename across schemas, `Subscription` paid-plan checks, admin endpoints, and frontend `useSubscription`. Resource visibility (`Tree.visibility='team'`, `StepLibrary.visibility='team'`) is a separate domain and intentionally untouched. New `backend/scripts/sync_stripe_plan_ids.py` — idempotent upsert of `plan_billing` rows from Stripe products by exact name match, picks active monthly recurring price, leaves annual fields NULL by design. Test-mode `plan_billing` populated for all 3 tiers in dev. Phase O Task 46 allowlist: `INTERNAL_TESTER_EMAILS` env var (comma-separated) bypasses `SELF_SERVE_ENABLED=false` for specific authenticated users — `Settings.is_self_serve_active_for(email)` centralizes the check; `/config/public` returns `self_serve_enabled=true` for allowlisted authenticated callers; `/auth/register` allows allowlisted emails to register without invite code. New `get_current_user_optional` dep for endpoints that work both anonymous and authed.
|
||||
|
||||
- **2026-05-06 — PR #163** Seed test users marked email-verified. Fixed seeded users showing the email verification banner in dev/test, blocking flows that gate on `email_verified=True`. Squash-merged into main as `dad5e1f`.
|
||||
|
||||
- **2026-05-06 — PR #162** Self-serve signup Phase 2 (frontend cutover). 18 commits across Tasks 27–44 of the Phase 2 plan: backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Single alembic head `c6cbfc534fad` (no new migrations in Phase 2). Squash-merged as `f1be3ab`.
|
||||
|
||||
- **2026-05-?? — PR #161** Self-serve signup backend (Phase 1). `plan_billing` sibling table for Stripe + catalog metadata, `sales_leads` and `stripe_events` tables, `complimentary` status with `has_pro_entitlement`, `BillingService.start_trial` wired into `/auth/register`, `/billing/checkout-session`, Stripe webhook handler with idempotency via `stripe_events`, Google + Microsoft OAuth callbacks with `oauth_identities` linking, `require_verified_email_after_grace` + `require_active_subscription` guards, bulk-create + soft-revoke invite endpoints, account-invite email-match enforcement, pilot complimentary backfill, `accounts.team_size_bucket` + `primary_psa` for wizard. Squash-merged as `f918b76`.
|
||||
|
||||
- **2026-05-02 — PR #159** In-product User Guides rewrite to Diátaxis how-tos. Replaced 15 feature-dump guides with 43 problem-oriented how-tos grouped under 10 categories. Dropped Maintenance Flows / AI Assistant / Flow Assist Sparkles guides (UI no longer exists). Renamed Step Library → Solutions Library. Authored 14 net-new how-tos for FlowPilot-era surfaces (tasklane keyboard flow, what-we-know, resolve, escalate, record-fix-outcome, post-docs-to-ticket, share-update, pause-and-leave, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate, etc.). Schema additions: `category`, optional `relatedSlugs`. Browser-verified against engineer + owner login.
|
||||
|
||||
- **2026-05-?? — PR #160** Post-PR-159 UI cleanup — sidebar IA + account redesign. Squash-merged as `a8b22cf`.
|
||||
|
||||
- **2026-05-01 — PR #158** Session-screen UX impeccable pass + tasklane keyboard flow. Heuristic score 24/40 → 33/40 across five sub-passes (distill, quieter, layout, typeset, polish). Removed duplicate "Suggested checks" chip strip → TaskLane is the single source of truth; added inline `Next steps · N pending` cue on the latest action-bearing AI bubble; consolidated session header to Resolve + Escalate + ⋯ kebab; centered messages column to match composer; dropped all banned decorations (side stripes, gradient surfaces, backdrop blur, accent borderTop) for a single decoration channel per surface; unified 14 text sizes into a 5-step scale. TaskLane keyboard flow: Enter submits + auto-advances, Shift+Enter newline, Esc cancel, focus jumps to Send after the last task. Banner ↔ script-panel are now linked (collapse hides both, any outcome closes both). WhatWeKnow section is collapsible with `sessionStorage` memory + auto-collapse-at-5-facts. Side fix: ParameterizationPreview no longer over-highlights short parameter values (word-boundary check). Two backlog entries logged in `.ai/TODO.md`: ConcludeSessionModal multi-select and `bg-card-hover` Tailwind drift in CommandPalette.
|
||||
- **2026-05-01 — PR #156** Suggested-fix "Awaiting verification" outcome. Engineers can now park a fix in `applied_pending` (waiting on client power-cycle, AD replication, license sync, etc.) instead of forcing a synchronous worked/didn't/partial verdict. PendingBanner with worked / didn't / update reason / dismiss; nudge "Still checking" records pending with a reason; page-level Resolve auto-patches pending → success before the resolution flow opens; page-level Escalate intercepts pending. Migration `c0f3a4b7e91d` (`pending_reason` column + status CHECK constraint).
|
||||
- **2026-04-30 — PR #155** Escalation Mode wedge. Magic-moment handoff-context screen for senior pickup, live SSE escalation arrivals, post-claim time-to-first-action metric (`GET /analytics/flowpilot/escalations`), atomic role-gated claim with conflict resolution, queue self-exclusion, chat ownership extended to claimed sessions. The wedge for the first paying-customer push.
|
||||
|
||||
---
|
||||
|
||||
@@ -207,17 +231,30 @@
|
||||
|
||||
## What's In Progress
|
||||
|
||||
- **GTM Validation:** Shadow & Ship — founder uses product for 2 weeks, then hands logins to 5 colleagues
|
||||
- **Solutions Library spec:** Written at `docs/plans/2026-03-23-solutions-library-design.md`, implementation deferred to post-pilot
|
||||
- **Self-serve cutover (Phase O):** PR #164 (open) closes the last code blockers — taxonomy reconciliation + `INTERNAL_TESTER_EMAILS` allowlist. After merge, remaining work is purely manual ops: live-mode Stripe Dashboard config, Railway prod env vars, internal validation pass with Andrea Henry + 2-3 external Directors of Onboarding, then `SELF_SERVE_ENABLED=true` flip with frontend redeploy.
|
||||
- **Stripe live-mode setup:** Test-mode is fully wired (3 products, monthly prices for Starter/Pro, Enterprise sales-led, `plan_billing` seeded via `sync_stripe_plan_ids.py`). Live mode requires manual Dashboard config — same script handles seeding live IDs.
|
||||
- **GTM Validation:** Shadow & Ship — founder uses product for real MSP tickets daily, then hands logins to 5 colleagues.
|
||||
- **Solutions Library spec:** Written at `docs/plans/2026-03-23-solutions-library-design.md`, implementation deferred to post-pilot.
|
||||
|
||||
---
|
||||
|
||||
## What's Next (Priority Order)
|
||||
|
||||
### Phase O Cutover (Weeks 0-1)
|
||||
|
||||
- Merge PR #164
|
||||
- Stripe Dashboard live-mode setup (Products + Prices for Starter/Pro, no Prices on Enterprise, Customer Portal config, webhook endpoint with 5 events)
|
||||
- Railway prod env vars (`sk_live_*`, `whsec_*`, `INTERNAL_TESTER_EMAILS`, prod Google + Microsoft OAuth credentials, `OAUTH_REDIRECT_BASE`)
|
||||
- Run `sync_stripe_plan_ids.py` against prod backend; verify `plan_billing` has `sk_live_*` price IDs
|
||||
- Internal validation pass (9 scenarios from Phase O Task 46 plan)
|
||||
- Email pilots about complimentary status, flip `SELF_SERVE_ENABLED=true` (frontend redeploy required for `VITE_SELF_SERVE_ENABLED`)
|
||||
- PostHog dashboards + Sentry alert at >1/hour Stripe webhook errors
|
||||
|
||||
### Pilot Phase (Weeks 1-2)
|
||||
|
||||
- Founder dogfooding: use ResolutionFlow for real MSP tickets daily
|
||||
- Collect feedback on copilot-first experience
|
||||
- 3 calls with external Directors of Onboarding to validate the documentation-builder thesis (cold pitch, no friendly contacts)
|
||||
- Collect feedback on copilot-first experience and self-serve onboarding flow
|
||||
- Fix issues discovered during real usage
|
||||
|
||||
### Post-Pilot (Weeks 3-4)
|
||||
|
||||
@@ -108,7 +108,7 @@ Run these in order. Stop at the first failure and investigate.
|
||||
# Ubuntu / Debian
|
||||
sudo apt update && sudo apt install -y \
|
||||
git curl build-essential \
|
||||
python3.11 python3.11-venv python3-pip \
|
||||
python3.12 python3.12-venv python3-pip \
|
||||
postgresql-client # not the server — only if running Postgres natively
|
||||
|
||||
# Node 20 via nvm (survives container rebuilds if stored in a volume)
|
||||
@@ -236,7 +236,7 @@ REPO_ROOT=/absolute/path/to/resolutionflow
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3.11 -m venv venv
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
|
||||
26
README.md
26
README.md
@@ -11,10 +11,10 @@
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Prerequisites: Docker, Python 3.11+, Node.js 20+
|
||||
# Prerequisites: Docker, Python 3.12, Node.js 20+
|
||||
|
||||
# Start PostgreSQL
|
||||
docker start patherly_postgres
|
||||
# Start PostgreSQL (and the rest of the dev stack)
|
||||
docker compose -f docker-compose.dev.yml up -d
|
||||
|
||||
# Backend
|
||||
cd backend
|
||||
@@ -105,16 +105,17 @@ Every session generates timestamped, detailed notes formatted for your PSA. Engi
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
patherly/
|
||||
resolutionflow/
|
||||
├── backend/
|
||||
│ ├── app/
|
||||
│ │ ├── main.py # FastAPI entry point
|
||||
│ │ ├── api/endpoints/ # Route handlers (35+ endpoints)
|
||||
│ │ ├── api/endpoints/ # Route handlers (50+ endpoints)
|
||||
│ │ ├── core/ # Config, database, permissions, security
|
||||
│ │ ├── models/ # SQLAlchemy models
|
||||
│ │ ├── schemas/ # Pydantic schemas
|
||||
│ │ └── services/psa/ # PSA provider abstraction layer
|
||||
│ ├── alembic/ # Database migrations
|
||||
│ ├── scripts/ # Seed + sync scripts (incl. sync_stripe_plan_ids.py)
|
||||
│ └── tests/ # Integration tests (100+)
|
||||
├── frontend/
|
||||
│ ├── src/
|
||||
@@ -122,13 +123,19 @@ patherly/
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── store/ # Zustand stores
|
||||
│ │ └── types/ # TypeScript interfaces
|
||||
├── .ai/ # Dual-agent handoff system (PROJECT_CONTEXT, HANDOFF, etc.)
|
||||
├── docs/ # Design docs, plans, ConnectWise reference
|
||||
├── brand-assets/ # SVGs, brand guide
|
||||
├── CLAUDE.md # AI assistant project context
|
||||
├── CLAUDE.md # AI assistant project context (Claude Code)
|
||||
├── AGENTS.md # AI assistant project context (Codex; shared protocol with CLAUDE.md)
|
||||
├── CURRENT-STATE.md # Detailed feature status
|
||||
├── DESIGN-SYSTEM.md # Visual + interaction design system
|
||||
├── PRODUCT.md # Design intent and brand personality
|
||||
└── CHANGELOG.md # Release history
|
||||
```
|
||||
|
||||
> The on-disk repo path is `resolutionflow/`. `patherly` is the legacy internal name — still appears in some Railway service names and the prod DB name. Treat as an alias, not canonical.
|
||||
|
||||
---
|
||||
|
||||
## Running Tests
|
||||
@@ -149,10 +156,13 @@ npm run build
|
||||
|
||||
| Document | Purpose |
|
||||
|----------|---------|
|
||||
| [CLAUDE.md](CLAUDE.md) | Full project context for AI-assisted development |
|
||||
| [CLAUDE.md](CLAUDE.md) | Project context for Claude Code |
|
||||
| [AGENTS.md](AGENTS.md) | Project context for Codex (shared protocol with CLAUDE.md) |
|
||||
| [.ai/PROJECT_CONTEXT.md](.ai/PROJECT_CONTEXT.md) | Stable architectural truth |
|
||||
| [CURRENT-STATE.md](CURRENT-STATE.md) | Detailed feature status |
|
||||
| [03-DEVELOPMENT-ROADMAP.md](03-DEVELOPMENT-ROADMAP.md) | Development roadmap |
|
||||
| [UI-DESIGN-SYSTEM.md](UI-DESIGN-SYSTEM.md) | Design system (Slate & Ice) |
|
||||
| [DESIGN-SYSTEM.md](DESIGN-SYSTEM.md) | Visual + interaction design system (charcoal palette + electric blue accent) |
|
||||
| [PRODUCT.md](PRODUCT.md) | Design intent, users, brand personality |
|
||||
| [DEV-ENV.md](DEV-ENV.md) | Development environment setup |
|
||||
| [CHANGELOG.md](CHANGELOG.md) | Release history |
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# Session Handoff — Design System v4 Migration
|
||||
|
||||
> **For the next Claude session:** Read this file completely, internalize the context, then delete it (`rm SESSION-HANDOFF.md`). This is a one-time context transfer.
|
||||
|
||||
---
|
||||
|
||||
## What Was Done This Session
|
||||
|
||||
### 1. FlowPilot Message Bar + AI Script Builder (MERGED to main)
|
||||
- PR #118 merged. Always-visible message bar in FlowPilot sessions, AI Script Builder at `/script-builder`, library reorg (My/Team Scripts tabs), FlowPilot-to-Script-Builder handoff, session abandon/close, unified session history.
|
||||
- Eng review completed: normalized `script_builder_messages` table, typed content helpers, 6 edge case tests.
|
||||
|
||||
### 2. Design System v4 Migration (PR #119, open, branch: `refactor/design-system-v4`)
|
||||
- Complete frontend redesign from glassmorphism to flat dark theme (Sentry/PostHog-inspired)
|
||||
- **CSS Foundation:** New color tokens in `index.css`, all via CSS custom properties. Light mode ready (just needs `.light` class values).
|
||||
- **Icon Rail Sidebar:** 72px rail with 5 grouped icons (Home, Work, Knowledge, Insights, Help). Full-height resizable drawer on hover. Pin-to-expand to 260px. Mobile hamburger overlay.
|
||||
- **Component Sweep:** ~200 files migrated. All hardcoded hex replaced with semantic Tailwind tokens (bg-card, text-foreground, border-border, etc.).
|
||||
- **Landing Page:** Flat surfaces, no glow, solid buttons.
|
||||
- **Interactive Shadows:** Dark-mode-aware — elevated surfaces + faint cyan accent glow (black shadows invisible on dark bg).
|
||||
- **Stat Cards:** 3px colored left borders.
|
||||
- **Tab Toggles:** Active state uses `tab-active-shadow` (elevated bg + faint glow).
|
||||
|
||||
### 3. GTM Strategy (from /office-hours)
|
||||
- Shadow & Ship approach: Michael uses ResolutionFlow on real tickets for 2 weeks, then hands logins to 5 MSP colleagues. Key metric: unprompted return.
|
||||
- Design doc at `~/.gstack/projects/patherly-patherly/`
|
||||
|
||||
---
|
||||
|
||||
## What Needs To Be Done Next
|
||||
|
||||
### Immediate (Design System v4 polish)
|
||||
1. **Home icon color fix:** The Home icon in the sidebar shouldn't have a cyan background when not active. Instead, the Home icon itself should always be cyan (brand accent), and only show the `bg-accent-dim` background when the route is actually `/`. Michael specifically requested this.
|
||||
2. **Visual QA pass:** Michael hasn't done a full page-by-page walkthrough yet. Expect feedback on individual pages once he does.
|
||||
3. **`font-label` cleanup:** ~10 files still reference `font-label` (deprecated alias for `font-mono`). Each needs inspection — some should be `font-mono`, others `font-sans text-xs`.
|
||||
4. **Inline `style` attributes:** ~29 instances still use hardcoded hex in inline styles (sidebar, drawer, badges). Should be converted to CSS variable references or Tailwind classes where possible.
|
||||
|
||||
### Before Merging PR #119
|
||||
- Run migrations: `docker exec resolutionflow_backend alembic upgrade head` (new tables from the Script Builder PR are on main now)
|
||||
- Full visual QA with backend running
|
||||
- Test mobile responsive (hamburger menu)
|
||||
- Test FlowPilot session with new message bar + action bar positioning
|
||||
|
||||
### Future
|
||||
- **Light mode toggle:** CSS variables are ready. Need to add `.light` class values in `index.css` + toggle in user settings/account page.
|
||||
- **Script Builder testing:** The AI Script Builder hasn't been tested end-to-end with the backend running yet.
|
||||
|
||||
---
|
||||
|
||||
## Key Files to Know
|
||||
|
||||
| File | What it does |
|
||||
|------|-------------|
|
||||
| `DESIGN-SYSTEM.md` | Single source of truth for all design decisions |
|
||||
| `frontend/src/index.css` | CSS tokens, component utilities, shadow patterns |
|
||||
| `frontend/src/components/layout/Sidebar.tsx` | Icon rail + drawer + pinned sidebar |
|
||||
| `frontend/src/components/layout/AppLayout.tsx` | CSS Grid shell |
|
||||
| `frontend/src/components/dashboard/StartSessionInput.tsx` | The Guided/Chat toggle |
|
||||
| `frontend/src/components/dashboard/PerformanceCards.tsx` | Stat cards with colored borders |
|
||||
|
||||
## Key Lessons From This Session
|
||||
|
||||
- The component sweep agents missed `editor-ai/`, `guides/`, `maintenance/`, `scripts/`, `settings/` directories and `text-brand-dark` references. Always do a final grep audit after sweeps.
|
||||
- `bg-[#hex]` hardcoding defeats the purpose of CSS variables. We had to do a second pass to replace 3,200+ hardcoded values with semantic tokens.
|
||||
- Black shadows (`rgba(0,0,0,...)`) are invisible on dark backgrounds. Use elevated surfaces + faint accent glow instead.
|
||||
- The sidebar flyout needed `position: fixed` to escape the CSS Grid cell clipping — `absolute` positioning was hidden behind the main content area.
|
||||
- Flyout hover timing: individual item `onMouseLeave` was killing the flyout before the mouse reached the drawer. Only the outer wrapper should handle `onMouseLeave`.
|
||||
|
||||
---
|
||||
|
||||
> **After reading this file:** Save relevant context to your session memory, then run `rm SESSION-HANDOFF.md` and `git add -A && git commit -m "chore: remove session handoff file"`.
|
||||
171
abc-feat-self-serve-signup-phase-2-design-20260507-112020.md
Normal file
171
abc-feat-self-serve-signup-phase-2-design-20260507-112020.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Design: Documentation Builder — Day 1 Onboarding Wedge
|
||||
|
||||
Generated by /office-hours on 2026-05-07
|
||||
Branch: feat/self-serve-signup-phase-2
|
||||
Repo: chihlasm/resolutionflow
|
||||
Status: DRAFT
|
||||
Mode: Startup
|
||||
|
||||
## Problem Statement
|
||||
|
||||
ResolutionFlow has two authoring surfaces — branching Flows (decision trees) and linear Projects (procedures). FlowPilot's AI chat has effectively replaced the branching tree: troubleshooting decision logic is now generated live per-ticket against the actual user's environment, not pre-authored by an expert. Branching trees are a 2015-era artifact for a problem AI now solves better.
|
||||
|
||||
That leaves a gap. Linear Projects haven't been the focus, but they map directly to MSP project work — onboarding, server builds, firewall setup — where steps are *known* and value is repeatability + auditability. Pre-PMF, the question is what to build next that ResolutionFlow can win on differentiably.
|
||||
|
||||
The thesis surfaced in this session: **execution IS documentation.** Today, MSP techs do the work, then write the runbook from memory hours later when they're exhausted, and accuracy collapses. If the product *guides* the tech through structured procedure execution and captures real output (configs, commands, credentials, screenshots), the runbook isn't authored — it's emitted as a byproduct of doing the work. The execution log IS the runbook.
|
||||
|
||||
Position: **"We're not a documentation app. We are the documentation builders."** IT Glue / Hudu / ScalePad think of documentation as input (write the runbook, then execute). ResolutionFlow inverts it: execute, and the runbook writes itself.
|
||||
|
||||
## Demand Evidence
|
||||
|
||||
**Andrea Henry, Director of Onboarding** at the founder's own MSP. Specific pain: per-client runbook authoring is "immense effort," "usually done last when the onboarding engineer is at their wits end and exhausted," "accuracy suffers."
|
||||
|
||||
The role itself is a demand signal. "Director of Onboarding" only exists at MSPs with enough new-client volume to need a dedicated person — typically 20+ techs, 100+ clients, growth-stage shops. That's a buyer with a budget, not an end-user pleading with their boss.
|
||||
|
||||
**Caveat:** Andrea is a prospect inside the founder's own company. Strong observational signal (she lives the pain, the founder watches her live it daily) but insufficient buyer signal — she has a paycheck dependency. External validation is required before this thesis is durable. See "The Assignment."
|
||||
|
||||
## Status Quo
|
||||
|
||||
Current MSP workflow for new client onboarding:
|
||||
1. Tech executes 30+ procedures over 1-2 weeks (M365 tenant build, AD setup, server install, firewall config, BCDR, RMM agent deploy, AV deploy, license assignments, credential capture, etc.).
|
||||
2. Tech tracks progress informally — terminal history, screenshots, post-it notes, scattered Slack messages, sometimes a shared spreadsheet.
|
||||
3. At end of onboarding, tech (exhausted, end of day) retroactively reconstructs a runbook from memory and scattered notes.
|
||||
4. Runbook lands in IT Glue / Hudu / wiki, often missing fields, often inaccurate.
|
||||
5. Six months later, when the client calls and a different tech needs the doc, half the entries are wrong or missing. Senior techs redo work to verify reality. Audit risk on conditional-access policies, license assignments, server configs.
|
||||
|
||||
Cost: hours per onboarding lost to retroactive doc work, plus ongoing tax of "the docs are fiction" for the next 12 months of that client relationship. At an MSP with 5+ new clients per month, this is a real labor sink.
|
||||
|
||||
## Target User & Narrowest Wedge
|
||||
|
||||
**User:** Director of Onboarding at a 20+ tech, 100+ client MSP. Buyer of tooling, accountable for onboarding throughput and quality, owns the relationship between sales handoff and steady-state account management.
|
||||
|
||||
**Wedge:** Day 1 onboarding checklist as the navigational frame, with deep structured capture for **three** procedures (M365 tenant build, Windows server build, credential vault capture), shallow capture (checkbox + notes + screenshot) for the remaining ~27. Output publishes to Hudu, IT Glue, and ConnectWise.
|
||||
|
||||
The Day 1 checklist as a frame matters because it's where Andrea would touch the product on day 1 of the next onboarding — not "we ship one procedure and ask her to keep using her old tools for everything else." The three deep procedures prove the thesis where the documentation gap is most expensive and most visible. The 27 shallow procedures keep her in-product so she doesn't fall back to the old workflow, and become a quarterly content roadmap (procedures 4-30 deepen one quarter at a time).
|
||||
|
||||
## Constraints
|
||||
|
||||
- Pre-PMF, small team. Cannot ship 30 procedures × 3 output systems as v1.
|
||||
- ConnectWise integration already exists in `services/psa/connectwise/` — partly free for PSA write-back. Hudu and IT Glue APIs are net-new integration work.
|
||||
- Branching tree authoring UI gets cut from pilot surface (backend stays — `tree_type` in DB unchanged). Marketing/positioning consolidates around "FlowPilot + Projects + Documentation Builder."
|
||||
- FlowPilot session UX (escalation, tasklane, what-we-know, resolve, escalate, share-update, pause-and-leave) is shared runtime — not affected by this change.
|
||||
- Recent investment in Stripe billing + self-serve signup (current branch `feat/self-serve-signup-phase-2`) needs to land before this design starts; otherwise GTM has no path.
|
||||
|
||||
## Premises
|
||||
|
||||
1. "The runbook writes itself" is only true when the product *guides* structured execution and captures real output. Checkbox + notes = checklist tool, not documentation builder. **Confirmed.**
|
||||
2. Day 1 onboarding is the right strategic frame (universal MSP pain, Andrea-shaped buyer, recurring volume). **Confirmed.**
|
||||
3. First ship is **frame + deep capture on 3 procedures**, not all 30. The other 27 stay shallow in v1, deepen over time. **Confirmed.**
|
||||
4. Output targets v1: Hudu, IT Glue, ConnectWise. Autotask deferred to v2. Halo / Kaseya BMS post-PMF. **Confirmed.**
|
||||
5. External validation is non-negotiable. 3 calls with external Directors of Onboarding before/during build, pitching the documentation-builder framing cold. If 0 of 3 light up, revise the thesis. **Confirmed.**
|
||||
6. Branching trees cut from pilot UI. Backend retains `tree_type`. All positioning consolidates. **Confirmed.**
|
||||
|
||||
## Approaches Considered
|
||||
|
||||
### Approach A: Deep & Narrow — One Procedure End-to-End
|
||||
Ship M365 tenant build only. Full Graph API capture, three-system output. Other 29 procedures outside the product.
|
||||
- **Effort:** S (4-6 weeks). **Risk:** Low.
|
||||
- **Pros:** Thesis proven on one thing. Fastest to v1. Lowest risk of overbuild.
|
||||
- **Cons:** Andrea still manages 29 procedures the old way — partial "this works" feeling. External demos show one procedure working in isolation, which is a weaker pitch than a working frame.
|
||||
|
||||
### Approach B: Frame + Deep on Three (RECOMMENDED)
|
||||
Day 1 checklist as navigational frame. Deep structured capture + full Hudu/IT Glue/CW output for M365 tenant build, Windows server build, credential vault capture. Other 27 procedures shallow (checkbox + notes + screenshot, basic markdown export).
|
||||
- **Effort:** M (10-14 weeks). **Risk:** Medium.
|
||||
- **Pros:** Andrea uses it on day 1 of next onboarding for everything. Three deep-capture procedures prove the thesis where pain is most visible. Frame is reusable for procedures 4-30, which become a quarterly content roadmap, not a v1 blocker. Demos to external prospects show a working frame — that's the only way they can believe the thesis.
|
||||
- **Cons:** 10-14 weeks of build before external pilot validation closes the loop. Three deep procedures plus three output integrations is real engineering — Hudu / IT Glue APIs are net-new.
|
||||
|
||||
### Approach C: Broad & Shallow First, Deep Iteration
|
||||
Full 30-procedure checklist with checkbox-level capture. Basic markdown runbook from checkbox state + free-text + screenshots. Publishes to Hudu / IT Glue / CW as a single doc. Iterate procedure-by-procedure to add deep capture over Q3-Q4.
|
||||
- **Effort:** S-M (6-8 weeks v1). **Risk:** High.
|
||||
- **Pros:** Fastest to "Andrea uses it for the whole onboarding." Output integrations stand up once.
|
||||
- **Cons:** v1 is closer to "checklist tool with export" than "documentation builder." Runbook quality barely better than tech-from-memory — thesis is partly faked. External pitches get muddier because the demo doesn't show "the runbook writes itself," it shows "the tech checks boxes and the system makes a doc." Hard to recover positioning once the market sees v1.
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
**Approach B — Frame + Deep on Three.**
|
||||
|
||||
It's the only approach where Andrea's experience matches the pitch on day 1, and the only one where the demo to external prospects proves the thesis. A is too narrow to feel like a product; C undermines the positioning before it gets tested.
|
||||
|
||||
## Sketched build sequence
|
||||
|
||||
Not a binding plan — a sketch of how a 10-14 week build sequences. Refine in `/plan-eng-review`.
|
||||
|
||||
1. **Weeks 1-2 — Cut and consolidate.**
|
||||
- Hide branching tree authoring UI from pilot surface. Backend (`tree_type`) untouched. Marketing copy + DESIGN-SYSTEM.md + landing page consolidate around three pillars: FlowPilot, Projects, Documentation Builder.
|
||||
- Procedural editor lives, gets primary nav slot.
|
||||
- Run the 3 external Director-of-Onboarding calls in parallel. Block build progression on signal.
|
||||
|
||||
2. **Weeks 3-5 — Day 1 frame.**
|
||||
- New project type: "Client Onboarding." Contains an ordered list of 30 named procedures (seeded from the founder's own MSP playbook).
|
||||
- Per-procedure state: not started / in progress (claimed by tech) / complete. Hand-off between techs. Per-tech assignment. Progress tracking visible to Andrea.
|
||||
- 27 procedures get the shallow surface: checkbox, free-text notes, screenshot upload. Time spent. Tech who completed.
|
||||
|
||||
3. **Weeks 6-9 — Three deep procedures.**
|
||||
- **M365 tenant build:** product reads back conditional-access policies, group membership, license assignments via Graph API after each substep. Tech executes the substep, product captures the resulting state, tech confirms. Output: structured asset.
|
||||
- **Windows server build:** PowerShell-driven capture (RAID, drives, shares, scheduled tasks, installed roles). Output: structured asset.
|
||||
- **Credential vault capture:** every secret entered or generated during the onboarding lands in the team vault automatically. No tech 1Password leakage. Output: structured asset + vault entries.
|
||||
|
||||
4. **Weeks 10-12 — Output integrations.**
|
||||
- Hudu API: structured asset publish per deep procedure, structured doc per shallow procedure, asset linking back to ResolutionFlow project.
|
||||
- IT Glue API: same shape, IT Glue's asset model.
|
||||
- ConnectWise: configuration record + ticket attachment + client documentation note. Reuse `services/psa/connectwise/`.
|
||||
|
||||
5. **Weeks 13-14 — Internal pilot + external pilot.**
|
||||
- Andrea runs next onboarding through it. Watch, don't help. Capture every break.
|
||||
- 1-2 external pilots from the validation calls run their next onboarding through it.
|
||||
- Decision gate: ship to GA or pivot.
|
||||
|
||||
## Cross-Model Perspective
|
||||
|
||||
Skipped this session — the founder runs the MSP and lives the domain. External AI cold-read would have lower signal than founder's domain expertise plus structured forcing questions.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Hudu vs. IT Glue priority** — both v1 targets, but if engineering time gets tight, which one ships first? Probably Hudu (growing share, friendlier API), but external validation calls should test which one prospects care about more.
|
||||
2. **Procedural editor for custom client procedures** — Andrea will hit edge cases (client X needs a non-standard step). Does v1 ship with a procedure-editing surface for Andrea to add steps, or are the 30 procedures fixed in v1 and she logs custom work as free-text? Recommend: fixed in v1, editor in v1.5.
|
||||
3. **Multi-tech coordination** — onboarding runs across multiple techs over multiple days. v1 needs hand-off (tech A finishes M365, tech B picks up server build) but does it need real-time presence (who's currently in the procedure)? Recommend: hand-off yes, presence v1.5.
|
||||
4. **Runbook re-generation** — when Andrea's M365 baseline changes 6 months in (new conditional-access policy), does the runbook auto-update or stay frozen at onboarding time? This is the IT Glue / Hudu live-doc question and matters a lot. Punt to v2 explicitly; v1 ships a snapshot at onboarding completion.
|
||||
5. **Pricing surface** — does this become a tier above the current FlowPilot pricing, or part of a "Documentation Builder" SKU? GTM call, not a build call, but flag for `/plan-ceo-review`.
|
||||
6. **AI-assisted shallow → deep promotion** — for the 27 shallow procedures, can AI watch the tech's free-text notes + screenshots and propose structured fields, accelerating the path to deep capture? Probably yes; mark as a research thread for Q3.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- **Internal:** Andrea runs the next 3 onboardings entirely through the product. Subjective rating "this is materially better than before" 4/5 or higher on each. Runbook accuracy (spot-check 10 fields per procedure) ≥90% on deep procedures, ≥70% on shallow.
|
||||
- **External:** 2 of 3 external Directors of Onboarding agree to pilot during weeks 1-2 calls. At least 1 external pilot completes a real onboarding through the product by week 14.
|
||||
- **Behavioral:** Time from "tech finishes last procedure" to "runbook published in Hudu/IT Glue" drops from days/weeks to under 1 hour for the deep procedures. Zero retroactive runbook authoring sessions.
|
||||
- **Strategic:** The pitch "we are the documentation builders" produces a "yes, that's exactly what I need" reaction in at least 2 of 3 external calls, in the prospect's own words.
|
||||
|
||||
## Distribution Plan
|
||||
|
||||
Web service, existing Railway deployment pipeline. No new distribution surface needed. Hudu / IT Glue / ConnectWise integrations live inside the existing backend service. Auth flows through the existing OAuth/API-key model per integration.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocking:** Stripe billing + self-serve signup (current branch) lands first. GTM motion has no path otherwise.
|
||||
- **Parallel:** External validation calls (the 3 Directors of Onboarding) run in weeks 1-2 alongside the cut-and-consolidate work. If 0/3 light up, this design pauses for a thesis revision.
|
||||
- **Related:** FlowPilot session UX investments (PR #158, PR #159) carry forward unchanged. Branching tree backend (`tree_type` column) stays in DB.
|
||||
|
||||
## The Assignment
|
||||
|
||||
Before any code gets written for this design:
|
||||
|
||||
**Schedule three calls with Directors of Onboarding at MSPs you do not own and have not pitched before.** Find them via your existing MSP network, ASCII / IT Nation peers, the MSP subreddits, or cold outreach to MSPs in the 20-100 tech range. Do not use vendor friends — they will be polite, not honest.
|
||||
|
||||
Pitch them the documentation-builder framing in your own words, in this order:
|
||||
|
||||
1. Open with the pain: "Walk me through your last new-client onboarding. Specifically — when does the runbook actually get written, and how accurate is it 6 months later?"
|
||||
2. Listen. Do not pitch yet. Take notes on the words they use.
|
||||
3. Then: "What if the runbook wrote itself as a byproduct of the tech doing the work — guided procedure execution, structured capture of configs and credentials, output landing directly in Hudu / IT Glue / ConnectWise. Would that be valuable to you, or am I solving a problem you don't have?"
|
||||
4. Watch their face / listen to their tone. The signal you want is "yes, that's exactly what I need" in their own words. The signal you want to fear is "interesting, send me more info."
|
||||
5. Ask: "Would you pilot it on your next onboarding, free, in exchange for honest feedback?"
|
||||
|
||||
If 0/3 say yes to pilot, the thesis needs revision before code. If 1/3, build but flag the risk. If 2-3/3, build with confidence.
|
||||
|
||||
Bring your own design doc (this one) to the calls. Show it. Let them critique it. Their language is more valuable than yours.
|
||||
|
||||
## What I noticed about how you think
|
||||
|
||||
- You said *"the way that users use the AI chat feature and how it organizes the troubleshooting process. The best part is how it documents the process from start to finish. This is the way troubleshooting will be done in the future."* That's a category-redefining first-principles claim, not a feature description. Most founders pitch features. You pitched a thesis. That's rare.
|
||||
- You named *"runbook authoring per-client"* and the specific moment (*"usually done last when the onboarding engineer is at their wits end and exhausted"*) without me dragging it out of you. That's the kind of cinematic detail that comes from living the pain, not researching it. You run the MSP. Andrea works for you. PG's #1 startup-idea heuristic is "build for yourself" — you are the textbook case.
|
||||
- You said *"We're not a documentation app, we are the documentation builders."* Hold onto that line. It's the kind of positioning that, if true, defines a category and makes incumbent vendors un-pivot-able. Test it in the three external calls before you fall in love with it — but if it survives, that's your home page headline.
|
||||
- When I challenged your wedge as too broad, you didn't budge. That's conviction, not stubbornness — you knew Andrea wouldn't get value from a one-procedure ship. Worth flagging because most founders cave on scope challenges. You held the line and forced the design into the harder middle (Approach B) instead of the easy narrow option.
|
||||
@@ -21,4 +21,22 @@ ANTHROPIC_API_KEY=
|
||||
VOYAGE_API_KEY=
|
||||
|
||||
# ConnectWise PSA Integration
|
||||
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
|
||||
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
|
||||
|
||||
# Stripe
|
||||
# Test keys from Stripe Dashboard → Developers → API keys (with Test mode toggled on).
|
||||
# Webhook secret for local dev: from `stripe listen --forward-to localhost:8000/api/v1/webhooks/stripe`.
|
||||
# When unset, app/core/config.py:stripe_enabled returns False and Stripe code paths short-circuit.
|
||||
STRIPE_SECRET_KEY=sk_test_
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_
|
||||
STRIPE_WEBHOOK_SECRET=whsec_
|
||||
|
||||
# Self-serve cutover
|
||||
# SELF_SERVE_ENABLED is the master switch for the public self-serve signup
|
||||
# flow (pricing page, invite-code-optional registration). Default is false
|
||||
# until Phase O cutover.
|
||||
# INTERNAL_TESTER_EMAILS is a comma-separated allowlist that bypasses the
|
||||
# global flag for specific users — used for prod test-mode validation
|
||||
# before the public flip. Empty by default.
|
||||
SELF_SERVE_ENABLED=false
|
||||
INTERNAL_TESTER_EMAILS=
|
||||
@@ -5,6 +5,12 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
libpango1.0-dev \
|
||||
libcairo2-dev \
|
||||
libgdk-pixbuf-2.0-dev \
|
||||
libffi-dev \
|
||||
libjpeg-dev \
|
||||
zlib1g-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt requirements-dev.txt ./
|
||||
@@ -12,4 +18,4 @@ RUN pip install --no-cache-dir -r requirements-dev.txt
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ]
|
||||
CMD [ "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload" ]
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
"""flow_proposal l1 source linkage
|
||||
|
||||
Revision ID: 1fd88a68b145
|
||||
Revises: cb9e282267d2
|
||||
Create Date: 2026-05-29 19:33:09.188681
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1fd88a68b145'
|
||||
down_revision: Union[str, None] = 'cb9e282267d2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"flow_proposals",
|
||||
sa.Column("l1_session_id", postgresql.UUID(as_uuid=True), nullable=True),
|
||||
)
|
||||
op.create_index(
|
||||
"ix_flow_proposals_l1_session_id",
|
||||
"flow_proposals",
|
||||
["l1_session_id"],
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_flow_proposals_l1_session_id",
|
||||
"flow_proposals",
|
||||
"l1_walk_sessions",
|
||||
["l1_session_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.alter_column("flow_proposals", "source_session_id", nullable=True)
|
||||
op.create_check_constraint(
|
||||
"ck_flow_proposals_exactly_one_source",
|
||||
"flow_proposals",
|
||||
"(source_session_id IS NOT NULL) <> (l1_session_id IS NOT NULL)",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"ck_flow_proposals_exactly_one_source",
|
||||
"flow_proposals",
|
||||
type_="check",
|
||||
)
|
||||
op.alter_column("flow_proposals", "source_session_id", nullable=False)
|
||||
op.drop_constraint(
|
||||
"fk_flow_proposals_l1_session_id",
|
||||
"flow_proposals",
|
||||
type_="foreignkey",
|
||||
)
|
||||
op.drop_index("ix_flow_proposals_l1_session_id", "flow_proposals")
|
||||
op.drop_column("flow_proposals", "l1_session_id")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""account_invites add revoked_at and email_sent_at
|
||||
|
||||
Revision ID: 2aa73d3231c2
|
||||
Revises: e1af7ab57ceb
|
||||
Create Date: 2026-05-06 07:28:28.514384
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '2aa73d3231c2'
|
||||
down_revision: Union[str, None] = 'e1af7ab57ceb'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("account_invites", sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("account_invites", sa.Column("email_sent_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.create_index("ix_account_invites_revoked_at", "account_invites", ["revoked_at"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_account_invites_revoked_at", table_name="account_invites")
|
||||
op.drop_column("account_invites", "email_sent_at")
|
||||
op.drop_column("account_invites", "revoked_at")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""add_starter_rename_team_to_enterprise
|
||||
|
||||
Revision ID: 4ce3e594cb87
|
||||
Revises: c6cbfc534fad
|
||||
Create Date: 2026-05-07 19:36:27.172082
|
||||
|
||||
Plan tier taxonomy reconciliation. Marketing surface and Stripe products
|
||||
named "Starter / Pro / Enterprise"; backend was on "free / pro / team".
|
||||
This migration:
|
||||
|
||||
1. Defensively migrates any existing subscriptions on plan='team' to
|
||||
plan='enterprise' (dev has zero such rows; prod is expected to have
|
||||
none, but the UPDATE is safe and idempotent).
|
||||
2. Renames the plan_limits row 'team' -> 'enterprise'. plan_billing
|
||||
and plan_feature_defaults are FK-referenced but currently empty;
|
||||
the rename works because PostgreSQL allows updating PK values when
|
||||
no FK rows reference them.
|
||||
3. Inserts a new plan_limits row for 'starter' between free and pro.
|
||||
|
||||
Resource visibility (Tree.visibility, StepLibrary.visibility) also uses
|
||||
the string 'team' for "shared with my account" — that is a separate
|
||||
domain and is intentionally not touched.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '4ce3e594cb87'
|
||||
down_revision: Union[str, None] = 'c6cbfc534fad'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("UPDATE subscriptions SET plan = 'enterprise' WHERE plan = 'team'")
|
||||
op.execute("UPDATE plan_limits SET plan = 'enterprise' WHERE plan = 'team'")
|
||||
op.execute("""
|
||||
INSERT INTO plan_limits (
|
||||
plan,
|
||||
max_trees,
|
||||
max_sessions_per_month,
|
||||
max_users,
|
||||
custom_branding,
|
||||
priority_support,
|
||||
export_formats,
|
||||
max_ai_builds_per_month,
|
||||
max_ai_builds_per_24h,
|
||||
kb_accelerator_enabled,
|
||||
kb_max_lifetime_conversions,
|
||||
kb_batch_max_size,
|
||||
kb_allowed_formats,
|
||||
kb_detailed_analysis,
|
||||
kb_conversational_refinement,
|
||||
kb_step_library_matching,
|
||||
kb_history_limit
|
||||
) VALUES (
|
||||
'starter',
|
||||
10,
|
||||
75,
|
||||
1,
|
||||
FALSE,
|
||||
FALSE,
|
||||
'["markdown", "text", "html"]'::jsonb,
|
||||
15,
|
||||
5,
|
||||
FALSE,
|
||||
NULL,
|
||||
NULL,
|
||||
'["txt", "paste", "md"]'::jsonb,
|
||||
FALSE,
|
||||
FALSE,
|
||||
FALSE,
|
||||
NULL
|
||||
)
|
||||
ON CONFLICT (plan) DO NOTHING
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DELETE FROM plan_limits WHERE plan = 'starter'")
|
||||
op.execute("UPDATE plan_limits SET plan = 'team' WHERE plan = 'enterprise'")
|
||||
op.execute("UPDATE subscriptions SET plan = 'team' WHERE plan = 'enterprise'")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""users add role_at_signup and onboarding_step_completed
|
||||
|
||||
Revision ID: 58e3caaa6269
|
||||
Revises: 5bb055a1593e
|
||||
Create Date: 2026-05-06 07:25:16.780761
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '58e3caaa6269'
|
||||
down_revision: Union[str, None] = '5bb055a1593e'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("role_at_signup", sa.String(50), nullable=True))
|
||||
op.add_column("users", sa.Column("onboarding_step_completed", sa.Integer(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "onboarding_step_completed")
|
||||
op.drop_column("users", "role_at_signup")
|
||||
@@ -0,0 +1,47 @@
|
||||
"""users password_hash nullable
|
||||
|
||||
Revision ID: 5bb055a1593e
|
||||
Revises: b1fad5ddf357
|
||||
Create Date: 2026-05-06 07:23:21.480252
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '5bb055a1593e'
|
||||
down_revision: Union[str, None] = 'b1fad5ddf357'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"users",
|
||||
"password_hash",
|
||||
existing_type=sa.String(255),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# NOTE: downgrade is non-trivial if any OAuth-only users exist.
|
||||
# This downgrade fails fast in that case rather than corrupting data.
|
||||
conn = op.get_bind()
|
||||
null_count = conn.execute(
|
||||
sa.text("SELECT COUNT(*) FROM users WHERE password_hash IS NULL")
|
||||
).scalar()
|
||||
if null_count and null_count > 0:
|
||||
raise RuntimeError(
|
||||
f"Cannot downgrade: {null_count} OAuth-only users have NULL password_hash. "
|
||||
"Set passwords or delete those rows before downgrading."
|
||||
)
|
||||
op.alter_column(
|
||||
"users",
|
||||
"password_hash",
|
||||
existing_type=sa.String(255),
|
||||
nullable=False,
|
||||
)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""add applied_pending status + pending_reason to session_suggested_fixes
|
||||
|
||||
Adds the `applied_pending` non-terminal status (engineer ran the fix but
|
||||
verification is deferred — waiting on client, async sync, etc) alongside
|
||||
the existing `applied_partial` status. Mirrors partial_notes with a new
|
||||
pending_reason column for the "what are you waiting on?" prose.
|
||||
|
||||
Revision ID: c0f3a4b7e91d
|
||||
Revises: 71efd2102f49
|
||||
Create Date: 2026-04-30
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "c0f3a4b7e91d"
|
||||
down_revision: Union[str, None] = "71efd2102f49"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"session_suggested_fixes",
|
||||
sa.Column("pending_reason", sa.Text(), nullable=True),
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_session_suggested_fixes_status",
|
||||
"session_suggested_fixes",
|
||||
type_="check",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_session_suggested_fixes_status",
|
||||
"session_suggested_fixes",
|
||||
"status IN ('proposed', 'applied_success', 'applied_failed', "
|
||||
"'applied_partial', 'applied_pending', 'dismissed')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(
|
||||
"UPDATE session_suggested_fixes "
|
||||
"SET status = 'applied_partial', "
|
||||
" partial_notes = COALESCE(partial_notes, pending_reason) "
|
||||
"WHERE status = 'applied_pending'"
|
||||
)
|
||||
op.drop_constraint(
|
||||
"ck_session_suggested_fixes_status",
|
||||
"session_suggested_fixes",
|
||||
type_="check",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
"ck_session_suggested_fixes_status",
|
||||
"session_suggested_fixes",
|
||||
"status IN ('proposed', 'applied_success', 'applied_failed', "
|
||||
"'applied_partial', 'dismissed')",
|
||||
)
|
||||
op.drop_column("session_suggested_fixes", "pending_reason")
|
||||
@@ -0,0 +1,79 @@
|
||||
"""create_internal_tickets
|
||||
|
||||
Revision ID: a1e6a018af02
|
||||
Revises: ff6fe5895ea2
|
||||
Create Date: 2026-05-28 16:29:32.624317
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a1e6a018af02'
|
||||
down_revision: Union[str, None] = 'ff6fe5895ea2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_NULL_UUID = "00000000-0000-0000-0000-000000000000"
|
||||
_CURRENT_ACCOUNT = (
|
||||
f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||
f"'{_NULL_UUID}')::uuid"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'internal_tickets',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('account_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('created_by_user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('customer_name', sa.String(120), nullable=True),
|
||||
sa.Column('customer_contact', sa.String(200), nullable=True),
|
||||
sa.Column('problem_statement', sa.Text(), nullable=False),
|
||||
sa.Column('status', sa.String(30), nullable=False, server_default='open'),
|
||||
sa.Column('flow_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('flow_proposal_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('ai_session_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('assigned_user_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('resolution_notes', sa.Text(), nullable=True),
|
||||
sa.Column('psa_promoted_ticket_id', sa.String(64), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ondelete='RESTRICT'),
|
||||
sa.ForeignKeyConstraint(['flow_id'], ['trees.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['flow_proposal_id'], ['flow_proposals.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['ai_session_id'], ['ai_sessions.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['assigned_user_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('open', 'walking', 'resolved', 'escalated')",
|
||||
name='ck_internal_tickets_status',
|
||||
),
|
||||
)
|
||||
op.create_index('ix_internal_tickets_account_id', 'internal_tickets', ['account_id'])
|
||||
op.create_index('ix_internal_tickets_status', 'internal_tickets', ['status'])
|
||||
op.create_index('ix_internal_tickets_assigned_user_id', 'internal_tickets', ['assigned_user_id'])
|
||||
|
||||
op.execute("ALTER TABLE internal_tickets ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE internal_tickets FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON internal_tickets
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON internal_tickets")
|
||||
op.execute("ALTER TABLE internal_tickets DISABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE internal_tickets NO FORCE ROW LEVEL SECURITY")
|
||||
op.drop_index('ix_internal_tickets_assigned_user_id', 'internal_tickets')
|
||||
op.drop_index('ix_internal_tickets_status', 'internal_tickets')
|
||||
op.drop_index('ix_internal_tickets_account_id', 'internal_tickets')
|
||||
op.drop_table('internal_tickets')
|
||||
59
backend/alembic/versions/a8186f22506d_add_l1_columns.py
Normal file
59
backend/alembic/versions/a8186f22506d_add_l1_columns.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""add_l1_columns
|
||||
|
||||
Revision ID: a8186f22506d
|
||||
Revises: b269a1add160
|
||||
Create Date: 2026-05-28 16:15:40.900535
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'a8186f22506d'
|
||||
down_revision: Union[str, None] = 'b269a1add160'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'users',
|
||||
sa.Column('can_cover_l1', sa.Boolean(), nullable=False, server_default='false'),
|
||||
)
|
||||
op.add_column(
|
||||
'accounts',
|
||||
sa.Column('l1_seats_purchased', sa.Integer(), nullable=False, server_default='0'),
|
||||
)
|
||||
op.add_column(
|
||||
'subscriptions',
|
||||
sa.Column('l1_seat_limit', sa.Integer(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
'audit_logs',
|
||||
sa.Column('acting_as', sa.String(30), nullable=True),
|
||||
)
|
||||
|
||||
# Rotate account_role CHECK constraint to include 'l1_tech'
|
||||
op.drop_constraint('ck_users_account_role_enum', 'users', type_='check')
|
||||
op.create_check_constraint(
|
||||
'ck_users_account_role_enum',
|
||||
'users',
|
||||
"account_role IN ('owner', 'admin', 'engineer', 'l1_tech', 'viewer')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Reverse the constraint rotation first
|
||||
op.drop_constraint('ck_users_account_role_enum', 'users', type_='check')
|
||||
op.create_check_constraint(
|
||||
'ck_users_account_role_enum',
|
||||
'users',
|
||||
"account_role IN ('owner', 'admin', 'engineer', 'viewer')",
|
||||
)
|
||||
op.drop_column('audit_logs', 'acting_as')
|
||||
op.drop_column('subscriptions', 'l1_seat_limit')
|
||||
op.drop_column('accounts', 'l1_seats_purchased')
|
||||
op.drop_column('users', 'can_cover_l1')
|
||||
@@ -0,0 +1,39 @@
|
||||
"""add oauth_identities
|
||||
|
||||
Revision ID: b1fad5ddf357
|
||||
Revises: c0f3a4b7e91d
|
||||
Create Date: 2026-05-06 07:17:11.374555
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b1fad5ddf357'
|
||||
down_revision: Union[str, None] = 'c0f3a4b7e91d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"oauth_identities",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("provider", sa.String(20), nullable=False),
|
||||
sa.Column("provider_subject", sa.String(255), nullable=False),
|
||||
sa.Column("provider_email_at_link", sa.String(255), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.UniqueConstraint("provider", "provider_subject", name="uq_oauth_identities_provider_subject"),
|
||||
)
|
||||
op.create_index("ix_oauth_identities_user_id", "oauth_identities", ["user_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_oauth_identities_user_id", table_name="oauth_identities")
|
||||
op.drop_table("oauth_identities")
|
||||
@@ -0,0 +1,72 @@
|
||||
"""add_session_policy_columns_to_accounts
|
||||
|
||||
Revision ID: b269a1add160
|
||||
Revises: 4ce3e594cb87
|
||||
Create Date: 2026-05-13 19:50:51.343777
|
||||
|
||||
Adds per-account session-policy overrides. NULL on either column means
|
||||
"use the system default from Settings.SESSION_*_MINUTES_DEFAULT." The
|
||||
CHECK constraint is defense-in-depth for the both-set case; the partial-
|
||||
override case (one NULL, one set) is validated at the app layer because
|
||||
the DB cannot see Settings.
|
||||
|
||||
See docs/plans/2026-05-13-session-expiration-policy.md for full design.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = 'b269a1add160'
|
||||
down_revision: Union[str, None] = '4ce3e594cb87'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'accounts',
|
||||
sa.Column(
|
||||
'session_idle_minutes',
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment=(
|
||||
'Account override for idle session window in minutes. '
|
||||
'NULL = use Settings.SESSION_IDLE_MINUTES_DEFAULT.'
|
||||
),
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
'accounts',
|
||||
sa.Column(
|
||||
'session_absolute_minutes',
|
||||
sa.Integer(),
|
||||
nullable=True,
|
||||
comment=(
|
||||
'Account override for absolute session lifetime in minutes. '
|
||||
'NULL = use Settings.SESSION_ABSOLUTE_MINUTES_DEFAULT.'
|
||||
),
|
||||
),
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'session_idle_le_absolute_when_both_set',
|
||||
'accounts',
|
||||
'('
|
||||
'session_idle_minutes IS NULL '
|
||||
'OR session_absolute_minutes IS NULL '
|
||||
'OR session_idle_minutes <= session_absolute_minutes'
|
||||
')',
|
||||
)
|
||||
op.execute(
|
||||
"COMMENT ON CONSTRAINT session_idle_le_absolute_when_both_set ON accounts IS "
|
||||
"'Defense in depth: catches idle > absolute when both are overridden. "
|
||||
"Partial-override case (one NULL, one set) is validated at the app layer "
|
||||
"against current system defaults, since the DB cannot see Settings.'"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint('session_idle_le_absolute_when_both_set', 'accounts', type_='check')
|
||||
op.drop_column('accounts', 'session_absolute_minutes')
|
||||
op.drop_column('accounts', 'session_idle_minutes')
|
||||
@@ -0,0 +1,97 @@
|
||||
"""create_l1_walk_sessions
|
||||
|
||||
Revision ID: b3358ba0e48c
|
||||
Revises: a1e6a018af02
|
||||
Create Date: 2026-05-28 16:33:52.120027
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'b3358ba0e48c'
|
||||
down_revision: Union[str, None] = 'a1e6a018af02'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_NULL_UUID = "00000000-0000-0000-0000-000000000000"
|
||||
_CURRENT_ACCOUNT = (
|
||||
f"COALESCE(NULLIF(current_setting('app.current_account_id', TRUE), ''), "
|
||||
f"'{_NULL_UUID}')::uuid"
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'l1_walk_sessions',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('account_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('created_by_user_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('acting_as', sa.String(30), nullable=True),
|
||||
sa.Column('ticket_id', sa.String(64), nullable=False),
|
||||
sa.Column('ticket_kind', sa.String(10), nullable=False),
|
||||
sa.Column('session_kind', sa.String(20), nullable=False),
|
||||
sa.Column('flow_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('flow_proposal_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('current_node_id', sa.String(100), nullable=True),
|
||||
sa.Column('walked_path', postgresql.JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column('walk_notes', postgresql.JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
|
||||
sa.Column('status', sa.String(20), nullable=False, server_default='active'),
|
||||
sa.Column('resolution_notes', sa.Text(), nullable=True),
|
||||
sa.Column('helpful', sa.Boolean(), nullable=True),
|
||||
sa.Column('escalation_reason', sa.Text(), nullable=True),
|
||||
sa.Column('escalation_reason_category', sa.String(30), nullable=True),
|
||||
sa.Column('started_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||
sa.Column('last_step_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.text('now()')),
|
||||
sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], ondelete='RESTRICT'),
|
||||
sa.ForeignKeyConstraint(['flow_id'], ['trees.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['flow_proposal_id'], ['flow_proposals.id'], ondelete='SET NULL'),
|
||||
sa.CheckConstraint(
|
||||
"ticket_kind IN ('psa', 'internal')",
|
||||
name='ck_l1_walk_sessions_ticket_kind',
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"session_kind IN ('flow', 'proposal', 'adhoc')",
|
||||
name='ck_l1_walk_sessions_session_kind',
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"status IN ('active', 'resolved', 'escalated', 'abandoned')",
|
||||
name='ck_l1_walk_sessions_status',
|
||||
),
|
||||
sa.CheckConstraint(
|
||||
"(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) "
|
||||
"OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) "
|
||||
"OR (session_kind = 'adhoc' AND flow_id IS NULL AND flow_proposal_id IS NULL)",
|
||||
name='ck_l1_walk_sessions_target_consistency',
|
||||
),
|
||||
)
|
||||
op.create_index('ix_l1_walk_sessions_account_id', 'l1_walk_sessions', ['account_id'])
|
||||
op.create_index('ix_l1_walk_sessions_created_by_user_id', 'l1_walk_sessions', ['created_by_user_id'])
|
||||
op.create_index('ix_l1_walk_sessions_status', 'l1_walk_sessions', ['status'])
|
||||
op.create_index('ix_l1_walk_sessions_last_step_at', 'l1_walk_sessions', ['last_step_at'])
|
||||
|
||||
op.execute("ALTER TABLE l1_walk_sessions ENABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE l1_walk_sessions FORCE ROW LEVEL SECURITY")
|
||||
op.execute(f"""
|
||||
CREATE POLICY tenant_isolation ON l1_walk_sessions
|
||||
USING (account_id = {_CURRENT_ACCOUNT})
|
||||
WITH CHECK (account_id = {_CURRENT_ACCOUNT})
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP POLICY IF EXISTS tenant_isolation ON l1_walk_sessions")
|
||||
op.execute("ALTER TABLE l1_walk_sessions DISABLE ROW LEVEL SECURITY")
|
||||
op.execute("ALTER TABLE l1_walk_sessions NO FORCE ROW LEVEL SECURITY")
|
||||
op.drop_index('ix_l1_walk_sessions_last_step_at', 'l1_walk_sessions')
|
||||
op.drop_index('ix_l1_walk_sessions_status', 'l1_walk_sessions')
|
||||
op.drop_index('ix_l1_walk_sessions_created_by_user_id', 'l1_walk_sessions')
|
||||
op.drop_index('ix_l1_walk_sessions_account_id', 'l1_walk_sessions')
|
||||
op.drop_table('l1_walk_sessions')
|
||||
@@ -0,0 +1,48 @@
|
||||
"""add ai_build session kind
|
||||
|
||||
Revision ID: beca7464b6b4
|
||||
Revises: b3358ba0e48c
|
||||
Create Date: 2026-05-29 18:41:38.601537
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'beca7464b6b4'
|
||||
down_revision: Union[str, None] = 'b3358ba0e48c'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_constraint("ck_l1_walk_sessions_session_kind", "l1_walk_sessions", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_l1_walk_sessions_session_kind", "l1_walk_sessions",
|
||||
"session_kind IN ('flow', 'proposal', 'adhoc', 'ai_build')",
|
||||
)
|
||||
op.drop_constraint("ck_l1_walk_sessions_target_consistency", "l1_walk_sessions", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_l1_walk_sessions_target_consistency", "l1_walk_sessions",
|
||||
"(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) "
|
||||
"OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) "
|
||||
"OR (session_kind IN ('adhoc', 'ai_build') AND flow_id IS NULL AND flow_proposal_id IS NULL)",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("ck_l1_walk_sessions_target_consistency", "l1_walk_sessions", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_l1_walk_sessions_target_consistency", "l1_walk_sessions",
|
||||
"(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) "
|
||||
"OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) "
|
||||
"OR (session_kind = 'adhoc' AND flow_id IS NULL AND flow_proposal_id IS NULL)",
|
||||
)
|
||||
op.drop_constraint("ck_l1_walk_sessions_session_kind", "l1_walk_sessions", type_="check")
|
||||
op.create_check_constraint(
|
||||
"ck_l1_walk_sessions_session_kind", "l1_walk_sessions",
|
||||
"session_kind IN ('flow', 'proposal', 'adhoc')",
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""subscriptions pilot complimentary backfill
|
||||
|
||||
This migration converts existing pilot/dev accounts to permanent complimentary
|
||||
Pro per the self-serve signup spec section 5. Forward-only; downgrade is
|
||||
prohibited because original status is not preserved.
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "c6cbfc534fad"
|
||||
down_revision: Union[str, None] = "c982a3fc4bf1"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Set status='complimentary' and plan='pro' for all existing accounts that
|
||||
don't have a canceled or past_due subscription. Pilot users transition to
|
||||
permanent complimentary Pro per spec section 5.
|
||||
|
||||
Forward-only — does not preserve original status values."""
|
||||
conn = op.get_bind()
|
||||
# Update existing rows
|
||||
conn.execute(sa.text("""
|
||||
UPDATE subscriptions
|
||||
SET status = 'complimentary', plan = 'pro',
|
||||
current_period_end = NULL, current_period_start = NULL,
|
||||
updated_at = now()
|
||||
WHERE status NOT IN ('canceled', 'past_due')
|
||||
"""))
|
||||
# Backfill: any account without a Subscription row gets one
|
||||
conn.execute(sa.text("""
|
||||
INSERT INTO subscriptions (id, account_id, plan, status, cancel_at_period_end, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), a.id, 'pro', 'complimentary', false, now(), now()
|
||||
FROM accounts a
|
||||
WHERE NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.account_id = a.id)
|
||||
"""))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
raise RuntimeError(
|
||||
"Cannot downgrade: original subscription state is not preserved. "
|
||||
"Restore from backup if needed."
|
||||
)
|
||||
45
backend/alembic/versions/c982a3fc4bf1_add_stripe_events.py
Normal file
45
backend/alembic/versions/c982a3fc4bf1_add_stripe_events.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""add stripe_events
|
||||
|
||||
Revision ID: c982a3fc4bf1
|
||||
Revises: f7da3f93b519
|
||||
Create Date: 2026-05-06 07:32:08.027633
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c982a3fc4bf1'
|
||||
down_revision: Union[str, None] = 'f7da3f93b519'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"stripe_events",
|
||||
sa.Column("id", sa.String(length=255), primary_key=True, nullable=False),
|
||||
sa.Column("event_type", sa.String(length=100), nullable=False),
|
||||
sa.Column(
|
||||
"processed_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"payload_excerpt",
|
||||
JSONB,
|
||||
nullable=False,
|
||||
server_default=sa.text("'{}'::jsonb"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_stripe_events_event_type", "stripe_events", ["event_type"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_stripe_events_event_type", table_name="stripe_events")
|
||||
op.drop_table("stripe_events")
|
||||
@@ -0,0 +1,35 @@
|
||||
"""add enabled_l1_categories to accounts
|
||||
|
||||
Revision ID: cb9e282267d2
|
||||
Revises: beca7464b6b4
|
||||
Create Date: 2026-05-29 18:48:27.155183
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'cb9e282267d2'
|
||||
down_revision: Union[str, None] = 'beca7464b6b4'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
_DEFAULT = ('["password_reset","account_lockout","printer","email_outlook_client",'
|
||||
'"wifi_network_basics","vpn_connect","teams_zoom_av","browser_cache_cookies",'
|
||||
'"peripheral_reconnect","os_restart_update"]')
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("accounts", sa.Column(
|
||||
"enabled_l1_categories", postgresql.JSONB(), nullable=False,
|
||||
server_default=sa.text(f"'{_DEFAULT}'::jsonb"),
|
||||
))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("accounts", "enabled_l1_categories")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""accounts add wizard columns
|
||||
|
||||
Revision ID: e1af7ab57ceb
|
||||
Revises: 58e3caaa6269
|
||||
Create Date: 2026-05-06 07:27:15.755518
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'e1af7ab57ceb'
|
||||
down_revision: Union[str, None] = '58e3caaa6269'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("accounts", sa.Column("team_size_bucket", sa.String(20), nullable=True))
|
||||
op.add_column("accounts", sa.Column("primary_psa", sa.String(20), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("accounts", "primary_psa")
|
||||
op.drop_column("accounts", "team_size_bucket")
|
||||
41
backend/alembic/versions/f236a91224d0_add_plan_billing.py
Normal file
41
backend/alembic/versions/f236a91224d0_add_plan_billing.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""add plan_billing
|
||||
|
||||
Revision ID: f236a91224d0
|
||||
Revises: 2aa73d3231c2
|
||||
Create Date: 2026-05-06 07:30:06.807887
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f236a91224d0'
|
||||
down_revision: Union[str, None] = '2aa73d3231c2'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"plan_billing",
|
||||
sa.Column("plan", sa.String(50), sa.ForeignKey("plan_limits.plan"), primary_key=True),
|
||||
sa.Column("display_name", sa.String(255), nullable=False),
|
||||
sa.Column("description", sa.Text(), nullable=True),
|
||||
sa.Column("monthly_price_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("annual_price_cents", sa.Integer(), nullable=True),
|
||||
sa.Column("stripe_product_id", sa.String(255), nullable=True),
|
||||
sa.Column("stripe_monthly_price_id", sa.String(255), nullable=True),
|
||||
sa.Column("stripe_annual_price_id", sa.String(255), nullable=True),
|
||||
sa.Column("is_public", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("is_archived", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("plan_billing")
|
||||
57
backend/alembic/versions/f7da3f93b519_add_sales_leads.py
Normal file
57
backend/alembic/versions/f7da3f93b519_add_sales_leads.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""add sales_leads
|
||||
|
||||
Revision ID: f7da3f93b519
|
||||
Revises: f236a91224d0
|
||||
Create Date: 2026-05-06 07:31:39.533305
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'f7da3f93b519'
|
||||
down_revision: Union[str, None] = 'f236a91224d0'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"sales_leads",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True, nullable=False),
|
||||
sa.Column("email", sa.String(length=255), nullable=False),
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("company", sa.String(length=255), nullable=False),
|
||||
sa.Column("team_size", sa.String(length=20), nullable=True),
|
||||
sa.Column("message", sa.Text(), nullable=True),
|
||||
sa.Column("source", sa.String(length=50), nullable=False),
|
||||
sa.Column("posthog_distinct_id", sa.String(length=255), nullable=True),
|
||||
sa.Column(
|
||||
"status",
|
||||
sa.String(length=20),
|
||||
nullable=False,
|
||||
server_default=sa.text("'new'"),
|
||||
),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.func.now(),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_sales_leads_email", "sales_leads", ["email"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_sales_leads_email", table_name="sales_leads")
|
||||
op.drop_table("sales_leads")
|
||||
@@ -0,0 +1,52 @@
|
||||
"""extend_flow_proposals_l1
|
||||
|
||||
Revision ID: ff6fe5895ea2
|
||||
Revises: a8186f22506d
|
||||
Create Date: 2026-05-28 16:26:06.932886
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ff6fe5895ea2'
|
||||
down_revision: Union[str, None] = 'a8186f22506d'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('flow_proposals', sa.Column('source', sa.String(30), nullable=True))
|
||||
op.add_column('flow_proposals', sa.Column('linked_ticket_id', sa.String(64), nullable=True))
|
||||
op.add_column('flow_proposals', sa.Column('linked_ticket_kind', sa.String(10), nullable=True))
|
||||
op.add_column(
|
||||
'flow_proposals',
|
||||
sa.Column('validated_by_outcome', sa.Boolean(), nullable=False, server_default='false'),
|
||||
)
|
||||
|
||||
# Backfill existing rows then enforce NOT NULL on source
|
||||
op.execute("UPDATE flow_proposals SET source = 'manual_draft' WHERE source IS NULL")
|
||||
op.alter_column('flow_proposals', 'source', nullable=False)
|
||||
|
||||
op.create_check_constraint(
|
||||
'ck_flow_proposals_source',
|
||||
'flow_proposals',
|
||||
"source IN ('ai_realtime_l1', 'kb_accelerator', 'manual_draft', 'ai_promoted')",
|
||||
)
|
||||
op.create_check_constraint(
|
||||
'ck_flow_proposals_linked_ticket_kind',
|
||||
'flow_proposals',
|
||||
"linked_ticket_kind IS NULL OR linked_ticket_kind IN ('psa', 'internal')",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint('ck_flow_proposals_linked_ticket_kind', 'flow_proposals', type_='check')
|
||||
op.drop_constraint('ck_flow_proposals_source', 'flow_proposals', type_='check')
|
||||
op.drop_column('flow_proposals', 'validated_by_outcome')
|
||||
op.drop_column('flow_proposals', 'linked_ticket_kind')
|
||||
op.drop_column('flow_proposals', 'linked_ticket_id')
|
||||
op.drop_column('flow_proposals', 'source')
|
||||
@@ -7,7 +7,13 @@ from sqlalchemy import select
|
||||
import sentry_sdk
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.security import decode_token
|
||||
from jose import JWTError
|
||||
|
||||
from app.core.security import (
|
||||
IdleTokenExpired,
|
||||
decode_refresh_token_strict,
|
||||
decode_token,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.models.plan_limits import PlanLimits
|
||||
from app.core.tenant_context import set_current_account_id, clear_current_account_id
|
||||
@@ -64,15 +70,72 @@ async def get_current_user(
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user_optional(
|
||||
request: Request,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> Optional[User]:
|
||||
"""Best-effort current user for endpoints that work both anonymous and authed.
|
||||
|
||||
Returns None on missing/invalid/expired token instead of raising. Used by
|
||||
surfaces like /config/public that anonymous clients can hit but where an
|
||||
authenticated user gets a tailored response (e.g. INTERNAL_TESTER_EMAILS
|
||||
allowlist override).
|
||||
"""
|
||||
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
|
||||
if not auth_header or not auth_header.lower().startswith("bearer "):
|
||||
return None
|
||||
token = auth_header.split(None, 1)[1].strip()
|
||||
if not token:
|
||||
return None
|
||||
|
||||
payload = decode_token(token)
|
||||
if payload is None or payload.get("type") != "access":
|
||||
return None
|
||||
|
||||
user_id = payload.get("sub")
|
||||
if user_id is None:
|
||||
return None
|
||||
try:
|
||||
user_uuid = UUID(user_id)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_uuid))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_refresh_token_payload(
|
||||
token: Annotated[str, Depends(oauth2_scheme)]
|
||||
) -> dict:
|
||||
"""Extract and validate a refresh token from the Authorization header."""
|
||||
payload = decode_token(token)
|
||||
if payload is None or payload.get("type") != "refresh":
|
||||
"""Extract and validate a refresh token from the Authorization header.
|
||||
|
||||
Returns one of three outcomes via HTTP 401 `detail`:
|
||||
- `session_expired_idle` — JWT signature valid but `exp` past
|
||||
- `invalid_refresh_token` — any other decode failure, or `type != "refresh"`
|
||||
- (200 path) — returns the decoded payload
|
||||
|
||||
The frontend uses these to choose between the "your session ended for
|
||||
security" banner and a plain logout redirect. See
|
||||
docs/plans/2026-05-13-session-expiration-policy.md §4.10.
|
||||
"""
|
||||
try:
|
||||
payload = decode_refresh_token_strict(token)
|
||||
except IdleTokenExpired:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid refresh token",
|
||||
detail="session_expired_idle",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
except JWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid_refresh_token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if payload.get("type") != "refresh":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid_refresh_token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return payload
|
||||
@@ -83,11 +146,12 @@ async def get_current_active_user(
|
||||
current_user: Annotated[User, Depends(get_current_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> User:
|
||||
"""Ensure user is active (not disabled). Auto-downgrades expired trials.
|
||||
Enforces must_change_password — blocks all routes except allowlist.
|
||||
"""Ensure user is active (not disabled). Enforces must_change_password —
|
||||
blocks all routes except allowlist.
|
||||
|
||||
Uses get_admin_db: runs before require_tenant_context sets the ContextVar,
|
||||
so tenant-scoped tables (subscriptions) would return 0 rows via app role.
|
||||
Trial expiry enforcement now happens via require_active_subscription in
|
||||
individual routers, NOT here. This dep no longer mutates Subscription
|
||||
state.
|
||||
"""
|
||||
if not current_user.is_active:
|
||||
raise HTTPException(
|
||||
@@ -106,26 +170,6 @@ async def get_current_active_user(
|
||||
# Set Sentry user context for error attribution
|
||||
sentry_sdk.set_user({"id": str(current_user.id), "email": current_user.email})
|
||||
|
||||
# Lightweight trial expiry check
|
||||
if current_user.account_id:
|
||||
from app.models.subscription import Subscription
|
||||
from datetime import datetime, timezone
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == current_user.account_id)
|
||||
)
|
||||
subscription = result.scalar_one_or_none()
|
||||
if (
|
||||
subscription
|
||||
and subscription.status == "trialing"
|
||||
and subscription.current_period_end
|
||||
and subscription.current_period_end < datetime.now(timezone.utc)
|
||||
):
|
||||
subscription.plan = "free"
|
||||
subscription.status = "active"
|
||||
subscription.current_period_end = None
|
||||
subscription.current_period_start = None
|
||||
await db.commit()
|
||||
|
||||
return current_user
|
||||
|
||||
|
||||
@@ -155,6 +199,53 @@ async def require_engineer_or_admin(
|
||||
)
|
||||
|
||||
|
||||
async def require_l1(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
) -> User:
|
||||
"""L1 tech exact-match (with super_admin bypass for support)."""
|
||||
if current_user.is_super_admin:
|
||||
return current_user
|
||||
if current_user.account_role != "l1_tech":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="L1 tech role required",
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
async def require_l1_or_coverage(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
) -> User:
|
||||
"""L1 endpoints: l1_tech, owners, super_admin, or engineers with can_cover_l1=True."""
|
||||
if current_user.is_super_admin:
|
||||
return current_user
|
||||
role = current_user.account_role
|
||||
if role == "l1_tech":
|
||||
return current_user
|
||||
if role == "owner":
|
||||
return current_user
|
||||
if role == "engineer" and current_user.can_cover_l1:
|
||||
return current_user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="L1 access requires l1_tech role or engineer coverage flag",
|
||||
)
|
||||
|
||||
|
||||
async def require_l1_or_above(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
) -> User:
|
||||
"""Any tier from l1_tech upward (l1_tech, engineer, owner, super_admin)."""
|
||||
if current_user.is_super_admin:
|
||||
return current_user
|
||||
if current_user.account_role in ("l1_tech", "engineer", "owner"):
|
||||
return current_user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="L1 or above required",
|
||||
)
|
||||
|
||||
|
||||
async def require_team_admin(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
) -> User:
|
||||
@@ -185,6 +276,20 @@ async def require_account_owner(
|
||||
)
|
||||
|
||||
|
||||
async def require_account_owner_or_admin(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||
) -> User:
|
||||
"""Require account owner or account-admin (blocks engineers); super_admin bypass."""
|
||||
if current_user.is_super_admin:
|
||||
return current_user
|
||||
if current_user.account_role in ("owner", "admin"):
|
||||
return current_user
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Account owner or admin access required",
|
||||
)
|
||||
|
||||
|
||||
def get_service_account_id(request: Request) -> Optional[UUID]:
|
||||
"""Return the cached ResolutionFlow service account UUID from app.state.
|
||||
|
||||
@@ -241,3 +346,117 @@ async def require_admin_db(
|
||||
the user object is needed in the handler.
|
||||
"""
|
||||
return db
|
||||
|
||||
|
||||
_SUBSCRIPTION_GUARD_ALLOWLIST = {
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/auth/logout",
|
||||
"/api/v1/auth/password/change",
|
||||
"/api/v1/auth/email/send-verification",
|
||||
"/api/v1/auth/email/verify",
|
||||
"/api/v1/billing/state",
|
||||
"/api/v1/billing/checkout-session",
|
||||
"/api/v1/billing/portal-session",
|
||||
"/api/v1/users/me",
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
"/api/v1/users/me/onboarding-dismiss-rest",
|
||||
}
|
||||
|
||||
|
||||
async def require_active_subscription(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
):
|
||||
"""Returns the Subscription row when the account has access; raises 402
|
||||
when locked. Mounted on routers requiring Pro entitlement.
|
||||
|
||||
'Locked' = (trialing AND current_period_end < now()) OR
|
||||
(canceled OR incomplete OR no subscription).
|
||||
Active states: active, complimentary, trialing-with-time-remaining, past_due.
|
||||
"""
|
||||
if request.url.path in _SUBSCRIPTION_GUARD_ALLOWLIST:
|
||||
return None
|
||||
|
||||
from app.models.subscription import Subscription
|
||||
from datetime import datetime, timezone
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == current_user.account_id)
|
||||
)
|
||||
sub = result.scalar_one_or_none()
|
||||
|
||||
if sub is None:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={"error": "no_subscription", "upgrade_url": "/account/billing/select-plan"},
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
is_live = (
|
||||
sub.status in ("active", "complimentary", "past_due")
|
||||
or (
|
||||
sub.status == "trialing"
|
||||
and sub.current_period_end is not None
|
||||
and sub.current_period_end > now
|
||||
)
|
||||
)
|
||||
if not is_live:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": "subscription_inactive",
|
||||
"status": sub.status,
|
||||
"plan": sub.plan,
|
||||
"current_period_end": sub.current_period_end.isoformat() if sub.current_period_end else None,
|
||||
"upgrade_url": "/account/billing/select-plan",
|
||||
},
|
||||
)
|
||||
|
||||
return sub
|
||||
|
||||
|
||||
_EMAIL_VERIFICATION_ALLOWLIST = {
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/auth/logout",
|
||||
"/api/v1/auth/email/send-verification",
|
||||
"/api/v1/auth/email/verify",
|
||||
"/api/v1/auth/password/change",
|
||||
"/api/v1/users/me",
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
"/api/v1/users/me/onboarding-dismiss-rest",
|
||||
"/api/v1/billing/state",
|
||||
"/api/v1/billing/checkout-session",
|
||||
"/api/v1/billing/portal-session",
|
||||
}
|
||||
|
||||
VERIFICATION_GRACE_DAYS = 7
|
||||
|
||||
|
||||
async def require_verified_email_after_grace(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Enforces 'this user has verified email OR is still in 7-day grace.'
|
||||
OAuth signups bypass cleanly because /auth/{google,microsoft}/callback
|
||||
sets users.email_verified_at = now() (provider-attested)."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
if request.url.path in _EMAIL_VERIFICATION_ALLOWLIST:
|
||||
return
|
||||
|
||||
if current_user.email_verified_at is not None:
|
||||
return
|
||||
|
||||
grace_ends = current_user.created_at + timedelta(days=VERIFICATION_GRACE_DAYS)
|
||||
if datetime.now(timezone.utc) < grace_ends:
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "email_not_verified",
|
||||
"grace_ended_at": grace_ends.isoformat(),
|
||||
"resend_url": "/api/v1/auth/email/send-verification",
|
||||
},
|
||||
)
|
||||
|
||||
54
backend/app/api/endpoints/account_invite_lookup.py
Normal file
54
backend/app/api/endpoints/account_invite_lookup.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Public endpoint for resolving an account invite code into display info.
|
||||
|
||||
Mounted as a public route (no tenant context, no auth) — used by the
|
||||
/accept-invite page on the frontend so an invitee can see what account they
|
||||
are about to join before they sign up. Uses the BYPASSRLS admin session
|
||||
factory because account_invites is account-scoped under Phase 4 RLS but the
|
||||
caller has no tenant identity yet.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.schemas.oauth import InviteLookupResponse
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["account-invite-lookup"])
|
||||
|
||||
|
||||
@router.get("/invites/{code}/lookup", response_model=InviteLookupResponse)
|
||||
async def lookup_invite(
|
||||
code: str,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> InviteLookupResponse:
|
||||
"""Return minimal display data for a valid (unused, unexpired, not revoked)
|
||||
invite. Returns 404 with `invite_invalid_or_expired_or_revoked` for any
|
||||
invalid state — the AcceptInvitePage shows a single "ask the inviter to
|
||||
resend" message regardless of which condition failed (anti-enumeration)."""
|
||||
result = await db.execute(
|
||||
select(AccountInvite)
|
||||
.where(AccountInvite.code == code)
|
||||
.options(
|
||||
joinedload(AccountInvite.account),
|
||||
joinedload(AccountInvite.invited_by),
|
||||
)
|
||||
)
|
||||
invite = result.scalar_one_or_none()
|
||||
|
||||
if invite is None or not invite.is_valid:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": "invite_invalid_or_expired_or_revoked"},
|
||||
)
|
||||
|
||||
return InviteLookupResponse(
|
||||
account_name=invite.account.name,
|
||||
inviter_name=invite.invited_by.name,
|
||||
invited_email=invite.email,
|
||||
role=invite.role,
|
||||
)
|
||||
214
backend/app/api/endpoints/account_security.py
Normal file
214
backend/app/api/endpoints/account_security.py
Normal file
@@ -0,0 +1,214 @@
|
||||
"""Account session-policy endpoints — owner-only.
|
||||
|
||||
GET /accounts/me/security — read the policy + system bounds.
|
||||
PATCH /accounts/me/security — set or clear the per-account override.
|
||||
|
||||
POST /accounts/me/security/revoke-sessions lands in the next commit.
|
||||
|
||||
See docs/plans/2026-05-13-session-expiration-policy.md §4.7 / §4.11.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select, update as sa_update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import require_account_owner
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.audit import log_audit
|
||||
from app.core.config import settings
|
||||
from app.core.security import resolve_session_policy
|
||||
from app.models.account import Account
|
||||
from app.models.refresh_token import RefreshToken
|
||||
from app.models.user import User
|
||||
from app.schemas.account_security import (
|
||||
ActiveUser,
|
||||
RevokeSessionsRequest,
|
||||
RevokeSessionsResponse,
|
||||
SessionPolicyResponse,
|
||||
SessionPolicyUpdateRequest,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/accounts/me/security", tags=["account-security"])
|
||||
|
||||
|
||||
def _policy_response(
|
||||
account: Account, active_users: list[ActiveUser]
|
||||
) -> SessionPolicyResponse:
|
||||
eff_idle, eff_abs = resolve_session_policy(account)
|
||||
return SessionPolicyResponse(
|
||||
idle_minutes=account.session_idle_minutes,
|
||||
absolute_minutes=account.session_absolute_minutes,
|
||||
effective_idle_minutes=eff_idle,
|
||||
effective_absolute_minutes=eff_abs,
|
||||
idle_minutes_min=settings.SESSION_IDLE_MINUTES_MIN,
|
||||
idle_minutes_max=settings.SESSION_IDLE_MINUTES_MAX,
|
||||
absolute_minutes_min=settings.SESSION_ABSOLUTE_MINUTES_MIN,
|
||||
absolute_minutes_max=settings.SESSION_ABSOLUTE_MINUTES_MAX,
|
||||
active_users=active_users,
|
||||
)
|
||||
|
||||
|
||||
async def _load_account(db: AsyncSession, account_id) -> Account:
|
||||
return (
|
||||
await db.execute(select(Account).where(Account.id == account_id))
|
||||
).scalar_one()
|
||||
|
||||
|
||||
async def _load_active_users(db: AsyncSession, account_id) -> list[ActiveUser]:
|
||||
"""Return distinct users in this account who currently hold an
|
||||
un-revoked refresh token. See plan §4.7."""
|
||||
from app.models.refresh_token import RefreshToken
|
||||
|
||||
stmt = (
|
||||
select(User.id, User.name, User.email, User.last_login)
|
||||
.join(RefreshToken, RefreshToken.user_id == User.id)
|
||||
.where(User.account_id == account_id, RefreshToken.revoked_at.is_(None))
|
||||
.distinct()
|
||||
.order_by(User.last_login.desc().nulls_last())
|
||||
)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
return [
|
||||
ActiveUser(user_id=row.id, name=row.name, email=row.email, last_login_at=row.last_login)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("", response_model=SessionPolicyResponse)
|
||||
async def get_session_policy(
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
):
|
||||
account = await _load_account(db, current_user.account_id)
|
||||
active_users = await _load_active_users(db, current_user.account_id)
|
||||
return _policy_response(account, active_users)
|
||||
|
||||
|
||||
@router.patch("", response_model=SessionPolicyResponse)
|
||||
async def update_session_policy(
|
||||
body: SessionPolicyUpdateRequest,
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
):
|
||||
account = await _load_account(db, current_user.account_id)
|
||||
|
||||
# Snapshot effective values BEFORE change, for audit.
|
||||
old_idle = account.session_idle_minutes
|
||||
old_abs = account.session_absolute_minutes
|
||||
effective_old_idle, effective_old_abs = resolve_session_policy(account)
|
||||
|
||||
new_idle = body.idle_minutes
|
||||
new_abs = body.absolute_minutes
|
||||
|
||||
# Per-field bound checks. NULL clears the override and is always valid.
|
||||
if new_idle is not None and not (
|
||||
settings.SESSION_IDLE_MINUTES_MIN <= new_idle <= settings.SESSION_IDLE_MINUTES_MAX
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
f"idle_minutes must be between {settings.SESSION_IDLE_MINUTES_MIN} "
|
||||
f"and {settings.SESSION_IDLE_MINUTES_MAX}"
|
||||
),
|
||||
)
|
||||
if new_abs is not None and not (
|
||||
settings.SESSION_ABSOLUTE_MINUTES_MIN <= new_abs <= settings.SESSION_ABSOLUTE_MINUTES_MAX
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
f"absolute_minutes must be between {settings.SESSION_ABSOLUTE_MINUTES_MIN} "
|
||||
f"and {settings.SESSION_ABSOLUTE_MINUTES_MAX}"
|
||||
),
|
||||
)
|
||||
|
||||
# Effective-value invariant: idle must not exceed absolute after defaults.
|
||||
# The DB CHECK only catches the both-set case; this catches the partial-
|
||||
# override case where (e.g.) idle=43200 with absolute=NULL would yield an
|
||||
# effective idle larger than the system default absolute.
|
||||
effective_new_idle = new_idle if new_idle is not None else settings.SESSION_IDLE_MINUTES_DEFAULT
|
||||
effective_new_abs = new_abs if new_abs is not None else settings.SESSION_ABSOLUTE_MINUTES_DEFAULT
|
||||
if effective_new_idle > effective_new_abs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
f"Effective idle ({effective_new_idle}min) cannot exceed effective "
|
||||
f"absolute ({effective_new_abs}min)"
|
||||
),
|
||||
)
|
||||
|
||||
account.session_idle_minutes = new_idle
|
||||
account.session_absolute_minutes = new_abs
|
||||
|
||||
await log_audit(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
account_id=account.id,
|
||||
action="account.session_policy_update",
|
||||
resource_type="account",
|
||||
resource_id=account.id,
|
||||
details={
|
||||
"old": {"idle_minutes": old_idle, "absolute_minutes": old_abs},
|
||||
"new": {"idle_minutes": new_idle, "absolute_minutes": new_abs},
|
||||
"effective_old": {
|
||||
"idle_minutes": effective_old_idle,
|
||||
"absolute_minutes": effective_old_abs,
|
||||
},
|
||||
"effective_new": {
|
||||
"idle_minutes": effective_new_idle,
|
||||
"absolute_minutes": effective_new_abs,
|
||||
},
|
||||
},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(account)
|
||||
active_users = await _load_active_users(db, account.id)
|
||||
return _policy_response(account, active_users)
|
||||
|
||||
|
||||
@router.post("/revoke-sessions", response_model=RevokeSessionsResponse)
|
||||
async def revoke_sessions(
|
||||
body: RevokeSessionsRequest,
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
):
|
||||
"""Bulk-revoke refresh tokens for users in the caller's account.
|
||||
|
||||
`scope="all"` revokes every active session in the account, including
|
||||
the caller's own. `scope="others"` preserves the caller's sessions.
|
||||
The caller's access token is NOT revoked (we don't track access JTIs);
|
||||
it dies on its 5-minute timer. For `scope="all"`, the frontend is
|
||||
expected to log the caller out locally after the response.
|
||||
|
||||
See docs/plans/2026-05-13-session-expiration-policy.md §4.11.
|
||||
"""
|
||||
# Subquery: refresh-token rows belonging to users in this account.
|
||||
user_ids_subq = select(User.id).where(User.account_id == current_user.account_id)
|
||||
|
||||
stmt = (
|
||||
sa_update(RefreshToken)
|
||||
.where(
|
||||
RefreshToken.user_id.in_(user_ids_subq),
|
||||
RefreshToken.revoked_at.is_(None),
|
||||
)
|
||||
.values(revoked_at=datetime.now(timezone.utc))
|
||||
.returning(RefreshToken.id)
|
||||
)
|
||||
if body.scope == "others":
|
||||
stmt = stmt.where(RefreshToken.user_id != current_user.id)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
revoked_count = len(result.all())
|
||||
|
||||
await log_audit(
|
||||
db,
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
action="account.sessions_revoked_bulk",
|
||||
resource_type="account",
|
||||
resource_id=current_user.account_id,
|
||||
details={"scope": body.scope, "revoked_count": revoked_count},
|
||||
)
|
||||
await db.commit()
|
||||
return RevokeSessionsResponse(revoked_count=revoked_count)
|
||||
@@ -19,15 +19,64 @@ from app.models.account_invite import AccountInvite
|
||||
from app.models.account_settings import AccountSettings
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.user import User
|
||||
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, TransferOwnershipRequest
|
||||
from app.schemas.account import AccountResponse, AccountUpdate, AccountInviteCreate, AccountInviteResponse, AccountInviteBulkCreate, AccountInviteBulkResponse, TransferOwnershipRequest
|
||||
from app.schemas.subscription import SubscriptionResponse, PlanLimitsResponse, UsageResponse, SubscriptionDetails
|
||||
from app.schemas.user import UserResponse, AccountRoleUpdate
|
||||
from app.schemas.user import UserResponse, AccountRoleUpdate, CoverageUpdate
|
||||
from app.core.security import verify_password
|
||||
from app.api.deps import get_current_active_user, require_account_owner
|
||||
from app.api.deps import (
|
||||
get_current_active_user,
|
||||
require_account_owner,
|
||||
require_account_owner_or_admin,
|
||||
require_engineer_or_admin,
|
||||
require_l1_or_above,
|
||||
)
|
||||
from app.services import l1_category_service
|
||||
from app.services.seat_enforcement import check_seat_available, get_seat_usage
|
||||
from app.schemas.seat_enforcement import SeatUsage
|
||||
from app.schemas.l1_categories import L1CategoriesResponse, L1CategoriesUpdate
|
||||
|
||||
_SEAT_CHECKED_ROLES = frozenset({"engineer", "l1_tech"})
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||
|
||||
|
||||
async def _load_account(db: AsyncSession, account_id: UUID) -> Account:
|
||||
"""Load an Account by id; raises 404 if missing."""
|
||||
result = await db.execute(select(Account).where(Account.id == account_id))
|
||||
account = result.scalar_one_or_none()
|
||||
if account is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Account not found")
|
||||
return account
|
||||
|
||||
|
||||
async def _enforce_seat_limit(db: AsyncSession, account_id: UUID, role: str) -> None:
|
||||
"""Raise HTTP 402 if the account has no capacity for the given role.
|
||||
|
||||
Only fires for seat-counted roles (engineer, l1_tech).
|
||||
Accounts without a subscription (free / pre-billing) are not blocked.
|
||||
Grandfathering: if current > limit, existing users keep access; this
|
||||
helper only blocks new additions.
|
||||
"""
|
||||
if role not in _SEAT_CHECKED_ROLES:
|
||||
return
|
||||
sub = await get_account_subscription(account_id, db)
|
||||
if sub is None:
|
||||
return # no subscription → no enforcement
|
||||
account = await _load_account(db, account_id)
|
||||
seat_result = await check_seat_available(account, sub, role, db)
|
||||
if not seat_result.available:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
"code": "seat_limit_exceeded",
|
||||
"role": seat_result.role,
|
||||
"current": seat_result.current,
|
||||
"limit": seat_result.limit,
|
||||
"upgrade_url": "/account/billing",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AccountResponse)
|
||||
async def get_my_account(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
@@ -88,6 +137,80 @@ async def get_my_members(
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.get("/me/seats", response_model=SeatUsage)
|
||||
async def get_my_account_seat_usage(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
):
|
||||
"""Returns engineer + l1_tech seat-usage counts. Accessible to engineer+.
|
||||
|
||||
Powers the SeatCounterWidget on admin/users and account/users surfaces.
|
||||
"""
|
||||
account = await _load_account(db, current_user.account_id)
|
||||
sub = await get_account_subscription(current_user.account_id, db)
|
||||
if sub is None:
|
||||
# No subscription → treat as unlimited; return live counts with no limit
|
||||
from sqlalchemy import func
|
||||
engineer_count = (await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(User.account_id == account.id)
|
||||
.where(User.account_role == "engineer")
|
||||
.where(User.is_active.is_(True))
|
||||
)).scalar_one()
|
||||
l1_count = (await db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(User.account_id == account.id)
|
||||
.where(User.account_role == "l1_tech")
|
||||
.where(User.is_active.is_(True))
|
||||
)).scalar_one()
|
||||
from app.schemas.seat_enforcement import SeatCheckResult
|
||||
return SeatUsage(
|
||||
engineer=SeatCheckResult(available=True, current=engineer_count, limit=None, role="engineer"),
|
||||
l1_tech=SeatCheckResult(available=True, current=l1_count, limit=None, role="l1_tech"),
|
||||
)
|
||||
engineer, l1_tech = await get_seat_usage(account, sub, db)
|
||||
return SeatUsage(engineer=engineer, l1_tech=l1_tech)
|
||||
|
||||
|
||||
@router.get("/me/l1-categories", response_model=L1CategoriesResponse)
|
||||
async def get_l1_categories(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_l1_or_above)],
|
||||
):
|
||||
"""The account's enabled L1 AI-build categories + the available + hard-floor lists.
|
||||
|
||||
Readable by any L1-or-above user (the walker needs to know what's buildable);
|
||||
only owners/admins may change it (PATCH below).
|
||||
"""
|
||||
enabled = await l1_category_service.get_enabled_categories(current_user.account_id, db)
|
||||
return L1CategoriesResponse(
|
||||
enabled=enabled,
|
||||
available=l1_category_service.DEFAULT_L1_CATEGORIES,
|
||||
hard_floor=l1_category_service.HARD_FLOOR_FORBIDDEN,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/me/l1-categories", response_model=L1CategoriesResponse)
|
||||
async def set_l1_categories(
|
||||
payload: L1CategoriesUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_account_owner_or_admin)],
|
||||
):
|
||||
"""Set the account's enabled L1 categories (owner/admin only).
|
||||
|
||||
Unknown and hard-floored keys are dropped by the service before persisting.
|
||||
"""
|
||||
enabled = await l1_category_service.set_enabled_categories(
|
||||
current_user.account_id, payload.enabled, db
|
||||
)
|
||||
await db.commit()
|
||||
return L1CategoriesResponse(
|
||||
enabled=enabled,
|
||||
available=l1_category_service.DEFAULT_L1_CATEGORIES,
|
||||
hard_floor=l1_category_service.HARD_FLOOR_FORBIDDEN,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=AccountResponse)
|
||||
async def update_my_account(
|
||||
data: AccountUpdate,
|
||||
@@ -141,12 +264,54 @@ async def update_member_role(
|
||||
detail="Cannot change your own role"
|
||||
)
|
||||
|
||||
# Seat enforcement: check capacity before promoting to a seat-counted role.
|
||||
# Demotions (engineer/l1_tech → viewer) and lateral moves skip the check.
|
||||
if data.account_role != user.account_role:
|
||||
await _enforce_seat_limit(db, current_user.account_id, data.account_role)
|
||||
|
||||
user.account_role = data.account_role
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/me/members/{user_id}/coverage", response_model=UserResponse)
|
||||
async def update_member_coverage(
|
||||
user_id: UUID,
|
||||
data: CoverageUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
):
|
||||
"""Toggle the `can_cover_l1` flag on an engineer in your account.
|
||||
|
||||
Owner-only. Returns 404 if target user not in your account. Returns 422
|
||||
if target user's role is not 'engineer' (coverage flag only applies to
|
||||
engineers — owners/super_admins already see L1 surface; viewers/l1_techs
|
||||
don't need this flag).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(User).where(
|
||||
User.id == user_id,
|
||||
User.account_id == current_user.account_id,
|
||||
)
|
||||
)
|
||||
target = result.scalar_one_or_none()
|
||||
if target is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="User not found in your account",
|
||||
)
|
||||
if target.account_role != "engineer":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="can_cover_l1 only applies to engineers",
|
||||
)
|
||||
target.can_cover_l1 = data.can_cover_l1
|
||||
await db.commit()
|
||||
await db.refresh(target)
|
||||
return target
|
||||
|
||||
|
||||
@router.post("/me/transfer-ownership", response_model=AccountResponse)
|
||||
async def transfer_ownership(
|
||||
data: TransferOwnershipRequest,
|
||||
@@ -260,7 +425,10 @@ async def create_invite(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_account_owner)]
|
||||
):
|
||||
"""Create an invite to join this account (owner only)."""
|
||||
"""Create an invite to join this account (owner only). Sends invite email."""
|
||||
# Seat enforcement: block invite if the target role is at capacity.
|
||||
await _enforce_seat_limit(db, current_user.account_id, data.role)
|
||||
|
||||
code = secrets.token_urlsafe(16)
|
||||
|
||||
expires_at = None
|
||||
@@ -276,11 +444,115 @@ async def create_invite(
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(invite)
|
||||
await db.flush()
|
||||
|
||||
# Lookup account name for email
|
||||
account_result = await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)
|
||||
account = account_result.scalar_one()
|
||||
|
||||
# Send invite email — non-blocking on failure (function returns False on error)
|
||||
email_sent = await EmailService.send_account_invite_email(
|
||||
to_email=invite.email,
|
||||
code=code,
|
||||
account_name=account.name,
|
||||
role=invite.role,
|
||||
)
|
||||
if email_sent:
|
||||
invite.email_sent_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(invite)
|
||||
return invite
|
||||
|
||||
|
||||
@router.post("/me/invites/bulk", response_model=AccountInviteBulkResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_invites_bulk(
|
||||
payload: AccountInviteBulkCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_account_owner)]
|
||||
):
|
||||
"""Create multiple invites in one call (wizard step 3 supports up to N).
|
||||
Per-row failures are returned in `failed`; successes in `created`."""
|
||||
# Lookup account once for email rendering
|
||||
account_result = await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)
|
||||
account = account_result.scalar_one()
|
||||
|
||||
created: list[AccountInvite] = []
|
||||
failed: list[dict] = []
|
||||
for invite_data in payload.invites:
|
||||
try:
|
||||
# Seat enforcement per invite row — 402 bubbles as an HTTPException
|
||||
# which is caught below and recorded in `failed`.
|
||||
await _enforce_seat_limit(db, current_user.account_id, invite_data.role)
|
||||
|
||||
code = secrets.token_urlsafe(16)
|
||||
expires_at = None
|
||||
if invite_data.expires_in_days:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=invite_data.expires_in_days)
|
||||
|
||||
invite = AccountInvite(
|
||||
account_id=current_user.account_id,
|
||||
invited_by_id=current_user.id,
|
||||
email=invite_data.email,
|
||||
code=code,
|
||||
role=invite_data.role,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
db.add(invite)
|
||||
await db.flush()
|
||||
|
||||
email_sent = await EmailService.send_account_invite_email(
|
||||
to_email=invite.email,
|
||||
code=code,
|
||||
account_name=account.name,
|
||||
role=invite.role,
|
||||
)
|
||||
if email_sent:
|
||||
invite.email_sent_at = datetime.now(timezone.utc)
|
||||
|
||||
created.append(invite)
|
||||
except HTTPException as exc:
|
||||
failed.append({"email": invite_data.email, "error": exc.detail})
|
||||
except Exception as e:
|
||||
failed.append({"email": invite_data.email, "error": str(e)})
|
||||
|
||||
await db.commit()
|
||||
for inv in created:
|
||||
await db.refresh(inv)
|
||||
|
||||
return AccountInviteBulkResponse(created=created, failed=failed)
|
||||
|
||||
|
||||
@router.delete("/me/invites/{invite_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def revoke_invite(
|
||||
invite_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_account_owner)]
|
||||
):
|
||||
"""Soft-revoke an invitation by setting revoked_at. Idempotent on already-
|
||||
revoked invites; rejects already-accepted invites."""
|
||||
result = await db.execute(
|
||||
select(AccountInvite).where(
|
||||
AccountInvite.id == invite_id,
|
||||
AccountInvite.account_id == current_user.account_id,
|
||||
)
|
||||
)
|
||||
invite = result.scalar_one_or_none()
|
||||
if not invite:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
|
||||
if invite.is_revoked:
|
||||
return None # idempotent
|
||||
if invite.is_used:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot revoke an accepted invite")
|
||||
invite.revoked_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/me/invites/{invite_id}/resend", response_model=AccountInviteResponse)
|
||||
async def resend_invite(
|
||||
invite_id: UUID,
|
||||
|
||||
@@ -972,7 +972,7 @@ async def update_user_plan(
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Change a user's subscription plan (super admin only)."""
|
||||
if data.plan not in ("free", "pro", "team"):
|
||||
if data.plan not in ("free", "pro", "starter", "enterprise"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
|
||||
user, subscription = await _get_user_subscription(user_id, db)
|
||||
old_plan = subscription.plan
|
||||
@@ -991,7 +991,7 @@ async def update_account_plan(
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Change an account subscription plan (super admin only)."""
|
||||
if data.plan not in ("free", "pro", "team"):
|
||||
if data.plan not in ("free", "pro", "starter", "enterprise"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid plan")
|
||||
account, subscription = await _get_account_subscription(account_id, db)
|
||||
old_plan = subscription.plan
|
||||
|
||||
@@ -28,7 +28,7 @@ async def get_dashboard_metrics(
|
||||
) or 0
|
||||
paid_accounts = await db.scalar(
|
||||
select(func.count()).select_from(Subscription).where(
|
||||
Subscription.plan.in_(["pro", "team"])
|
||||
Subscription.plan.in_(["pro", "starter", "enterprise"])
|
||||
)
|
||||
) or 0
|
||||
total_trees = await db.scalar(
|
||||
|
||||
@@ -8,34 +8,101 @@ from app.core.database import get_db
|
||||
from app.core.audit import log_audit
|
||||
from app.models.user import User
|
||||
from app.models.plan_limits import PlanLimits
|
||||
from app.models.plan_billing import PlanBilling
|
||||
from app.models.account import Account
|
||||
from app.models.account_limit_override import AccountLimitOverride
|
||||
from app.models.subscription import Subscription
|
||||
from app.schemas.admin import (
|
||||
PlanLimitResponse, PlanLimitUpdate,
|
||||
PlanLimitResponse, PlanLimitUpdate, PlanLimitWithBillingResponse,
|
||||
AccountOverrideCreate, AccountOverrideUpdate, AccountOverrideResponse,
|
||||
)
|
||||
from app.api.deps import require_admin
|
||||
from app.services.billing import BillingService
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin-plan-limits"])
|
||||
|
||||
|
||||
@router.get("/plan-limits", response_model=list[PlanLimitResponse])
|
||||
# Fields on PlanLimitUpdate that map to plan_billing (not plan_limits).
|
||||
_PLAN_BILLING_FIELDS = (
|
||||
"display_name",
|
||||
"description",
|
||||
"monthly_price_cents",
|
||||
"annual_price_cents",
|
||||
"stripe_product_id",
|
||||
"stripe_monthly_price_id",
|
||||
"stripe_annual_price_id",
|
||||
"is_public",
|
||||
"is_archived",
|
||||
"sort_order",
|
||||
)
|
||||
|
||||
# Subset of _PLAN_BILLING_FIELDS that are NOT NULL on the PlanBilling model.
|
||||
# These are Optional[...] on PlanLimitUpdate, so a caller sending an explicit
|
||||
# null for any of them would otherwise trigger a NOT NULL violation at commit.
|
||||
_PLAN_BILLING_NOT_NULL_FIELDS = frozenset({
|
||||
"display_name",
|
||||
"is_public",
|
||||
"is_archived",
|
||||
"sort_order",
|
||||
})
|
||||
|
||||
|
||||
def _merge_plan_with_billing(
|
||||
plan: PlanLimits, billing: PlanBilling | None
|
||||
) -> PlanLimitWithBillingResponse:
|
||||
"""Build a merged response. Billing fields are None when no plan_billing row
|
||||
exists for the plan."""
|
||||
payload = {
|
||||
"plan": plan.plan,
|
||||
"max_trees": plan.max_trees,
|
||||
"max_sessions_per_month": plan.max_sessions_per_month,
|
||||
"max_users": plan.max_users,
|
||||
"custom_branding": plan.custom_branding,
|
||||
"priority_support": plan.priority_support,
|
||||
"export_formats": plan.export_formats or [],
|
||||
}
|
||||
if billing is not None:
|
||||
payload.update({
|
||||
"display_name": billing.display_name,
|
||||
"description": billing.description,
|
||||
"monthly_price_cents": billing.monthly_price_cents,
|
||||
"annual_price_cents": billing.annual_price_cents,
|
||||
"stripe_product_id": billing.stripe_product_id,
|
||||
"stripe_monthly_price_id": billing.stripe_monthly_price_id,
|
||||
"stripe_annual_price_id": billing.stripe_annual_price_id,
|
||||
"is_public": billing.is_public,
|
||||
"is_archived": billing.is_archived,
|
||||
"sort_order": billing.sort_order,
|
||||
})
|
||||
return PlanLimitWithBillingResponse(**payload)
|
||||
|
||||
|
||||
@router.get("/plan-limits", response_model=list[PlanLimitWithBillingResponse])
|
||||
async def list_plan_limits(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""List all plan limit configurations."""
|
||||
result = await db.execute(select(PlanLimits))
|
||||
return result.scalars().all()
|
||||
"""List all plan limit configurations, merged with plan_billing fields
|
||||
where present. Plans without a plan_billing row return None for the
|
||||
billing fields."""
|
||||
rows = (await db.execute(
|
||||
select(PlanLimits, PlanBilling)
|
||||
.outerjoin(PlanBilling, PlanLimits.plan == PlanBilling.plan)
|
||||
)).all()
|
||||
return [_merge_plan_with_billing(pl, pb) for pl, pb in rows]
|
||||
|
||||
|
||||
@router.put("/plan-limits", response_model=PlanLimitResponse)
|
||||
@router.put("/plan-limits", response_model=PlanLimitWithBillingResponse)
|
||||
async def update_plan_limits(
|
||||
data: PlanLimitUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Update a plan's limits."""
|
||||
"""Update a plan's limits and (if any plan_billing field is included)
|
||||
upsert the matching plan_billing row in the same transaction. After
|
||||
commit, invalidates the in-process billing cache for accounts on this
|
||||
plan (currently a no-op — see BillingService.invalidate_billing_cache).
|
||||
"""
|
||||
result = await db.execute(select(PlanLimits).where(PlanLimits.plan == data.plan))
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
@@ -48,10 +115,50 @@ async def update_plan_limits(
|
||||
plan.priority_support = data.priority_support
|
||||
plan.export_formats = data.export_formats
|
||||
|
||||
await log_audit(db, current_user.id, "plan_limits.update", "plan_limits", details={"plan": data.plan})
|
||||
# Did the request include any plan_billing field? (Pydantic gives us
|
||||
# `model_fields_set` to distinguish "user passed null" from "field omitted".)
|
||||
billing_fields_set = data.model_fields_set & set(_PLAN_BILLING_FIELDS)
|
||||
billing: PlanBilling | None = None
|
||||
if billing_fields_set:
|
||||
billing = (await db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == data.plan)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if billing is None:
|
||||
# Create. display_name is required on the model — derive from the
|
||||
# plan name when the caller didn't supply one (e.g. "pro" → "Pro").
|
||||
display_name = data.display_name or data.plan.capitalize()
|
||||
billing = PlanBilling(plan=data.plan, display_name=display_name)
|
||||
db.add(billing)
|
||||
|
||||
# Apply only the fields the caller actually included. Allows partial
|
||||
# updates without clobbering existing values.
|
||||
for field in billing_fields_set:
|
||||
value = getattr(data, field)
|
||||
if value is None and field in _PLAN_BILLING_NOT_NULL_FIELDS:
|
||||
# Don't NULL out a NOT NULL column on update.
|
||||
continue
|
||||
setattr(billing, field, value)
|
||||
|
||||
await log_audit(
|
||||
db, current_user.id, "plan_limits.update", "plan_limits",
|
||||
details={"plan": data.plan, "updated_billing": bool(billing_fields_set)},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(plan)
|
||||
return plan
|
||||
if billing is not None:
|
||||
await db.refresh(billing)
|
||||
|
||||
# Invalidate any in-process billing cache for accounts on this plan.
|
||||
# TODO: invalidate app.state.billing_cache when added.
|
||||
account_ids = [
|
||||
row[0] for row in (await db.execute(
|
||||
select(Subscription.account_id).where(Subscription.plan == data.plan)
|
||||
)).all()
|
||||
]
|
||||
await BillingService.invalidate_billing_cache(account_ids)
|
||||
|
||||
return _merge_plan_with_billing(plan, billing)
|
||||
|
||||
|
||||
@router.get("/account-overrides", response_model=list[AccountOverrideResponse])
|
||||
|
||||
@@ -15,7 +15,7 @@ from datetime import datetime
|
||||
from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, status
|
||||
from sqlalchemy import or_, select, func, text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
@@ -452,6 +452,13 @@ async def resolve_session(
|
||||
|
||||
|
||||
# ── Escalate ──
|
||||
#
|
||||
# Thin shim over HandoffManager. The legacy `flowpilot_engine.escalate_session`
|
||||
# is no longer the source of truth — every escalation now creates a
|
||||
# SessionHandoff row, fans out via the SSE bus, dispatches AppNotification +
|
||||
# external channels via notify(), and emails per-user. EscalateModal and the
|
||||
# /handoff endpoint both funnel through here / through HandoffManager so the
|
||||
# senior-pickup magic-moment screen works regardless of entry point.
|
||||
|
||||
@router.post("/{session_id}/escalate", response_model=SessionCloseResponse)
|
||||
@limiter.limit("15/minute")
|
||||
@@ -459,25 +466,62 @@ async def escalate_session(
|
||||
request: Request,
|
||||
session_id: UUID,
|
||||
data: EscalateSessionRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Escalate a FlowPilot session to another engineer."""
|
||||
"""Escalate a FlowPilot session — unified through HandoffManager."""
|
||||
from app.services.handoff_manager import HandoffManager, enrich_escalation_async
|
||||
|
||||
# Owner-only — matches the original constraint on flowpilot_engine.escalate_session.
|
||||
session_result = await db.execute(
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
session = session_result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
||||
)
|
||||
|
||||
manager = HandoffManager(db)
|
||||
try:
|
||||
result = await flowpilot_engine.escalate_session(
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session_id,
|
||||
request=data,
|
||||
intent="escalate",
|
||||
engineer_notes=data.escalation_reason,
|
||||
user_id=current_user.id,
|
||||
db=db,
|
||||
priority="normal",
|
||||
target_user_id=data.escalated_to_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||
|
||||
documentation, psa_result = await manager.finalize_escalation(
|
||||
handoff, session, current_user.id
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
return result
|
||||
|
||||
await manager.dispatch_escalation_notifications(handoff)
|
||||
|
||||
# AI enrichment (Sonnet assessment + enhanced escalation_package) runs
|
||||
# in the background so the escalating engineer doesn't wait on
|
||||
# 15-25s of model latency. Result lands on the handoff row when ready;
|
||||
# the senior's magic-moment screen reads it at pickup time.
|
||||
background_tasks.add_task(
|
||||
enrich_escalation_async, handoff.id, current_user.id
|
||||
)
|
||||
|
||||
return SessionCloseResponse(
|
||||
session_id=session.id,
|
||||
status=session.status,
|
||||
documentation=documentation,
|
||||
**psa_result,
|
||||
)
|
||||
|
||||
|
||||
# ── Pause ──
|
||||
@@ -644,7 +688,8 @@ async def get_escalation_queue(
|
||||
select(AISession)
|
||||
.where(
|
||||
scope_filter,
|
||||
AISession.status == "requesting_escalation",
|
||||
AISession.status.in_(("requesting_escalation", "escalated")),
|
||||
AISession.user_id != current_user.id,
|
||||
)
|
||||
.order_by(AISession.created_at.desc())
|
||||
)
|
||||
@@ -838,13 +883,25 @@ async def list_sessions(
|
||||
date_to: Optional[datetime] = Query(None),
|
||||
q: Optional[str] = Query(None, min_length=2, max_length=200),
|
||||
):
|
||||
"""List the current user's AI sessions (owned or picked up)."""
|
||||
"""List the current user's AI sessions (owned or picked up).
|
||||
|
||||
"Picked up" includes both the legacy escalation_package.picked_up_by
|
||||
marker (set by flowpilot_engine.pickup_session) AND the new
|
||||
escalated_to_id field (set by HandoffManager.claim_session for the
|
||||
unified handoff/escalate path). Without the escalated_to_id branch
|
||||
the senior tech wouldn't see a session they just claimed in their
|
||||
chat sidebar — the picked-up session lands as the active chat with
|
||||
no entry in the list, which is what the user reported as "4 versions
|
||||
of the session" (their unrelated owned sessions show up while the
|
||||
claimed one is invisible).
|
||||
"""
|
||||
user_id_str = str(current_user.id)
|
||||
query = (
|
||||
select(AISession)
|
||||
.where(
|
||||
or_(
|
||||
AISession.user_id == current_user.id,
|
||||
AISession.escalated_to_id == current_user.id,
|
||||
AISession.escalation_package["picked_up_by"].as_string() == user_id_str,
|
||||
)
|
||||
)
|
||||
@@ -901,10 +958,21 @@ async def get_session(
|
||||
if not session:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
|
||||
# Allow access if user is owner, escalation target, or picked-up handler
|
||||
# Allow access if user is owner, escalation target, or picked-up handler.
|
||||
# Sessions in transit (requesting_escalation / escalated) are also
|
||||
# readable by any account member — the whole point of escalation is that
|
||||
# other techs can see the context before claiming. Tenant boundary is
|
||||
# enforced by RLS on the underlying query, so account-scope is the right
|
||||
# ceiling for in-transit reads.
|
||||
pkg = session.escalation_package or {}
|
||||
is_handler = pkg.get("picked_up_by") == str(current_user.id)
|
||||
if session.user_id != current_user.id and session.escalated_to_id != current_user.id and not is_handler:
|
||||
is_in_transit = session.status in ("requesting_escalation", "escalated")
|
||||
if (
|
||||
session.user_id != current_user.id
|
||||
and session.escalated_to_id != current_user.id
|
||||
and not is_handler
|
||||
and not is_in_transit
|
||||
):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
|
||||
|
||||
return _build_session_detail(session)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timezone, timedelta
|
||||
@@ -19,6 +20,7 @@ from app.core.security import (
|
||||
create_email_verification_token,
|
||||
decode_token,
|
||||
hash_token,
|
||||
resolve_session_policy,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.models.invite_code import InviteCode
|
||||
@@ -41,11 +43,21 @@ from app.core.email import EmailService
|
||||
from app.api.deps import get_current_active_user, get_refresh_token_payload
|
||||
from app.core.audit import log_audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
async def _store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
|
||||
"""Decode a refresh token JWT and store its hash in the database."""
|
||||
async def store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
|
||||
"""Decode a refresh token JWT and store its hash in the database.
|
||||
|
||||
Module-public so OAuth callback endpoints (and any future token-issuing
|
||||
surface) can register the JTI in the ``refresh_tokens`` table the same
|
||||
way ``/auth/login`` does. Without this the first ``/auth/refresh`` call
|
||||
will reject the token as "revoked" because no row exists.
|
||||
|
||||
Caller is responsible for committing the session.
|
||||
"""
|
||||
payload = decode_token(refresh_token_str)
|
||||
if payload and payload.get("jti"):
|
||||
token_record = RefreshToken(
|
||||
@@ -56,12 +68,130 @@ async def _store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id
|
||||
db.add(token_record)
|
||||
|
||||
|
||||
async def _mint_session_tokens(user: User, db: AsyncSession) -> Token:
|
||||
"""Mint a fresh refresh+access pair for a new login.
|
||||
|
||||
Snapshots the account's current session policy into the refresh JWT
|
||||
(auth_time/idle_max/abs_max) and registers the JTI in refresh_tokens.
|
||||
Caller is responsible for committing the session. Use this for every
|
||||
NEW login (password, OAuth, etc.) — for /auth/refresh use
|
||||
_refresh_session_tokens instead, which carries claims forward.
|
||||
|
||||
See docs/plans/2026-05-13-session-expiration-policy.md §4.6.
|
||||
"""
|
||||
account = (
|
||||
await db.execute(select(Account).where(Account.id == user.account_id))
|
||||
).scalar_one()
|
||||
idle_minutes, abs_minutes = resolve_session_policy(account)
|
||||
idle_max_seconds = idle_minutes * 60
|
||||
abs_max_seconds = abs_minutes * 60
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
auth_time_unix = int(now.timestamp())
|
||||
|
||||
refresh_token_str = create_refresh_token(
|
||||
user_id=str(user.id),
|
||||
auth_time=auth_time_unix,
|
||||
idle_max_seconds=idle_max_seconds,
|
||||
abs_max_seconds=abs_max_seconds,
|
||||
)
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_str,
|
||||
token_type="bearer",
|
||||
must_change_password=user.must_change_password,
|
||||
idle_expires_at=now + timedelta(seconds=idle_max_seconds),
|
||||
absolute_expires_at=datetime.fromtimestamp(
|
||||
auth_time_unix + abs_max_seconds, tz=timezone.utc
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_refresh_claims(
|
||||
payload: dict, user: User, db: AsyncSession
|
||||
) -> tuple[int, int, int]:
|
||||
"""Return (auth_time, idle_max_seconds, abs_max_seconds) for a refresh.
|
||||
|
||||
Grandfathers legacy tokens issued before the session-policy PR: tokens
|
||||
missing any of auth_time/idle_max/abs_max get treated as if just minted
|
||||
under the account's current policy. One free rotation under the new
|
||||
rules — see plan §5.1. Callers that have the claims use them as-is.
|
||||
"""
|
||||
auth_time = payload.get("auth_time")
|
||||
idle_max_seconds = payload.get("idle_max")
|
||||
abs_max_seconds = payload.get("abs_max")
|
||||
|
||||
if auth_time is None or idle_max_seconds is None or abs_max_seconds is None:
|
||||
account = (
|
||||
await db.execute(select(Account).where(Account.id == user.account_id))
|
||||
).scalar_one()
|
||||
idle_minutes, abs_minutes = resolve_session_policy(account)
|
||||
auth_time = int(datetime.now(timezone.utc).timestamp())
|
||||
idle_max_seconds = idle_minutes * 60
|
||||
abs_max_seconds = abs_minutes * 60
|
||||
|
||||
return auth_time, idle_max_seconds, abs_max_seconds
|
||||
|
||||
|
||||
async def _mint_with_claims(
|
||||
user: User,
|
||||
auth_time: int,
|
||||
idle_max_seconds: int,
|
||||
abs_max_seconds: int,
|
||||
db: AsyncSession,
|
||||
) -> Token:
|
||||
"""Mint a refresh+access pair carrying explicit session-policy claims.
|
||||
|
||||
Used by /auth/refresh after the grandfather + absolute-cap checks
|
||||
have already produced the effective claim values. Caller commits.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
refresh_token_str = create_refresh_token(
|
||||
user_id=str(user.id),
|
||||
auth_time=auth_time,
|
||||
idle_max_seconds=idle_max_seconds,
|
||||
abs_max_seconds=abs_max_seconds,
|
||||
)
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_str,
|
||||
token_type="bearer",
|
||||
must_change_password=user.must_change_password,
|
||||
idle_expires_at=now + timedelta(seconds=idle_max_seconds),
|
||||
absolute_expires_at=datetime.fromtimestamp(
|
||||
auth_time + abs_max_seconds, tz=timezone.utc
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _generate_display_code() -> str:
|
||||
"""Generate a random 8-character alphanumeric display code."""
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
return ''.join(secrets.choice(chars) for _ in range(8))
|
||||
|
||||
|
||||
async def _reject_if_oauth_only(db: AsyncSession, user) -> None:
|
||||
"""If the user has no password_hash, raise 400 with a list of linked
|
||||
providers so the client can redirect them to the right OAuth flow."""
|
||||
if user is None or user.password_hash is not None:
|
||||
return
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
result = await db.execute(
|
||||
select(OAuthIdentity.provider).where(OAuthIdentity.user_id == user.id)
|
||||
)
|
||||
providers = [row for row in result.scalars().all()]
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "use_oauth_provider", "providers": providers},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit("3/minute")
|
||||
async def register(
|
||||
@@ -108,10 +238,24 @@ async def register(
|
||||
detail="Account invite code has expired"
|
||||
)
|
||||
|
||||
if account_invite_record.email.lower() != user_data.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": "invite_email_mismatch"},
|
||||
)
|
||||
|
||||
# Validate platform invite code (skip if account invite was provided)
|
||||
invite_code_record = None
|
||||
if not account_invite_record:
|
||||
if settings.REQUIRE_INVITE_CODE and not user_data.invite_code:
|
||||
# When SELF_SERVE_ENABLED is on, the platform invite gate is bypassed
|
||||
# entirely — public self-serve signup is the whole point. The
|
||||
# invite_code field stays in the schema for backward compatibility
|
||||
# and so paid/trial-bearing codes still apply when supplied.
|
||||
if (
|
||||
settings.REQUIRE_INVITE_CODE
|
||||
and not settings.is_self_serve_active_for(user_data.email)
|
||||
and not user_data.invite_code
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invite code is required"
|
||||
@@ -145,6 +289,33 @@ async def register(
|
||||
detail="Invite code has expired"
|
||||
)
|
||||
|
||||
# Seat enforcement: re-check at accept time (race-condition guard).
|
||||
# Fires only when an account invite is being accepted and the target role
|
||||
# is seat-counted (engineer, l1_tech). Accounts without a subscription
|
||||
# (free / pre-billing) are not blocked.
|
||||
if account_invite_record and account_invite_record.role in ("engineer", "l1_tech"):
|
||||
from app.core.subscriptions import get_account_subscription
|
||||
from app.services.seat_enforcement import check_seat_available
|
||||
from app.models.account import Account as _Account
|
||||
sub = await get_account_subscription(account_invite_record.account_id, db)
|
||||
if sub is not None:
|
||||
acct_result = await db.execute(
|
||||
select(_Account).where(_Account.id == account_invite_record.account_id)
|
||||
)
|
||||
acct = acct_result.scalar_one()
|
||||
seat_result = await check_seat_available(acct, sub, account_invite_record.role, db)
|
||||
if not seat_result.available:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
"code": "seat_limit_exceeded",
|
||||
"role": seat_result.role,
|
||||
"current": seat_result.current,
|
||||
"limit": seat_result.limit,
|
||||
"upgrade_url": "/account/billing",
|
||||
},
|
||||
)
|
||||
|
||||
# Check if email already exists
|
||||
result = await db.execute(select(User).where(User.email == user_data.email))
|
||||
existing_user = result.scalar_one_or_none()
|
||||
@@ -195,26 +366,30 @@ async def register(
|
||||
# Now set account owner and create subscription
|
||||
new_account.owner_id = new_user.id
|
||||
|
||||
# Apply plan/trial from invite code if present
|
||||
sub_plan = "free"
|
||||
sub_status = "active"
|
||||
period_start = None
|
||||
period_end = None
|
||||
if invite_code_record and invite_code_record.assigned_plan:
|
||||
# Plan/trial driven by platform invite code (existing pilot flow)
|
||||
sub_plan = invite_code_record.assigned_plan
|
||||
sub_status = "active"
|
||||
period_start = None
|
||||
period_end = None
|
||||
if invite_code_record.trial_duration_days:
|
||||
sub_status = "trialing"
|
||||
period_start = datetime.now(timezone.utc)
|
||||
period_end = period_start + timedelta(days=invite_code_record.trial_duration_days)
|
||||
|
||||
new_subscription = Subscription(
|
||||
account_id=new_account.id,
|
||||
plan=sub_plan,
|
||||
status=sub_status,
|
||||
current_period_start=period_start,
|
||||
current_period_end=period_end,
|
||||
)
|
||||
db.add(new_subscription)
|
||||
db.add(Subscription(
|
||||
account_id=new_account.id,
|
||||
plan=sub_plan,
|
||||
status=sub_status,
|
||||
current_period_start=period_start,
|
||||
current_period_end=period_end,
|
||||
))
|
||||
else:
|
||||
# New self-serve shop — start the standard Pro trial.
|
||||
# start_trial commits internally; flush our pending User/Account changes
|
||||
# first so the FK is satisfied.
|
||||
await db.flush()
|
||||
from app.services.billing import BillingService
|
||||
await BillingService.start_trial(db, new_account.id)
|
||||
|
||||
# Mark platform invite code as used
|
||||
if invite_code_record:
|
||||
@@ -224,6 +399,34 @@ async def register(
|
||||
await db.commit()
|
||||
await db.refresh(new_user)
|
||||
|
||||
# Auto-send verification email for newly-registered users.
|
||||
# Skip silently if verification already done (shouldn't happen for fresh
|
||||
# users, but defensive).
|
||||
if new_user.email_verified_at is None:
|
||||
verification_enabled = await SettingsManager.get(
|
||||
"email_verification_enabled", db, default=True
|
||||
)
|
||||
if verification_enabled:
|
||||
try:
|
||||
raw_token = create_email_verification_token(str(new_user.id))
|
||||
payload = decode_token(raw_token)
|
||||
if payload and payload.get("jti"):
|
||||
token_record = EmailVerificationToken(
|
||||
token_hash=hash_token(payload["jti"]),
|
||||
user_id=new_user.id,
|
||||
expires_at=datetime.fromtimestamp(payload["exp"], tz=timezone.utc),
|
||||
)
|
||||
db.add(token_record)
|
||||
await db.commit()
|
||||
|
||||
verification_url = f"{settings.FRONTEND_URL}/verify-email?token={raw_token}"
|
||||
await EmailService.send_email_verification_email(
|
||||
to_email=new_user.email,
|
||||
verification_url=verification_url,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("verification email send failed for %s: %s", new_user.email, e)
|
||||
|
||||
return new_user
|
||||
|
||||
|
||||
@@ -239,6 +442,7 @@ async def login(
|
||||
result = await db.execute(select(User).where(User.email == form_data.username))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
await _reject_if_oauth_only(db, user)
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -249,20 +453,9 @@ async def login(
|
||||
# Update last login
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
|
||||
# Create tokens
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# Store refresh token hash in DB
|
||||
await _store_refresh_token(db, refresh_token_str, user.id)
|
||||
token = await _mint_session_tokens(user, db)
|
||||
await db.commit()
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_str,
|
||||
token_type="bearer",
|
||||
must_change_password=user.must_change_password,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/login/json", response_model=Token)
|
||||
@@ -276,6 +469,7 @@ async def login_json(
|
||||
result = await db.execute(select(User).where(User.email == credentials.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
await _reject_if_oauth_only(db, user)
|
||||
if not user or not verify_password(credentials.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -284,19 +478,9 @@ async def login_json(
|
||||
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# Store refresh token hash in DB
|
||||
await _store_refresh_token(db, refresh_token_str, user.id)
|
||||
token = await _mint_session_tokens(user, db)
|
||||
await db.commit()
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token_str,
|
||||
token_type="bearer",
|
||||
must_change_password=user.must_change_password,
|
||||
)
|
||||
return token
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
@@ -306,13 +490,39 @@ async def refresh_token(
|
||||
payload: Annotated[dict, Depends(get_refresh_token_payload)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
):
|
||||
"""Refresh access token using refresh token (rotation: old token is revoked)."""
|
||||
"""Refresh access token, enforcing both idle and absolute session windows.
|
||||
|
||||
Algorithm (see plan §4.5):
|
||||
|
||||
1. Decode refresh JWT (the dep already rejects idle-expired tokens with
|
||||
session_expired_idle).
|
||||
2. Load the user. If missing or inactive, 401 invalid_refresh_token.
|
||||
3. Resolve effective auth_time/idle_max/abs_max (grandfather legacy
|
||||
tokens that pre-date this PR).
|
||||
4. Atomically revoke the JTI regardless of outcome — so an absolute-
|
||||
expired token cannot be replayed; the second attempt finds it
|
||||
already revoked and gets invalid_refresh_token instead.
|
||||
5. If the atomic UPDATE matched zero rows, 401 invalid_refresh_token.
|
||||
6. If now >= auth_time + abs_max, 401 session_expired_absolute.
|
||||
7. Otherwise mint new tokens carrying the claims forward.
|
||||
"""
|
||||
user_id = payload.get("sub")
|
||||
jti = payload.get("jti")
|
||||
|
||||
# Atomically revoke the old refresh token (token rotation).
|
||||
# Using a conditional UPDATE prevents the race where two concurrent
|
||||
# refresh requests both read revoked_at=NULL and both succeed.
|
||||
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="invalid_refresh_token",
|
||||
)
|
||||
|
||||
auth_time, idle_max_seconds, abs_max_seconds = await _resolve_refresh_claims(
|
||||
payload, user, db
|
||||
)
|
||||
|
||||
# Atomically revoke the old refresh token first — this consumes the
|
||||
# token regardless of whether the absolute check passes, so an absolute-
|
||||
# expired token cannot be replayed.
|
||||
if jti:
|
||||
token_hash = hash_token(jti)
|
||||
result = await db.execute(
|
||||
@@ -325,35 +535,31 @@ async def refresh_token(
|
||||
.returning(RefreshToken.id, RefreshToken.user_id)
|
||||
)
|
||||
revoked_row = result.fetchone()
|
||||
|
||||
if not revoked_row:
|
||||
# Either the token doesn't exist or was already revoked/used
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Refresh token has been revoked"
|
||||
detail="invalid_refresh_token",
|
||||
)
|
||||
|
||||
result = await db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
# Absolute-window check. Boundary is `>=`, not `>` — a deadline equal to
|
||||
# now is expired. The token row has already been revoked above, so the
|
||||
# client cannot retry this token even though we're raising after the
|
||||
# consume.
|
||||
now_unix = int(datetime.now(timezone.utc).timestamp())
|
||||
if now_unix >= auth_time + abs_max_seconds:
|
||||
# Commit the revoke so the consumed-on-failure invariant survives
|
||||
# any subsequent rollback in the request lifecycle.
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="User not found"
|
||||
detail="session_expired_absolute",
|
||||
)
|
||||
|
||||
access_token = create_access_token(data={"sub": str(user.id)})
|
||||
new_refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# Store new refresh token
|
||||
await _store_refresh_token(db, new_refresh_token_str, user.id)
|
||||
await db.commit()
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=new_refresh_token_str,
|
||||
token_type="bearer"
|
||||
token = await _mint_with_claims(
|
||||
user, auth_time, idle_max_seconds, abs_max_seconds, db
|
||||
)
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
@@ -441,6 +647,7 @@ async def change_password(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)]
|
||||
):
|
||||
"""Change the current user's password."""
|
||||
await _reject_if_oauth_only(db, current_user)
|
||||
if not verify_password(data.current_password, current_user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -484,7 +691,7 @@ async def forgot_password(
|
||||
result = await db.execute(select(User).where(User.email == data.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
if user and user.password_hash is not None:
|
||||
# Create reset token JWT
|
||||
raw_token = create_password_reset_token(str(user.id))
|
||||
payload = decode_token(raw_token)
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
"""Public beta signup endpoint — no auth required."""
|
||||
"""Legacy beta signup endpoint — redirects to /register?from=beta.
|
||||
|
||||
Phase 2 (self-serve signup) makes the public register flow the canonical
|
||||
front door. The old `/api/v1/beta-signup` POST endpoint is kept mounted to
|
||||
preserve any external links that still hit it, but now responds with a
|
||||
307 Temporary Redirect to `/register?from=beta` so the user lands in the
|
||||
real signup flow. The `?from=beta` marker lets the frontend tag the
|
||||
signup origin for analytics.
|
||||
|
||||
Note: there is no `beta_signup` database table — the original endpoint
|
||||
only fired a notification email. There is therefore no waitlist to email
|
||||
and no migration to run when retiring the endpoint.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from app.core.email import EmailService
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/beta-signup", tags=["beta"])
|
||||
|
||||
|
||||
class BetaSignupRequest(BaseModel):
|
||||
email: EmailStr
|
||||
# Local-dev fallback when FRONTEND_URL isn't configured. The redirect must
|
||||
# be absolute — a relative URL would resolve against the API origin
|
||||
# (api.resolutionflow.com), which has no /register page.
|
||||
_DEFAULT_FRONTEND_URL = "http://localhost:5173"
|
||||
|
||||
|
||||
class BetaSignupResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
@router.post("", include_in_schema=False)
|
||||
async def beta_signup_redirect() -> RedirectResponse:
|
||||
"""Redirect legacy beta-signup POST to the public register page.
|
||||
|
||||
|
||||
@router.post("", response_model=BetaSignupResponse)
|
||||
async def beta_signup(data: BetaSignupRequest):
|
||||
"""Collect beta interest — sends notification to beta@resolutionflow.com."""
|
||||
sent = await EmailService.send_beta_signup_notification(data.email)
|
||||
if not sent:
|
||||
logger.warning("Beta signup recorded (email delivery skipped): %s", data.email)
|
||||
return BetaSignupResponse(
|
||||
success=True,
|
||||
message="Thanks! We'll be in touch with beta access details.",
|
||||
Returns 307 so any client following the redirect preserves the HTTP
|
||||
method; the frontend treats `/register?from=beta` as the canonical
|
||||
entry point and reads the `from` query param for analytics.
|
||||
"""
|
||||
frontend_url = settings.FRONTEND_URL or _DEFAULT_FRONTEND_URL
|
||||
return RedirectResponse(
|
||||
url=f"{frontend_url}/register?from=beta",
|
||||
status_code=307,
|
||||
)
|
||||
|
||||
76
backend/app/api/endpoints/billing.py
Normal file
76
backend/app/api/endpoints/billing.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.config import settings
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.schemas.billing import (
|
||||
BillingPortalSessionResponse,
|
||||
BillingStateResponse,
|
||||
CheckoutSessionCreate,
|
||||
CheckoutSessionResponse,
|
||||
)
|
||||
from app.services.billing import BillingService
|
||||
|
||||
router = APIRouter(prefix="/billing", tags=["billing"])
|
||||
|
||||
|
||||
@router.post("/checkout-session", response_model=CheckoutSessionResponse)
|
||||
async def create_checkout_session(
|
||||
payload: CheckoutSessionCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> CheckoutSessionResponse:
|
||||
account = (await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)).scalar_one()
|
||||
url = await BillingService.create_checkout_session(
|
||||
db=db,
|
||||
account=account,
|
||||
plan=payload.plan,
|
||||
seats=payload.seats,
|
||||
billing_interval=payload.billing_interval,
|
||||
success_url=f"{settings.FRONTEND_URL}/account/billing?success=1",
|
||||
cancel_url=f"{settings.FRONTEND_URL}/account/billing/select-plan",
|
||||
)
|
||||
return CheckoutSessionResponse(url=url)
|
||||
|
||||
|
||||
@router.get("/state", response_model=BillingStateResponse)
|
||||
async def get_billing_state(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> BillingStateResponse:
|
||||
account = (await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)).scalar_one()
|
||||
state = await BillingService.get_billing_state(db, account)
|
||||
return BillingStateResponse(**state)
|
||||
|
||||
|
||||
@router.get("/portal-session", response_model=BillingPortalSessionResponse)
|
||||
async def get_billing_portal_session(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> BillingPortalSessionResponse:
|
||||
"""Return a Stripe-hosted Customer Portal URL for the account so the user
|
||||
can update card / cancel. Allowlisted from the subscription + email-verify
|
||||
guards (a canceled or unverified-past-grace user must still be able to
|
||||
update billing)."""
|
||||
if not settings.stripe_enabled:
|
||||
raise HTTPException(status_code=503, detail={"error": "stripe_not_configured"})
|
||||
|
||||
account = (await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)).scalar_one()
|
||||
|
||||
try:
|
||||
url = await BillingService.open_customer_portal(account)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail={"error": "no_stripe_customer"})
|
||||
return BillingPortalSessionResponse(url=url)
|
||||
50
backend/app/api/endpoints/config.py
Normal file
50
backend/app/api/endpoints/config.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Public runtime configuration endpoint.
|
||||
|
||||
GET /api/v1/config/public
|
||||
Returns the small set of runtime flags the frontend needs at app load
|
||||
to decide whether to render the self-serve signup flow and which OAuth
|
||||
buttons to show. No authentication required.
|
||||
|
||||
The response model lives in `app.schemas.config` so it can be reused by
|
||||
frontend codegen and other call sites if needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import get_current_user_optional
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.schemas.config import PublicConfigResponse
|
||||
|
||||
router = APIRouter(prefix="/config", tags=["config"])
|
||||
|
||||
|
||||
@router.get("/public", response_model=PublicConfigResponse)
|
||||
async def get_public_config(
|
||||
current_user: Annotated[Optional[User], Depends(get_current_user_optional)],
|
||||
) -> PublicConfigResponse:
|
||||
"""Return public-safe runtime config.
|
||||
|
||||
`oauth_providers` reflects which OAuth client IDs are configured server
|
||||
side; the frontend uses it to render only buttons that will actually
|
||||
succeed. `self_serve_enabled` is the master switch for the new public
|
||||
self-serve signup flow; an authenticated caller whose email is on the
|
||||
INTERNAL_TESTER_EMAILS allowlist sees `True` even when the global flag
|
||||
is off, so internal validation in prod test mode can exercise the full
|
||||
surface before the public flip.
|
||||
"""
|
||||
providers: list[str] = []
|
||||
if settings.GOOGLE_CLIENT_ID:
|
||||
providers.append("google")
|
||||
if settings.MS_CLIENT_ID:
|
||||
providers.append("microsoft")
|
||||
|
||||
user_email = current_user.email if current_user else None
|
||||
return PublicConfigResponse(
|
||||
self_serve_enabled=settings.is_self_serve_active_for(user_email),
|
||||
oauth_providers=providers,
|
||||
)
|
||||
@@ -3,8 +3,10 @@
|
||||
Endpoints:
|
||||
GET /analytics/flowpilot?period=30d — Main dashboard data
|
||||
GET /analytics/flowpilot/knowledge-gaps — Knowledge gap report
|
||||
GET /analytics/flowpilot/escalations?period=30d — Escalation handoff metrics
|
||||
"""
|
||||
import logging
|
||||
import statistics
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from typing import Annotated, Optional
|
||||
|
||||
@@ -13,10 +15,17 @@ from sqlalchemy import select, func, case, cast, Date, extract
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.rate_limit import limiter
|
||||
from app.api.deps import get_current_active_user, get_db, require_team_admin
|
||||
from app.api.deps import (
|
||||
get_current_active_user,
|
||||
get_db,
|
||||
require_engineer_or_admin,
|
||||
require_team_admin,
|
||||
)
|
||||
from app.models.user import User
|
||||
from app.models.tree import Tree
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.ai_session_step import AISessionStep
|
||||
from app.models.session_handoff import SessionHandoff
|
||||
from app.models.flow_proposal import FlowProposal
|
||||
from app.models.psa_activity_log import PsaActivityLog
|
||||
from app.models.psa_post_log import PsaPostLog
|
||||
@@ -36,6 +45,7 @@ from app.schemas.flowpilot_analytics import (
|
||||
EnhancedPsaMetrics,
|
||||
PsaFunnel,
|
||||
PsaDailyTrend,
|
||||
EscalationMetrics,
|
||||
)
|
||||
from app.services.knowledge_gap_service import get_knowledge_gaps, KnowledgeGapReport
|
||||
|
||||
@@ -727,3 +737,104 @@ async def get_enhanced_psa_metrics(
|
||||
push_funnel=push_funnel,
|
||||
daily_trend=daily_trend,
|
||||
)
|
||||
|
||||
|
||||
# ─── Escalation Mode metrics (wedge stat for /escalations queue + analytics page)
|
||||
#
|
||||
# Pulls all (handoff.claimed_at, first_step_after_claim.created_at) pairs in the
|
||||
# window and aggregates avg/median/p95 of the delta in Python. Pilot scale
|
||||
# (~1k rows max per account per month) makes this cheaper and clearer than
|
||||
# Postgres percentile_cont gymnastics.
|
||||
#
|
||||
# IMPORTANT: this is the in-product metric only. The "minutes recovered"
|
||||
# sales claim requires manual baseline measurement (see The Assignment in
|
||||
# docs/plans/2026-04-27-escalation-mode-wedge-design.md).
|
||||
|
||||
|
||||
@router.get("/escalations", response_model=EscalationMetrics)
|
||||
@limiter.limit("30/minute")
|
||||
async def get_escalation_metrics(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
period: str = Query("30d", pattern="^(7d|30d|90d)$"),
|
||||
) -> EscalationMetrics:
|
||||
"""Time-to-first-action after escalation claim, account-scoped.
|
||||
|
||||
Returns:
|
||||
n_handoffs_claimed: handoffs in window that were claimed by a senior.
|
||||
n_handoffs_with_action: subset where the senior took at least one
|
||||
action (an ai_session_step row created after claimed_at).
|
||||
avg/median/p95_seconds_to_first_action: aggregates of
|
||||
(first_step.created_at - claimed_at) in seconds.
|
||||
|
||||
Excludes handoffs where claimed_at IS NULL (never claimed) and handoffs
|
||||
where no ai_session_step was created after the claim. Both are
|
||||
counted — n_handoffs_claimed includes "no action yet" handoffs so the
|
||||
conversion rate is visible.
|
||||
"""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="No account"
|
||||
)
|
||||
|
||||
account_id = current_user.account_id
|
||||
period_start = _get_period_start(period)
|
||||
|
||||
# First-action timestamp per handoff via correlated scalar subquery.
|
||||
first_action_subq = (
|
||||
select(func.min(AISessionStep.created_at))
|
||||
.where(
|
||||
AISessionStep.session_id == SessionHandoff.session_id,
|
||||
AISessionStep.created_at > SessionHandoff.claimed_at,
|
||||
)
|
||||
.correlate(SessionHandoff)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
rows = (
|
||||
await db.execute(
|
||||
select(
|
||||
SessionHandoff.claimed_at,
|
||||
first_action_subq.label("first_action_at"),
|
||||
).where(
|
||||
SessionHandoff.account_id == account_id,
|
||||
SessionHandoff.claimed_at.isnot(None),
|
||||
SessionHandoff.claimed_at >= period_start,
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
n_handoffs_claimed = len(rows)
|
||||
deltas: list[float] = []
|
||||
for claimed_at, first_action_at in rows:
|
||||
if first_action_at is None:
|
||||
continue
|
||||
delta_s = (first_action_at - claimed_at).total_seconds()
|
||||
# Floor at zero — clock drift between rows could in theory yield a
|
||||
# tiny negative if a step's created_at races claimed_at. Surface as
|
||||
# 0s rather than absurd negative deltas.
|
||||
if delta_s < 0:
|
||||
delta_s = 0.0
|
||||
deltas.append(delta_s)
|
||||
|
||||
n_handoffs_with_action = len(deltas)
|
||||
if n_handoffs_with_action == 0:
|
||||
return EscalationMetrics(
|
||||
period=period,
|
||||
n_handoffs_claimed=n_handoffs_claimed,
|
||||
n_handoffs_with_action=0,
|
||||
)
|
||||
|
||||
sorted_deltas = sorted(deltas)
|
||||
p95_idx = max(0, int(round(0.95 * (n_handoffs_with_action - 1))))
|
||||
|
||||
return EscalationMetrics(
|
||||
period=period,
|
||||
n_handoffs_claimed=n_handoffs_claimed,
|
||||
n_handoffs_with_action=n_handoffs_with_action,
|
||||
avg_seconds_to_first_action=round(statistics.fmean(deltas), 2),
|
||||
median_seconds_to_first_action=round(statistics.median(deltas), 2),
|
||||
p95_seconds_to_first_action=round(sorted_deltas[p95_idx], 2),
|
||||
)
|
||||
|
||||
@@ -194,6 +194,7 @@ async def create_folder(
|
||||
|
||||
new_folder = UserFolder(
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
name=folder_data.name,
|
||||
color=folder_data.color,
|
||||
icon=folder_data.icon,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""PSA integration endpoints — connection CRUD and test."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
@@ -11,6 +12,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.api.deps import get_current_active_user, require_account_owner, require_engineer_or_admin
|
||||
from app.core.database import get_db
|
||||
from app.models.psa_connection import PsaConnection
|
||||
@@ -30,6 +33,17 @@ from app.schemas.psa_connection import (
|
||||
PSABoardResponse,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.schemas.psa_tickets import (
|
||||
PSAResourceSchema,
|
||||
PSATicketCreatedSchema,
|
||||
PSATicketStatusUpdateSchema,
|
||||
TicketCreatePayloadSchema,
|
||||
PSAPrioritySchema,
|
||||
TicketListResponseSchema,
|
||||
AiParseRequestSchema,
|
||||
AiParseResponseSchema,
|
||||
)
|
||||
import app.services.ticket_service as ticket_svc
|
||||
from app.services.psa.encryption import (
|
||||
decrypt_credentials,
|
||||
encrypt_credentials,
|
||||
@@ -362,33 +376,36 @@ async def list_boards(
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
boards = await provider.list_boards()
|
||||
return [PSABoardResponse(id=b.id, name=b.name) for b in boards]
|
||||
except PSAError:
|
||||
except PSAError as e:
|
||||
# Boards are optional UI chrome — degrade gracefully rather than surfacing a toast
|
||||
logger.warning("list_boards failed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/tickets/search", response_model=list[PSATicketSearchResult])
|
||||
@router.get("/tickets/search", response_model=TicketListResponseSchema)
|
||||
async def search_tickets(
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
query: str = "",
|
||||
board_id: int | None = None,
|
||||
status_id: int | None = None,
|
||||
status_name: str | None = None,
|
||||
include_closed: bool = False,
|
||||
assigned_to_me: bool = False,
|
||||
unassigned: bool = False,
|
||||
board_ids: str = "",
|
||||
priority: str | None = None,
|
||||
company_id: int | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
page_size: int = 25,
|
||||
):
|
||||
"""Search ConnectWise tickets."""
|
||||
"""Search ConnectWise tickets — returns paginated TicketListResponse."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account
|
||||
from app.services.psa.exceptions import PSAError
|
||||
|
||||
# Resolve assigned_to_me → member_identifier (CW login name for resources contains filter)
|
||||
member_identifier: str | None = None
|
||||
if assigned_to_me:
|
||||
conn_result = await db.execute(
|
||||
@@ -407,23 +424,18 @@ async def search_tickets(
|
||||
)
|
||||
mapping = mapping_result.scalar_one_or_none()
|
||||
if not mapping:
|
||||
# No mapping for this user — return empty list
|
||||
return []
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account as _get_provider
|
||||
from app.services.psa.exceptions import PSAError as _PSAError
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
try:
|
||||
_provider = await _get_provider(current_user.account_id, db)
|
||||
_provider = await get_provider_for_account(current_user.account_id, db)
|
||||
cw_members = await _provider.list_members()
|
||||
matched = next((m for m in cw_members if m.id == mapping.external_member_id), None)
|
||||
if matched:
|
||||
member_identifier = matched.identifier
|
||||
else:
|
||||
return []
|
||||
except _PSAError:
|
||||
return []
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
except PSAError:
|
||||
return {"items": [], "total": 0, "page": page, "page_size": page_size}
|
||||
|
||||
# Parse comma-separated board_ids
|
||||
parsed_board_ids: list[int] = []
|
||||
if board_ids:
|
||||
try:
|
||||
@@ -433,33 +445,250 @@ async def search_tickets(
|
||||
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
tickets = await provider.search_tickets(
|
||||
result = await provider.search_tickets(
|
||||
query,
|
||||
board_id=board_id,
|
||||
status_id=status_id,
|
||||
status_name=status_name,
|
||||
include_closed=include_closed,
|
||||
member_identifier=member_identifier,
|
||||
unassigned=unassigned,
|
||||
board_ids=parsed_board_ids,
|
||||
company_id=company_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return [
|
||||
items = [
|
||||
PSATicketSearchResult(
|
||||
id=t.id,
|
||||
summary=t.summary,
|
||||
company_name=t.company_name,
|
||||
company_id=t.company_id,
|
||||
board_name=t.board_name,
|
||||
board_id=t.board_id,
|
||||
status_name=t.status_name,
|
||||
status_id=t.status_id,
|
||||
priority_name=t.priority_name,
|
||||
priority_id=t.priority_id,
|
||||
closed=t.closed,
|
||||
)
|
||||
for t in tickets
|
||||
for t in result.items
|
||||
]
|
||||
return {"items": items, "total": result.total, "page": result.page, "page_size": result.page_size}
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/tickets", response_model=PSATicketCreatedSchema, status_code=201)
|
||||
async def create_ticket(
|
||||
data: TicketCreatePayloadSchema,
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Create a new PSA ticket."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
from app.services.psa.exceptions import PSAError
|
||||
from app.services.psa.types import TicketCreatePayload
|
||||
try:
|
||||
return await ticket_svc.create_ticket(
|
||||
current_user.account_id,
|
||||
TicketCreatePayload(**data.model_dump()),
|
||||
db,
|
||||
)
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/tickets/ai-parse", response_model=AiParseResponseSchema)
|
||||
async def ai_parse_ticket(
|
||||
data: AiParseRequestSchema,
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Parse natural language into a ticket pre-fill payload using Claude."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account
|
||||
from app.services.psa.exceptions import PSAError
|
||||
import anthropic
|
||||
import json
|
||||
|
||||
# Fetch boards + members for context (both cached)
|
||||
boards = []
|
||||
members = []
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
boards = await provider.list_boards()
|
||||
members = await provider.list_members()
|
||||
except PSAError:
|
||||
pass
|
||||
|
||||
boards_list = [{"id": b.id, "name": b.name} for b in boards]
|
||||
members_list = [{"id": m.id, "name": m.name, "identifier": m.identifier} for m in members]
|
||||
|
||||
system_prompt = """You are a ticket triage assistant for an MSP help desk.
|
||||
Extract structured ticket information from the engineer's natural language description.
|
||||
Return ONLY valid JSON matching this exact schema — no other text:
|
||||
{
|
||||
"summary": "short one-line ticket title or null",
|
||||
"board_id": "integer matching one of the provided boards or null",
|
||||
"priority_name": "one of: Critical, High, Medium, Low, or null",
|
||||
"description": "expanded description or null",
|
||||
"assignee_identifier": "member identifier string from the provided members list or null",
|
||||
"warnings": ["list of strings explaining what could not be resolved"]
|
||||
}"""
|
||||
|
||||
user_msg = f"""Available boards: {json.dumps(boards_list)}
|
||||
Available members: {json.dumps(members_list[:50])}
|
||||
|
||||
Engineer's description: {data.prompt}"""
|
||||
|
||||
missing_fields: list[str] = []
|
||||
warnings: list[str] = []
|
||||
response_data = AiParseResponseSchema()
|
||||
|
||||
try:
|
||||
client = anthropic.AsyncAnthropic(
|
||||
api_key=settings.ANTHROPIC_API_KEY,
|
||||
max_retries=1,
|
||||
)
|
||||
msg = await client.messages.create(
|
||||
model=settings.get_model_for_action("default"),
|
||||
max_tokens=512,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_msg}],
|
||||
)
|
||||
raw = msg.content[0].text.strip()
|
||||
# Strip markdown fences if present
|
||||
if raw.startswith("```"):
|
||||
import re
|
||||
raw = re.sub(r'^```(?:json)?\s*', '', raw)
|
||||
raw = re.sub(r'\s*```$', '', raw.strip())
|
||||
parsed = json.loads(raw)
|
||||
|
||||
response_data.summary = parsed.get("summary")
|
||||
response_data.description = parsed.get("description")
|
||||
warnings = parsed.get("warnings", [])
|
||||
|
||||
# Resolve board_id
|
||||
if parsed.get("board_id"):
|
||||
board_match = next((b for b in boards if b.id == int(parsed["board_id"])), None)
|
||||
if board_match:
|
||||
response_data.board_id = board_match.id
|
||||
else:
|
||||
missing_fields.append("board_id")
|
||||
warnings.append(f"Board ID {parsed['board_id']} not found")
|
||||
else:
|
||||
missing_fields.append("board_id")
|
||||
|
||||
# Resolve assignee
|
||||
if parsed.get("assignee_identifier"):
|
||||
member = next((m for m in members if m.identifier == parsed["assignee_identifier"]), None)
|
||||
if member:
|
||||
response_data.assigned_member_id = int(member.id)
|
||||
else:
|
||||
warnings.append(f"Member '{parsed['assignee_identifier']}' not found")
|
||||
|
||||
# Priority/status/company always need manual selection
|
||||
missing_fields.extend(["status_id", "priority_id", "company_id"])
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("AI parse failed: %s", e)
|
||||
missing_fields = ["summary", "board_id", "status_id", "priority_id", "company_id"]
|
||||
warnings = ["AI parsing failed — please fill in manually"]
|
||||
|
||||
response_data.missing_fields = missing_fields
|
||||
response_data.warnings = warnings
|
||||
return response_data
|
||||
|
||||
|
||||
@router.patch("/tickets/{ticket_id}/status", response_model=PSATicketStatusUpdateSchema)
|
||||
async def update_ticket_status_endpoint(
|
||||
ticket_id: int,
|
||||
status_id: int,
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Update a ticket's status."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
from app.services.psa.exceptions import PSAError
|
||||
try:
|
||||
return await ticket_svc.update_status(current_user.account_id, ticket_id, status_id, db)
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/tickets/{ticket_id}/resources", response_model=list[PSAResourceSchema])
|
||||
async def list_ticket_resources(
|
||||
ticket_id: int,
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
from app.services.psa.exceptions import PSAError
|
||||
try:
|
||||
return await ticket_svc.list_resources(current_user.account_id, ticket_id, db)
|
||||
except PSAError as e:
|
||||
# Resources are optional display data — degrade gracefully rather than surfacing a toast
|
||||
logger.warning("list_resources(%s) failed: %s", ticket_id, e)
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/tickets/{ticket_id}/resources", response_model=PSAResourceSchema, status_code=201)
|
||||
async def add_ticket_resource(
|
||||
ticket_id: int,
|
||||
member_id: int,
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
from app.services.psa.exceptions import PSAError
|
||||
try:
|
||||
return await ticket_svc.add_resource(current_user.account_id, ticket_id, member_id, db)
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/tickets/{ticket_id}/resources/{member_id}", status_code=204)
|
||||
async def remove_ticket_resource(
|
||||
ticket_id: int,
|
||||
member_id: int,
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
from app.services.psa.exceptions import PSAError
|
||||
try:
|
||||
await ticket_svc.remove_resource(current_user.account_id, ticket_id, member_id, db)
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/priorities", response_model=list[PSAPrioritySchema])
|
||||
async def list_priorities(
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""List PSA priority levels for ticket creation form."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
from app.services.psa.registry import get_provider_for_account
|
||||
from app.services.psa.exceptions import PSAError
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
raw = await provider.list_priorities()
|
||||
return [PSAPrioritySchema(id=p["id"], name=p["name"]) for p in raw if p.get("id")]
|
||||
except PSAError as e:
|
||||
logger.warning("list_priorities failed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/tickets/{ticket_id}/context")
|
||||
async def get_ticket_context(
|
||||
ticket_id: int,
|
||||
@@ -561,7 +790,30 @@ async def get_ticket_statuses(
|
||||
except PSANotFoundError:
|
||||
raise HTTPException(status_code=404, detail="Ticket not found")
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
logger.warning("get_ticket_statuses(%s) failed: %s", ticket_id, e)
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/boards/{board_id}/statuses", response_model=list[PSATicketStatusItem])
|
||||
async def get_board_statuses(
|
||||
board_id: int,
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Get available statuses for a service board directly (no ticket lookup required)."""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(status_code=400, detail="User has no account")
|
||||
|
||||
from app.services.psa.registry import get_provider_for_account
|
||||
from app.services.psa.exceptions import PSAError
|
||||
|
||||
try:
|
||||
provider = await get_provider_for_account(current_user.account_id, db)
|
||||
statuses = await provider.get_ticket_statuses(board_id)
|
||||
return [PSATicketStatusItem(id=s.id, name=s.name, is_closed=s.is_closed) for s in statuses]
|
||||
except PSAError as e:
|
||||
logger.warning("get_board_statuses(%s) failed: %s", board_id, e)
|
||||
return []
|
||||
|
||||
|
||||
# ── member mapping endpoints ─────────────────────────────────────────
|
||||
@@ -569,7 +821,7 @@ async def get_ticket_statuses(
|
||||
|
||||
@router.get("/members", response_model=list[PsaMemberResponse])
|
||||
async def list_members(
|
||||
current_user: Annotated[User, Depends(require_account_owner)],
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""List CW members (from CW API)."""
|
||||
@@ -587,7 +839,9 @@ async def list_members(
|
||||
for m in members
|
||||
]
|
||||
except PSAError as e:
|
||||
raise HTTPException(status_code=502, detail=str(e))
|
||||
# Members are optional display data — degrade gracefully
|
||||
logger.warning("list_members failed: %s", e)
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/member-mappings", response_model=list[PsaMemberMappingResponse])
|
||||
|
||||
373
backend/app/api/endpoints/l1.py
Normal file
373
backend/app/api/endpoints/l1.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""L1 Workspace endpoints (Phase 1).
|
||||
|
||||
PSA-merge queue support + AI build path are deferred to Phase 2.
|
||||
"""
|
||||
from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status as http_status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, require_engineer_or_admin, require_l1_or_coverage
|
||||
from app.models.l1_walk_session import L1WalkSession
|
||||
from app.models.user import User
|
||||
from app.schemas.l1 import (
|
||||
EscalateRequest,
|
||||
EscalateWithoutWalkRequest,
|
||||
IntakeRequest,
|
||||
IntakeResponse,
|
||||
NextNodeRequest,
|
||||
NextNodeResponse,
|
||||
NotesRequest,
|
||||
QueueRow,
|
||||
ResolveRequest,
|
||||
StepRequest,
|
||||
WalkSessionResponse,
|
||||
)
|
||||
from app.services import internal_ticket_service, l1_session_service, match_or_build
|
||||
|
||||
|
||||
router = APIRouter(prefix="/l1", tags=["l1"])
|
||||
|
||||
|
||||
def _to_response(session: L1WalkSession) -> WalkSessionResponse:
|
||||
return WalkSessionResponse(
|
||||
id=session.id,
|
||||
session_kind=session.session_kind,
|
||||
flow_id=session.flow_id,
|
||||
flow_proposal_id=session.flow_proposal_id,
|
||||
current_node_id=session.current_node_id,
|
||||
walked_path=session.walked_path or [],
|
||||
walk_notes=session.walk_notes or [],
|
||||
status=session.status,
|
||||
started_at=session.started_at,
|
||||
last_step_at=session.last_step_at,
|
||||
resolved_at=session.resolved_at,
|
||||
)
|
||||
|
||||
|
||||
async def _get_session_or_404(
|
||||
db: AsyncSession, session_id: UUID, user: User
|
||||
) -> L1WalkSession:
|
||||
"""Fetch a session by id, scoped to the caller's account.
|
||||
|
||||
Phase 1 policy (per spec §7.9): sessions are account-scoped, not
|
||||
user-scoped. Any L1 or coverage engineer in the same account can
|
||||
step/note/resolve/escalate any session — supports team coverage
|
||||
(e.g., L1 hands off mid-shift; coverage engineer takes over a call).
|
||||
For a stricter "creator-only" policy, add
|
||||
``created_by_user_id == user.id`` here.
|
||||
"""
|
||||
session = await db.get(L1WalkSession, session_id)
|
||||
if session is None or session.account_id != user.account_id:
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_404_NOT_FOUND,
|
||||
detail="Session not found",
|
||||
)
|
||||
return session
|
||||
|
||||
|
||||
@router.post("/intake", response_model=IntakeResponse)
|
||||
async def intake(
|
||||
payload: IntakeRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
"""L1 intake (Phase 2A): match a published flow, else gate + build.
|
||||
|
||||
Runs the match_or_build orchestrator. Outcomes:
|
||||
- matched → create ticket + flow session, walk the published flow.
|
||||
- build → create ticket + ai_build session (category persisted as a hidden
|
||||
meta entry on walked_path for /next-node), walk an AI-built tree.
|
||||
- suggest → near-miss prompt; no session created.
|
||||
- out_of_scope → category disabled/unknown; no session created.
|
||||
"""
|
||||
result = await match_or_build.match_or_build(
|
||||
user.account_id,
|
||||
payload.problem_statement,
|
||||
None,
|
||||
ticket_ref="",
|
||||
db=db,
|
||||
force_build=payload.force_build,
|
||||
)
|
||||
outcome = result["outcome"]
|
||||
|
||||
if outcome in ("suggest", "out_of_scope"):
|
||||
await db.commit()
|
||||
return IntakeResponse(
|
||||
outcome=outcome,
|
||||
near_miss=result.get("near_miss"),
|
||||
category=result.get("category"),
|
||||
)
|
||||
|
||||
# matched OR build → create a ticket and a session
|
||||
ticket = await internal_ticket_service.create_ticket(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
created_by_user_id=user.id,
|
||||
problem_statement=payload.problem_statement,
|
||||
customer_name=payload.customer_name,
|
||||
customer_contact=payload.customer_contact,
|
||||
)
|
||||
if outcome == "matched":
|
||||
session = await l1_session_service.start_flow_session(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
user=user,
|
||||
flow_id=UUID(result["flow_id"]),
|
||||
ticket_id=str(ticket.id),
|
||||
ticket_kind="internal",
|
||||
)
|
||||
else: # build
|
||||
session = await l1_session_service.start_ai_build_session(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
user=user,
|
||||
ticket_id=str(ticket.id),
|
||||
ticket_kind="internal",
|
||||
)
|
||||
# Persist the classified category as a hidden meta entry so /next-node
|
||||
# can recover it (no dedicated column; ai_tree_builder skips meta entries).
|
||||
session.walked_path = [
|
||||
{"node_type": "meta", "category": result.get("category", "unknown")}
|
||||
]
|
||||
await db.flush()
|
||||
|
||||
await db.commit()
|
||||
return IntakeResponse(
|
||||
outcome=outcome,
|
||||
session_id=session.id,
|
||||
session_kind=session.session_kind,
|
||||
ticket_id=str(ticket.id),
|
||||
ticket_kind="internal",
|
||||
flow_id=UUID(result["flow_id"]) if outcome == "matched" else None,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/queue", response_model=list[QueueRow])
|
||||
async def queue(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
status_filter: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
):
|
||||
"""Phase 1 queue: internal tickets only. PSA-fed rows in Phase 2."""
|
||||
tickets = await internal_ticket_service.list_tickets_for_account(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
status=status_filter,
|
||||
limit=limit,
|
||||
)
|
||||
return [
|
||||
QueueRow(
|
||||
ticket_id=str(t.id),
|
||||
ticket_kind="internal",
|
||||
problem_statement=t.problem_statement,
|
||||
customer_name=t.customer_name,
|
||||
status=t.status,
|
||||
created_at=t.created_at,
|
||||
)
|
||||
for t in tickets
|
||||
]
|
||||
|
||||
|
||||
@router.get("/sessions/active", response_model=list[WalkSessionResponse])
|
||||
async def list_active_sessions(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
"""The caller's currently-active sessions (for the dashboard 'Resume in progress' widget)."""
|
||||
stmt = (
|
||||
select(L1WalkSession)
|
||||
.where(L1WalkSession.created_by_user_id == user.id)
|
||||
.where(L1WalkSession.status == "active")
|
||||
.order_by(L1WalkSession.last_step_at.desc())
|
||||
.limit(20)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
return [_to_response(s) for s in result.scalars()]
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", response_model=WalkSessionResponse)
|
||||
async def get_session(
|
||||
session_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
session = await _get_session_or_404(db, session_id, user)
|
||||
return _to_response(session)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/step", response_model=WalkSessionResponse)
|
||||
async def post_step(
|
||||
session_id: UUID,
|
||||
payload: StepRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
await _get_session_or_404(db, session_id, user)
|
||||
try:
|
||||
updated = await l1_session_service.record_step(
|
||||
db,
|
||||
session_id=session_id,
|
||||
node_id=payload.node_id,
|
||||
question=payload.question,
|
||||
answer=payload.answer,
|
||||
note=payload.note,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
await db.commit()
|
||||
return _to_response(updated)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/notes", response_model=WalkSessionResponse)
|
||||
async def post_notes(
|
||||
session_id: UUID,
|
||||
payload: NotesRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
await _get_session_or_404(db, session_id, user)
|
||||
try:
|
||||
updated = await l1_session_service.update_notes(
|
||||
db,
|
||||
session_id=session_id,
|
||||
notes=payload.notes,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
await db.commit()
|
||||
return _to_response(updated)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/resolve", response_model=WalkSessionResponse)
|
||||
async def post_resolve(
|
||||
session_id: UUID,
|
||||
payload: ResolveRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
await _get_session_or_404(db, session_id, user)
|
||||
try:
|
||||
updated = await l1_session_service.resolve(
|
||||
db,
|
||||
session_id=session_id,
|
||||
helpful=payload.helpful,
|
||||
resolution_notes=payload.resolution_notes,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
await db.commit()
|
||||
return _to_response(updated)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/escalate", response_model=WalkSessionResponse)
|
||||
async def post_escalate(
|
||||
session_id: UUID,
|
||||
payload: EscalateRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
await _get_session_or_404(db, session_id, user)
|
||||
try:
|
||||
updated = await l1_session_service.escalate(
|
||||
db,
|
||||
session_id=session_id,
|
||||
reason=payload.reason or "",
|
||||
reason_category=payload.reason_category,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=http_status.HTTP_400_BAD_REQUEST, detail=str(exc))
|
||||
await db.commit()
|
||||
return _to_response(updated)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/next-node", response_model=NextNodeResponse)
|
||||
async def next_node(
|
||||
session_id: UUID,
|
||||
payload: NextNodeRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
"""Record the answer/ack on the current node, then generate the next node.
|
||||
|
||||
problem_text comes from the linked internal ticket; category from the hidden
|
||||
meta entry seeded at intake (ai_tree_builder skips meta entries). node_text is
|
||||
the rendered text of the node being answered (the client holds it) so the
|
||||
walked path and the captured tree stay legible.
|
||||
"""
|
||||
session = await _get_session_or_404(db, session_id, user)
|
||||
ticket = await internal_ticket_service.get_ticket(
|
||||
db, ticket_id=UUID(session.ticket_id)
|
||||
)
|
||||
problem_text = ticket.problem_statement if ticket else ""
|
||||
category = next(
|
||||
(s.get("category") for s in (session.walked_path or [])
|
||||
if s.get("node_type") == "meta"),
|
||||
"unknown",
|
||||
)
|
||||
try:
|
||||
node = await l1_session_service.advance_ai_build(
|
||||
db,
|
||||
session_id=session_id,
|
||||
problem_text=problem_text,
|
||||
category=category or "unknown",
|
||||
node_id=payload.node_id,
|
||||
node_text=payload.node_text,
|
||||
answer=payload.answer,
|
||||
note=payload.note,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=http_status.HTTP_409_CONFLICT, detail=str(exc)
|
||||
)
|
||||
await db.commit()
|
||||
return NextNodeResponse(node=node, session_status=session.status)
|
||||
|
||||
|
||||
@router.get("/escalations", response_model=list[WalkSessionResponse])
|
||||
async def l1_escalations(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
limit: int = 50,
|
||||
):
|
||||
"""Engineer-visible list of escalated L1 sessions (the handoff queue)."""
|
||||
rows = await db.execute(
|
||||
select(L1WalkSession)
|
||||
.where(
|
||||
L1WalkSession.account_id == user.account_id,
|
||||
L1WalkSession.status == "escalated",
|
||||
)
|
||||
.order_by(L1WalkSession.last_step_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return [_to_response(s) for s in rows.scalars()]
|
||||
|
||||
|
||||
@router.post("/escalate-without-walk", response_model=WalkSessionResponse)
|
||||
async def post_escalate_without_walk(
|
||||
payload: EscalateWithoutWalkRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
user: Annotated[User, Depends(require_l1_or_coverage)],
|
||||
):
|
||||
ticket = await internal_ticket_service.create_ticket(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
created_by_user_id=user.id,
|
||||
problem_statement=payload.problem_statement,
|
||||
customer_name=payload.customer_name,
|
||||
customer_contact=payload.customer_contact,
|
||||
)
|
||||
session = await l1_session_service.escalate_without_walk(
|
||||
db,
|
||||
account_id=user.account_id,
|
||||
user=user,
|
||||
ticket_id=str(ticket.id),
|
||||
ticket_kind="internal",
|
||||
reason_category=payload.reason_category,
|
||||
reason=payload.reason,
|
||||
)
|
||||
await db.commit()
|
||||
return _to_response(session)
|
||||
247
backend/app/api/endpoints/oauth.py
Normal file
247
backend/app/api/endpoints/oauth.py
Normal file
@@ -0,0 +1,247 @@
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.endpoints.auth import _mint_session_tokens
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.config import settings
|
||||
from app.models.account import Account
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
from app.models.user import User
|
||||
from app.schemas.oauth import OAuthCallbackPayload, OAuthCallbackResponse
|
||||
from app.services.billing import BillingService
|
||||
from app.services.oauth_providers import (
|
||||
google_exchange_code,
|
||||
microsoft_exchange_code,
|
||||
OAuthProfile,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth-oauth"])
|
||||
|
||||
|
||||
def _generate_display_code(length: int = 8) -> str:
|
||||
"""Match the helper used by /auth/register — A-Z + 0-9, length 8."""
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
async def _sign_in_or_register(
|
||||
db: AsyncSession,
|
||||
provider: str,
|
||||
profile: OAuthProfile,
|
||||
*,
|
||||
account_invite_code: str | None = None,
|
||||
invited_email: str | None = None,
|
||||
) -> tuple[User, bool]:
|
||||
"""Returns (user, is_new_user). Idempotent on (provider, provider_subject).
|
||||
|
||||
When ``account_invite_code`` is supplied (from the /accept-invite flow),
|
||||
a brand-new user is created inside the invited account instead of getting
|
||||
a personal account + Pro trial. Mismatch between the OAuth profile email
|
||||
and ``invited_email`` raises ``invite_email_mismatch`` per the spec
|
||||
contract that mirrors the email+password register path.
|
||||
"""
|
||||
identity = (
|
||||
await db.execute(
|
||||
select(OAuthIdentity).where(
|
||||
OAuthIdentity.provider == provider,
|
||||
OAuthIdentity.provider_subject == profile.provider_subject,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if identity:
|
||||
user = (
|
||||
await db.execute(select(User).where(User.id == identity.user_id))
|
||||
).scalar_one()
|
||||
return user, False
|
||||
|
||||
user = (
|
||||
await db.execute(select(User).where(User.email == profile.email))
|
||||
).scalar_one_or_none()
|
||||
is_new_user = user is None
|
||||
|
||||
# If the user arrived via an invite link but already has a ResolutionFlow
|
||||
# account (e.g., previously signed up with email+password), silently
|
||||
# linking the OAuth identity to that existing account would bypass the
|
||||
# invite — they'd stay in their personal account and the invite would
|
||||
# never be consumed. Fail loud instead so they can sign in and accept the
|
||||
# invite from the dashboard. The "invited user wants to transfer accounts"
|
||||
# case is a v2 concern.
|
||||
if account_invite_code and not is_new_user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "email_already_registered_use_login",
|
||||
"message": (
|
||||
"An account already exists for this email. Please sign in "
|
||||
"instead, then accept the invite from your dashboard."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
invite_record: AccountInvite | None = None
|
||||
if is_new_user and account_invite_code:
|
||||
# SELECT FOR UPDATE so two concurrent OAuth callbacks can't both
|
||||
# consume the same invite code.
|
||||
invite_record = (
|
||||
await db.execute(
|
||||
select(AccountInvite)
|
||||
.where(AccountInvite.code == account_invite_code)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if invite_record is None or not invite_record.is_valid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invite_invalid_or_expired_or_revoked"},
|
||||
)
|
||||
# Verify the OAuth profile email matches what was invited. We compare
|
||||
# against the invite row directly (source of truth), but also accept
|
||||
# the client-supplied invited_email as a defensive equality check.
|
||||
if invite_record.email.lower() != profile.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invite_email_mismatch"},
|
||||
)
|
||||
if invited_email and invited_email.lower() != invite_record.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invite_email_mismatch"},
|
||||
)
|
||||
|
||||
if is_new_user:
|
||||
if invite_record is not None:
|
||||
# Seat enforcement: re-check at OAuth accept time (race-condition guard).
|
||||
if invite_record.role in ("engineer", "l1_tech"):
|
||||
from app.core.subscriptions import get_account_subscription
|
||||
from app.services.seat_enforcement import check_seat_available
|
||||
sub = await get_account_subscription(invite_record.account_id, db)
|
||||
if sub is not None:
|
||||
acct_result = await db.execute(
|
||||
select(Account).where(Account.id == invite_record.account_id)
|
||||
)
|
||||
acct = acct_result.scalar_one()
|
||||
seat_result = await check_seat_available(acct, sub, invite_record.role, db)
|
||||
if not seat_result.available:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
"code": "seat_limit_exceeded",
|
||||
"role": seat_result.role,
|
||||
"current": seat_result.current,
|
||||
"limit": seat_result.limit,
|
||||
"upgrade_url": "/account/billing",
|
||||
},
|
||||
)
|
||||
|
||||
# Join the invited account directly — no personal account, no
|
||||
# trial creation.
|
||||
user = User(
|
||||
email=profile.email,
|
||||
name=profile.name,
|
||||
password_hash=None,
|
||||
account_id=invite_record.account_id,
|
||||
account_role=invite_record.role,
|
||||
role="engineer",
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
invite_record.accepted_by_id = user.id
|
||||
invite_record.used_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
else:
|
||||
account = Account(
|
||||
name=f"{profile.name}'s Account",
|
||||
display_code=_generate_display_code(),
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email=profile.email,
|
||||
name=profile.name,
|
||||
password_hash=None,
|
||||
account_id=account.id,
|
||||
account_role="owner",
|
||||
role="engineer",
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
account.owner_id = user.id
|
||||
await db.flush()
|
||||
# start_trial commits internally; flushed account/user above.
|
||||
await BillingService.start_trial(db, account.id)
|
||||
|
||||
db.add(
|
||||
OAuthIdentity(
|
||||
user_id=user.id,
|
||||
provider=provider,
|
||||
provider_subject=profile.provider_subject,
|
||||
provider_email_at_link=profile.email,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user, is_new_user
|
||||
|
||||
|
||||
@router.post("/google/callback", response_model=OAuthCallbackResponse)
|
||||
async def google_callback(
|
||||
payload: OAuthCallbackPayload,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> OAuthCallbackResponse:
|
||||
if not settings.GOOGLE_CLIENT_ID:
|
||||
raise HTTPException(status_code=503, detail="Google sign-in not configured")
|
||||
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/google/callback"
|
||||
profile = await google_exchange_code(payload.code, redirect_uri)
|
||||
user, is_new = await _sign_in_or_register(
|
||||
db,
|
||||
"google",
|
||||
profile,
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
token = await _mint_session_tokens(user, db)
|
||||
await db.commit()
|
||||
return OAuthCallbackResponse(
|
||||
access_token=token.access_token,
|
||||
refresh_token=token.refresh_token,
|
||||
is_new_user=is_new,
|
||||
idle_expires_at=token.idle_expires_at,
|
||||
absolute_expires_at=token.absolute_expires_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/microsoft/callback", response_model=OAuthCallbackResponse)
|
||||
async def microsoft_callback(
|
||||
payload: OAuthCallbackPayload,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> OAuthCallbackResponse:
|
||||
if not settings.MS_CLIENT_ID:
|
||||
raise HTTPException(status_code=503, detail="Microsoft sign-in not configured")
|
||||
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/microsoft/callback"
|
||||
profile = await microsoft_exchange_code(payload.code, redirect_uri)
|
||||
user, is_new = await _sign_in_or_register(
|
||||
db,
|
||||
"microsoft",
|
||||
profile,
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
token = await _mint_session_tokens(user, db)
|
||||
await db.commit()
|
||||
return OAuthCallbackResponse(
|
||||
access_token=token.access_token,
|
||||
refresh_token=token.refresh_token,
|
||||
is_new_user=is_new,
|
||||
idle_expires_at=token.idle_expires_at,
|
||||
absolute_expires_at=token.absolute_expires_at,
|
||||
)
|
||||
@@ -2,19 +2,24 @@
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.database import get_db
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.models.account import Account
|
||||
from app.models.assistant_chat import AssistantChat
|
||||
from app.models.psa_connection import PsaConnection
|
||||
from app.models.session import Session
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.schemas.onboarding import OnboardingStatus
|
||||
from app.schemas.onboarding import (
|
||||
OnboardingStatus,
|
||||
OnboardingStepRequest,
|
||||
OnboardingStepResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["onboarding"])
|
||||
|
||||
@@ -85,6 +90,10 @@ async def get_onboarding_status(
|
||||
)
|
||||
connected_psa = (psa_q.scalar() or 0) > 0
|
||||
|
||||
# New (Phase 2 — Task 41)
|
||||
email_verified = current_user.email_verified_at is not None
|
||||
shop_setup_done = (current_user.onboarding_step_completed or 0) >= 1
|
||||
|
||||
return OnboardingStatus(
|
||||
created_flow=created_flow,
|
||||
ran_session=ran_session,
|
||||
@@ -94,6 +103,8 @@ async def get_onboarding_status(
|
||||
connected_psa=connected_psa,
|
||||
is_team_user=is_team_user,
|
||||
dismissed=current_user.onboarding_dismissed,
|
||||
email_verified=email_verified,
|
||||
shop_setup_done=shop_setup_done,
|
||||
)
|
||||
|
||||
|
||||
@@ -109,3 +120,98 @@ async def dismiss_onboarding(
|
||||
|
||||
# Return updated status (reuse the GET logic)
|
||||
return await get_onboarding_status(db=db, current_user=current_user)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Welcome wizard endpoints (Phase 2)
|
||||
#
|
||||
# These persist Step 1/2/3 progress for the post-signup welcome wizard.
|
||||
# Mounted on /users/me/* (the parent router prefix is /users) so the wizard
|
||||
# can run before email verification and during trial.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/me/onboarding-step", response_model=OnboardingStepResponse)
|
||||
async def patch_onboarding_step(
|
||||
body: OnboardingStepRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> OnboardingStepResponse:
|
||||
"""Persist welcome-wizard progress for the current user.
|
||||
|
||||
Contract:
|
||||
- step=1 + complete writes accounts.name, accounts.team_size_bucket,
|
||||
users.role_at_signup, then sets users.onboarding_step_completed=1.
|
||||
- step=2 + complete writes accounts.primary_psa, then sets
|
||||
users.onboarding_step_completed=2.
|
||||
- step=3 + complete just sets users.onboarding_step_completed=3
|
||||
(invites are POSTed separately).
|
||||
- action="skip" ignores `data` entirely and only advances the step.
|
||||
- The new step must be >= current onboarding_step_completed (None=>0);
|
||||
otherwise 400. Idempotent re-PATCH of the same step succeeds.
|
||||
"""
|
||||
current_step = current_user.onboarding_step_completed or 0
|
||||
if body.step < current_step:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": "step_cannot_decrease",
|
||||
"current_step": current_step,
|
||||
"requested_step": body.step,
|
||||
},
|
||||
)
|
||||
|
||||
if body.action == "complete" and body.data is not None and body.step in (1, 2):
|
||||
# Load the user's account for field writes. Step 3 has no data writes.
|
||||
account_result = await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)
|
||||
account = account_result.scalar_one_or_none()
|
||||
if account is None:
|
||||
# Should never happen — user is required to have an account_id.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="account_not_found",
|
||||
)
|
||||
|
||||
if body.step == 1:
|
||||
data = body.data
|
||||
if data.company_name is not None:
|
||||
account.name = data.company_name
|
||||
if data.team_size_bucket is not None:
|
||||
account.team_size_bucket = data.team_size_bucket
|
||||
if data.role_at_signup is not None:
|
||||
current_user.role_at_signup = data.role_at_signup
|
||||
elif body.step == 2:
|
||||
data = body.data
|
||||
if data.primary_psa is not None:
|
||||
account.primary_psa = data.primary_psa
|
||||
|
||||
current_user.onboarding_step_completed = body.step
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return OnboardingStepResponse(
|
||||
onboarding_step_completed=current_user.onboarding_step_completed,
|
||||
onboarding_dismissed=current_user.onboarding_dismissed,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/me/onboarding-dismiss-rest", response_model=OnboardingStepResponse)
|
||||
async def dismiss_onboarding_rest(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> OnboardingStepResponse:
|
||||
"""Set users.onboarding_dismissed=TRUE — backs the wizard's "Skip the rest" button.
|
||||
|
||||
Returns the same shape as the step PATCH so the frontend can update its
|
||||
local store from a single response.
|
||||
"""
|
||||
current_user.onboarding_dismissed = True
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return OnboardingStepResponse(
|
||||
onboarding_step_completed=current_user.onboarding_step_completed,
|
||||
onboarding_dismissed=current_user.onboarding_dismissed,
|
||||
)
|
||||
|
||||
58
backend/app/api/endpoints/plans_public.py
Normal file
58
backend/app/api/endpoints/plans_public.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Public plans endpoint — no auth required.
|
||||
|
||||
GET /api/v1/plans/public
|
||||
Returns the public-safe view of `plan_billing` joined with
|
||||
`plan_limits.max_users` (exposed as `max_seats`), filtered to
|
||||
`is_public=True AND is_archived=False`, ordered by sort_order ASC, plan ASC.
|
||||
|
||||
Distinct from `/admin/plan-limits` (admin-only, returns ALL plans including
|
||||
archived/internal). This endpoint exists to power the marketing /pricing page
|
||||
without exposing the rest of the admin-only billing surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.models.plan_billing import PlanBilling
|
||||
from app.models.plan_limits import PlanLimits
|
||||
from app.schemas.billing import PublicPlanResponse
|
||||
|
||||
router = APIRouter(prefix="/plans", tags=["plans"])
|
||||
|
||||
|
||||
@router.get("/public", response_model=list[PublicPlanResponse])
|
||||
async def list_public_plans(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> list[PublicPlanResponse]:
|
||||
"""List public, non-archived plans for the marketing /pricing page.
|
||||
|
||||
Public — no auth. Uses `get_admin_db` because this is a cross-tenant read
|
||||
of the global plan catalog (same pattern as `/config/public`).
|
||||
"""
|
||||
stmt = (
|
||||
select(PlanBilling, PlanLimits.max_users)
|
||||
.outerjoin(PlanLimits, PlanBilling.plan == PlanLimits.plan)
|
||||
.where(PlanBilling.is_public.is_(True))
|
||||
.where(PlanBilling.is_archived.is_(False))
|
||||
.order_by(PlanBilling.sort_order.asc(), PlanBilling.plan.asc())
|
||||
)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
return [
|
||||
PublicPlanResponse(
|
||||
plan=billing.plan,
|
||||
display_name=billing.display_name,
|
||||
description=billing.description,
|
||||
monthly_price_cents=billing.monthly_price_cents,
|
||||
annual_price_cents=billing.annual_price_cents,
|
||||
max_seats=max_users,
|
||||
sort_order=billing.sort_order,
|
||||
is_public=billing.is_public,
|
||||
)
|
||||
for billing, max_users in rows
|
||||
]
|
||||
114
backend/app/api/endpoints/sales_leads.py
Normal file
114
backend/app/api/endpoints/sales_leads.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Public Talk-to-Sales endpoint — no auth required.
|
||||
|
||||
POST /api/v1/sales-leads
|
||||
- Inserts a sales_leads row.
|
||||
- Fires (best-effort) a notification email to settings.SALES_LEAD_RECIPIENT_EMAIL.
|
||||
- Emits a server-side PostHog event (best-effort).
|
||||
- Rate-limited per IP (5/hour).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.config import settings
|
||||
from app.core.email import EmailService
|
||||
from app.core.rate_limit import limiter
|
||||
from app.models.sales_lead import SalesLead
|
||||
from app.schemas.sales_lead import SalesLeadCreate, SalesLeadCreateResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/sales-leads", tags=["sales"])
|
||||
|
||||
|
||||
async def _send_notification_email(lead: SalesLead) -> None:
|
||||
"""Fire-and-forget wrapper. EmailService methods never raise, but we
|
||||
still wrap in a try/except to defend against future regressions."""
|
||||
try:
|
||||
await EmailService.send_sales_lead_notification(
|
||||
to_email=settings.SALES_LEAD_RECIPIENT_EMAIL,
|
||||
lead=lead,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Sales lead notification email failed for lead %s",
|
||||
lead.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _capture_posthog_event(lead: SalesLead) -> None:
|
||||
"""Emit `talk_to_sales_form_submitted` server-side. Best-effort.
|
||||
|
||||
Backend PostHog SDK isn't initialized in the project today; this function
|
||||
is the single instrumentation point so wiring it up later is a one-line
|
||||
change. The call is wrapped so any future failure can never fail the
|
||||
request.
|
||||
"""
|
||||
try:
|
||||
# Lazy import — keeps the dependency optional. When the backend
|
||||
# PostHog client is wired in (likely as `app.core.analytics.posthog`),
|
||||
# swap the import path here and the event will fire automatically.
|
||||
try:
|
||||
from app.core.analytics import posthog # type: ignore[attr-defined]
|
||||
except ImportError:
|
||||
logger.debug(
|
||||
"PostHog server-side capture skipped — client not configured"
|
||||
)
|
||||
return
|
||||
|
||||
distinct_id = lead.posthog_distinct_id or f"sales_lead:{lead.id}"
|
||||
posthog.capture(
|
||||
distinct_id=distinct_id,
|
||||
event="talk_to_sales_form_submitted",
|
||||
properties={
|
||||
"source": lead.source,
|
||||
"company": lead.company,
|
||||
"team_size": lead.team_size,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"PostHog capture failed for sales lead %s",
|
||||
lead.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=SalesLeadCreateResponse, status_code=201)
|
||||
@limiter.limit("5/hour")
|
||||
async def create_sales_lead(
|
||||
request: Request,
|
||||
data: SalesLeadCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> SalesLeadCreateResponse:
|
||||
"""Public Talk-to-Sales submission.
|
||||
|
||||
Creates a sales_leads row, fires (best-effort) a notification email and a
|
||||
server-side PostHog event. Rate-limited per IP at 5/hour.
|
||||
"""
|
||||
lead = SalesLead(
|
||||
email=str(data.email).lower(),
|
||||
name=data.name,
|
||||
company=data.company,
|
||||
team_size=data.team_size,
|
||||
message=data.message,
|
||||
source=data.source,
|
||||
posthog_distinct_id=data.posthog_distinct_id,
|
||||
)
|
||||
db.add(lead)
|
||||
await db.commit()
|
||||
await db.refresh(lead)
|
||||
|
||||
# Fire-and-forget: email + analytics. Failures must not fail the request.
|
||||
asyncio.create_task(_send_notification_email(lead))
|
||||
_capture_posthog_event(lead)
|
||||
|
||||
return SalesLeadCreateResponse(id=lead.id, status="received")
|
||||
@@ -260,6 +260,7 @@ async def save_to_library(
|
||||
category_id=data.category_id,
|
||||
share_with_team=data.share_with_team,
|
||||
user_id=current_user.id,
|
||||
account_id=current_user.account_id,
|
||||
team_id=current_user.team_id,
|
||||
script_body=data.script_body,
|
||||
parameters_schema=data.parameters_schema,
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
"""Handoff endpoints — unified park/escalate.
|
||||
|
||||
POST /ai-sessions/{id}/handoff — Create handoff
|
||||
POST /ai-sessions/{id}/handoff — Create handoff
|
||||
GET /ai-sessions/{id}/handoffs — Handoff history
|
||||
POST /ai-sessions/{id}/handoffs/{hid}/claim — Claim session
|
||||
GET /ai-sessions/queue — Team queue
|
||||
GET /ai-sessions/queue — Team queue
|
||||
GET /ai-sessions/escalations/stream — SSE: live escalation arrivals
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from typing import Annotated, AsyncGenerator
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db
|
||||
from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin
|
||||
from app.core.escalation_bus import bus as escalation_bus
|
||||
from app.models.user import User
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_handoff import SessionHandoff
|
||||
from app.services.handoff_manager import HandoffManager
|
||||
from app.services.handoff_manager import HandoffAlreadyClaimedError, HandoffManager
|
||||
from app.schemas.session_handoff import (
|
||||
HandoffCreateRequest,
|
||||
HandoffResponse,
|
||||
@@ -36,6 +41,7 @@ router = APIRouter(prefix="/ai-sessions/{session_id}", tags=["session-handoffs"]
|
||||
async def create_handoff(
|
||||
session_id: UUID,
|
||||
body: HandoffCreateRequest,
|
||||
background_tasks: BackgroundTasks,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> HandoffResponse:
|
||||
@@ -58,12 +64,35 @@ async def create_handoff(
|
||||
engineer_notes=body.engineer_notes,
|
||||
user_id=current_user.id,
|
||||
priority=body.priority,
|
||||
target_user_id=body.target_user_id,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
# For escalate: generate documentation + push to PSA before commit so
|
||||
# the handoff and the PSA-state changes land atomically.
|
||||
if handoff.intent == "escalate":
|
||||
await manager.finalize_escalation(handoff, session, current_user.id)
|
||||
|
||||
await db.commit()
|
||||
return HandoffResponse.model_validate(handoff)
|
||||
|
||||
# Best-effort notification dispatch AFTER commit so we never email about
|
||||
# a rolled-back handoff. Failures are swallowed inside the manager —
|
||||
# handoff creation is authoritative; notifications are advisory.
|
||||
if handoff.intent == "escalate":
|
||||
from app.services.handoff_manager import enrich_escalation_async
|
||||
|
||||
await manager.dispatch_escalation_notifications(handoff)
|
||||
# AI enrichment (Sonnet assessment + enhanced escalation_package)
|
||||
# runs in the background after the response is sent so the
|
||||
# escalating engineer doesn't wait on 15-25s of model latency.
|
||||
background_tasks.add_task(
|
||||
enrich_escalation_async, handoff.id, current_user.id
|
||||
)
|
||||
|
||||
return HandoffResponse.model_validate(handoff).model_copy(
|
||||
update={"handed_off_by_name": current_user.name}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/handoffs", response_model=list[HandoffResponse])
|
||||
@@ -86,21 +115,49 @@ async def list_handoffs(
|
||||
async def claim_handoff(
|
||||
session_id: UUID,
|
||||
handoff_id: UUID,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
current_user: Annotated[User, Depends(require_engineer_or_admin)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
) -> HandoffResponse:
|
||||
"""Claim a handed-off session."""
|
||||
"""Claim a handed-off session.
|
||||
|
||||
Role-gated to engineer/admin/owner — viewers cannot claim. The race-condition
|
||||
story (two seniors clicking Pick Up simultaneously) depends on auth gating
|
||||
for audit integrity. Codex review flagged this as wedge-relevant; locked
|
||||
in-scope for Escalation Mode v1.
|
||||
"""
|
||||
manager = HandoffManager(db)
|
||||
try:
|
||||
handoff = await manager.claim_session(
|
||||
handoff_id=handoff_id,
|
||||
claiming_user_id=current_user.id,
|
||||
)
|
||||
except HandoffAlreadyClaimedError as e:
|
||||
# Loser of the race — the API surfaces structured detail so the
|
||||
# client can render "Already claimed by {name} {time_ago}" without
|
||||
# a follow-up fetch.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
"error": "already_claimed",
|
||||
"claimed_by_id": str(e.claimed_by_id),
|
||||
"claimed_by_name": e.claimed_by_name,
|
||||
"claimed_at": e.claimed_at.isoformat(),
|
||||
},
|
||||
)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
await db.commit()
|
||||
return HandoffResponse.model_validate(handoff)
|
||||
handed_off_by_name = (
|
||||
handoff.handed_off_by_user.name
|
||||
if handoff.handed_off_by_user
|
||||
else None
|
||||
)
|
||||
return HandoffResponse.model_validate(handoff).model_copy(
|
||||
update={"handed_off_by_name": handed_off_by_name}
|
||||
)
|
||||
|
||||
|
||||
@queue_router.get("/queue")
|
||||
@@ -114,3 +171,83 @@ async def get_queue(
|
||||
team_id=current_user.team_id,
|
||||
account_id=current_user.account_id,
|
||||
)
|
||||
|
||||
|
||||
# ─── Live escalation arrivals (SSE) ──────────────────────────────────────────
|
||||
#
|
||||
# Streams `handoff_created` events to subscribers in the same account_id as
|
||||
# the new handoff. Connected EscalationQueue instances prepend the new card
|
||||
# with the locked 200ms slide-in. Account-scoped: cross-tenant leakage is
|
||||
# prevented at the bus.publish boundary (only handoff.account_id subscribers
|
||||
# are notified) and re-enforced here by binding the subscription to
|
||||
# current_user.account_id.
|
||||
#
|
||||
# Heartbeat: a `: keepalive\n\n` SSE comment every 25s keeps the connection
|
||||
# alive through Railway / nginx default 60s idle timeouts. Reconnect policy
|
||||
# is on the client (browser EventSource auto-reconnects; our fetch-based
|
||||
# reader retries with backoff).
|
||||
|
||||
|
||||
_HEARTBEAT_INTERVAL_S = 25
|
||||
_QUEUE_GET_TIMEOUT_S = 25 # < heartbeat so heartbeat fires reliably
|
||||
|
||||
|
||||
@queue_router.get("/escalations/stream")
|
||||
async def stream_escalations(
|
||||
request: Request,
|
||||
current_user: Annotated[
|
||||
User,
|
||||
Depends(require_engineer_or_admin, scope="function"),
|
||||
],
|
||||
):
|
||||
"""SSE stream of new escalation arrivals for the current user's account.
|
||||
|
||||
Role-gated to engineer/admin/owner so viewers can't subscribe (matches
|
||||
the queue + claim role surface). One open connection per browser tab is
|
||||
expected; the bus handles fan-out.
|
||||
"""
|
||||
if not current_user.account_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="No account"
|
||||
)
|
||||
|
||||
account_id = current_user.account_id
|
||||
|
||||
async def event_generator() -> AsyncGenerator[str, None]:
|
||||
queue = await escalation_bus.subscribe(account_id)
|
||||
try:
|
||||
# Initial hello so the client knows the stream is live.
|
||||
yield (
|
||||
"event: ready\n"
|
||||
f"data: {json.dumps({'account_id': str(account_id)})}\n\n"
|
||||
)
|
||||
|
||||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
try:
|
||||
event = await asyncio.wait_for(
|
||||
queue.get(), timeout=_QUEUE_GET_TIMEOUT_S
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# Heartbeat keeps the connection alive through proxies.
|
||||
yield ": keepalive\n\n"
|
||||
continue
|
||||
|
||||
event_type = event.get("type", "message")
|
||||
yield (
|
||||
f"event: {event_type}\n"
|
||||
f"data: {json.dumps(event)}\n\n"
|
||||
)
|
||||
finally:
|
||||
await escalation_bus.unsubscribe(account_id, queue)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -318,6 +318,11 @@ async def patch_suggested_fix_outcome(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="notes are required when outcome is applied_partial",
|
||||
)
|
||||
if body.outcome == "applied_pending" and not (body.notes and body.notes.strip()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="notes are required when outcome is applied_pending",
|
||||
)
|
||||
|
||||
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
|
||||
if fix.status in TERMINAL:
|
||||
@@ -329,6 +334,10 @@ async def patch_suggested_fix_outcome(
|
||||
fix.status = body.outcome
|
||||
if body.outcome == "applied_partial":
|
||||
fix.partial_notes = (body.notes or "").strip() or None
|
||||
elif body.outcome == "applied_pending":
|
||||
# Pending is parked, not terminal — keep applied_at, do NOT stamp
|
||||
# verified_at. Reason explains what the engineer is waiting on.
|
||||
fix.pending_reason = (body.notes or "").strip() or None
|
||||
elif body.outcome == "applied_failed":
|
||||
fix.failure_reason = (body.notes or "").strip() or None
|
||||
fix.verified_at = now
|
||||
|
||||
@@ -20,6 +20,7 @@ from app.core.audit import log_audit
|
||||
from app.core.rate_limit import limiter
|
||||
|
||||
router = APIRouter(tags=["shares"])
|
||||
public_router = APIRouter(tags=["shares"])
|
||||
|
||||
|
||||
def build_share_response(share: SessionShare) -> ShareResponse:
|
||||
@@ -206,7 +207,7 @@ async def _get_optional_user(request: Request, db: AsyncSession) -> Optional[Use
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/share/{share_token}", response_model=SharePublicView)
|
||||
@public_router.get("/share/{share_token}", response_model=SharePublicView)
|
||||
@limiter.limit("30/minute")
|
||||
async def access_share(
|
||||
share_token: str,
|
||||
|
||||
@@ -161,7 +161,7 @@ async def get_sidebar_stats(
|
||||
select(func.count()).where(
|
||||
and_(
|
||||
esc_scope,
|
||||
AISession.status == "requesting_escalation",
|
||||
AISession.status.in_(("requesting_escalation", "escalated")),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
from fastapi import APIRouter, Request, HTTPException, status, Depends
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.config import settings
|
||||
from app.core.stripe_handlers import WEBHOOK_HANDLERS
|
||||
from app.services.billing import BillingService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,49 +14,36 @@ router = APIRouter(prefix="/webhooks", tags=["webhooks"])
|
||||
@router.post("/stripe")
|
||||
async def stripe_webhook(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
db: AsyncSession = Depends(get_admin_db),
|
||||
):
|
||||
"""Handle Stripe webhook events.
|
||||
"""Stripe webhook handler. Public endpoint; signature verification is the
|
||||
only gate. Idempotency via stripe_events table.
|
||||
|
||||
Returns 200 for all events to prevent Stripe retries.
|
||||
Actual processing happens only when Stripe is configured.
|
||||
Returns 200 even when Stripe is not configured — keeps the receiver
|
||||
permissive for local dev.
|
||||
"""
|
||||
if not settings.stripe_enabled:
|
||||
if not settings.stripe_enabled or not settings.STRIPE_WEBHOOK_SECRET:
|
||||
return {"status": "ok", "message": "Stripe not configured, event ignored"}
|
||||
|
||||
payload = await request.body()
|
||||
sig_header = request.headers.get("stripe-signature")
|
||||
|
||||
if not sig_header:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Missing stripe-signature header"
|
||||
)
|
||||
raise HTTPException(status_code=400, detail="Missing stripe-signature header")
|
||||
|
||||
# Verify webhook signature
|
||||
try:
|
||||
import stripe
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning("stripe package not installed, cannot verify webhook")
|
||||
return {"status": "ok", "message": "stripe package not installed"}
|
||||
except Exception as e:
|
||||
logger.error("Stripe webhook signature verification failed: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invalid signature"
|
||||
)
|
||||
logger.warning("stripe webhook bad signature: %s", e)
|
||||
raise HTTPException(status_code=400, detail="Invalid signature")
|
||||
|
||||
event_type = event.get("type", "")
|
||||
handler = WEBHOOK_HANDLERS.get(event_type)
|
||||
|
||||
if handler:
|
||||
try:
|
||||
await handler(event, db)
|
||||
except Exception:
|
||||
logger.exception("Error handling Stripe event %s", event_type)
|
||||
|
||||
return {"status": "ok"}
|
||||
applied = await BillingService.apply_subscription_event(
|
||||
db,
|
||||
event_id=event["id"],
|
||||
event_type=event["type"],
|
||||
payload={"data": event["data"]},
|
||||
)
|
||||
return {"status": "ok", "applied": applied}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.deps import require_tenant_context
|
||||
from app.api.deps import (
|
||||
require_tenant_context,
|
||||
require_active_subscription,
|
||||
require_verified_email_after_grace,
|
||||
)
|
||||
from app.api.endpoints import (
|
||||
admin,
|
||||
admin_audit,
|
||||
l1,
|
||||
admin_categories,
|
||||
admin_dashboard,
|
||||
admin_feature_flags,
|
||||
@@ -19,10 +24,13 @@ from app.api.endpoints import (
|
||||
analytics,
|
||||
assistant_chat,
|
||||
auth,
|
||||
billing,
|
||||
beta_feedback,
|
||||
beta_signup,
|
||||
sales_leads,
|
||||
branding,
|
||||
categories,
|
||||
config as config_endpoints,
|
||||
copilot,
|
||||
device_types,
|
||||
draft_templates,
|
||||
@@ -36,7 +44,9 @@ from app.api.endpoints import (
|
||||
maintenance_schedules,
|
||||
network_diagrams,
|
||||
notifications,
|
||||
oauth as oauth_endpoints,
|
||||
onboarding,
|
||||
plans_public,
|
||||
public_templates,
|
||||
ratings,
|
||||
scripts,
|
||||
@@ -62,6 +72,8 @@ from app.api.endpoints import (
|
||||
uploads,
|
||||
webhooks,
|
||||
accounts,
|
||||
account_invite_lookup,
|
||||
account_security,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -77,10 +89,18 @@ api_router = APIRouter()
|
||||
# in Phase 1. This will need revisiting in Phase 2 when `users` gets RLS.
|
||||
# ---------------------------------------------------------------------------
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(oauth_endpoints.router)
|
||||
api_router.include_router(billing.router) # Reachable when subscription locked
|
||||
api_router.include_router(shared.router) # Public share links (no auth)
|
||||
api_router.include_router(shares.public_router) # Public session share links (optional auth)
|
||||
api_router.include_router(beta_signup.router)
|
||||
api_router.include_router(sales_leads.router) # Talk-to-Sales (no auth, rate-limited)
|
||||
api_router.include_router(webhooks.router) # Stripe webhook receiver
|
||||
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited)
|
||||
api_router.include_router(survey.router) # Public survey flow (no auth, rate-limited)
|
||||
api_router.include_router(config_endpoints.router) # Public runtime feature flags
|
||||
api_router.include_router(account_invite_lookup.router) # Public invite-code lookup for /accept-invite
|
||||
api_router.include_router(plans_public.router) # Public plan catalog for /pricing page
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin endpoints — super_admin only
|
||||
@@ -100,23 +120,37 @@ api_router.include_router(admin_survey.router)
|
||||
api_router.include_router(admin_gallery.router)
|
||||
# ---------------------------------------------------------------------------
|
||||
# User-facing endpoints — tenant context required
|
||||
#
|
||||
# _tenant_deps: routers that only require an authenticated user inside a
|
||||
# tenant (auth/account/admin/non-Pro feature surfaces).
|
||||
# _pro_deps: routers gated behind an active Pro subscription. Adds
|
||||
# require_active_subscription which raises 402 unless the
|
||||
# account's Subscription is active/complimentary/past_due or
|
||||
# trialing-with-time-remaining. Allowlisted paths in deps.py
|
||||
# bypass the gate for billing/account admin/auth flows.
|
||||
# ---------------------------------------------------------------------------
|
||||
_tenant_deps = [Depends(require_tenant_context)]
|
||||
_pro_deps = [
|
||||
Depends(require_tenant_context),
|
||||
Depends(require_active_subscription),
|
||||
Depends(require_verified_email_after_grace),
|
||||
]
|
||||
|
||||
api_router.include_router(trees.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(trees.router, dependencies=_pro_deps)
|
||||
api_router.include_router(sidebar.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(sessions.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(sessions.router, dependencies=_pro_deps)
|
||||
api_router.include_router(invite.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(categories.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(tags.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(folders.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(step_categories.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(steps.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(step_categories.router, dependencies=_pro_deps)
|
||||
api_router.include_router(steps.router, dependencies=_pro_deps)
|
||||
api_router.include_router(accounts.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(account_security.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(shares.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(tree_markdown.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ratings.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(analytics.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(analytics.router, dependencies=_pro_deps)
|
||||
api_router.include_router(target_lists.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(maintenance_schedules.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(feedback.router, dependencies=_tenant_deps)
|
||||
@@ -124,32 +158,34 @@ api_router.include_router(ai_builder.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_fix.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_chat.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(copilot.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(assistant_chat.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(survey.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(assistant_chat.router, dependencies=_pro_deps)
|
||||
api_router.include_router(tree_transfer.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_suggestions.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(kb_accelerator.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(scripts.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(integrations.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(scripts.router, dependencies=_pro_deps)
|
||||
api_router.include_router(integrations.router, dependencies=_pro_deps)
|
||||
api_router.include_router(onboarding.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(branding.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(supporting_data.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(network_diagrams.router, dependencies=_tenant_deps)
|
||||
# session_handoffs queue router must come before ai_sessions to avoid conflict
|
||||
api_router.include_router(session_handoffs.queue_router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_resolutions.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_handoffs.queue_router, dependencies=_pro_deps)
|
||||
api_router.include_router(session_resolutions.router, dependencies=_pro_deps)
|
||||
# session_facts mounts under /ai-sessions/{id}/facts — register before ai_sessions
|
||||
# so the {session_id}/facts subpaths take precedence over any future generic catchalls.
|
||||
api_router.include_router(session_facts.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_suggested_fixes.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_facts.router, dependencies=_pro_deps)
|
||||
api_router.include_router(session_suggested_fixes.router, dependencies=_pro_deps)
|
||||
api_router.include_router(draft_templates.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_sessions.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(flow_proposals.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(flowpilot_analytics.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(ai_sessions.router, dependencies=_pro_deps)
|
||||
api_router.include_router(flow_proposals.router, dependencies=_pro_deps)
|
||||
api_router.include_router(flowpilot_analytics.router, dependencies=_pro_deps)
|
||||
api_router.include_router(notifications.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(uploads.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(script_builder.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(script_builder.router, dependencies=_pro_deps)
|
||||
api_router.include_router(beta_feedback.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_branches.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_handoffs.router, dependencies=_tenant_deps)
|
||||
api_router.include_router(session_branches.router, dependencies=_pro_deps)
|
||||
api_router.include_router(session_handoffs.router, dependencies=_pro_deps)
|
||||
api_router.include_router(device_types.router, dependencies=_tenant_deps)
|
||||
# L1 is a separate seat-counted SKU; subscription gating is enforced by
|
||||
# seat_enforcement (engineer + l1_seat_limit), not require_active_subscription.
|
||||
api_router.include_router(l1.router, dependencies=_tenant_deps)
|
||||
|
||||
@@ -147,6 +147,40 @@ def build_anthropic_chat_messages(
|
||||
return messages
|
||||
|
||||
|
||||
def _extract_text_from_response(response: Any, model: str) -> str:
|
||||
"""Return the first text block's text from an Anthropic message response.
|
||||
|
||||
Robustness over the naive ``response.content[0].text``:
|
||||
- Skips non-text leading blocks (e.g. ``thinking``) and returns the first
|
||||
block whose ``type == "text"``. Indexing ``content[0]`` blindly throws or
|
||||
returns garbage the moment a non-text block leads the response.
|
||||
- Surfaces truncation/refusal: when ``stop_reason`` is ``max_tokens`` or
|
||||
``refusal``, emits a structured warning so silent output corruption
|
||||
(truncated JSON, empty refusals) is observable rather than handed
|
||||
downstream to be guessed at.
|
||||
- Raises ``ValueError`` when no text block is present (e.g. a bare refusal)
|
||||
instead of returning a non-text block's attributes.
|
||||
"""
|
||||
stop_reason = getattr(response, "stop_reason", None)
|
||||
if stop_reason in ("max_tokens", "refusal"):
|
||||
logger.warning(
|
||||
"anthropic.stop_reason",
|
||||
extra={
|
||||
"event": "anthropic.stop_reason",
|
||||
"model": model,
|
||||
"stop_reason": stop_reason,
|
||||
},
|
||||
)
|
||||
|
||||
for block in response.content:
|
||||
if getattr(block, "type", None) == "text":
|
||||
return block.text
|
||||
|
||||
raise ValueError(
|
||||
f"Anthropic response contained no text block (stop_reason={stop_reason!r})"
|
||||
)
|
||||
|
||||
|
||||
def _log_anthropic_cache_usage(usage: Any, model: str) -> None:
|
||||
"""Emit a structured log line capturing cache_read / cache_creation tokens."""
|
||||
cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
|
||||
@@ -176,6 +210,7 @@ class AIProvider(ABC):
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int = 4096,
|
||||
schema: dict[str, Any] | None = None,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Generate a JSON response from the AI model.
|
||||
|
||||
@@ -185,6 +220,15 @@ class AIProvider(ABC):
|
||||
Anthropic prompt caching per module-docstring policy.
|
||||
messages: List of message dicts with "role" and "content" keys.
|
||||
max_tokens: Maximum output tokens.
|
||||
schema: Optional JSON Schema constraining the response shape.
|
||||
When provided, the Anthropic backend uses structured outputs
|
||||
(`output_config.format`) to guarantee valid, parseable JSON —
|
||||
no markdown fences, no truncated-brace repair. Must satisfy the
|
||||
structured-output schema limits (every object needs
|
||||
`additionalProperties: false`; no recursion; numeric/string
|
||||
constraints are stripped). `None` preserves the legacy
|
||||
prompt-only behavior. The Gemini backend currently ignores this
|
||||
argument (it already requests `application/json`).
|
||||
|
||||
Returns:
|
||||
Tuple of (response_text, input_tokens, output_tokens).
|
||||
@@ -231,7 +275,11 @@ class GeminiProvider(AIProvider):
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int = 4096,
|
||||
schema: dict[str, Any] | None = None,
|
||||
) -> tuple[str, int, int]:
|
||||
# `schema` is accepted for interface parity but ignored: Gemini already
|
||||
# constrains output via response_mime_type="application/json" below.
|
||||
# Mapping JSON Schema -> Gemini response_schema is deferred.
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
|
||||
@@ -362,18 +410,28 @@ class AnthropicProvider(AIProvider):
|
||||
system_prompt: str | list[SystemBlock],
|
||||
messages: list[dict[str, Any]],
|
||||
max_tokens: int = 4096,
|
||||
schema: dict[str, Any] | None = None,
|
||||
) -> tuple[str, int, int]:
|
||||
client = _get_anthropic_client(self._api_key, self._timeout)
|
||||
normalized_system = _normalize_system_for_anthropic(system_prompt)
|
||||
|
||||
response = await client.messages.create(
|
||||
model=self._model,
|
||||
max_tokens=max_tokens,
|
||||
system=normalized_system,
|
||||
messages=messages,
|
||||
)
|
||||
create_kwargs: dict[str, Any] = {
|
||||
"model": self._model,
|
||||
"max_tokens": max_tokens,
|
||||
"system": normalized_system,
|
||||
"messages": messages,
|
||||
}
|
||||
if schema is not None:
|
||||
# Structured outputs: constrain the response to valid JSON matching
|
||||
# the schema (Sonnet 4.6 / Haiku 4.5). Removes the need for
|
||||
# markdown-fence stripping and truncated-JSON repair downstream.
|
||||
create_kwargs["output_config"] = {
|
||||
"format": {"type": "json_schema", "schema": schema}
|
||||
}
|
||||
|
||||
text = response.content[0].text
|
||||
response = await client.messages.create(**create_kwargs)
|
||||
|
||||
text = _extract_text_from_response(response, self._model)
|
||||
input_tokens = response.usage.input_tokens
|
||||
output_tokens = response.usage.output_tokens
|
||||
|
||||
|
||||
@@ -13,13 +13,20 @@ async def log_audit(
|
||||
resource_id: Optional[UUID] = None,
|
||||
details: Optional[dict] = None,
|
||||
account_id: Optional[UUID] = None,
|
||||
acting_as: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Record an audit log entry. Does not commit — piggybacks on the caller's commit."""
|
||||
"""Record an audit log entry. Does not commit — caller's commit picks it up.
|
||||
|
||||
acting_as: optional tag from the session (e.g. 'l1_coverage' for engineers
|
||||
on the L1 surface, None for native l1_tech users).
|
||||
"""
|
||||
if account_id is None:
|
||||
# Derive from the acting user's account as a fallback (one extra query).
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
result = await db.execute(select(User.account_id).where(User.id == user_id))
|
||||
result = await db.execute(
|
||||
select(User.account_id).where(User.id == user_id)
|
||||
)
|
||||
account_id = result.scalar_one()
|
||||
|
||||
entry = AuditLog(
|
||||
@@ -29,5 +36,6 @@ async def log_audit(
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
details=details,
|
||||
acting_as=acting_as,
|
||||
)
|
||||
db.add(entry)
|
||||
|
||||
@@ -69,6 +69,19 @@ class Settings(BaseSettings):
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 5
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
|
||||
|
||||
# Session policy — see docs/plans/2026-05-13-session-expiration-policy.md
|
||||
# Refresh tokens enforce two windows: idle (between rotations) and absolute
|
||||
# (from original login). Defaults can be overridden per-account, bounded by
|
||||
# the MIN/MAX values below. Values are minutes everywhere except inside the
|
||||
# refresh JWT, where idle_max/abs_max are stored as seconds for direct
|
||||
# Unix-time math.
|
||||
SESSION_IDLE_MINUTES_DEFAULT: int = 4320 # 3 days
|
||||
SESSION_ABSOLUTE_MINUTES_DEFAULT: int = 20160 # 14 days
|
||||
SESSION_IDLE_MINUTES_MIN: int = 15
|
||||
SESSION_IDLE_MINUTES_MAX: int = 43200 # 30 days
|
||||
SESSION_ABSOLUTE_MINUTES_MIN: int = 60 # 1 hour
|
||||
SESSION_ABSOLUTE_MINUTES_MAX: int = 129600 # 90 days
|
||||
|
||||
# Security
|
||||
BCRYPT_ROUNDS: int = 12
|
||||
|
||||
@@ -84,6 +97,7 @@ class Settings(BaseSettings):
|
||||
RESEND_API_KEY: Optional[str] = None
|
||||
FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>"
|
||||
FEEDBACK_EMAIL: Optional[str] = None
|
||||
SALES_LEAD_RECIPIENT_EMAIL: str = "sales@resolutionflow.com"
|
||||
|
||||
@property
|
||||
def email_enabled(self) -> bool:
|
||||
@@ -94,11 +108,46 @@ class Settings(BaseSettings):
|
||||
STRIPE_SECRET_KEY: Optional[str] = None
|
||||
STRIPE_PUBLISHABLE_KEY: Optional[str] = None
|
||||
STRIPE_WEBHOOK_SECRET: Optional[str] = None
|
||||
SELF_SERVE_ENABLED: bool = False
|
||||
|
||||
# Internal tester allowlist for soft cutover. Comma-separated emails;
|
||||
# when SELF_SERVE_ENABLED is False, listed users still see the self-serve
|
||||
# surfaces (pricing page, invite-code-optional registration, etc.) so the
|
||||
# full flow can be exercised in prod test mode before public flip.
|
||||
INTERNAL_TESTER_EMAILS: list[str] = []
|
||||
|
||||
@field_validator("INTERNAL_TESTER_EMAILS", mode="before")
|
||||
@classmethod
|
||||
def split_internal_tester_emails(cls, v) -> list[str]:
|
||||
"""Parse a comma-separated string into a normalized lowercase list."""
|
||||
if v is None or v == "":
|
||||
return []
|
||||
if isinstance(v, list):
|
||||
return [e.strip().lower() for e in v if e and e.strip()]
|
||||
if isinstance(v, str):
|
||||
return [e.strip().lower() for e in v.split(",") if e.strip()]
|
||||
return []
|
||||
|
||||
def is_internal_tester(self, email: Optional[str]) -> bool:
|
||||
"""Case-insensitive allowlist check. None/empty email is never a tester."""
|
||||
if not email:
|
||||
return False
|
||||
return email.lower() in self.INTERNAL_TESTER_EMAILS
|
||||
|
||||
def is_self_serve_active_for(self, email: Optional[str]) -> bool:
|
||||
"""True if self-serve surfaces should render for this user.
|
||||
|
||||
Either the global flag is on, or the user is on the internal-tester
|
||||
allowlist. Anonymous calls (email is None) only see the global flag.
|
||||
"""
|
||||
if self.SELF_SERVE_ENABLED:
|
||||
return True
|
||||
return self.is_internal_tester(email)
|
||||
|
||||
@property
|
||||
def stripe_enabled(self) -> bool:
|
||||
"""Check if Stripe is configured."""
|
||||
return self.STRIPE_SECRET_KEY is not None and self.STRIPE_WEBHOOK_SECRET is not None
|
||||
return bool(self.STRIPE_SECRET_KEY)
|
||||
|
||||
# AI Flow Builder
|
||||
ANTHROPIC_API_KEY: Optional[str] = None
|
||||
@@ -106,11 +155,27 @@ class Settings(BaseSettings):
|
||||
AI_CONVERSATION_TTL_HOURS: int = 24
|
||||
AI_MAX_CALLS_PER_FLOW: int = 10
|
||||
AI_REQUEST_TIMEOUT_SECONDS: int = 120
|
||||
# When True, KB conversion constrains the Anthropic response with a JSON
|
||||
# schema (structured outputs) instead of relying on prompt-only JSON +
|
||||
# downstream fence-stripping / brace-repair. Default OFF: enable in staging
|
||||
# and smoke-test constrained decoding against the live model before turning
|
||||
# it on in production. Only affects the Anthropic backend.
|
||||
AI_KB_CONVERT_STRUCTURED_OUTPUT: bool = False
|
||||
# AI Provider selection
|
||||
AI_PROVIDER: str = "anthropic" # "gemini" or "anthropic"
|
||||
GOOGLE_AI_API_KEY: Optional[str] = None
|
||||
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
|
||||
AI_MODEL_ANTHROPIC: str = "claude-sonnet-4-6"
|
||||
# Bound for the diagnostic assessment Sonnet call. Generation runs in a
|
||||
# FastAPI BackgroundTask (commit e8ba74e), so this no longer blocks the
|
||||
# senior's click — only how long we wait before publishing
|
||||
# `handoff_assessment_ready` with has_assessment=false. 15s was hitting
|
||||
# tail latency on Sonnet (timeout 03:57:35 in field testing 2026-04-29),
|
||||
# leaving the magic-moment placeholder permanent. 45s is the right
|
||||
# ceiling: well above Sonnet p99 for a 500-token output, far enough
|
||||
# below "the senior gives up watching" that we still surface SOMETHING
|
||||
# on persistent slowness.
|
||||
ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS: int = 45
|
||||
|
||||
# Model tier routing — maps action types to model tiers
|
||||
AI_MODEL_TIERS: dict[str, str] = {
|
||||
@@ -146,6 +211,10 @@ class Settings(BaseSettings):
|
||||
# concrete rendered script so a draft_template can be proposed.
|
||||
# Creates a persistent library artifact on accept, so Sonnet.
|
||||
"template_extraction": "standard",
|
||||
# L1 AI tree builder (Phase 2A): per-node generation is latency-sensitive
|
||||
# on a live call → Sonnet; classification is a short label task → Haiku.
|
||||
"l1_realtime_build": "standard",
|
||||
"l1_classify": "fast",
|
||||
}
|
||||
|
||||
def get_model_for_action(self, action_type: str) -> str:
|
||||
@@ -183,6 +252,13 @@ class Settings(BaseSettings):
|
||||
"""Check if ConnectWise integration is configured."""
|
||||
return self.CW_CLIENT_ID is not None
|
||||
|
||||
# OAuth providers (self-serve signup)
|
||||
GOOGLE_CLIENT_ID: Optional[str] = None
|
||||
GOOGLE_CLIENT_SECRET: Optional[str] = None
|
||||
MS_CLIENT_ID: Optional[str] = None
|
||||
MS_CLIENT_SECRET: Optional[str] = None
|
||||
OAUTH_REDIRECT_BASE: str = "http://localhost:5173"
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN: Optional[str] = None
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.sales_lead import SalesLead
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -484,6 +489,99 @@ class EmailService:
|
||||
logger.exception("Failed to send beta signup notification for %s", signup_email)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def send_sales_lead_notification(
|
||||
to_email: str,
|
||||
lead: "SalesLead",
|
||||
) -> bool:
|
||||
"""Notify the sales recipient about a new Talk-to-Sales submission.
|
||||
|
||||
Fire-and-forget. Returns False (and logs) on any failure; never raises.
|
||||
"""
|
||||
if not settings.email_enabled:
|
||||
logger.warning(
|
||||
"Sales lead email not sent — RESEND_API_KEY not configured (lead %s)",
|
||||
lead.id,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
import resend
|
||||
import html as html_mod
|
||||
from datetime import datetime, timezone
|
||||
|
||||
resend.api_key = settings.RESEND_API_KEY
|
||||
|
||||
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
safe_email = html_mod.escape(lead.email)
|
||||
safe_name = html_mod.escape(lead.name)
|
||||
safe_company = html_mod.escape(lead.company)
|
||||
safe_team_size = html_mod.escape(lead.team_size or "—")
|
||||
safe_source = html_mod.escape(lead.source)
|
||||
safe_message = html_mod.escape(lead.message or "(no message)")
|
||||
subject = f"[ResolutionFlow Sales] New lead — {safe_company} ({safe_email})"
|
||||
|
||||
email_html = f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
|
||||
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
|
||||
<tr><td style="padding:40px 40px 24px;text-align:center;">
|
||||
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
|
||||
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">New Sales Lead</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 16px;">
|
||||
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
|
||||
Source: <strong style="color:#f8fafc;">{safe_source}</strong>
|
||||
</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 16px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.06);border-radius:12px;">
|
||||
<tr><td style="padding:16px;">
|
||||
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Name</p>
|
||||
<p style="margin:0 0 12px;color:#f8fafc;font-size:16px;font-weight:600;">{safe_name}</p>
|
||||
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Email</p>
|
||||
<p style="margin:0 0 12px;color:#22d3ee;font-size:16px;font-weight:600;">{safe_email}</p>
|
||||
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Company</p>
|
||||
<p style="margin:0 0 12px;color:#f8fafc;font-size:16px;font-weight:600;">{safe_company}</p>
|
||||
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Team Size</p>
|
||||
<p style="margin:0;color:#f8fafc;font-size:16px;font-weight:600;">{safe_team_size}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 16px;">
|
||||
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Message</p>
|
||||
<p style="margin:0;color:#8891a0;font-size:14px;line-height:1.6;white-space:pre-wrap;">{safe_message}</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 32px;">
|
||||
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
|
||||
Submitted at {date_str} · Lead ID: {lead.id}
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>"""
|
||||
|
||||
resend.Emails.send({
|
||||
"from": settings.FROM_EMAIL,
|
||||
"to": [to_email],
|
||||
"reply_to": lead.email,
|
||||
"subject": subject,
|
||||
"html": email_html,
|
||||
})
|
||||
logger.info("Sales lead notification sent for %s (lead %s)", lead.email, lead.id)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to send sales lead notification for %s (lead %s)",
|
||||
lead.email,
|
||||
lead.id,
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def send_notification_email(
|
||||
to_email: str,
|
||||
|
||||
105
backend/app/core/escalation_bus.py
Normal file
105
backend/app/core/escalation_bus.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""In-memory pub/sub bus for live escalation events.
|
||||
|
||||
Single-process, non-durable. When a handoff fires, every connected SSE
|
||||
subscriber for the same `account_id` receives the event. Subscribers come
|
||||
and go as senior techs open and close the EscalationQueue page.
|
||||
|
||||
Pre-PMF scale (3 pilots × 5-20 techs/MSP = ~15-60 concurrent subscribers
|
||||
total, single Railway replica) makes in-memory the right call. When the
|
||||
deployment scales horizontally, swap this for Redis pub/sub or similar —
|
||||
the public surface (`publish` / `subscribe`) is intentionally narrow so
|
||||
the swap is local.
|
||||
|
||||
Events are JSON-serializable dicts. `publish()` is non-blocking (drops the
|
||||
event if a subscriber's queue is full rather than back-pressuring the
|
||||
caller). `subscribe()` MUST be paired with `unsubscribe()` in a finally
|
||||
block, or you leak queues.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Bound how many unconsumed events can sit in a subscriber's queue before
|
||||
# we start dropping. 64 is generous for the queue-page use case; if a
|
||||
# subscriber is that far behind, they're probably gone or stuck.
|
||||
_QUEUE_MAXSIZE = 64
|
||||
|
||||
|
||||
class EscalationBus:
|
||||
"""Account-scoped pub/sub for escalation arrival events."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: dict[UUID, set[asyncio.Queue[dict[str, Any]]]] = {}
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_account_id(account_id: UUID | str) -> UUID:
|
||||
return account_id if isinstance(account_id, UUID) else UUID(str(account_id))
|
||||
|
||||
async def subscribe(self, account_id: UUID | str) -> asyncio.Queue[dict[str, Any]]:
|
||||
"""Register a new subscriber queue for an account.
|
||||
|
||||
Caller must invoke `unsubscribe(account_id, queue)` when the
|
||||
consumer disconnects.
|
||||
"""
|
||||
normalized_account_id = self._normalize_account_id(account_id)
|
||||
queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(
|
||||
maxsize=_QUEUE_MAXSIZE
|
||||
)
|
||||
async with self._lock:
|
||||
self._subscribers.setdefault(normalized_account_id, set()).add(queue)
|
||||
return queue
|
||||
|
||||
async def unsubscribe(
|
||||
self, account_id: UUID | str, queue: asyncio.Queue[dict[str, Any]]
|
||||
) -> None:
|
||||
normalized_account_id = self._normalize_account_id(account_id)
|
||||
async with self._lock:
|
||||
subs = self._subscribers.get(normalized_account_id)
|
||||
if subs is None:
|
||||
return
|
||||
subs.discard(queue)
|
||||
if not subs:
|
||||
self._subscribers.pop(normalized_account_id, None)
|
||||
|
||||
async def publish(self, account_id: UUID | str, event: dict[str, Any]) -> int:
|
||||
"""Fan event out to every subscriber for `account_id`.
|
||||
|
||||
Returns the number of subscribers that successfully received the
|
||||
event. Drops the event for any subscriber whose queue is full
|
||||
(logs at warning level).
|
||||
"""
|
||||
normalized_account_id = self._normalize_account_id(account_id)
|
||||
async with self._lock:
|
||||
subs = list(self._subscribers.get(normalized_account_id, ()))
|
||||
if not subs:
|
||||
return 0
|
||||
delivered = 0
|
||||
for queue in subs:
|
||||
try:
|
||||
queue.put_nowait(event)
|
||||
delivered += 1
|
||||
except asyncio.QueueFull:
|
||||
logger.warning(
|
||||
"EscalationBus: dropped event for full subscriber queue "
|
||||
"(account_id=%s, event=%s)",
|
||||
normalized_account_id,
|
||||
event.get("type", "?"),
|
||||
)
|
||||
return delivered
|
||||
|
||||
def subscriber_count(self, account_id: UUID | str) -> int:
|
||||
"""Diagnostic — number of active subscribers for an account."""
|
||||
normalized_account_id = self._normalize_account_id(account_id)
|
||||
return len(self._subscribers.get(normalized_account_id, ()))
|
||||
|
||||
|
||||
# Module-level singleton. FastAPI imports this; `subscribe()` and `publish()`
|
||||
# are coroutine-safe via the internal Lock.
|
||||
bus = EscalationBus()
|
||||
@@ -202,6 +202,115 @@ the engineer attached, NOT from this schema):
|
||||
9. Return ONLY valid JSON — no markdown fences, no explanation text."""
|
||||
|
||||
|
||||
# ── Structured-output schemas ──
|
||||
#
|
||||
# These constrain the model's JSON via Anthropic structured outputs
|
||||
# (output_config.format) so the response is guaranteed valid and parseable —
|
||||
# no markdown fences, no truncated-brace repair. They must be a SUPERSET of
|
||||
# every field the corresponding system prompt instructs the model to emit:
|
||||
# additionalProperties is False everywhere, so any field the prompt asks for
|
||||
# but the schema omits would be impossible to produce.
|
||||
#
|
||||
# `type`/`field_type` are intentionally left as plain strings (no enum): the
|
||||
# downstream parser already normalizes/tolerates the type values, and an enum
|
||||
# risks constraining the model away from a value the prompt would yield.
|
||||
|
||||
_TROUBLESHOOTING_OPTION_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {"type": "string"},
|
||||
"next_node_id": {"type": "string"},
|
||||
},
|
||||
"required": ["label", "next_node_id"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
_TROUBLESHOOTING_NODE_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"type": {"type": "string"},
|
||||
"question": {"type": "string"},
|
||||
"options": {"type": "array", "items": _TROUBLESHOOTING_OPTION_SCHEMA},
|
||||
"next_node_id": {"type": "string"},
|
||||
"confidence": {"type": "number"},
|
||||
"source_excerpt": {"type": "string"},
|
||||
},
|
||||
# Only the universal fields are required. `question`/`options`/`next_node_id`
|
||||
# vary by node type and stay optional so a resolution node need not carry
|
||||
# options and an action node need not carry a question.
|
||||
"required": ["id", "type", "confidence", "source_excerpt"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
TROUBLESHOOTING_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"nodes": {"type": "array", "items": _TROUBLESHOOTING_NODE_SCHEMA},
|
||||
},
|
||||
"required": ["title", "description", "nodes"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
_PROCEDURAL_STEP_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"type": {"type": "string"},
|
||||
"content": {"type": "string"},
|
||||
"confidence": {"type": "number"},
|
||||
"source_excerpt": {"type": "string"},
|
||||
},
|
||||
"required": ["id", "type", "content", "confidence", "source_excerpt"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
_PROCEDURAL_INTAKE_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"variable_name": {"type": "string"},
|
||||
"label": {"type": "string"},
|
||||
"field_type": {"type": "string"},
|
||||
"required": {"type": "boolean"},
|
||||
"display_order": {"type": "integer"},
|
||||
},
|
||||
"required": [
|
||||
"variable_name",
|
||||
"label",
|
||||
"field_type",
|
||||
"required",
|
||||
"display_order",
|
||||
],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
PROCEDURAL_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string"},
|
||||
"description": {"type": "string"},
|
||||
"steps": {"type": "array", "items": _PROCEDURAL_STEP_SCHEMA},
|
||||
"intake_form": {"type": "array", "items": _PROCEDURAL_INTAKE_SCHEMA},
|
||||
},
|
||||
"required": ["title", "description", "steps", "intake_form"],
|
||||
"additionalProperties": False,
|
||||
}
|
||||
|
||||
|
||||
def _schema_for_target_type(target_type: str) -> dict[str, Any]:
|
||||
"""Return the structured-output schema for a KB conversion target type.
|
||||
|
||||
Mirrors the prompt selection in ``convert_document``: only
|
||||
``"troubleshooting"`` uses the decision-tree schema; everything else is
|
||||
treated as a procedural flow.
|
||||
"""
|
||||
if target_type == "troubleshooting":
|
||||
return TROUBLESHOOTING_SCHEMA
|
||||
return PROCEDURAL_SCHEMA
|
||||
|
||||
|
||||
def _build_user_message(
|
||||
source_text: str,
|
||||
source_metadata: dict[str, Any] | None,
|
||||
@@ -404,6 +513,16 @@ async def convert_document(
|
||||
model = settings.get_model_for_action("kb_convert")
|
||||
provider = get_ai_provider(model=model)
|
||||
|
||||
# Structured outputs (flagged): constrain the response to a JSON schema so
|
||||
# the model can't emit fences or truncated JSON. Falls back to prompt-only
|
||||
# JSON (schema=None) when disabled; the parse path below stays intact either
|
||||
# way as a belt-and-suspenders fallback.
|
||||
schema = (
|
||||
_schema_for_target_type(kb_import.target_type)
|
||||
if settings.AI_KB_CONVERT_STRUCTURED_OUTPUT
|
||||
else None
|
||||
)
|
||||
|
||||
try:
|
||||
raw_text, input_tokens, output_tokens = await provider.generate_json(
|
||||
system_prompt=[
|
||||
@@ -414,6 +533,7 @@ async def convert_document(
|
||||
],
|
||||
messages=[{"role": "user", "content": user_message}],
|
||||
max_tokens=16384,
|
||||
schema=schema,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("AI conversion failed for kb_import=%s: %s", kb_import.id, e)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"""
|
||||
Centralized permission checks for ResolutionFlow.
|
||||
|
||||
Role hierarchy: super_admin > owner > engineer > viewer
|
||||
Role hierarchy: super_admin > owner > engineer > l1_tech > viewer
|
||||
|
||||
- super_admin: is_super_admin=True, full system access
|
||||
- owner: account_role='owner', manage account resources
|
||||
- engineer: account_role='engineer' (default), CRUD own trees/steps
|
||||
- l1_tech: account_role='l1_tech', use /l1/* surface only — walk flows, resolve/escalate
|
||||
- viewer: account_role='viewer', read-only (can browse, run sessions, rate steps)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +24,8 @@ ROLE_HIERARCHY = {
|
||||
"super_admin": 4,
|
||||
"owner": 3,
|
||||
"engineer": 2,
|
||||
"viewer": 1,
|
||||
"l1_tech": 1,
|
||||
"viewer": 0,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,9 +5,18 @@ import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from jose.exceptions import ExpiredSignatureError
|
||||
from passlib.context import CryptContext
|
||||
from .config import settings
|
||||
|
||||
|
||||
class IdleTokenExpired(Exception):
|
||||
"""Raised by decode_refresh_token_strict when a refresh JWT is past its `exp`.
|
||||
|
||||
Distinct from JWTError so callers can map idle expiry to `session_expired_idle`
|
||||
on the wire while all other decode failures map to `invalid_refresh_token`.
|
||||
"""
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
@@ -33,14 +42,54 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
"""Create a JWT refresh token with a unique jti for revocation tracking."""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now(timezone.utc) + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
def create_refresh_token(
|
||||
user_id: str,
|
||||
*,
|
||||
auth_time: int,
|
||||
idle_max_seconds: int,
|
||||
abs_max_seconds: int,
|
||||
) -> str:
|
||||
"""Create a JWT refresh token with session-policy claims embedded.
|
||||
|
||||
The JWT carries five claims beyond the standard `sub`/`type`/`jti`:
|
||||
|
||||
- `auth_time`: Unix-seconds timestamp of the original login; never reset
|
||||
on rotation. Used by `/auth/refresh` to enforce the absolute cap.
|
||||
- `idle_max`: idle window in seconds, snapshotted from the account's
|
||||
policy at login. Carried forward across rotations unchanged.
|
||||
- `abs_max`: absolute lifetime in seconds, snapshotted at login.
|
||||
- `exp`: current idle deadline (`now + idle_max`). Standard JWT expiry.
|
||||
|
||||
See docs/plans/2026-05-13-session-expiration-policy.md §4.2 for the unit
|
||||
convention (everything outside the JWT is minutes; inside the JWT it's
|
||||
seconds so `auth_time + abs_max` is direct Unix math).
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
expire = now + timedelta(seconds=idle_max_seconds)
|
||||
jti = str(uuid.uuid4())
|
||||
to_encode.update({"exp": expire, "type": "refresh", "jti": jti})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
to_encode = {
|
||||
"sub": user_id,
|
||||
"type": "refresh",
|
||||
"jti": jti,
|
||||
"exp": expire,
|
||||
"auth_time": auth_time,
|
||||
"idle_max": idle_max_seconds,
|
||||
"abs_max": abs_max_seconds,
|
||||
}
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
|
||||
|
||||
def resolve_session_policy(account) -> tuple[int, int]:
|
||||
"""Return (idle_minutes, absolute_minutes) for an account.
|
||||
|
||||
NULL overrides fall back to the system defaults from Settings. Partial
|
||||
overrides (one column NULL, one set) are intentionally allowed at this
|
||||
layer; the PATCH /accounts/me/security endpoint validates the resolved
|
||||
effective values to enforce idle <= absolute. See plan §4.3.
|
||||
"""
|
||||
idle = account.session_idle_minutes or settings.SESSION_IDLE_MINUTES_DEFAULT
|
||||
absolute = account.session_absolute_minutes or settings.SESSION_ABSOLUTE_MINUTES_DEFAULT
|
||||
return idle, absolute
|
||||
|
||||
|
||||
def hash_token(jti: str) -> str:
|
||||
@@ -49,7 +98,14 @@ def hash_token(jti: str) -> str:
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
"""Decode and validate a JWT token."""
|
||||
"""Decode and validate a JWT token.
|
||||
|
||||
Collapses all jose errors (including expiry) into None — preserved for
|
||||
access tokens, password-reset tokens, and email-verification tokens where
|
||||
the caller does not need to distinguish expiry from invalid. Refresh tokens
|
||||
use decode_refresh_token_strict instead so they can map idle expiry to
|
||||
`session_expired_idle` distinctly.
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
@@ -57,6 +113,24 @@ def decode_token(token: str) -> Optional[dict]:
|
||||
return None
|
||||
|
||||
|
||||
def decode_refresh_token_strict(token: str) -> dict:
|
||||
"""Decode a refresh token, distinguishing idle expiry from invalid.
|
||||
|
||||
Raises:
|
||||
IdleTokenExpired: token signature is valid but `exp` is past — i.e. the
|
||||
idle window has elapsed.
|
||||
JWTError: any other decode failure (bad signature, malformed, wrong
|
||||
algorithm).
|
||||
|
||||
Type discrimination (`type == "refresh"`) is the caller's responsibility —
|
||||
this function only inspects the JWT itself.
|
||||
"""
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
except ExpiredSignatureError as e:
|
||||
raise IdleTokenExpired() from e
|
||||
|
||||
|
||||
def create_password_reset_token(user_id: str) -> str:
|
||||
"""Create a JWT password reset token (30-minute expiry, unique JTI)."""
|
||||
jti = str(uuid.uuid4())
|
||||
|
||||
@@ -221,6 +221,18 @@ async def lifespan(app: FastAPI):
|
||||
max_instances=1,
|
||||
)
|
||||
|
||||
# L1 walk session cleanup: flip stale active sessions to 'abandoned' (hourly)
|
||||
from app.services.l1_session_cleanup import run_cleanup_job as l1_cleanup_run
|
||||
scheduler.add_job(
|
||||
l1_cleanup_run,
|
||||
trigger="interval",
|
||||
hours=1,
|
||||
id="l1_session_cleanup",
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
args=[async_session_maker],
|
||||
)
|
||||
|
||||
# Auto-seed trees in background on PR environments
|
||||
seed_task = None
|
||||
if settings.SEED_ON_DEPLOY:
|
||||
|
||||
@@ -62,6 +62,12 @@ from .session_fact import SessionFact
|
||||
from .session_suggested_fix import SessionSuggestedFix
|
||||
from .draft_template import DraftTemplate
|
||||
from .account_settings import AccountSettings
|
||||
from .oauth_identity import OAuthIdentity # noqa: F401
|
||||
from .plan_billing import PlanBilling # noqa: F401
|
||||
from .sales_lead import SalesLead # noqa: F401
|
||||
from .stripe_event import StripeEvent # noqa: F401
|
||||
from .internal_ticket import InternalTicket # noqa: F401
|
||||
from .l1_walk_session import L1WalkSession # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -138,4 +144,10 @@ __all__ = [
|
||||
"SessionSuggestedFix",
|
||||
"DraftTemplate",
|
||||
"AccountSettings",
|
||||
"OAuthIdentity",
|
||||
"PlanBilling",
|
||||
"SalesLead",
|
||||
"StripeEvent",
|
||||
"InternalTicket",
|
||||
"L1WalkSession",
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean, Integer
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean, Integer, text as sa_text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from app.core.database import Base
|
||||
@@ -44,16 +44,42 @@ class Account(Base):
|
||||
Integer, nullable=True, default=100, server_default="100"
|
||||
)
|
||||
|
||||
# Session policy override (NULL = use Settings.SESSION_*_MINUTES_DEFAULT).
|
||||
# Validated at the app layer because the DB cannot see Settings; a DB
|
||||
# CHECK constraint covers the both-set case only.
|
||||
session_idle_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
session_absolute_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Custom branding (Task 9)
|
||||
branding_logo_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
branding_primary_color: Mapped[Optional[str]] = mapped_column(String(7), nullable=True) # hex like #06b6d4
|
||||
branding_company_name: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
team_size_bucket: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
primary_psa: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
|
||||
# L1 workspace seats
|
||||
l1_seats_purchased: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0"
|
||||
)
|
||||
|
||||
# SSO / SAML groundwork (Task 11)
|
||||
sso_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
sso_provider: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # "saml" | "oidc"
|
||||
sso_config: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
|
||||
# L1 AI tree builder — per-account allowlist of problem categories.
|
||||
# Keep this server_default in sync with DEFAULT_L1_CATEGORIES in
|
||||
# app/services/l1_category_service.py when adding/removing categories.
|
||||
enabled_l1_categories: Mapped[list[str]] = mapped_column(
|
||||
JSONB(), nullable=False,
|
||||
server_default=sa_text(
|
||||
"'[\"password_reset\",\"account_lockout\",\"printer\","
|
||||
"\"email_outlook_client\",\"wifi_network_basics\",\"vpn_connect\","
|
||||
"\"teams_zoom_av\",\"browser_cache_cookies\",\"peripheral_reconnect\","
|
||||
"\"os_restart_update\"]'::jsonb"
|
||||
),
|
||||
)
|
||||
|
||||
# Relationships
|
||||
owner: Mapped["User"] = relationship("User", foreign_keys=[owner_id], back_populates="owned_account")
|
||||
users: Mapped[list["User"]] = relationship("User", foreign_keys="[User.account_id]", back_populates="account")
|
||||
|
||||
@@ -27,6 +27,8 @@ class AccountInvite(Base):
|
||||
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
used_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
revoked_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
email_sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
# Relationships
|
||||
account: Mapped["Account"] = relationship("Account")
|
||||
@@ -37,6 +39,10 @@ class AccountInvite(Base):
|
||||
def is_used(self) -> bool:
|
||||
return self.accepted_by_id is not None
|
||||
|
||||
@property
|
||||
def is_revoked(self) -> bool:
|
||||
return self.revoked_at is not None
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
if self.expires_at is None:
|
||||
@@ -45,4 +51,4 @@ class AccountInvite(Base):
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return not self.is_used and not self.is_expired
|
||||
return not self.is_used and not self.is_expired and not self.is_revoked
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import Optional, Any, TYPE_CHECKING
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Boolean, Integer, Float, CheckConstraint
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB, TSVECTOR
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
@@ -46,6 +46,7 @@ class AISession(Base):
|
||||
"confidence_tier IN ('guided', 'exploring', 'discovery')",
|
||||
name="ck_ai_sessions_confidence_tier",
|
||||
),
|
||||
sa.Index("idx_ai_sessions_search", "search_vector", postgresql_using="gin"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
@@ -150,6 +151,18 @@ class AISession(Base):
|
||||
Text, nullable=True,
|
||||
comment="Why escalated (set on escalation)",
|
||||
)
|
||||
search_vector: Mapped[Optional[str]] = mapped_column(
|
||||
TSVECTOR,
|
||||
sa.Computed(
|
||||
"to_tsvector('english', "
|
||||
"coalesce(problem_summary, '') || ' ' || "
|
||||
"coalesce(resolution_summary, '') || ' ' || "
|
||||
"coalesce(escalation_reason, '') || ' ' || "
|
||||
"coalesce(problem_domain, ''))",
|
||||
persisted=True,
|
||||
),
|
||||
nullable=True,
|
||||
)
|
||||
escalation_package: Mapped[Optional[dict[str, Any]]] = mapped_column(
|
||||
JSONB, nullable=True,
|
||||
comment="Context package for receiving engineer: steps_tried, hypotheses, suggestions",
|
||||
|
||||
@@ -35,6 +35,7 @@ class AuditLog(Base):
|
||||
)
|
||||
details: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
|
||||
ip_address: Mapped[Optional[str]] = mapped_column(String(45), nullable=True)
|
||||
acting_as: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
|
||||
@@ -7,7 +7,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, Any, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float, CheckConstraint
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, Integer, Float, Boolean, CheckConstraint, text as sa_text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
from app.models.tree import Tree
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.l1_walk_session import L1WalkSession
|
||||
|
||||
|
||||
class FlowProposal(Base):
|
||||
@@ -48,6 +49,18 @@ class FlowProposal(Base):
|
||||
"status IN ('pending', 'approved', 'modified', 'rejected', 'dismissed', 'auto_reinforced')",
|
||||
name="ck_flow_proposals_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"source IN ('ai_realtime_l1', 'kb_accelerator', 'manual_draft', 'ai_promoted')",
|
||||
name="ck_flow_proposals_source",
|
||||
),
|
||||
CheckConstraint(
|
||||
"linked_ticket_kind IS NULL OR linked_ticket_kind IN ('psa', 'internal')",
|
||||
name="ck_flow_proposals_linked_ticket_kind",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(source_session_id IS NOT NULL) <> (l1_session_id IS NOT NULL)",
|
||||
name="ck_flow_proposals_exactly_one_source",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
@@ -65,10 +78,16 @@ class FlowProposal(Base):
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
source_session_id: Mapped[uuid.UUID] = mapped_column(
|
||||
source_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ai_sessions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
l1_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("l1_walk_sessions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
@@ -135,6 +154,16 @@ class FlowProposal(Base):
|
||||
comment="The flow that was created/updated when this proposal was approved",
|
||||
)
|
||||
|
||||
# ── L1 workspace ──
|
||||
source: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False, server_default=sa_text("'manual_draft'"),
|
||||
)
|
||||
linked_ticket_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
linked_ticket_kind: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
|
||||
validated_by_outcome: Mapped[bool] = mapped_column(
|
||||
Boolean(), nullable=False, server_default=sa_text('false'),
|
||||
)
|
||||
|
||||
# ── Timestamps ──
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
@@ -146,7 +175,17 @@ class FlowProposal(Base):
|
||||
# ── Relationships ──
|
||||
account: Mapped["Account"] = relationship("Account")
|
||||
team: Mapped[Optional["Team"]] = relationship("Team")
|
||||
source_session: Mapped["AISession"] = relationship("AISession")
|
||||
target_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[target_flow_id])
|
||||
published_flow: Mapped[Optional["Tree"]] = relationship("Tree", foreign_keys=[published_flow_id])
|
||||
source_session: Mapped[Optional["AISession"]] = relationship("AISession")
|
||||
# Two FK paths exist between FlowProposal and L1WalkSession
|
||||
# (FlowProposal.l1_session_id here, L1WalkSession.flow_proposal_id there),
|
||||
# so each relationship must name its foreign_keys explicitly.
|
||||
l1_session: Mapped[Optional["L1WalkSession"]] = relationship(
|
||||
"L1WalkSession", foreign_keys="[FlowProposal.l1_session_id]"
|
||||
)
|
||||
target_flow: Mapped[Optional["Tree"]] = relationship(
|
||||
"Tree", foreign_keys=[target_flow_id]
|
||||
)
|
||||
published_flow: Mapped[Optional["Tree"]] = relationship(
|
||||
"Tree", foreign_keys=[published_flow_id]
|
||||
)
|
||||
reviewer: Mapped[Optional["User"]] = relationship("User")
|
||||
|
||||
117
backend/app/models/internal_ticket.py
Normal file
117
backend/app/models/internal_ticket.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Internal ticket model.
|
||||
|
||||
Fallback ticket table for L1 intake when the account has no PSA integration.
|
||||
Tracks the customer-facing problem, resolution lifecycle, and optional links
|
||||
to a flow, flow proposal, AI session, and assigned engineer.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import String, Text, DateTime, ForeignKey, CheckConstraint
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.models.tree import Tree
|
||||
from app.models.flow_proposal import FlowProposal
|
||||
from app.models.ai_session import AISession
|
||||
|
||||
|
||||
class InternalTicket(Base):
|
||||
"""A fallback support ticket for accounts without a PSA integration.
|
||||
|
||||
status lifecycle:
|
||||
- open: Submitted, not yet picked up.
|
||||
- walking: L1 technician is actively walking the flow.
|
||||
- resolved: Issue resolved; resolution_notes captured.
|
||||
- escalated: Could not resolve; requires higher-tier intervention.
|
||||
"""
|
||||
__tablename__ = "internal_tickets"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"status IN ('open', 'walking', 'resolved', 'escalated')",
|
||||
name="ck_internal_tickets_status",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_by_user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
# ── Customer info ──
|
||||
customer_name: Mapped[Optional[str]] = mapped_column(String(120), nullable=True)
|
||||
customer_contact: Mapped[Optional[str]] = mapped_column(String(200), nullable=True)
|
||||
problem_statement: Mapped[str] = mapped_column(Text(), nullable=False)
|
||||
|
||||
# ── Lifecycle ──
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False, server_default=sa_text("'open'"), index=True,
|
||||
)
|
||||
|
||||
# ── Optional links ──
|
||||
flow_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("trees.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
flow_proposal_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("flow_proposals.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
ai_session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("ai_sessions.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
assigned_user_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# ── Resolution ──
|
||||
resolution_notes: Mapped[Optional[str]] = mapped_column(Text(), nullable=True)
|
||||
psa_promoted_ticket_id: Mapped[Optional[str]] = mapped_column(
|
||||
String(64), nullable=True,
|
||||
comment="External PSA ticket ID when this ticket is promoted to a PSA system",
|
||||
)
|
||||
|
||||
# ── Timestamps ──
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
resolved_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
|
||||
# ── Relationships ──
|
||||
account: Mapped["Account"] = relationship("Account")
|
||||
created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_user_id])
|
||||
assigned_user: Mapped[Optional["User"]] = relationship("User", foreign_keys=[assigned_user_id])
|
||||
flow: Mapped[Optional["Tree"]] = relationship("Tree")
|
||||
flow_proposal: Mapped[Optional["FlowProposal"]] = relationship("FlowProposal")
|
||||
ai_session: Mapped[Optional["AISession"]] = relationship("AISession")
|
||||
147
backend/app/models/l1_walk_session.py
Normal file
147
backend/app/models/l1_walk_session.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""L1 walk session model.
|
||||
|
||||
Per-session state for an L1 technician walking a ticket through a flow,
|
||||
flow proposal, or ad-hoc investigation. Tracks the walked path, notes
|
||||
captured at each step, and terminal resolution / escalation metadata.
|
||||
"""
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional, TYPE_CHECKING
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import String, Text, DateTime, Boolean, ForeignKey, CheckConstraint
|
||||
from sqlalchemy import text as sa_text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.models.tree import Tree
|
||||
from app.models.flow_proposal import FlowProposal
|
||||
|
||||
|
||||
class L1WalkSession(Base):
|
||||
"""A single L1 technician session walking a ticket.
|
||||
|
||||
session_kind values:
|
||||
- flow: Walking a published flow (flow_id required, flow_proposal_id null).
|
||||
- proposal: Walking a draft flow proposal (flow_proposal_id required, flow_id null).
|
||||
- adhoc: Free-form investigation (both flow_id and flow_proposal_id null).
|
||||
- ai_build: AI-generated decision-tree walk (both flow_id and flow_proposal_id null).
|
||||
|
||||
status lifecycle:
|
||||
- active: Session is in progress.
|
||||
- resolved: Issue resolved; resolution_notes captured.
|
||||
- escalated: Could not resolve; escalation_reason captured.
|
||||
- abandoned: Session exited without resolution or explicit escalation.
|
||||
"""
|
||||
|
||||
__tablename__ = "l1_walk_sessions"
|
||||
__table_args__ = (
|
||||
CheckConstraint(
|
||||
"ticket_kind IN ('psa', 'internal')",
|
||||
name="ck_l1_walk_sessions_ticket_kind",
|
||||
),
|
||||
CheckConstraint(
|
||||
"session_kind IN ('flow', 'proposal', 'adhoc', 'ai_build')",
|
||||
name="ck_l1_walk_sessions_session_kind",
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('active', 'resolved', 'escalated', 'abandoned')",
|
||||
name="ck_l1_walk_sessions_status",
|
||||
),
|
||||
CheckConstraint(
|
||||
"(session_kind = 'flow' AND flow_id IS NOT NULL AND flow_proposal_id IS NULL) "
|
||||
"OR (session_kind = 'proposal' AND flow_proposal_id IS NOT NULL AND flow_id IS NULL) "
|
||||
"OR (session_kind IN ('adhoc', 'ai_build') AND flow_id IS NULL AND flow_proposal_id IS NULL)",
|
||||
name="ck_l1_walk_sessions_target_consistency",
|
||||
),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("accounts.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
created_by_user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("users.id", ondelete="RESTRICT"),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# ── Actor context ──
|
||||
acting_as: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
|
||||
|
||||
# ── Ticket reference ──
|
||||
ticket_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
ticket_kind: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
|
||||
# ── Session kind + target ──
|
||||
session_kind: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
flow_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("trees.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
flow_proposal_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("flow_proposals.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
# ── Navigation state ──
|
||||
current_node_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
walked_path: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSONB(), nullable=False, server_default=sa_text("'[]'::jsonb"),
|
||||
)
|
||||
walk_notes: Mapped[list[dict[str, Any]]] = mapped_column(
|
||||
JSONB(), nullable=False, server_default=sa_text("'[]'::jsonb"),
|
||||
)
|
||||
|
||||
# ── Lifecycle ──
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, server_default=sa_text("'active'"), index=True,
|
||||
)
|
||||
|
||||
# ── Resolution ──
|
||||
resolution_notes: Mapped[Optional[str]] = mapped_column(Text(), nullable=True)
|
||||
helpful: Mapped[Optional[bool]] = mapped_column(Boolean(), nullable=True)
|
||||
|
||||
# ── Escalation ──
|
||||
escalation_reason: Mapped[Optional[str]] = mapped_column(Text(), nullable=True)
|
||||
escalation_reason_category: Mapped[Optional[str]] = mapped_column(
|
||||
String(30), nullable=True,
|
||||
)
|
||||
|
||||
# ── Timestamps ──
|
||||
started_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
last_step_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
index=True,
|
||||
)
|
||||
resolved_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True,
|
||||
)
|
||||
|
||||
# ── Relationships ──
|
||||
account: Mapped["Account"] = relationship("Account")
|
||||
created_by: Mapped["User"] = relationship("User", foreign_keys=[created_by_user_id])
|
||||
flow: Mapped[Optional["Tree"]] = relationship("Tree")
|
||||
# Two FK paths exist between L1WalkSession and FlowProposal
|
||||
# (L1WalkSession.flow_proposal_id here, FlowProposal.l1_session_id there),
|
||||
# so each relationship must name its foreign_keys explicitly.
|
||||
flow_proposal: Mapped[Optional["FlowProposal"]] = relationship(
|
||||
"FlowProposal", foreign_keys="[L1WalkSession.flow_proposal_id]"
|
||||
)
|
||||
36
backend/app/models/oauth_identity.py
Normal file
36
backend/app/models/oauth_identity.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
from sqlalchemy import String, DateTime, ForeignKey, UniqueConstraint, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class OAuthIdentity(Base):
|
||||
__tablename__ = "oauth_identities"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("provider", "provider_subject", name="uq_oauth_identities_provider_subject"),
|
||||
Index("ix_oauth_identities_user_id", "user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
provider_subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
provider_email_at_link: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
user: Mapped["User"] = relationship("User", backref="oauth_identities")
|
||||
31
backend/app/models/plan_billing.py
Normal file
31
backend/app/models/plan_billing.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class PlanBilling(Base):
|
||||
__tablename__ = "plan_billing"
|
||||
|
||||
plan: Mapped[str] = mapped_column(
|
||||
String(50), ForeignKey("plan_limits.plan"), primary_key=True
|
||||
)
|
||||
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
monthly_price_cents: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
annual_price_cents: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
stripe_product_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
stripe_monthly_price_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
stripe_annual_price_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
is_archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
28
backend/app/models/sales_lead.py
Normal file
28
backend/app/models/sales_lead.py
Normal file
@@ -0,0 +1,28 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from sqlalchemy import String, DateTime, Text, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SalesLead(Base):
|
||||
__tablename__ = "sales_leads"
|
||||
__table_args__ = (Index("ix_sales_leads_email", "email"),)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
company: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
team_size: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
source: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
posthog_distinct_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="new")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
)
|
||||
@@ -37,7 +37,7 @@ class SessionSuggestedFix(Base):
|
||||
),
|
||||
CheckConstraint(
|
||||
"status IN ('proposed', 'applied_success', 'applied_failed', "
|
||||
"'applied_partial', 'dismissed')",
|
||||
"'applied_partial', 'applied_pending', 'dismissed')",
|
||||
name="ck_session_suggested_fixes_status",
|
||||
),
|
||||
)
|
||||
@@ -81,6 +81,7 @@ class SessionSuggestedFix(Base):
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
partial_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
pending_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
ai_outcome_proposal: Mapped[dict[str, Any] | None] = mapped_column(
|
||||
JSONB, nullable=True
|
||||
|
||||
17
backend/app/models/stripe_event.py
Normal file
17
backend/app/models/stripe_event.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import String, DateTime, Index
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class StripeEvent(Base):
|
||||
__tablename__ = "stripe_events"
|
||||
__table_args__ = (Index("ix_stripe_events_event_type", "event_type"),)
|
||||
|
||||
id: Mapped[str] = mapped_column(String(255), primary_key=True) # Stripe event id
|
||||
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
processed_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
payload_excerpt: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
|
||||
@@ -21,6 +21,7 @@ class Subscription(Base):
|
||||
billing_interval: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(50), nullable=False, default="active")
|
||||
seat_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
l1_seat_limit: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
current_period_start: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
current_period_end: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
cancel_at_period_end: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
@@ -32,8 +33,20 @@ class Subscription(Base):
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.status in ("active", "trialing")
|
||||
return self.status in ("active", "trialing", "complimentary")
|
||||
|
||||
@property
|
||||
def is_paid(self) -> bool:
|
||||
return self.plan in ("pro", "team")
|
||||
# Excludes complimentary and trialing so MRR/paid-customer metrics aren't inflated.
|
||||
return self.plan in ("pro", "starter", "enterprise") and self.status not in ("complimentary", "trialing")
|
||||
|
||||
@property
|
||||
def has_pro_entitlement(self) -> bool:
|
||||
"""True if the account can access Pro features right now."""
|
||||
if self.plan in ("pro", "starter", "enterprise"):
|
||||
if self.status in ("active", "complimentary"):
|
||||
return True
|
||||
if self.status == "trialing" and self.current_period_end is not None:
|
||||
from datetime import datetime, timezone
|
||||
return self.current_period_end > datetime.now(timezone.utc)
|
||||
return False
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text, Integer, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
@@ -22,7 +22,7 @@ class User(Base):
|
||||
name='ck_users_role_enum'
|
||||
),
|
||||
CheckConstraint(
|
||||
"account_role IN ('owner', 'admin', 'engineer', 'viewer')",
|
||||
"account_role IN ('owner', 'admin', 'engineer', 'l1_tech', 'viewer')",
|
||||
name='ck_users_account_role_enum'
|
||||
),
|
||||
)
|
||||
@@ -33,7 +33,7 @@ class User(Base):
|
||||
default=uuid.uuid4
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
password_hash: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")
|
||||
is_super_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
@@ -50,6 +50,9 @@ class User(Base):
|
||||
index=True
|
||||
)
|
||||
account_role: Mapped[str] = mapped_column(String(50), nullable=False, default="engineer")
|
||||
can_cover_l1: Mapped[bool] = mapped_column(
|
||||
Boolean(), nullable=False, server_default=text('false')
|
||||
)
|
||||
|
||||
# Legacy team columns (kept for PR A coexistence)
|
||||
team_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||
@@ -76,6 +79,8 @@ class User(Base):
|
||||
|
||||
# Onboarding
|
||||
onboarding_dismissed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false")
|
||||
role_at_signup: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
onboarding_step_completed: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
|
||||
# Branding (solo pros without a team)
|
||||
logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -27,7 +27,7 @@ class TransferOwnershipRequest(BaseModel):
|
||||
|
||||
class AccountInviteCreate(BaseModel):
|
||||
email: str = Field(..., max_length=255)
|
||||
role: str = Field("engineer", pattern="^(engineer|viewer)$")
|
||||
role: str = Field("engineer", pattern="^(engineer|viewer|l1_tech)$")
|
||||
expires_in_days: Optional[int] = Field(None, ge=1, le=30)
|
||||
|
||||
|
||||
@@ -42,3 +42,12 @@ class AccountInviteResponse(BaseModel):
|
||||
used_at: Optional[datetime] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class AccountInviteBulkCreate(BaseModel):
|
||||
invites: list[AccountInviteCreate]
|
||||
|
||||
|
||||
class AccountInviteBulkResponse(BaseModel):
|
||||
created: list[AccountInviteResponse]
|
||||
failed: list[dict] # entries shaped {"email": str, "error": str}
|
||||
|
||||
77
backend/app/schemas/account_security.py
Normal file
77
backend/app/schemas/account_security.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Schemas for /accounts/me/security — session-policy management.
|
||||
|
||||
See docs/plans/2026-05-13-session-expiration-policy.md §4.7 and §4.11.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ActiveUser(BaseModel):
|
||||
"""One row in the active-users list on GET /accounts/me/security.
|
||||
|
||||
Rendered as 'name (email) · logged in 2d ago' on the Account Security
|
||||
page. `last_login_at` reflects the last successful sign-in, not the last
|
||||
refresh-token use — that requires the deferred refresh_tokens.last_used_at
|
||||
follow-up (see plan §9).
|
||||
"""
|
||||
|
||||
user_id: UUID
|
||||
name: str
|
||||
email: str
|
||||
last_login_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class SessionPolicyResponse(BaseModel):
|
||||
"""GET /accounts/me/security — the policy in effect for this account.
|
||||
|
||||
Surfaces both the override (which may be NULL) and the effective value
|
||||
(after defaults applied) so the frontend can show the current state
|
||||
without re-implementing the defaults logic.
|
||||
"""
|
||||
|
||||
# Per-account override values, NULL = "use system default."
|
||||
idle_minutes: Optional[int] = Field(
|
||||
default=None,
|
||||
description="Account override; NULL means use the system default.",
|
||||
)
|
||||
absolute_minutes: Optional[int] = Field(default=None)
|
||||
|
||||
# Effective values after defaults applied (always non-NULL).
|
||||
effective_idle_minutes: int
|
||||
effective_absolute_minutes: int
|
||||
|
||||
# System-imposed bounds for the Custom-preset form inputs.
|
||||
idle_minutes_min: int
|
||||
idle_minutes_max: int
|
||||
absolute_minutes_min: int
|
||||
absolute_minutes_max: int
|
||||
|
||||
# Active sessions in this account — users with at least one un-revoked
|
||||
# refresh token. Drives the Active Sessions section in the UI.
|
||||
active_users: list[ActiveUser] = Field(default_factory=list)
|
||||
|
||||
|
||||
class SessionPolicyUpdateRequest(BaseModel):
|
||||
"""PATCH /accounts/me/security — set or clear the per-account override.
|
||||
|
||||
Pass `null` for either field to clear the override and fall back to the
|
||||
system default. Both bounds checks and the idle <= absolute invariant
|
||||
are validated against the *effective* values at the endpoint, since the
|
||||
DB CHECK constraint only covers the both-set case.
|
||||
"""
|
||||
|
||||
idle_minutes: Optional[int] = None
|
||||
absolute_minutes: Optional[int] = None
|
||||
|
||||
|
||||
class RevokeSessionsRequest(BaseModel):
|
||||
"""POST /accounts/me/security/revoke-sessions — bulk-revoke refresh tokens."""
|
||||
|
||||
scope: Literal["all", "others"] = "all"
|
||||
|
||||
|
||||
class RevokeSessionsResponse(BaseModel):
|
||||
revoked_count: int
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user