Compare commits
54 Commits
f10649abc2
...
fix/seed-t
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c64e9ad62 | |||
| 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 |
@@ -1,83 +1,36 @@
|
||||
# CURRENT_TASK.md
|
||||
|
||||
**Task:** Build **Escalation Mode** — the wedge for ResolutionFlow's GTM (first paying-customer push). When a junior tech escalates a FlowPilot session, the senior tech sees structured handoff context in seconds instead of running a 5-minute verbal "tell me what you tried" call.
|
||||
**Active task:** None — pick next from `.ai/TODO.md` or `03-DEVELOPMENT-ROADMAP.md`.
|
||||
|
||||
**Status:** ✅ **Engineering complete.** Browser QA passed (2026-04-30). Branch `feat/escalation-metric-endpoint`; PR #155 ready to mark ready-for-review.
|
||||
## Recently shipped
|
||||
|
||||
**Plan:** [`docs/plans/2026-04-27-escalation-mode-wedge-design.md`](../docs/plans/2026-04-27-escalation-mode-wedge-design.md). Reviewed by `/office-hours`, `/plan-eng-review`, `/plan-design-review`, `/codex review`. Eng + Design CLEARED.
|
||||
- **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).
|
||||
|
||||
**Test plan artifact:** [`docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md`](../docs/plans/2026-04-27-escalation-mode-wedge-test-plan.md).
|
||||
## Two-metric framing (Escalation Mode — read before quoting numbers)
|
||||
|
||||
## What's done (all sessions combined)
|
||||
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.
|
||||
|
||||
All plan items complete. Key commits on `feat/escalation-metric-endpoint`:
|
||||
|
||||
| Commit | What it ships |
|
||||
|---|---|
|
||||
| `d51e95c` | Plan + test-plan artifacts |
|
||||
| `52f6d03` | `GET /analytics/flowpilot/escalations` — time-to-first-action metric |
|
||||
| `7a5b853` | Role-gate claim to engineer-or-admin |
|
||||
| `07d0db9` | Email notifications on escalation |
|
||||
| `9f0bfd4` | `EscalationMetricCard` on `/escalations` |
|
||||
| `b8627f4` | SSE live-arrival animations in `EscalationQueue` |
|
||||
| `8e9d22e` | Magic-moment handoff-context screen |
|
||||
| `641853a` | Bell-icon opens pickup flow |
|
||||
| `029680a` | Unify `/escalate` through `HandoffManager` |
|
||||
| `0f00ee5` | Plan-locked polish: chips, unread dot, race toast, AI refresh |
|
||||
| `665530f` | Structural task-lane race fix |
|
||||
| `db717b0` | 3-option CTA, copy button fix, post-escalation redirect, claim 500 fix |
|
||||
| `dc69c9d` | Allow `escalated_to_id` to send chat (GET AI analysis fix) |
|
||||
|
||||
**Browser QA results (2026-04-30):**
|
||||
|
||||
- ✅ Post-escalation redirect (dashboard + toast)
|
||||
- ✅ Magic-moment screen: header, AI assessment, 2-option CTA
|
||||
- ✅ "I'll take it from here": claim → dismiss → composer focused
|
||||
- ✅ "Get AI analysis": claim → briefing → AI responds → task lane populates
|
||||
- ✅ Task lane copy button: toast + checkmark
|
||||
- ✅ Chip expansion: inline detail + "Open in Tasks panel"
|
||||
- ✅ Post-claim overlay: dismissible mode, Close only
|
||||
|
||||
## Done on `feat/escalation-metric-endpoint` (branched from `main` @ `c0ed6d9`)
|
||||
|
||||
| Commit | What it ships |
|
||||
|---|---|
|
||||
| `d51e95c` | Plan + test-plan artifacts |
|
||||
| `52f6d03` | `GET /analytics/flowpilot/escalations` — in-product time-to-first-action |
|
||||
| `7a5b853` | Role-gate POST `/handoffs/{id}/claim` to engineer-or-admin |
|
||||
| `07d0db9` | `HandoffManager.dispatch_escalation_notifications` — emails engineer/admin teammates |
|
||||
| `9f0bfd4` | `EscalationMetricCard` mounted above the queue list |
|
||||
| `bc15952` | Codex: stabilize SSE backend tests |
|
||||
| `9bdd995` | Bound escalation assessment latency (ORIGINAL: 5s) |
|
||||
| `b8627f4` | Frontend SSE subscription in `EscalationQueue.tsx` — live-arrival animations |
|
||||
| `8e9d22e` | Magic-moment handoff-context screen on pickup |
|
||||
| `641853a` | Bell-icon notification opens the pickup flow |
|
||||
| `029680a` | Unify `/escalate` through `HandoffManager` |
|
||||
| `8914391` | First task-lane race fix (insufficient — see `665530f`) |
|
||||
| `0f00ee5` | Four plan-locked items: live AI refresh, suggested-step chips, unread dot, race-condition toast |
|
||||
| `665530f` | Structural task-lane fix — `taskLaneOwnerChatId` tagging |
|
||||
| `b7d7ff0` | docs(ai): refresh handoff for compute swap |
|
||||
| `0d1b305` | **Live-test fixes**: selectChat-gating bug (loadedChatIdsRef), 45s timeout bump, Enter-to-submit on escalate forms, dashboard expand-to-preview |
|
||||
|
||||
## Live-test results (2026-04-29 morning)
|
||||
|
||||
After the structural task-lane fix and the four polish items, end-to-end test confirmed:
|
||||
|
||||
- ✅ Junior escalates → senior gets bell-icon notification.
|
||||
- ✅ Magic-moment screen renders with handoff data on Pick Up.
|
||||
- ✅ Senior's chat surface loads with conversation history (after `0d1b305`'s selectChat fix — was completely broken before).
|
||||
- ✅ Sidebar shows the picked-up session with the "Escalated" pill (after `0d1b305`'s `loadChats()` call).
|
||||
- ✅ Suggested-step chips render below the composer.
|
||||
- ✅ Unread 6px dot on queue cards.
|
||||
- ✅ Task-lane regression is gone — no stale flash on new sessions.
|
||||
- ❌ **AI assessment placeholder never clears.** Drives the consolidation work above.
|
||||
|
||||
Untested live (low priority, can verify post-consolidation): race-condition toast (needs second user in same account).
|
||||
|
||||
## Two-metric framing — read this before quoting numbers to anyone
|
||||
|
||||
The in-product 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
|
||||
## 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.
|
||||
|
||||
@@ -13,6 +13,28 @@
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -2,55 +2,35 @@
|
||||
|
||||
# HANDOFF.md
|
||||
|
||||
**Last updated:** 2026-04-30 (Codex review-fix pass)
|
||||
**Last updated:** 2026-05-06 (Phase 1 backend complete on `feat/self-serve-signup-spec`)
|
||||
|
||||
**Active task:** **Escalation Mode** wedge — BROWSER QA COMPLETE + review fixes applied. Branch: `feat/escalation-metric-endpoint`. PR #155 ready to mark ready-for-review after committing this fix pass.
|
||||
**Active task:** Phase 1 self-serve signup backend foundation — DONE on branch. PR not yet opened.
|
||||
|
||||
## Where this session ended
|
||||
|
||||
Code-review fixes were applied after browser QA:
|
||||
24 commits on top of `main` (`31ca3fb`). All 26 tasks from `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-1-backend.md` complete. Full pytest run is green (1167 passed, 35 deselected). Single alembic head: `c6cbfc534fad`.
|
||||
|
||||
- `claim_session` now uses atomic conditional `UPDATE ... WHERE claimed_by IS NULL` instead of read-then-write, so simultaneous senior pickup cannot silently overwrite `claimed_by`.
|
||||
- Original escalators cannot claim their own handoff. The escalation queue also excludes the current user's own escalated sessions, preventing the post-escalation dashboard from showing the junior their own handoff.
|
||||
- `session.escalation_package["handoff_id"]` is now populated from a preassigned UUID instead of `None` before flush.
|
||||
- Frontend build blockers removed: deleted unused legacy `claiming` / `handleStartHere` path in `AssistantChatPage` and unused `onStartHere` destructuring in `HandoffContextScreen`.
|
||||
Phase 1 covered: schema additions (oauth_identities, plan_billing, sales_leads, stripe_events, plus 5 new columns across users/accounts/account_invites), Subscription complimentary status + has_pro_entitlement, the two new guards (`require_active_subscription`, `require_verified_email_after_grace`), full BillingService (start_trial / create_checkout_session / apply_subscription_event / get_billing_state), Stripe webhook handler, Google + Microsoft OAuth callbacks with oauth_identities linking, OAuth-only password guard, register-time verification email + invite email-match, bulk + soft-revoke invite routes, GET /billing/state, and the pilot complimentary backfill migration.
|
||||
|
||||
**Validation:**
|
||||
|
||||
- `git diff --check` ✅
|
||||
- `cd backend && pytest --override-ini='addopts=' tests/test_handoff_manager.py tests/test_session_handoffs_api.py tests/test_escalation_bus.py` ✅ `28 passed in 42.23s`
|
||||
- `cd frontend && /config/.bun/bin/bunx tsc -p tsconfig.app.json --noEmit --pretty false && /config/.bun/bin/bunx tsc -p tsconfig.node.json --noEmit --pretty false` ✅
|
||||
- Full frontend build could not complete because generated dirs are root-owned in this workspace: `frontend/node_modules/.tmp`, `frontend/node_modules/.vite-temp`, and likely `frontend/dist` produce EACCES. Type errors from review are fixed.
|
||||
|
||||
**Not testable in dev (known limitations):**
|
||||
- "Continue where X left off": requires senior to have existing task lane for session (won't occur on first pickup)
|
||||
- Browser-level 409 race toast still requires two distinct senior accounts. Backend claim write is now atomic and covered by service/API tests for conflict, self-claim, and idempotent same-user retry.
|
||||
The conftest's `test_user` fixture was modified to seed a Pro/active Subscription post-register (delete-then-insert) so the new subscription guard doesn't 402 every existing test. Two existing tests adapted because they explicitly assumed the old free-plan default: `test_subscription_limits.py` (the two free-plan tests now downgrade inline) and `test_kb_accelerator.py::TestQuota::test_get_quota` (the `kb_setup` fixture downgrades to free).
|
||||
|
||||
## Resume point — DO THIS NEXT
|
||||
|
||||
**Ship:** Commit this review-fix pass, then mark PR #155 ready-for-review and demo to stakeholder.
|
||||
1. Open the PR for branch `feat/self-serve-signup-spec`. Use `gh pr create` against `main`. Suggested title: `feat: self-serve signup backend (Phase 1)`. Body should mention dark-launch posture (every new endpoint is gated by env config, not a feature flag — see Task 26 §3 in the plan).
|
||||
2. Phase 2 (frontend + cutover) lives in a sibling plan: `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend.md` (assumed; verify path). It's the next logical task once Phase 1 ships.
|
||||
|
||||
Optional before shipping:
|
||||
- Record Loom demo walking through the escalation flow end-to-end
|
||||
## Followups deferred from this session
|
||||
|
||||
## Key files changed this session
|
||||
- **OAuth callbacks don't call `_store_refresh_token`.** The Google/Microsoft callbacks issue a refresh JWT but never persist its hash to `refresh_tokens` (the password-login flow does via `auth.py:_store_refresh_token`). Result: refresh-token revocation/rotation lookups won't find OAuth-issued tokens. Decide before Phase 2 dark-launch whether to backfill — likely yes, by extracting `_store_refresh_token` to a shared module and calling it from `_sign_in_or_register`.
|
||||
- **`stripe_enabled` was relaxed** in Task 14 from `bool(STRIPE_SECRET_KEY) and bool(STRIPE_WEBHOOK_SECRET)` to just the secret key. The webhook handler in Task 16 independently checks `STRIPE_WEBHOOK_SECRET` before calling `construct_event`, so signature verification is still safe — but if any other code reads `stripe_enabled` and assumes the webhook secret is set, that's a latent bug. Audit before Phase 2 cutover.
|
||||
- **`backend/app/core/stripe_handlers.py`** is a stub module that's no longer referenced after Task 16. Safe to delete in a follow-up; left in place to keep Phase 1 diff focused.
|
||||
- **Pilot backfill migration `c6cbfc534fad` has not been applied to prod yet.** It runs once at deploy time and is forward-only.
|
||||
|
||||
- `backend/app/services/handoff_manager.py` — `_generate_handoff_summary` replaces old assessment pair; `enrich_escalation_async` unified; `claim_session` eager-loads `handed_off_by_user`
|
||||
- `backend/app/api/endpoints/ai_sessions.py` — escalation queue excludes the current user's own escalations
|
||||
- `backend/app/api/endpoints/session_handoffs.py` — self-claim returns 403
|
||||
- `backend/app/services/flowpilot_engine.py` — `generate_status_update` early-returns saved prose for `context='escalation'`
|
||||
- `backend/app/schemas/session_handoff.py` — `handed_off_by_name: str | None = None` added
|
||||
- `backend/app/api/endpoints/session_handoffs.py` — both create + claim endpoints pass `handed_off_by_name`
|
||||
- `frontend/src/types/branching.ts` — `HandoffResponse` updated with `summary_prose`, `what_we_know`, `confidence: string`, `handed_off_by_name`
|
||||
- `frontend/src/components/flowpilot/HandoffContextScreen.tsx` — 3-option CTA; `hasTaskLane`, `activeOptionKey`, `onContinue/onAIAnalysis/onOwnThing` props
|
||||
- `frontend/src/components/assistant/TaskLane.tsx` — `id="task-lane-card-{idx}"` on all card variants
|
||||
- `frontend/src/pages/AssistantChatPage.tsx` — `handleContinue`, `handleAIAnalysis`, `handleOwnThing` handlers; chip → card navigation; `activeOptionKey` state
|
||||
- `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py` — regression coverage for atomic/idempotent claim, self-claim rejection, queue self-exclusion, and pre-flush handoff ID
|
||||
## Environment notes (carry-forward)
|
||||
|
||||
## Watch-outs
|
||||
|
||||
- Dev stack: backend `:8000`, frontend `:5173`, postgres `:5433` (docker-compose). HMR works.
|
||||
- Test users (Acme MSP, password `TestPass123!`): `engineer@resolutionflow.example.com` (junior), `teamadmin@resolutionflow.example.com` (senior).
|
||||
- `handleAIAnalysis` pre-adds `urlSessionId` to `loadedChatIdsRef` before dismissing so the normal selectChat effect doesn't double-fire. It then calls `selectChat` manually before sending the briefing.
|
||||
- Legacy `claiming` / `handleStartHere` on `AssistantChatPage` was removed; `activeOptionKey !== null` is the active pre-claim processing signal.
|
||||
- The bus is acceptable for v1 pilot scale only (Railway single-replica). Redis pub/sub is the swap when horizontal scaling appears.
|
||||
- Code-server LXC has bun + docker but no native python/node/npm. Use `docker exec resolutionflow_{backend,frontend} ...` for build/test commands.
|
||||
- Pytest WORKDIR is `/app` — test paths in pytest commands are `tests/<file>`, NOT `backend/tests/<file>`.
|
||||
- Backend pytest cmd: `docker exec resolutionflow_backend pytest tests/<path> -v --override-ini="addopts="`. The full run takes ~25 min.
|
||||
- Alembic via `docker exec -w /app resolutionflow_backend alembic ...`. Never pass `--rev-id`.
|
||||
- No `gh` CLI on this LXC — use the Gitea API (`$GITEA_TOKEN` in `.claude/settings.local.json`) for PR/issue work, or run `gh` from a host that has it.
|
||||
- Headless Chromium (`/qa`, `/browse`) needs `CONTAINER=1` in the env launching the browse server (LXC namespace constraint).
|
||||
|
||||
@@ -89,6 +89,15 @@ python -m scripts.seed_trees # seed (from
|
||||
|
||||
**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
|
||||
|
||||
@@ -12,6 +12,94 @@
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
## Up next
|
||||
|
||||
- [ ] **Parallelize backend pytest with pytest-xdist.** ✅ landing as PR #151. Verified locally: backend suite 22 min → 4m 28s with `-n auto` on the 8-core homelab runner. Per-worker DB isolation via `PYTEST_XDIST_WORKER` in conftest.py.
|
||||
None selected. Pick from the backlog below or `03-DEVELOPMENT-ROADMAP.md`.
|
||||
|
||||
## Backlog
|
||||
|
||||
- [ ] **Frontend lint warnings cleanup.** 23 `react-hooks/exhaustive-deps` warnings remain after PR #149 (mostly missing-deps in useEffect). Either fix them or audit them for known-safe ones and add eslint-disable comments. Not blocking CI today.
|
||||
- [ ] **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.
|
||||
@@ -20,4 +20,6 @@
|
||||
|
||||
- [ ] **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.
|
||||
|
||||
- [ ] **(MOVED IN-SCOPE for Escalation Mode v1, 2026-04-27)** ~~Add role gate to handoff claim endpoint.~~ Codex review correctly flagged this as wedge-relevant (the race-condition story depends on auth gating). Now part of the Escalation Mode v1 build, not a deferred TODO.
|
||||
- [ ] **`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.
|
||||
|
||||
@@ -40,7 +40,7 @@ Prefer correct architecture over minimal diff. Flag "simpler approach" tradeoffs
|
||||
### 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.
|
||||
- **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
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -28,7 +28,14 @@ All notable changes to ResolutionFlow are documented here.
|
||||
|
||||
## [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)
|
||||
@@ -42,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
|
||||
@@ -52,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.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **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 1, 2026
|
||||
|
||||
---
|
||||
|
||||
@@ -10,6 +10,14 @@
|
||||
|
||||
---
|
||||
|
||||
## Recently shipped (post-0.1.0.0)
|
||||
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## What's Complete
|
||||
|
||||
### Core Platform
|
||||
|
||||
@@ -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,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,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,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,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")
|
||||
@@ -83,11 +83,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 +107,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
|
||||
|
||||
|
||||
@@ -241,3 +222,114 @@ 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",
|
||||
}
|
||||
|
||||
|
||||
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/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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -19,7 +19,7 @@ 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.core.security import verify_password
|
||||
@@ -260,7 +260,7 @@ 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."""
|
||||
code = secrets.token_urlsafe(16)
|
||||
|
||||
expires_at = None
|
||||
@@ -276,11 +276,109 @@ 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:
|
||||
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 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,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timezone, timedelta
|
||||
@@ -41,6 +42,8 @@ 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"])
|
||||
|
||||
|
||||
@@ -62,6 +65,22 @@ def _generate_display_code() -> str:
|
||||
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,6 +127,12 @@ 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:
|
||||
@@ -195,26 +220,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 +253,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 +296,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,
|
||||
@@ -276,6 +334,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,
|
||||
@@ -441,6 +500,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 +544,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)
|
||||
|
||||
52
backend/app/api/endpoints/billing.py
Normal file
52
backend/app/api/endpoints/billing.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
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 (
|
||||
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)
|
||||
123
backend/app/api/endpoints/oauth.py
Normal file
123
backend/app/api/endpoints/oauth.py
Normal file
@@ -0,0 +1,123 @@
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, create_refresh_token
|
||||
from app.models.account import Account
|
||||
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
|
||||
) -> tuple[User, bool]:
|
||||
"""Returns (user, is_new_user). Idempotent on (provider, provider_subject)."""
|
||||
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 is_new_user:
|
||||
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)
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=create_refresh_token({"sub": str(user.id)}),
|
||||
is_new_user=is_new,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=create_refresh_token({"sub": str(user.id)}),
|
||||
is_new_user=is_new,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +1,10 @@
|
||||
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,
|
||||
@@ -19,6 +23,7 @@ from app.api.endpoints import (
|
||||
analytics,
|
||||
assistant_chat,
|
||||
auth,
|
||||
billing,
|
||||
beta_feedback,
|
||||
beta_signup,
|
||||
branding,
|
||||
@@ -36,6 +41,7 @@ from app.api.endpoints import (
|
||||
maintenance_schedules,
|
||||
network_diagrams,
|
||||
notifications,
|
||||
oauth as oauth_endpoints,
|
||||
onboarding,
|
||||
public_templates,
|
||||
ratings,
|
||||
@@ -77,6 +83,8 @@ 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)
|
||||
@@ -102,23 +110,36 @@ 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(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)
|
||||
@@ -126,31 +147,31 @@ 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(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)
|
||||
|
||||
@@ -94,11 +94,12 @@ 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
|
||||
|
||||
@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
|
||||
@@ -193,6 +194,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
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ 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
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -138,4 +142,8 @@ __all__ = [
|
||||
"SessionSuggestedFix",
|
||||
"DraftTemplate",
|
||||
"AccountSettings",
|
||||
"OAuthIdentity",
|
||||
"PlanBilling",
|
||||
"SalesLead",
|
||||
"StripeEvent",
|
||||
]
|
||||
|
||||
@@ -48,6 +48,8 @@ class Account(Base):
|
||||
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)
|
||||
|
||||
# SSO / SAML groundwork (Task 11)
|
||||
sso_enabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
@@ -32,8 +32,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", "team") 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", "team"):
|
||||
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
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
@@ -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)
|
||||
@@ -76,6 +76,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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
40
backend/app/schemas/billing.py
Normal file
40
backend/app/schemas/billing.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from typing import Literal, Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CheckoutSessionCreate(BaseModel):
|
||||
plan: Literal["pro", "starter", "team", "enterprise"]
|
||||
seats: int
|
||||
billing_interval: Literal["monthly", "annual"] = "monthly"
|
||||
|
||||
|
||||
class CheckoutSessionResponse(BaseModel):
|
||||
url: str
|
||||
|
||||
|
||||
class SubscriptionState(BaseModel):
|
||||
status: str
|
||||
plan: str
|
||||
current_period_start: Optional[datetime]
|
||||
current_period_end: Optional[datetime]
|
||||
cancel_at_period_end: bool
|
||||
seat_limit: Optional[int]
|
||||
has_pro_entitlement: bool
|
||||
is_paid: bool
|
||||
|
||||
|
||||
class PlanBillingState(BaseModel):
|
||||
display_name: str
|
||||
description: Optional[str] = None
|
||||
monthly_price_cents: Optional[int] = None
|
||||
annual_price_cents: Optional[int] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class BillingStateResponse(BaseModel):
|
||||
subscription: SubscriptionState
|
||||
plan_billing: Optional[PlanBillingState]
|
||||
plan_limits: Dict[str, Any]
|
||||
enabled_features: Dict[str, bool]
|
||||
13
backend/app/schemas/oauth.py
Normal file
13
backend/app/schemas/oauth.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OAuthCallbackPayload(BaseModel):
|
||||
code: str
|
||||
state: str | None = None
|
||||
|
||||
|
||||
class OAuthCallbackResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
is_new_user: bool
|
||||
@@ -20,6 +20,7 @@ FixStatus = Literal[
|
||||
"applied_success",
|
||||
"applied_failed",
|
||||
"applied_partial",
|
||||
"applied_pending",
|
||||
"dismissed",
|
||||
]
|
||||
|
||||
@@ -40,6 +41,7 @@ class SessionSuggestedFixResponse(BaseModel):
|
||||
applied_at: datetime | None
|
||||
verified_at: datetime | None
|
||||
partial_notes: str | None
|
||||
pending_reason: str | None
|
||||
failure_reason: str | None
|
||||
ai_outcome_proposal: dict[str, Any] | None
|
||||
|
||||
@@ -91,7 +93,11 @@ class SessionSuggestedFixDecisionResponse(BaseModel):
|
||||
# Subset of FixStatus that the engineer can set via the outcome endpoint —
|
||||
# `proposed` is excluded because you can't un-decide a fix back to "proposed".
|
||||
FixOutcome = Literal[
|
||||
"applied_success", "applied_failed", "applied_partial", "dismissed"
|
||||
"applied_success",
|
||||
"applied_failed",
|
||||
"applied_partial",
|
||||
"applied_pending",
|
||||
"dismissed",
|
||||
]
|
||||
|
||||
|
||||
@@ -103,14 +109,18 @@ class SessionSuggestedFixOutcomeRequest(BaseModel):
|
||||
engineer took); outcome captures whether the fix actually worked.
|
||||
|
||||
Allowed transitions:
|
||||
- from `proposed` or `applied_partial`: any outcome is valid
|
||||
(partial is parked, not terminal — the engineer may update notes,
|
||||
abandon via dismiss, or advance to success/failed)
|
||||
- from `proposed`, `applied_partial`, or `applied_pending`: any outcome
|
||||
is valid. Partial means "did some of it"; pending means "did all of
|
||||
it but verification is deferred (waiting on client, async sync, etc)".
|
||||
Both are parked, not terminal — the engineer may advance them to
|
||||
success/failed/dismiss.
|
||||
- from any terminal outcome (`applied_success`, `applied_failed`,
|
||||
`dismissed`): server returns 409
|
||||
"""
|
||||
outcome: FixOutcome
|
||||
# Required for applied_partial, optional for applied_failed, ignored otherwise.
|
||||
# Required for applied_partial AND applied_pending; optional for
|
||||
# applied_failed; ignored otherwise. For pending, this is the
|
||||
# "what are you waiting on?" reason (e.g. "client power-cycling router").
|
||||
notes: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
|
||||
296
backend/app/services/billing.py
Normal file
296
backend/app/services/billing.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""Single billing service module. Stripe is the only impl — no provider
|
||||
abstraction. Account row is canonical local state; Stripe is canonical
|
||||
remote state; the webhook handler bridges the two."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
import stripe
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.account import Account
|
||||
from app.models.plan_billing import PlanBilling
|
||||
from app.models.stripe_event import StripeEvent
|
||||
from app.models.subscription import Subscription
|
||||
|
||||
|
||||
TRIAL_DAYS = 14
|
||||
|
||||
|
||||
class BillingService:
|
||||
@staticmethod
|
||||
async def start_trial(db: AsyncSession, account_id) -> Subscription:
|
||||
"""Idempotent. Creates a trialing Subscription on Pro for the account if
|
||||
one doesn't exist; otherwise returns the existing row."""
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account_id)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
sub = Subscription(
|
||||
account_id=account_id,
|
||||
plan="pro",
|
||||
status="trialing",
|
||||
current_period_start=datetime.now(timezone.utc),
|
||||
current_period_end=datetime.now(timezone.utc) + timedelta(days=TRIAL_DAYS),
|
||||
)
|
||||
db.add(sub)
|
||||
await db.commit()
|
||||
await db.refresh(sub)
|
||||
return sub
|
||||
|
||||
@staticmethod
|
||||
async def create_checkout_session(
|
||||
db: AsyncSession,
|
||||
account: Account,
|
||||
plan: str,
|
||||
seats: int,
|
||||
billing_interval: str,
|
||||
success_url: str,
|
||||
cancel_url: str,
|
||||
) -> str:
|
||||
"""Create a Stripe Checkout Session for subscription purchase. If the
|
||||
account currently has a trialing subscription with time remaining, that
|
||||
trial end is preserved on the new Stripe subscription so the user
|
||||
isn't charged early."""
|
||||
if not settings.stripe_enabled:
|
||||
raise RuntimeError("Stripe not configured")
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
|
||||
plan_billing = (await db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == plan)
|
||||
)).scalar_one_or_none()
|
||||
if plan_billing is None:
|
||||
raise ValueError(f"Unknown plan: {plan}")
|
||||
price_id = (
|
||||
plan_billing.stripe_monthly_price_id if billing_interval == "monthly"
|
||||
else plan_billing.stripe_annual_price_id
|
||||
)
|
||||
if price_id is None:
|
||||
raise RuntimeError(
|
||||
f"Plan '{plan}' has no Stripe price for {billing_interval}"
|
||||
)
|
||||
|
||||
if account.stripe_customer_id is None:
|
||||
customer = stripe.Customer.create(
|
||||
email=None,
|
||||
metadata={"account_id": str(account.id)},
|
||||
)
|
||||
account.stripe_customer_id = customer.id
|
||||
await db.commit()
|
||||
|
||||
sub = (await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account.id)
|
||||
)).scalar_one_or_none()
|
||||
subscription_data = {}
|
||||
if (
|
||||
sub
|
||||
and sub.status == "trialing"
|
||||
and sub.current_period_end
|
||||
and sub.current_period_end > datetime.now(timezone.utc)
|
||||
):
|
||||
subscription_data["trial_end"] = int(sub.current_period_end.timestamp())
|
||||
|
||||
session = stripe.checkout.Session.create(
|
||||
customer=account.stripe_customer_id,
|
||||
line_items=[{"price": price_id, "quantity": seats}],
|
||||
mode="subscription",
|
||||
subscription_data=subscription_data or None,
|
||||
success_url=success_url,
|
||||
cancel_url=cancel_url,
|
||||
allow_promotion_codes=False,
|
||||
)
|
||||
return session.url
|
||||
|
||||
@staticmethod
|
||||
async def get_billing_state(db: AsyncSession, account):
|
||||
"""Aggregate Subscription + PlanLimits + PlanBilling + resolved feature
|
||||
flags for the account."""
|
||||
from app.models.plan_limits import PlanLimits
|
||||
from app.models.plan_billing import PlanBilling
|
||||
from app.models.feature_flag import (
|
||||
FeatureFlag, PlanFeatureDefault, AccountFeatureOverride,
|
||||
)
|
||||
|
||||
sub = (await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account.id)
|
||||
)).scalar_one_or_none()
|
||||
if sub is None:
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=404, detail="No subscription for account")
|
||||
|
||||
pl = (await db.execute(
|
||||
select(PlanLimits).where(PlanLimits.plan == sub.plan)
|
||||
)).scalar_one_or_none()
|
||||
pb = (await db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == sub.plan)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
# Resolved feature flags: plan defaults overridden by account overrides
|
||||
defaults = (await db.execute(
|
||||
select(PlanFeatureDefault, FeatureFlag)
|
||||
.join(FeatureFlag, PlanFeatureDefault.flag_id == FeatureFlag.id)
|
||||
.where(PlanFeatureDefault.plan == sub.plan)
|
||||
)).all()
|
||||
resolved = {flag.flag_key: pfd.enabled for pfd, flag in defaults}
|
||||
overrides = (await db.execute(
|
||||
select(AccountFeatureOverride, FeatureFlag)
|
||||
.join(FeatureFlag, AccountFeatureOverride.flag_id == FeatureFlag.id)
|
||||
.where(AccountFeatureOverride.account_id == account.id)
|
||||
)).all()
|
||||
for ovr, flag in overrides:
|
||||
resolved[flag.flag_key] = ovr.enabled
|
||||
|
||||
return {
|
||||
"subscription": {
|
||||
"status": sub.status,
|
||||
"plan": sub.plan,
|
||||
"current_period_start": sub.current_period_start,
|
||||
"current_period_end": sub.current_period_end,
|
||||
"cancel_at_period_end": sub.cancel_at_period_end,
|
||||
"seat_limit": sub.seat_limit,
|
||||
"has_pro_entitlement": sub.has_pro_entitlement,
|
||||
"is_paid": sub.is_paid,
|
||||
},
|
||||
"plan_billing": pb,
|
||||
"plan_limits": _plan_limits_to_dict(pl) if pl else {},
|
||||
"enabled_features": resolved,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def apply_subscription_event(
|
||||
db: AsyncSession, event_id: str, event_type: str, payload: dict
|
||||
) -> bool:
|
||||
"""Idempotent. Returns True if the event was applied; False if it had
|
||||
already been processed (idempotent ack). The webhook handler returns 200
|
||||
either way."""
|
||||
try:
|
||||
db.add(StripeEvent(
|
||||
id=event_id,
|
||||
event_type=event_type,
|
||||
payload_excerpt=_excerpt(payload),
|
||||
))
|
||||
await db.commit()
|
||||
except IntegrityError:
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
if event_type == "checkout.session.completed":
|
||||
await _handle_checkout_completed(db, payload)
|
||||
elif event_type == "customer.subscription.updated":
|
||||
await _handle_subscription_updated(db, payload)
|
||||
elif event_type == "customer.subscription.deleted":
|
||||
await _handle_subscription_deleted(db, payload)
|
||||
elif event_type == "invoice.payment_failed":
|
||||
await _handle_payment_failed(db, payload)
|
||||
elif event_type == "invoice.payment_succeeded":
|
||||
await _handle_payment_succeeded(db, payload)
|
||||
return True
|
||||
|
||||
|
||||
def _plan_limits_to_dict(pl) -> dict:
|
||||
return {c.name: getattr(pl, c.name) for c in pl.__table__.columns}
|
||||
|
||||
|
||||
def _excerpt(payload: dict) -> dict:
|
||||
obj = payload.get("data", {}).get("object", {})
|
||||
return {
|
||||
"object_id": obj.get("id"),
|
||||
"customer": obj.get("customer"),
|
||||
"subscription": obj.get("subscription"),
|
||||
"status": obj.get("status"),
|
||||
}
|
||||
|
||||
|
||||
async def _handle_checkout_completed(db: AsyncSession, payload: dict):
|
||||
obj = payload["data"]["object"]
|
||||
customer_id = obj["customer"]
|
||||
subscription_id = obj["subscription"]
|
||||
|
||||
account = (await db.execute(
|
||||
select(Account).where(Account.stripe_customer_id == customer_id)
|
||||
)).scalar_one_or_none()
|
||||
if account is None:
|
||||
return
|
||||
|
||||
sub = (await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account.id)
|
||||
)).scalar_one_or_none()
|
||||
if sub is None:
|
||||
return
|
||||
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
stripe_sub = stripe.Subscription.retrieve(subscription_id)
|
||||
sub.stripe_subscription_id = subscription_id
|
||||
sub.stripe_price_id = stripe_sub["items"]["data"][0]["price"]["id"]
|
||||
sub.status = "active"
|
||||
sub.current_period_start = datetime.fromtimestamp(stripe_sub["current_period_start"], tz=timezone.utc)
|
||||
sub.current_period_end = datetime.fromtimestamp(stripe_sub["current_period_end"], tz=timezone.utc)
|
||||
sub.seat_limit = stripe_sub["items"]["data"][0]["quantity"]
|
||||
pb = (await db.execute(
|
||||
select(PlanBilling).where(
|
||||
(PlanBilling.stripe_monthly_price_id == sub.stripe_price_id) |
|
||||
(PlanBilling.stripe_annual_price_id == sub.stripe_price_id)
|
||||
)
|
||||
)).scalar_one_or_none()
|
||||
if pb is not None:
|
||||
sub.plan = pb.plan
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _handle_subscription_updated(db: AsyncSession, payload: dict):
|
||||
obj = payload["data"]["object"]
|
||||
sub = (await db.execute(
|
||||
select(Subscription).where(Subscription.stripe_subscription_id == obj["id"])
|
||||
)).scalar_one_or_none()
|
||||
if sub is None:
|
||||
return
|
||||
sub.status = obj["status"]
|
||||
sub.current_period_start = datetime.fromtimestamp(obj["current_period_start"], tz=timezone.utc)
|
||||
sub.current_period_end = datetime.fromtimestamp(obj["current_period_end"], tz=timezone.utc)
|
||||
sub.cancel_at_period_end = obj.get("cancel_at_period_end", False)
|
||||
sub.seat_limit = obj["items"]["data"][0]["quantity"]
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _handle_subscription_deleted(db: AsyncSession, payload: dict):
|
||||
obj = payload["data"]["object"]
|
||||
sub = (await db.execute(
|
||||
select(Subscription).where(Subscription.stripe_subscription_id == obj["id"])
|
||||
)).scalar_one_or_none()
|
||||
if sub is None:
|
||||
return
|
||||
sub.status = "canceled"
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _handle_payment_failed(db: AsyncSession, payload: dict):
|
||||
obj = payload["data"]["object"]
|
||||
subscription_id = obj.get("subscription")
|
||||
if not subscription_id:
|
||||
return
|
||||
sub = (await db.execute(
|
||||
select(Subscription).where(Subscription.stripe_subscription_id == subscription_id)
|
||||
)).scalar_one_or_none()
|
||||
if sub is None:
|
||||
return
|
||||
sub.status = "past_due"
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def _handle_payment_succeeded(db: AsyncSession, payload: dict):
|
||||
obj = payload["data"]["object"]
|
||||
subscription_id = obj.get("subscription")
|
||||
if not subscription_id:
|
||||
return
|
||||
sub = (await db.execute(
|
||||
select(Subscription).where(Subscription.stripe_subscription_id == subscription_id)
|
||||
)).scalar_one_or_none()
|
||||
if sub is None:
|
||||
return
|
||||
if sub.status == "past_due":
|
||||
sub.status = "active"
|
||||
await db.commit()
|
||||
@@ -63,6 +63,9 @@ the active suggested fix, as given in the input bundle under "Outcome status":>
|
||||
provided. State that it did not resolve the issue.
|
||||
- applied_partial: Include the fix as a partially tried path. Include partial \
|
||||
notes if provided. Indicate it was not fully completed or not verified.
|
||||
- applied_pending: List the fix as applied but awaiting verification. Include \
|
||||
the pending reason if provided. Make it clear the next engineer should follow \
|
||||
up to confirm it worked.
|
||||
- applied_success: Note that the fix was applied and verified but escalation \
|
||||
is still needed for another reason (unusual — reflect this accurately).
|
||||
- dismissed: Do not mention the fix as a tried path; it was only considered.
|
||||
@@ -80,6 +83,8 @@ symptoms are still being narrowed."
|
||||
- applied_failed or dismissed: Say the proposed fix did not hold or was set \
|
||||
aside. State any remaining uncertainty.
|
||||
- applied_partial: Note the partial application and what remains open.
|
||||
- applied_pending: Note that the fix is in place but unverified. Reference the \
|
||||
pending reason. Frame this as the leading hypothesis pending confirmation.
|
||||
- applied_success: Unusual in an escalate path — state the fix resolved the \
|
||||
original symptom but a new or related issue requires escalation.
|
||||
|
||||
@@ -92,6 +97,8 @@ accordingly — e.g. suggest alternatives or deeper investigation paths, \
|
||||
drawing on the failure reason if provided. \
|
||||
If the fix is partially applied (applied_partial), the first step is typically \
|
||||
to complete or verify it. \
|
||||
If the fix is pending verification (applied_pending), the first step is \
|
||||
typically to confirm whether the fix held — reference what was being waited on. \
|
||||
If the fix is still proposed (no outcome), the first step is to try it if \
|
||||
confidence is high (>80%).>
|
||||
|
||||
@@ -299,6 +306,8 @@ class EscalationPackageGeneratorService:
|
||||
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
|
||||
if active_fix.partial_notes:
|
||||
lines.append(f"Partial notes: {active_fix.partial_notes}")
|
||||
if active_fix.pending_reason:
|
||||
lines.append(f"Pending reason: {active_fix.pending_reason}")
|
||||
if active_fix.failure_reason:
|
||||
lines.append(f"Failure reason: {active_fix.failure_reason}")
|
||||
|
||||
|
||||
71
backend/app/services/oauth_providers.py
Normal file
71
backend/app/services/oauth_providers.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""OAuth provider helpers. Each provider exposes:
|
||||
- exchange_code(code, redirect_uri) -> OAuthProfile
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@dataclass
|
||||
class OAuthProfile:
|
||||
provider_subject: str
|
||||
email: str
|
||||
name: str
|
||||
|
||||
|
||||
async def google_exchange_code(code: str, redirect_uri: str) -> OAuthProfile:
|
||||
async with httpx.AsyncClient(timeout=10) as cli:
|
||||
token_response = await cli.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": settings.GOOGLE_CLIENT_ID,
|
||||
"client_secret": settings.GOOGLE_CLIENT_SECRET,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
)
|
||||
token_response.raise_for_status()
|
||||
access_token = token_response.json()["access_token"]
|
||||
|
||||
userinfo = await cli.get(
|
||||
"https://openidconnect.googleapis.com/v1/userinfo",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
userinfo.raise_for_status()
|
||||
data = userinfo.json()
|
||||
return OAuthProfile(
|
||||
provider_subject=data["sub"],
|
||||
email=data["email"],
|
||||
name=data.get("name") or data["email"].split("@")[0],
|
||||
)
|
||||
|
||||
|
||||
async def microsoft_exchange_code(code: str, redirect_uri: str) -> OAuthProfile:
|
||||
async with httpx.AsyncClient(timeout=10) as cli:
|
||||
token_response = await cli.post(
|
||||
"https://login.microsoftonline.com/common/oauth2/v2.0/token",
|
||||
data={
|
||||
"code": code,
|
||||
"client_id": settings.MS_CLIENT_ID,
|
||||
"client_secret": settings.MS_CLIENT_SECRET,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
"scope": "openid email profile",
|
||||
},
|
||||
)
|
||||
token_response.raise_for_status()
|
||||
access_token = token_response.json()["access_token"]
|
||||
|
||||
userinfo = await cli.get(
|
||||
"https://graph.microsoft.com/v1.0/me",
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
)
|
||||
userinfo.raise_for_status()
|
||||
data = userinfo.json()
|
||||
return OAuthProfile(
|
||||
provider_subject=data["id"],
|
||||
email=data.get("mail") or data["userPrincipalName"],
|
||||
name=data.get("displayName") or data["userPrincipalName"].split("@")[0],
|
||||
)
|
||||
@@ -83,6 +83,10 @@ state means the engineer resolved the issue another way; the note should cover \
|
||||
that actual resolution, not just the failed attempt.
|
||||
- applied_partial: Note that the fix was partially applied. If partial_notes \
|
||||
are provided, include them. Then describe the final resolution path taken.
|
||||
- applied_pending: Note that the fix was applied and verification is pending. \
|
||||
If pending_reason is provided, include it as the provided waiting reason. \
|
||||
Frame the resolution as provisional — the fix is in place but not yet \
|
||||
confirmed. Do not write closure language.
|
||||
- dismissed: Treat the fix as considered and set aside. Do not center the note \
|
||||
on it. Describe the resolution based on what was actually confirmed and done.
|
||||
- proposed (no outcome yet): Write "Resolution not yet applied — fix proposed: \
|
||||
@@ -322,6 +326,8 @@ class ResolutionNoteGeneratorService:
|
||||
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
|
||||
if active_fix.partial_notes:
|
||||
lines.append(f"Partial notes: {active_fix.partial_notes}")
|
||||
if active_fix.pending_reason:
|
||||
lines.append(f"Pending reason: {active_fix.pending_reason}")
|
||||
if active_fix.failure_reason:
|
||||
lines.append(f"Failure reason: {active_fix.failure_reason}")
|
||||
|
||||
|
||||
@@ -97,7 +97,18 @@ async def main() -> None:
|
||||
)
|
||||
row = result.first()
|
||||
if row:
|
||||
print(f" [SKIP] {cfg['email']} already exists")
|
||||
# Backfill email_verified_at for existing rows so older test
|
||||
# users created before this script set the field still bypass
|
||||
# the 7-day verification grace.
|
||||
await conn.execute(
|
||||
text("""
|
||||
UPDATE users
|
||||
SET email_verified_at = COALESCE(email_verified_at, :now)
|
||||
WHERE email = :email
|
||||
"""),
|
||||
{"email": cfg["email"], "now": now},
|
||||
)
|
||||
print(f" [SKIP] {cfg['email']} already exists (email_verified_at backfilled if null)")
|
||||
if cfg["key"] == "team_admin":
|
||||
team_account_id = row.account_id
|
||||
continue
|
||||
@@ -130,12 +141,17 @@ async def main() -> None:
|
||||
|
||||
# ---- Create User ----
|
||||
user_id = uuid.uuid4()
|
||||
# email_verified_at is stamped at seed time so test users bypass the
|
||||
# 7-day verification grace immediately. Without this, fixtures hit
|
||||
# require_verified_email_after_grace once their created_at ages past
|
||||
# 7 days and get walled out of protected routes.
|
||||
await conn.execute(
|
||||
text("""
|
||||
INSERT INTO users (id, email, password_hash, name, role, is_super_admin,
|
||||
is_team_admin, is_active, account_id, account_role, created_at)
|
||||
is_team_admin, is_active, account_id, account_role,
|
||||
created_at, email_verified_at)
|
||||
VALUES (:id, :email, :pw, :name, 'engineer', :is_sa, :is_ta, true,
|
||||
:account_id, :account_role, :now)
|
||||
:account_id, :account_role, :now, :now)
|
||||
"""),
|
||||
{
|
||||
"id": user_id,
|
||||
|
||||
@@ -248,13 +248,23 @@ async def client(test_db: AsyncSession):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_user(client):
|
||||
async def test_user(client, test_db):
|
||||
"""
|
||||
Create a test user and return their credentials.
|
||||
|
||||
Also seeds a default active Pro Subscription so Pro-guarded routes work
|
||||
in tests. Phase 1 Task 11 added require_active_subscription; without
|
||||
this seed every existing test that hits a Pro router would 402. The
|
||||
register endpoint creates a default `free`/`active` Subscription, so
|
||||
we delete-then-insert to avoid the unique account_id constraint.
|
||||
|
||||
Returns:
|
||||
dict with email, password, and user_data
|
||||
"""
|
||||
import uuid
|
||||
from sqlalchemy import delete
|
||||
from app.models.subscription import Subscription
|
||||
|
||||
user_data = {
|
||||
"email": "test@example.com",
|
||||
"password": "TestPassword123!",
|
||||
@@ -264,6 +274,13 @@ async def test_user(client):
|
||||
response = await client.post("/api/v1/auth/register", json=user_data)
|
||||
assert response.status_code == 200 or response.status_code == 201
|
||||
|
||||
account_id = uuid.UUID(response.json()["account_id"])
|
||||
await test_db.execute(
|
||||
delete(Subscription).where(Subscription.account_id == account_id)
|
||||
)
|
||||
test_db.add(Subscription(account_id=account_id, plan="pro", status="active"))
|
||||
await test_db.commit()
|
||||
|
||||
return {
|
||||
"email": user_data["email"],
|
||||
"password": user_data["password"],
|
||||
@@ -346,11 +363,14 @@ async def test_admin(client, test_db):
|
||||
Create a test super-admin user.
|
||||
|
||||
Registers as engineer (the only role available at registration),
|
||||
then promotes to super_admin directly via the DB session.
|
||||
then promotes to super_admin directly via the DB session. Also
|
||||
seeds a default active Pro Subscription (see test_user docstring).
|
||||
"""
|
||||
import uuid
|
||||
from uuid import UUID as PyUUID
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, delete
|
||||
from app.models.user import User
|
||||
from app.models.subscription import Subscription
|
||||
|
||||
admin_data = {
|
||||
"email": "admin@example.com",
|
||||
@@ -365,6 +385,12 @@ async def test_admin(client, test_db):
|
||||
result = await test_db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one()
|
||||
user.is_super_admin = True
|
||||
|
||||
account_id = uuid.UUID(response.json()["account_id"])
|
||||
await test_db.execute(
|
||||
delete(Subscription).where(Subscription.account_id == account_id)
|
||||
)
|
||||
test_db.add(Subscription(account_id=account_id, plan="pro", status="active"))
|
||||
await test_db.commit()
|
||||
|
||||
return {
|
||||
|
||||
180
backend/tests/test_account_invite_extensions.py
Normal file
180
backend/tests/test_account_invite_extensions.py
Normal file
@@ -0,0 +1,180 @@
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from sqlalchemy import select
|
||||
from app.models.account_invite import AccountInvite
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_invite_sends_email_and_stamps_email_sent_at(
|
||||
client, test_db, test_user, auth_headers
|
||||
):
|
||||
"""Regression: today's create_invite does NOT send email. After this task, it MUST."""
|
||||
with patch(
|
||||
"app.core.email.EmailService.send_account_invite_email",
|
||||
new_callable=AsyncMock, return_value=True,
|
||||
) as mock_send:
|
||||
response = await client.post(
|
||||
"/api/v1/accounts/me/invites",
|
||||
json={"email": "teammate@example.com", "role": "engineer"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 201, response.json()
|
||||
mock_send.assert_called_once()
|
||||
kwargs = mock_send.call_args.kwargs
|
||||
assert kwargs["to_email"] == "teammate@example.com"
|
||||
assert kwargs["role"] == "engineer"
|
||||
assert kwargs["code"]
|
||||
|
||||
invite = (await test_db.execute(
|
||||
select(AccountInvite).where(AccountInvite.email == "teammate@example.com")
|
||||
)).scalar_one()
|
||||
assert invite.email_sent_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_invite_email_failure_still_creates_row(
|
||||
client, test_db, test_user, auth_headers
|
||||
):
|
||||
"""When EmailService returns False, the invite row is still created but
|
||||
email_sent_at remains NULL."""
|
||||
with patch(
|
||||
"app.core.email.EmailService.send_account_invite_email",
|
||||
new_callable=AsyncMock, return_value=False,
|
||||
):
|
||||
response = await client.post(
|
||||
"/api/v1/accounts/me/invites",
|
||||
json={"email": "fail-mail@example.com", "role": "engineer"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
|
||||
invite = (await test_db.execute(
|
||||
select(AccountInvite).where(AccountInvite.email == "fail-mail@example.com")
|
||||
)).scalar_one()
|
||||
assert invite.email_sent_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bulk_invite_creates_n_rows_and_sends_n_emails(
|
||||
client, test_db, test_user, auth_headers
|
||||
):
|
||||
with patch(
|
||||
"app.core.email.EmailService.send_account_invite_email",
|
||||
new_callable=AsyncMock, return_value=True,
|
||||
) as mock_send:
|
||||
response = await client.post(
|
||||
"/api/v1/accounts/me/invites/bulk",
|
||||
json={"invites": [
|
||||
{"email": "a@example.com", "role": "engineer"},
|
||||
{"email": "b@example.com", "role": "engineer"},
|
||||
{"email": "c@example.com", "role": "viewer"},
|
||||
]},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 201, response.json()
|
||||
body = response.json()
|
||||
assert len(body["created"]) == 3
|
||||
assert body["failed"] == []
|
||||
assert mock_send.call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_invite_sets_revoked_at(client, test_db, test_user, auth_headers):
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app.models.account_invite import AccountInvite
|
||||
|
||||
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
|
||||
invite = AccountInvite(
|
||||
account_id=account_id,
|
||||
invited_by_id=invited_by_id,
|
||||
email="revoked@example.com",
|
||||
code="REVOKEME01",
|
||||
role="engineer",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
)
|
||||
test_db.add(invite)
|
||||
await test_db.commit()
|
||||
invite_id = invite.id
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/accounts/me/invites/{invite_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
await test_db.refresh(invite)
|
||||
assert invite.revoked_at is not None
|
||||
assert invite.is_valid is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_invite_idempotent(client, test_db, test_user, auth_headers):
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app.models.account_invite import AccountInvite
|
||||
|
||||
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
|
||||
invite = AccountInvite(
|
||||
account_id=account_id,
|
||||
invited_by_id=invited_by_id,
|
||||
email="revoked2@example.com",
|
||||
code="REVOKEME02",
|
||||
role="engineer",
|
||||
revoked_at=datetime.now(timezone.utc),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
)
|
||||
test_db.add(invite)
|
||||
await test_db.commit()
|
||||
invite_id = invite.id
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/accounts/me/invites/{invite_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_invite_404_when_not_found(client, test_user, auth_headers):
|
||||
import uuid
|
||||
response = await client.delete(
|
||||
f"/api/v1/accounts/me/invites/{uuid.uuid4()}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revoke_used_invite_returns_400(
|
||||
client, test_db, test_user, auth_headers
|
||||
):
|
||||
import uuid
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app.models.account_invite import AccountInvite
|
||||
|
||||
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
|
||||
invite = AccountInvite(
|
||||
account_id=account_id,
|
||||
invited_by_id=invited_by_id,
|
||||
email="used@example.com",
|
||||
code="USEDCODE01",
|
||||
role="engineer",
|
||||
accepted_by_id=invited_by_id, # mark as used
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
)
|
||||
test_db.add(invite)
|
||||
await test_db.commit()
|
||||
invite_id = invite.id
|
||||
|
||||
response = await client.delete(
|
||||
f"/api/v1/accounts/me/invites/{invite_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
27
backend/tests/test_account_invite_model.py
Normal file
27
backend/tests/test_account_invite_model.py
Normal file
@@ -0,0 +1,27 @@
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app.models.account_invite import AccountInvite
|
||||
|
||||
|
||||
def make_invite(**kwargs):
|
||||
return AccountInvite(
|
||||
account_id=kwargs.get("account_id", "00000000-0000-0000-0000-000000000001"),
|
||||
invited_by_id=kwargs.get("invited_by_id", "00000000-0000-0000-0000-000000000002"),
|
||||
email=kwargs.get("email", "x@y.com"),
|
||||
code=kwargs.get("code", "ABCD1234"),
|
||||
role=kwargs.get("role", "engineer"),
|
||||
accepted_by_id=kwargs.get("accepted_by_id"),
|
||||
expires_at=kwargs.get("expires_at"),
|
||||
revoked_at=kwargs.get("revoked_at"),
|
||||
)
|
||||
|
||||
|
||||
def test_invite_revoked_is_invalid():
|
||||
invite = make_invite(revoked_at=datetime.now(timezone.utc))
|
||||
assert invite.is_revoked is True
|
||||
assert invite.is_valid is False
|
||||
|
||||
|
||||
def test_invite_unrevoked_unexpired_unused_is_valid():
|
||||
invite = make_invite(expires_at=datetime.now(timezone.utc) + timedelta(days=7))
|
||||
assert invite.is_valid is True
|
||||
@@ -21,17 +21,21 @@ class TestAccountEndpoints:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_my_subscription(self, client: AsyncClient, auth_headers: dict):
|
||||
"""Test getting current user's subscription details."""
|
||||
"""Test getting current user's subscription details.
|
||||
|
||||
The test_user fixture seeds a Pro/active Subscription so
|
||||
Pro-guarded routers work; reflect that in the expected plan.
|
||||
"""
|
||||
response = await client.get("/api/v1/accounts/me/subscription", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "subscription" in data
|
||||
assert "limits" in data
|
||||
assert "usage" in data
|
||||
assert data["subscription"]["plan"] == "free"
|
||||
assert data["subscription"]["plan"] == "pro"
|
||||
assert data["subscription"]["status"] == "active"
|
||||
assert data["limits"]["max_trees"] == 3
|
||||
assert data["limits"]["max_sessions_per_month"] == 20
|
||||
assert data["limits"]["max_trees"] == 25
|
||||
assert data["limits"]["max_sessions_per_month"] == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_my_members(self, client: AsyncClient, auth_headers: dict):
|
||||
|
||||
56
backend/tests/test_billing_checkout.py
Normal file
56
backend/tests/test_billing_checkout.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from app.models.plan_billing import PlanBilling
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_session_creates_stripe_session(
|
||||
client, test_db, test_user, auth_headers, monkeypatch
|
||||
):
|
||||
"""End-to-end: post body → Stripe SDK called → URL returned. Stripe SDK
|
||||
mocked; Customer + Session calls patched."""
|
||||
from app.core.config import settings
|
||||
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
|
||||
|
||||
test_db.add(PlanBilling(
|
||||
plan="pro",
|
||||
display_name="Pro",
|
||||
stripe_product_id="prod_test",
|
||||
stripe_monthly_price_id="price_test_monthly",
|
||||
))
|
||||
await test_db.commit()
|
||||
|
||||
fake_customer = MagicMock()
|
||||
fake_customer.id = "cus_test_123"
|
||||
fake_session = MagicMock()
|
||||
fake_session.url = "https://checkout.stripe.com/test"
|
||||
|
||||
with patch("stripe.Customer.create", return_value=fake_customer) as cust_mock, \
|
||||
patch("stripe.checkout.Session.create", return_value=fake_session) as sess_mock:
|
||||
response = await client.post(
|
||||
"/api/v1/billing/checkout-session",
|
||||
json={"plan": "pro", "seats": 3, "billing_interval": "monthly"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.json()
|
||||
assert response.json()["url"] == "https://checkout.stripe.com/test"
|
||||
cust_mock.assert_called_once()
|
||||
sess_mock.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_session_unknown_plan_returns_500(
|
||||
client, test_db, test_user, auth_headers, monkeypatch
|
||||
):
|
||||
"""No PlanBilling row → ValueError surfaces as 500 (the endpoint doesn't
|
||||
catch business errors)."""
|
||||
from app.core.config import settings
|
||||
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/billing/checkout-session",
|
||||
json={"plan": "pro", "seats": 1, "billing_interval": "monthly"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 500
|
||||
80
backend/tests/test_billing_service.py
Normal file
80
backend/tests/test_billing_service.py
Normal file
@@ -0,0 +1,80 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import select, delete
|
||||
from app.models.subscription import Subscription
|
||||
from app.services.billing import BillingService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_trial_creates_trialing_pro_subscription(test_db):
|
||||
"""Direct service test — bypasses register, creates account inline."""
|
||||
from app.models.account import Account
|
||||
account = Account(name="DirectTest", display_code="DIRECT01")
|
||||
test_db.add(account)
|
||||
await test_db.flush()
|
||||
|
||||
sub = await BillingService.start_trial(test_db, account.id)
|
||||
assert sub.plan == "pro"
|
||||
assert sub.status == "trialing"
|
||||
assert sub.current_period_end is not None
|
||||
assert sub.current_period_end > datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_trial_is_idempotent(test_db):
|
||||
from app.models.account import Account
|
||||
account = Account(name="Idempo", display_code="IDEMPO01")
|
||||
test_db.add(account)
|
||||
await test_db.flush()
|
||||
|
||||
sub1 = await BillingService.start_trial(test_db, account.id)
|
||||
sub2 = await BillingService.start_trial(test_db, account.id)
|
||||
assert sub1.id == sub2.id
|
||||
|
||||
rows = (await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account.id)
|
||||
)).scalars().all()
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_creates_trial_subscription(client, test_db):
|
||||
"""Registering a brand-new shop (no invite code) yields a Pro/trialing sub."""
|
||||
response = await client.post("/api/v1/auth/register", json={
|
||||
"email": "newshop@example.com",
|
||||
"password": "Verystrong1Pwd",
|
||||
"name": "New Shop",
|
||||
})
|
||||
assert response.status_code in (200, 201), response.json()
|
||||
|
||||
body = response.json()
|
||||
account_id = uuid.UUID(body["account_id"])
|
||||
|
||||
sub = (await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account_id)
|
||||
)).scalar_one()
|
||||
assert sub.plan == "pro"
|
||||
assert sub.status == "trialing"
|
||||
assert sub.current_period_end is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_subscription_event_is_idempotent(test_db):
|
||||
payload = {
|
||||
"data": {"object": {
|
||||
"id": "evt_test_1",
|
||||
"customer": "cus_xxx",
|
||||
"subscription": "sub_xxx",
|
||||
"status": "active",
|
||||
}}
|
||||
}
|
||||
|
||||
applied_first = await BillingService.apply_subscription_event(
|
||||
test_db, "evt_test_1", "customer.subscription.updated", payload
|
||||
)
|
||||
applied_second = await BillingService.apply_subscription_event(
|
||||
test_db, "evt_test_1", "customer.subscription.updated", payload
|
||||
)
|
||||
assert applied_first is True
|
||||
assert applied_second is False # already-processed → ack without re-applying
|
||||
64
backend/tests/test_billing_state_endpoint.py
Normal file
64
backend/tests/test_billing_state_endpoint.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverride
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_billing_state_returns_subscription_plan_features(
|
||||
client, test_db, test_user, auth_headers
|
||||
):
|
||||
"""Subscription is already seeded by test_user fixture (pro/active).
|
||||
Add a feature flag default for `pro` and verify it shows up in the response."""
|
||||
flag = FeatureFlag(flag_key="psa_integration", display_name="PSA Integration")
|
||||
test_db.add(flag)
|
||||
await test_db.flush()
|
||||
test_db.add(PlanFeatureDefault(plan="pro", flag_id=flag.id, enabled=True))
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get("/api/v1/billing/state", headers=auth_headers)
|
||||
assert response.status_code == 200, response.json()
|
||||
body = response.json()
|
||||
assert body["subscription"]["status"] == "active"
|
||||
assert body["subscription"]["plan"] == "pro"
|
||||
assert body["subscription"]["has_pro_entitlement"] is True
|
||||
assert body["subscription"]["is_paid"] is True
|
||||
assert body["enabled_features"]["psa_integration"] is True
|
||||
# plan_limits should be a dict with the seeded pro limits from conftest
|
||||
assert body["plan_limits"]["plan"] == "pro"
|
||||
assert body["plan_limits"]["max_trees"] == 25
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_billing_state_account_override_beats_plan_default(
|
||||
client, test_db, test_user, auth_headers
|
||||
):
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
|
||||
flag = FeatureFlag(flag_key="escalation_mode", display_name="Escalation Mode")
|
||||
test_db.add(flag)
|
||||
await test_db.flush()
|
||||
test_db.add(PlanFeatureDefault(plan="pro", flag_id=flag.id, enabled=False))
|
||||
test_db.add(AccountFeatureOverride(
|
||||
account_id=account_id, flag_id=flag.id, enabled=True,
|
||||
))
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get("/api/v1/billing/state", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["enabled_features"]["escalation_mode"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_billing_state_404_when_no_subscription(
|
||||
client, test_db, test_user, auth_headers
|
||||
):
|
||||
"""Wipe the seeded subscription and verify the endpoint surfaces 404."""
|
||||
from sqlalchemy import delete
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get("/api/v1/billing/state", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
98
backend/tests/test_email_verification_autosend.py
Normal file
98
backend/tests/test_email_verification_autosend.py
Normal file
@@ -0,0 +1,98 @@
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from sqlalchemy import select
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_auto_sends_verification_email(client, test_db):
|
||||
"""Fresh registration triggers send_email_verification_email."""
|
||||
with patch(
|
||||
"app.core.email.EmailService.send_email_verification_email",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_send:
|
||||
response = await client.post("/api/v1/auth/register", json={
|
||||
"email": "newshop@example.com",
|
||||
"password": "Verystrong1Pwd",
|
||||
"name": "New Shop",
|
||||
})
|
||||
assert response.status_code in (200, 201), response.json()
|
||||
mock_send.assert_called_once()
|
||||
kwargs = mock_send.call_args.kwargs
|
||||
assert kwargs["to_email"] == "newshop@example.com"
|
||||
assert "/verify-email?token=" in kwargs["verification_url"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_with_account_invite_code_email_mismatch_rejected(
|
||||
client, test_db, test_user
|
||||
):
|
||||
"""Invite code is for invited@example.com but user registers with a
|
||||
different email -> 400 invite_email_mismatch."""
|
||||
from app.models.account_invite import AccountInvite
|
||||
import uuid
|
||||
|
||||
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
|
||||
invite = AccountInvite(
|
||||
account_id=account_id,
|
||||
invited_by_id=invited_by_id,
|
||||
email="invited@example.com",
|
||||
code="INVITECODE99",
|
||||
role="engineer",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
)
|
||||
test_db.add(invite)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.post("/api/v1/auth/register", json={
|
||||
"email": "wrong-email@example.com",
|
||||
"password": "Verystrong1Pwd",
|
||||
"name": "Wrong Email",
|
||||
"account_invite_code": "INVITECODE99",
|
||||
})
|
||||
assert response.status_code == 400, response.json()
|
||||
assert response.json()["detail"]["error"] == "invite_email_mismatch"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_with_account_invite_code_email_match_accepted(
|
||||
client, test_db, test_user
|
||||
):
|
||||
"""Invite code is for invited@example.com - registering with that email
|
||||
succeeds and joins the existing account."""
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.models.user import User
|
||||
import uuid
|
||||
|
||||
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
|
||||
invite = AccountInvite(
|
||||
account_id=account_id,
|
||||
invited_by_id=invited_by_id,
|
||||
email="invited@example.com",
|
||||
code="INVITECODE100",
|
||||
role="engineer",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
)
|
||||
test_db.add(invite)
|
||||
await test_db.commit()
|
||||
|
||||
with patch(
|
||||
"app.core.email.EmailService.send_email_verification_email",
|
||||
new_callable=AsyncMock,
|
||||
):
|
||||
response = await client.post("/api/v1/auth/register", json={
|
||||
"email": "invited@example.com",
|
||||
"password": "Verystrong1Pwd",
|
||||
"name": "Invited",
|
||||
"account_invite_code": "INVITECODE100",
|
||||
})
|
||||
assert response.status_code in (200, 201), response.json()
|
||||
|
||||
new_user = (await test_db.execute(
|
||||
select(User).where(User.email == "invited@example.com")
|
||||
)).scalar_one()
|
||||
assert new_user.account_id == account_id # joined existing account
|
||||
87
backend/tests/test_email_verification_guard.py
Normal file
87
backend/tests/test_email_verification_guard.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
async def _set_user_email_state(test_db, user_id, *, verified_at=None, created_at=None):
|
||||
user = (await test_db.execute(select(User).where(User.id == user_id))).scalar_one()
|
||||
user.email_verified_at = verified_at
|
||||
if created_at is not None:
|
||||
user.created_at = created_at
|
||||
await test_db.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verified_user_passes(client, test_db, test_user, auth_headers):
|
||||
user_id = uuid.UUID(test_user["user_data"]["id"])
|
||||
await _set_user_email_state(test_db, user_id, verified_at=datetime.now(timezone.utc))
|
||||
response = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert response.status_code != 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unverified_in_grace_passes(client, test_db, test_user, auth_headers):
|
||||
user_id = uuid.UUID(test_user["user_data"]["id"])
|
||||
await _set_user_email_state(
|
||||
test_db, user_id,
|
||||
verified_at=None,
|
||||
created_at=datetime.now(timezone.utc) - timedelta(days=2),
|
||||
)
|
||||
response = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert response.status_code != 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unverified_past_grace_blocks(client, test_db, test_user, auth_headers):
|
||||
user_id = uuid.UUID(test_user["user_data"]["id"])
|
||||
await _set_user_email_state(
|
||||
test_db, user_id,
|
||||
verified_at=None,
|
||||
created_at=datetime.now(timezone.utc) - timedelta(days=10),
|
||||
)
|
||||
response = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert response.status_code == 403
|
||||
body = response.json()
|
||||
assert body["detail"]["error"] == "email_not_verified"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unverified_past_grace_allowlisted_still_passes(client, test_db, test_user, auth_headers):
|
||||
user_id = uuid.UUID(test_user["user_data"]["id"])
|
||||
await _set_user_email_state(
|
||||
test_db, user_id,
|
||||
verified_at=None,
|
||||
created_at=datetime.now(timezone.utc) - timedelta(days=10),
|
||||
)
|
||||
response = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_combined_guards_unverified_expired_trial(client, test_db, test_user, auth_headers):
|
||||
"""A user who is BOTH past grace AND on an expired trial should get blocked
|
||||
by one of the two guards. Either error is acceptable; we just verify a
|
||||
refusal."""
|
||||
from app.models.subscription import Subscription
|
||||
from sqlalchemy import delete
|
||||
|
||||
user_id = uuid.UUID(test_user["user_data"]["id"])
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
await _set_user_email_state(
|
||||
test_db, user_id,
|
||||
verified_at=None,
|
||||
created_at=datetime.now(timezone.utc) - timedelta(days=10),
|
||||
)
|
||||
|
||||
# Replace the seeded active sub with an expired trial
|
||||
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
|
||||
test_db.add(Subscription(
|
||||
account_id=account_id, plan="pro", status="trialing",
|
||||
current_period_end=datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
))
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert response.status_code in (402, 403)
|
||||
@@ -193,6 +193,95 @@ async def test_applied_at_auto_stamped_on_first_outcome(
|
||||
assert body["verified_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_requires_notes(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""applied_pending requires notes (the "what are you waiting on?" reason)."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_pending"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "notes" in r.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_stores_reason_and_stamps_applied_at(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""applied_pending stores notes under pending_reason and stamps applied_at
|
||||
but NOT verified_at — the fix is parked, not verified."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
r = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_pending", "notes": "client power-cycling router"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["status"] == "applied_pending"
|
||||
assert body["pending_reason"] == "client power-cycling router"
|
||||
assert body["applied_at"] is not None
|
||||
assert body["verified_at"] is None
|
||||
assert body["partial_notes"] is None
|
||||
assert body["failure_reason"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_to_success_allowed(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""pending is non-terminal — engineer can advance to success once verified."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r1 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_pending", "notes": "waiting on AD replication"},
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
|
||||
r2 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
headers=auth_headers,
|
||||
json={"outcome": "applied_success"},
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
body = r2.json()
|
||||
assert body["status"] == "applied_success"
|
||||
assert body["verified_at"] is not None
|
||||
# pending_reason is preserved as audit trail
|
||||
assert body["pending_reason"] == "waiting on AD replication"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_reason_can_be_updated(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""pending→pending with new notes updates the stored pending_reason."""
|
||||
session_id, fix_id = await _make_session_with_fix(test_db, test_user)
|
||||
|
||||
r1 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_pending", "notes": "waiting on AD replication"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r1.status_code == 200
|
||||
assert r1.json()["pending_reason"] == "waiting on AD replication"
|
||||
|
||||
r2 = await client.patch(
|
||||
f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
|
||||
json={"outcome": "applied_pending", "notes": "now waiting on client to confirm login"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["pending_reason"] == "now waiting on client to confirm login"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_outcome_stores_notes_as_failure_reason(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
|
||||
45
backend/tests/test_get_current_active_user_no_mutation.py
Normal file
45
backend/tests/test_get_current_active_user_no_mutation.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import select
|
||||
from app.models.subscription import Subscription
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_expired_trial_is_not_mutated_by_get_current_active_user(
|
||||
test_db, client, test_user, auth_headers
|
||||
):
|
||||
"""The previous deps.py:109 logic mutated trialing→active+free on expiry.
|
||||
That's gone. An expired-trial Subscription should retain status='trialing'
|
||||
and current_period_end after any authenticated request."""
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
|
||||
# If a Subscription already exists for this account (e.g. created by
|
||||
# the register handler), update it; otherwise insert a new one.
|
||||
existing = await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account_id)
|
||||
)
|
||||
sub = existing.scalar_one_or_none()
|
||||
expired_end = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||
if sub is None:
|
||||
sub = Subscription(
|
||||
account_id=account_id,
|
||||
plan="pro",
|
||||
status="trialing",
|
||||
current_period_end=expired_end,
|
||||
)
|
||||
test_db.add(sub)
|
||||
else:
|
||||
sub.plan = "pro"
|
||||
sub.status = "trialing"
|
||||
sub.current_period_end = expired_end
|
||||
await test_db.commit()
|
||||
|
||||
# Call any authenticated endpoint that goes through get_current_active_user.
|
||||
response = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
await test_db.refresh(sub)
|
||||
assert sub.status == "trialing"
|
||||
assert sub.plan == "pro"
|
||||
assert sub.current_period_end is not None
|
||||
@@ -13,6 +13,14 @@ pytestmark = pytest.mark.asyncio
|
||||
@pytest.fixture
|
||||
async def kb_setup(client, auth_headers, test_db):
|
||||
"""Seed KB plan limits and return helpers."""
|
||||
# KB tests were authored against a free-plan user. Phase 1 conftest seeds
|
||||
# the test_user with a pro/active Subscription; downgrade to free here so
|
||||
# quota numbers match the original test intent.
|
||||
from app.models.subscription import Subscription
|
||||
sub = (await test_db.execute(__import__("sqlalchemy").select(Subscription))).scalar_one()
|
||||
sub.plan = "free"
|
||||
await test_db.commit()
|
||||
|
||||
# Update plan_limits with KB columns for 'free' plan
|
||||
await test_db.execute(
|
||||
__import__("sqlalchemy").text("""
|
||||
|
||||
120
backend/tests/test_oauth_callbacks.py
Normal file
120
backend/tests/test_oauth_callbacks.py
Normal file
@@ -0,0 +1,120 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
from app.models.subscription import Subscription
|
||||
from app.services.oauth_providers import OAuthProfile
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_callback_creates_user_account_subscription(
|
||||
client, test_db, monkeypatch
|
||||
):
|
||||
"""Brand-new user via Google OAuth -> User + Account + Subscription + OAuthIdentity."""
|
||||
from app.core.config import settings
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
|
||||
|
||||
profile = OAuthProfile(
|
||||
provider_subject="google_subject_123",
|
||||
email="newuser@example.com",
|
||||
name="New User",
|
||||
)
|
||||
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
|
||||
response = await client.post(
|
||||
"/api/v1/auth/google/callback", json={"code": "auth_code_xyz"}
|
||||
)
|
||||
assert response.status_code == 200, response.json()
|
||||
body = response.json()
|
||||
assert body["is_new_user"] is True
|
||||
assert body["access_token"]
|
||||
|
||||
user = (await test_db.execute(
|
||||
select(User).where(User.email == "newuser@example.com")
|
||||
)).scalar_one()
|
||||
assert user.password_hash is None
|
||||
assert user.email_verified_at is not None
|
||||
|
||||
identity = (await test_db.execute(
|
||||
select(OAuthIdentity).where(OAuthIdentity.user_id == user.id)
|
||||
)).scalar_one()
|
||||
assert identity.provider == "google"
|
||||
assert identity.provider_subject == "google_subject_123"
|
||||
|
||||
sub = (await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == user.account_id)
|
||||
)).scalar_one()
|
||||
assert sub.status == "trialing"
|
||||
assert sub.plan == "pro"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_callback_existing_user_is_idempotent(
|
||||
client, test_db, test_user, monkeypatch
|
||||
):
|
||||
"""When test_user's email is already registered, OAuth links + returns the
|
||||
same user. Two calls with same provider_subject must not duplicate
|
||||
OAuthIdentity rows."""
|
||||
from app.core.config import settings
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
|
||||
|
||||
user_id = uuid.UUID(test_user["user_data"]["id"])
|
||||
email = test_user["email"]
|
||||
name = test_user["user_data"]["name"]
|
||||
|
||||
profile = OAuthProfile(
|
||||
provider_subject="google_subject_456",
|
||||
email=email,
|
||||
name=name,
|
||||
)
|
||||
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
|
||||
r1 = await client.post("/api/v1/auth/google/callback", json={"code": "x"})
|
||||
r2 = await client.post("/api/v1/auth/google/callback", json={"code": "x"})
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 200
|
||||
assert r1.json()["is_new_user"] is False
|
||||
assert r2.json()["is_new_user"] is False
|
||||
|
||||
identities = (await test_db.execute(
|
||||
select(OAuthIdentity).where(OAuthIdentity.user_id == user_id)
|
||||
)).scalars().all()
|
||||
assert len(identities) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_google_callback_503_when_unconfigured(client, monkeypatch):
|
||||
from app.core.config import settings
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/google/callback", json={"code": "x"}
|
||||
)
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_microsoft_callback_creates_user(client, test_db, monkeypatch):
|
||||
from app.core.config import settings
|
||||
monkeypatch.setattr(settings, "MS_CLIENT_ID", "client_dummy")
|
||||
monkeypatch.setattr(settings, "MS_CLIENT_SECRET", "secret_dummy")
|
||||
|
||||
profile = OAuthProfile(
|
||||
provider_subject="ms_subject_789",
|
||||
email="msuser@example.com",
|
||||
name="MS User",
|
||||
)
|
||||
with patch("app.api.endpoints.oauth.microsoft_exchange_code", return_value=profile):
|
||||
response = await client.post(
|
||||
"/api/v1/auth/microsoft/callback", json={"code": "auth_code"}
|
||||
)
|
||||
assert response.status_code == 200, response.json()
|
||||
user = (await test_db.execute(
|
||||
select(User).where(User.email == "msuser@example.com")
|
||||
)).scalar_one()
|
||||
identity = (await test_db.execute(
|
||||
select(OAuthIdentity).where(OAuthIdentity.user_id == user.id)
|
||||
)).scalar_one()
|
||||
assert identity.provider == "microsoft"
|
||||
39
backend/tests/test_oauth_identity_model.py
Normal file
39
backend/tests/test_oauth_identity_model.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_identity_unique_provider_subject(test_db, test_user):
|
||||
"""Two rows with same provider+subject should violate uniqueness."""
|
||||
user_id = uuid.UUID(test_user["user_data"]["id"])
|
||||
|
||||
row1 = OAuthIdentity(
|
||||
user_id=user_id,
|
||||
provider="google",
|
||||
provider_subject="abc-123",
|
||||
provider_email_at_link="alex@acmemsp.com",
|
||||
)
|
||||
test_db.add(row1)
|
||||
await test_db.commit()
|
||||
|
||||
row2 = OAuthIdentity(
|
||||
user_id=user_id,
|
||||
provider="google",
|
||||
provider_subject="abc-123",
|
||||
provider_email_at_link="alex@acmemsp.com",
|
||||
)
|
||||
test_db.add(row2)
|
||||
with pytest.raises(Exception): # IntegrityError
|
||||
await test_db.commit()
|
||||
await test_db.rollback()
|
||||
|
||||
rows = (
|
||||
await test_db.execute(
|
||||
select(OAuthIdentity).where(OAuthIdentity.user_id == user_id)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(rows) == 1
|
||||
83
backend/tests/test_oauth_only_user_paths.py
Normal file
83
backend/tests/test_oauth_only_user_paths.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from app.models.user import User
|
||||
from app.models.account import Account
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
|
||||
|
||||
async def _make_oauth_only_user(test_db, email, *, with_identity=True):
|
||||
"""Create an OAuth-only user (password_hash=None) directly in the test DB."""
|
||||
import secrets
|
||||
account = Account(
|
||||
name=f"{email}-acct",
|
||||
display_code=secrets.token_hex(4).upper(),
|
||||
)
|
||||
test_db.add(account)
|
||||
await test_db.flush()
|
||||
user = User(
|
||||
email=email,
|
||||
name="OAuth User",
|
||||
password_hash=None,
|
||||
account_id=account.id,
|
||||
account_role="owner",
|
||||
)
|
||||
test_db.add(user)
|
||||
await test_db.flush()
|
||||
if with_identity:
|
||||
test_db.add(OAuthIdentity(
|
||||
user_id=user.id, provider="google",
|
||||
provider_subject=f"google_{email}",
|
||||
provider_email_at_link=email,
|
||||
))
|
||||
await test_db.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_form_rejects_oauth_only_user_with_helpful_error(client, test_db):
|
||||
await _make_oauth_only_user(test_db, "oauth-only@example.com")
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login",
|
||||
data={"username": "oauth-only@example.com", "password": "wontwork"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
body = response.json()
|
||||
assert body["detail"]["error"] == "use_oauth_provider"
|
||||
assert "google" in body["detail"]["providers"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_json_rejects_oauth_only_user(client, test_db):
|
||||
await _make_oauth_only_user(test_db, "oauth-only2@example.com")
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login/json",
|
||||
json={"email": "oauth-only2@example.com", "password": "wontwork"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["error"] == "use_oauth_provider"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_password_forgot_silent_for_oauth_only_user(client, test_db):
|
||||
"""OAuth-only users get the generic message; no email is sent."""
|
||||
await _make_oauth_only_user(test_db, "oauth-forgot@example.com", with_identity=False)
|
||||
from unittest.mock import AsyncMock, patch
|
||||
with patch("app.core.email.EmailService.send_password_reset_email", new_callable=AsyncMock) as mock_send:
|
||||
response = await client.post(
|
||||
"/api/v1/auth/password/forgot",
|
||||
json={"email": "oauth-forgot@example.com"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
mock_send.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_login_for_password_user_still_works(client, test_user):
|
||||
"""Regression: existing password-based login must still succeed."""
|
||||
response = await client.post(
|
||||
"/api/v1/auth/login/json",
|
||||
json={"email": test_user["email"], "password": test_user["password"]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["access_token"]
|
||||
85
backend/tests/test_pilot_complimentary_backfill.py
Normal file
85
backend/tests/test_pilot_complimentary_backfill.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Smoke test for the complimentary backfill: assertions about the post-state.
|
||||
The actual migration runs at deploy time; tests use create_all so the
|
||||
migration body isn't executed automatically. We invoke the SQL inline to
|
||||
exercise the same effect."""
|
||||
import uuid
|
||||
import pytest
|
||||
from sqlalchemy import select, text, delete
|
||||
from app.models.account import Account
|
||||
from app.models.subscription import Subscription
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complimentary_backfill_sets_status_and_inserts_missing_rows(test_db):
|
||||
"""Inline-run the backfill SQL and assert post-state."""
|
||||
# Seed a fresh account with no subscription
|
||||
no_sub_account = Account(name="NoSub", display_code="NOSUB001")
|
||||
test_db.add(no_sub_account)
|
||||
await test_db.flush()
|
||||
|
||||
# Seed an account with a trialing subscription (should become complimentary)
|
||||
trial_account = Account(name="Trial", display_code="TRIAL001")
|
||||
test_db.add(trial_account)
|
||||
await test_db.flush()
|
||||
test_db.add(Subscription(
|
||||
account_id=trial_account.id, plan="free", status="trialing",
|
||||
))
|
||||
|
||||
# Seed an account with a canceled subscription (should be preserved)
|
||||
canceled_account = Account(name="Cancel", display_code="CANCL001")
|
||||
test_db.add(canceled_account)
|
||||
await test_db.flush()
|
||||
test_db.add(Subscription(
|
||||
account_id=canceled_account.id, plan="pro", status="canceled",
|
||||
))
|
||||
await test_db.commit()
|
||||
|
||||
# Run the same SQL the migration runs
|
||||
await test_db.execute(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')
|
||||
"""))
|
||||
await test_db.execute(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)
|
||||
"""))
|
||||
await test_db.commit()
|
||||
|
||||
# All three accounts now have a Subscription
|
||||
no_sub_row = (await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == no_sub_account.id)
|
||||
)).scalar_one()
|
||||
assert no_sub_row.status == "complimentary"
|
||||
assert no_sub_row.plan == "pro"
|
||||
|
||||
trial_row = (await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == trial_account.id)
|
||||
)).scalar_one()
|
||||
assert trial_row.status == "complimentary"
|
||||
assert trial_row.plan == "pro"
|
||||
|
||||
canceled_row = (await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == canceled_account.id)
|
||||
)).scalar_one()
|
||||
# Canceled is preserved
|
||||
assert canceled_row.status == "canceled"
|
||||
assert canceled_row.plan == "pro"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complimentary_subscription_passes_active_subscription_guard(
|
||||
client, test_db, test_user, auth_headers
|
||||
):
|
||||
"""The require_active_subscription guard accepts complimentary status."""
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
|
||||
test_db.add(Subscription(account_id=account_id, plan="pro", status="complimentary"))
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert response.status_code != 402
|
||||
144
backend/tests/test_stripe_webhook_handler.py
Normal file
144
backend/tests/test_stripe_webhook_handler.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import json
|
||||
import uuid
|
||||
import pytest
|
||||
from sqlalchemy import delete, select
|
||||
from unittest.mock import patch
|
||||
from app.models.subscription import Subscription
|
||||
|
||||
|
||||
def _make_event(event_id, event_type, obj):
|
||||
return {
|
||||
"id": event_id,
|
||||
"type": event_type,
|
||||
"data": {"object": obj},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkout_completed_activates_subscription(
|
||||
client, test_db, test_user, auth_headers, monkeypatch
|
||||
):
|
||||
from app.core.config import settings
|
||||
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
|
||||
monkeypatch.setattr(settings, "STRIPE_WEBHOOK_SECRET", "whsec_dummy")
|
||||
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
# Replace seeded sub with trialing + stripe_customer_id linkage
|
||||
from app.models.account import Account
|
||||
account = (await test_db.execute(select(Account).where(Account.id == account_id))).scalar_one()
|
||||
account.stripe_customer_id = "cus_xxx"
|
||||
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
|
||||
test_db.add(Subscription(account_id=account_id, plan="pro", status="trialing"))
|
||||
await test_db.commit()
|
||||
|
||||
event = _make_event("evt_co_1", "checkout.session.completed", {
|
||||
"id": "cs_xxx",
|
||||
"customer": "cus_xxx",
|
||||
"subscription": "sub_xxx",
|
||||
})
|
||||
|
||||
with patch("stripe.Subscription.retrieve", return_value={
|
||||
"id": "sub_xxx",
|
||||
"status": "active",
|
||||
"current_period_start": 1714521600,
|
||||
"current_period_end": 1717113600,
|
||||
"items": {"data": [{
|
||||
"price": {"id": "price_test_monthly"},
|
||||
"quantity": 5,
|
||||
}]},
|
||||
"cancel_at_period_end": False,
|
||||
}), patch("stripe.Webhook.construct_event", return_value=event):
|
||||
response = await client.post(
|
||||
"/api/v1/webhooks/stripe",
|
||||
content=json.dumps(event),
|
||||
headers={"stripe-signature": "fake-sig"},
|
||||
)
|
||||
assert response.status_code == 200, response.json()
|
||||
|
||||
sub = (await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account_id)
|
||||
)).scalar_one()
|
||||
assert sub.status == "active"
|
||||
assert sub.stripe_subscription_id == "sub_xxx"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscription_deleted_cancels_account(
|
||||
client, test_db, test_user, auth_headers, monkeypatch
|
||||
):
|
||||
from app.core.config import settings
|
||||
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
|
||||
monkeypatch.setattr(settings, "STRIPE_WEBHOOK_SECRET", "whsec_dummy")
|
||||
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
|
||||
test_db.add(Subscription(
|
||||
account_id=account_id, plan="pro", status="active",
|
||||
stripe_subscription_id="sub_xxx",
|
||||
))
|
||||
await test_db.commit()
|
||||
|
||||
event = _make_event("evt_del_1", "customer.subscription.deleted", {
|
||||
"id": "sub_xxx",
|
||||
"current_period_start": 1714521600,
|
||||
"current_period_end": 1717113600,
|
||||
"items": {"data": [{"quantity": 1}]},
|
||||
})
|
||||
|
||||
with patch("stripe.Webhook.construct_event", return_value=event):
|
||||
response = await client.post(
|
||||
"/api/v1/webhooks/stripe",
|
||||
content=json.dumps(event),
|
||||
headers={"stripe-signature": "fake-sig"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
sub = (await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account_id)
|
||||
)).scalar_one()
|
||||
assert sub.status == "canceled"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_signature_failure_returns_400(client, monkeypatch):
|
||||
from app.core.config import settings
|
||||
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
|
||||
monkeypatch.setattr(settings, "STRIPE_WEBHOOK_SECRET", "whsec_dummy")
|
||||
|
||||
with patch("stripe.Webhook.construct_event", side_effect=ValueError("bad sig")):
|
||||
response = await client.post(
|
||||
"/api/v1/webhooks/stripe",
|
||||
content=b"{}",
|
||||
headers={"stripe-signature": "fake-sig"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_webhook_idempotency(
|
||||
client, test_db, test_user, auth_headers, monkeypatch
|
||||
):
|
||||
from app.core.config import settings
|
||||
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
|
||||
monkeypatch.setattr(settings, "STRIPE_WEBHOOK_SECRET", "whsec_dummy")
|
||||
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
|
||||
test_db.add(Subscription(account_id=account_id, plan="pro", status="trialing"))
|
||||
await test_db.commit()
|
||||
|
||||
event = _make_event("evt_dup_1", "customer.subscription.updated", {
|
||||
"id": "sub_yyy",
|
||||
"status": "active",
|
||||
"current_period_start": 1714521600,
|
||||
"current_period_end": 1717113600,
|
||||
"items": {"data": [{"quantity": 1}]},
|
||||
"cancel_at_period_end": False,
|
||||
})
|
||||
with patch("stripe.Webhook.construct_event", return_value=event):
|
||||
r1 = await client.post("/api/v1/webhooks/stripe", content=json.dumps(event), headers={"stripe-signature": "x"})
|
||||
r2 = await client.post("/api/v1/webhooks/stripe", content=json.dumps(event), headers={"stripe-signature": "x"})
|
||||
assert r1.status_code == 200
|
||||
assert r2.status_code == 200
|
||||
assert r1.json()["applied"] is True
|
||||
assert r2.json()["applied"] is False
|
||||
89
backend/tests/test_subscription_guards.py
Normal file
89
backend/tests/test_subscription_guards.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Tests for require_active_subscription dependency.
|
||||
|
||||
Verifies the 402 gating logic for Pro-guarded routers and the allowlist
|
||||
that lets billing/account/auth flows through even when locked.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from sqlalchemy import delete
|
||||
from app.models.subscription import Subscription
|
||||
|
||||
|
||||
async def _set_subscription(test_db, account_id, **fields):
|
||||
"""Replace any existing Subscription on the account with one matching `fields`."""
|
||||
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
|
||||
test_db.add(Subscription(account_id=account_id, **fields))
|
||||
await test_db.commit()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_active_subscription_passes(client, test_db, test_user, auth_headers):
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
await _set_subscription(test_db, account_id, plan="pro", status="active")
|
||||
response = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert response.status_code != 402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complimentary_subscription_passes(client, test_db, test_user, auth_headers):
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
await _set_subscription(test_db, account_id, plan="pro", status="complimentary")
|
||||
response = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert response.status_code != 402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trialing_unexpired_passes(client, test_db, test_user, auth_headers):
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
await _set_subscription(
|
||||
test_db, account_id,
|
||||
plan="pro", status="trialing",
|
||||
current_period_end=datetime.now(timezone.utc) + timedelta(days=5),
|
||||
)
|
||||
response = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert response.status_code != 402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trialing_expired_returns_402(client, test_db, test_user, auth_headers):
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
await _set_subscription(
|
||||
test_db, account_id,
|
||||
plan="pro", status="trialing",
|
||||
current_period_end=datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
)
|
||||
response = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert response.status_code == 402
|
||||
body = response.json()
|
||||
assert body["detail"]["error"] == "subscription_inactive"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canceled_returns_402(client, test_db, test_user, auth_headers):
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
await _set_subscription(test_db, account_id, plan="pro", status="canceled")
|
||||
response = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert response.status_code == 402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_subscription_returns_402(client, test_db, test_user, auth_headers):
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
# Remove the seeded default subscription
|
||||
await test_db.execute(delete(Subscription).where(Subscription.account_id == account_id))
|
||||
await test_db.commit()
|
||||
response = await client.get("/api/v1/trees", headers=auth_headers)
|
||||
assert response.status_code == 402
|
||||
body = response.json()
|
||||
assert body["detail"]["error"] == "no_subscription"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_me_bypasses_guard(client, test_db, test_user, auth_headers):
|
||||
"""Allowlisted route works even when subscription is canceled."""
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
await _set_subscription(test_db, account_id, plan="pro", status="canceled")
|
||||
response = await client.get("/api/v1/auth/me", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
@@ -10,8 +10,15 @@ class TestSubscriptionLimits:
|
||||
"""Test suite for subscription plan limits."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_free_plan_tree_limit(self, client: AsyncClient, auth_headers: dict):
|
||||
async def test_free_plan_tree_limit(
|
||||
self, client: AsyncClient, auth_headers: dict, test_db: AsyncSession
|
||||
):
|
||||
"""Test that free plan has tree creation limit of 3."""
|
||||
from app.models.subscription import Subscription
|
||||
sub = (await test_db.execute(select(Subscription))).scalar_one()
|
||||
sub.plan = "free"
|
||||
await test_db.commit()
|
||||
|
||||
tree_template = {
|
||||
"name": "Limit Test Tree",
|
||||
"tree_structure": {
|
||||
@@ -90,8 +97,15 @@ class TestSubscriptionLimits:
|
||||
assert response.status_code == 201
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_free_plan_limits_correct(self, client: AsyncClient, auth_headers: dict):
|
||||
async def test_free_plan_limits_correct(
|
||||
self, client: AsyncClient, auth_headers: dict, test_db: AsyncSession
|
||||
):
|
||||
"""Test that free plan limits are correct."""
|
||||
from app.models.subscription import Subscription
|
||||
sub = (await test_db.execute(select(Subscription))).scalar_one()
|
||||
sub.plan = "free"
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get("/api/v1/accounts/me/subscription", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
limits = response.json()["limits"]
|
||||
|
||||
41
backend/tests/test_subscription_properties.py
Normal file
41
backend/tests/test_subscription_properties.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from app.models.subscription import Subscription
|
||||
|
||||
|
||||
def make_sub(**kwargs):
|
||||
sub = Subscription()
|
||||
sub.plan = kwargs.get("plan", "free")
|
||||
sub.status = kwargs.get("status", "active")
|
||||
sub.current_period_end = kwargs.get("current_period_end")
|
||||
return sub
|
||||
|
||||
|
||||
def test_complimentary_is_active_but_not_paid():
|
||||
sub = make_sub(plan="pro", status="complimentary")
|
||||
assert sub.is_active is True
|
||||
assert sub.is_paid is False
|
||||
assert sub.has_pro_entitlement is True
|
||||
|
||||
|
||||
def test_paid_pro_active():
|
||||
sub = make_sub(plan="pro", status="active")
|
||||
assert sub.is_paid is True
|
||||
assert sub.has_pro_entitlement is True
|
||||
|
||||
|
||||
def test_trial_unexpired_has_entitlement():
|
||||
sub = make_sub(plan="pro", status="trialing", current_period_end=datetime.now(timezone.utc) + timedelta(days=5))
|
||||
assert sub.is_active is True
|
||||
assert sub.is_paid is False
|
||||
assert sub.has_pro_entitlement is True
|
||||
|
||||
|
||||
def test_trial_expired_no_entitlement():
|
||||
sub = make_sub(plan="pro", status="trialing", current_period_end=datetime.now(timezone.utc) - timedelta(hours=1))
|
||||
assert sub.has_pro_entitlement is False
|
||||
|
||||
|
||||
def test_canceled_no_entitlement():
|
||||
sub = make_sub(plan="pro", status="canceled")
|
||||
assert sub.is_active is False
|
||||
assert sub.has_pro_entitlement is False
|
||||
@@ -12,13 +12,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.models.tree import Tree
|
||||
from app.models.subscription import Subscription
|
||||
from app.core.security import get_password_hash
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async def _create_account_and_user(db: AsyncSession, prefix: str):
|
||||
"""Create a fresh account + engineer user. Returns (account, user, plain_password)."""
|
||||
"""Create a fresh account + engineer user. Returns (account, user, plain_password).
|
||||
|
||||
Seeds a default active Pro Subscription for the account so requests pass
|
||||
the require_active_subscription guard added in Phase 1 Task 11.
|
||||
"""
|
||||
password = "TestPass123!"
|
||||
account = Account(
|
||||
name=f"{prefix}-corp",
|
||||
@@ -36,6 +41,7 @@ async def _create_account_and_user(db: AsyncSession, prefix: str):
|
||||
account_role="engineer",
|
||||
)
|
||||
db.add(user)
|
||||
db.add(Subscription(account_id=account.id, plan="pro", status="active"))
|
||||
await db.flush()
|
||||
return account, user, password
|
||||
|
||||
@@ -168,6 +174,7 @@ async def test_ai_session_search_cannot_see_other_users_sessions(
|
||||
account = Account(name="Shared Corp", display_code=uuid.uuid4().hex[:8])
|
||||
test_db.add(account)
|
||||
await test_db.flush()
|
||||
test_db.add(Subscription(account_id=account.id, plan="pro", status="active"))
|
||||
|
||||
password = "TestPass123!"
|
||||
user_a = User(
|
||||
|
||||
23
backend/tests/test_user_password_nullable.py
Normal file
23
backend/tests/test_user_password_nullable.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import pytest
|
||||
from app.models.user import User
|
||||
from app.models.account import Account
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_can_be_created_without_password_hash(test_db):
|
||||
"""OAuth-only users have password_hash=None and the row should commit cleanly."""
|
||||
account = Account(name="OAuthShop", display_code="OAUTH001")
|
||||
test_db.add(account)
|
||||
await test_db.flush()
|
||||
|
||||
user = User(
|
||||
email="oauth-only@example.com",
|
||||
name="OAuth Only",
|
||||
password_hash=None,
|
||||
account_id=account.id,
|
||||
account_role="engineer",
|
||||
)
|
||||
test_db.add(user)
|
||||
await test_db.commit()
|
||||
await test_db.refresh(user)
|
||||
assert user.password_hash is None
|
||||
81
docs/plans/2026-05-01-issue-cleanup-plan.md
Normal file
81
docs/plans/2026-05-01-issue-cleanup-plan.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Issue Cleanup Plan - 2026-05-01
|
||||
|
||||
## Tracker Hygiene
|
||||
|
||||
These are safe tracker updates before any feature work:
|
||||
|
||||
1. Close Gitea #127 (`feat: show AI content scope indicator`) unless an always-visible badge is still desired.
|
||||
- Current code already has IT/MSP scope copy in the assistant empty state.
|
||||
- `ASSISTANT_SYSTEM_PROMPT` also has an off-domain redirect boundary.
|
||||
2. Rewrite Gitea #66 (`Tree Templates + Import/Export`) to the remaining scope only.
|
||||
- `.rfflow` export/import is implemented in `tree_transfer.py` and exposed in the library UI.
|
||||
- Remaining work: curated packs, authenticated one-click install from gallery, template versioning, marketplace/community path.
|
||||
3. Close or archive open PR #124 (`feat/cockpit-harness`).
|
||||
- It is unmergeable against current `main` and overlaps newer `/pilot` work.
|
||||
4. Keep Gitea #58, #60, #128, #129, #130 open.
|
||||
- They still describe real product gaps.
|
||||
|
||||
## Recommended Order
|
||||
|
||||
### 1. Low-Risk Maintenance
|
||||
|
||||
- Status: started 2026-05-01.
|
||||
- Frontend lint is clean after removing stale disable comments and tightening hook dependencies.
|
||||
- Added `data-testid` selectors for e2e-critical session history and FlowPilot command-palette controls.
|
||||
- Added `AssistantChatPage` observability for unexpected `currentChatRef` guard mismatches so stale async discards are visible in the console.
|
||||
|
||||
Why first: these reduce future regression cost and are small, well-bounded changes.
|
||||
|
||||
### 2. Pilot UX Friction
|
||||
|
||||
- Status: started 2026-05-01.
|
||||
- #130: Added diagnostic command help affordances in `TaskLane` action cards. Each active diagnostic card can explain what it checks, what to look for, and when to use it.
|
||||
- #128: Keep the existing responsive drawer behavior for now. `TaskLane` already uses a side panel on wide screens and a bottom drawer below the desktop breakpoint; do not add a top/side preference unless pilot feedback shows the current responsive layout is blocking workflow.
|
||||
- EscalationQueue mobile design stays deferred until a customer asks for it.
|
||||
|
||||
Why second: this improves the current FlowPilot wedge without changing core data models.
|
||||
|
||||
Validation run:
|
||||
|
||||
- `docker exec -w /app resolutionflow_frontend npm run lint`
|
||||
- `docker exec -w /app resolutionflow_frontend npx tsc -b`
|
||||
- `docker exec -w /app resolutionflow_frontend npm run build`
|
||||
|
||||
### 3. Workflow Quality Signals
|
||||
|
||||
- #58: Add structured "step is wrong" flags separate from thumbs-up/down helpfulness.
|
||||
- Existing `StepFeedback` is not enough; it only records helpful/unhelpful and cannot capture incorrect/outdated/unclear/missing-info reasons.
|
||||
|
||||
Why third: useful, but needs schema/API/UI/admin surfaces.
|
||||
|
||||
### 4. Client Intelligence
|
||||
|
||||
- #60: Recurring issue detection.
|
||||
- Start with a read-only banner using existing `sessions.client_name + tree_id` filters.
|
||||
- Add same-resolution detection only after confirming the available session outcome/node data is reliable enough.
|
||||
|
||||
Why fourth: high value, but it touches session-start and close-out flows and needs careful false-positive handling.
|
||||
|
||||
### 5. Documentation Structure
|
||||
|
||||
- #129: Hierarchical guide navigation.
|
||||
- Current `/guides` route is a card grid plus detail pages with sections and breadcrumbs, but not a collapsible guide tree.
|
||||
|
||||
Why fifth: valid UX request, but less urgent than pilot workflow gaps.
|
||||
|
||||
## Gitea Actions Needed
|
||||
|
||||
The current environment does not have a Gitea token configured, so API writes fail with `401 token is required`. Once authenticated:
|
||||
|
||||
```bash
|
||||
curl -X PATCH \
|
||||
https://gitea.resolutionflow.com/api/v1/repos/chihlasm/resolutionflow/issues/127 \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"state":"closed"}'
|
||||
```
|
||||
|
||||
For #66, prefer editing the title/body instead of closing it:
|
||||
|
||||
- Title: `feat: curated template packs and one-click install`
|
||||
- Body: remove completed `.rfflow` export/import acceptance criteria and keep pack/install/versioning work.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,968 @@
|
||||
# Self-Serve Signup & Onboarding — Phase 2: Frontend + Cutover
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
|
||||
>
|
||||
> **Granularity note:** Unlike Phase 1, this plan defines *contracts and acceptance criteria* — not every component detail. Implementers exercise judgment on internal structure (hooks vs. props, file splits, CSS organization) as long as the contracts hold and integration tests pass. Steps use checkbox (`- [ ]`) syntax for tracking; each task is one mergeable PR.
|
||||
|
||||
**Goal:** Layer the user-facing self-serve flow on top of the Phase 1 backend foundation — pricing page, OAuth buttons + register redesign, welcome wizard, dashboard redesign with trial pill + next-step card + checklist, accept-invite page, sales contact form, billing portal — gated behind `SELF_SERVE_ENABLED` and `VITE_SELF_SERVE_ENABLED` until cutover.
|
||||
|
||||
**Architecture:** Frontend reads billing state from a new `useBillingStore` Zustand store fed by `GET /billing/state`. New routes layer on the existing React Router v7 + lazyWithRetry pattern. Wizard state is server-persisted via `PATCH /users/me/onboarding-step`. Authenticated routes mount under existing `AppLayout`; public routes (pricing, contact-sales, accept-invite, verify-email) are top-level. Cutover is two flag flips: backend `SELF_SERVE_ENABLED=true`, frontend `VITE_SELF_SERVE_ENABLED=true`.
|
||||
|
||||
**Tech Stack:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config), Zustand (immer + zundo), React Router v7, Axios, Lucide. Backend additions: a few small endpoints (Phase 1 left them out) — see Phase I.
|
||||
|
||||
**Spec reference:** `docs/superpowers/specs/2026-05-05-self-serve-signup-onboarding-design.md` (commit `bbb01ef`).
|
||||
**Phase 1 reference:** `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-1-backend.md`.
|
||||
|
||||
---
|
||||
|
||||
## Phase Sequencing
|
||||
|
||||
Each phase ends in a mergeable PR. Frontend gates everything behind `VITE_SELF_SERVE_ENABLED` so the new surfaces stay invisible to public users until Phase O cutover.
|
||||
|
||||
| Phase | Tasks | Outcome |
|
||||
|---|---|---|
|
||||
| I | 27–31 | Backend endpoints Phase 1 deferred + `SELF_SERVE_ENABLED` flag + `/admin/plan-limits` extension |
|
||||
| J | 32–34 | Frontend billing foundation: `useBillingStore`, hooks, gating components — proven against Phase 1 backend |
|
||||
| K | 35–37 | Auth surfaces: register redesign with OAuth buttons, accept-invite page, email-verification surfaces |
|
||||
| L | 38–39 | Welcome wizard — 3 steps with persistence |
|
||||
| M | 40–41 | Dashboard redesign — trial pill, next-step card, checklist redesign |
|
||||
| N | 42–44 | Public surfaces: pricing page, contact-sales form, landing-page CTA, beta-signup 307 |
|
||||
| O | 45–47 | Cutover: Stripe live-mode setup, internal validation, feature-flag flip |
|
||||
|
||||
---
|
||||
|
||||
## Phase I — Backend endpoints + admin extension + feature flag
|
||||
|
||||
### Task 27: BillingService.open_customer_portal + GET /billing/portal-session
|
||||
|
||||
**Outcome:** Authed users can request a Stripe-hosted Customer Portal URL for card updates and cancellation.
|
||||
|
||||
**Contract:**
|
||||
|
||||
```
|
||||
GET /api/v1/billing/portal-session
|
||||
→ 200 { url: string }
|
||||
→ 503 when STRIPE_SECRET_KEY unset
|
||||
→ 400 when account has no stripe_customer_id (must complete checkout first)
|
||||
```
|
||||
|
||||
`BillingService.open_customer_portal(account)` creates a `stripe.billing_portal.Session` with `return_url=$FRONTEND_URL/account/billing` and returns the session URL.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Endpoint mounted at `/billing/portal-session` and is in the `_SUBSCRIPTION_GUARD_ALLOWLIST` and `_EMAIL_VERIFICATION_ALLOWLIST` (so it works for canceled / unverified-past-grace users who need to update billing).
|
||||
- [ ] Returns 400 with `{"error": "no_stripe_customer"}` when `account.stripe_customer_id is None`.
|
||||
- [ ] Stripe call mocked via `respx`; happy-path test asserts shape `{url: ...}`.
|
||||
|
||||
**Integration test added:**
|
||||
|
||||
- `test_billing_portal_returns_url_for_account_with_stripe_customer`
|
||||
|
||||
**Commit:** `feat(billing): add BillingService.open_customer_portal + GET endpoint`
|
||||
|
||||
---
|
||||
|
||||
### Task 28: PATCH /users/me/onboarding-step
|
||||
|
||||
**Outcome:** Welcome wizard can persist Step 1/2/3 state to the server.
|
||||
|
||||
**Contract:**
|
||||
|
||||
```
|
||||
PATCH /api/v1/users/me/onboarding-step
|
||||
body: {
|
||||
step: 1 | 2 | 3,
|
||||
action: "complete" | "skip",
|
||||
data?: {
|
||||
// step 1
|
||||
company_name?: string,
|
||||
team_size_bucket?: "1-2"|"3-5"|"6-10"|"11-25"|"26+",
|
||||
role_at_signup?: "owner"|"lead_tech"|"tech"|"other",
|
||||
// step 2
|
||||
primary_psa?: "connectwise"|"autotask"|"halopsa"|"none",
|
||||
// step 3 has no data — invitations posted separately to /accounts/me/invites/bulk
|
||||
},
|
||||
}
|
||||
→ 200 { onboarding_step_completed: int, onboarding_dismissed: false }
|
||||
```
|
||||
|
||||
Writes:
|
||||
- step=1 + action=complete → `accounts.name`, `accounts.team_size_bucket`, `users.role_at_signup`, `users.onboarding_step_completed=1`
|
||||
- step=1 + action=skip → `users.onboarding_step_completed=1` only (no field writes)
|
||||
- step=2 → `accounts.primary_psa` (only on complete) + `users.onboarding_step_completed=2`
|
||||
- step=3 → `users.onboarding_step_completed=3` (the actual invites POST is separate)
|
||||
|
||||
Validates: `step` cannot decrease; `action="skip"` ignores the `data` payload.
|
||||
|
||||
**Endpoint also exposes a sibling:** `POST /users/me/onboarding-dismiss-rest` → sets `users.onboarding_dismissed=TRUE`. Used by "Skip the rest" button.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] In `_EMAIL_VERIFICATION_ALLOWLIST` (so users can move through the wizard before verifying email).
|
||||
- [ ] In `_SUBSCRIPTION_GUARD_ALLOWLIST` (wizard runs during the trial; never gated).
|
||||
- [ ] Refusing to decrease `step` is enforced (a step=2 PATCH followed by step=1 returns 400).
|
||||
- [ ] Tests cover: complete with data writes fields; skip without data only advances step; idempotent re-PATCH of same step.
|
||||
|
||||
**Integration tests added:**
|
||||
|
||||
- `test_onboarding_step1_complete_writes_account_name_and_team_size_and_role`
|
||||
- `test_onboarding_step2_skip_advances_without_psa`
|
||||
- `test_onboarding_step_cannot_decrease`
|
||||
- `test_onboarding_dismiss_rest_sets_flag`
|
||||
|
||||
**Commit:** `feat(onboarding): add PATCH /users/me/onboarding-step + dismiss-rest`
|
||||
|
||||
---
|
||||
|
||||
### Task 29: POST /sales-leads endpoint
|
||||
|
||||
**Outcome:** Public Talk-to-sales form has somewhere to post.
|
||||
|
||||
**Contract:**
|
||||
|
||||
```
|
||||
POST /api/v1/sales-leads
|
||||
body: {
|
||||
email: string,
|
||||
name: string,
|
||||
company: string,
|
||||
team_size?: string,
|
||||
message?: string,
|
||||
source: "pricing_page" | "register_footer" | "landing_page",
|
||||
posthog_distinct_id?: string,
|
||||
}
|
||||
→ 201 { id: uuid, status: "received" }
|
||||
```
|
||||
|
||||
Public — no auth required. Rate-limit: max 5 submissions per IP per hour (use existing `core.rate_limit`).
|
||||
|
||||
Side effects:
|
||||
1. Insert `sales_leads` row.
|
||||
2. Fire-and-forget `EmailService.send_sales_lead_notification` to `settings.SALES_LEAD_RECIPIENT_EMAIL` (new env var, default `sales@resolutionflow.com`).
|
||||
3. Emit PostHog server-side event `talk_to_sales_form_submitted` with `source` property.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Anti-spam: rate-limited per IP.
|
||||
- [ ] Email send failure doesn't fail the request (logged warning).
|
||||
- [ ] Sales-lead recipient email is configurable; defaults to a placeholder until cutover.
|
||||
|
||||
**Integration tests:**
|
||||
|
||||
- `test_sales_lead_creates_row_and_sends_notification_email`
|
||||
- `test_sales_lead_rate_limited_after_5_per_hour`
|
||||
|
||||
**Commit:** `feat(sales): add POST /sales-leads public endpoint`
|
||||
|
||||
---
|
||||
|
||||
### Task 30: Extend /admin/plan-limits to surface plan_billing fields
|
||||
|
||||
**Outcome:** Super-admins can manage plan_billing (Stripe IDs, display names, prices, public/archived flags) via the same admin page they already use.
|
||||
|
||||
**Contract change:**
|
||||
|
||||
```
|
||||
GET /api/v1/admin/plan-limits → list[PlanLimitWithBillingResponse]
|
||||
|
||||
PlanLimitWithBillingResponse extends PlanLimitResponse with:
|
||||
display_name?: string
|
||||
description?: string
|
||||
monthly_price_cents?: int | null
|
||||
annual_price_cents?: int | null
|
||||
stripe_product_id?: string | null
|
||||
stripe_monthly_price_id?: string | null
|
||||
stripe_annual_price_id?: string | null
|
||||
is_public?: bool
|
||||
is_archived?: bool
|
||||
sort_order?: int
|
||||
|
||||
PUT /api/v1/admin/plan-limits accepts the same fields; updates plan_billing
|
||||
in the same transaction. If a plan_billing row doesn't exist for the plan,
|
||||
PUT creates it.
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Single PUT round-trips both `plan_limits` and `plan_billing` in one transaction.
|
||||
- [ ] Cache invalidation: `app.state.billing_cache` flushed for all accounts on the affected plan.
|
||||
- [ ] No new admin page in v1 — existing `/admin/plan-limits` UI just gets new form fields.
|
||||
|
||||
**Integration tests:**
|
||||
|
||||
- `test_admin_plan_limits_get_includes_plan_billing_fields_when_present`
|
||||
- `test_admin_plan_limits_put_creates_plan_billing_row`
|
||||
- `test_admin_plan_limits_put_invalidates_billing_cache`
|
||||
|
||||
**Commit:** `feat(admin): extend /admin/plan-limits to manage plan_billing fields`
|
||||
|
||||
---
|
||||
|
||||
### Task 31: Wire SELF_SERVE_ENABLED feature flag
|
||||
|
||||
**Outcome:** A single flag controls whether the new public-facing self-serve flow is exposed.
|
||||
|
||||
**Contract:**
|
||||
|
||||
Backend:
|
||||
- `settings.SELF_SERVE_ENABLED: bool = False` (already added in Phase 1 Task 14).
|
||||
- New endpoint `GET /api/v1/config/public` (no auth) returns `{self_serve_enabled: bool, oauth_providers: ["google", "microsoft"] | []}` — frontend reads this once at load.
|
||||
|
||||
Frontend:
|
||||
- `VITE_SELF_SERVE_ENABLED` env var (build-time bake-in per Lesson 60).
|
||||
- New `useAppConfig` hook: prefers backend `/config/public` response, falls back to `VITE_SELF_SERVE_ENABLED` for build-time gating.
|
||||
- Public routes (`/pricing`, `/contact-sales`, `/accept-invite`, OAuth callbacks) return 404 from the frontend router when `self_serve_enabled === false`.
|
||||
- Register page hides OAuth buttons + invite-code-removed copy when flag is off (preserves the existing invite-code-required register flow).
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Flag is OFF by default in all envs except where explicitly enabled.
|
||||
- [ ] When OFF: existing `/auth/register` invite-code flow still works exactly as today.
|
||||
- [ ] When ON: new flows are reachable; invite-code requirement is removed (the field still exists in the schema for backward-compat but the gate-check accepts NULL).
|
||||
|
||||
**Integration tests:**
|
||||
|
||||
- `test_get_config_public_returns_self_serve_flag`
|
||||
- `test_register_invite_code_required_when_self_serve_disabled` (regression)
|
||||
- `test_register_invite_code_optional_when_self_serve_enabled`
|
||||
|
||||
**Commit:** `feat(config): add SELF_SERVE_ENABLED flag + GET /config/public`
|
||||
|
||||
---
|
||||
|
||||
## Phase J — Frontend billing foundation
|
||||
|
||||
### Task 32: useBillingStore Zustand store + GET /billing/state integration
|
||||
|
||||
**Outcome:** Frontend has a single source of truth for subscription / plan / feature state.
|
||||
|
||||
**Contract — store shape:**
|
||||
|
||||
```typescript
|
||||
// frontend/src/store/billingStore.ts
|
||||
interface BillingState {
|
||||
subscription: {
|
||||
status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'complimentary'
|
||||
plan: string
|
||||
current_period_start: string | null // ISO
|
||||
current_period_end: string | null // ISO
|
||||
cancel_at_period_end: boolean
|
||||
seat_limit: number | null
|
||||
has_pro_entitlement: boolean
|
||||
is_paid: boolean
|
||||
} | null
|
||||
planBilling: {
|
||||
display_name: string
|
||||
description: string | null
|
||||
monthly_price_cents: number | null
|
||||
annual_price_cents: number | null
|
||||
} | null
|
||||
planLimits: Record<string, unknown>
|
||||
enabledFeatures: Record<string, boolean>
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface BillingStore extends BillingState {
|
||||
fetch: () => Promise<void>
|
||||
refetch: () => Promise<void>
|
||||
reset: () => void
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- Auto-fetches on auth-store login (subscribe to `authStore`).
|
||||
- Auto-resets on logout.
|
||||
- Polls every 60s while the dashboard is mounted (simple `useInterval` in a top-level component is fine — no SSE for v1).
|
||||
- `refetch()` is exposed for explicit refresh after Stripe Checkout success-redirect.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Initial state is null/empty; populates after first successful fetch.
|
||||
- [ ] 401 from `/billing/state` triggers logout via existing axios interceptor (no special handling needed).
|
||||
- [ ] Polling disabled when no user is logged in.
|
||||
|
||||
**Integration tests (Vitest):**
|
||||
|
||||
- `useBillingStore fetches on login and populates subscription`
|
||||
- `useBillingStore resets on logout`
|
||||
- `useBillingStore refetch overwrites stale data`
|
||||
|
||||
**Commit:** `feat(billing): add useBillingStore and /billing/state integration`
|
||||
|
||||
---
|
||||
|
||||
### Task 33: useFeature, useFeatureLimit, useTrialBanner hooks
|
||||
|
||||
**Outcome:** Components can ask "is this feature on?" / "how many sessions left?" / "what stage is the trial in?" without re-implementing the read.
|
||||
|
||||
**Contract — hook signatures:**
|
||||
|
||||
```typescript
|
||||
// useFeature: enabled boolean for a feature key
|
||||
function useFeature(flagKey: string): boolean
|
||||
|
||||
// useFeatureLimit: progress against a quantitative limit
|
||||
function useFeatureLimit(field: keyof PlanLimits): {
|
||||
used: number // from /api/v1/usage/{field} (lazy fetch, cached 60s)
|
||||
limit: number | null
|
||||
percentage: number | null // null when limit is null (unlimited)
|
||||
isAtLimit: boolean
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
// useTrialBanner: derives stage from subscription state
|
||||
function useTrialBanner(): {
|
||||
stage: 'pristine' | 'warning' | 'urgent' | 'expired' | 'complimentary' | 'paid' | 'past_due' | 'canceled' | null
|
||||
daysRemaining: number | null
|
||||
}
|
||||
```
|
||||
|
||||
**Stage derivation:**
|
||||
- `subscription.status === 'complimentary'` → `complimentary`
|
||||
- `subscription.status === 'active'` → `paid`
|
||||
- `subscription.status === 'past_due'` → `past_due`
|
||||
- `subscription.status === 'canceled'` → `canceled`
|
||||
- `subscription.status === 'trialing'` AND `current_period_end > now()` → `pristine` (>3 days), `warning` (1–3), `urgent` (<1)
|
||||
- `subscription.status === 'trialing'` AND `current_period_end <= now()` → `expired`
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] `useFeatureLimit` does NOT block render — returns `isLoading=true` until usage data arrives.
|
||||
- [ ] `useTrialBanner` returns `null` when subscription is null (no flicker on initial load).
|
||||
- [ ] All three hooks subscribe to `useBillingStore` such that updates propagate without manual refetch.
|
||||
|
||||
**Integration tests (Vitest):**
|
||||
|
||||
- `useFeature returns false when flag absent`
|
||||
- `useFeatureLimit transitions isLoading → loaded`
|
||||
- `useTrialBanner stage matches subscription state matrix`
|
||||
|
||||
**Commit:** `feat(billing): add useFeature, useFeatureLimit, useTrialBanner hooks`
|
||||
|
||||
---
|
||||
|
||||
### Task 34: FeatureGate, UpgradePrompt, EmailVerificationGate components
|
||||
|
||||
**Outcome:** Three drop-in components that handle the most common gating patterns. Component implementation details (props, layout, Tailwind classes) are at implementer's discretion as long as the API holds.
|
||||
|
||||
**Contracts:**
|
||||
|
||||
```tsx
|
||||
// FeatureGate: render children if feature enabled, else fallback (default <UpgradePrompt />)
|
||||
<FeatureGate feature="psa_integration" fallback={<UpgradePrompt feature="psa_integration" />}>
|
||||
<PsaConfigPanel />
|
||||
</FeatureGate>
|
||||
|
||||
// UpgradePrompt: standardized "this feature is on Pro" affordance with CTA
|
||||
<UpgradePrompt feature="psa_integration" /> // resolves display name + plan name internally
|
||||
|
||||
// EmailVerificationGate: wraps protected content; renders <EmailVerificationWall /> past grace
|
||||
<EmailVerificationGate>
|
||||
<DashboardContent />
|
||||
</EmailVerificationGate>
|
||||
```
|
||||
|
||||
**Behavior:**
|
||||
|
||||
- `<FeatureGate>` reads from `useFeature(feature)`. Server-side check via `require_feature` is the security boundary; this is UX.
|
||||
- `<UpgradePrompt>` CTA links to `/account/billing/select-plan`.
|
||||
- `<EmailVerificationGate>` reads `users.email_verified_at` + `users.created_at` from `authStore.user`. Day 1–6 unverified renders children (banner shown elsewhere). Day 7+ unverified renders `<EmailVerificationWall>`.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] All three components are exported from `frontend/src/components/common/`.
|
||||
- [ ] No CSS-in-JS — Tailwind classes per existing pattern.
|
||||
- [ ] Lock icon + greyed style for `<UpgradePrompt>` matches the design system tokens (no `bg-accent` for non-interactive elements per design lessons).
|
||||
|
||||
**Integration tests (Vitest + Playwright):**
|
||||
|
||||
- `FeatureGate renders children when flag enabled, fallback when disabled` (Vitest)
|
||||
- `UpgradePrompt CTA navigates to /account/billing/select-plan` (Vitest)
|
||||
- `EmailVerificationGate renders wall on day 8 unverified user` (Vitest, mocked authStore)
|
||||
|
||||
**Commit:** `feat(billing): add FeatureGate, UpgradePrompt, EmailVerificationGate components`
|
||||
|
||||
---
|
||||
|
||||
## Phase K — Auth surfaces
|
||||
|
||||
### Task 35: Register page redesign with OAuth buttons + invite-code-optional
|
||||
|
||||
**Outcome:** New register flow supports email+password OR Google OR Microsoft, with promo code field collapsed (deferred per spec) and the legacy invite-code field invisible when `SELF_SERVE_ENABLED`.
|
||||
|
||||
**Contract:**
|
||||
|
||||
Frontend route stays `/register`. Component lives at `frontend/src/pages/RegisterPage.tsx` (modified, not replaced).
|
||||
|
||||
Top-of-page CTAs:
|
||||
- **"Continue with Google"** button → opens OAuth window → on callback, POSTs `code` to `POST /api/v1/auth/google/callback` → stores tokens via existing auth-store login flow → redirects to `/welcome` (new user) or `/` (returning).
|
||||
- **"Continue with Microsoft"** button → same shape against `/auth/microsoft/callback`.
|
||||
- **"or sign up with email"** divider, then existing email + password form.
|
||||
|
||||
Removed/conditional:
|
||||
- **Invite-code field** — hidden when `useAppConfig().self_serve_enabled === true`. When the flag is off, the existing required-invite-code flow runs unchanged.
|
||||
- **Promo-code field** — not in v1 (deferred per spec). UI should NOT include it.
|
||||
|
||||
`/register?plan=pro` query param is captured into `localStorage` (`rf-intended-plan`) so `BillingService.start_trial` (already runs on Pro by default) can later be enriched OR the in-app picker can preselect.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Email+password register call still works; auto-sends verification email per Phase 1 Task 20.
|
||||
- [ ] OAuth callback creates User + Account + Subscription per Phase 1 Task 17/18; lands on `/welcome`.
|
||||
- [ ] When self-serve disabled: invite-code flow visible, OAuth buttons hidden.
|
||||
- [ ] When self-serve enabled: invite-code field hidden, OAuth buttons visible.
|
||||
- [ ] Existing test users (`engineer@resolutionflow.example.com` etc.) can still log in via `/login` unchanged.
|
||||
|
||||
**Integration tests (Playwright):**
|
||||
|
||||
- `register email+password → verification email queued → land on /welcome`
|
||||
- `register via Google OAuth (mocked provider) → land on /welcome`
|
||||
- `register page hides OAuth + shows invite-code field when self_serve_enabled is false`
|
||||
|
||||
**Commit:** `feat(auth): redesign /register with OAuth buttons; hide invite-code under flag`
|
||||
|
||||
---
|
||||
|
||||
### Task 36: AcceptInvitePage at /accept-invite?code=...
|
||||
|
||||
**Outcome:** Invitee from email can join an existing account with set-password OR Google OR Microsoft.
|
||||
|
||||
**Contract:**
|
||||
|
||||
New top-level route `/accept-invite?code=<32-char-code>`. Component at `frontend/src/pages/AcceptInvitePage.tsx`.
|
||||
|
||||
Flow:
|
||||
1. On mount, `GET /api/v1/accounts/invites/{code}/lookup` (NEW endpoint — see acceptance criteria) returns `{account_name, inviter_name, invited_email, role}` or 404/410 (expired/revoked/used).
|
||||
2. Render: "Join {account_name} on ResolutionFlow" + email locked to `invited_email` + three sign-in options (set password, Google, Microsoft).
|
||||
3. On submit, POST to existing `/auth/register` with `account_invite_code` and the email matching `invited_email` (per Phase 1 Task 20 enforcement).
|
||||
4. OAuth path: launch provider with state including the invite code; callback POSTs `{code, account_invite_code, invited_email}` to handle linking.
|
||||
5. Success → land on `/?welcome=teammate` (suppresses welcome wizard for invitees per spec).
|
||||
|
||||
**Backend addition needed (small):**
|
||||
|
||||
```
|
||||
GET /api/v1/accounts/invites/{code}/lookup
|
||||
→ 200 {account_name, inviter_name, invited_email, role}
|
||||
→ 404 invite_invalid_or_expired_or_revoked
|
||||
```
|
||||
|
||||
This is a public endpoint (no auth) reading account-scoped data, so uses `_admin_session_factory()` per the Phase 4 RLS pattern.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Invalid/expired/revoked codes show a clear "ask {inviter} to resend" message with a link to email the inviter (via `mailto:`).
|
||||
- [ ] Email field is locked to `invited_email` — frontend doesn't even render an editable input.
|
||||
- [ ] OAuth path requires the provider's email to match `invited_email`; mismatch returns the same `invite_email_mismatch` error from Phase 1.
|
||||
- [ ] Successful accept lands on `/?welcome=teammate`; the dashboard shows a "Welcome to {account_name}" toast and a checklist with "Setup shop" + "Invite a teammate" auto-marked done.
|
||||
|
||||
**Integration tests (Playwright):**
|
||||
|
||||
- `accept invite with email/password → join existing account → land on /?welcome=teammate`
|
||||
- `accept invite with Google OAuth (matching email) → land on dashboard`
|
||||
- `accept invite with mismatched email → see invite_email_mismatch error`
|
||||
- `accept invite with expired code → see resend message`
|
||||
|
||||
**Commit:** `feat(auth): add /accept-invite page + lookup endpoint`
|
||||
|
||||
---
|
||||
|
||||
### Task 37: Email verification surfaces — banner, wall, /verify-email route
|
||||
|
||||
**Outcome:** UI for the soft 7-day grace + day-7 wall.
|
||||
|
||||
**Contract:**
|
||||
|
||||
- **`<EmailVerificationBanner />`** — thin top-of-dashboard bar visible when `users.email_verified_at IS NULL` AND grace not expired. "Resend" link calls existing `POST /auth/email/send-verification`.
|
||||
- **`<EmailVerificationWall />`** — full-content replacement when grace expired. Same "Resend" CTA + a "Sign out" button.
|
||||
- **`/verify-email?token=...`** — frontend route that calls existing `POST /auth/email/verify` and shows success/error state. On success, refreshes the auth store and redirects to `/?verified=1` toast.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Banner contrasts well in dark theme (use `bg-warning-dim` per design tokens, not custom colors).
|
||||
- [ ] Wall has a "Sign out" button so a user with a typo'd email can recover.
|
||||
- [ ] Verification success toast does not double-fire on remount.
|
||||
- [ ] If user is already verified when hitting `/verify-email`, the page shows "Already verified" rather than failing.
|
||||
|
||||
**Integration tests (Playwright):**
|
||||
|
||||
- `unverified day-1 user sees banner on dashboard`
|
||||
- `unverified day-8 user sees wall, can sign out, can resend`
|
||||
- `clicking verification link verifies and redirects to dashboard with toast`
|
||||
|
||||
**Commit:** `feat(auth): add email verification banner, wall, /verify-email page`
|
||||
|
||||
---
|
||||
|
||||
## Phase L — Welcome wizard
|
||||
|
||||
### Task 38: Wizard scaffold + Step 1 (Your shop)
|
||||
|
||||
**Outcome:** Authed users at `/welcome` see a deliberate first-impression flow that captures shop context.
|
||||
|
||||
**Routing:**
|
||||
|
||||
```
|
||||
/welcome → redirects to next incomplete step or "/" if done
|
||||
/welcome/step-1 → "Your shop"
|
||||
/welcome/step-2 → "Your PSA"
|
||||
/welcome/step-3 → "Invite your team"
|
||||
```
|
||||
|
||||
A top-level `<WelcomeRouter />` reads `users.onboarding_step_completed` + `users.onboarding_dismissed` from authStore and dispatches:
|
||||
|
||||
| State | Redirect |
|
||||
|---|---|
|
||||
| `onboarding_dismissed === true` | `/` |
|
||||
| `onboarding_step_completed >= 3` | `/` |
|
||||
| `onboarding_step_completed === null/0` | `/welcome/step-1` |
|
||||
| `onboarding_step_completed === 1` | `/welcome/step-2` |
|
||||
| `onboarding_step_completed === 2` | `/welcome/step-3` |
|
||||
|
||||
**Step 1 fields (per spec):**
|
||||
|
||||
- Company name (pre-filled from `accounts.name`, editable)
|
||||
- Team size: select from `1-2 / 3-5 / 6-10 / 11-25 / 26+`
|
||||
- Your role: select from `Owner / Lead Tech / Tech / Other`
|
||||
|
||||
**Step 1 actions:**
|
||||
- **Continue** → PATCH `/users/me/onboarding-step` `{step: 1, action: "complete", data: {...}}` → `/welcome/step-2`
|
||||
- **Skip** → PATCH `{step: 1, action: "skip"}` → `/welcome/step-2`
|
||||
- **Skip the rest** → POST `/users/me/onboarding-dismiss-rest` → `/`
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Each navigation persists state server-side before transition; refresh resumes correctly.
|
||||
- [ ] Skip-the-rest is a quiet text link, not a primary button.
|
||||
- [ ] Email-verification banner is visible above the wizard if user is unverified (banner persists into wizard).
|
||||
|
||||
**Integration tests (Playwright):**
|
||||
|
||||
- `new user lands on /welcome/step-1 after register`
|
||||
- `step-1 Continue with all fields filled persists and advances`
|
||||
- `step-1 Skip-the-rest dismisses and lands on /`
|
||||
- `refresh in middle of step-1 returns to step-1 with prior data still in form (or empty if not yet saved)`
|
||||
|
||||
**Commit:** `feat(onboarding): add welcome wizard scaffold + Step 1 (Your shop)`
|
||||
|
||||
---
|
||||
|
||||
### Task 39: Wizard Steps 2 (Your PSA) and 3 (Invite team)
|
||||
|
||||
**Outcome:** Wizard is complete; users can finish or skip individual steps.
|
||||
|
||||
**Step 2 fields (per spec):**
|
||||
|
||||
- PSA selection: tiles for `ConnectWise / Autotask / HaloPSA / No PSA yet`. Selecting one shows a quiet inline "Connect now" link that navigates to `/account/integrations` (out of wizard).
|
||||
|
||||
**Step 3 fields (per spec):**
|
||||
|
||||
- Email input rows × 3, with "+ Add another" up to 10 max
|
||||
- Per-row role select: default "Tech" (maps to `engineer`), with "Viewer" option
|
||||
- "Skip" and "Skip the rest" links
|
||||
|
||||
**Step 3 submit behavior:**
|
||||
|
||||
- POST `/api/v1/accounts/me/invites/bulk` with the populated rows.
|
||||
- Then PATCH `/users/me/onboarding-step` `{step: 3, action: "complete"}`.
|
||||
- On success → `/?welcome=true` (shows a "You're all set" toast).
|
||||
- Bulk endpoint's `failed[]` array displayed inline next to the failed email; user can retry.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Step 2 default action is "Continue" (not "Connect now"); the inline credential entry is intentionally NOT in the wizard.
|
||||
- [ ] Step 3 invites are sent (email send happens server-side per Phase 1 Task 22).
|
||||
- [ ] Empty Step 3 + Skip = no invites sent; step still advances.
|
||||
- [ ] Each step's persistence is independent — navigating back via browser back button respects `onboarding_step_completed`.
|
||||
|
||||
**Integration tests (Playwright):**
|
||||
|
||||
- `step-2 select ConnectWise → continue → primary_psa is set in /billing/state-equivalent or /auth/me`
|
||||
- `step-3 enter 2 emails → invites visible in /accounts/me/invites + emails sent`
|
||||
- `step-3 with one bad email shows partial success, user can retry`
|
||||
- `wizard end-to-end: register → step-1 → step-2 → step-3 → dashboard with success toast`
|
||||
|
||||
**Commit:** `feat(onboarding): add wizard Steps 2 (PSA) and 3 (Invite team)`
|
||||
|
||||
---
|
||||
|
||||
## Phase M — Dashboard redesign
|
||||
|
||||
### Task 40: Topbar trial pill + email verification banner integration
|
||||
|
||||
**Outcome:** Every authed page shows the right billing-state pill in the topbar.
|
||||
|
||||
**Contract — `<TrialPill />` placement:**
|
||||
|
||||
Mounts inside `AppLayout` topbar. Reads `useTrialBanner()`:
|
||||
|
||||
| Stage | Pill |
|
||||
|---|---|
|
||||
| `pristine` | "Pro trial · Nd" — info color |
|
||||
| `warning` (≤3d) | "Pro trial · Nd" — warning amber |
|
||||
| `urgent` (≤1d) | "Pro trial · today" — urgent (warning amber, slightly more saturated) |
|
||||
| `expired` | "Trial expired — pick a plan" — clickable → `/account/billing/select-plan` |
|
||||
| `paid` | tier display name (e.g., "Pro") — quiet |
|
||||
| `complimentary` | "Complimentary Pro" — friendly tag, no CTA |
|
||||
| `past_due` | "Payment failed — update card" — clickable → `/account/billing` |
|
||||
| `canceled` | "Reactivate" — clickable → `/account/billing/select-plan` |
|
||||
| `null` | hidden |
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Color tokens are existing design-system tokens (`--accent` / `--warning` / etc.) — no custom colors.
|
||||
- [ ] Pill is keyboard-focusable for clickable variants.
|
||||
- [ ] EmailVerificationBanner from Task 37 sits BELOW the topbar, ABOVE main content. Both can coexist.
|
||||
- [ ] Mobile: pill collapses to icon + tooltip when topbar is too narrow.
|
||||
|
||||
**Integration tests (Playwright):**
|
||||
|
||||
- `complimentary user sees "Complimentary Pro" pill`
|
||||
- `trialing user with 12 days remaining sees "Pro trial · 12d"`
|
||||
- `expired-trial user sees clickable "Trial expired" pill`
|
||||
- `past_due user sees clickable "Payment failed" pill`
|
||||
|
||||
**Commit:** `feat(dashboard): add TrialPill in AppLayout topbar`
|
||||
|
||||
---
|
||||
|
||||
### Task 41: Next-step card + checklist redesign + dashboard wiring
|
||||
|
||||
**Outcome:** Dashboard surfaces a single "next thing to do" card; full checklist available behind a toggle. Replaces the existing `OnboardingChecklist` component.
|
||||
|
||||
**Contract:**
|
||||
|
||||
- **`<NextStepCard />`** at top of dashboard content (below banner). Reads from existing `/users/onboarding-status` payload (extended in Phase 1 to drop SOLO/TEAM split — see Phase 1 Task wiring if needed; if not done, do it here).
|
||||
- Shows the highest-priority incomplete item with a primary CTA button. Items in priority order:
|
||||
1. Verify your email (only if unverified — hidden for OAuth signups)
|
||||
2. Set up your shop (`onboarding_step_completed >= 1`)
|
||||
3. Run your first FlowPilot session (existing `ran_session` check)
|
||||
4. Connect your PSA (existing `connected_psa` check)
|
||||
5. Invite a teammate (extend existing `invited_teammate` check)
|
||||
6. Pick a plan — surfaces near trial end (only when stage is `warning` / `urgent` / `expired`)
|
||||
- Below the card, "Show all setup steps" toggle expands a full checklist view (single list, no SOLO/TEAM split per spec).
|
||||
|
||||
**OnboardingChecklist component changes:**
|
||||
|
||||
- Remove `SOLO_ITEMS` / `TEAM_ITEMS` split — single unified list.
|
||||
- Drop the stale `tried_ai_assistant` / "Check out the Script Builder" item entirely.
|
||||
- Add "Pick a plan" item that shows when trial-banner stage is `warning` or later.
|
||||
|
||||
**Backend addition:**
|
||||
|
||||
`/api/v1/users/onboarding-status` (existing endpoint) response shape extended:
|
||||
|
||||
```python
|
||||
class OnboardingStatus(BaseModel):
|
||||
# existing
|
||||
created_flow: bool
|
||||
ran_session: bool
|
||||
exported_session: bool
|
||||
invited_teammate: bool
|
||||
connected_psa: bool
|
||||
is_team_user: bool # KEEP — internal logic only; no UI bifurcation
|
||||
dismissed: bool # users.onboarding_dismissed
|
||||
# NEW
|
||||
email_verified: bool
|
||||
shop_setup_done: bool # users.onboarding_step_completed >= 1
|
||||
# REMOVED from new code paths (kept in payload for backward-compat during deploy):
|
||||
# tried_ai_assistant: bool
|
||||
```
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Old `OnboardingChecklist` widget is replaced wholesale on the dashboard route. Other pages that referenced it (none found in current code, but confirm via grep) are updated or unaffected.
|
||||
- [ ] Next-step card disappears when all items are done OR `onboarding_dismissed=TRUE`.
|
||||
- [ ] No SOLO/TEAM bifurcation in the checklist UI.
|
||||
- [ ] Stale "Script Builder" item is gone.
|
||||
|
||||
**Integration tests (Playwright):**
|
||||
|
||||
- `dashboard for new user surfaces "Verify your email" as next step`
|
||||
- `after verifying, next step is "Set up your shop"`
|
||||
- `after wizard step 1, next step is "Run your first FlowPilot session"`
|
||||
- `"Show all setup steps" expands to a 6-item list with no SOLO/TEAM headers`
|
||||
- `Pick-a-plan appears at trial day 12, urgent at day 13, primary at day 14`
|
||||
|
||||
**Commit:** `feat(dashboard): replace checklist with next-step card + unified list`
|
||||
|
||||
---
|
||||
|
||||
## Phase N — Public surfaces
|
||||
|
||||
### Task 42: Pricing page (B-style) at /pricing
|
||||
|
||||
**Outcome:** Public pricing page lives at `/pricing`, gated by feature flag.
|
||||
|
||||
**Contract:**
|
||||
|
||||
Public route. Component at `frontend/src/pages/PricingPage.tsx`. Reads `plan_billing` data via a new public endpoint:
|
||||
|
||||
```
|
||||
GET /api/v1/plans/public
|
||||
→ 200 [
|
||||
{
|
||||
plan: string,
|
||||
display_name: string,
|
||||
description: string | null,
|
||||
monthly_price_cents: number | null,
|
||||
annual_price_cents: number | null,
|
||||
max_seats: number | null, // from plan_limits
|
||||
sort_order: number,
|
||||
is_public: true, // filtered server-side
|
||||
},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
Page sections (per spec B):
|
||||
1. Hero (one-liner + reverse-trial reassurance)
|
||||
2. Three plan cards (Starter / Pro recommended / Enterprise) — Pro card has "Recommended" badge; Enterprise card has "Talk to sales" CTA → `/contact-sales`
|
||||
3. Comparison table (which features in which plan) — driven by feature flag display names
|
||||
4. Single testimonial slot (placeholder until real testimonial available)
|
||||
5. Trust strip — security/compliance copy
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Returns 404 when `self_serve_enabled === false`.
|
||||
- [ ] Plan cards show prices from `plan_billing.monthly_price_cents`. Enterprise card hides price.
|
||||
- [ ] "Start free trial" buttons on Starter/Pro link to `/register?plan=pro` (or starter).
|
||||
- [ ] "Talk to sales" on Enterprise links to `/contact-sales`.
|
||||
- [ ] Trust strip claims should be honest — see spec open-risks #7 (GDPR DPA) and #7b (SOC2). If those aren't ready by cutover, copy in this task uses softer language (e.g., "Built on Stripe + AWS · Encrypted in transit and at rest").
|
||||
|
||||
**Integration tests (Playwright):**
|
||||
|
||||
- `unauth user sees pricing page when self_serve_enabled is true`
|
||||
- `pricing page → "Start free trial" → /register?plan=pro`
|
||||
- `pricing page → "Talk to sales" → /contact-sales`
|
||||
- `pricing page returns 404 when self_serve_enabled is false`
|
||||
|
||||
**Commit:** `feat(pricing): add /pricing page (B-style)`
|
||||
|
||||
---
|
||||
|
||||
### Task 43: Talk-to-sales form at /contact-sales + landing-page CTA
|
||||
|
||||
**Outcome:** Enterprise prospects have a clear path; `LandingPage.tsx` gets a "See pricing" CTA.
|
||||
|
||||
**Contract:**
|
||||
|
||||
`/contact-sales` route with form posting to `POST /sales-leads` (Phase I Task 29).
|
||||
|
||||
Form fields:
|
||||
- Name (required)
|
||||
- Work email (required)
|
||||
- Company (required)
|
||||
- Team size (select; same buckets as wizard Step 1 + a "more than 26" option)
|
||||
- "What brought you here?" (textarea, optional)
|
||||
- Submit button
|
||||
|
||||
After submit:
|
||||
- Confirmation page: "Thanks — we'll reach out within 1 business day. Want to skip ahead? [Calendly link]"
|
||||
- Calendly link is a config string (`VITE_CALENDLY_URL`); when unset, link section is hidden.
|
||||
|
||||
`LandingPage.tsx` modification:
|
||||
- Add a prominent "See pricing" CTA near the existing "Get started" CTA.
|
||||
- Both visible regardless of `self_serve_enabled` (see-pricing 404s if flag off, landing keeps existing behavior). Actually: gate the See-pricing CTA behind `useAppConfig().self_serve_enabled` so we don't show a button that 404s.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Form blocks duplicate submissions client-side (disable button while in flight).
|
||||
- [ ] PostHog `talk_to_sales_form_submitted` event fires with `source: 'pricing_page' | 'landing_page'` based on referrer.
|
||||
- [ ] Calendly link block hides when `VITE_CALENDLY_URL` unset.
|
||||
|
||||
**Integration tests (Playwright):**
|
||||
|
||||
- `submit /contact-sales form → see confirmation page → /sales-leads has new row`
|
||||
- `landing page shows "See pricing" CTA when self_serve_enabled, hides when off`
|
||||
|
||||
**Commit:** `feat(sales): add /contact-sales form + landing page CTA`
|
||||
|
||||
---
|
||||
|
||||
### Task 44: Beta-signup deprecation
|
||||
|
||||
**Outcome:** The legacy `beta_signup.py` endpoint redirects to register; existing waitlist gets a heads-up email.
|
||||
|
||||
**Contract:**
|
||||
|
||||
- `POST /api/v1/beta-signup` (existing) → keep mounted but return `307 Temporary Redirect` to `/register?from=beta`.
|
||||
- One-off admin script `scripts/email_beta_waitlist.py` that reads existing `beta_signup` table and queues "we've launched" emails to each.
|
||||
- Don't drop the table; archive in place.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Existing tests against `/beta-signup` either updated to expect 307 or removed.
|
||||
- [ ] Script is idempotent (uses an `email_sent_at` field on the beta-signup row, adding it via migration if needed).
|
||||
|
||||
**Integration tests:**
|
||||
|
||||
- `POST /beta-signup returns 307 to /register?from=beta`
|
||||
|
||||
**Commit:** `feat(sales): redirect beta-signup to /register; queue waitlist emails`
|
||||
|
||||
---
|
||||
|
||||
## Phase O — Cutover
|
||||
|
||||
### Task 45: Stripe live-mode setup checklist (manual)
|
||||
|
||||
**Outcome:** Stripe live-mode is configured and matches test mode. Manual step; this task tracks completion.
|
||||
|
||||
**Checklist:**
|
||||
|
||||
- [ ] In Stripe Dashboard (live mode):
|
||||
- [ ] Create Products: ResolutionFlow Starter, ResolutionFlow Pro, ResolutionFlow Enterprise.
|
||||
- [ ] Create monthly + annual recurring Prices for Starter and Pro.
|
||||
- [ ] Enterprise has no Prices in the catalog (sales-created per customer).
|
||||
- [ ] Enable Customer Portal: update payment method, cancel subscription, view invoices. Disable plan-switching from the portal.
|
||||
- [ ] Register webhook endpoint at `https://api.resolutionflow.com/api/v1/webhooks/stripe` with events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.payment_failed`, `invoice.payment_succeeded`.
|
||||
- [ ] Save the live webhook signing secret.
|
||||
- [ ] In Railway prod environment variables:
|
||||
- [ ] `STRIPE_SECRET_KEY` (live mode key, `sk_live_...`)
|
||||
- [ ] `STRIPE_WEBHOOK_SECRET` (live signing secret)
|
||||
- [ ] `STRIPE_PUBLISHABLE_KEY` (live publishable key) → `VITE_STRIPE_PUBLISHABLE_KEY` for frontend builds
|
||||
- [ ] `OAUTH_REDIRECT_BASE` = `https://resolutionflow.com`
|
||||
- [ ] `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` for prod Google OAuth app (separate from dev/test)
|
||||
- [ ] `MS_CLIENT_ID` / `MS_CLIENT_SECRET` for prod Microsoft OAuth app
|
||||
- [ ] Run `python -m scripts.sync_stripe_plan_ids` (Phase 1 Task 6 referenced; create if not existing) to populate `plan_billing` rows with live Stripe IDs:
|
||||
- [ ] Pro monthly + annual price IDs
|
||||
- [ ] Starter monthly + annual price IDs (if Starter is in scope; see open risk #14)
|
||||
- [ ] Enterprise: stripe_product_id only, no price IDs
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] Live webhook receives a test event (use Stripe CLI's `stripe trigger checkout.session.completed` against the live endpoint with a test customer) and is logged in `stripe_events`.
|
||||
- [ ] `plan_billing` rows query returns expected Stripe IDs for Pro tier.
|
||||
|
||||
**No commit** — this is a deploy-time operation.
|
||||
|
||||
---
|
||||
|
||||
### Task 46: Internal validation pass (test mode → soft cutover via per-email allowlist)
|
||||
|
||||
**Outcome:** Real flow exercised end-to-end against the prod backend with `SELF_SERVE_ENABLED=false`, gated to internal testers only.
|
||||
|
||||
**Per-email allowlist mechanism:**
|
||||
|
||||
Backend reads `INTERNAL_TESTER_EMAILS` env var (comma-separated). When `SELF_SERVE_ENABLED=false` AND `current_user.email` is in the list, treat the user as if the flag were on (e.g., bypass invite-code requirement, expose pricing page via a header check). For frontend, the `/config/public` endpoint returns `self_serve_enabled: true` for these specific authenticated users.
|
||||
|
||||
**Validation scenarios:**
|
||||
|
||||
- [ ] Email signup → wizard step-by-step → first FlowPilot session run → trial-end synthetic time (DB query: `UPDATE subscriptions SET current_period_end = now() - interval '1 day' WHERE account_id = ...`) → plan picker → Stripe Checkout (test card `4242 4242 4242 4242`) → webhook → status='active'.
|
||||
- [ ] Google sign-in (real Google account) → `/welcome` → wizard → dashboard.
|
||||
- [ ] Microsoft sign-in (real M365 account) → same flow.
|
||||
- [ ] Invite-by-email: existing tester invites a teammate → teammate receives email → clicks link → `/accept-invite` → set password → joins account → lands on `/?welcome=teammate`.
|
||||
- [ ] Email match enforcement: try to register with `account_invite_code` and a different email → see `invite_email_mismatch`.
|
||||
- [ ] Past-due simulation: use Stripe test card `4000 0000 0000 0341` → first invoice succeeds, next charge declines → `subscription_status='past_due'` → topbar pill changes → user can update card via Customer Portal.
|
||||
- [ ] Pilot complimentary: log in as an existing pilot account → see "Complimentary Pro" pill, no walls, no nudges.
|
||||
- [ ] Webhook signature failure: send a forged webhook → 400 + log entry.
|
||||
- [ ] OAuth-only user attempts password login: rejected with `use_oauth_provider`.
|
||||
|
||||
**Acceptance criteria:**
|
||||
|
||||
- [ ] All 9 scenarios pass in prod test mode with internal testers.
|
||||
- [ ] Errors logged during validation are reviewed and either fixed or documented.
|
||||
|
||||
**No commit** — validation is a checklist of test runs.
|
||||
|
||||
---
|
||||
|
||||
### Task 47: Feature-flag flip + week-1 monitoring
|
||||
|
||||
**Outcome:** `SELF_SERVE_ENABLED=true` and `VITE_SELF_SERVE_ENABLED=true` in prod. Public pricing page is live.
|
||||
|
||||
**Cutover steps:**
|
||||
|
||||
- [ ] Send pre-launch email to all pilot users via `EmailService.send_complimentary_account_announcement` (1-2 days before flip).
|
||||
- [ ] Schedule the flip during low-traffic hours.
|
||||
- [ ] Update Railway env vars: `SELF_SERVE_ENABLED=true` (backend), `VITE_SELF_SERVE_ENABLED=true` (frontend, requires redeploy since Vite bakes at build time).
|
||||
- [ ] Verify prod: pricing page returns 200; new user can register without invite code.
|
||||
- [ ] Announce launch (founder action; not eng).
|
||||
|
||||
**Week-1 monitoring (PostHog dashboards):**
|
||||
|
||||
- [ ] Funnel: `pricing_page_viewed → register_started → register_completed → email_verification_completed → welcome_wizard_completed → first_session_started`
|
||||
- [ ] OAuth method mix
|
||||
- [ ] Wizard skip rate per step
|
||||
- [ ] `feature_gate_blocked` count by `flag_key`
|
||||
- [ ] Trial conversion: `trial_modal_shown → checkout_completed`
|
||||
- [ ] Stripe webhook error rate (Sentry alert if > 1/hour)
|
||||
- [ ] `subscriptions.is_paid` audit query (manual SQL): confirm complimentary accounts are NOT counted in MRR
|
||||
|
||||
**Rollback plan:**
|
||||
|
||||
- Flip both flags back to `false`. Pricing page → 404. Register page → invite-code-required flow. Pilot complimentary status preserved (benign).
|
||||
- Stripe webhook handler stays live regardless.
|
||||
- Forward-only schema means nothing to revert at the DB level.
|
||||
|
||||
**No commit** — this is a deploy + monitor task.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage check (against `2026-05-05-self-serve-signup-onboarding-design.md`):**
|
||||
|
||||
| Spec section | Covered by |
|
||||
|---|---|
|
||||
| §3.1 Pricing page | Task 42 |
|
||||
| §3.2 Register page redesign with OAuth + invite-code-optional | Task 35 |
|
||||
| §3.3 Welcome wizard (3 steps) | Tasks 38, 39 |
|
||||
| §3.4 Dashboard with topbar pill + next-step card | Tasks 40, 41 |
|
||||
| §3.5 Email verification surfaces | Task 37 |
|
||||
| §3.6 Trial-end conversion (in-app modal day 10, wall day 14) | Task 41 covers checklist; the modal is part of Task 40's TrialPill stage transitions + the dashboard's modal trigger via `useTrialBanner` — implementer's discretion to add a `<TrialEndingModal />` component if it emerges naturally |
|
||||
| §3.7 Plan picker → Stripe Checkout | Frontend page at `/account/billing/select-plan` lives within the dashboard area; Task 41's "Pick a plan" CTA navigates there. Component exists in scope of Task 40/41 — implementer's call on whether to split into its own file. |
|
||||
| §3.8 Past-due / dunning | Task 40 (TrialPill `past_due` stage) + Customer Portal link |
|
||||
| §3.9 Sales lead | Tasks 29, 43 |
|
||||
| §3.10 Owner transfer (existing) | No new task — surface in Account → Team page during dashboard work, implementer's discretion |
|
||||
| §4 BillingService.open_customer_portal | Task 27 |
|
||||
| §4 PATCH /users/me/onboarding-step | Task 28 |
|
||||
| §4 GET /billing/state consumed by frontend | Task 32 |
|
||||
| §4 useFeature/useFeatureLimit/useTrialBanner | Task 33 |
|
||||
| §4 FeatureGate / UpgradePrompt | Task 34 |
|
||||
| §4 Caching invalidation triggered from /admin/plan-limits | Task 30 |
|
||||
| §5 Beta-signup deprecation | Task 44 |
|
||||
| §5 SELF_SERVE_ENABLED dark launch | Task 31 |
|
||||
| §5 Stripe live-mode setup | Task 45 |
|
||||
| §5 Internal validation phase | Task 46 |
|
||||
| §5 Cutover + monitoring | Task 47 |
|
||||
|
||||
**Gaps and judgment-calls (called out for implementer):**
|
||||
|
||||
- **`<TrialEndingModal />` (day-10 in-app modal)** — left to implementer to decide whether it's its own task or rolled into Task 40. Spec is clear about behavior; component split is style.
|
||||
- **Plan picker page (`/account/billing/select-plan`)** — frontend page that calls `POST /billing/checkout-session` and redirects. Lives within Task 40/41 area; not its own task. Acceptance: "user can pick Starter/Pro + seats and be redirected to Stripe Checkout."
|
||||
- **Owner-transfer surface in Account → Team page** — existing endpoint, just needs UI. Implementer's call on which task absorbs this.
|
||||
- **`<TrialEndedWall />`** — referenced in spec; renders on dashboard route when trial expired. Lives in Task 40/41 area as a render-branch of the dashboard layout.
|
||||
|
||||
**Placeholder scan:** none — every "implementer's discretion" call is bounded by a contract and acceptance criteria.
|
||||
|
||||
**Type/contract consistency:**
|
||||
|
||||
- `BillingState` shape in Task 32 matches `BillingStateResponse` from Phase 1 Task 24.
|
||||
- `PATCH /users/me/onboarding-step` payload in Task 28 matches the wizard's writes in Tasks 38, 39.
|
||||
- OAuth callback contract in Task 35 matches Phase 1 Task 17/18 endpoint shapes.
|
||||
- `<EmailVerificationGate>` in Task 34 reads from authStore; `<TrialPill>` in Task 40 reads from `useBillingStore`. Different sources, intentional (verification is on `User`, trial is on `Subscription`).
|
||||
|
||||
---
|
||||
|
||||
## Execution Handoff
|
||||
|
||||
**Plan complete and saved to `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md`.**
|
||||
|
||||
This plan is intentionally higher-altitude than Phase 1: contracts and acceptance criteria, not component-detail walkthroughs. Implementers exercise judgment on internal structure as long as contracts hold and integration tests pass.
|
||||
|
||||
**Recommended execution sequence:**
|
||||
|
||||
1. **Phase 1 first** (`2026-05-06-self-serve-signup-phase-1-backend.md`). Phase 2 depends on its endpoints.
|
||||
2. After Phase 1 lands, **execute Phase 2 phases I → O sequentially**. Each phase is one or a few mergeable PRs.
|
||||
3. **Cutover (Phase O)** is gated by Phase 1 + Phase 2 both green in prod test mode.
|
||||
|
||||
**Two execution options for Phase 2:**
|
||||
|
||||
**1. Subagent-Driven (recommended)** — fresh subagent per task with two-stage review. Higher-altitude tasks pair well with this since the subagent has room to make local design decisions inside the contract.
|
||||
|
||||
**2. Inline Execution** — execute tasks in a long-running session using executing-plans, with checkpoints between phases.
|
||||
|
||||
**Which approach?**
|
||||
@@ -0,0 +1,904 @@
|
||||
# Self-Serve Signup & Onboarding — Design Spec
|
||||
|
||||
**Date:** 2026-05-05
|
||||
**Status:** Draft (revised after review-findings pass; pending user re-review)
|
||||
**Author:** Michael Chihlas + Claude
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Open ResolutionFlow to public self-serve signup with a 14-day reverse trial on Pro, Stripe-backed billing, a sales-assist lane for Enterprise, and a hybrid onboarding flow (3-step welcome wizard + dashboard with next-step card). The current invite-code-gated registration is removed; existing pilot users transition to a permanent `subscriptions.status='complimentary'` state. **The billing layer reuses existing infrastructure** (`subscriptions` + `plan_limits` + `feature_flags` + `plan_feature_defaults` + `account_feature_overrides` + `account_invites` + `email_verification_tokens`) — this spec adds only what's missing, not parallel structures.
|
||||
|
||||
---
|
||||
|
||||
## Decisions Made
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Trigger for redoing signup/onboarding | Open self-serve channel (D); must look trustworthy; must hook into payment processor cleanly |
|
||||
| Trial / payment model | A + E — reverse trial (14 days, no card upfront) + sales-assist lane for Enterprise |
|
||||
| Plan structure | Two self-serve tiers (Starter, Pro) per seat + sales-assist Enterprise. Defined via existing `plan_limits.plan` keys + a new `plan_billing` sibling table (Stripe IDs, prices, public catalog metadata). |
|
||||
| Payment processor | Stripe with hosted Checkout; no provider abstraction |
|
||||
| Auth strategy | Stay with custom auth. Extend existing email verification (auto-send on register, 7-day soft grace + dashboard wall). Add Google + Microsoft via new `oauth_identities` table; `users.password_hash` becomes nullable with explicit OAuth-only handling in login/change-password/reset. Extend existing `account_invites` (enforce email match at register, wire `EmailService` into create/bulk). |
|
||||
| Signup form scope | A — minimal form (treat all signups as team-of-1) |
|
||||
| Plan choice timing | X — defer; trial runs on full Pro; picker shown around day 12 and at trial-end |
|
||||
| Feature gating | **Reuse existing `feature_flags` + `plan_feature_defaults` + `account_feature_overrides`.** Admin via existing `/admin/plan-limits` + `/admin/feature-flags` endpoints. No new combined `/admin/plans` surface in v1. |
|
||||
| Onboarding shape | C — hybrid (3-step welcome wizard then dashboard with checklist) |
|
||||
| Welcome wizard layout | V2 — narrative 3 steps (Your shop, Your PSA, Invite your team) |
|
||||
| Dashboard first-run | C — topbar trial pill + single "next step" card (full checklist behind a "Show all" toggle) |
|
||||
| Email verification | Soft, 7-day grace, hard wall day 7; skipped entirely for OAuth signups (provider-attested). **Reuses existing `email_verification_tokens` table + `/auth/email/send-verification` + `/auth/email/verify`.** Backend enforcement via new `require_verified_email_after_grace` dep with path allowlist (auth, profile, billing) returns 403 when grace expires unverified. Frontend `<EmailVerificationWall />` is a UX layer over the same rule. |
|
||||
| Pricing page | B — pricing + light marketing context (comparison table + testimonial slot + trust strip) |
|
||||
| Trial-end conversion flow | A — quiet days 1-9, gentle nudges 10-13, hard wall day 14 with plan picker |
|
||||
| Trial expiry enforcement | **Replace `deps.py:109` auto-downgrade.** Expiry is computed at request time (`status='trialing' AND current_period_end < now()`); no mutation to `plan='free'`. New backend `require_active_subscription` dep with path allowlist returns 402 when locked. |
|
||||
| `is_paid` semantics | `subscriptions.is_paid` excludes `complimentary` so comp accounts don't inflate paid/MRR metrics. New `has_pro_entitlement` property covers "this account can access Pro features" (true for paid Pro + complimentary Pro + active trial). |
|
||||
| Billing state surface | **Separate `GET /billing/state` endpoint** feeding a new frontend `useBillingStore`. `/auth/me` stays user-focused. |
|
||||
| Teammate invite-accept | Set-password OR Google/Microsoft OAuth; email-locked **(enforced at `/auth/register` against `account_invites.email`)**; no welcome wizard for invitees. |
|
||||
| Existing pilot users | All transitioned to `subscriptions.status='complimentary'` on Pro — no nags, no walls, voluntary conversion path. |
|
||||
| Existing invite codes | Registration gate removed. Table preserved for historical pilots; `User.invite_code_id` retained for existing rows; not consumed at new signups. **No repurposing.** |
|
||||
| Promo codes | **Deferred from v1.** Add a new `promo_codes` table later if a launch campaign needs them. |
|
||||
|
||||
---
|
||||
|
||||
## Section 1 — System overview
|
||||
|
||||
### What this delivers
|
||||
|
||||
Public registration through `/pricing` → `/register` → `/welcome` → dashboard, with the billing substrate built almost entirely on existing infrastructure. New code is concentrated in (a) the OAuth surface, (b) Stripe-aware billing service + webhook handler, (c) the welcome wizard + dashboard redesign, and (d) the public-facing pricing page.
|
||||
|
||||
### Four chunks of work
|
||||
|
||||
1. **Front-of-funnel** — public `/pricing` page (B-style: comparison table + testimonial slot + trust strip), sales-lead capture form, reworked `/register` form with OAuth options.
|
||||
2. **Onboarding surfaces** — 3-step welcome wizard (V2: shop → PSA selection → invite team) firing immediately after register; redesigned dashboard with topbar trial pill + single "next-step" card (C-style); 6-item checklist (Verify email → Setup shop → Run first session → Connect PSA → Invite teammate → Pick a plan).
|
||||
3. **Billing integration over existing schema** — extend `plan_limits` with a sibling `plan_billing` table (Stripe IDs + public catalog metadata); seed Starter / Pro / Enterprise rows in `plan_limits`; seed `feature_flags` + `plan_feature_defaults` for the Pro/Starter gating split; add `subscriptions.status='complimentary'` value; replace `deps.py:109` trial-expiry mutation with computed checks; add a `BillingService`, Stripe webhook handler, and `require_active_subscription` dep. Reuses existing `/admin/plan-limits` and `/admin/feature-flags` admin surfaces.
|
||||
4. **Auth additions** — Google + Microsoft OAuth via a new `oauth_identities` table (`users.password_hash` becomes nullable). Extend existing `email_verification_tokens` flow with auto-send on register and a 7-day soft-grace dashboard wall. Extend existing `account_invites` to enforce email match at registration and to actually send the invitation email at create-time (today only resend sends).
|
||||
|
||||
### What stays the same
|
||||
|
||||
- Existing JWT auth + JTI refresh rotation
|
||||
- `Account` / `Team` / `User` model and the `is_super_admin` / `account_role` / `is_team_admin` permission hierarchy (with `account_role` enum `'owner' | 'admin' | 'engineer' | 'viewer'`)
|
||||
- Phase 4 RLS (subscription state lives on `subscriptions`, account-scoped — RLS rules already configured for it)
|
||||
- All product surfaces (FlowPilot, PSA integrations, sessions, flows)
|
||||
- `/admin/plan-limits` + `/admin/feature-flags` admin endpoints (extended, not replaced)
|
||||
- `/accounts/me/transfer-ownership` (existing — covers owner transfer, no longer flagged "out of scope")
|
||||
- `/accounts/me/invites` and `/me/invites/{id}/resend` (extended with email send + email-match enforcement)
|
||||
|
||||
### What's deprecated
|
||||
|
||||
- Invite-code-as-registration-gate. The `invite_codes` table is preserved (historical foreign keys from `users.invite_code_id`); the gate is removed at `/auth/register`.
|
||||
- `beta_signup.py` waitlist endpoint becomes a 307 redirect to `/register`.
|
||||
- The current SOLO/TEAM split in `OnboardingChecklist` (one unified list).
|
||||
- The "Check out the Script Builder" item mapped to the stale `tried_ai_assistant` key.
|
||||
- Custom card-collection forms (Stripe Checkout owns this).
|
||||
- The auto-downgrade-on-expired-trial logic in `deps.py:109` (replaced with non-mutating computed checks).
|
||||
|
||||
### Sequencing principle
|
||||
|
||||
The billing extensions (new columns, new dep, replacing the auto-downgrade) and the Stripe webhook handler are the longest pole and the most unfamiliar surface area. Build it first, ship it dark behind `SELF_SERVE_ENABLED=false`, then layer the funnel and onboarding surfaces once it's stable. Detailed phases live in the implementation plan.
|
||||
|
||||
---
|
||||
|
||||
## Section 2 — Data model
|
||||
|
||||
### Schema additions (new, small)
|
||||
|
||||
#### `oauth_identities`
|
||||
|
||||
```
|
||||
id UUID PK
|
||||
user_id UUID FK users
|
||||
provider VARCHAR(20) -- 'google' | 'microsoft'
|
||||
provider_subject VARCHAR(255) -- provider's stable user id
|
||||
provider_email_at_link VARCHAR(255) -- email reported by provider at link time
|
||||
created_at, updated_at TIMESTAMP WITH TIME ZONE
|
||||
UNIQUE (provider, provider_subject)
|
||||
INDEX (user_id)
|
||||
```
|
||||
|
||||
A user can have zero password (OAuth-only), one password, and 0+ OAuth identities. v1 ships with one identity per user (signup creates one row). Account linking is a future feature with no schema change required.
|
||||
|
||||
#### `plan_billing` (sibling to `plan_limits`)
|
||||
|
||||
```
|
||||
plan VARCHAR(50) PK FK plan_limits.plan
|
||||
display_name VARCHAR(255) NOT NULL
|
||||
description TEXT NULL
|
||||
monthly_price_cents INTEGER NULL -- nullable for Enterprise (custom)
|
||||
annual_price_cents INTEGER NULL
|
||||
stripe_product_id VARCHAR(255) NULL
|
||||
stripe_monthly_price_id VARCHAR(255) NULL
|
||||
stripe_annual_price_id VARCHAR(255) NULL
|
||||
is_public BOOLEAN NOT NULL DEFAULT TRUE
|
||||
is_archived BOOLEAN NOT NULL DEFAULT FALSE
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
created_at, updated_at TIMESTAMP WITH TIME ZONE
|
||||
```
|
||||
|
||||
`plan_limits.plan` stays the canonical plan key. `plan_billing` carries the Stripe + public-catalog metadata. Joined into the existing `/admin/plan-limits` admin endpoint via the response schema (single PUT updates both tables in one transaction).
|
||||
|
||||
#### `sales_leads`
|
||||
|
||||
```
|
||||
id UUID PK
|
||||
email VARCHAR(255) INDEXED
|
||||
name VARCHAR(255)
|
||||
company VARCHAR(255)
|
||||
team_size VARCHAR(20) -- range string from form
|
||||
message TEXT
|
||||
source VARCHAR(50) -- 'pricing_page' | 'register_footer' | etc.
|
||||
posthog_distinct_id VARCHAR(255) NULL
|
||||
status VARCHAR(20) DEFAULT 'new' -- 'new' | 'contacted' | 'closed'
|
||||
created_at, updated_at
|
||||
```
|
||||
|
||||
Global table. No RLS.
|
||||
|
||||
#### `stripe_events`
|
||||
|
||||
Webhook idempotency log. Global table.
|
||||
|
||||
```
|
||||
id VARCHAR(255) PK -- Stripe event id
|
||||
event_type VARCHAR(100) INDEXED
|
||||
processed_at TIMESTAMP WITH TIME ZONE
|
||||
payload_excerpt JSONB
|
||||
```
|
||||
|
||||
### Modifications to existing tables
|
||||
|
||||
#### `subscriptions` — extend the status enum
|
||||
|
||||
- New status value: `'complimentary'`. Status enum effectively becomes `'active' | 'trialing' | 'past_due' | 'canceled' | 'incomplete' | 'complimentary'`. The column is `String(50)` so no schema migration is required for the value itself; we update the value-level checks only.
|
||||
- `Subscription.is_active` already returns `True` for `('active', 'trialing')` — extend to include `'complimentary'`.
|
||||
- `Subscription.is_paid` (currently `plan in ('pro', 'team')`) → narrow to `plan in ('pro', 'team') AND status NOT IN ('complimentary',)`. Used for revenue / paid-customer / MRR calculations only.
|
||||
- New `Subscription.has_pro_entitlement` property: returns True for `(plan='pro' AND status IN ('active', 'complimentary'))` OR `(status='trialing' AND current_period_end > now())`. Used for "can this account access Pro features."
|
||||
|
||||
These are model-level Python property changes plus tests; the underlying column type doesn't change.
|
||||
|
||||
#### `users` — additions
|
||||
|
||||
- `email_verified_at` already exists. No add. Email-verification flow uses it.
|
||||
- `password_hash` — **change `nullable=False` → `nullable=True`** to support OAuth-only users. Migration sets nullable; no data backfill needed (existing rows all have hashes).
|
||||
- `role_at_signup VARCHAR(50) NULL` — `'owner' | 'lead_tech' | 'tech' | 'other'` (welcome-wizard Step 1 captures this).
|
||||
|
||||
The existing `users.onboarding_dismissed` field stays. **Add a new `users.onboarding_step_completed INTEGER NULL`** that tracks the highest wizard step the user has either completed or explicitly skipped (1, 2, or 3; NULL = haven't started). This is the only new column needed beyond `role_at_signup` and resolves the per-step skip ambiguity that derived data couldn't represent.
|
||||
|
||||
Wizard state model:
|
||||
|
||||
- User clicks **Continue** on a step → `onboarding_step_completed = step_number`. Step's data fields are written (e.g., Step 1 writes `users.role_at_signup` + `accounts.team_size_bucket`).
|
||||
- User clicks **Skip** on a step → `onboarding_step_completed = step_number`. Step's data fields stay NULL.
|
||||
- User clicks **Skip the rest** on any step → `users.onboarding_dismissed = TRUE` (whatever step they were on stays as `onboarding_step_completed = step_number - 1`).
|
||||
- Wizard is "done" when `onboarding_dismissed = TRUE` OR `onboarding_step_completed >= 3`.
|
||||
- `/welcome` redirect logic: if done, go to `/`; otherwise go to `/welcome/step-{onboarding_step_completed + 1 or 1}`.
|
||||
|
||||
This makes "I intentionally skipped inviting teammates" representable separately from "I haven't reached Step 3 yet."
|
||||
|
||||
#### `accounts` — additions for wizard data
|
||||
|
||||
`accounts.name` (existing, `String(255) NOT NULL`) is reused for the wizard's "Company name" field — the wizard updates this row rather than a new column. Today `accounts.name` is populated at register-time from the user's input or a sensible default; the wizard lets the user correct it.
|
||||
|
||||
New columns:
|
||||
|
||||
- `team_size_bucket VARCHAR(20) NULL` — `'1-2' | '3-5' | '6-10' | '11-25' | '26+'`
|
||||
- `primary_psa VARCHAR(20) NULL` — `'connectwise' | 'autotask' | 'halopsa' | 'none'`
|
||||
|
||||
No billing state on `accounts` — it lives on `subscriptions`.
|
||||
|
||||
#### `account_invites` — small additions
|
||||
|
||||
- `revoked_at TIMESTAMP WITH TIME ZONE NULL` — distinguishes revoked from used. Current model has only `used_at`; revoke (resend handler at `accounts.py:323`) currently deletes the row. Add `revoked_at` + change resend to soft-revoke for audit trail.
|
||||
- (Optional) `email_sent_at TIMESTAMP WITH TIME ZONE NULL` — track that the invite email was actually sent (today, only resend sends; create does not).
|
||||
|
||||
`AccountInvite.is_used` and `is_valid` properties extend to consider `revoked_at`.
|
||||
|
||||
### Migrations
|
||||
|
||||
Single Alembic chain — manual revisions per Lesson 77. Multi-head heads on `main` (`070`, `c0f3a4b7e91d`, `024`) currently coexist; the new chain branches from the most recent and merges via `alembic upgrade heads` (plural).
|
||||
|
||||
1. `add_oauth_identities.py` — new table.
|
||||
2. `users_password_hash_nullable.py` — alter to nullable.
|
||||
3. `users_add_role_at_signup_and_onboarding_step.py` — add `role_at_signup` and `onboarding_step_completed` columns.
|
||||
4. `accounts_add_wizard_columns.py` — add `team_size_bucket`, `primary_psa`. (`accounts.name` already exists; wizard writes to it.)
|
||||
5. `account_invites_add_revoked_at_and_email_sent_at.py` — add columns.
|
||||
6. `add_plan_billing.py` — new sibling table. Seeds Starter / Pro / Enterprise rows **with `stripe_product_id` / `stripe_*_price_id` left NULL**. Existing `plan_limits` rows already exist for `'free' / 'pro' / 'team'`; this migration aligns keys (`'starter' | 'pro' | 'enterprise'` if we rename, OR keep `'free' / 'pro' / 'team'` and treat `'free'` as the floor — open risk #14 captures the decision). Stripe IDs are populated **out-of-band** per environment via either the existing `/admin/plan-limits` PUT (extended to accept Stripe fields) or a one-off `python -m scripts.sync_stripe_plan_ids` admin command driven by env vars. **Migrations stay environment-agnostic** — they don't read live mode vs. test mode IDs.
|
||||
7. `seed_pro_starter_feature_flags.py` — register feature keys (`psa_integration`, `escalation_mode`, `script_builder`, `analytics_dashboards`, `knowledge_flywheel`, `team_admin_full`, `monthly_sessions` quantitative, `seats` quantitative, `sso`, `audit_log`) in `feature_flags`; populate `plan_feature_defaults` per the Pro/Starter split.
|
||||
8. `subscriptions_pilot_complimentary_backfill.py` — `UPDATE subscriptions SET status='complimentary', plan='pro' WHERE status NOT IN ('canceled')` for accounts that exist as of cutover. Single statement; ≤ 100 rows expected.
|
||||
9. `add_sales_leads_and_stripe_events.py` — two new tables.
|
||||
|
||||
Forward-only. No down-migrations for the data backfills (step 8) — the original status values per account are not preserved.
|
||||
|
||||
### RLS notes
|
||||
|
||||
- `oauth_identities` is account-adjacent (joined via `user_id`), but RLS on `users` is admin-DB-only (per `deps.py` `get_current_user` uses `get_admin_db`). Treat `oauth_identities` the same — no per-tenant RLS policy; queries use admin session. Verify against current `users` table policy before merging.
|
||||
- `plan_billing` is global (joins `plan_limits.plan`, also global). No RLS.
|
||||
- `sales_leads`, `stripe_events` are global. No RLS.
|
||||
- `account_invites` already has its policy (account-scoped). No change.
|
||||
- `subscriptions` already has its policy. No change to schema means no RLS revision.
|
||||
|
||||
### Index notes
|
||||
|
||||
- `oauth_identities (provider, provider_subject)` UNIQUE — the OAuth callback's primary lookup.
|
||||
- `oauth_identities (user_id)` — list a user's identities.
|
||||
- `account_invites (revoked_at)` — partial filter for active-invites queries (`WHERE accepted_by_id IS NULL AND revoked_at IS NULL`).
|
||||
|
||||
---
|
||||
|
||||
## Section 3 — Funnel walkthrough
|
||||
|
||||
### 1. Acquisition — `/pricing` (public)
|
||||
|
||||
New route. B-style page: hero (one-liner + reverse-trial reassurance), three plan cards (Starter / Pro recommended / Enterprise), comparison table, testimonial slot (placeholder copy until a real one lands), trust strip ("SOC2 in progress · Stripe billing · GDPR DPA available"). Plan card data sourced from `plan_billing` filtered by `is_public=TRUE AND is_archived=FALSE`.
|
||||
|
||||
- **Pro/Starter cards** → "Start free trial" → `/register?plan=pro` (or `?plan=starter`). Query param remembered through OAuth round-trip.
|
||||
- **Enterprise card** → "Talk to sales" → `/contact-sales` → POST `/sales-leads` → confirmation page with Calendly link in the email.
|
||||
- Existing `LandingPage.tsx` gets a "See pricing" CTA pointing here.
|
||||
|
||||
### 2. Registration — `/register` (public, redesigned)
|
||||
|
||||
Three sign-up paths on one page:
|
||||
|
||||
- **Google sign-in** (primary button at top) → OAuth round-trip → `/auth/google/callback`. Backend creates a User if first time (`oauth_identities` row + Account + Subscription on Pro trial via `BillingService.start_trial`), marks `email_verified_at = now()` (provider-attested), redirects to `/welcome`.
|
||||
- **Microsoft sign-in** (button) → same flow with `provider='microsoft'`.
|
||||
- **Email + password** → POST `/auth/register`. Backend creates User (with `password_hash` set) + Account, calls `BillingService.start_trial`, sends verification email via existing `EmailService.send_email_verification_email` (auto-send is added; today the user has to click a button), returns JWT, frontend redirects to `/welcome`.
|
||||
|
||||
Form fields: full name, work email, password (10+ chars, complexity rules per existing `UserCreate.password_complexity` validator). The current `invite_code` field on `UserCreate` is **removed at the registration gate** — public signups don't need one. The `account_invite_code` field is **kept** for the teammate-accept flow (see step 5b below).
|
||||
|
||||
**Critical fix flagged in review:** registration with `account_invite_code` must enforce `user_data.email == account_invites.email` (today this is not enforced at `/auth/register`). The check happens in the register handler before the User is created; mismatch returns 400 with `{"error": "invite_email_mismatch"}`.
|
||||
|
||||
### 3. Welcome wizard — `/welcome` (authed)
|
||||
|
||||
Dedicated routes: `/welcome/step-1` (Your shop), `/welcome/step-2` (Your PSA), `/welcome/step-3` (Invite team). `/welcome` itself redirects to the lowest-numbered incomplete step. Each step persists immediately (PATCH endpoints — see Appendix A) so refreshes don't lose data and "Skip the rest" lands cleanly.
|
||||
|
||||
- **Step 1 — Your shop**: company name (pre-filled from existing `accounts.name`, editable), team size bucket, your role. Saves to `accounts.name`, `accounts.team_size_bucket`, `users.role_at_signup`.
|
||||
- **Step 2 — Your PSA**: PSA selection only. Saves to `accounts.primary_psa`. Quiet "Connect now" link → `/account/integrations` (out of wizard); default action is **Continue**. No API key entry inside the wizard.
|
||||
- **Step 3 — Invite your team**: up to 3 email fields visible with "+ Add another" link; each invite defaults to "Tech" role; fully skippable. POSTs to a new `POST /accounts/me/invites/bulk` (thin wrapper around the existing single-create) **and sends invite emails per row**. The wizard's "Tech" UI label maps to `account_invites.role = 'engineer'` in the DB; "Viewer" UI label maps to `'viewer'` (per the existing CHECK constraint).
|
||||
|
||||
**Critical fix flagged in review:** today, `POST /accounts/me/invites` (`accounts.py:257`) creates the row but does NOT send the email — only `/me/invites/{id}/resend` sends. The new flow wires `EmailService.send_account_invite_email` (existing method at `core/email.py:125`) into both create and bulk paths and stamps `email_sent_at` on success.
|
||||
|
||||
Skip behavior: "Skip" on a step advances `users.onboarding_step_completed` (recording that the user saw and chose to skip that step). A separate "Skip the rest, take me to dashboard" link sets `users.onboarding_dismissed=TRUE` and redirects to `/`. Wizard is "done" when `onboarding_dismissed=TRUE` OR `onboarding_step_completed >= 3`. Auth-store reads this state on app load; `/welcome` redirects to the next incomplete step or to `/` if done.
|
||||
|
||||
**Invited teammate variant:** invitee's email link goes to a frontend `/accept-invite?code=…` route that posts to `/auth/register` with `account_invite_code` (per the existing `UserCreate` schema). They land on `/?welcome=teammate` instead of the wizard, and get a brief "Welcome to {company}'s ResolutionFlow" toast. Re-running the wizard on already-onboarded users is suppressed via `users.onboarding_dismissed` OR derived data presence.
|
||||
|
||||
### 4. Dashboard — `/` (authed, redesigned)
|
||||
|
||||
- **Topbar pill** in `AppLayout` renders based on `subscriptions.status` and `current_period_end`:
|
||||
- `trialing` AND `current_period_end > now()`: "Pro trial · Nd" — blue, amber when ≤3d remaining, red when ≤1d.
|
||||
- `trialing` AND `current_period_end <= now()`: "Trial expired — pick a plan" (the locked state — no mutation has occurred at the DB level, just rendered differently).
|
||||
- `active`: tier name only ("Pro" / "Starter") — no urgency.
|
||||
- `complimentary`: "Complimentary Pro" — friendly tag, no CTA.
|
||||
- `past_due`: "Payment failed — update card" — clickable, routes to `/account/billing`.
|
||||
- `canceled`: pill becomes a "Reactivate" CTA.
|
||||
- **Next-step card** sits below the topbar. "Show all setup steps" link expands the full 6-item list inline.
|
||||
- **Email-verification banner** (when `users.email_verified_at IS NULL`): always-visible thin bar above the next-step card with a "Resend" link (POSTs to existing `/auth/email/send-verification`). On day 7 unverified, the dashboard route renders `<EmailVerificationWall />` instead of normal content.
|
||||
|
||||
Checklist items (same for everyone — no SOLO/TEAM split):
|
||||
|
||||
1. **Verify your email** — auto-completes on link click; hidden if signed up via OAuth.
|
||||
2. **Set up your shop** — completes when `users.onboarding_step_completed >= 1`.
|
||||
3. **Run your first FlowPilot session** — the wedge. Highlighted as the headline action when prior items are complete.
|
||||
4. **Connect your PSA** — auto-completes when first PSA connection saved. Pre-fills the provider based on welcome wizard selection.
|
||||
5. **Invite a teammate** — auto-completes when first invitation is sent.
|
||||
6. **Pick a plan** — appears earlier with low emphasis; turns urgent at ≤3 days remaining in trial.
|
||||
|
||||
The stale `tried_ai_assistant` / "Check out the Script Builder" item is dropped entirely.
|
||||
|
||||
### 5. Email verification — existing endpoints, new gating
|
||||
|
||||
- `POST /auth/email/send-verification` (existing, `auth.py:621`) is auto-called by `/auth/register` — today the user has to click a button.
|
||||
- `POST /auth/email/verify` (existing, `auth.py:662`) consumes the token and sets `users.email_verified_at`.
|
||||
- The frontend `/verify-email?token=…` route calls the existing endpoint and shows a success or error state.
|
||||
- New: a frontend gating layer (`<EmailVerificationGate />`) wraps the dashboard route. Day 1-6 unverified shows the soft banner; day 7+ unverified renders `<EmailVerificationWall />`.
|
||||
- **Backend enforcement** via the new `require_verified_email_after_grace` dep (Section 4). The frontend wall is UX; the backend dep prevents direct API access by an unverified user past the 7-day grace. Mounted on every protected router; allowlists `/auth/*` (logout, verify, send-verification, password change), `/users/me`, and `/billing/*` so the user can still log out, verify, manage their profile, and convert to paid.
|
||||
|
||||
No new endpoints, no new column. One new backend dep.
|
||||
|
||||
### 6. Trial-end — Days 10-14
|
||||
|
||||
- **Day 10**: in-app modal once ("Your trial ends in 4 days. Pick a plan to keep going."). Fired by `useTrialBanner` hook reading from `useBillingStore` (which polls `GET /billing/state`); per-user dismiss recorded in localStorage. Email day 10 + day 13 (`EmailService.send_trial_ending`).
|
||||
- **Day 14**: when `subscriptions.status='trialing'` AND `current_period_end < now()`, the dashboard route renders `<TrialEndedWall />` with the plan picker (Starter / Pro radio + seat count input). **No DB mutation occurs** — the lockout is computed at request time. Past sessions remain visible read-only for 30 days after `current_period_end` — computed at render time as `current_period_end + INTERVAL '30 days' < now()`. After that window, sessions are still in the database (no destructive action) but the dashboard hides them behind the wall until billing is added.
|
||||
|
||||
### 7. Plan picker → Stripe Checkout — `/account/billing/select-plan` (authed)
|
||||
|
||||
User picks Starter/Pro + seat count → POST `/billing/checkout-session` → backend calls `stripe.checkout.sessions.create` with:
|
||||
|
||||
- `customer_email` from User
|
||||
- `line_items` (price_id from `plan_billing` × quantity = seats)
|
||||
- `mode='subscription'`
|
||||
- `subscription_data.trial_end = current_period_end` if still in trial (Stripe takes over the trial countdown)
|
||||
- `success_url=/account/billing?success=1`, `cancel_url=/account/billing/select-plan`
|
||||
|
||||
Frontend redirects to Stripe-hosted Checkout. Stripe `checkout.session.completed` webhook → backend updates `subscriptions.status='active'`, sets `stripe_subscription_id`, `stripe_price_id`, refreshes `current_period_start/end` from the Stripe subscription, sets `seat_limit`. Idempotency via `stripe_events.id`.
|
||||
|
||||
Success URL renders dashboard with "Pro active 🎉" toast.
|
||||
|
||||
### 8. Past-due / dunning
|
||||
|
||||
Stripe `invoice.payment_failed` webhook → `subscriptions.status='past_due'`. Topbar pill flips to "Payment failed — update card" linking to `/account/billing`, which uses Stripe's Customer Portal for card updates and cancellation. Dashboard remains accessible during the dunning window (Stripe default: 4 retries over 3 weeks). Account locks via `require_active_subscription` only at `canceled`.
|
||||
|
||||
### 9. Sales lead — `/contact-sales` (public)
|
||||
|
||||
Form posts to `/sales-leads` → creates row + sends email to `sales@resolutionflow.com` + emits PostHog event. Confirmation page: "Thanks — we'll reach out within 1 business day. Want to skip ahead? [Calendly link]." The Calendly link is a config string, not a calendar integration in v1.
|
||||
|
||||
### 10. Owner transfer (existing — noted)
|
||||
|
||||
Owner transfer is supported via the existing `POST /accounts/me/transfer-ownership` (`accounts.py:150`). The pricing-page Enterprise tier and the Account → Team page in the redesigned dashboard surface this for owners who need to hand off the account. **Not flagged as out-of-scope risk** as it was in the prior draft.
|
||||
|
||||
---
|
||||
|
||||
## Section 4 — Billing substrate + Stripe integration
|
||||
|
||||
### `app.services.billing.BillingService`
|
||||
|
||||
Single billing module — not a polymorphic provider abstraction.
|
||||
|
||||
```python
|
||||
class BillingService:
|
||||
@staticmethod
|
||||
async def start_trial(db, account: Account) -> Subscription:
|
||||
"""Creates or updates the Subscription row for a new account.
|
||||
Sets plan='pro', status='trialing', current_period_end=now()+14d.
|
||||
Called from /auth/register (email path) and OAuth-callback flows.
|
||||
No Stripe API call yet — Stripe Customer is created lazily at first
|
||||
checkout."""
|
||||
|
||||
@staticmethod
|
||||
async def create_checkout_session(db, account, plan, seats, billing_interval) -> str:
|
||||
"""Returns the Stripe Checkout URL. Creates Stripe Customer if missing
|
||||
(stores stripe_customer_id on the **Account** row — existing column at
|
||||
accounts.stripe_customer_id), then builds checkout.sessions.create
|
||||
with line_items, mode='subscription', subscription_data.trial_end if
|
||||
still within local trial, success/cancel URLs. Subscription row is
|
||||
updated by the webhook handler with stripe_subscription_id and
|
||||
stripe_price_id once checkout completes."""
|
||||
|
||||
@staticmethod
|
||||
async def apply_subscription_event(db, event_type: str, payload: dict) -> None:
|
||||
"""Single entry point for every Stripe webhook that mutates subscription
|
||||
state. Pure function of (event_type, payload) -> DB writes. Called from
|
||||
the webhook handler after signature verification + idempotency check."""
|
||||
|
||||
@staticmethod
|
||||
async def open_customer_portal(account) -> str:
|
||||
"""Returns Stripe-hosted Customer Portal URL for card updates and
|
||||
cancellation."""
|
||||
|
||||
@staticmethod
|
||||
async def get_billing_state(db, account: Account) -> BillingStateResponse:
|
||||
"""Returns the full billing snapshot for /billing/state — subscription
|
||||
status, plan, plan_billing metadata, plan_limits values, and the
|
||||
flattened effective feature flags (defaults overridden by
|
||||
account_feature_overrides)."""
|
||||
```
|
||||
|
||||
`account_id` is the canonical local key; Stripe is the canonical remote state; the webhook handler is the bridge.
|
||||
|
||||
### Replacing the trial auto-downgrade
|
||||
|
||||
The existing logic in `deps.py:81-129` mutates `subscriptions` on every request when a trial expires:
|
||||
|
||||
```python
|
||||
# CURRENT (to be removed):
|
||||
if subscription.status == "trialing" and subscription.current_period_end < now():
|
||||
subscription.plan = "free"
|
||||
subscription.status = "active"
|
||||
subscription.current_period_end = None
|
||||
subscription.current_period_start = None
|
||||
await db.commit()
|
||||
```
|
||||
|
||||
**Replace this entire block with no-op.** Trial expiry becomes a *computed* state. The data stays as `status='trialing'`, `current_period_end` in the past — readable, observable, idempotent. The new `require_active_subscription` dep enforces the lockout.
|
||||
|
||||
If we ever want an explicit `'expired'` status (for analytics observability), it can be added later without changing the semantic of "trialing + past current_period_end = locked."
|
||||
|
||||
### New backend dep — `require_active_subscription`
|
||||
|
||||
```python
|
||||
_SUBSCRIPTION_GUARD_ALLOWLIST = {
|
||||
# auth & profile
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/auth/logout",
|
||||
"/api/v1/auth/password/change",
|
||||
"/api/v1/auth/email/send-verification",
|
||||
"/api/v1/auth/email/verify",
|
||||
# billing surfaces
|
||||
"/api/v1/billing/state",
|
||||
"/api/v1/billing/checkout-session",
|
||||
"/api/v1/billing/portal-session",
|
||||
# users own profile
|
||||
"/api/v1/users/me",
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
# read-only history (pattern match: /sessions and /trees in GET only)
|
||||
}
|
||||
|
||||
async def require_active_subscription(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_admin_db),
|
||||
) -> Subscription:
|
||||
"""Enforces 'this account currently has access.' Mounted on routers that
|
||||
require Pro entitlement. Returns the Subscription row when allowed; raises
|
||||
402 with structured payload when locked."""
|
||||
|
||||
if request.url.path in _SUBSCRIPTION_GUARD_ALLOWLIST:
|
||||
return None # bypass
|
||||
|
||||
sub = await _get_subscription_for_account(db, current_user.account_id)
|
||||
if not sub:
|
||||
raise HTTPException(402, detail={"error": "no_subscription"})
|
||||
|
||||
is_live = (
|
||||
sub.status in ("active", "complimentary")
|
||||
or (
|
||||
sub.status == "trialing"
|
||||
and sub.current_period_end is not None
|
||||
and sub.current_period_end > datetime.now(timezone.utc)
|
||||
)
|
||||
or sub.status == "past_due" # dunning grace — Stripe retries
|
||||
)
|
||||
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
|
||||
```
|
||||
|
||||
Mounted on every router under `/api/v1/` *except* the explicit allowlist. GET endpoints for past sessions/trees during the 30-day read-only post-expiry window need a softer variant — see Section 3 step 6 for the read-only contract. Implementation plan will identify each protected endpoint specifically.
|
||||
|
||||
### New backend dep — `require_verified_email_after_grace`
|
||||
|
||||
Mirror of `require_active_subscription`, but for email verification. The frontend `<EmailVerificationWall />` is a UX layer; this dep is the security layer that prevents an unverified user from bypassing the wall by hitting product APIs directly.
|
||||
|
||||
```python
|
||||
_EMAIL_VERIFICATION_ALLOWLIST = {
|
||||
# auth & session
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/auth/logout",
|
||||
"/api/v1/auth/email/send-verification",
|
||||
"/api/v1/auth/email/verify",
|
||||
"/api/v1/auth/password/change",
|
||||
# users own profile
|
||||
"/api/v1/users/me",
|
||||
# billing — let user manage subscription even if email unverified
|
||||
"/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: User = Depends(get_current_active_user),
|
||||
) -> None:
|
||||
"""Enforces 'this user has verified their email, OR is still inside the
|
||||
7-day grace from account creation.' OAuth signups bypass cleanly because
|
||||
/auth/google/callback and /auth/microsoft/callback set
|
||||
users.email_verified_at = now() (provider-attested).
|
||||
Mounted on every protected router *except* the explicit allowlist."""
|
||||
|
||||
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 # still inside grace
|
||||
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "email_not_verified",
|
||||
"grace_ended_at": grace_ends.isoformat(),
|
||||
"resend_url": "/api/v1/auth/email/send-verification",
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Differs from `require_active_subscription` in three ways:
|
||||
|
||||
- **403 (Forbidden) rather than 402 (Payment Required)** — verification is identity, not billing. Lets the frontend interceptor route to a verification CTA, distinct from the upgrade CTA.
|
||||
- **No DB read** — uses fields already on the `current_user` row from `get_current_active_user`. Cheap.
|
||||
- **Allowlist includes `/billing/*`** — an unverified user past day 7 should still be able to convert to paid (verification gates feature use, not billing). The verification banner persists into Checkout if needed.
|
||||
|
||||
The two guards compose: most routers depend on **both** `require_active_subscription` AND `require_verified_email_after_grace`. The implementation plan will identify each protected router specifically; both guards are non-optional for product surfaces.
|
||||
|
||||
### Stripe webhook handler — `POST /api/v1/webhooks/stripe`
|
||||
|
||||
A stub already exists at `app/api/endpoints/webhooks.py` with signature verification + an early-out when `settings.stripe_enabled=False`. This work extends the stub — does not replace it — by wiring concrete event handlers, idempotency tracking, and `BillingService.apply_subscription_event` integration.
|
||||
|
||||
- Public endpoint; signature verification is the only gate.
|
||||
- Reads `Stripe-Signature` header → `stripe.Webhook.construct_event(payload, sig, STRIPE_WEBHOOK_SECRET)` → 400 on mismatch.
|
||||
- **Idempotency**: every event recorded in `stripe_events` keyed by Stripe's event id. If the row exists, return 200 immediately.
|
||||
- Uses `_admin_session_factory()` — no `current_account_id` is set during webhook processing (Phase 4 RLS pattern).
|
||||
- **Replay protection**: Stripe signatures embed a timestamp; reject if older than 5 min.
|
||||
|
||||
Events handled:
|
||||
|
||||
| Event | Action |
|
||||
|---|---|
|
||||
| `checkout.session.completed` | Activate: `subscriptions.status='active'`, set `subscriptions.stripe_subscription_id`, `subscriptions.stripe_price_id`, `subscriptions.current_period_start/end`, `subscriptions.seat_limit` from session line_items. (`accounts.stripe_customer_id` was set earlier at `create_checkout_session` time.) |
|
||||
| `customer.subscription.updated` | Reflect plan changes / period transitions / seat updates |
|
||||
| `customer.subscription.deleted` | `status='canceled'`, lock via `require_active_subscription` |
|
||||
| `invoice.payment_failed` | `status='past_due'` |
|
||||
| `invoice.payment_succeeded` | Confirm `status='active'` after dunning recovery |
|
||||
| Other | Log and ack 200 |
|
||||
|
||||
### Backend feature-gate dep — `require_feature`
|
||||
|
||||
Reads from the existing 3-table chain (no new tables). **`require_feature` internally composes with `require_active_subscription`** — feature gating without subscription gating would let canceled/expired-trial accounts pass feature checks. They are not independent.
|
||||
|
||||
```python
|
||||
async def require_feature(flag_key: str):
|
||||
async def _dep(
|
||||
sub: Subscription = Depends(require_active_subscription),
|
||||
user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_admin_db),
|
||||
) -> None:
|
||||
# require_active_subscription has already verified the account is live;
|
||||
# sub is the live Subscription row. Now check the feature flag.
|
||||
flag = await _resolve_flag(db, user.account_id, sub.plan, flag_key)
|
||||
if not flag.enabled:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": "feature_not_in_plan",
|
||||
"feature": flag_key,
|
||||
"current_plan": sub.plan,
|
||||
"upgrade_url": "/account/billing/select-plan",
|
||||
},
|
||||
)
|
||||
return _dep
|
||||
|
||||
|
||||
async def _resolve_flag(db, account_id, plan_key, flag_key):
|
||||
"""Resolve effective feature flag value:
|
||||
1. account_feature_overrides for (account_id, flag_key) -> if exists, use that
|
||||
2. else plan_feature_defaults for (plan, flag_key) -> use that
|
||||
3. else default disabled
|
||||
"""
|
||||
```
|
||||
|
||||
Used as `Depends(require_feature("psa_integration"))` on PSA endpoints, Escalation Mode, Script Builder, Analytics endpoints. The 402-with-payload pattern lets the frontend route the user to `/account/billing/select-plan`.
|
||||
|
||||
For quantitative limits (sessions per month, AI builds): existing `plan_limits` columns (`max_sessions_per_month`, `max_ai_builds_per_month`, etc.) already cover these. Use a sibling helper:
|
||||
|
||||
```python
|
||||
async def require_within_limit(field: str):
|
||||
"""e.g., field='max_sessions_per_month' — checks current usage against
|
||||
the resolved plan_limits value, with account-override consulting via
|
||||
/admin/plan-limits/account-overrides table."""
|
||||
```
|
||||
|
||||
This is closer to the existing `get_user_plan_limits` helper (`core/subscriptions.py`) and reuses that path.
|
||||
|
||||
### Caching strategy
|
||||
|
||||
- Subscription row, plan_limits row, plan_billing row, and resolved feature flag map: cached in `app.state.billing_cache` keyed by `account_id`. TTL 5 minutes.
|
||||
- Explicit invalidation triggers:
|
||||
- Stripe webhook handler when `subscriptions` state changes (account-keyed invalidation).
|
||||
- `/admin/plan-limits` PUT (invalidate **all** accounts on that plan, since plan-wide limits / billing fields changed).
|
||||
- `/admin/plan-limits/account-overrides` POST/PUT/DELETE (account-keyed).
|
||||
- `/admin/feature-flags` PUT/DELETE on flag definitions (full-cache flush).
|
||||
- `/admin/feature-flags/plan-defaults` PUT (invalidate **all** accounts on that plan).
|
||||
- `/admin/feature-flags/account-overrides` POST/DELETE (account-keyed).
|
||||
- For Railway multi-worker: per-process cache. The 5-minute TTL bounds inconsistency. Acceptable for v1; revisit with Redis pubsub if we run > 2 workers.
|
||||
|
||||
### Frontend — `useBillingStore` + `GET /billing/state`
|
||||
|
||||
```
|
||||
GET /billing/state -> {
|
||||
subscription: {
|
||||
status: 'trialing' | 'active' | 'past_due' | 'canceled' | 'incomplete' | 'complimentary',
|
||||
plan: 'starter' | 'pro' | 'enterprise',
|
||||
current_period_start: ISODateTime | null,
|
||||
current_period_end: ISODateTime | null,
|
||||
cancel_at_period_end: boolean,
|
||||
seat_limit: number | null,
|
||||
has_pro_entitlement: boolean,
|
||||
is_paid: boolean,
|
||||
},
|
||||
plan_billing: {
|
||||
display_name: string,
|
||||
monthly_price_cents: number | null,
|
||||
annual_price_cents: number | null,
|
||||
},
|
||||
plan_limits: {
|
||||
max_trees, max_sessions_per_month, max_users, ...all current PlanLimits fields
|
||||
},
|
||||
enabled_features: Record<string, boolean>, -- flat resolved map
|
||||
}
|
||||
```
|
||||
|
||||
Frontend hooks:
|
||||
|
||||
- `useFeature(key: string): boolean` — reads `enabled_features[key]` from `useBillingStore`.
|
||||
- `useFeatureLimit(key): { used, limit, percentage, isAtLimit }` — combines `plan_limits[key]` with a lazy `/usage/{key}` count.
|
||||
- `useTrialBanner(): { stage: 'pristine' | 'warning' | 'urgent' | 'expired', daysRemaining }` — derived from `subscription.status` + `current_period_end`.
|
||||
- `<FeatureGate feature="psa_integration" fallback={<UpgradePrompt />}>...children</FeatureGate>` — wrapper for whole-section gating.
|
||||
|
||||
`useBillingStore` is a Zustand store with:
|
||||
- Initial fetch on auth-store login.
|
||||
- Refetch on webhook-driven server-sent events (or, for v1, polling every 60s while the dashboard is mounted).
|
||||
- Manual `refetchBilling()` exposed for use after Stripe Checkout success-redirect.
|
||||
|
||||
`/auth/me` and `UserResponse` stay user-focused — no billing data embedded.
|
||||
|
||||
### Admin UI — reuse existing surfaces
|
||||
|
||||
- `/admin/plan-limits` — extended to also surface `plan_billing` fields in the editor (single PUT round-trips both tables in one transaction).
|
||||
- `/admin/feature-flags` — unchanged. Toggling a flag's `plan_feature_defaults` enables/disables the feature for that plan tier.
|
||||
- `/admin/feature-flags/account-overrides` — unchanged. Used for sales-negotiated grants, comp accounts, kill-switching a feature for one customer.
|
||||
|
||||
No new combined `/admin/plans` admin page in v1.
|
||||
|
||||
### Failure modes
|
||||
|
||||
| Scenario | Outcome |
|
||||
|---|---|
|
||||
| User abandons Stripe Checkout | No webhook fires; `subscriptions.status` stays `trialing`; trial-end wall fires normally on day 14 via `require_active_subscription` |
|
||||
| Webhook arrives before app reconciles local state | `stripe_events` idempotency makes this safe |
|
||||
| Webhook secret rotated | Old webhook attempts 400 until env var redeployed |
|
||||
| Concurrent webhooks for the same subscription | DB row-level locks on the `subscriptions` row serialize updates; idempotency check is the first read in the transaction |
|
||||
| Stripe outage during checkout | `BillingService.create_checkout_session` raises; frontend shows "Couldn't start checkout — try again" toast |
|
||||
| Account on `complimentary` accidentally hits a webhook (e.g., admin manually attached a Stripe customer) | Handler transitions to whatever Stripe says; admin can revert via DB or via `/admin/plan-limits/account-overrides` if needed |
|
||||
| OAuth-only user attempts `/auth/login` (password) | Login endpoint rejects with 400 `{"error": "use_oauth_provider", "providers": ["google"]}` so frontend can route them to the right button |
|
||||
| OAuth-only user attempts `/auth/password/change` | Endpoint rejects with 400 — must set initial password via a separate `/auth/password/set-initial` flow (out of scope for v1; OAuth users stay OAuth-only) |
|
||||
| OAuth-only user requests password reset | Reset email is suppressed; user is shown "Sign in with {provider}" instead |
|
||||
|
||||
---
|
||||
|
||||
## Section 5 — Migration plan
|
||||
|
||||
### Pre-deploy: Stripe configuration
|
||||
|
||||
Manual setup, separate per environment.
|
||||
|
||||
**Status note (2026-05-05):** Stripe **test mode** Products + Prices + webhook endpoint + test env vars in Railway are already configured. Live-mode setup remains for cutover.
|
||||
|
||||
For each environment:
|
||||
|
||||
1. **Stripe Dashboard**:
|
||||
- Create Products: `ResolutionFlow Starter`, `ResolutionFlow Pro`, `ResolutionFlow Enterprise` (no public price).
|
||||
- Create Prices for Starter/Pro: monthly + annual recurring.
|
||||
- Enable **Customer Portal** with: update payment method, cancel subscription, view invoices. Disable plan-switching from the portal.
|
||||
- Register webhook endpoint at `https://api.resolutionflow.com/api/v1/webhooks/stripe` with the events listed in Section 4. Save the signing secret.
|
||||
2. **Railway env vars** (per environment):
|
||||
- `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PUBLISHABLE_KEY` (frontend; needs `ARG`+`ENV` in `frontend/Dockerfile` per Lesson 60).
|
||||
|
||||
### Schema migration
|
||||
|
||||
Manual revisions per Lesson 77. New chain branches from the most recent of `main`'s heads (`070`, `c0f3a4b7e91d`, `024`) and merges via `alembic upgrade heads`. Migration filenames are listed in Section 2.
|
||||
|
||||
Forward-only.
|
||||
|
||||
### Pilot user transition
|
||||
|
||||
- Migration step 8 sets `subscriptions.status='complimentary'`, `plan='pro'` for all existing accounts (≤ 100 rows). Single statement.
|
||||
- **Outbound communication**: a single email from `EmailService.send_complimentary_account_announcement` to every pilot user 1-2 days before cutover:
|
||||
> *"We're opening ResolutionFlow up for new signups. Your account is now a Complimentary Pro account — nothing changes for you. You'll see a small "Complimentary Pro" tag in the app instead of any trial pill. Thanks for piloting."*
|
||||
- **In-app first-login toast** (optional; ship without if scope tightens): per-browser via localStorage key `rf-complimentary-announcement-seen-{user_id}`.
|
||||
|
||||
### Existing invite-code disposition
|
||||
|
||||
- `invite_codes` table preserved.
|
||||
- `User.invite_code_id` foreign keys preserved for historical pilots.
|
||||
- Registration handler (`/auth/register`) drops the invite_code-required gate. The `UserCreate.invite_code` field stays in the schema for backward compatibility but is ignored at registration. No new validations against the `invite_codes` table at signup.
|
||||
- No promo-code repurposing. Invite codes simply stop being consumed.
|
||||
|
||||
### Beta-signup deprecation
|
||||
|
||||
- `beta_signup.py` endpoint stays mounted but returns 307 redirect to `/register?from=beta`.
|
||||
- Existing waitlist rows: send a "we've launched — come on in" email with a one-time `from=beta` link. Preserve the table; do not drop.
|
||||
|
||||
### Deploy ordering — dark launch then cutover
|
||||
|
||||
1. **Backend deploy with `SELF_SERVE_ENABLED=false`**: all new endpoints exist (webhook handler, billing, OAuth callbacks, sales-leads, bulk invite, billing/state). `/auth/register` retains the existing invite-code requirement. `/pricing` returns 404. Webhook handler is **live**.
|
||||
2. **Frontend deploy with `VITE_SELF_SERVE_ENABLED=false`**: new surfaces are routed but hidden behind the flag.
|
||||
3. **Stripe live-mode configuration in prod** (manual, ~30 min).
|
||||
4. **Internal validation (1-2 days)**: founder + any teammates use a per-email allowlist to enable self-serve for their accounts only. Tests cover: email signup, OAuth signup paths, invitation accept (with email-match enforcement), pilot complimentary view, past-due simulation via Stripe test cards, subscription guard for locked accounts.
|
||||
5. **Cutover**: flip `SELF_SERVE_ENABLED=true` and `VITE_SELF_SERVE_ENABLED=true` in prod. Pricing page goes live.
|
||||
6. **Week 1 monitoring**: PostHog funnel; webhook logs; error rates.
|
||||
|
||||
### Rollback strategy
|
||||
|
||||
- Schema is forward-only — no down-migration for the backfills.
|
||||
- Rollback = flag flip. `SELF_SERVE_ENABLED=false` reverts public surfaces; pilot users continue on `complimentary` status (benign — the existing schema supports it either way after step 8).
|
||||
- New surfaces (pricing page, etc.) return 404 when the flag is off.
|
||||
- Webhook handler stays live regardless.
|
||||
|
||||
### Risks worth flagging
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| Pilot users confused by "Complimentary Pro" change | Pre-launch email + first-login toast |
|
||||
| `is_paid` regression — paid metrics include comp accounts pre-fix | Audit `Subscription.is_paid` callers as part of step 1 of implementation; fix in same PR |
|
||||
| Webhook misfires producing wrong subscription state | Idempotency table + alerting + Stripe webhook replay |
|
||||
| Multi-head Alembic merge breaks in CI | Test `alembic upgrade heads` (plural) on a fresh DB before merging |
|
||||
| Stripe Test vs. Live mode confusion | Distinct env vars per env; first prod transaction verified manually |
|
||||
| OAuth callback `redirect_uri` drift across envs | Single `OAUTH_REDIRECT_BASE` env var; tested per env in validation |
|
||||
| Email deliverability for verification + invitations + sales leads | Reuse existing `EmailService` pipeline; verify SPF/DKIM/DMARC alignment |
|
||||
| Email-match enforcement at register breaks teammate accept if invitee mistypes their address | Clear error message; resend with corrected email is one click from the failure page |
|
||||
| Subscription guard allowlist drift (a new endpoint added without thinking about lockout) | Add a CI test that exercises every router with a `canceled` subscription and verifies 402 unless explicitly allowlisted |
|
||||
| Email-verification guard allowlist drift (a new endpoint added without thinking about unverified users past grace) | Same CI pattern — exercise every router with an unverified day-8 user and verify 403 unless explicitly allowlisted |
|
||||
| Plan key rename (`free`/`pro`/`team` → `starter`/`pro`/`enterprise`) | Decision deferred to implementation plan; if rename, migration must update every reference in `subscriptions.plan` and `plan_limits.plan` |
|
||||
|
||||
---
|
||||
|
||||
## Section 6 — Testing, rollout, open risks
|
||||
|
||||
### Test strategy
|
||||
|
||||
#### Backend (`pytest`)
|
||||
|
||||
- **Unit tests** for `BillingService` methods. Stripe mocked via `respx`. Each method's happy path + at least one error path.
|
||||
- **Webhook handler integration tests**: feed canned Stripe webhook payloads and assert resulting `subscriptions` state. One test per event type. **Idempotency test**: send the same event id twice, assert single state mutation.
|
||||
- **`require_feature` integration tests**: parametrized over (plan, flag_key) pairs; test override resolution (`account_feature_overrides` beats `plan_feature_defaults`).
|
||||
- **`require_active_subscription` integration tests**:
|
||||
- Each `subscriptions.status` value × allowlisted/non-allowlisted route → expected 200 or 402.
|
||||
- **Replaces and verifies the trial expiry change**: a `trialing` row with `current_period_end < now()` should NOT be mutated by the dep; the dep should return 402 on protected routes and 200 on allowlisted routes.
|
||||
- "complimentary should not block protected routes" smoke test.
|
||||
- **`require_verified_email_after_grace` integration tests**:
|
||||
- Each combination of (verified, unverified-in-grace, unverified-past-grace) × (allowlisted, non-allowlisted route) → expected 200 or 403.
|
||||
- OAuth-signup user has `email_verified_at` set at callback time → never blocked.
|
||||
- User on day 6 unverified passes; user on day 8 unverified blocks; verifying mid-test transitions to passing.
|
||||
- **Combined-guard test**: protected routers mounting both `require_active_subscription` and `require_verified_email_after_grace` reject an unverified expired-trial account with the appropriate error (whichever check fires first is acceptable; assert one of the two error payloads).
|
||||
- **Subscription model property tests**: `is_active`, `is_paid`, `has_pro_entitlement` across every status × plan combination.
|
||||
- **Auth integration tests**:
|
||||
- `/auth/register` happy path + duplicate email + weak password + email-match enforcement when `account_invite_code` provided.
|
||||
- `/auth/google/callback` and `/auth/microsoft/callback` with mocked OAuth provider responses.
|
||||
- `/auth/email/send-verification` auto-fired by register.
|
||||
- `/auth/email/verify` with valid / expired / already-used tokens (already covered; smoke regression).
|
||||
- **OAuth-only user paths**: `/auth/login` rejects, `/auth/password/change` rejects, password reset suppressed.
|
||||
- **Invitation tests**:
|
||||
- `/accounts/me/invites` create now sends email (regression: today it doesn't).
|
||||
- `/accounts/me/invites/bulk` creates N rows + sends N emails.
|
||||
- Email-match enforcement at register.
|
||||
- Expired/revoked token, idempotent re-accept.
|
||||
- **Plan-limits + feature-flags admin tests**: existing tests stay; extend with a test that round-trips `plan_billing` fields through `/admin/plan-limits` PUT.
|
||||
- **Anti-parrot guardrail**: existing `tests/test_prompt_anti_parrot.py` covers any new system prompts (verification email, invitation email, sales-lead intake) automatically.
|
||||
- **Phase 4 RLS smoke test**: every new account-scoped endpoint exercised with a non-matching `app.current_account_id`.
|
||||
|
||||
#### Frontend (Vitest + Playwright)
|
||||
|
||||
- **Component tests** for `<TrialPill />` (each subscription status branch + trialing-expired computed branch), `<NextStepCard />`, `<EmailVerificationBanner />`, `<EmailVerificationWall />`, `<TrialEndedWall />`, `<FeatureGate />`, `<UpgradePrompt />`.
|
||||
- **Hook tests** for `useFeature`, `useFeatureLimit`, `useTrialBanner`, `useBillingStore` (initial fetch, refetch on webhook event, refetch after Stripe Checkout success).
|
||||
- **Playwright E2E**:
|
||||
- Register → wizard step-by-step → dashboard.
|
||||
- OAuth round-trip with mocked provider.
|
||||
- Trial-end wall → plan picker → mock Stripe Checkout → activated state.
|
||||
- Past-due banner via webhook simulation.
|
||||
- Pilot complimentary view (no walls, no nudges, "Complimentary Pro" pill).
|
||||
- Invitation accept (full flow with `account_invite_code` from a backend fixture; email-match success and failure paths).
|
||||
|
||||
#### Manual validation phase (1-2 days before cutover)
|
||||
|
||||
| Scenario | Method |
|
||||
|---|---|
|
||||
| Email signup → wizard → first session → trial-end synthetic time → Checkout → active | Real flow with Stripe test mode + a date-shimmed account |
|
||||
| Google sign-in | Real Google account |
|
||||
| Microsoft sign-in | Real Microsoft 365 account |
|
||||
| Past-due simulation | Stripe test card `4000 0000 0000 0341` |
|
||||
| Pilot complimentary banner + first-login toast | Log in as an existing pilot account post-deploy |
|
||||
| Webhook signature mismatch handling | Send a forged webhook with bad signature, expect 400 + log entry |
|
||||
| OAuth provider redirect_uri matches | Visual check on each environment's Google + Microsoft app config |
|
||||
| `is_paid` audit | Query a known complimentary account: confirm `is_paid=False`, `has_pro_entitlement=True` |
|
||||
|
||||
### Rollout monitoring
|
||||
|
||||
#### PostHog event taxonomy
|
||||
|
||||
- **Funnel**: `pricing_page_viewed`, `register_started`, `register_completed` (with `method`), `email_verification_sent`, `email_verification_completed`.
|
||||
- **Wizard**: `welcome_wizard_step_completed` (step number), `welcome_wizard_skipped` (`from_step`), `welcome_wizard_completed`.
|
||||
- **Activation**: `first_session_started` (existing), `psa_connected`, `teammate_invited`, `teammate_accepted_invite`.
|
||||
- **Trial conversion**: `trial_modal_shown`, `trial_modal_dismissed`, `trial_ended_wall_shown`, `plan_picker_viewed`, `checkout_session_created`, `checkout_completed`, `checkout_abandoned`.
|
||||
- **Feature-gate signal**: `feature_gate_blocked` (with `feature_key` + `current_plan`).
|
||||
- **Sales**: `talk_to_sales_form_submitted` (with `source`), `complimentary_account_first_view`.
|
||||
|
||||
#### Alerting
|
||||
|
||||
- Stripe webhook signature failures > 1/hour.
|
||||
- Stripe API errors during checkout-session creation > 1/hour.
|
||||
- OAuth callback failures > 5/hour.
|
||||
- Email send failures (`EmailService` errors) on verification or invitation paths.
|
||||
- Any 500 from `/webhooks/stripe`.
|
||||
- 402 rate spike on non-allowlisted endpoints (could indicate guard misconfiguration).
|
||||
|
||||
#### Operational dashboards
|
||||
|
||||
- Daily: trial signups, completed checkouts, MRR delta (using corrected `is_paid`).
|
||||
- Weekly: trial→paid conversion rate, OAuth-method mix, wizard skip rate per step.
|
||||
- Per-feature: `feature_gate_blocked` count by `flag_key`.
|
||||
|
||||
### Stripe MCP tooling note
|
||||
|
||||
Once the Stripe MCP plugin loads in a future Claude Code session, it speeds up two things: **debugging webhook state** for support cases and **ad-hoc subscription mutations** (compt'ing accounts, fixing stuck states). Worth using post-launch for ad-hoc support; not load-bearing for the spec.
|
||||
|
||||
### Open risks and unknowns (carry-forward)
|
||||
|
||||
| # | Item | Status |
|
||||
|---|---|---|
|
||||
| 1 | **Pricing numbers** ($/seat/month for Starter and Pro) | Out of design scope. Set during validation phase. Schema supports any value via `plan_billing.monthly_price_cents` / `annual_price_cents`. |
|
||||
| 2 | **Stripe Tax** | Disabled in v1. Revisit when first international signup arrives. |
|
||||
| 3 | **Multi-account membership** (one user in multiple shops) | Out of scope. v1 is one user → one account. |
|
||||
| 4 | **Owner transfer** | **Existing capability** — `POST /accounts/me/transfer-ownership` (`accounts.py:150`). Surface in the redesigned Account → Team page. |
|
||||
| 5 | **Annual billing UI** | Stripe Prices exist via `plan_billing.stripe_annual_price_id`, but the in-app picker only surfaces monthly in v1. Add later. |
|
||||
| 6 | **SSO (SAML/OIDC) for Enterprise** | Promised on the pricing page Enterprise tier. Actual impl deferred until first paying Enterprise customer. Sales conversation must set expectations honestly. |
|
||||
| 7 | **GDPR DPA template** | Trust strip claims "GDPR-ready DPA available." Founder/lawyer needs to produce the actual document — not eng work, but blocking the trust-strip claim being honest. |
|
||||
| 7b | **SOC2 status** | Trust strip claims "SOC2 in progress." If the engagement isn't started by cutover, soften the trust-strip copy. |
|
||||
| 8 | **Customer Portal cancellation customization** | Stripe-hosted Portal can't be customized. Acceptable for v1. |
|
||||
| 9 | **Email deliverability** | First big surge may trip spam filters. Verify SPF/DKIM/DMARC alignment before cutover. |
|
||||
| 10 | **Reverse-trial conversion math** | If trial→paid is bad post-launch, may need to flip to card-upfront. Schema supports it; policy decision based on data. Re-evaluate at week 4. |
|
||||
| 11 | **Promo codes** | **Deferred from v1.** No `promo_codes` table. If a launch campaign needs them, add a separate table later with Stripe coupon semantics; do not retrofit `invite_codes`. |
|
||||
| 12 | **Pricing page A/B testing** | Not in v1. PostHog has experiment tooling for A/B headlines later. |
|
||||
| 13 | **OAuth-only password set-initial flow** | An OAuth-only user can't add a password later in v1. Out of scope; users who want a password can ask support to enable it manually. |
|
||||
| 14 | **Plan key rename** | Existing `plan_limits` rows use `'free' / 'pro' / 'team'`. Public-facing tiers are Starter / Pro / Enterprise. Implementation plan decides whether to rename keys or maintain a display-name mapping in `plan_billing`. |
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — Endpoint inventory
|
||||
|
||||
Categorized as **NEW**, **MODIFIED**, or **EXISTING (referenced)**.
|
||||
|
||||
### Public
|
||||
|
||||
| Status | Method | Path | Purpose |
|
||||
|---|---|---|---|
|
||||
| NEW (frontend route) | GET | `/pricing` | Public pricing page |
|
||||
| NEW | POST | `/sales-leads` | Talk-to-sales form |
|
||||
| NEW | GET/POST | `/auth/google/callback` | Google OAuth callback |
|
||||
| NEW | GET/POST | `/auth/microsoft/callback` | Microsoft OAuth callback |
|
||||
| EXISTING | POST | `/auth/email/send-verification` | (auto-called from register; today user-initiated) |
|
||||
| EXISTING | POST | `/auth/email/verify` | Token consumption |
|
||||
| MODIFIED | POST | `/auth/register` | Drops invite-code-required gate; calls `BillingService.start_trial()`; auto-sends verification email; **enforces email match against `account_invites.email` when `account_invite_code` is provided** |
|
||||
| MODIFIED | POST | `/webhooks/stripe` | Stripe webhook handler. Stub exists at `app/api/endpoints/webhooks.py` (signature verification + early-out when `stripe_enabled=False`). This work fleshes out event handlers (`checkout.session.completed`, `customer.subscription.*`, `invoice.payment_*`), idempotency via `stripe_events`, and `BillingService.apply_subscription_event` integration. |
|
||||
|
||||
### Authenticated user
|
||||
|
||||
| Status | Method | Path | Purpose |
|
||||
|---|---|---|---|
|
||||
| EXISTING | GET | `/auth/me` | Stays user-focused — no billing data embedded |
|
||||
| NEW | GET | `/billing/state` | Subscription + plan + plan_limits + resolved feature flags |
|
||||
| NEW | POST | `/billing/checkout-session` | Create Stripe Checkout session |
|
||||
| NEW | GET | `/billing/portal-session` | Create Stripe Customer Portal session |
|
||||
| NEW | GET | `/usage/{flag_or_limit_key}` | Live usage count for quantitative limits |
|
||||
| NEW | PATCH | `/users/me/onboarding-step` | Persist welcome wizard step state (writes `accounts.name`, `accounts.team_size_bucket`, `accounts.primary_psa`, `users.role_at_signup`) |
|
||||
| EXISTING | POST | `/accounts/me/transfer-ownership` | Owner transfer (no change) |
|
||||
| MODIFIED | POST | `/accounts/me/invites` | **Now sends invite email at create-time** (today only resend sends) |
|
||||
| NEW | POST | `/accounts/me/invites/bulk` | Wraps single-create in a loop; sends email per row |
|
||||
| EXISTING | POST | `/accounts/me/invites/{id}/resend` | (no change) |
|
||||
| NEW | DELETE | `/accounts/me/invites/{id}` | Soft-revoke an invite by setting `revoked_at`. (No DELETE/revoke route exists today; only POST create, POST resend, GET list.) |
|
||||
|
||||
### Super-admin (existing — referenced)
|
||||
|
||||
| Status | Method | Path | Purpose |
|
||||
|---|---|---|---|
|
||||
| MODIFIED | GET | `/admin/plan-limits` | Response now includes `plan_billing` fields per row |
|
||||
| MODIFIED | PUT | `/admin/plan-limits` | Accepts `plan_billing` fields in payload (single transaction) |
|
||||
| EXISTING | GET/POST/PUT/DELETE | `/admin/plan-limits/account-overrides` | (no change) |
|
||||
| EXISTING | GET/POST/PUT/DELETE | `/admin/feature-flags` | (no change) |
|
||||
| EXISTING | PUT | `/admin/feature-flags/plan-defaults` | (no change) |
|
||||
| EXISTING | GET/POST/DELETE | `/admin/feature-flags/account-overrides` | (no change) |
|
||||
|
||||
No new combined `/admin/plans` admin page in v1.
|
||||
|
||||
---
|
||||
|
||||
## Appendix B — Glossary
|
||||
|
||||
- **Reverse trial**: time-bounded full-access trial with no card required at signup; card requested before billing kicks in.
|
||||
- **Sales-assist (E)**: dedicated path for Enterprise prospects via "Talk to sales" CTA → contact form → manual onboarding by founder/sales.
|
||||
- **Wedge**: Escalation Mode — the magic-moment feature pilots are evaluated against (≥1.0 hour saved per week per pilot per kill-switch criteria).
|
||||
- **Complimentary**: permanent, non-time-bounded `subscriptions.status='complimentary'` value for grandfathered pilot users. No nags, no walls, full Pro entitlement. Distinct from `trialing` in that it never expires; distinct from `active` in that it doesn't count toward paid/MRR metrics.
|
||||
- **Has Pro entitlement**: a property derived from `(status, plan, current_period_end)` that answers "can this account access Pro features right now?" — true for paid Pro, complimentary Pro, and active trials. Used by `require_feature` and `require_active_subscription`.
|
||||
- **Locked subscription**: computed state `(status='trialing' AND current_period_end < now())` OR `(status IN ('canceled', 'incomplete'))`. No mutation occurs; `require_active_subscription` raises 402 on protected routes.
|
||||
- **Plan keys**: `plan_limits.plan` is the canonical key; `plan_billing` joins on it; `subscriptions.plan` is the per-account key. Public-facing tier names (Starter / Pro / Enterprise) are display labels via `plan_billing.display_name`.
|
||||
@@ -30,7 +30,7 @@ test.describe('authenticated navigation smoke tests', () => {
|
||||
await page.goto('/account')
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Account Management' }),
|
||||
page.getByRole('heading', { name: 'Settings' }),
|
||||
).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@ function App() {
|
||||
} else {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [fetchUser, isAuthenticated, setLoading])
|
||||
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
@@ -13,12 +13,14 @@ export type FixStatus =
|
||||
| 'applied_success'
|
||||
| 'applied_failed'
|
||||
| 'applied_partial'
|
||||
| 'applied_pending'
|
||||
| 'dismissed'
|
||||
|
||||
export type FixOutcome =
|
||||
| 'applied_success'
|
||||
| 'applied_failed'
|
||||
| 'applied_partial'
|
||||
| 'applied_pending'
|
||||
| 'dismissed'
|
||||
|
||||
export interface AIOutcomeProposal {
|
||||
@@ -41,6 +43,7 @@ export interface SessionSuggestedFix {
|
||||
applied_at: string | null
|
||||
verified_at: string | null
|
||||
partial_notes: string | null
|
||||
pending_reason: string | null
|
||||
failure_reason: string | null
|
||||
ai_outcome_proposal: AIOutcomeProposal | null
|
||||
superseded_at: string | null
|
||||
@@ -126,11 +129,12 @@ export const sessionSuggestedFixesApi = {
|
||||
|
||||
/**
|
||||
* Record the outcome of applying a suggested fix. Transition rules:
|
||||
* - from `proposed` or `applied_partial`: any outcome is valid (partial is
|
||||
* parked, not terminal — engineer may update notes, abandon via dismiss,
|
||||
* or advance to success/failed).
|
||||
* - from `proposed`, `applied_partial`, or `applied_pending`: any outcome
|
||||
* is valid. Partial = "did some of it"; pending = "did all of it but
|
||||
* verification is deferred". Both are parked, not terminal.
|
||||
* - from a terminal status (`applied_success`, `applied_failed`, `dismissed`):
|
||||
* server returns 409.
|
||||
* - `applied_pending` requires `notes` (the "what are you waiting on?" reason).
|
||||
*/
|
||||
async patchOutcome(
|
||||
sessionId: string,
|
||||
|
||||
@@ -39,7 +39,7 @@ export function FlowAnalyticsPanel({ treeId }: FlowAnalyticsPanelProps) {
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLoading(true)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
|
||||
setError(false)
|
||||
analyticsApi
|
||||
.getFlowAnalytics(treeId, period)
|
||||
|
||||
@@ -74,7 +74,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
onClick={() => setExpanded(true)}
|
||||
className="w-full rounded-lg border border-default/50 bg-elevated/20 p-2.5 flex items-center justify-between text-left hover:bg-elevated/40 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Terminal size={12} />
|
||||
<span>{pendingCount} diagnostic check{pendingCount !== 1 ? 's' : ''} — not completed</span>
|
||||
</div>
|
||||
@@ -95,7 +95,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{responses.map((r, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[0.75rem] text-muted-foreground">
|
||||
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{r.state === 'done' ? (
|
||||
<Check size={10} className="text-success shrink-0" />
|
||||
) : (
|
||||
@@ -118,7 +118,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowRunAll(!showRunAll)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors"
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-accent-text hover:text-accent transition-colors"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
<span>Run All ({commandActions.length} commands)</span>
|
||||
@@ -128,12 +128,12 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
{showRunAll && (
|
||||
<div className="mt-2 rounded-lg border border-default bg-code p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Combined diagnostic script
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopyCommand(combinedScript)}
|
||||
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
<Copy size={11} />
|
||||
<span>Copy</span>
|
||||
@@ -167,23 +167,23 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-[0.8125rem] font-medium text-heading">{action.label}</div>
|
||||
{action.description && (
|
||||
<div className="text-[0.75rem] text-muted-foreground mt-0.5">{action.description}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">{action.description}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status badge for handled cards */}
|
||||
{response.state === 'done' && (
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-success">Done</span>
|
||||
<span className="shrink-0 text-[0.625rem] font-semibold uppercase tracking-wider text-success">Done</span>
|
||||
)}
|
||||
{response.state === 'skipped' && (
|
||||
<span className="shrink-0 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
<span className="shrink-0 text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command with copy button */}
|
||||
{action.command && response.state !== 'skipped' && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
|
||||
<code className="flex-1 text-[0.75rem] font-mono text-heading truncate">
|
||||
<code className="flex-1 text-xs font-mono text-heading truncate">
|
||||
{action.command}
|
||||
</code>
|
||||
<button
|
||||
@@ -201,20 +201,20 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
<div className="mt-2 flex flex-col sm:flex-row items-stretch sm:items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'pasting' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-2 sm:py-1 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors min-h-[44px] sm:min-h-0"
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-2 sm:py-1 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
<Clipboard size={11} />
|
||||
Paste Result
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'typing' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-2 sm:py-1 text-[0.75rem] font-medium text-heading hover:bg-elevated transition-colors min-h-[44px] sm:min-h-0"
|
||||
className="flex items-center justify-center gap-1 rounded-md border border-default bg-elevated/50 px-2.5 py-2 sm:py-1 text-xs font-medium text-heading hover:bg-elevated transition-colors min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
Type Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'skipped' })}
|
||||
className="flex items-center justify-center gap-1 rounded-md px-2.5 py-2 sm:py-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors min-h-[44px] sm:min-h-0"
|
||||
className="flex items-center justify-center gap-1 rounded-md px-2.5 py-2 sm:py-1 text-xs text-muted-foreground hover:text-heading transition-colors min-h-[44px] sm:min-h-0"
|
||||
>
|
||||
<SkipForward size={11} />
|
||||
Skip
|
||||
@@ -237,14 +237,14 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'done' })}
|
||||
disabled={!response.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} />
|
||||
Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateCard(idx, { state: 'pending', value: '' })}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
||||
className="text-xs text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -282,7 +282,7 @@ export function ActionCardGroup({ actions, onSubmit, disabled, stale }: ActionCa
|
||||
</button>
|
||||
|
||||
{submitError && (
|
||||
<div className="flex items-center gap-1.5 text-[0.75rem] text-danger">
|
||||
<div className="flex items-center gap-1.5 text-xs text-danger">
|
||||
<AlertCircle size={12} />
|
||||
<span>Failed to send</span>
|
||||
<button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Sparkles, User } from 'lucide-react'
|
||||
import { Sparkles, User, ListChecks } from 'lucide-react'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { SuggestedFlowCard } from './SuggestedFlowCard'
|
||||
import type { SuggestedFlow } from '@/types/copilot'
|
||||
@@ -8,9 +8,14 @@ interface ChatMessageProps {
|
||||
content: string
|
||||
suggestedFlows?: SuggestedFlow[]
|
||||
imageUrls?: string[]
|
||||
/** When set on an assistant message, renders a leading "Next steps · N pending"
|
||||
* emphasis above the bubble. Used on the current turn only — the canonical
|
||||
* list of items lives in the TaskLane. */
|
||||
actionCount?: number
|
||||
}
|
||||
|
||||
export function ChatMessage({ role, content, suggestedFlows, imageUrls }: ChatMessageProps) {
|
||||
export function ChatMessage({ role, content, suggestedFlows, imageUrls, actionCount }: ChatMessageProps) {
|
||||
const hasActionEmphasis = role === 'assistant' && actionCount !== undefined && actionCount > 0
|
||||
return (
|
||||
<div className={`flex gap-3 ${role === 'user' ? 'flex-row-reverse' : ''}`}>
|
||||
{/* Avatar */}
|
||||
@@ -41,20 +46,32 @@ export function ChatMessage({ role, content, suggestedFlows, imageUrls }: ChatMe
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasActionEmphasis && (
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium text-heading">
|
||||
<ListChecks size={12} className="text-primary" />
|
||||
Next steps
|
||||
<span className="text-muted-foreground font-normal">
|
||||
· {actionCount} pending in Tasks
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`rounded-2xl px-4 py-3 text-[0.875rem] leading-relaxed ${
|
||||
className={`rounded-xl px-4 py-3 text-sm leading-relaxed ${
|
||||
role === 'user'
|
||||
? 'bg-primary/15 text-foreground'
|
||||
: 'bg-input text-foreground border border-border'
|
||||
: hasActionEmphasis
|
||||
? 'bg-input text-foreground border border-hover'
|
||||
: 'bg-input text-foreground border border-border'
|
||||
}`}
|
||||
>
|
||||
<MarkdownContent content={content} className="text-[0.875rem] leading-relaxed" />
|
||||
<MarkdownContent content={content} className="text-sm leading-relaxed" />
|
||||
</div>
|
||||
|
||||
{/* Suggested flows (assistant only) */}
|
||||
{role === 'assistant' && suggestedFlows && suggestedFlows.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
||||
<span className="text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
||||
Related Flows
|
||||
</span>
|
||||
{suggestedFlows.map(flow => (
|
||||
|
||||
@@ -85,7 +85,7 @@ export function ChatSidebar({
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
{pinnedChats.length > 0 && (
|
||||
<div className="px-3 mb-1">
|
||||
<span className="font-sans text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
||||
<span className="text-[0.625rem] uppercase tracking-widest text-muted-foreground">
|
||||
Pinned
|
||||
</span>
|
||||
</div>
|
||||
@@ -159,7 +159,7 @@ export function ChatSidebarCollapsedBar({
|
||||
<History size={14} />
|
||||
<span>History</span>
|
||||
{chats.length > 0 && (
|
||||
<span className="text-[10px] bg-elevated rounded-full px-1.5 py-0.5 font-medium">{chats.length}</span>
|
||||
<span className="text-[0.625rem] bg-elevated rounded-full px-1.5 py-0.5 font-medium">{chats.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
@@ -203,7 +203,7 @@ function ChatItem({
|
||||
<div className="flex-1 min-w-0">
|
||||
{confirming ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[0.75rem] text-danger font-medium">Delete?</span>
|
||||
<span className="text-xs text-danger font-medium">Delete?</span>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onDelete(); setConfirming(false) }}
|
||||
className="text-[0.6875rem] font-medium text-danger hover:text-danger px-1.5 py-0.5 rounded bg-danger/15 hover:bg-danger/25 transition-colors"
|
||||
@@ -222,12 +222,12 @@ function ChatItem({
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="text-[0.8125rem] font-medium truncate">{chat.title}</div>
|
||||
{chat.psa_ticket_id && (
|
||||
<span className="font-mono shrink-0 rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.5625rem] text-accent-text">
|
||||
<span className="font-mono shrink-0 rounded-md bg-accent-dim px-1.5 py-0.5 text-[0.625rem] text-accent-text">
|
||||
#{chat.psa_ticket_id}
|
||||
</span>
|
||||
)}
|
||||
{(chat.status === 'escalated' || chat.status === 'requesting_escalation') && (
|
||||
<span className="font-sans shrink-0 rounded-md bg-warning-dim px-1.5 py-0.5 text-[0.5625rem] uppercase tracking-wider text-warning border border-warning/20">
|
||||
<span className="shrink-0 rounded-md bg-warning-dim px-1.5 py-0.5 text-[0.625rem] uppercase tracking-wider text-warning border border-warning/20">
|
||||
Escalated
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -268,7 +268,7 @@ export function ConcludeSessionModal({
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'w-6 h-6 rounded-full flex items-center justify-center text-[0.6875rem] font-sans text-xs font-medium transition-colors',
|
||||
'w-6 h-6 rounded-full flex items-center justify-center text-[0.6875rem] font-medium transition-colors',
|
||||
step === s
|
||||
? 'bg-primary text-white'
|
||||
: (i < ['select-outcome', 'add-notes', 'summary'].indexOf(step))
|
||||
@@ -280,7 +280,7 @@ export function ConcludeSessionModal({
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-sans text-xs',
|
||||
'text-xs',
|
||||
step === s ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
@@ -329,7 +329,7 @@ export function ConcludeSessionModal({
|
||||
<div className="space-y-4">
|
||||
{/* Selected outcome badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn('px-3 py-1.5 rounded-lg flex items-center gap-2 text-xs font-sans text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}>
|
||||
<div className={cn('px-3 py-1.5 rounded-lg flex items-center gap-2 text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}>
|
||||
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
|
||||
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
|
||||
</div>
|
||||
@@ -342,7 +342,7 @@ export function ConcludeSessionModal({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground block mb-2">
|
||||
<label className="text-[0.625rem] uppercase tracking-widest text-muted-foreground block mb-2">
|
||||
Additional Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
@@ -383,7 +383,7 @@ export function ConcludeSessionModal({
|
||||
<div className="space-y-4">
|
||||
{/* Outcome badge */}
|
||||
{selectedOutcome && (
|
||||
<div className={cn('px-3 py-1.5 rounded-lg inline-flex items-center gap-2 text-xs font-sans text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}>
|
||||
<div className={cn('px-3 py-1.5 rounded-lg inline-flex items-center gap-2 text-xs', selectedOutcome.bg, selectedOutcome.border, 'border')}>
|
||||
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
|
||||
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
|
||||
</div>
|
||||
@@ -396,7 +396,7 @@ export function ConcludeSessionModal({
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||
<span className="text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles size={10} className="text-primary" />
|
||||
Ticket Notes
|
||||
</span>
|
||||
@@ -488,7 +488,7 @@ export function ConcludeSessionModal({
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||
<span className="text-[0.625rem] uppercase tracking-widest text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles size={10} className="text-primary" />
|
||||
Status Update
|
||||
</span>
|
||||
|
||||
@@ -27,11 +27,11 @@ export function SuggestedFlowCard({ flow }: SuggestedFlowCardProps) {
|
||||
<span className="text-[0.8125rem] font-medium text-foreground truncate">
|
||||
{flow.tree_name}
|
||||
</span>
|
||||
<span className="font-sans text-xs text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
<span className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
{flow.tree_type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[0.75rem] text-muted-foreground mt-0.5 line-clamp-2">
|
||||
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||
{flow.relevance_snippet}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import {
|
||||
Copy, Check, SkipForward, Terminal, ChevronDown, ChevronUp,
|
||||
Send, Clipboard, Loader2, PanelRightClose, MessageCircleQuestion, Eye,
|
||||
Send, Clipboard, Loader2, PanelRightClose, Pencil, HelpCircle, Eye,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -31,6 +31,62 @@ interface ActionResponse {
|
||||
|
||||
type TaskResponse = QuestionResponse | ActionResponse
|
||||
|
||||
interface DiagnosticHelp {
|
||||
what: string
|
||||
lookFor: string
|
||||
usefulWhen: string
|
||||
}
|
||||
|
||||
function getDiagnosticHelp(action: ActionResponse): DiagnosticHelp {
|
||||
const command = (action.command || '').toLowerCase()
|
||||
|
||||
if (command.includes('test-netconnection') || command.includes('ping ')) {
|
||||
return {
|
||||
what: action.description || 'Checks whether the target is reachable over the network.',
|
||||
lookFor: 'Successful replies, low packet loss, and whether the expected port shows as open.',
|
||||
usefulWhen: 'Use it when you need to separate a service problem from a basic connectivity problem.',
|
||||
}
|
||||
}
|
||||
|
||||
if (command.includes('nslookup') || command.includes('resolve-dnsname')) {
|
||||
return {
|
||||
what: action.description || 'Checks how DNS resolves the hostname or record.',
|
||||
lookFor: 'Wrong IPs, NXDOMAIN responses, timeout errors, or different answers from different resolvers.',
|
||||
usefulWhen: 'Use it when names fail but direct IP access may still work.',
|
||||
}
|
||||
}
|
||||
|
||||
if (command.includes('ipconfig') || command.includes('get-netipconfiguration')) {
|
||||
return {
|
||||
what: action.description || 'Shows local IP, gateway, DNS, and adapter configuration.',
|
||||
lookFor: 'APIPA addresses, missing gateways, wrong DNS servers, disconnected adapters, or stale leases.',
|
||||
usefulWhen: 'Use it early when the symptom may be local network configuration.',
|
||||
}
|
||||
}
|
||||
|
||||
if (command.includes('get-eventlog') || command.includes('get-winevent') || command.includes('eventlog')) {
|
||||
return {
|
||||
what: action.description || 'Reads Windows event logs for recent errors or warnings.',
|
||||
lookFor: 'Events matching the failure time, repeated error IDs, service crashes, or permission failures.',
|
||||
usefulWhen: 'Use it when the UI only shows a generic error and you need system-level evidence.',
|
||||
}
|
||||
}
|
||||
|
||||
if (command.includes('get-service') || command.includes('restart-service')) {
|
||||
return {
|
||||
what: action.description || 'Checks service state on the affected machine.',
|
||||
lookFor: 'Stopped services, restart loops, disabled startup types, or dependency failures.',
|
||||
usefulWhen: 'Use it when a feature depends on a Windows service or background agent.',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
what: action.description || 'Runs the diagnostic check suggested by FlowPilot.',
|
||||
lookFor: 'Errors, unexpected values, failed checks, or output that differs from a known-good machine.',
|
||||
usefulWhen: 'Use it when you need evidence before choosing the next troubleshooting step.',
|
||||
}
|
||||
}
|
||||
|
||||
interface TaskLaneProps {
|
||||
questions: QuestionItem[]
|
||||
actions: ActionItem[]
|
||||
@@ -98,6 +154,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
const [showRunAll, setShowRunAll] = useState(false)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
const [expandedHelpKey, setExpandedHelpKey] = useState<string | null>(null)
|
||||
|
||||
// ── Resize state ──
|
||||
const DEFAULT_WIDTH = 340
|
||||
@@ -166,22 +223,22 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
questions: questionsRef.current.map(q => ({ text: q.text, context: q.context })),
|
||||
actions: actionsRef.current.map(a => ({ label: a.label, command: a.command, description: a.description })),
|
||||
responses: tasksRef.current as unknown as Array<Record<string, unknown>>,
|
||||
}).catch(() => { /* silent — best-effort save */ })
|
||||
}).catch(() => { /* silent - best-effort save */ })
|
||||
}, 2000)
|
||||
return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current) }
|
||||
}, [sessionId, tasks]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [sessionId, tasks])
|
||||
|
||||
// Reset when new tasks come in from AI response — but preserve saved state
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
const saved = loadTaskState(sessionId)
|
||||
if (saved && saved.length > 0) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs task UI from persisted session state
|
||||
setTasks(saved)
|
||||
return
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: syncs derived state from prop changes
|
||||
|
||||
setTasks([
|
||||
...questions.map((q): QuestionResponse => ({
|
||||
type: 'question', text: q.text, context: q.context, state: 'pending', value: '',
|
||||
@@ -190,12 +247,30 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
type: 'action', label: a.label, command: a.command, description: a.description, state: 'pending', value: '',
|
||||
})),
|
||||
])
|
||||
}, [questions, actions]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [questions, actions, sessionId])
|
||||
|
||||
const updateTask = (idx: number, updates: Partial<TaskResponse>) => {
|
||||
setTasks(prev => prev.map((t, i) => i === idx ? { ...t, ...updates } as TaskResponse : t))
|
||||
}
|
||||
|
||||
// Mark `idx` done and advance focus to the next pending task. If none are
|
||||
// left, focus the Send button so the engineer can fire the batch with one
|
||||
// more keystroke. Powers both keyboard submit (Enter / Cmd+Enter) and the
|
||||
// mouse path on the Answer / Done buttons.
|
||||
const sendButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const submitAndAdvance = (idx: number, value: string) => {
|
||||
if (!value.trim()) return
|
||||
const nextIdx = tasks.findIndex((t, i) => i > idx && t.state === 'pending')
|
||||
setTasks(prev => prev.map((t, i) => {
|
||||
if (i === idx) return { ...t, state: 'done' } as TaskResponse
|
||||
if (nextIdx !== -1 && i === nextIdx) return { ...t, state: 'active' } as TaskResponse
|
||||
return t
|
||||
}))
|
||||
if (nextIdx === -1) {
|
||||
setTimeout(() => sendButtonRef.current?.focus(), 50)
|
||||
}
|
||||
}
|
||||
|
||||
const questionTasks = tasks.filter(t => t.type === 'question')
|
||||
const actionTasks = tasks.filter(t => t.type === 'action') as ActionResponse[]
|
||||
const allHandled = tasks.every(t => t.state === 'done' || t.state === 'skipped')
|
||||
@@ -293,20 +368,21 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
</div>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0" style={{ borderTop: '2px solid var(--color-accent)' }}>
|
||||
<div className="px-4 py-3 border-b border-default flex items-center justify-between shrink-0">
|
||||
<h3 className="font-heading text-sm font-bold text-heading flex items-center gap-2">
|
||||
Tasks
|
||||
<span className={cn(
|
||||
'text-[10px] font-semibold px-2 py-0.5 rounded-full',
|
||||
allHandled
|
||||
? 'bg-success-dim text-success'
|
||||
: 'bg-accent-dim text-accent-text'
|
||||
)}>
|
||||
{allHandled ? '✓ Ready' : `${doneCount}/${totalCount}`}
|
||||
</span>
|
||||
{allHandled ? (
|
||||
<span className="flex items-center gap-1 text-[0.625rem] font-semibold uppercase tracking-wider text-success">
|
||||
<Check size={10} /> Ready
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[0.625rem] font-medium tabular-nums text-muted-foreground">
|
||||
{doneCount}/{totalCount}
|
||||
</span>
|
||||
)}
|
||||
{loading && (
|
||||
<span
|
||||
className="flex items-center gap-1 text-[10px] font-medium text-muted-foreground"
|
||||
className="flex items-center gap-1 text-[0.625rem] font-medium text-muted-foreground"
|
||||
title="AI is thinking"
|
||||
>
|
||||
<Loader2 size={10} className="animate-spin" />
|
||||
@@ -329,7 +405,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
{questionTasks.length > 0 && (
|
||||
<section>
|
||||
<div className="pb-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<div className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
Questions
|
||||
{questionTasks.every(q => q.state === 'done' || q.state === 'skipped') && (
|
||||
@@ -344,12 +420,12 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
|
||||
if (q.state === 'done') {
|
||||
return (
|
||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/40 p-3 mb-2 cursor-pointer hover:border-default transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Check size={12} className="text-success shrink-0" />
|
||||
<span className="text-[0.8125rem] text-foreground">{q.text}</span>
|
||||
<span className="text-[0.8125rem] text-muted-foreground">{q.text}</span>
|
||||
</div>
|
||||
<div className="text-[0.75rem] text-muted-foreground mt-1 pl-5 italic truncate">"{q.value}"</div>
|
||||
<div className="text-xs text-muted-foreground/80 mt-1 pl-5 italic truncate">"{q.value}"</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -359,7 +435,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
||||
<div className="flex justify-between">
|
||||
<div className="text-[0.8125rem] text-muted-foreground line-through">{q.text}</div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -377,33 +453,47 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
autoFocus
|
||||
value={q.value}
|
||||
onChange={e => updateTask(idx, { value: e.target.value })}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submitAndAdvance(idx, q.value)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
updateTask(idx, { state: 'pending', value: '' })
|
||||
}
|
||||
}}
|
||||
placeholder="Type your answer..."
|
||||
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground resize-y min-h-[48px] max-h-[150px] focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'done' })}
|
||||
disabled={!q.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="mt-1.5 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitAndAdvance(idx, q.value)}
|
||||
disabled={!q.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-xs text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[0.625rem] text-muted-foreground tabular-nums">
|
||||
⏎ submit · ⇧⏎ newline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'active' })}
|
||||
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||
>
|
||||
<MessageCircleQuestion size={11} /> Answer
|
||||
<Pencil size={11} /> Answer
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'skipped' })}
|
||||
@@ -424,7 +514,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
{actionTasks.length > 0 && (
|
||||
<section>
|
||||
<div className="pb-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<div className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
|
||||
Diagnostic Checks
|
||||
{actionTasks.every(a => a.state === 'done' || a.state === 'skipped') && (
|
||||
@@ -438,7 +528,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
<div className="mb-2">
|
||||
<button
|
||||
onClick={() => setShowRunAll(!showRunAll)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-accent-text hover:text-accent transition-colors pl-0.5"
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-accent-text hover:text-accent transition-colors pl-0.5"
|
||||
>
|
||||
<Terminal size={12} />
|
||||
Run All ({commandActions.length} commands)
|
||||
@@ -447,16 +537,16 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
{showRunAll && (
|
||||
<div className="mt-2 rounded-lg border border-default bg-code p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
|
||||
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Combined script</span>
|
||||
<button
|
||||
onClick={() => void handleCopy(combinedScript)}
|
||||
className="flex items-center gap-1 text-[0.75rem] text-muted-foreground hover:text-heading transition-colors"
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
{copiedKey === combinedScript ? <Check size={11} className="text-success" /> : <Copy size={11} />}
|
||||
{copiedKey === combinedScript ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-[0.75rem] font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
|
||||
<pre className="text-xs font-mono text-heading whitespace-pre-wrap overflow-x-auto">{combinedScript}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -468,10 +558,10 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
|
||||
if (a.state === 'done') {
|
||||
return (
|
||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/30 p-3 mb-2 cursor-pointer hover:border-success/40 transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/40 p-3 mb-2 cursor-pointer hover:border-default transition-colors" onClick={() => updateTask(idx, { state: 'active' })}>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Check size={12} className="text-success shrink-0" />
|
||||
<span className="text-[0.8125rem] font-medium text-foreground flex-1">{a.label}</span>
|
||||
<span className="text-[0.8125rem] text-muted-foreground flex-1">{a.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -482,7 +572,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default/50 bg-elevated/20 p-3 mb-2 opacity-60 cursor-pointer hover:opacity-80 hover:border-default transition-all" onClick={() => updateTask(idx, { state: 'pending' })} title="Click to restore">
|
||||
<div className="flex justify-between">
|
||||
<div className="text-[0.8125rem] text-muted-foreground line-through">{a.label}</div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
<span className="text-[0.625rem] font-semibold uppercase tracking-wider text-muted-foreground">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -490,10 +580,49 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
|
||||
return (
|
||||
<div key={idx} id={`task-lane-card-${idx}`} className="rounded-lg border border-default bg-card p-3 mb-2 hover:border-hover transition-colors">
|
||||
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
|
||||
{a.description && (
|
||||
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[0.8125rem] font-medium text-heading">{a.label}</div>
|
||||
{a.description && (
|
||||
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 leading-relaxed">{a.description}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpandedHelpKey(expandedHelpKey === `${idx}` ? null : `${idx}`)}
|
||||
className={cn(
|
||||
'shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-elevated/50 hover:text-heading',
|
||||
expandedHelpKey === `${idx}` && 'bg-accent-dim text-accent-text',
|
||||
)}
|
||||
title="Explain this check"
|
||||
aria-label="Explain this diagnostic check"
|
||||
aria-expanded={expandedHelpKey === `${idx}`}
|
||||
>
|
||||
<HelpCircle size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{expandedHelpKey === `${idx}` && (() => {
|
||||
const help = getDiagnosticHelp(a)
|
||||
return (
|
||||
<div className="mt-2 rounded-lg border border-info/20 bg-info-dim/20 p-2.5 text-[0.6875rem] leading-relaxed">
|
||||
<div className="space-y-1.5">
|
||||
<p>
|
||||
<span className="font-semibold text-heading">What it checks: </span>
|
||||
<span className="text-muted-foreground">{help.what}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold text-heading">What to look for: </span>
|
||||
<span className="text-muted-foreground">{help.lookFor}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span className="font-semibold text-heading">When to use it: </span>
|
||||
<span className="text-muted-foreground">{help.usefulWhen}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{a.command && (
|
||||
<div className="mt-2 flex items-center gap-2 rounded bg-code px-2.5 py-1.5">
|
||||
@@ -517,31 +646,45 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
autoFocus
|
||||
value={a.value}
|
||||
onChange={e => updateTask(idx, { value: e.target.value })}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submitAndAdvance(idx, a.value)
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
updateTask(idx, { state: 'pending', value: '' })
|
||||
}
|
||||
}}
|
||||
placeholder="Paste command output here..."
|
||||
className="w-full rounded-md border border-default bg-input px-3 py-2 text-[0.8125rem] text-heading placeholder:text-muted-foreground font-mono resize-y min-h-[60px] max-h-[200px] overflow-y-auto focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
rows={3}
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'done' })}
|
||||
disabled={!a.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="mt-1.5 flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => submitAndAdvance(idx, a.value)}
|
||||
disabled={!a.value.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Done
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'pending', value: '' })}
|
||||
className="text-xs text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[0.625rem] text-muted-foreground tabular-nums">
|
||||
⏎ submit · ⇧⏎ newline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => updateTask(idx, { state: 'active' })}
|
||||
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-[0.75rem] font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||
className="flex items-center gap-1 rounded-md border border-accent/40 bg-accent-dim/30 px-2.5 py-1.5 text-xs font-medium text-accent-text hover:bg-accent-dim/50 transition-colors"
|
||||
>
|
||||
<Clipboard size={11} /> {a.command ? 'Paste Output' : 'Answer'}
|
||||
</button>
|
||||
@@ -602,7 +745,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
<div className="mb-2">
|
||||
<button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] font-medium text-muted-foreground hover:text-heading transition-colors mb-1"
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-heading transition-colors mb-1"
|
||||
>
|
||||
<Eye size={12} />
|
||||
Preview ({handledCount}/{totalCount} done)
|
||||
@@ -616,6 +759,7 @@ export function TaskLane({ questions, actions, sessionId, onSubmit, onClose, loa
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
ref={sendButtonRef}
|
||||
onClick={handleSubmit}
|
||||
disabled={!anyHandled || loading || submitting}
|
||||
className={cn(
|
||||
|
||||
@@ -24,9 +24,6 @@ export function GuideCard({ guide }: GuideCardProps) {
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
{guide.summary}
|
||||
</p>
|
||||
<span className="mt-2 inline-block font-sans text-[0.625rem] uppercase tracking-widest text-primary">
|
||||
{guide.sections.length} {guide.sections.length === 1 ? 'section' : 'sections'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
@@ -37,7 +37,12 @@ export function GuideSection({ section, index }: GuideSectionProps) {
|
||||
<div className="mt-2 flex items-start gap-2 rounded-lg bg-primary/5 border-l-2 border-primary px-3 py-2">
|
||||
<Lightbulb size={14} className="text-primary shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
<span className="font-semibold text-foreground">Tip:</span> {step.tip}
|
||||
<span className="font-semibold text-foreground">Tip:</span>{' '}
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: step.tip.replace(/\*\*(.*?)\*\*/g, '<strong class="text-foreground font-semibold">$1</strong>'),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -296,7 +296,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
}
|
||||
|
||||
return result
|
||||
}, [query, searchFlows, searchSessions, searchAISessions, user])
|
||||
}, [query, searchFlows, searchSessions, searchAISessions, user, onPilotSession])
|
||||
|
||||
// Flatten all items for keyboard navigation
|
||||
const flatItems: PaletteItem[] = builtGroups.flatMap(g => g.items)
|
||||
@@ -401,6 +401,7 @@ export function CommandPalette({ open, onClose }: CommandPaletteProps) {
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
data-testid={item.id === 'flowpilot' ? 'command-palette-flowpilot' : undefined}
|
||||
onClick={() => handleSelect(item)}
|
||||
onMouseEnter={() => setSelectedIndex(itemGlobalIdx)}
|
||||
className={cn(
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useCallback, useEffect, useRef, useState, type PointerEvent as ReactPoi
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
LayoutGrid, Clock, AlertTriangle, GitBranch, Code2, Wand2,
|
||||
ListChecks, Download, BarChart3,
|
||||
LayoutGrid, Clock, AlertTriangle, GitBranch,
|
||||
ListChecks, BarChart3,
|
||||
Settings, Pin, PinOff,
|
||||
History, FileText, Network, Ticket,
|
||||
FileText, Ticket, BookOpen,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
@@ -31,11 +31,6 @@ interface NavEntry {
|
||||
children?: NavSubItem[]
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
title: string
|
||||
items: NavEntry[]
|
||||
}
|
||||
|
||||
/* ── Sidebar component ──────────────────────────────── */
|
||||
|
||||
export function Sidebar() {
|
||||
@@ -78,36 +73,40 @@ export function Sidebar() {
|
||||
|
||||
/* ── Navigation data ──────────────────────────────── */
|
||||
|
||||
/* ── Grouped nav: 5 top-level icons (Sentry-style) ── */
|
||||
/* Single source-of-truth IA. Same items, same order, in both rail
|
||||
* and pinned modes. Pin/unpin is a width/label affordance, not an
|
||||
* IA switch. A hairline divider separates the two groups; no labels. */
|
||||
|
||||
const railGroups: NavEntry[] = [
|
||||
const workItems: NavEntry[] = [
|
||||
{
|
||||
href: '/', icon: LayoutGrid, label: 'Home', shortLabel: 'Home',
|
||||
href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash',
|
||||
matchPaths: ['/'],
|
||||
},
|
||||
{
|
||||
href: '/sessions', icon: History, label: 'History', shortLabel: 'History',
|
||||
badge: stats?.active_count || undefined,
|
||||
matchPaths: ['/sessions', '/escalations', '/pilot'],
|
||||
children: [
|
||||
{ href: '/sessions', label: 'Session History', count: stats?.active_count || undefined },
|
||||
{ href: '/escalations', label: 'Escalations', count: stats?.escalation_count || undefined },
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets',
|
||||
matchPaths: ['/tickets'],
|
||||
},
|
||||
{
|
||||
href: '/sessions', icon: Clock, label: 'Sessions', shortLabel: 'Sessions',
|
||||
badge: stats?.active_count || undefined,
|
||||
matchPaths: ['/sessions'],
|
||||
},
|
||||
{
|
||||
href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal',
|
||||
badge: stats?.escalation_count || undefined,
|
||||
matchPaths: ['/escalations'],
|
||||
},
|
||||
]
|
||||
|
||||
const libraryItems: NavEntry[] = [
|
||||
{
|
||||
href: '/trees', icon: GitBranch, label: 'Flows', shortLabel: 'Flows',
|
||||
badge: stats?.tree_counts.total || undefined,
|
||||
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/review-queue', '/network-diagrams'],
|
||||
matchPaths: ['/trees', '/flows', '/my-trees', '/step-library', '/network-diagrams'],
|
||||
children: [
|
||||
{ href: '/trees', label: 'Flow Library', count: stats?.tree_counts.total || undefined },
|
||||
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
|
||||
{ href: '/network-diagrams', label: 'Network Maps' },
|
||||
{ href: '/step-library', label: 'Solutions Library' },
|
||||
{ href: '/review-queue', label: 'Review Queue' },
|
||||
{ href: '/network-diagrams', label: 'Network Maps' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -115,60 +114,25 @@ export function Sidebar() {
|
||||
badge: pendingDraftCount || undefined,
|
||||
matchPaths: ['/scripts', '/script-builder'],
|
||||
children: [
|
||||
{ href: '/scripts', label: 'Script Library', count: pendingDraftCount || undefined },
|
||||
{ href: '/script-builder', label: 'Script Builder' },
|
||||
],
|
||||
},
|
||||
{
|
||||
href: '/analytics', icon: BarChart3, label: 'Insights', shortLabel: 'Data',
|
||||
href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review',
|
||||
matchPaths: ['/review-queue'],
|
||||
},
|
||||
{
|
||||
href: '/analytics', icon: BarChart3, label: 'Analytics', shortLabel: 'Stats',
|
||||
matchPaths: ['/analytics', '/shares'],
|
||||
children: [
|
||||
{ href: '/analytics', label: 'Analytics' },
|
||||
{ href: '/shares', label: 'Exports' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/* Pinned mode still uses the detailed section layout */
|
||||
const sections: NavSection[] = [
|
||||
{
|
||||
title: 'RESOLVE',
|
||||
items: [
|
||||
{ href: '/', icon: LayoutGrid, label: 'Dashboard', shortLabel: 'Dash' },
|
||||
{ href: '/sessions', icon: Clock, label: 'Session History', shortLabel: 'History', badge: stats?.active_count || undefined, matchPaths: ['/sessions'] },
|
||||
{ href: '/tickets', icon: Ticket, label: 'Tickets', shortLabel: 'Tickets', matchPaths: ['/tickets'] },
|
||||
{ href: '/escalations', icon: AlertTriangle, label: 'Escalations', shortLabel: 'Escal', badge: stats?.escalation_count || undefined },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'KNOWLEDGE',
|
||||
items: [
|
||||
{
|
||||
href: '/trees', icon: GitBranch, label: 'Flow Library', shortLabel: 'Flows',
|
||||
badge: stats?.tree_counts.total || undefined,
|
||||
matchPaths: ['/trees', '/flows', '/my-trees'],
|
||||
children: [
|
||||
{ href: '/trees', label: 'Flow Library' },
|
||||
{ href: '/trees?type=procedural', label: 'Projects', count: stats?.tree_counts.procedural || undefined },
|
||||
],
|
||||
},
|
||||
{ href: '/network-diagrams', icon: Network, label: 'Network Maps', shortLabel: 'NetMap', matchPaths: ['/network-diagrams'] },
|
||||
{ href: '/scripts', icon: Code2, label: 'Scripts', shortLabel: 'Scripts' },
|
||||
{ href: '/script-builder', icon: Wand2, label: 'Script Builder', shortLabel: 'Builder' },
|
||||
{ href: '/review-queue', icon: ListChecks, label: 'Review Queue', shortLabel: 'Review' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'INSIGHTS',
|
||||
items: [
|
||||
{ href: '/analytics', icon: BarChart3, label: 'Analytics', shortLabel: 'Stats' },
|
||||
{ href: '/shares', icon: Download, label: 'Exports', shortLabel: 'Export' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const footerItems: NavEntry[] = [
|
||||
{ href: '/account', icon: Settings, label: 'Account', shortLabel: 'Acct' },
|
||||
{ href: '/guides', icon: BookOpen, label: 'Guides', shortLabel: 'Guides', matchPaths: ['/guides'] },
|
||||
{ href: '/account', icon: Settings, label: 'Account', shortLabel: 'Acct', matchPaths: ['/account'] },
|
||||
]
|
||||
|
||||
/* ── Active detection ─────────────────────────────── */
|
||||
@@ -369,9 +333,9 @@ export function Sidebar() {
|
||||
|
||||
/* ── Find active flyout group for drawer ── */
|
||||
|
||||
const allRailItems = [...workItems, ...libraryItems, ...footerItems]
|
||||
const activeFlyoutGroup = flyoutIndex && !sidebarPinned
|
||||
? railGroups.find((_, i) => `rail-${i}` === flyoutIndex) ||
|
||||
footerItems.find((_, i) => `footer-${i}` === flyoutIndex)
|
||||
? allRailItems.find(item => item.href === flyoutIndex) || null
|
||||
: null
|
||||
|
||||
/* ── Main render ──────────────────────────────────── */
|
||||
@@ -386,23 +350,20 @@ export function Sidebar() {
|
||||
>
|
||||
{/* Pinned sidebar content */}
|
||||
<div className="px-3 py-2 space-y-0.5">
|
||||
{sections.map((section, si) => (
|
||||
<div key={section.title}>
|
||||
{si > 0 && (
|
||||
<div className="font-mono text-[0.5625rem] uppercase tracking-[0.12em] text-text-muted px-3 pt-3 pb-1">
|
||||
{section.title}
|
||||
</div>
|
||||
)}
|
||||
{section.items.map((item, ii) => renderPinnedItem(item, `${si}-${ii}`))}
|
||||
</div>
|
||||
))}
|
||||
{workItems.map(item => renderPinnedItem(item, item.href))}
|
||||
<div
|
||||
className="my-3 border-t"
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{libraryItems.map(item => renderPinnedItem(item, item.href))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-3 pt-2 pb-4 space-y-0.5" style={{ borderTop: '1px solid var(--color-border-default)' }}>
|
||||
{footerItems.map((item, i) => renderPinnedItem(item, `footer-${i}`))}
|
||||
{footerItems.map(item => renderPinnedItem(item, item.href))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSidebarPinned}
|
||||
@@ -417,7 +378,7 @@ export function Sidebar() {
|
||||
)
|
||||
}
|
||||
|
||||
/* Icon Rail (default) — 5 grouped icons, Sentry-style */
|
||||
/* Icon rail (default, unpinned) — same items as pinned mode, narrower. */
|
||||
return (
|
||||
<div
|
||||
className="flex h-full"
|
||||
@@ -432,14 +393,20 @@ export function Sidebar() {
|
||||
>
|
||||
{/* Nav items */}
|
||||
<div className="flex flex-col items-center w-full px-1 space-y-1.5">
|
||||
{railGroups.map((item, i) => renderRailItem(item, `rail-${i}`))}
|
||||
{workItems.map(item => renderRailItem(item, item.href))}
|
||||
<div
|
||||
className="w-8 my-1 border-t self-center"
|
||||
style={{ borderColor: 'var(--color-border-default)' }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{libraryItems.map(item => renderRailItem(item, item.href))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Footer: Account + Pin */}
|
||||
{/* Footer: Guides, Account + Pin */}
|
||||
<div className="flex flex-col items-center w-full px-1 pb-5 pt-3 space-y-1.5" style={{ borderTop: '1px solid var(--color-border-default)' }}>
|
||||
{footerItems.map((item, i) => renderRailItem(item, `footer-${i}`))}
|
||||
{footerItems.map(item => renderRailItem(item, item.href))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleSidebarPinned}
|
||||
@@ -471,7 +438,7 @@ export function Sidebar() {
|
||||
>
|
||||
{/* Drawer header */}
|
||||
<div className="px-3 mb-3">
|
||||
<h3 className="text-[0.6875rem] font-mono uppercase tracking-[0.12em] text-[#fbbf24]">
|
||||
<h3 className="text-[0.6875rem] font-mono uppercase tracking-[0.12em] text-text-muted">
|
||||
{activeFlyoutGroup.label}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { FolderPlus, Check, Plus } from 'lucide-react'
|
||||
import { foldersApi } from '@/api/folders'
|
||||
import type { FolderListItem } from '@/types'
|
||||
@@ -16,26 +16,7 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadFoldersAndAssignments()
|
||||
}
|
||||
}, [isOpen, treeId])
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen])
|
||||
|
||||
const loadFoldersAndAssignments = async () => {
|
||||
const loadFoldersAndAssignments = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const foldersData = await foldersApi.list()
|
||||
@@ -59,7 +40,26 @@ export function AddToFolderMenu({ treeId, onFolderCreated }: AddToFolderMenuProp
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [treeId])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadFoldersAndAssignments()
|
||||
}
|
||||
}, [isOpen, loadFoldersAndAssignments])
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [isOpen])
|
||||
|
||||
const toggleFolder = async (folderId: string) => {
|
||||
try {
|
||||
|
||||
@@ -56,6 +56,14 @@ function getIndentedName(folders: FolderListItem[], folderId: string): string {
|
||||
return indent + (depth > 1 ? '└ ' : '') + (folder?.name || '')
|
||||
}
|
||||
|
||||
// Get path string for sorting
|
||||
function getPath(allFolders: FolderListItem[], folderId: string): string {
|
||||
const f = allFolders.find((x) => x.id === folderId)
|
||||
if (!f) return ''
|
||||
if (!f.parent_id) return f.name
|
||||
return getPath(allFolders, f.parent_id) + '/' + f.name
|
||||
}
|
||||
|
||||
export function FolderEditModal({
|
||||
folder,
|
||||
parentId: initialParentId,
|
||||
@@ -110,14 +118,6 @@ export function FolderEditModal({
|
||||
})
|
||||
}, [folder, folders])
|
||||
|
||||
// Get path string for sorting
|
||||
function getPath(allFolders: FolderListItem[], folderId: string): string {
|
||||
const f = allFolders.find((x) => x.id === folderId)
|
||||
if (!f) return ''
|
||||
if (!f.parent_id) return f.name
|
||||
return getPath(allFolders, f.parent_id) + '/' + f.name
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (folder) {
|
||||
setName(folder.name)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useCallback, useState, useEffect } from 'react'
|
||||
import { X, Copy, Check, Link2, Users, Lock, Globe } from 'lucide-react'
|
||||
import type { TreeListItem, TreeShare, TreeVisibility } from '@/types'
|
||||
import { treesApi } from '@/api/trees'
|
||||
@@ -20,16 +20,7 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
||||
const [allowForking, setAllowForking] = useState(true)
|
||||
const [visibility, setVisibility] = useState<TreeVisibility>('private')
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadShares()
|
||||
// Reset state
|
||||
setCopied(false)
|
||||
setAllowForking(true)
|
||||
}
|
||||
}, [isOpen, tree.id])
|
||||
|
||||
const loadShares = async () => {
|
||||
const loadShares = useCallback(async () => {
|
||||
try {
|
||||
const sharesData = await treesApi.listShares(tree.id)
|
||||
setShares(sharesData)
|
||||
@@ -40,7 +31,16 @@ export function ShareTreeModal({ tree, isOpen, onClose }: ShareTreeModalProps) {
|
||||
} catch (err) {
|
||||
console.error('Failed to load shares:', err)
|
||||
}
|
||||
}
|
||||
}, [tree.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadShares()
|
||||
// Reset state
|
||||
setCopied(false)
|
||||
setAllowForking(true)
|
||||
}
|
||||
}, [isOpen, loadShares])
|
||||
|
||||
const handleGenerateLink = async () => {
|
||||
setIsGenerating(true)
|
||||
|
||||
@@ -57,7 +57,7 @@ function TabButton({
|
||||
aria-selected={active}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'relative px-3 py-[7px] text-[12.5px] font-medium rounded-t-md transition-colors',
|
||||
'relative px-3 py-[7px] text-xs font-medium rounded-t-md transition-colors',
|
||||
'border-b-2 -mb-px',
|
||||
active
|
||||
? 'text-heading border-accent bg-bg-page'
|
||||
|
||||
@@ -49,8 +49,8 @@ export function EscalateInterceptDialog({
|
||||
Before escalating — what happened with the fix?
|
||||
</div>
|
||||
<div className="text-[12px] text-muted-foreground leading-[1.5] mb-3">
|
||||
“{fixTitle}” is still in the Verifying state. Tag its outcome so
|
||||
the senior picking this up knows what's been tried.
|
||||
“{fixTitle}” still needs an outcome. Tag it so the senior
|
||||
picking this up knows what's been tried.
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* + 07-verify-states.html.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import { Sparkles, Check, ChevronDown, X, MoreHorizontal, Info } from 'lucide-react'
|
||||
import { Sparkles, Check, ChevronDown, X, MoreHorizontal, Info, Clock3 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type {
|
||||
SessionSuggestedFix,
|
||||
@@ -21,6 +21,7 @@ export type BannerMode =
|
||||
| 'proposed' // AI just proposed; engineer hasn't applied yet
|
||||
| 'verifying' // Engineer clicked Apply; awaiting outcome
|
||||
| 'partial' // Applied partially; awaiting finish or terminal outcome
|
||||
| 'pending' // Applied fully; verification deferred (waiting on client, etc)
|
||||
| 'ai_confirming' // AI emitted [FIX_OUTCOME]; engineer confirms
|
||||
| 'nudge' // Compact nudge shown after N post-apply messages
|
||||
|
||||
@@ -45,6 +46,7 @@ export function ProposalBanner(props: ProposalBannerProps) {
|
||||
case 'proposed': return <ProposedBanner {...props} />
|
||||
case 'verifying': return <VerifyingBanner {...props} />
|
||||
case 'partial': return <PartialBanner {...props} />
|
||||
case 'pending': return <PendingBanner {...props} />
|
||||
case 'ai_confirming': return <AIConfirmingBanner {...props} />
|
||||
case 'nudge': return <NudgeBanner {...props} />
|
||||
}
|
||||
@@ -52,27 +54,24 @@ export function ProposalBanner(props: ProposalBannerProps) {
|
||||
|
||||
function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: ProposalBannerProps) {
|
||||
return (
|
||||
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||||
<div className="border-t border-warning/30 bg-card px-5 py-3 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
|
||||
<Sparkles size={15} />
|
||||
</div>
|
||||
<Sparkles size={16} className="text-warning shrink-0 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning">
|
||||
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-warning">
|
||||
<span>Suggested Fix</span>
|
||||
<span className="tabular-nums px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold">
|
||||
<span className="tabular-nums px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[0.625rem] font-bold">
|
||||
{fix.confidence_pct}% confidence
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||
{fix.title}
|
||||
</div>
|
||||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
||||
<div className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{fix.description}
|
||||
</div>
|
||||
{fix.script_template_id && (
|
||||
<div className="mt-1.5 inline-flex items-center gap-1.5 text-[11.5px] text-success">
|
||||
<div className="mt-1.5 inline-flex items-center gap-1.5 text-[0.6875rem] text-success">
|
||||
<Check size={11} />
|
||||
Matches an existing Script Library template — one-click apply
|
||||
</div>
|
||||
@@ -90,13 +89,13 @@ function ProposedBanner({ fix, onApply, onDismiss, onToggleCollapsed }: Proposal
|
||||
)}
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-2.5 py-1.5 rounded text-[12.5px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||||
className="px-2.5 py-1.5 rounded text-xs text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={onApply}
|
||||
className="px-3.5 py-[9px] rounded-lg bg-warning text-[#1a1200] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
className="px-3.5 py-[9px] rounded-lg bg-warning text-[#1a1200] font-semibold text-xs hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
>
|
||||
Apply fix
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
|
||||
@@ -114,27 +113,23 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||||
: 'Applied'
|
||||
|
||||
return (
|
||||
<div className="relative border-t border-warning/30 bg-gradient-to-b from-warning-dim/40 to-warning-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||||
<div className="border-t border-warning/30 bg-card px-5 py-3 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="relative shrink-0 mt-0.5 w-7 h-7 rounded-md border border-warning/30 bg-warning-dim flex items-center justify-center text-warning">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<span className="absolute inset-[-3px] rounded-lg animate-pulse-amber pointer-events-none" />
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0 mt-1">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-warning">
|
||||
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-warning">
|
||||
<span>Verifying</span>
|
||||
<span className="px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold normal-case tracking-normal">
|
||||
<span className="px-2 py-[2px] rounded-full bg-warning/20 text-warning text-[0.625rem] font-bold normal-case tracking-normal">
|
||||
{appliedLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||
Did "{fix.title}" work?
|
||||
</div>
|
||||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
||||
<div className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
Mark the outcome so the AI can either close the session with this as the resolution, or propose something else.
|
||||
</div>
|
||||
</div>
|
||||
@@ -148,7 +143,7 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||||
</button>
|
||||
{showOverflow && (
|
||||
<div className={cn(
|
||||
'absolute top-full right-0 mt-1 w-48 rounded-lg',
|
||||
'absolute top-full right-0 mt-1 w-56 rounded-lg',
|
||||
'border border-white/10 bg-card shadow-xl py-1 z-10',
|
||||
)}>
|
||||
<button
|
||||
@@ -157,10 +152,21 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||||
const notes = window.prompt('What did you run / skip?')
|
||||
if (notes && notes.trim()) onOutcome('applied_partial', notes.trim())
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-[12.5px] hover:bg-elevated text-primary"
|
||||
className="w-full text-left px-3 py-2 text-xs hover:bg-elevated text-primary"
|
||||
>
|
||||
Mark partial…
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowOverflow(false)
|
||||
const reason = window.prompt('What are you waiting on? (e.g. "client power-cycling router")')
|
||||
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs hover:bg-elevated text-primary inline-flex items-center gap-2"
|
||||
>
|
||||
<Clock3 size={12} className="text-info" />
|
||||
Waiting to verify…
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
@@ -168,14 +174,14 @@ function VerifyingBanner({ fix, onOutcome }: ProposalBannerProps) {
|
||||
const reason = window.prompt("Why didn't it work? (optional)")
|
||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||
}}
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger inline-flex items-center gap-1.5"
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-xs font-medium hover:bg-danger-dim hover:border-danger inline-flex items-center gap-1.5"
|
||||
>
|
||||
<X size={12} strokeWidth={2.5} />
|
||||
Didn't work
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOutcome('applied_success')}
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Check size={12} strokeWidth={2.5} />
|
||||
It worked
|
||||
@@ -196,25 +202,22 @@ function formatRelativeMinutes(iso: string): string {
|
||||
|
||||
function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
|
||||
return (
|
||||
<div className="relative border-t border-info/30 bg-gradient-to-b from-info-dim/40 to-info-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-info" />
|
||||
<div className="border-t border-info/30 bg-card px-5 py-3 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-info/30 bg-info-dim flex items-center justify-center text-info">
|
||||
<Info size={15} />
|
||||
</div>
|
||||
<Info size={16} className="text-info shrink-0 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-info">
|
||||
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-info">
|
||||
<span>Partially applied</span>
|
||||
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[10.5px] font-bold normal-case tracking-normal">
|
||||
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[0.625rem] font-bold normal-case tracking-normal">
|
||||
Parked
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||
{fix.title}
|
||||
</div>
|
||||
{fix.partial_notes && (
|
||||
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-[12px] italic text-primary">
|
||||
<span className="not-italic font-bold text-info text-[10.5px] uppercase tracking-[0.6px]">Note</span>
|
||||
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-xs italic text-primary">
|
||||
<span className="not-italic font-bold text-info text-[0.625rem] uppercase tracking-[0.6px]">Note</span>
|
||||
<span>{fix.partial_notes}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -225,19 +228,19 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
|
||||
const reason = window.prompt("Why didn't it work? (optional)")
|
||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||
}}
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-[12.5px] font-medium hover:bg-danger-dim hover:border-danger"
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-xs font-medium hover:bg-danger-dim hover:border-danger"
|
||||
>
|
||||
Didn't work
|
||||
</button>
|
||||
<button
|
||||
onClick={onApply}
|
||||
className="px-3 py-[9px] rounded-lg bg-card border border-white/10 text-primary text-[12.5px] font-medium hover:bg-elevated"
|
||||
className="px-3 py-[9px] rounded-lg bg-card border border-white/10 text-primary text-xs font-medium hover:bg-elevated"
|
||||
>
|
||||
Finish it ›
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOutcome('applied_success')}
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-[12.5px] hover:brightness-110"
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110"
|
||||
>
|
||||
It worked
|
||||
</button>
|
||||
@@ -247,6 +250,69 @@ function PartialBanner({ fix, onOutcome, onApply }: ProposalBannerProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function PendingBanner({ fix, onOutcome, onDismiss }: ProposalBannerProps) {
|
||||
return (
|
||||
<div className="border-t border-info/30 bg-card px-5 py-3 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<Clock3 size={16} className="text-info shrink-0 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-info">
|
||||
<span>Awaiting verification</span>
|
||||
<span className="px-2 py-[2px] rounded-full bg-info/20 text-info text-[0.625rem] font-bold normal-case tracking-normal">
|
||||
Parked
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||
{fix.title}
|
||||
</div>
|
||||
{fix.pending_reason && (
|
||||
<div className="mt-1.5 flex items-center gap-2 px-2.5 py-1.5 rounded-md bg-info/[0.08] border border-info/30 text-xs italic text-primary">
|
||||
<span className="not-italic font-bold text-info text-[0.625rem] uppercase tracking-[0.6px]">Waiting on</span>
|
||||
<span>{fix.pending_reason}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-xs hover:bg-white/[0.08] hover:text-primary"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const reason = window.prompt(
|
||||
'Update what you\'re waiting on:',
|
||||
fix.pending_reason ?? '',
|
||||
)
|
||||
if (reason && reason.trim()) onOutcome('applied_pending', reason.trim())
|
||||
}}
|
||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-xs hover:bg-white/[0.08] hover:text-primary"
|
||||
>
|
||||
Update reason
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const reason = window.prompt("Why didn't it work? (optional)")
|
||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||
}}
|
||||
className="px-3 py-[9px] rounded-lg border border-danger/30 text-danger text-xs font-medium hover:bg-danger-dim hover:border-danger"
|
||||
>
|
||||
Didn't work
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOutcome('applied_success')}
|
||||
className="px-3 py-[9px] rounded-lg bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110 inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Check size={12} strokeWidth={2.5} />
|
||||
It worked
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: ProposalBannerProps) {
|
||||
const proposal = fix.ai_outcome_proposal
|
||||
if (!proposal) return null
|
||||
@@ -260,37 +326,34 @@ function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: Pro
|
||||
: 'was partially applied'
|
||||
|
||||
return (
|
||||
<div className="relative border-t border-accent/30 bg-gradient-to-b from-accent-dim/40 to-accent-dim/20 px-5 py-3 animate-slide-up">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-accent" />
|
||||
<div className="border-t border-accent/30 bg-card px-5 py-3 animate-slide-up">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 mt-0.5 w-7 h-7 rounded-md border border-accent/30 bg-accent-dim flex items-center justify-center text-accent">
|
||||
<Sparkles size={15} />
|
||||
</div>
|
||||
<Sparkles size={16} className="text-accent shrink-0 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 font-heading text-[10px] font-semibold uppercase tracking-[1.2px] text-accent">
|
||||
<div className="flex items-center gap-2 font-heading text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-accent">
|
||||
<span>AI detected outcome</span>
|
||||
<span className="px-2 py-[2px] rounded-full bg-accent/20 text-accent text-[10.5px] font-bold normal-case tracking-normal">
|
||||
<span className="px-2 py-[2px] rounded-full bg-accent/20 text-accent text-[0.625rem] font-bold normal-case tracking-normal">
|
||||
{isSuccess ? 'Success' : isFailure ? 'Failure' : 'Partial'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[14px] font-semibold text-heading leading-snug">
|
||||
<div className="mt-0.5 text-sm font-semibold text-heading leading-snug">
|
||||
AI thinks the fix {headlineVerb} — confirm?
|
||||
</div>
|
||||
<div className="mt-1 text-[12.5px] text-muted-foreground leading-relaxed">
|
||||
<div className="mt-1 text-xs text-muted-foreground leading-relaxed">
|
||||
{proposal.reason || 'Based on the recent chat. One click either confirms or corrects.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 pt-0.5">
|
||||
<button
|
||||
onClick={onRejectAIProposal}
|
||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-[12.5px] hover:bg-white/[0.08] hover:text-primary"
|
||||
className="px-3 py-[9px] rounded-lg text-muted-foreground text-xs hover:bg-white/[0.08] hover:text-primary"
|
||||
>
|
||||
Not yet
|
||||
</button>
|
||||
<button
|
||||
onClick={onAcceptAIProposal}
|
||||
className={cn(
|
||||
'px-3 py-[9px] rounded-lg font-semibold text-[12.5px] inline-flex items-center gap-1.5 hover:brightness-110',
|
||||
'px-3 py-[9px] rounded-lg font-semibold text-xs inline-flex items-center gap-1.5 hover:brightness-110',
|
||||
isSuccess
|
||||
? 'bg-success text-[#0a1a12]'
|
||||
: 'bg-danger text-[#180808]',
|
||||
@@ -307,20 +370,29 @@ function AIConfirmingBanner({ fix, onAcceptAIProposal, onRejectAIProposal }: Pro
|
||||
|
||||
function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
|
||||
return (
|
||||
<div className="relative border-t border-warning/30 bg-warning-dim/60 px-5 py-2 flex items-center gap-3">
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||||
<div className="border-t border-warning/30 bg-card px-5 py-2 flex items-center gap-3">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-warning shrink-0">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M12 8v4" />
|
||||
<path d="M12 16h.01" />
|
||||
</svg>
|
||||
<span className="flex-1 text-[12.5px] text-primary">
|
||||
<span className="flex-1 text-xs text-primary">
|
||||
Did <strong className="text-heading">"{fix.title}"</strong> work?
|
||||
</span>
|
||||
<button
|
||||
onClick={onSilenceNudge}
|
||||
className="px-2.5 py-1 rounded text-[12px] text-muted-foreground hover:bg-white/[0.08] hover:text-primary"
|
||||
onClick={() => {
|
||||
const reason = window.prompt(
|
||||
'What are you waiting on? (e.g. "client power-cycling router")',
|
||||
)
|
||||
if (reason && reason.trim()) {
|
||||
onOutcome('applied_pending', reason.trim())
|
||||
} else {
|
||||
onSilenceNudge()
|
||||
}
|
||||
}}
|
||||
className="px-2.5 py-1 rounded text-xs text-muted-foreground hover:bg-white/[0.08] hover:text-primary inline-flex items-center gap-1"
|
||||
>
|
||||
<Clock3 size={11} />
|
||||
Still checking
|
||||
</button>
|
||||
<button
|
||||
@@ -328,13 +400,13 @@ function NudgeBanner({ fix, onOutcome, onSilenceNudge }: ProposalBannerProps) {
|
||||
const reason = window.prompt("Why didn't it work? (optional)")
|
||||
onOutcome('applied_failed', reason?.trim() || undefined)
|
||||
}}
|
||||
className="px-2.5 py-1 rounded border border-danger/30 text-danger text-[12px] hover:bg-danger-dim"
|
||||
className="px-2.5 py-1 rounded border border-danger/30 text-danger text-xs hover:bg-danger-dim"
|
||||
>
|
||||
No
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOutcome('applied_success')}
|
||||
className="px-2.5 py-1 rounded bg-success text-[#0a1a12] font-semibold text-[12px] hover:brightness-110"
|
||||
className="px-2.5 py-1 rounded bg-success text-[#0a1a12] font-semibold text-xs hover:brightness-110"
|
||||
>
|
||||
Yes
|
||||
</button>
|
||||
@@ -346,15 +418,14 @@ function CollapsedBanner({ fix, onToggleCollapsed }: ProposalBannerProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggleCollapsed}
|
||||
className="relative w-full border-t border-warning/30 bg-warning-dim/40 px-5 py-2 flex items-center gap-2.5 hover:bg-warning-dim/60 transition-colors text-left"
|
||||
className="w-full border-t border-warning/30 bg-card px-5 py-2 flex items-center gap-2.5 hover:bg-[var(--color-bg-card-hover)] transition-colors text-left"
|
||||
>
|
||||
<div className="absolute left-0 top-0 bottom-0 w-[3px] bg-warning" />
|
||||
<Sparkles size={12} className="text-warning shrink-0" />
|
||||
<span className="flex-1 text-[12px] font-medium text-heading truncate">{fix.title}</span>
|
||||
<span className="px-1.5 py-[1px] rounded-full bg-warning/20 text-warning text-[10.5px] font-bold tabular-nums">
|
||||
<span className="flex-1 text-xs font-medium text-heading truncate">{fix.title}</span>
|
||||
<span className="px-1.5 py-[1px] rounded-full bg-warning/20 text-warning text-[0.625rem] font-bold tabular-nums">
|
||||
{fix.confidence_pct}%
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[11px]">▸ expand</span>
|
||||
<span className="text-muted-foreground text-[0.6875rem]">▸ expand</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -80,10 +80,27 @@ function tokenize(body: string, highlightValues: Record<string, string> | undefi
|
||||
while (cursor < seg.text.length) {
|
||||
let matched: { key: string; value: string } | null = null
|
||||
for (const [key, value] of valueEntries) {
|
||||
if (seg.text.startsWith(value, cursor)) {
|
||||
matched = { key, value }
|
||||
break
|
||||
}
|
||||
if (!seg.text.startsWith(value, cursor)) continue
|
||||
// Word-boundary guard: a single-char value like "D" (drive letter)
|
||||
// would otherwise light up every capital D in identifiers like
|
||||
// `Get-ADUser`. We only require a boundary on a side of the value
|
||||
// that itself starts/ends with a word char, so values that begin or
|
||||
// end in punctuation (e.g. "D:\\Folder") still match cleanly.
|
||||
const valueStartsWithWordChar = /^\w/.test(value)
|
||||
const valueEndsWithWordChar = /\w$/.test(value)
|
||||
const before = cursor > 0 ? seg.text[cursor - 1] : undefined
|
||||
const after = cursor + value.length < seg.text.length
|
||||
? seg.text[cursor + value.length]
|
||||
: undefined
|
||||
const startBounded = !valueStartsWithWordChar
|
||||
|| before === undefined
|
||||
|| !/\w/.test(before)
|
||||
const endBounded = !valueEndsWithWordChar
|
||||
|| after === undefined
|
||||
|| !/\w/.test(after)
|
||||
if (!startBounded || !endBounded) continue
|
||||
matched = { key, value }
|
||||
break
|
||||
}
|
||||
if (matched) {
|
||||
flushPending()
|
||||
|
||||
@@ -39,7 +39,7 @@ export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="flex items-center gap-1.5 text-[0.75rem] text-muted-foreground hover:text-accent-text transition-colors pl-1 mt-1"
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-accent-text transition-colors pl-1 mt-1"
|
||||
>
|
||||
<Plus size={12} />
|
||||
Add a note
|
||||
@@ -62,20 +62,20 @@ export function AddNoteButton({ onAdd }: AddNoteButtonProps) {
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder="Short label (optional)"
|
||||
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-[0.75rem] text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-xs text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={busy || !text.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Add
|
||||
</button>
|
||||
<button
|
||||
onClick={reset}
|
||||
disabled={busy}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
className="text-xs text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* and renders the section. Loading/refresh logic lives in the parent
|
||||
* (AssistantChatPage) so it can coordinate with the chat send cycle.
|
||||
*/
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState } from 'react'
|
||||
import { ChevronDown, ChevronRight, Loader2 } from 'lucide-react'
|
||||
import type { SessionFact } from '@/api/sessionFacts'
|
||||
import { WhatWeKnowItem } from './WhatWeKnowItem'
|
||||
import { AddNoteButton } from './AddNoteButton'
|
||||
@@ -23,32 +23,65 @@ interface WhatWeKnowProps {
|
||||
onUpdateFact: (factId: string, text: string, summary: string | null) => Promise<void> | void
|
||||
onDeleteFact: (factId: string) => Promise<void> | void
|
||||
loading?: boolean
|
||||
/** Used as the sessionStorage key for the engineer's collapse preference.
|
||||
* When the parent re-keys this component on session change, the lazy
|
||||
* initializer reads fresh state for the new session. */
|
||||
sessionId?: string | null
|
||||
}
|
||||
|
||||
const COLLAPSE_STORAGE_KEY = 'rf-whatweknow-collapsed'
|
||||
// First-render auto-collapse threshold. Past this, the section is hidden by
|
||||
// default so Questions / Diagnostic Checks stay above the fold. The engineer's
|
||||
// explicit toggle (stored per-session) always wins over this heuristic.
|
||||
const AUTO_COLLAPSE_THRESHOLD = 5
|
||||
|
||||
export function WhatWeKnow({
|
||||
facts,
|
||||
onAddNote,
|
||||
onUpdateFact,
|
||||
onDeleteFact,
|
||||
loading,
|
||||
sessionId,
|
||||
}: WhatWeKnowProps) {
|
||||
const count = facts.length
|
||||
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
||||
if (sessionId) {
|
||||
try {
|
||||
const stored = sessionStorage.getItem(`${COLLAPSE_STORAGE_KEY}:${sessionId}`)
|
||||
if (stored !== null) return stored === '1'
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return count >= AUTO_COLLAPSE_THRESHOLD
|
||||
})
|
||||
|
||||
const toggle = () => {
|
||||
setCollapsed(prev => {
|
||||
const next = !prev
|
||||
if (sessionId) {
|
||||
try { sessionStorage.setItem(`${COLLAPSE_STORAGE_KEY}:${sessionId}`, next ? '1' : '0') } catch { /* ignore */ }
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'rounded-lg p-3 -mx-1 mb-1',
|
||||
// Subtle green-to-transparent gradient distinguishes this section
|
||||
// from the rest of the lane (mockup 01-session-primary.png).
|
||||
'bg-gradient-to-b from-success/[0.05] to-transparent',
|
||||
)}
|
||||
>
|
||||
<div className="pb-2">
|
||||
<div className="flex items-center gap-2 text-[10px] font-semibold uppercase tracking-[1.2px] text-muted-foreground pl-0.5">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success" />
|
||||
What we know
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="tabular-nums">{count}</span>
|
||||
<section className="rounded-lg p-3 -mx-1 mb-1">
|
||||
<div className={collapsed ? '' : 'pb-2'}>
|
||||
<div className="flex items-center gap-2 pl-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? 'Expand What we know' : 'Collapse What we know'}
|
||||
className="flex items-center gap-2 text-[0.625rem] font-semibold uppercase tracking-[1.2px] text-muted-foreground hover:text-heading transition-colors"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={10} /> : <ChevronDown size={10} />}
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success" />
|
||||
What we know
|
||||
<span className="text-muted-foreground">·</span>
|
||||
<span className="tabular-nums">{count}</span>
|
||||
</button>
|
||||
{loading && (
|
||||
<span
|
||||
className="ml-auto flex items-center gap-1 text-[0.625rem] font-medium normal-case tracking-normal text-muted-foreground"
|
||||
@@ -61,29 +94,33 @@ export function WhatWeKnow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{count === 0 && loading && (
|
||||
<div className="space-y-2 px-1 py-2">
|
||||
<div className="h-3 w-3/4 rounded bg-elevated/60 animate-pulse" />
|
||||
<div className="h-3 w-1/2 rounded bg-elevated/60 animate-pulse" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<>
|
||||
{count === 0 && loading && (
|
||||
<div className="space-y-2 px-1 py-2">
|
||||
<div className="h-3 w-3/4 rounded bg-elevated/60 animate-pulse" />
|
||||
<div className="h-3 w-1/2 rounded bg-elevated/60 animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{count === 0 && !loading && (
|
||||
<div className="text-xs text-muted-foreground italic px-1 py-2">
|
||||
Nothing confirmed yet — facts appear here as the engineer answers questions and runs checks.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{facts.map((fact) => (
|
||||
<WhatWeKnowItem
|
||||
key={fact.id}
|
||||
fact={fact}
|
||||
onSave={(text, summary) => onUpdateFact(fact.id, text, summary)}
|
||||
onDelete={() => onDeleteFact(fact.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<AddNoteButton onAdd={onAddNote} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{count === 0 && !loading && (
|
||||
<div className="text-[0.75rem] text-muted-foreground italic px-1 py-2">
|
||||
Nothing confirmed yet — facts appear here as the engineer answers questions and runs checks.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{facts.map((fact) => (
|
||||
<WhatWeKnowItem
|
||||
key={fact.id}
|
||||
fact={fact}
|
||||
onSave={(text, summary) => onUpdateFact(fact.id, text, summary)}
|
||||
onDelete={() => onDeleteFact(fact.id)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<AddNoteButton onAdd={onAddNote} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -79,20 +79,20 @@ export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps)
|
||||
value={draftSummary}
|
||||
onChange={(e) => setDraftSummary(e.target.value)}
|
||||
placeholder="Short label (e.g. 'rules out tenant/license')"
|
||||
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-[0.75rem] text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
className="mt-1.5 w-full rounded-md border border-default bg-input px-2.5 py-1 text-xs text-heading placeholder:text-muted-foreground focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30"
|
||||
/>
|
||||
<div className="mt-1.5 flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={busy || !draftText.trim()}
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-[0.75rem] font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
className="flex items-center gap-1 rounded-md bg-accent px-2.5 py-1 text-xs font-medium text-white disabled:opacity-40 hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Check size={11} /> Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={busy}
|
||||
className="text-[0.75rem] text-muted-foreground hover:text-heading"
|
||||
className="text-xs text-muted-foreground hover:text-heading"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@@ -104,7 +104,7 @@ export function WhatWeKnowItem({ fact, onSave, onDelete }: WhatWeKnowItemProps)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group rounded-lg border-l-[3px] border-l-success border border-success/25 bg-success-dim/20 p-3 mb-2',
|
||||
'group rounded-lg border border-default/40 p-3 mb-2',
|
||||
busy && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useCallback, useState, useEffect, useRef } from 'react'
|
||||
import { Palette, Upload, Trash2, Loader2 } from 'lucide-react'
|
||||
import { getBranding, updateBranding, deleteLogo } from '@/api/branding'
|
||||
import type { BrandingInfo } from '@/api/branding'
|
||||
@@ -23,11 +23,7 @@ export function BrandingSettings({ teamId }: BrandingSettingsProps) {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadBranding()
|
||||
}, [teamId])
|
||||
|
||||
const loadBranding = async () => {
|
||||
const loadBranding = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await getBranding(teamId)
|
||||
@@ -44,7 +40,11 @@ export function BrandingSettings({ teamId }: BrandingSettingsProps) {
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [teamId])
|
||||
|
||||
useEffect(() => {
|
||||
loadBranding()
|
||||
}, [loadBranding])
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
|
||||
@@ -47,9 +47,9 @@ export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPan
|
||||
if (node) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setDraft(cloneWithoutChildren(node))
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
|
||||
setIsDirty(false)
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}, [nodeId, node?.type]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user