Compare commits
87 Commits
fb2dc222fd
...
feat/self-
| Author | SHA1 | Date | |
|---|---|---|---|
| f85b90c95e | |||
| 5e6541ab92 | |||
| 4a37a47887 | |||
| f31b873459 | |||
| 380fcf7bde | |||
| 4b098deac5 | |||
| 502c0a44e8 | |||
| 06200fabb1 | |||
| 3630dd5a80 | |||
| 5e0c9d2de1 | |||
| fee4cb5b74 | |||
| c75ce0c9a3 | |||
| db2478dd89 | |||
| 67fae91087 | |||
| 0c326d0616 | |||
| 99343ab7a9 | |||
| 53dd5f13e5 | |||
| 9b517d3320 | |||
| 7d939a4acf | |||
| 39e85c9770 | |||
| 70ab1f34d4 | |||
| ece82225f2 | |||
| 0b5ed9aa10 | |||
| 7a9cb4b03b | |||
| 80baf89b00 | |||
| d05b475a41 | |||
| 694279f89e | |||
| 16f5e4ce05 | |||
| 2f8ec3775e | |||
| f918b766b0 | |||
| fbb41e789c | |||
| 97d36dd400 | |||
| f26f468878 | |||
| 79942c3fd3 | |||
| 4768ae0648 | |||
| e54d6c586a | |||
| 86893562b9 | |||
| b0708ed650 | |||
| 2ef2350de7 | |||
| f4606f073a | |||
| 9b709488d9 | |||
| 18180bc57f | |||
| f683bb5720 | |||
| 9851d56633 | |||
| 519c7eb5ce | |||
| 9ec208f6e7 | |||
| cfe0e6cae6 | |||
| e3f5ed4985 | |||
| 5105eaf529 | |||
| 974b188c1e | |||
| a28b635b19 | |||
| 50e7763380 | |||
| b3ed76c203 | |||
| 453ba3fefc | |||
| 143c979975 | |||
| ab0d40c1e2 | |||
| 278b9342b4 | |||
| a8b22cfa0b | |||
| b544a7a462 | |||
| 07a3f01184 | |||
| 86120423da | |||
| 0f90c0e199 | |||
| 93fa4eac5c | |||
| dc71d5873b | |||
| 307a6285e6 | |||
| 5e10005276 | |||
| d3a9031e23 | |||
| 708e8b977f | |||
| 8b0358af3b | |||
| 0156aae684 | |||
| 4d8b107121 | |||
| a21fe93454 | |||
| 595844de0b | |||
| b74d3cf584 | |||
| 50ddacdb66 | |||
| a5e2dcf43f | |||
| 3ba4532675 | |||
| 15042af6e2 | |||
| 5bee264d70 | |||
| 7cee7228dc | |||
| 00663a4734 | |||
| ac42f971fc | |||
| f10649abc2 | |||
| ab5e0deaf7 | |||
| f601a0db58 | |||
| dc69c9ddfb | |||
| db717b0b3f |
@@ -1,103 +1,37 @@
|
||||
# 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:** Self-serve signup Phase 2 — PR #162 is open on `feat/self-serve-signup-phase-2`. Current focus is resolving its failing Gitea checks. Phase O manual ops (Stripe live setup, internal validation, flag flip) remain pending after review/merge. See `.ai/HANDOFF.md` for the resume point.
|
||||
|
||||
**Status:** in-flight on `feat/escalation-metric-endpoint`. Branch pushed; **draft PR #155** open ([gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155](https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/155)). Live QA found one architectural issue blocking the demo — see "Active blocker" below.
|
||||
## 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-06 — `feat/self-serve-signup-phase-2`** Phase 2 frontend cutover code (Tasks 27–44 of the plan, 18 commits). Backend remainders + frontend billing foundation + auth surfaces (OAuth + accept-invite + verify-email) + welcome wizard + dashboard redesign (TrialPill, NextStepCard, unified checklist) + public surfaces (`/pricing`, `/contact-sales`) + beta-signup deprecation. Phase O (Stripe live setup, internal validation, flag flip) is operational and pending. Single alembic head `c6cbfc534fad` (no new migrations).
|
||||
- **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)
|
||||
|
||||
## Active blocker — AI assessment still empty after pickup
|
||||
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.
|
||||
|
||||
**The bug** (live-test confirmed 2026-04-29): senior picks up an escalation, magic-moment screen renders with the "AI assessment is still generating" placeholder, and **the placeholder never clears**. Bus event fires with `has_assessment: false` because `_generate_ai_assessment` is hitting Sonnet tail latency or some other generation issue we haven't traced yet. Bumping `ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS` from 15 → 45 (commit `0d1b305`) didn't fix it in the field.
|
||||
|
||||
**Why patching is the wrong move:** the real architectural issue is that we make **three** AI calls per escalation, all summarizing the same source material:
|
||||
|
||||
1. `_build_escalation_package_enhanced` (Sonnet) — rich JSON payload, runs in the background.
|
||||
2. `_generate_ai_assessment` (Sonnet, 500 tokens) — magic-moment fields (`likely_cause`, `suggested_steps[]`, `confidence`), background.
|
||||
3. `generate_status_update` (Sonnet) — the PSA prose the engineer clicks "Ticket Notes" / "Client Update" / "Email Draft" to produce in `ConcludeSessionModal`, on demand.
|
||||
|
||||
User's correct observation (2026-04-29): the engineer is *typically* generating a status update during the escalate flow anyway. There's no reason to do that work three times.
|
||||
|
||||
**Next active task: consolidate the three calls into one.** See `## Active task — AI generation consolidation` below.
|
||||
|
||||
## Active task — AI generation consolidation
|
||||
|
||||
**Goal:** ONE AI call per escalation that produces a single structured payload covering both the magic-moment screen's diagnostic fields AND the PSA-ready prose. Magic-moment populates immediately. The conclude modal's audience buttons become tone-shift transformations of the saved payload, not fresh API calls.
|
||||
|
||||
**Proposed shape** (decide during implementation):
|
||||
|
||||
```python
|
||||
# Persist on SessionHandoff:
|
||||
{
|
||||
"summary_prose": "<PSA-flavored ticket-notes paragraph>",
|
||||
"what_we_know": ["<one-liner>", ...],
|
||||
"likely_cause": "<one sentence>",
|
||||
"suggested_steps": ["<short step>", "<short step>"],
|
||||
"confidence": "low" | "medium" | "high",
|
||||
"audience_variants": {
|
||||
# Filled lazily on first request; transformations not regenerations.
|
||||
"client_update": null,
|
||||
"email_draft": null,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation order (suggested):**
|
||||
|
||||
1. **Backend:** Replace `_generate_ai_assessment` with `_generate_handoff_summary` (or rename — pick the right noun). One Sonnet call, structured JSON response, persisted to `handoff.ai_assessment_data` + a new `handoff.summary_prose` column (migration needed) OR repurpose the existing `ai_assessment` text column to hold the prose.
|
||||
2. **Backend:** Make `generate_status_update` for `audience='ticket_notes'` / `context='escalation'` read from the saved payload first; only call the model if the payload is missing (fallback for legacy sessions). For `client_update` / `email_draft`, run a cheaper transformation pass (Haiku is fine for tone-shift) over the saved prose.
|
||||
3. **Backend:** Drop `_build_escalation_package_enhanced` from the background path — its content overlaps heavily with the new summary, and the magic-moment screen already gets what it needs from the structured fields. Keep it only if downstream PSA push depends on it (verify by grep). Migration concern: the `ai_session.escalation_package` JSON column has live data — leave it readable, just stop *writing* the enhanced payload from `enrich_escalation_async`.
|
||||
4. **Frontend:** `HandoffContextScreen` reads from the new structured fields. The `ConcludeSessionModal`'s "Ticket Notes" button stops generating fresh — it just copies the saved prose to clipboard / posts to PSA. "Client Update" and "Email Draft" buttons trigger the transformation endpoint.
|
||||
5. **Test plan:** Magic-moment screen populates within ~5s instead of ~25s. Engineer's "Ticket Notes" button is instant. Token spend per escalation drops by ~60%.
|
||||
|
||||
**Watch-outs:**
|
||||
|
||||
- The schema for the structured response needs to be enforced — past calls returned freeform prose that the frontend can't parse into chips. Use Anthropic's tool-use / structured output if needed.
|
||||
- Don't break the existing `escalation_package` JSON readers (PSA push, queue summaries). Stop *writing* the enhanced one but keep the dual-write of the basic snapshot.
|
||||
- `_generate_ai_assessment` is referenced in tests (`test_handoff_manager.py` stubs it via `AsyncMock`). Update test fixtures when renaming.
|
||||
|
||||
## 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,57 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-07 — Standardize backend Python on 3.12
|
||||
|
||||
**Context:** Runtime facts had drifted from docs. The backend Dockerfiles and running dev container were already on Python 3.12, GitHub CI had just been updated to 3.12, but project docs still said Python 3.11 and Gitea CI relied on the runner's ambient Python.
|
||||
|
||||
**Decision:** Treat Python 3.12 as the backend standard. Pin local pyenv via `.python-version` to 3.12.13, matching the current `python:3.12-slim` container patch level. Add explicit Python 3.12 setup to Gitea CI and keep GitHub CI on Python 3.12.
|
||||
|
||||
**Rejected:** Moving Docker/runtime back to Python 3.11. The application was already building and running on 3.12, so reverting the runtime would add churn without a product or dependency reason.
|
||||
|
||||
**Consequences:** Native backend work should use `backend/venv` created from Python 3.12.13. Future docs/CI/runtime changes should preserve Python 3.12 unless a deliberate upgrade decision is recorded.
|
||||
|
||||
## 2026-04-30 — Add `applied_pending` non-terminal status to suggested fixes
|
||||
|
||||
**Context:** The verifying banner forces a synchronous verdict — worked / didn't / partial — but a lot of real MSP fixes are async. Engineer ran the script but is waiting on the client to power-cycle, AD replication, an O365 license sync. With only the existing outcomes, the engineer either leaves the banner stale (eroding the verifying signal) or guesses wrong (corrupting outcome data). User flagged the gap directly. Today's `NudgeBanner` "Still checking" button just silences the nudge — it doesn't tell the system anything.
|
||||
|
||||
**Decision:** Add a fourth, non-terminal outcome `applied_pending`, parallel to `applied_partial`. Required `pending_reason` Text column stores the "what are you waiting on?" reason. Outcome endpoint allows pending → {success, failed, partial, dismissed} transitions; pending stamps `applied_at` but NOT `verified_at` (it's parked, not verified). Resolution-note generator frames the fix as provisional (no closure language); escalation-package generator surfaces pending verification as the leading hypothesis with a reference to what's being waited on. Frontend exposes the state via a new `PendingBanner` component (info-tone, mirrors `PartialBanner`) plus a "Waiting to verify…" overflow option in the verifying banner. `NudgeBanner` "Still checking" now records pending with a reason instead of just silencing.
|
||||
|
||||
**Rejected:**
|
||||
|
||||
- **Reuse `applied_partial`.** Semantically wrong — partial means "I did some of it." Pending means "I did all of it, just can't tell if it worked." Generators write different prose for each, and conflating them would lose the distinction in the customer-facing resolution note and the next-engineer escalation handoff.
|
||||
- **Add a `pending_reason` column without a new status.** The status field is what the dashboard, banner, and generators all branch on. Hiding pending state in a separate column would proliferate `IF pending_reason IS NOT NULL` checks across every consumer.
|
||||
- **Cross-session "Follow-ups" dashboard rollup in v1.** Per-session `PendingBanner` is the chat-anchored reminder. Add the dashboard surface only if engineers report losing track across multiple pending sessions in pilot use.
|
||||
- **Optional follow-up timer ("remind me in 30m").** Out of scope; nice-to-have but not the wedge.
|
||||
|
||||
**Consequences:**
|
||||
|
||||
- Engineers can park a fix honestly without losing the verifying signal. The state survives across sessions because it's persisted server-side.
|
||||
- `pending_reason` is preserved as audit trail when the engineer advances pending → success/failed/dismissed; it is not auto-cleared. Intentional — it tells the next reader "we waited for X, then it worked."
|
||||
- New consumers of `FixStatus` must handle the `applied_pending` case. Currently three: the banner derivation in `AssistantChatPage`, the resolution-note generator, and the escalation-package generator. All three updated in this change.
|
||||
- Migration `c0f3a4b7e91d` is reversible — downgrade rewrites pending rows back to `applied_partial` and copies `pending_reason` into `partial_notes` if the partial slot was empty, then drops the column.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-30 — Allow `escalated_to_id` to send chat messages in claimed sessions
|
||||
|
||||
**Context:** During browser QA, clicking "Get AI analysis" on the magic-moment screen returned `POST /ai-sessions/{id}/chat → 400`. The senior tech who claimed the session is stored as `escalated_to_id` on `AISession`, not `user_id` (which remains the junior who created the session). `unified_chat_service.send_chat_message` queried `WHERE ai_sessions.user_id = :user_id`, so the senior's ID never matched and the endpoint rejected the request.
|
||||
|
||||
**Decision:** Extend the ownership check in `send_chat_message` to `OR ai_sessions.escalated_to_id = :user_id` using SQLAlchemy `or_()`. This is the minimal, correct fix: the session model already has a semantically valid "also owns" field for the claiming senior; extending the WHERE clause makes that ownership real.
|
||||
|
||||
**Rejected:**
|
||||
|
||||
- **Transfer `user_id` to the senior on claim.** Breaks the audit trail — `user_id` is the originating engineer throughout the session lifecycle. Any query scoped to "sessions this engineer worked on" would silently lose the junior's history.
|
||||
- **A separate `can_send_message` service method.** Adds indirection with no benefit for v1. One `or_()` line in the existing query is sufficient.
|
||||
- **Checking a role/permission flag instead.** Role gating (engineer/admin) already happens at the claim endpoint. The chat-send check is about session ownership, not role. Mixing the two concerns would be confusing.
|
||||
|
||||
**Consequences:**
|
||||
- Seniors can send AI briefings and continue chat work in sessions they have claimed. Core escalation pickup flow unblocked.
|
||||
- Any future caller of `send_chat_message` should be aware that "user_id or escalated_to_id" is the ownership rule. The service-level check is the single enforcement point.
|
||||
- `user_id` remains the originating engineer for all audit, history, and analytics queries. No data migration needed.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-29 — Consolidate the three per-escalation AI calls into one structured generation
|
||||
|
||||
**Context:** A single user-initiated escalation currently triggers three separate Sonnet calls, all summarizing the same source material (session state, steps taken, "what we know") from slightly different angles:
|
||||
|
||||
@@ -2,64 +2,56 @@
|
||||
|
||||
# HANDOFF.md
|
||||
|
||||
**Last updated:** 2026-04-29 04:30 EDT
|
||||
**Last updated:** 2026-05-07 (PR #162 CI investigation/fixes)
|
||||
|
||||
**Active task:** **Escalation Mode** wedge — AI generation consolidation. Full status + design in [`CURRENT_TASK.md`](CURRENT_TASK.md). The wedge demo is **demo-blocked** by an empty AI assessment that didn't fix with a timeout bump. Architectural cause: 3 redundant AI calls per escalation; the right fix is to consolidate.
|
||||
**Active task:** PR #162 (`feat/self-serve-signup-phase-2`) is open in Gitea. Current session is resolving its failing checks.
|
||||
|
||||
**Branch:** `feat/escalation-metric-endpoint` at `0d1b305`. Pushed to origin. Draft PR #155 open.
|
||||
## Where this session ended
|
||||
|
||||
## Where the previous session ended
|
||||
PR #162 originally failed quickly in Gitea CI. Public Gitea status metadata was available, but job logs redirected to login and no `GITEA_TOKEN` was present. The branch was pushed over SSH.
|
||||
|
||||
Live QA bash on the wedge demo. Branch state: 4 commits added this session (`0f00ee5`, `665530f`, `b7d7ff0`, `0d1b305`).
|
||||
Fixed environment drift first:
|
||||
|
||||
**Confirmed working in browser:**
|
||||
- Standardized backend native/dev/CI Python on 3.12.13 to match Docker.
|
||||
- Added `.python-version`.
|
||||
- Rebuilt `backend/venv` from pyenv Python 3.12.13 and verified native `pytest --version` / `alembic --version` with explicit local env.
|
||||
- Updated Gitea CI backend/e2e Python setup to 3.12.
|
||||
|
||||
- Junior escalates → senior bell-icon notification
|
||||
- Senior Pick Up → magic-moment screen with handoff data
|
||||
- Senior Start Here → chat surface loads with conversation history (`0d1b305` fixed the selectChat-gating bug — was rendering blank before)
|
||||
- Sidebar shows picked-up session with "Escalated" pill (`0d1b305`'s `loadChats()` after claim)
|
||||
- Suggested-step chips render below the composer
|
||||
- Unread 6px dot on queue cards persists across refresh
|
||||
- Task-lane regression killed — no stale flash on new sessions
|
||||
- Enter-to-submit (Shift+Enter for newline) on `EscalateModal` and `ConcludeSessionModal`
|
||||
- `PendingEscalations` rows on dashboard expand to show escalation reason + step count + ticket #
|
||||
Fixed Gitea runner assumptions next:
|
||||
|
||||
**Active blocker:**
|
||||
- Added `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs.
|
||||
- Pushed `fix(ci): set up node in gitea workflow`.
|
||||
|
||||
- **AI assessment never populates** on the magic-moment screen. Bumping the timeout 15s → 45s in `0d1b305` did not fix it in the field. Backend logs from earlier in session showed Sonnet timing out at 15s; the assumption was the call would complete with more headroom, but live test still empty. May be a different failure mode (assessment generating but the bus event firing with `has_assessment: false`, or the frontend subscription not refetching, or the call genuinely failing past 45s).
|
||||
Local frontend validation then exposed real lint failures in Phase 2 React code under the current lint stack. The current WIP fixes:
|
||||
|
||||
## Resume point — DO THIS NEXT
|
||||
- `react-refresh/only-export-components` for exported pure helpers used by tests/shared invite OAuth code.
|
||||
- `react-hooks/set-state-in-effect` warnings where local state intentionally mirrors route/config/cache state.
|
||||
- `react-hooks/purity` warnings from `Date.now()` during render.
|
||||
- Redundant loading-state write in pricing page.
|
||||
|
||||
**Replace the three redundant AI calls with a single structured generation.** Full implementation plan in [`CURRENT_TASK.md`](CURRENT_TASK.md) under "Active task — AI generation consolidation." Summary:
|
||||
Validation after those frontend changes:
|
||||
|
||||
1. **Backend:** Replace `_generate_ai_assessment` with one Sonnet call returning structured JSON: `summary_prose` (PSA-flavored) + `what_we_know[]` + `likely_cause` + `suggested_steps[]` + `confidence`. Persist to `SessionHandoff`. Use Anthropic structured output / tool-use to enforce the schema.
|
||||
2. **Backend:** Make `generate_status_update` for `audience='ticket_notes'` / `context='escalation'` read the saved payload (instant). For `client_update` and `email_draft`, run a cheaper Haiku transformation over the saved prose, not a full re-summarization.
|
||||
3. **Backend:** Stop calling `_build_escalation_package_enhanced` from the background path — overlapping content. Verify nothing downstream depends on the *enhanced* enriched payload before removing.
|
||||
4. **Frontend:** `HandoffContextScreen` reads from the consolidated structured fields. `ConcludeSessionModal`'s "Ticket Notes" button stops generating, just copies the saved prose. "Client Update" / "Email Draft" trigger the cheap transformation.
|
||||
5. **Test plan:** magic-moment populates in ~5s. Token spend down ~60%. AI summary blocker resolved.
|
||||
- `docker exec -w /app resolutionflow_frontend npm run lint` passed.
|
||||
- `docker exec -w /app resolutionflow_frontend npm run test:coverage` passed (`198` tests).
|
||||
- `docker exec -w /app -e NODE_OPTIONS=--max-old-space-size=4096 resolutionflow_frontend npm run build` passed.
|
||||
|
||||
**Implementation order (suggested):** 1 → 4 (so the magic moment shows the new fields) → 2 → 3 (cleanup) → tests.
|
||||
Known local noise:
|
||||
|
||||
**Watch-outs:**
|
||||
- React `act(...)` warnings appeared in existing tests during coverage but did not fail the suite.
|
||||
- Vite emitted large chunk warnings during build.
|
||||
- Unrelated dirty/untracked files remain and should not be staged unless explicitly requested: `docker-compose.dev.yml`, `.env.example`, `abc-feat-self-serve-signup-phase-2-design-20260507-112020.md`, `core.*`, `docs/architecture/`, `docs/tutorials/`.
|
||||
|
||||
- Schema enforcement matters. Past calls returned freeform prose that doesn't parse into chips. Anthropic structured output / tool-use is the right tool.
|
||||
- `escalation_package` JSON column has live data on existing sessions — keep it READABLE, just stop *writing* the enhanced payload from `enrich_escalation_async`. Dual-write the basic snapshot if downstream queue summaries need it.
|
||||
- `_generate_ai_assessment` is stubbed in `test_handoff_manager.py` and `test_session_handoffs_api.py` via `AsyncMock`. Update test fixtures when renaming.
|
||||
- The frontend assessment-ready SSE subscription (added in `0f00ee5`) is fine as-is — it'll dispatch on the new event payload. No client changes for the live-refresh path.
|
||||
## Resume point
|
||||
|
||||
## Useful breadcrumbs
|
||||
1. Commit the frontend lint fixes and `.ai/` handoff updates with the required Codex trailer.
|
||||
2. Push `feat/self-serve-signup-phase-2`.
|
||||
3. Poll Gitea PR #162 statuses for the new head SHA:
|
||||
`curl -fsSL https://gitea.resolutionflow.com/api/v1/repos/chihlasm/resolutionflow/statuses/<sha> | python -m json.tool`
|
||||
4. If statuses are still pending, report that local frontend CI is green and Gitea runner work is queued/running. If a check fails, public statuses may show only the context/description; logs require authenticated Gitea access.
|
||||
|
||||
- AI assessment current impl: [`backend/app/services/handoff_manager.py`](../backend/app/services/handoff_manager.py) — `_generate_ai_assessment`, `_generate_ai_assessment_with_timeout`, `enrich_escalation_async`.
|
||||
- Status update current impl: [`backend/app/services/flowpilot_engine.py`](../backend/app/services/flowpilot_engine.py) — `generate_status_update`, `_build_status_update_prompt`, `_build_status_update_context`.
|
||||
- Enhanced package builder: [`backend/app/services/flowpilot_engine.py`](../backend/app/services/flowpilot_engine.py) — `_build_escalation_package_enhanced` (line ~1694).
|
||||
- Magic-moment screen: [`frontend/src/components/flowpilot/HandoffContextScreen.tsx`](../frontend/src/components/flowpilot/HandoffContextScreen.tsx).
|
||||
- Conclude modal: [`frontend/src/components/assistant/ConcludeSessionModal.tsx`](../frontend/src/components/assistant/ConcludeSessionModal.tsx) — see `handleGenerateStatusUpdate`.
|
||||
- Magic-moment integration + suggested-step chips: [`frontend/src/pages/AssistantChatPage.tsx`](../frontend/src/pages/AssistantChatPage.tsx).
|
||||
- Test fixtures stubbing the assessment: `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`.
|
||||
## Carry-forward
|
||||
|
||||
## Watch-outs (general)
|
||||
|
||||
- Dev stack on this machine: backend `:8000`, frontend `:5173`, postgres `:5433`. All running via docker-compose. HMR works.
|
||||
- Test users (Acme MSP shared account, password `TestPass123!`): `engineer@resolutionflow.example.com` (junior), `teamadmin@resolutionflow.example.com` (senior).
|
||||
- The bus is acceptable for v1 pilot scale only (Railway single-replica). Redis pub/sub is the swap when horizontal scaling appears.
|
||||
- `streamEscalations` doesn't drive token refresh on a mid-stream 401. Acceptable for v1.
|
||||
- Phase O manual ops remain pending after PR review/merge: Stripe live setup, internal validation, feature-flag flip.
|
||||
- Backend env: `SALES_LEAD_RECIPIENT_EMAIL`.
|
||||
- Frontend env: `VITE_SELF_SERVE_ENABLED`, `VITE_GOOGLE_CLIENT_ID`, `VITE_MS_CLIENT_ID`, `VITE_OAUTH_REDIRECT_BASE`, `VITE_CALENDLY_URL`.
|
||||
- Single alembic head remains `c6cbfc534fad`; Phase 2 added no migrations.
|
||||
|
||||
@@ -26,7 +26,7 @@ Go-to-Market Validation (pre-PMF). Backend feature-complete (55+ endpoints, 100+
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Backend:** Python 3.11 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
|
||||
- **Backend:** Python 3.12 + FastAPI, SQLAlchemy 2.0 async (asyncpg), Alembic, Pydantic v2, JWT (python-jose + bcrypt, JTI refresh rotation), APScheduler (in-process with FastAPI lifespan).
|
||||
- **Frontend:** React 19 + Vite + TypeScript, Tailwind v4 (CSS-only config in `index.css`), Zustand (immer + zundo), React Router v7, Axios (token-refresh interceptor), Lucide.
|
||||
- **DB:** PostgreSQL 16 (RLS enabled Phase 4, pgvector).
|
||||
|
||||
@@ -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,160 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-07 11:45 EDT — Codex — Push PR #162 CI runner setup fixes
|
||||
|
||||
- Inspected Gitea PR #162 via public API. PR head was `380fcf7` and all CI jobs failed quickly; pushed local commits through `4a37a47`, including Python 3.12 setup for Gitea backend/e2e jobs.
|
||||
- New run on `4a37a47` showed frontend still failed quickly while backend/e2e remained pending. Root cause likely same class of runner drift: Gitea frontend/e2e jobs used `npm` without setting up Node.
|
||||
- Added explicit `actions/setup-node@v4` with Node 20 to Gitea frontend and e2e jobs. This keeps CI from relying on runner ambient Node/npm.
|
||||
- Files touched: `.gitea/workflows/ci.yml`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-07 11:30 EDT — Codex — Standardize backend Python on 3.12
|
||||
|
||||
- Standardized repo declarations around Python 3.12: added `.python-version` pinned to 3.12.13, updated stale Python 3.11 docs, and added explicit Python 3.12 setup steps to Gitea CI. GitHub CI was already updated to Python 3.12 by the user.
|
||||
- Installed pyenv Python 3.12.13 and created `backend/venv` from that interpreter. Installed `backend/requirements-dev.txt` into the venv.
|
||||
- Verified native `python --version` and venv `python --version` both report 3.12.13. Verified native `pytest 8.4.2` and `alembic 1.18.3` with explicit safe test env vars; plain pytest import still depends on local `.env` values being valid.
|
||||
- Rebuilt and restarted the dev backend container with `docker compose -f docker-compose.dev.yml build backend` and `up -d backend`; confirmed `docker exec resolutionflow_backend python --version` reports 3.12.13.
|
||||
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `README.md`, `DEV-ENV.md`, `.ai/PROJECT_CONTEXT.md`, `.ai/DECISIONS.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-07 11:14 EDT — Codex — Recheck native Python availability
|
||||
|
||||
- Re-ran the startup ritual and checked the host Python state after the user reported fixing the missing native Python issue.
|
||||
- Verified `python` and `python3` resolve to `/config/.pyenv/shims/*` and run Python 3.12.10. `pip` and `pip3` are available as pip 25.0.1 under the same pyenv install.
|
||||
- Confirmed there is no native `python3.11`, pyenv currently lists only `3.12.10`, no repo virtualenv exists under `backend/venv`, `backend/.venv`, or root `.venv`, and `python -m pytest --version` from `backend/` fails with `No module named pytest`.
|
||||
- Conclusion: native Python is present, but it is not yet a ready backend dev/test environment for ResolutionFlow. Docker remains the reliable path for pytest/alembic until a Python 3.11 virtualenv with `backend/requirements*.txt` is installed.
|
||||
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-06 — Claude — Self-serve signup Phase 2 (frontend + cutover code) shipped on `feat/self-serve-signup-phase-2`
|
||||
|
||||
- Executed Tasks 27–44 of `docs/superpowers/plans/2026-05-06-self-serve-signup-phase-2-frontend-cutover.md` via `superpowers:subagent-driven-development`. 18 commits on `feat/self-serve-signup-phase-2` (off `main` `f918b76`); HEAD `c75ce0c`. Each task: dispatched implementer subagent with full task text + curated context, then spec-compliance + code-quality review subagents; review issues either fixed in-flight via `git commit --amend` or noted as deferred scope.
|
||||
- Backend (Phase I, Tasks 27–31): `BillingService.open_customer_portal` + `GET /billing/portal-session`; `PATCH /users/me/onboarding-step` + dismiss-rest sibling; public `POST /sales-leads` (5/hr/IP); `/admin/plan-limits` GET/PUT round-trips `plan_billing` in one transaction with NOT-NULL guards on `display_name|is_public|is_archived|sort_order`; `BillingService.invalidate_billing_cache` no-op stub; `GET /config/public` (`{self_serve_enabled, oauth_providers}`); `auth/register` invite-code gate now `REQUIRE_INVITE_CODE and not SELF_SERVE_ENABLED and not invite_code`. Also (T36): `GET /accounts/invites/{code}/lookup` (public, joinedload account+inviter); OAuth callback honors `account_invite_code+invited_email`, rejects existing-email user with `email_already_registered_use_login`. Also (T42, T44): `GET /plans/public`; `POST /beta-signup` returns 307 to `${FRONTEND_URL}/register?from=beta`. `OnboardingStatus` extended with `email_verified`+`shop_setup_done`; `UserResponse` exposes `onboarding_step_completed`+`onboarding_dismissed`.
|
||||
- Frontend (Phases J–N, Tasks 32–44): `useBillingStore` Zustand store + `useBillingPoll` mounted in `AppLayout`; `useFeature` / `useFeatureLimit` (60s module cache, lazy `/usage/{field}` fetch with silent fallback — endpoint deferred) / `useTrialBanner` (fractional-day boundary so 24h = warning); `FeatureGate` / `UpgradePrompt` (inline `FEATURE_CATALOG`) / `EmailVerificationGate` (mounted in AppLayout around `<ViewTransitionOutlet />`). `RegisterPage` redesign with OAuth buttons + invite-code conditional; `OAuthCallbackPage` with CSRF state validation + UTF-8-safe base64url state encoding (factored into `lib/oauthState.ts`); `useAppConfig` hook. `AcceptInvitePage` at `/accept-invite` with locked email; `EmailVerificationBanner` refactored to design-system tokens; `EmailVerificationWall` polished; `VerifyEmailPage` at `/verify-email` with single-fire ref guard; `WelcomeRouter` + `WelcomeStep1/2/3` at `/welcome*`; `TrialPill` in topbar (8 stages); `NextStepCard` + `SetupChecklist` (replace orphaned `OnboardingChecklist`); `PricingPage` at `/pricing`; `ContactSalesPage` at `/contact-sales`; `LandingPage` got "See pricing" CTA + replaced beta-signup form with `<Link>`.
|
||||
- Final cross-cutting review caught one real bug — relative `/beta-signup` 307 target landing on API origin instead of frontend — fixed via amend (HEAD `c75ce0c`).
|
||||
- Tests: ~165+ new tests across backend pytest + frontend vitest. Sweep at end-of-branch all-green; tsc -b clean.
|
||||
- Phase O (Tasks 45–47) is explicit manual operations: Stripe live-mode setup, internal validation via `INTERNAL_TESTER_EMAILS` per-email allowlist (backend support for that allowlist is NOT yet built), feature-flag flip + week-1 monitoring. Surfaced as the resume point in HANDOFF.md.
|
||||
- Working tree was dirty before this session (`.ai/HANDOFF.md`, `.env.example`s, `core.*` core dumps, `docs/architecture/`, `docs/tutorials/`); intentionally not staged into Phase 2 commits. Files touched: see `git log --oneline f918b76..HEAD` on `feat/self-serve-signup-phase-2`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-02 ~01:00 UTC — Claude — In-product User Guides Diátaxis rewrite shipped (PR #159)
|
||||
|
||||
- Audited the in-product `/guides` collection against live UI via `/browse` (engineer + owner test users). Existing 15 guides predated the FlowPilot pivot — every "click X in the sidebar" reference was wrong (Dashboard → Home, All Flows → Flows, Sessions → History, Exports gone, etc.). Three guides described surfaces that no longer exist: Maintenance Flows, AI Assistant page, Flow Assist Sparkles button. Findings written to `/tmp/guides-audit.md`.
|
||||
- Rebuilt `frontend/src/data/guides.ts` from scratch as 43 problem-oriented Diátaxis how-tos under 10 categories. Single-outcome each, terse imperative steps, real UI labels (Create New, Sign in, Manage, Build New Script, Send Invite, Save Settings, Create Category, etc.). Added `category: CategoryId` and optional `relatedSlugs?: string[]` to the `Guide` interface; new `Category` type and `categories` const drive the hub layout. `GuidesHubPage` now renders category sections (auto-hides empty); `GuideDetailPage` renders a Related guides footer; `GuideCard` lost its misleading "N sections" subtitle.
|
||||
- Fixed `GuideSection.tsx`: `step.tip` was rendered as plain text so `**bold**` markdown in tips rendered literally. Applied the same regex replacement used on `step.instruction`. Verified against `/guides/start-a-session` tip block.
|
||||
- Authored 14 net-new how-tos for FlowPilot-era surfaces with no prior coverage: tasklane-keyboard-flow, view-what-we-know, ask-ai-mid-session, pause-and-leave-session, resolve-a-session, record-suggested-fix-outcome, escalate-a-session, post-docs-to-ticket, send-client-update, build-script-from-scratch, open-suggested-flow, pin-a-flow, invite-teammate. Dropped change-teammate-role from scope — couldn't verify the role-change UI control without a non-owner test member.
|
||||
- Verified owner-only surfaces with `pro@resolutionflow.example.com`: Membership inline form on `/account` (not a separate `/team-members` route), `/account/categories` real button is **Create Category** (not Add), `/account/chat-retention` real fields are **Retention Period (days)** + **Max Conversations** + **Save Settings**, `/account/integrations` form fields confirmed. Three guides corrected post-audit.
|
||||
- Smoke-tested all 43 detail pages — every slug renders, no "Guide Not Found" fallthroughs.
|
||||
- Added `100.64.78.44 docker-01` entry to `/etc/hosts` (user ran `sudo tee` from a normal terminal because the LXC `!` shell prefix can't drive interactive sudo). Should now persist across `/browse` sessions on this LXC.
|
||||
- `docker exec -w /app resolutionflow_frontend npx tsc -b` clean.
|
||||
- Files touched: `frontend/src/data/guides.ts`, `frontend/src/pages/GuidesHubPage.tsx`, `frontend/src/pages/GuideDetailPage.tsx`, `frontend/src/components/guides/GuideCard.tsx`, `frontend/src/components/guides/GuideSection.tsx`, `CHANGELOG.md`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`. Working tree dirty — user not yet asked to commit.
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-01 21:55 UTC — Claude — Session-screen impeccable pass + tasklane keyboard flow shipped (PR #158)
|
||||
|
||||
- Ran the `/impeccable` skill against the assistant chat session screen (chat history / chat bar / TaskLane). Initial design-health score: 24/40 with explicit DESIGN-SYSTEM violations (gradient surfaces in WhatWeKnow + ProposalBanner, side stripes in TaskLane done states + every banner mode, accent borderTop on lane header, backdrop blur on handoff overlay).
|
||||
- Walked through all 5 impeccable sub-passes (distill, quieter, layout, typeset, polish). Score after pass: 33/40 (+9). Biggest gains in Aesthetic & Minimalist (1→3), Consistency & Standards (1→3), Recognition Rather Than Recall (2→4).
|
||||
- Inline iterations on top of the impeccable steps: linked banner ↔ script-panel lifecycle (collapse hides both, dismiss closes both, any outcome closes both); collapsible WhatWeKnow with `sessionStorage` memory + auto-collapse-at-5-facts; full keyboard flow on TaskLane (Enter submits + auto-advances, Shift+Enter newline, Esc cancels, focus jumps to Send Responses after the last task).
|
||||
- Side fix: `ParameterizationPreview` was over-highlighting short parameter values (a `"D"` lit up every capital D in `Get-ADUser`/`Add-Type`/etc.). Added a word-boundary guard, conditional on whether the value itself starts/ends with a word character so values with leading punctuation (`"D:\\Folder"`) still match cleanly.
|
||||
- Followups logged in `.ai/TODO.md`: `ConcludeSessionModal` multi-select for paused/escalated outcomes (real feature work — engineers often need ≥2 of Ticket Notes / Client Update / Email Draft), and `bg-card-hover` Tailwind drift in `CommandPalette` (silently broken classes — two-line fix).
|
||||
- Branched as `feat/session-distill-quieter`, 4 commits (impeccable pass, parameterize fix, TODO followups, hint contrast + font-sans audit). PR #158 created via Gitea API (`$GITEA_TOKEN` env, no `gh` on this LXC). Merged into `main` as `5e10005`. Local branch deleted.
|
||||
- Validation at every commit boundary: `docker exec -w /app resolutionflow_frontend npx tsc -b`, `npm run lint`, and `npm run build` all clean.
|
||||
- Files touched: 14 frontend files (TaskLane, AssistantChatPage, ChatMessage, ProposalBanner, WhatWeKnow, WhatWeKnowItem, SuggestedFlowCard, ChatSidebar, ConcludeSessionModal, ChatTabStrip, ActionCardGroup, AddNoteButton, ParameterizationPreview), `.ai/TODO.md`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `CHANGELOG.md`, `CURRENT-STATE.md`.
|
||||
|
||||
## 2026-05-01 07:20 UTC — Codex — Start issue cleanup plan sections 1 and 2
|
||||
|
||||
- Started `docs/plans/2026-05-01-issue-cleanup-plan.md` sections 1 and 2.
|
||||
- Cleaned frontend lint to zero warnings by removing stale lint disables, tightening hook dependencies, and adding justified comments where effects are intentionally keyed to route or owner identity.
|
||||
- Added e2e selectors for session history controls and the FlowPilot command-palette entry.
|
||||
- Added `AssistantChatPage` observability for unexpected `currentChatRef` stale async discards.
|
||||
- Added `TaskLane` diagnostic help affordances for common command categories and documented #128 as "keep the existing responsive side-panel/bottom-drawer behavior until pilot feedback says otherwise."
|
||||
- Verified `npm run lint`, `npx tsc -b`, and `npm run build` in `resolutionflow_frontend`; build only reported the existing Vite large-chunk warning.
|
||||
- Files touched: frontend lint-cleanup files, `frontend/src/components/assistant/TaskLane.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `frontend/src/pages/SessionHistoryPage.tsx`, `frontend/src/components/layout/CommandPalette.tsx`, `docs/plans/2026-05-01-issue-cleanup-plan.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-01 06:05 UTC — Codex — Clean stale TODOs and add issue cleanup plan
|
||||
|
||||
- Removed the resolved pytest-xdist item from `.ai/TODO.md` and reset "Up next" to no selected task.
|
||||
- Removed the resolved "Add role gate to handoff claim endpoint" backlog item from `.ai/TODO.md`.
|
||||
- Updated the frontend lint cleanup TODO from 23 warnings to the current `npm run lint` result: 24 warnings, 0 errors.
|
||||
- Tried to close Gitea #127 through the API, but this environment has no Gitea token; API returned `401 token is required`.
|
||||
- Added `docs/plans/2026-05-01-issue-cleanup-plan.md` with safe tracker actions and a recommended order for clearing remaining issues.
|
||||
- Files touched: `.ai/TODO.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `docs/plans/2026-05-01-issue-cleanup-plan.md`.
|
||||
|
||||
## 2026-05-01 05:40 UTC — Codex — Audit TODO backlog and Gitea issue validity
|
||||
|
||||
- Compared `.ai/TODO.md`, inline code TODOs, and open Gitea issues against current `main`.
|
||||
- Verified pytest-xdist is already shipped (`backend/requirements-dev.txt`, `backend/tests/conftest.py`, `.gitea/workflows/ci.yml`) so the `.ai/TODO.md` xdist item is stale. Ran frontend lint in Docker; current state is `0 errors, 24 warnings`, so the lint cleanup item remains valid but its count is stale.
|
||||
- Verified Gitea issue status: #58, #60, #128, #129, #130 remain valid; #66 is partially resolved by current `.rfflow` import/export and should be narrowed to template packs/marketplace; #127 is mostly resolved by current UI copy and prompt boundaries unless an always-visible scope badge is still wanted. Open PR #124 is stale/unmergeable against current `main`.
|
||||
- Verified inline TODOs still valid: post-session contextual feedback prompt, FlowPilot analytics domain/time-entry placeholders, prompt-cache verification note unless live telemetry has confirmed it, proposal `modify` flow editor wiring, and procedural ghost-step accept/dismiss buttons.
|
||||
- Files touched: `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-05-01 03:45 UTC — Claude Opus 4.7 — QA, merge, and ship PR #156 pending-verification
|
||||
|
||||
- Committed two logical units of pending work on `feat/fix-pending-verification`: prior session's local review fixes as `5bee264` (Codex-attributed, 5 source files + 3 `.ai/` notes) and this session's docker-exec docs as `15042af` (Claude-attributed, `.ai/PROJECT_CONTEXT.md` + `AGENTS.md`). Cleaned up a 20MB `core.22120` Chromium dump left behind by an earlier sandbox crash.
|
||||
- Resolved a tooling gap surfaced by Codex's prior session ("npm/python/python3 are not on the host path") by documenting that this code-server LXC uses bun + docker for the toolchain. The `docker exec resolutionflow_{backend,frontend}` form is now the canonical command pattern in `.ai/PROJECT_CONTEXT.md`.
|
||||
- Got `$B`/Playwright Chromium running in the code-server LXC. After the user's restart cleared the AppArmor unprivileged-userns block, Chromium still aborted at the deeper `sandbox/linux/services/credentials.cc` layer because of the LXC namespace constraint. Workaround: launch browse with `CONTAINER=1` so it auto-adds `--no-sandbox`. Also added `100.64.78.44 docker-01` to code-server's `/etc/hosts` (via `docker exec -u 0`) so the headless browser could resolve the bake-in `VITE_API_URL`.
|
||||
- Drove `/qa` against the dev stack at `http://100.64.78.44:5173`. No naturally-occurring `applied_pending` fix existed in the DB, so seeded session `4a558056-bcbd-4b51-925b-248d70eb318d` and fix `cd4ff2fd-751a-4bcb-8cfa-3c77b4864fb2` into the test state (un-resolved session, swapped supersession on the two fixes). Saved a restore script first; verified DB matches pre-test state after teardown.
|
||||
- QA result: 5/7 scripted checks PASS with concrete DB + UI evidence. Banner renders correctly ("Awaiting verification" header, "Parked" tag, fix title + pending_reason, 4 actions). "Update reason" updates server-side. "It worked" → `applied_success` with `verified_at` stamped. "Dismiss" → `dismissed` with no terminal timestamp. Page-level Resolve auto-patches `applied_pending` → `applied_success` before the resolution flow opens. Page-level Escalate fires `EscalateInterceptDialog` with the generalized "still needs an outcome" copy. 2 entry-path checks (VerifyingBanner overflow, nudge "Still checking") deferred because they require live AI-generated chat state to drive; the mutating handlers behind those entry paths are verified via the tested transitions. Report at `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md`.
|
||||
- Pushed `feat/fix-pending-verification`. Polled Gitea actions runs 161; required `CI / frontend` and `CI / backend` plus `CI / e2e` all green. Merged via Gitea API as a merge commit (`3ba4532`).
|
||||
- Post-merge cleanup: fast-forwarded local `main`, deleted `feat/fix-pending-verification` locally and on the remote. Wrote handoff updates on `chore/post-156-handoff` matching the prior `chore/post-153-handoff` pattern.
|
||||
- Files touched (this session): `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/PROJECT_CONTEXT.md`, `.ai/SESSION_LOG.md`, `AGENTS.md`, `.gstack/qa-reports/qa-report-pending-verification-2026-04-30.md`, `.gstack/qa-reports/screenshots/01-08*.png`. Plus the two prior-session-authored commits committed by this session (5 source + 3 `.ai/` notes).
|
||||
|
||||
## 2026-05-01 02:24 UTC — Codex — Review-fix PR #156 pending-verification flow
|
||||
|
||||
- Reviewed PR #156 for bugs and found three actionable gaps: pending fixes could be resolved from the page-level Resolve path without updating the fix outcome, the PendingBanner lacked the dismiss action described in the PR body, and new system-prompt examples used real-looking pending reasons contrary to the prompt anti-parrot lesson.
|
||||
- Applied fixes locally on `feat/fix-pending-verification`: page-level Resolve now patches `applied_pending` to `applied_success`; page-level Escalate now intercepts `applied_pending` before handoff; PendingBanner now has Dismiss; escalation intercept copy no longer says only "Verifying state"; generator prompts no longer include real-looking pending examples.
|
||||
- Verified via running containers: prompt anti-parrot guardrail `2 passed`, suggested-fix outcome suite `21 passed`, frontend `npx tsc -b` clean, frontend `npm run build` clean except the existing Vite large-chunk warning, and `git diff --check` clean.
|
||||
- Left for next session: browser QA PR #156 using CURRENT_TASK.md checklist, then commit/push local review fixes and merge.
|
||||
- Files touched: `backend/app/services/resolution_note_generator.py`, `backend/app/services/escalation_package_generator.py`, `frontend/src/components/pilot/ProposalBanner.tsx`, `frontend/src/components/pilot/EscalateInterceptDialog.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-04-30 — Claude Code — Land PR #155, ship pending-verification feature on PR #156
|
||||
|
||||
- Committed Codex's review-pass changes (atomic conditional `UPDATE` for `claim_session`, self-claim 403, queue self-exclusion, pre-flush handoff UUID, frontend dead-code removal) as `f10649a` on `feat/escalation-metric-endpoint`.
|
||||
- Pushed `feat/escalation-metric-endpoint`, un-drafted PR #155, retitled it (stripped "WIP:"), and merged via Gitea API as a merge commit (`ac42f97`). 4/4 CI checks green at merge.
|
||||
- Picked up follow-up work surfaced by the user: the suggested-fix verifying banner forces a synchronous verdict, but real fixes are often async (waiting on client power-cycle, AD replication, license sync). Added a fourth, non-terminal outcome.
|
||||
- Designed the model: new `FixStatus="applied_pending"` parallel to `applied_partial`. Distinct semantics — partial = "did some of it"; pending = "did all of it, can't verify yet." Distinct prose in the resolution-note + escalation-package generators.
|
||||
- Implemented on a fresh branch `feat/fix-pending-verification` off main:
|
||||
- Backend: extended `FixStatus`/`FixOutcome` literals, added `pending_reason` Text column and CHECK constraint update via Alembic migration `c0f3a4b7e91d`. `patch_outcome` accepts pending, requires notes, stamps `applied_at` only (NOT `verified_at`); pending in/out transitions allowed.
|
||||
- Frontend: new `BannerMode='pending'` + `PendingBanner` component (info-tone, mirrors `PartialBanner`). "Waiting to verify…" added to `VerifyingBanner` overflow menu. `NudgeBanner` "Still checking" button now records `applied_pending` with a reason instead of just silencing for the session — closes the loop semantically. `AssistantChatPage` banner-mode derivation maps the new status.
|
||||
- Tests: 4 new integration tests in `test_fix_outcome_endpoint.py` covering notes-required, reason-storage with applied_at-not-verified_at semantics, pending→success transition, and pending_reason update on re-PATCH. 21/21 pass.
|
||||
- Validation: `tsc --noEmit -p tsconfig.app.json` exit 0; `alembic upgrade heads` applied cleanly.
|
||||
- Single-commit PR #156 opened: https://gitea.resolutionflow.com/chihlasm/resolutionflow/pulls/156. Branch rebased onto post-merge main.
|
||||
- Cleanup: removed 10 stray `core.*` dumps from the worktree; deleted merged `feat/escalation-metric-endpoint` locally and on the remote.
|
||||
- Files touched: `backend/app/models/session_suggested_fix.py`, `backend/app/schemas/session_suggested_fix.py`, `backend/app/api/endpoints/session_suggested_fixes.py`, `backend/app/services/resolution_note_generator.py`, `backend/app/services/escalation_package_generator.py`, `backend/tests/test_fix_outcome_endpoint.py`, `backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py`, `frontend/src/api/sessionSuggestedFixes.ts`, `frontend/src/components/pilot/ProposalBanner.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/CURRENT_TASK.md`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`, `.ai/DECISIONS.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-30 06:25 UTC — Codex — Apply Escalation Mode review fixes
|
||||
|
||||
- Reviewed the recent Escalation Mode wedge work and fixed the actionable findings before PR #155 is marked ready.
|
||||
- Reworked `HandoffManager.claim_session` from read-then-write to an atomic conditional update, preserving idempotent same-user retries and returning a typed conflict for a different claimant.
|
||||
- Blocked original engineers from claiming their own handoffs and filtered their own escalated sessions out of `/ai-sessions/escalation-queue`, preventing the post-escalation dashboard from showing a junior their own handoff.
|
||||
- Fixed the compatibility payload so `session.escalation_package["handoff_id"]` is populated from a preassigned UUID before flush.
|
||||
- Removed unused legacy frontend pickup state (`claiming`, `handleStartHere`, unused `onStartHere` destructuring) that made `tsc -b` fail under `noUnusedLocals`.
|
||||
- Added regression coverage for pre-flush handoff IDs, conflict handling, self-claim rejection, successful non-owner claim, and own-escalation queue exclusion.
|
||||
- Verified `git diff --check`; focused backend tests passed (`28 passed in 42.23s`); frontend `tsc --noEmit` checks passed for app and node configs. Full Vite/build script remains blocked by root-owned generated directories under `frontend/node_modules` / `frontend/dist` in this workspace, not by TypeScript errors.
|
||||
- Files touched: `backend/app/services/handoff_manager.py`, `backend/app/api/endpoints/ai_sessions.py`, `backend/app/api/endpoints/session_handoffs.py`, `backend/tests/test_handoff_manager.py`, `backend/tests/test_session_handoffs_api.py`, `frontend/src/components/flowpilot/HandoffContextScreen.tsx`, `frontend/src/pages/AssistantChatPage.tsx`, `.ai/HANDOFF.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
## 2026-04-30 — Claude Code — Browser QA pass complete; chat ownership bug found and fixed; PR #155 ready
|
||||
|
||||
- Ran full browser QA pass on the escalation mode feature using gstack `/qa` skill.
|
||||
- **Critical bug found and fixed (commit `dc69c9d`):** `POST /ai-sessions/{id}/chat → 400` when senior clicked "Get AI analysis" on the magic-moment screen. Root cause: `unified_chat_service.send_chat_message` checked `AISession.user_id == user_id` only; senior is stored as `escalated_to_id`, not `user_id`. Fix: `or_(AISession.user_id == user_id, AISession.escalated_to_id == user_id)` in the WHERE clause.
|
||||
- **All 7 QA scenarios passed:**
|
||||
- Post-escalation redirect: junior routed to `/` with "Session escalated" toast.
|
||||
- Magic-moment screen: header, metadata, two-column AI assessment, 2-option CTA rendered correctly.
|
||||
- "I'll take it from here": claim → dismiss overlay → composer focused.
|
||||
- "Get AI analysis": claim → briefing sent → AI responded → task lane populated (after `dc69c9d` fix).
|
||||
- Task lane copy button: toast + checkmark visual feedback.
|
||||
- Chip expansion: inline detail card + "Open in Tasks panel" scroll.
|
||||
- Post-claim toolbar re-open: dismissible mode with Close-only CTA.
|
||||
- **Known non-blockers:** "Continue where X left off" path untestable on first pickup (`hasTaskLane=false` is correct v1 behavior). 409 race condition untestable with one senior account; backend logic code-reviewed and correct.
|
||||
- Backend tests: 17/17 pass.
|
||||
- Updated `HANDOFF.md` to reflect QA complete; updated `CURRENT_TASK.md` status to engineering+QA complete; appended architectural decision to `DECISIONS.md`.
|
||||
- Branch `feat/escalation-metric-endpoint` is ready for PR #155 to be marked ready-for-review.
|
||||
- **Files touched this session:** `backend/app/services/unified_chat_service.py`, `.ai/HANDOFF.md`, `.ai/CURRENT_TASK.md`, `.ai/DECISIONS.md`, `.ai/SESSION_LOG.md`.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-29 04:30 EDT — Claude Code — Live QA bash, pickup bug fixes, AI summary consolidation surfaced
|
||||
|
||||
- User on a freshly swapped computer ran the live QA flow. Identified two bugs missed by static analysis from the previous session:
|
||||
@@ -182,3 +336,13 @@
|
||||
- Files touched: `.ai/*.md` (created), `CLAUDE.md` (rewritten), `AGENTS.md` (created), `SESSION-HANDOFF.md` (deleted).
|
||||
- Follow-up (same day): Codex review pass flagged stale SaaS-role claim and incomplete file-listings carried over from the pre-migration CLAUDE.md. Verified against `backend/app/core/permissions.py`, `frontend/src/hooks/usePermissions.ts`, `backend/app/api/deps.py`, `backend/app/api/router.py`, and `backend/app/services/psa/`. Corrected PROJECT_CONTEXT.md role hierarchy (`super_admin > owner > engineer > viewer`, not `team_admin`), added `require_account_owner` / `require_team_admin` to deps list, replaced stale endpoint comment with a summary pointing at `api/router.py`, added `exceptions.py` + `ticket_context.py` to the PSA file list. Also replaced seed-example content in `CURRENT_TASK.md` and `TODO.md` with clearer empty-state sentinels.
|
||||
- Branch cleanup (same day): committed pending test-isolation work as `b14a16a chore(tests): gate RLS tests behind RUN_RLS_TESTS flag`, new Phase 9 review doc as `b3506b5 docs(pilot): phase 9 review issues`, and `.remember/` gitignore entry as `b3be1e0 chore: ignore .remember/ skill runtime state`. Deleted `docs/landing-handoff/` (prepared for external design work, not meant to live in the repo). Working tree clean; 3 cleanup commits unpushed.
|
||||
|
||||
## 2026-05-07 UTC — Codex — Resolve PR #162 CI failures
|
||||
|
||||
- Investigated Gitea PR #162 failing checks for `feat/self-serve-signup-phase-2`. Public status metadata was available, but job logs required Gitea login and no token was present.
|
||||
- Standardized backend development/CI Python on 3.12.13 to match the Docker image: added `.python-version`, updated Gitea CI Python setup, rebuilt the local backend virtualenv, and verified native `pytest` / `alembic` command availability with explicit local env.
|
||||
- Added explicit Node 20 setup to Gitea frontend and e2e jobs so CI no longer depends on the runner's ambient Node installation.
|
||||
- Reproduced the remaining frontend failure locally. Lint failed on Phase 2 React code because the current eslint stack flags exported pure helpers, render-time `Date.now()`, and effect-driven state synchronization.
|
||||
- Patched the affected frontend surfaces narrowly: dashboard helper exports, app-config cache handling, feature-limit cache/fetch state, trial-banner time capture, invite/OAuth route error state, pricing loading state, and OAuth authorize URL helper export.
|
||||
- Verified sequential frontend CI locally in Docker: `npm run lint` passed, `npm run test:coverage` passed (`198` tests), and `npm run build` passed with only Vite chunk-size warnings.
|
||||
- Files touched: `.python-version`, `.gitea/workflows/ci.yml`, `.github/workflows/ci.yml`, `.ai/*`, `README.md`, `DEV-ENV.md`, and the frontend lint-fix files under `frontend/src/components/dashboard`, `frontend/src/hooks`, and `frontend/src/pages`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -46,6 +46,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -105,6 +110,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Cache npm
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
@@ -171,6 +181,16 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node.js 20
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -37,10 +37,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python 3.11
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
backend/requirements.txt
|
||||
@@ -143,10 +143,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python 3.11
|
||||
- name: Set up Python 3.12
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
backend/requirements.txt
|
||||
|
||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.12.13
|
||||
@@ -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
|
||||
|
||||
@@ -108,7 +108,7 @@ Run these in order. Stop at the first failure and investigate.
|
||||
# Ubuntu / Debian
|
||||
sudo apt update && sudo apt install -y \
|
||||
git curl build-essential \
|
||||
python3.11 python3.11-venv python3-pip \
|
||||
python3.12 python3.12-venv python3-pip \
|
||||
postgresql-client # not the server — only if running Postgres natively
|
||||
|
||||
# Node 20 via nvm (survives container rebuilds if stored in a volume)
|
||||
@@ -236,7 +236,7 @@ REPO_ROOT=/absolute/path/to/resolutionflow
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python3.11 -m venv venv
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Prerequisites: Docker, Python 3.11+, Node.js 20+
|
||||
# Prerequisites: Docker, Python 3.12, Node.js 20+
|
||||
|
||||
# Start PostgreSQL
|
||||
docker start patherly_postgres
|
||||
|
||||
@@ -21,4 +21,12 @@ ANTHROPIC_API_KEY=
|
||||
VOYAGE_API_KEY=
|
||||
|
||||
# ConnectWise PSA Integration
|
||||
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
|
||||
CW_CLIENT_ID=<CONNECTWISE CLIENT ID>
|
||||
|
||||
# Stripe
|
||||
# Test keys from Stripe Dashboard → Developers → API keys (with Test mode toggled on).
|
||||
# Webhook secret for local dev: from `stripe listen --forward-to localhost:8000/api/v1/webhooks/stripe`.
|
||||
# When unset, app/core/config.py:stripe_enabled returns False and Stripe code paths short-circuit.
|
||||
STRIPE_SECRET_KEY=sk_test_
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_
|
||||
STRIPE_WEBHOOK_SECRET=whsec_
|
||||
@@ -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,117 @@ async def require_admin_db(
|
||||
the user object is needed in the handler.
|
||||
"""
|
||||
return db
|
||||
|
||||
|
||||
_SUBSCRIPTION_GUARD_ALLOWLIST = {
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/auth/logout",
|
||||
"/api/v1/auth/password/change",
|
||||
"/api/v1/auth/email/send-verification",
|
||||
"/api/v1/auth/email/verify",
|
||||
"/api/v1/billing/state",
|
||||
"/api/v1/billing/checkout-session",
|
||||
"/api/v1/billing/portal-session",
|
||||
"/api/v1/users/me",
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
"/api/v1/users/me/onboarding-dismiss-rest",
|
||||
}
|
||||
|
||||
|
||||
async def require_active_subscription(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
):
|
||||
"""Returns the Subscription row when the account has access; raises 402
|
||||
when locked. Mounted on routers requiring Pro entitlement.
|
||||
|
||||
'Locked' = (trialing AND current_period_end < now()) OR
|
||||
(canceled OR incomplete OR no subscription).
|
||||
Active states: active, complimentary, trialing-with-time-remaining, past_due.
|
||||
"""
|
||||
if request.url.path in _SUBSCRIPTION_GUARD_ALLOWLIST:
|
||||
return None
|
||||
|
||||
from app.models.subscription import Subscription
|
||||
from datetime import datetime, timezone
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription).where(Subscription.account_id == current_user.account_id)
|
||||
)
|
||||
sub = result.scalar_one_or_none()
|
||||
|
||||
if sub is None:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={"error": "no_subscription", "upgrade_url": "/account/billing/select-plan"},
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
is_live = (
|
||||
sub.status in ("active", "complimentary", "past_due")
|
||||
or (
|
||||
sub.status == "trialing"
|
||||
and sub.current_period_end is not None
|
||||
and sub.current_period_end > now
|
||||
)
|
||||
)
|
||||
if not is_live:
|
||||
raise HTTPException(
|
||||
status_code=402,
|
||||
detail={
|
||||
"error": "subscription_inactive",
|
||||
"status": sub.status,
|
||||
"plan": sub.plan,
|
||||
"current_period_end": sub.current_period_end.isoformat() if sub.current_period_end else None,
|
||||
"upgrade_url": "/account/billing/select-plan",
|
||||
},
|
||||
)
|
||||
|
||||
return sub
|
||||
|
||||
|
||||
_EMAIL_VERIFICATION_ALLOWLIST = {
|
||||
"/api/v1/auth/me",
|
||||
"/api/v1/auth/logout",
|
||||
"/api/v1/auth/email/send-verification",
|
||||
"/api/v1/auth/email/verify",
|
||||
"/api/v1/auth/password/change",
|
||||
"/api/v1/users/me",
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
"/api/v1/users/me/onboarding-dismiss-rest",
|
||||
"/api/v1/billing/state",
|
||||
"/api/v1/billing/checkout-session",
|
||||
"/api/v1/billing/portal-session",
|
||||
}
|
||||
|
||||
VERIFICATION_GRACE_DAYS = 7
|
||||
|
||||
|
||||
async def require_verified_email_after_grace(
|
||||
request: Request,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
):
|
||||
"""Enforces 'this user has verified email OR is still in 7-day grace.'
|
||||
OAuth signups bypass cleanly because /auth/{google,microsoft}/callback
|
||||
sets users.email_verified_at = now() (provider-attested)."""
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
if request.url.path in _EMAIL_VERIFICATION_ALLOWLIST:
|
||||
return
|
||||
|
||||
if current_user.email_verified_at is not None:
|
||||
return
|
||||
|
||||
grace_ends = current_user.created_at + timedelta(days=VERIFICATION_GRACE_DAYS)
|
||||
if datetime.now(timezone.utc) < grace_ends:
|
||||
return
|
||||
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "email_not_verified",
|
||||
"grace_ended_at": grace_ends.isoformat(),
|
||||
"resend_url": "/api/v1/auth/email/send-verification",
|
||||
},
|
||||
)
|
||||
|
||||
54
backend/app/api/endpoints/account_invite_lookup.py
Normal file
54
backend/app/api/endpoints/account_invite_lookup.py
Normal file
@@ -0,0 +1,54 @@
|
||||
"""Public endpoint for resolving an account invite code into display info.
|
||||
|
||||
Mounted as a public route (no tenant context, no auth) — used by the
|
||||
/accept-invite page on the frontend so an invitee can see what account they
|
||||
are about to join before they sign up. Uses the BYPASSRLS admin session
|
||||
factory because account_invites is account-scoped under Phase 4 RLS but the
|
||||
caller has no tenant identity yet.
|
||||
"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.models.account_invite import AccountInvite
|
||||
from app.schemas.oauth import InviteLookupResponse
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["account-invite-lookup"])
|
||||
|
||||
|
||||
@router.get("/invites/{code}/lookup", response_model=InviteLookupResponse)
|
||||
async def lookup_invite(
|
||||
code: str,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> InviteLookupResponse:
|
||||
"""Return minimal display data for a valid (unused, unexpired, not revoked)
|
||||
invite. Returns 404 with `invite_invalid_or_expired_or_revoked` for any
|
||||
invalid state — the AcceptInvitePage shows a single "ask the inviter to
|
||||
resend" message regardless of which condition failed (anti-enumeration)."""
|
||||
result = await db.execute(
|
||||
select(AccountInvite)
|
||||
.where(AccountInvite.code == code)
|
||||
.options(
|
||||
joinedload(AccountInvite.account),
|
||||
joinedload(AccountInvite.invited_by),
|
||||
)
|
||||
)
|
||||
invite = result.scalar_one_or_none()
|
||||
|
||||
if invite is None or not invite.is_valid:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": "invite_invalid_or_expired_or_revoked"},
|
||||
)
|
||||
|
||||
return InviteLookupResponse(
|
||||
account_name=invite.account.name,
|
||||
inviter_name=invite.invited_by.name,
|
||||
invited_email=invite.email,
|
||||
role=invite.role,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -8,34 +8,101 @@ from app.core.database import get_db
|
||||
from app.core.audit import log_audit
|
||||
from app.models.user import User
|
||||
from app.models.plan_limits import PlanLimits
|
||||
from app.models.plan_billing import PlanBilling
|
||||
from app.models.account import Account
|
||||
from app.models.account_limit_override import AccountLimitOverride
|
||||
from app.models.subscription import Subscription
|
||||
from app.schemas.admin import (
|
||||
PlanLimitResponse, PlanLimitUpdate,
|
||||
PlanLimitResponse, PlanLimitUpdate, PlanLimitWithBillingResponse,
|
||||
AccountOverrideCreate, AccountOverrideUpdate, AccountOverrideResponse,
|
||||
)
|
||||
from app.api.deps import require_admin
|
||||
from app.services.billing import BillingService
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin-plan-limits"])
|
||||
|
||||
|
||||
@router.get("/plan-limits", response_model=list[PlanLimitResponse])
|
||||
# Fields on PlanLimitUpdate that map to plan_billing (not plan_limits).
|
||||
_PLAN_BILLING_FIELDS = (
|
||||
"display_name",
|
||||
"description",
|
||||
"monthly_price_cents",
|
||||
"annual_price_cents",
|
||||
"stripe_product_id",
|
||||
"stripe_monthly_price_id",
|
||||
"stripe_annual_price_id",
|
||||
"is_public",
|
||||
"is_archived",
|
||||
"sort_order",
|
||||
)
|
||||
|
||||
# Subset of _PLAN_BILLING_FIELDS that are NOT NULL on the PlanBilling model.
|
||||
# These are Optional[...] on PlanLimitUpdate, so a caller sending an explicit
|
||||
# null for any of them would otherwise trigger a NOT NULL violation at commit.
|
||||
_PLAN_BILLING_NOT_NULL_FIELDS = frozenset({
|
||||
"display_name",
|
||||
"is_public",
|
||||
"is_archived",
|
||||
"sort_order",
|
||||
})
|
||||
|
||||
|
||||
def _merge_plan_with_billing(
|
||||
plan: PlanLimits, billing: PlanBilling | None
|
||||
) -> PlanLimitWithBillingResponse:
|
||||
"""Build a merged response. Billing fields are None when no plan_billing row
|
||||
exists for the plan."""
|
||||
payload = {
|
||||
"plan": plan.plan,
|
||||
"max_trees": plan.max_trees,
|
||||
"max_sessions_per_month": plan.max_sessions_per_month,
|
||||
"max_users": plan.max_users,
|
||||
"custom_branding": plan.custom_branding,
|
||||
"priority_support": plan.priority_support,
|
||||
"export_formats": plan.export_formats or [],
|
||||
}
|
||||
if billing is not None:
|
||||
payload.update({
|
||||
"display_name": billing.display_name,
|
||||
"description": billing.description,
|
||||
"monthly_price_cents": billing.monthly_price_cents,
|
||||
"annual_price_cents": billing.annual_price_cents,
|
||||
"stripe_product_id": billing.stripe_product_id,
|
||||
"stripe_monthly_price_id": billing.stripe_monthly_price_id,
|
||||
"stripe_annual_price_id": billing.stripe_annual_price_id,
|
||||
"is_public": billing.is_public,
|
||||
"is_archived": billing.is_archived,
|
||||
"sort_order": billing.sort_order,
|
||||
})
|
||||
return PlanLimitWithBillingResponse(**payload)
|
||||
|
||||
|
||||
@router.get("/plan-limits", response_model=list[PlanLimitWithBillingResponse])
|
||||
async def list_plan_limits(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""List all plan limit configurations."""
|
||||
result = await db.execute(select(PlanLimits))
|
||||
return result.scalars().all()
|
||||
"""List all plan limit configurations, merged with plan_billing fields
|
||||
where present. Plans without a plan_billing row return None for the
|
||||
billing fields."""
|
||||
rows = (await db.execute(
|
||||
select(PlanLimits, PlanBilling)
|
||||
.outerjoin(PlanBilling, PlanLimits.plan == PlanBilling.plan)
|
||||
)).all()
|
||||
return [_merge_plan_with_billing(pl, pb) for pl, pb in rows]
|
||||
|
||||
|
||||
@router.put("/plan-limits", response_model=PlanLimitResponse)
|
||||
@router.put("/plan-limits", response_model=PlanLimitWithBillingResponse)
|
||||
async def update_plan_limits(
|
||||
data: PlanLimitUpdate,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Update a plan's limits."""
|
||||
"""Update a plan's limits and (if any plan_billing field is included)
|
||||
upsert the matching plan_billing row in the same transaction. After
|
||||
commit, invalidates the in-process billing cache for accounts on this
|
||||
plan (currently a no-op — see BillingService.invalidate_billing_cache).
|
||||
"""
|
||||
result = await db.execute(select(PlanLimits).where(PlanLimits.plan == data.plan))
|
||||
plan = result.scalar_one_or_none()
|
||||
if not plan:
|
||||
@@ -48,10 +115,50 @@ async def update_plan_limits(
|
||||
plan.priority_support = data.priority_support
|
||||
plan.export_formats = data.export_formats
|
||||
|
||||
await log_audit(db, current_user.id, "plan_limits.update", "plan_limits", details={"plan": data.plan})
|
||||
# Did the request include any plan_billing field? (Pydantic gives us
|
||||
# `model_fields_set` to distinguish "user passed null" from "field omitted".)
|
||||
billing_fields_set = data.model_fields_set & set(_PLAN_BILLING_FIELDS)
|
||||
billing: PlanBilling | None = None
|
||||
if billing_fields_set:
|
||||
billing = (await db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == data.plan)
|
||||
)).scalar_one_or_none()
|
||||
|
||||
if billing is None:
|
||||
# Create. display_name is required on the model — derive from the
|
||||
# plan name when the caller didn't supply one (e.g. "pro" → "Pro").
|
||||
display_name = data.display_name or data.plan.capitalize()
|
||||
billing = PlanBilling(plan=data.plan, display_name=display_name)
|
||||
db.add(billing)
|
||||
|
||||
# Apply only the fields the caller actually included. Allows partial
|
||||
# updates without clobbering existing values.
|
||||
for field in billing_fields_set:
|
||||
value = getattr(data, field)
|
||||
if value is None and field in _PLAN_BILLING_NOT_NULL_FIELDS:
|
||||
# Don't NULL out a NOT NULL column on update.
|
||||
continue
|
||||
setattr(billing, field, value)
|
||||
|
||||
await log_audit(
|
||||
db, current_user.id, "plan_limits.update", "plan_limits",
|
||||
details={"plan": data.plan, "updated_billing": bool(billing_fields_set)},
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(plan)
|
||||
return plan
|
||||
if billing is not None:
|
||||
await db.refresh(billing)
|
||||
|
||||
# Invalidate any in-process billing cache for accounts on this plan.
|
||||
# TODO: invalidate app.state.billing_cache when added.
|
||||
account_ids = [
|
||||
row[0] for row in (await db.execute(
|
||||
select(Subscription.account_id).where(Subscription.plan == data.plan)
|
||||
)).all()
|
||||
]
|
||||
await BillingService.invalidate_billing_cache(account_ids)
|
||||
|
||||
return _merge_plan_with_billing(plan, billing)
|
||||
|
||||
|
||||
@router.get("/account-overrides", response_model=list[AccountOverrideResponse])
|
||||
|
||||
@@ -689,6 +689,7 @@ async def get_escalation_queue(
|
||||
.where(
|
||||
scope_filter,
|
||||
AISession.status.in_(("requesting_escalation", "escalated")),
|
||||
AISession.user_id != current_user.id,
|
||||
)
|
||||
.order_by(AISession.created_at.desc())
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import secrets
|
||||
import string
|
||||
from datetime import datetime, timezone, timedelta
|
||||
@@ -41,11 +42,21 @@ from app.core.email import EmailService
|
||||
from app.api.deps import get_current_active_user, get_refresh_token_payload
|
||||
from app.core.audit import log_audit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
|
||||
|
||||
async def _store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
|
||||
"""Decode a refresh token JWT and store its hash in the database."""
|
||||
async def store_refresh_token(db: AsyncSession, refresh_token_str: str, user_id) -> None:
|
||||
"""Decode a refresh token JWT and store its hash in the database.
|
||||
|
||||
Module-public so OAuth callback endpoints (and any future token-issuing
|
||||
surface) can register the JTI in the ``refresh_tokens`` table the same
|
||||
way ``/auth/login`` does. Without this the first ``/auth/refresh`` call
|
||||
will reject the token as "revoked" because no row exists.
|
||||
|
||||
Caller is responsible for committing the session.
|
||||
"""
|
||||
payload = decode_token(refresh_token_str)
|
||||
if payload and payload.get("jti"):
|
||||
token_record = RefreshToken(
|
||||
@@ -62,6 +73,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,10 +135,24 @@ async def register(
|
||||
detail="Account invite code has expired"
|
||||
)
|
||||
|
||||
if account_invite_record.email.lower() != user_data.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={"error": "invite_email_mismatch"},
|
||||
)
|
||||
|
||||
# Validate platform invite code (skip if account invite was provided)
|
||||
invite_code_record = None
|
||||
if not account_invite_record:
|
||||
if settings.REQUIRE_INVITE_CODE and not user_data.invite_code:
|
||||
# When SELF_SERVE_ENABLED is on, the platform invite gate is bypassed
|
||||
# entirely — public self-serve signup is the whole point. The
|
||||
# invite_code field stays in the schema for backward compatibility
|
||||
# and so paid/trial-bearing codes still apply when supplied.
|
||||
if (
|
||||
settings.REQUIRE_INVITE_CODE
|
||||
and not settings.SELF_SERVE_ENABLED
|
||||
and not user_data.invite_code
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Invite code is required"
|
||||
@@ -195,26 +236,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 +269,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 +312,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,
|
||||
@@ -254,7 +328,7 @@ async def login(
|
||||
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# Store refresh token hash in DB
|
||||
await _store_refresh_token(db, refresh_token_str, user.id)
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
await db.commit()
|
||||
|
||||
return Token(
|
||||
@@ -276,6 +350,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,
|
||||
@@ -288,7 +363,7 @@ async def login_json(
|
||||
refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# Store refresh token hash in DB
|
||||
await _store_refresh_token(db, refresh_token_str, user.id)
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
await db.commit()
|
||||
|
||||
return Token(
|
||||
@@ -346,7 +421,7 @@ async def refresh_token(
|
||||
new_refresh_token_str = create_refresh_token(data={"sub": str(user.id)})
|
||||
|
||||
# Store new refresh token
|
||||
await _store_refresh_token(db, new_refresh_token_str, user.id)
|
||||
await store_refresh_token(db, new_refresh_token_str, user.id)
|
||||
await db.commit()
|
||||
|
||||
return Token(
|
||||
@@ -441,6 +516,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 +560,7 @@ async def forgot_password(
|
||||
result = await db.execute(select(User).where(User.email == data.email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user:
|
||||
if user and user.password_hash is not None:
|
||||
# Create reset token JWT
|
||||
raw_token = create_password_reset_token(str(user.id))
|
||||
payload = decode_token(raw_token)
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
"""Public beta signup endpoint — no auth required."""
|
||||
"""Legacy beta signup endpoint — redirects to /register?from=beta.
|
||||
|
||||
Phase 2 (self-serve signup) makes the public register flow the canonical
|
||||
front door. The old `/api/v1/beta-signup` POST endpoint is kept mounted to
|
||||
preserve any external links that still hit it, but now responds with a
|
||||
307 Temporary Redirect to `/register?from=beta` so the user lands in the
|
||||
real signup flow. The `?from=beta` marker lets the frontend tag the
|
||||
signup origin for analytics.
|
||||
|
||||
Note: there is no `beta_signup` database table — the original endpoint
|
||||
only fired a notification email. There is therefore no waitlist to email
|
||||
and no migration to run when retiring the endpoint.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from app.core.email import EmailService
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/beta-signup", tags=["beta"])
|
||||
|
||||
|
||||
class BetaSignupRequest(BaseModel):
|
||||
email: EmailStr
|
||||
# Local-dev fallback when FRONTEND_URL isn't configured. The redirect must
|
||||
# be absolute — a relative URL would resolve against the API origin
|
||||
# (api.resolutionflow.com), which has no /register page.
|
||||
_DEFAULT_FRONTEND_URL = "http://localhost:5173"
|
||||
|
||||
|
||||
class BetaSignupResponse(BaseModel):
|
||||
success: bool
|
||||
message: str
|
||||
@router.post("", include_in_schema=False)
|
||||
async def beta_signup_redirect() -> RedirectResponse:
|
||||
"""Redirect legacy beta-signup POST to the public register page.
|
||||
|
||||
|
||||
@router.post("", response_model=BetaSignupResponse)
|
||||
async def beta_signup(data: BetaSignupRequest):
|
||||
"""Collect beta interest — sends notification to beta@resolutionflow.com."""
|
||||
sent = await EmailService.send_beta_signup_notification(data.email)
|
||||
if not sent:
|
||||
logger.warning("Beta signup recorded (email delivery skipped): %s", data.email)
|
||||
return BetaSignupResponse(
|
||||
success=True,
|
||||
message="Thanks! We'll be in touch with beta access details.",
|
||||
Returns 307 so any client following the redirect preserves the HTTP
|
||||
method; the frontend treats `/register?from=beta` as the canonical
|
||||
entry point and reads the `from` query param for analytics.
|
||||
"""
|
||||
frontend_url = settings.FRONTEND_URL or _DEFAULT_FRONTEND_URL
|
||||
return RedirectResponse(
|
||||
url=f"{frontend_url}/register?from=beta",
|
||||
status_code=307,
|
||||
)
|
||||
|
||||
76
backend/app/api/endpoints/billing.py
Normal file
76
backend/app/api/endpoints/billing.py
Normal file
@@ -0,0 +1,76 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.config import settings
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
from app.schemas.billing import (
|
||||
BillingPortalSessionResponse,
|
||||
BillingStateResponse,
|
||||
CheckoutSessionCreate,
|
||||
CheckoutSessionResponse,
|
||||
)
|
||||
from app.services.billing import BillingService
|
||||
|
||||
router = APIRouter(prefix="/billing", tags=["billing"])
|
||||
|
||||
|
||||
@router.post("/checkout-session", response_model=CheckoutSessionResponse)
|
||||
async def create_checkout_session(
|
||||
payload: CheckoutSessionCreate,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> CheckoutSessionResponse:
|
||||
account = (await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)).scalar_one()
|
||||
url = await BillingService.create_checkout_session(
|
||||
db=db,
|
||||
account=account,
|
||||
plan=payload.plan,
|
||||
seats=payload.seats,
|
||||
billing_interval=payload.billing_interval,
|
||||
success_url=f"{settings.FRONTEND_URL}/account/billing?success=1",
|
||||
cancel_url=f"{settings.FRONTEND_URL}/account/billing/select-plan",
|
||||
)
|
||||
return CheckoutSessionResponse(url=url)
|
||||
|
||||
|
||||
@router.get("/state", response_model=BillingStateResponse)
|
||||
async def get_billing_state(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> BillingStateResponse:
|
||||
account = (await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)).scalar_one()
|
||||
state = await BillingService.get_billing_state(db, account)
|
||||
return BillingStateResponse(**state)
|
||||
|
||||
|
||||
@router.get("/portal-session", response_model=BillingPortalSessionResponse)
|
||||
async def get_billing_portal_session(
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> BillingPortalSessionResponse:
|
||||
"""Return a Stripe-hosted Customer Portal URL for the account so the user
|
||||
can update card / cancel. Allowlisted from the subscription + email-verify
|
||||
guards (a canceled or unverified-past-grace user must still be able to
|
||||
update billing)."""
|
||||
if not settings.stripe_enabled:
|
||||
raise HTTPException(status_code=503, detail={"error": "stripe_not_configured"})
|
||||
|
||||
account = (await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)).scalar_one()
|
||||
|
||||
try:
|
||||
url = await BillingService.open_customer_portal(account)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail={"error": "no_stripe_customer"})
|
||||
return BillingPortalSessionResponse(url=url)
|
||||
40
backend/app/api/endpoints/config.py
Normal file
40
backend/app/api/endpoints/config.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Public runtime configuration endpoint.
|
||||
|
||||
GET /api/v1/config/public
|
||||
Returns the small set of runtime flags the frontend needs at app load
|
||||
to decide whether to render the self-serve signup flow and which OAuth
|
||||
buttons to show. No authentication required.
|
||||
|
||||
The response model lives in `app.schemas.config` so it can be reused by
|
||||
frontend codegen and other call sites if needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas.config import PublicConfigResponse
|
||||
|
||||
router = APIRouter(prefix="/config", tags=["config"])
|
||||
|
||||
|
||||
@router.get("/public", response_model=PublicConfigResponse)
|
||||
async def get_public_config() -> PublicConfigResponse:
|
||||
"""Return public-safe runtime config.
|
||||
|
||||
`oauth_providers` reflects which OAuth client IDs are configured server
|
||||
side; the frontend uses it to render only buttons that will actually
|
||||
succeed. `self_serve_enabled` is the master switch for the new public
|
||||
self-serve signup flow.
|
||||
"""
|
||||
providers: list[str] = []
|
||||
if settings.GOOGLE_CLIENT_ID:
|
||||
providers.append("google")
|
||||
if settings.MS_CLIENT_ID:
|
||||
providers.append("microsoft")
|
||||
|
||||
return PublicConfigResponse(
|
||||
self_serve_enabled=settings.SELF_SERVE_ENABLED,
|
||||
oauth_providers=providers,
|
||||
)
|
||||
231
backend/app/api/endpoints/oauth.py
Normal file
231
backend/app/api/endpoints/oauth.py
Normal file
@@ -0,0 +1,231 @@
|
||||
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.api.endpoints.auth import store_refresh_token
|
||||
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.account_invite import AccountInvite
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
from app.models.user import User
|
||||
from app.schemas.oauth import OAuthCallbackPayload, OAuthCallbackResponse
|
||||
from app.services.billing import BillingService
|
||||
from app.services.oauth_providers import (
|
||||
google_exchange_code,
|
||||
microsoft_exchange_code,
|
||||
OAuthProfile,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth-oauth"])
|
||||
|
||||
|
||||
def _generate_display_code(length: int = 8) -> str:
|
||||
"""Match the helper used by /auth/register — A-Z + 0-9, length 8."""
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
async def _sign_in_or_register(
|
||||
db: AsyncSession,
|
||||
provider: str,
|
||||
profile: OAuthProfile,
|
||||
*,
|
||||
account_invite_code: str | None = None,
|
||||
invited_email: str | None = None,
|
||||
) -> tuple[User, bool]:
|
||||
"""Returns (user, is_new_user). Idempotent on (provider, provider_subject).
|
||||
|
||||
When ``account_invite_code`` is supplied (from the /accept-invite flow),
|
||||
a brand-new user is created inside the invited account instead of getting
|
||||
a personal account + Pro trial. Mismatch between the OAuth profile email
|
||||
and ``invited_email`` raises ``invite_email_mismatch`` per the spec
|
||||
contract that mirrors the email+password register path.
|
||||
"""
|
||||
identity = (
|
||||
await db.execute(
|
||||
select(OAuthIdentity).where(
|
||||
OAuthIdentity.provider == provider,
|
||||
OAuthIdentity.provider_subject == profile.provider_subject,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if identity:
|
||||
user = (
|
||||
await db.execute(select(User).where(User.id == identity.user_id))
|
||||
).scalar_one()
|
||||
return user, False
|
||||
|
||||
user = (
|
||||
await db.execute(select(User).where(User.email == profile.email))
|
||||
).scalar_one_or_none()
|
||||
is_new_user = user is None
|
||||
|
||||
# If the user arrived via an invite link but already has a ResolutionFlow
|
||||
# account (e.g., previously signed up with email+password), silently
|
||||
# linking the OAuth identity to that existing account would bypass the
|
||||
# invite — they'd stay in their personal account and the invite would
|
||||
# never be consumed. Fail loud instead so they can sign in and accept the
|
||||
# invite from the dashboard. The "invited user wants to transfer accounts"
|
||||
# case is a v2 concern.
|
||||
if account_invite_code and not is_new_user:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": "email_already_registered_use_login",
|
||||
"message": (
|
||||
"An account already exists for this email. Please sign in "
|
||||
"instead, then accept the invite from your dashboard."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
invite_record: AccountInvite | None = None
|
||||
if is_new_user and account_invite_code:
|
||||
# SELECT FOR UPDATE so two concurrent OAuth callbacks can't both
|
||||
# consume the same invite code.
|
||||
invite_record = (
|
||||
await db.execute(
|
||||
select(AccountInvite)
|
||||
.where(AccountInvite.code == account_invite_code)
|
||||
.with_for_update()
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if invite_record is None or not invite_record.is_valid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invite_invalid_or_expired_or_revoked"},
|
||||
)
|
||||
# Verify the OAuth profile email matches what was invited. We compare
|
||||
# against the invite row directly (source of truth), but also accept
|
||||
# the client-supplied invited_email as a defensive equality check.
|
||||
if invite_record.email.lower() != profile.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invite_email_mismatch"},
|
||||
)
|
||||
if invited_email and invited_email.lower() != invite_record.email.lower():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "invite_email_mismatch"},
|
||||
)
|
||||
|
||||
if is_new_user:
|
||||
if invite_record is not None:
|
||||
# Join the invited account directly — no personal account, no
|
||||
# trial creation.
|
||||
user = User(
|
||||
email=profile.email,
|
||||
name=profile.name,
|
||||
password_hash=None,
|
||||
account_id=invite_record.account_id,
|
||||
account_role=invite_record.role,
|
||||
role="engineer",
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
invite_record.accepted_by_id = user.id
|
||||
invite_record.used_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
else:
|
||||
account = Account(
|
||||
name=f"{profile.name}'s Account",
|
||||
display_code=_generate_display_code(),
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
user = User(
|
||||
email=profile.email,
|
||||
name=profile.name,
|
||||
password_hash=None,
|
||||
account_id=account.id,
|
||||
account_role="owner",
|
||||
role="engineer",
|
||||
email_verified_at=datetime.now(timezone.utc),
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
account.owner_id = user.id
|
||||
await db.flush()
|
||||
# start_trial commits internally; flushed account/user above.
|
||||
await BillingService.start_trial(db, account.id)
|
||||
|
||||
db.add(
|
||||
OAuthIdentity(
|
||||
user_id=user.id,
|
||||
provider=provider,
|
||||
provider_subject=profile.provider_subject,
|
||||
provider_email_at_link=profile.email,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return user, is_new_user
|
||||
|
||||
|
||||
@router.post("/google/callback", response_model=OAuthCallbackResponse)
|
||||
async def google_callback(
|
||||
payload: OAuthCallbackPayload,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> OAuthCallbackResponse:
|
||||
if not settings.GOOGLE_CLIENT_ID:
|
||||
raise HTTPException(status_code=503, detail="Google sign-in not configured")
|
||||
redirect_uri = f"{settings.OAUTH_REDIRECT_BASE}/auth/google/callback"
|
||||
profile = await google_exchange_code(payload.code, redirect_uri)
|
||||
user, is_new = await _sign_in_or_register(
|
||||
db,
|
||||
"google",
|
||||
profile,
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
refresh_token_str = create_refresh_token({"sub": str(user.id)})
|
||||
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
|
||||
# reject this token as "revoked" (the rotation logic requires a row to
|
||||
# mark as used). _sign_in_or_register already committed; this needs a
|
||||
# second commit.
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
await db.commit()
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=refresh_token_str,
|
||||
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,
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
refresh_token_str = create_refresh_token({"sub": str(user.id)})
|
||||
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
|
||||
# reject this token as "revoked" (the rotation logic requires a row to
|
||||
# mark as used). _sign_in_or_register already committed; this needs a
|
||||
# second commit.
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
await db.commit()
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=refresh_token_str,
|
||||
is_new_user=is_new,
|
||||
)
|
||||
@@ -2,19 +2,24 @@
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.database import get_db
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.models.account import Account
|
||||
from app.models.assistant_chat import AssistantChat
|
||||
from app.models.psa_connection import PsaConnection
|
||||
from app.models.session import Session
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.schemas.onboarding import OnboardingStatus
|
||||
from app.schemas.onboarding import (
|
||||
OnboardingStatus,
|
||||
OnboardingStepRequest,
|
||||
OnboardingStepResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["onboarding"])
|
||||
|
||||
@@ -85,6 +90,10 @@ async def get_onboarding_status(
|
||||
)
|
||||
connected_psa = (psa_q.scalar() or 0) > 0
|
||||
|
||||
# New (Phase 2 — Task 41)
|
||||
email_verified = current_user.email_verified_at is not None
|
||||
shop_setup_done = (current_user.onboarding_step_completed or 0) >= 1
|
||||
|
||||
return OnboardingStatus(
|
||||
created_flow=created_flow,
|
||||
ran_session=ran_session,
|
||||
@@ -94,6 +103,8 @@ async def get_onboarding_status(
|
||||
connected_psa=connected_psa,
|
||||
is_team_user=is_team_user,
|
||||
dismissed=current_user.onboarding_dismissed,
|
||||
email_verified=email_verified,
|
||||
shop_setup_done=shop_setup_done,
|
||||
)
|
||||
|
||||
|
||||
@@ -109,3 +120,98 @@ async def dismiss_onboarding(
|
||||
|
||||
# Return updated status (reuse the GET logic)
|
||||
return await get_onboarding_status(db=db, current_user=current_user)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Welcome wizard endpoints (Phase 2)
|
||||
#
|
||||
# These persist Step 1/2/3 progress for the post-signup welcome wizard.
|
||||
# Mounted on /users/me/* (the parent router prefix is /users) so the wizard
|
||||
# can run before email verification and during trial.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.patch("/me/onboarding-step", response_model=OnboardingStepResponse)
|
||||
async def patch_onboarding_step(
|
||||
body: OnboardingStepRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> OnboardingStepResponse:
|
||||
"""Persist welcome-wizard progress for the current user.
|
||||
|
||||
Contract:
|
||||
- step=1 + complete writes accounts.name, accounts.team_size_bucket,
|
||||
users.role_at_signup, then sets users.onboarding_step_completed=1.
|
||||
- step=2 + complete writes accounts.primary_psa, then sets
|
||||
users.onboarding_step_completed=2.
|
||||
- step=3 + complete just sets users.onboarding_step_completed=3
|
||||
(invites are POSTed separately).
|
||||
- action="skip" ignores `data` entirely and only advances the step.
|
||||
- The new step must be >= current onboarding_step_completed (None=>0);
|
||||
otherwise 400. Idempotent re-PATCH of the same step succeeds.
|
||||
"""
|
||||
current_step = current_user.onboarding_step_completed or 0
|
||||
if body.step < current_step:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"error": "step_cannot_decrease",
|
||||
"current_step": current_step,
|
||||
"requested_step": body.step,
|
||||
},
|
||||
)
|
||||
|
||||
if body.action == "complete" and body.data is not None and body.step in (1, 2):
|
||||
# Load the user's account for field writes. Step 3 has no data writes.
|
||||
account_result = await db.execute(
|
||||
select(Account).where(Account.id == current_user.account_id)
|
||||
)
|
||||
account = account_result.scalar_one_or_none()
|
||||
if account is None:
|
||||
# Should never happen — user is required to have an account_id.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="account_not_found",
|
||||
)
|
||||
|
||||
if body.step == 1:
|
||||
data = body.data
|
||||
if data.company_name is not None:
|
||||
account.name = data.company_name
|
||||
if data.team_size_bucket is not None:
|
||||
account.team_size_bucket = data.team_size_bucket
|
||||
if data.role_at_signup is not None:
|
||||
current_user.role_at_signup = data.role_at_signup
|
||||
elif body.step == 2:
|
||||
data = body.data
|
||||
if data.primary_psa is not None:
|
||||
account.primary_psa = data.primary_psa
|
||||
|
||||
current_user.onboarding_step_completed = body.step
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return OnboardingStepResponse(
|
||||
onboarding_step_completed=current_user.onboarding_step_completed,
|
||||
onboarding_dismissed=current_user.onboarding_dismissed,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/me/onboarding-dismiss-rest", response_model=OnboardingStepResponse)
|
||||
async def dismiss_onboarding_rest(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> OnboardingStepResponse:
|
||||
"""Set users.onboarding_dismissed=TRUE — backs the wizard's "Skip the rest" button.
|
||||
|
||||
Returns the same shape as the step PATCH so the frontend can update its
|
||||
local store from a single response.
|
||||
"""
|
||||
current_user.onboarding_dismissed = True
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
return OnboardingStepResponse(
|
||||
onboarding_step_completed=current_user.onboarding_step_completed,
|
||||
onboarding_dismissed=current_user.onboarding_dismissed,
|
||||
)
|
||||
|
||||
58
backend/app/api/endpoints/plans_public.py
Normal file
58
backend/app/api/endpoints/plans_public.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Public plans endpoint — no auth required.
|
||||
|
||||
GET /api/v1/plans/public
|
||||
Returns the public-safe view of `plan_billing` joined with
|
||||
`plan_limits.max_users` (exposed as `max_seats`), filtered to
|
||||
`is_public=True AND is_archived=False`, ordered by sort_order ASC, plan ASC.
|
||||
|
||||
Distinct from `/admin/plan-limits` (admin-only, returns ALL plans including
|
||||
archived/internal). This endpoint exists to power the marketing /pricing page
|
||||
without exposing the rest of the admin-only billing surface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.models.plan_billing import PlanBilling
|
||||
from app.models.plan_limits import PlanLimits
|
||||
from app.schemas.billing import PublicPlanResponse
|
||||
|
||||
router = APIRouter(prefix="/plans", tags=["plans"])
|
||||
|
||||
|
||||
@router.get("/public", response_model=list[PublicPlanResponse])
|
||||
async def list_public_plans(
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> list[PublicPlanResponse]:
|
||||
"""List public, non-archived plans for the marketing /pricing page.
|
||||
|
||||
Public — no auth. Uses `get_admin_db` because this is a cross-tenant read
|
||||
of the global plan catalog (same pattern as `/config/public`).
|
||||
"""
|
||||
stmt = (
|
||||
select(PlanBilling, PlanLimits.max_users)
|
||||
.outerjoin(PlanLimits, PlanBilling.plan == PlanLimits.plan)
|
||||
.where(PlanBilling.is_public.is_(True))
|
||||
.where(PlanBilling.is_archived.is_(False))
|
||||
.order_by(PlanBilling.sort_order.asc(), PlanBilling.plan.asc())
|
||||
)
|
||||
rows = (await db.execute(stmt)).all()
|
||||
return [
|
||||
PublicPlanResponse(
|
||||
plan=billing.plan,
|
||||
display_name=billing.display_name,
|
||||
description=billing.description,
|
||||
monthly_price_cents=billing.monthly_price_cents,
|
||||
annual_price_cents=billing.annual_price_cents,
|
||||
max_seats=max_users,
|
||||
sort_order=billing.sort_order,
|
||||
is_public=billing.is_public,
|
||||
)
|
||||
for billing, max_users in rows
|
||||
]
|
||||
114
backend/app/api/endpoints/sales_leads.py
Normal file
114
backend/app/api/endpoints/sales_leads.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Public Talk-to-Sales endpoint — no auth required.
|
||||
|
||||
POST /api/v1/sales-leads
|
||||
- Inserts a sales_leads row.
|
||||
- Fires (best-effort) a notification email to settings.SALES_LEAD_RECIPIENT_EMAIL.
|
||||
- Emits a server-side PostHog event (best-effort).
|
||||
- Rate-limited per IP (5/hour).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.config import settings
|
||||
from app.core.email import EmailService
|
||||
from app.core.rate_limit import limiter
|
||||
from app.models.sales_lead import SalesLead
|
||||
from app.schemas.sales_lead import SalesLeadCreate, SalesLeadCreateResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/sales-leads", tags=["sales"])
|
||||
|
||||
|
||||
async def _send_notification_email(lead: SalesLead) -> None:
|
||||
"""Fire-and-forget wrapper. EmailService methods never raise, but we
|
||||
still wrap in a try/except to defend against future regressions."""
|
||||
try:
|
||||
await EmailService.send_sales_lead_notification(
|
||||
to_email=settings.SALES_LEAD_RECIPIENT_EMAIL,
|
||||
lead=lead,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Sales lead notification email failed for lead %s",
|
||||
lead.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _capture_posthog_event(lead: SalesLead) -> None:
|
||||
"""Emit `talk_to_sales_form_submitted` server-side. Best-effort.
|
||||
|
||||
Backend PostHog SDK isn't initialized in the project today; this function
|
||||
is the single instrumentation point so wiring it up later is a one-line
|
||||
change. The call is wrapped so any future failure can never fail the
|
||||
request.
|
||||
"""
|
||||
try:
|
||||
# Lazy import — keeps the dependency optional. When the backend
|
||||
# PostHog client is wired in (likely as `app.core.analytics.posthog`),
|
||||
# swap the import path here and the event will fire automatically.
|
||||
try:
|
||||
from app.core.analytics import posthog # type: ignore[attr-defined]
|
||||
except ImportError:
|
||||
logger.debug(
|
||||
"PostHog server-side capture skipped — client not configured"
|
||||
)
|
||||
return
|
||||
|
||||
distinct_id = lead.posthog_distinct_id or f"sales_lead:{lead.id}"
|
||||
posthog.capture(
|
||||
distinct_id=distinct_id,
|
||||
event="talk_to_sales_form_submitted",
|
||||
properties={
|
||||
"source": lead.source,
|
||||
"company": lead.company,
|
||||
"team_size": lead.team_size,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"PostHog capture failed for sales lead %s",
|
||||
lead.id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=SalesLeadCreateResponse, status_code=201)
|
||||
@limiter.limit("5/hour")
|
||||
async def create_sales_lead(
|
||||
request: Request,
|
||||
data: SalesLeadCreate,
|
||||
db: Annotated[AsyncSession, Depends(get_admin_db)],
|
||||
) -> SalesLeadCreateResponse:
|
||||
"""Public Talk-to-Sales submission.
|
||||
|
||||
Creates a sales_leads row, fires (best-effort) a notification email and a
|
||||
server-side PostHog event. Rate-limited per IP at 5/hour.
|
||||
"""
|
||||
lead = SalesLead(
|
||||
email=str(data.email).lower(),
|
||||
name=data.name,
|
||||
company=data.company,
|
||||
team_size=data.team_size,
|
||||
message=data.message,
|
||||
source=data.source,
|
||||
posthog_distinct_id=data.posthog_distinct_id,
|
||||
)
|
||||
db.add(lead)
|
||||
await db.commit()
|
||||
await db.refresh(lead)
|
||||
|
||||
# Fire-and-forget: email + analytics. Failures must not fail the request.
|
||||
asyncio.create_task(_send_notification_email(lead))
|
||||
_capture_posthog_event(lead)
|
||||
|
||||
return SalesLeadCreateResponse(id=lead.id, status="received")
|
||||
@@ -90,7 +90,9 @@ async def create_handoff(
|
||||
enrich_escalation_async, handoff.id, current_user.id
|
||||
)
|
||||
|
||||
return HandoffResponse.model_validate(handoff)
|
||||
return HandoffResponse.model_validate(handoff).model_copy(
|
||||
update={"handed_off_by_name": current_user.name}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/handoffs", response_model=list[HandoffResponse])
|
||||
@@ -142,11 +144,20 @@ async def claim_handoff(
|
||||
"claimed_at": e.claimed_at.isoformat(),
|
||||
},
|
||||
)
|
||||
except PermissionError as e:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
await db.commit()
|
||||
return HandoffResponse.model_validate(handoff)
|
||||
handed_off_by_name = (
|
||||
handoff.handed_off_by_user.name
|
||||
if handoff.handed_off_by_user
|
||||
else None
|
||||
)
|
||||
return HandoffResponse.model_validate(handoff).model_copy(
|
||||
update={"handed_off_by_name": handed_off_by_name}
|
||||
)
|
||||
|
||||
|
||||
@queue_router.get("/queue")
|
||||
|
||||
@@ -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,10 +23,13 @@ from app.api.endpoints import (
|
||||
analytics,
|
||||
assistant_chat,
|
||||
auth,
|
||||
billing,
|
||||
beta_feedback,
|
||||
beta_signup,
|
||||
sales_leads,
|
||||
branding,
|
||||
categories,
|
||||
config as config_endpoints,
|
||||
copilot,
|
||||
device_types,
|
||||
draft_templates,
|
||||
@@ -36,7 +43,9 @@ from app.api.endpoints import (
|
||||
maintenance_schedules,
|
||||
network_diagrams,
|
||||
notifications,
|
||||
oauth as oauth_endpoints,
|
||||
onboarding,
|
||||
plans_public,
|
||||
public_templates,
|
||||
ratings,
|
||||
scripts,
|
||||
@@ -62,6 +71,7 @@ from app.api.endpoints import (
|
||||
uploads,
|
||||
webhooks,
|
||||
accounts,
|
||||
account_invite_lookup,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
@@ -77,12 +87,18 @@ api_router = APIRouter()
|
||||
# in Phase 1. This will need revisiting in Phase 2 when `users` gets RLS.
|
||||
# ---------------------------------------------------------------------------
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(oauth_endpoints.router)
|
||||
api_router.include_router(billing.router) # Reachable when subscription locked
|
||||
api_router.include_router(shared.router) # Public share links (no auth)
|
||||
api_router.include_router(shares.public_router) # Public session share links (optional auth)
|
||||
api_router.include_router(beta_signup.router)
|
||||
api_router.include_router(sales_leads.router) # Talk-to-Sales (no auth, rate-limited)
|
||||
api_router.include_router(webhooks.router) # Stripe webhook receiver
|
||||
api_router.include_router(public_templates.router) # Public gallery (no auth, rate-limited)
|
||||
api_router.include_router(survey.router) # Public survey flow (no auth, rate-limited)
|
||||
api_router.include_router(config_endpoints.router) # Public runtime feature flags
|
||||
api_router.include_router(account_invite_lookup.router) # Public invite-code lookup for /accept-invite
|
||||
api_router.include_router(plans_public.router) # Public plan catalog for /pricing page
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Admin endpoints — super_admin only
|
||||
@@ -102,23 +118,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 +155,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)
|
||||
|
||||
@@ -84,6 +84,7 @@ class Settings(BaseSettings):
|
||||
RESEND_API_KEY: Optional[str] = None
|
||||
FROM_EMAIL: str = "ResolutionFlow <invites@resolutionflow.com>"
|
||||
FEEDBACK_EMAIL: Optional[str] = None
|
||||
SALES_LEAD_RECIPIENT_EMAIL: str = "sales@resolutionflow.com"
|
||||
|
||||
@property
|
||||
def email_enabled(self) -> bool:
|
||||
@@ -94,11 +95,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 +195,13 @@ class Settings(BaseSettings):
|
||||
"""Check if ConnectWise integration is configured."""
|
||||
return self.CW_CLIENT_ID is not None
|
||||
|
||||
# OAuth providers (self-serve signup)
|
||||
GOOGLE_CLIENT_ID: Optional[str] = None
|
||||
GOOGLE_CLIENT_SECRET: Optional[str] = None
|
||||
MS_CLIENT_ID: Optional[str] = None
|
||||
MS_CLIENT_SECRET: Optional[str] = None
|
||||
OAUTH_REDIRECT_BASE: str = "http://localhost:5173"
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN: Optional[str] = None
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.sales_lead import SalesLead
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -484,6 +489,99 @@ class EmailService:
|
||||
logger.exception("Failed to send beta signup notification for %s", signup_email)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def send_sales_lead_notification(
|
||||
to_email: str,
|
||||
lead: "SalesLead",
|
||||
) -> bool:
|
||||
"""Notify the sales recipient about a new Talk-to-Sales submission.
|
||||
|
||||
Fire-and-forget. Returns False (and logs) on any failure; never raises.
|
||||
"""
|
||||
if not settings.email_enabled:
|
||||
logger.warning(
|
||||
"Sales lead email not sent — RESEND_API_KEY not configured (lead %s)",
|
||||
lead.id,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
import resend
|
||||
import html as html_mod
|
||||
from datetime import datetime, timezone
|
||||
|
||||
resend.api_key = settings.RESEND_API_KEY
|
||||
|
||||
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||
safe_email = html_mod.escape(lead.email)
|
||||
safe_name = html_mod.escape(lead.name)
|
||||
safe_company = html_mod.escape(lead.company)
|
||||
safe_team_size = html_mod.escape(lead.team_size or "—")
|
||||
safe_source = html_mod.escape(lead.source)
|
||||
safe_message = html_mod.escape(lead.message or "(no message)")
|
||||
subject = f"[ResolutionFlow Sales] New lead — {safe_company} ({safe_email})"
|
||||
|
||||
email_html = f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
|
||||
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
|
||||
<tr><td style="padding:40px 40px 24px;text-align:center;">
|
||||
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
|
||||
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">New Sales Lead</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 16px;">
|
||||
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
|
||||
Source: <strong style="color:#f8fafc;">{safe_source}</strong>
|
||||
</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 16px;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.06);border-radius:12px;">
|
||||
<tr><td style="padding:16px;">
|
||||
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Name</p>
|
||||
<p style="margin:0 0 12px;color:#f8fafc;font-size:16px;font-weight:600;">{safe_name}</p>
|
||||
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Email</p>
|
||||
<p style="margin:0 0 12px;color:#22d3ee;font-size:16px;font-weight:600;">{safe_email}</p>
|
||||
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Company</p>
|
||||
<p style="margin:0 0 12px;color:#f8fafc;font-size:16px;font-weight:600;">{safe_company}</p>
|
||||
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Team Size</p>
|
||||
<p style="margin:0;color:#f8fafc;font-size:16px;font-weight:600;">{safe_team_size}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 16px;">
|
||||
<p style="margin:0 0 4px;color:#5a6170;font-size:12px;text-transform:uppercase;letter-spacing:1px;">Message</p>
|
||||
<p style="margin:0;color:#8891a0;font-size:14px;line-height:1.6;white-space:pre-wrap;">{safe_message}</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 32px;">
|
||||
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
|
||||
Submitted at {date_str} · Lead ID: {lead.id}
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>"""
|
||||
|
||||
resend.Emails.send({
|
||||
"from": settings.FROM_EMAIL,
|
||||
"to": [to_email],
|
||||
"reply_to": lead.email,
|
||||
"subject": subject,
|
||||
"html": email_html,
|
||||
})
|
||||
logger.info("Sales lead notification sent for %s (lead %s)", lead.email, lead.id)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to send sales lead notification for %s (lead %s)",
|
||||
lead.email,
|
||||
lead.id,
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def send_notification_email(
|
||||
to_email: str,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -172,6 +172,21 @@ class PlanLimitResponse(BaseModel):
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class PlanLimitWithBillingResponse(PlanLimitResponse):
|
||||
"""PlanLimits + plan_billing fields merged. Billing fields are None when no
|
||||
plan_billing row exists for the plan yet."""
|
||||
display_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
monthly_price_cents: Optional[int] = None
|
||||
annual_price_cents: Optional[int] = None
|
||||
stripe_product_id: Optional[str] = None
|
||||
stripe_monthly_price_id: Optional[str] = None
|
||||
stripe_annual_price_id: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
is_archived: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class PlanLimitUpdate(BaseModel):
|
||||
plan: str
|
||||
max_trees: Optional[int] = None
|
||||
@@ -180,6 +195,19 @@ class PlanLimitUpdate(BaseModel):
|
||||
custom_branding: bool = False
|
||||
priority_support: bool = False
|
||||
export_formats: list = Field(default_factory=lambda: ["markdown", "text"])
|
||||
# plan_billing fields — all optional, partial-update semantics. If any are
|
||||
# set in the body, the admin endpoint upserts the plan_billing row in the
|
||||
# same transaction.
|
||||
display_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
monthly_price_cents: Optional[int] = None
|
||||
annual_price_cents: Optional[int] = None
|
||||
stripe_product_id: Optional[str] = None
|
||||
stripe_monthly_price_id: Optional[str] = None
|
||||
stripe_annual_price_id: Optional[str] = None
|
||||
is_public: Optional[bool] = None
|
||||
is_archived: Optional[bool] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class AccountOverrideCreate(BaseModel):
|
||||
|
||||
64
backend/app/schemas/billing.py
Normal file
64
backend/app/schemas/billing.py
Normal file
@@ -0,0 +1,64 @@
|
||||
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 BillingPortalSessionResponse(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]
|
||||
|
||||
|
||||
class PublicPlanResponse(BaseModel):
|
||||
"""Public-safe view of a billable plan, used by the marketing /pricing page.
|
||||
|
||||
Sourced from `plan_billing` joined with `plan_limits.max_users` (exposed
|
||||
here as `max_seats`). Always filtered server-side to is_public=True and
|
||||
is_archived=False, so `is_public` is a constant True for any row returned
|
||||
here — included for clarity and forward compatibility.
|
||||
"""
|
||||
plan: str
|
||||
display_name: str
|
||||
description: Optional[str] = None
|
||||
monthly_price_cents: Optional[int] = None
|
||||
annual_price_cents: Optional[int] = None
|
||||
max_seats: Optional[int] = None
|
||||
sort_order: int
|
||||
is_public: bool = True
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
18
backend/app/schemas/config.py
Normal file
18
backend/app/schemas/config.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Pydantic schemas for public runtime configuration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class PublicConfigResponse(BaseModel):
|
||||
"""Runtime feature flags + OAuth provider list exposed to anonymous clients.
|
||||
|
||||
Read once by the frontend at app load to decide whether to render the
|
||||
self-serve signup flow and which OAuth buttons to show.
|
||||
"""
|
||||
|
||||
self_serve_enabled: bool
|
||||
oauth_providers: List[str]
|
||||
32
backend/app/schemas/oauth.py
Normal file
32
backend/app/schemas/oauth.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OAuthCallbackPayload(BaseModel):
|
||||
code: str
|
||||
state: str | None = None
|
||||
# When the OAuth flow originated from /accept-invite, the frontend round-trips
|
||||
# the invite code + invited email so the backend can link the new user to the
|
||||
# invited account instead of creating a personal one.
|
||||
account_invite_code: str | None = None
|
||||
invited_email: str | None = None
|
||||
|
||||
|
||||
class OAuthCallbackResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
is_new_user: bool
|
||||
|
||||
|
||||
class InviteLookupResponse(BaseModel):
|
||||
"""Public response surface for GET /accounts/invites/{code}/lookup.
|
||||
|
||||
Returns the minimum context needed for the AcceptInvitePage:
|
||||
account name (so we can title the card), inviter name (for the resend
|
||||
fallback message), invited email (locked into the form), and role.
|
||||
"""
|
||||
|
||||
account_name: str
|
||||
inviter_name: str
|
||||
invited_email: str
|
||||
role: str
|
||||
@@ -1,12 +1,55 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class OnboardingStatus(BaseModel):
|
||||
created_flow: bool
|
||||
ran_session: bool
|
||||
exported_session: bool
|
||||
# Kept for backward-compat during deploy; new code paths should not branch on this.
|
||||
tried_ai_assistant: bool
|
||||
invited_teammate: bool
|
||||
connected_psa: bool
|
||||
is_team_user: bool
|
||||
dismissed: bool
|
||||
# New (Phase 2 — Task 41) — drive the unified next-step card + checklist.
|
||||
email_verified: bool
|
||||
shop_setup_done: bool
|
||||
|
||||
|
||||
# --- Welcome wizard (Phase 2) ----------------------------------------------
|
||||
|
||||
|
||||
TeamSizeBucket = Literal["1-2", "3-5", "6-10", "11-25", "26+"]
|
||||
RoleAtSignup = Literal["owner", "lead_tech", "tech", "other"]
|
||||
PrimaryPsa = Literal["connectwise", "autotask", "halopsa", "none"]
|
||||
WizardStep = Literal[1, 2, 3]
|
||||
WizardAction = Literal["complete", "skip"]
|
||||
|
||||
|
||||
class OnboardingStepData(BaseModel):
|
||||
"""Optional payload carried with `action="complete"` for steps 1 and 2.
|
||||
|
||||
Step 1 fields: company_name, team_size_bucket, role_at_signup
|
||||
Step 2 fields: primary_psa
|
||||
Step 3 has no data (invitations posted separately).
|
||||
"""
|
||||
|
||||
# Step 1
|
||||
company_name: Optional[str] = Field(default=None, max_length=255)
|
||||
team_size_bucket: Optional[TeamSizeBucket] = None
|
||||
role_at_signup: Optional[RoleAtSignup] = None
|
||||
# Step 2
|
||||
primary_psa: Optional[PrimaryPsa] = None
|
||||
|
||||
|
||||
class OnboardingStepRequest(BaseModel):
|
||||
step: WizardStep
|
||||
action: WizardAction
|
||||
data: Optional[OnboardingStepData] = None
|
||||
|
||||
|
||||
class OnboardingStepResponse(BaseModel):
|
||||
onboarding_step_completed: Optional[int]
|
||||
onboarding_dismissed: bool
|
||||
|
||||
27
backend/app/schemas/sales_lead.py
Normal file
27
backend/app/schemas/sales_lead.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Pydantic schemas for Talk-to-Sales submissions."""
|
||||
|
||||
from typing import Literal, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
SalesLeadSource = Literal["pricing_page", "register_footer", "landing_page"]
|
||||
|
||||
|
||||
class SalesLeadCreate(BaseModel):
|
||||
"""Public Talk-to-Sales form submission."""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
email: EmailStr
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
company: str = Field(..., min_length=1, max_length=255)
|
||||
team_size: Optional[str] = Field(default=None, max_length=20)
|
||||
message: Optional[str] = Field(default=None, max_length=5000)
|
||||
source: SalesLeadSource
|
||||
posthog_distinct_id: Optional[str] = Field(default=None, max_length=255)
|
||||
|
||||
|
||||
class SalesLeadCreateResponse(BaseModel):
|
||||
id: UUID
|
||||
status: Literal["received"] = "received"
|
||||
@@ -21,6 +21,7 @@ class HandoffResponse(BaseModel):
|
||||
id: UUID
|
||||
session_id: UUID
|
||||
handed_off_by: UUID
|
||||
handed_off_by_name: str | None = None
|
||||
intent: str
|
||||
source_branch_id: UUID | None
|
||||
snapshot: dict[str, Any]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -58,6 +58,8 @@ class UserResponse(UserBase):
|
||||
timezone: str = "UTC"
|
||||
avatar_url: Optional[str] = None
|
||||
email_verified_at: Optional[datetime] = None
|
||||
onboarding_step_completed: Optional[int] = None
|
||||
onboarding_dismissed: bool = False
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
356
backend/app/services/billing.py
Normal file
356
backend/app/services/billing.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""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."""
|
||||
import logging
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BillingService:
|
||||
@staticmethod
|
||||
async def invalidate_billing_cache(account_ids) -> None:
|
||||
"""No-op stub for future in-process billing cache invalidation.
|
||||
|
||||
Today there is no `app.state.billing_cache` — `BillingService.get_billing_state`
|
||||
always reads fresh from the DB. Call sites that mutate plan/feature data
|
||||
invoke this hook so that wiring is in place when an in-process cache is
|
||||
added later. Until then, this just logs.
|
||||
|
||||
TODO: when an in-process billing cache (e.g. `app.state.billing_cache`)
|
||||
is introduced, evict entries for the given account_ids here.
|
||||
"""
|
||||
try:
|
||||
count = len(list(account_ids))
|
||||
except TypeError:
|
||||
count = -1
|
||||
logger.debug(
|
||||
"BillingService.invalidate_billing_cache called for %d account(s) "
|
||||
"(no-op stub — wire to app.state.billing_cache when added)",
|
||||
count,
|
||||
)
|
||||
|
||||
@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 open_customer_portal(account: Account) -> str:
|
||||
"""Create a Stripe-hosted Customer Portal session and return the URL.
|
||||
|
||||
Raises RuntimeError if Stripe isn't configured (endpoint maps to 503).
|
||||
Raises ValueError if the account has no stripe_customer_id yet — the
|
||||
user must complete a checkout first (endpoint maps to 400).
|
||||
"""
|
||||
if not settings.stripe_enabled:
|
||||
raise RuntimeError("Stripe not configured")
|
||||
if account.stripe_customer_id is None:
|
||||
raise ValueError("no_stripe_customer")
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
session = stripe.billing_portal.Session.create(
|
||||
customer=account.stripe_customer_id,
|
||||
return_url=f"{settings.FRONTEND_URL}/account/billing",
|
||||
)
|
||||
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.
|
||||
|
||||
Atomic: the StripeEvent idempotency mark and the handler's state
|
||||
mutations are committed in a single transaction. If the handler raises
|
||||
the entire transaction (idempotency mark + partial mutations) is rolled
|
||||
back, so a Stripe retry will re-run the handler. Without this, a
|
||||
handler that fails mid-flight would leave the StripeEvent row persisted
|
||||
and silently desync subscription state from Stripe.
|
||||
"""
|
||||
db.add(StripeEvent(
|
||||
id=event_id,
|
||||
event_type=event_type,
|
||||
payload_excerpt=_excerpt(payload),
|
||||
))
|
||||
try:
|
||||
await db.flush()
|
||||
except IntegrityError:
|
||||
# Duplicate event_id — already processed (or in flight). Ack with False.
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
try:
|
||||
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)
|
||||
await db.commit()
|
||||
except Exception:
|
||||
# Roll back the StripeEvent insert + any partial handler mutations
|
||||
# so Stripe's retry can re-run cleanly.
|
||||
await db.rollback()
|
||||
raise
|
||||
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
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
|
||||
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"]
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
|
||||
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"
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
|
||||
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"
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
|
||||
|
||||
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"
|
||||
# No commit — apply_subscription_event commits once for the full event.
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -913,6 +913,41 @@ async def generate_status_update(
|
||||
"""Generate a status update for ticket notes, client communication, or email draft."""
|
||||
session = await _load_session(session_id, user_id, db)
|
||||
|
||||
# For escalation/ticket_notes, return the pre-generated handoff prose immediately
|
||||
# if enrich_escalation_async has already populated it. This eliminates the
|
||||
# redundant Sonnet re-summarization on every "Ticket Notes" click.
|
||||
if request.context == "escalation" and request.audience == "ticket_notes":
|
||||
from app.models.session_handoff import SessionHandoff
|
||||
|
||||
handoff_q = await db.execute(
|
||||
select(SessionHandoff)
|
||||
.where(
|
||||
SessionHandoff.session_id == session_id,
|
||||
SessionHandoff.intent == "escalate",
|
||||
)
|
||||
.order_by(SessionHandoff.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
escalation_handoff = handoff_q.scalar_one_or_none()
|
||||
saved_data = (
|
||||
escalation_handoff.ai_assessment_data or {}
|
||||
) if escalation_handoff else {}
|
||||
prose = saved_data.get("summary_prose") or (
|
||||
escalation_handoff.ai_assessment if escalation_handoff else None
|
||||
)
|
||||
if prose:
|
||||
return StatusUpdateResponse(
|
||||
content=prose,
|
||||
audience=request.audience,
|
||||
length=request.length,
|
||||
context=request.context,
|
||||
session_status=session.status,
|
||||
steps_completed=session.step_count or 0,
|
||||
time_spent_display=None,
|
||||
client_name=None,
|
||||
generated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Build conversation summary from session steps
|
||||
steps_summary = []
|
||||
for step in sorted(session.steps, key=lambda s: s.step_order):
|
||||
|
||||
@@ -14,15 +14,17 @@ on top of per-user emails. The `/escalate` endpoint is now a thin shim
|
||||
calling these in sequence.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.core.ai_provider import get_ai_provider
|
||||
from app.core.config import settings
|
||||
from app.core.email import EmailService
|
||||
from app.core.escalation_bus import bus as escalation_bus
|
||||
@@ -86,6 +88,10 @@ class HandoffManager:
|
||||
to produce), and merges the handoff metadata into it. Self-targeting
|
||||
is rejected with ValueError, matching legacy behavior.
|
||||
"""
|
||||
user_id = UUID(str(user_id))
|
||||
if target_user_id:
|
||||
target_user_id = UUID(str(target_user_id))
|
||||
|
||||
# Eager-load steps + user — _build_escalation_package_enhanced and
|
||||
# finalize_escalation iterate over session.steps to compose the
|
||||
# legacy enriched package and the SessionDocumentation, and the
|
||||
@@ -123,7 +129,9 @@ class HandoffManager:
|
||||
# immediately with `ai_assessment=None`; the magic-moment screen
|
||||
# shows "Assessment still computing" until enrich_async finishes
|
||||
# and the senior refreshes (or, eventually, polls).
|
||||
handoff_id = uuid4()
|
||||
handoff = SessionHandoff(
|
||||
id=handoff_id,
|
||||
session_id=session_id,
|
||||
account_id=session.account_id,
|
||||
handed_off_by=user_id,
|
||||
@@ -157,7 +165,7 @@ class HandoffManager:
|
||||
"snapshot": snapshot,
|
||||
"intent": intent,
|
||||
"engineer_notes": engineer_notes,
|
||||
"handoff_id": str(handoff.id),
|
||||
"handoff_id": str(handoff_id),
|
||||
}
|
||||
|
||||
await self.db.flush()
|
||||
@@ -430,26 +438,49 @@ class HandoffManager:
|
||||
the API can return 409 with the data the loser's toast needs. A
|
||||
re-claim by the same user is idempotent.
|
||||
"""
|
||||
claiming_user_id = UUID(str(claiming_user_id))
|
||||
claimed_at = datetime.now(timezone.utc)
|
||||
|
||||
update_result = await self.db.execute(
|
||||
update(SessionHandoff)
|
||||
.where(
|
||||
SessionHandoff.id == handoff_id,
|
||||
SessionHandoff.claimed_by.is_(None),
|
||||
SessionHandoff.handed_off_by != claiming_user_id,
|
||||
)
|
||||
.values(claimed_by=claiming_user_id, claimed_at=claimed_at)
|
||||
.returning(SessionHandoff.id)
|
||||
)
|
||||
claimed_now = update_result.scalar_one_or_none() is not None
|
||||
|
||||
result = await self.db.execute(
|
||||
select(SessionHandoff)
|
||||
.options(selectinload(SessionHandoff.claimed_by_user))
|
||||
.options(
|
||||
selectinload(SessionHandoff.claimed_by_user),
|
||||
selectinload(SessionHandoff.handed_off_by_user),
|
||||
)
|
||||
.where(SessionHandoff.id == handoff_id)
|
||||
)
|
||||
handoff = result.scalar_one_or_none()
|
||||
if not handoff:
|
||||
raise ValueError(f"Handoff {handoff_id} not found")
|
||||
|
||||
if handoff.claimed_by is not None and handoff.claimed_by != claiming_user_id:
|
||||
handed_off_by = UUID(str(handoff.handed_off_by))
|
||||
claimed_by = (
|
||||
UUID(str(handoff.claimed_by)) if handoff.claimed_by is not None else None
|
||||
)
|
||||
|
||||
if handed_off_by == claiming_user_id:
|
||||
raise PermissionError("Cannot claim your own handoff")
|
||||
|
||||
if not claimed_now and claimed_by != claiming_user_id:
|
||||
claimer = handoff.claimed_by_user
|
||||
raise HandoffAlreadyClaimedError(
|
||||
claimed_by_id=handoff.claimed_by,
|
||||
claimed_by_id=claimed_by,
|
||||
claimed_by_name=claimer.name if claimer else "another engineer",
|
||||
claimed_at=handoff.claimed_at or datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
handoff.claimed_by = claiming_user_id
|
||||
handoff.claimed_at = datetime.now(timezone.utc)
|
||||
|
||||
# Reactivate session
|
||||
session_result = await self.db.execute(
|
||||
select(AISession).where(AISession.id == handoff.session_id)
|
||||
@@ -463,61 +494,111 @@ class HandoffManager:
|
||||
await self.db.flush()
|
||||
return handoff
|
||||
|
||||
async def _generate_ai_assessment(
|
||||
async def _generate_handoff_summary(
|
||||
self, session: AISession
|
||||
) -> tuple[str | None, dict[str, Any] | None]:
|
||||
"""Generate AI diagnostic assessment for escalation handoffs."""
|
||||
try:
|
||||
from app.services.assistant_chat_service import _call_ai
|
||||
) -> dict[str, Any] | None:
|
||||
"""Single structured AI call for the escalation magic-moment screen.
|
||||
|
||||
context = f"Problem: {session.problem_summary or 'Unknown'}\nDomain: {session.problem_domain or 'Unknown'}"
|
||||
msgs = session.conversation_messages or []
|
||||
# Include last 10 messages for context
|
||||
recent = "\n".join(
|
||||
f"[{m.get('role', '?')}]: {m.get('content', '')[:200]}"
|
||||
for m in msgs[-10:]
|
||||
)
|
||||
|
||||
assessment_text, _, _ = await _call_ai(
|
||||
system_base="You are a diagnostic assessment generator for MSP escalations.",
|
||||
rag_context="",
|
||||
history=[],
|
||||
new_message=(
|
||||
f"Generate a brief diagnostic assessment for this escalation.\n"
|
||||
f"{context}\n\nRecent conversation:\n{recent}\n\n"
|
||||
f"Return: 1) Most likely cause, 2) Suggested next steps, 3) Confidence (low/medium/high)"
|
||||
),
|
||||
max_tokens=500,
|
||||
)
|
||||
|
||||
assessment_data = {
|
||||
"likely_cause": "See assessment text",
|
||||
"suggested_steps": [],
|
||||
"confidence": "medium",
|
||||
}
|
||||
|
||||
return assessment_text, assessment_data
|
||||
except Exception:
|
||||
logger.exception("Failed to generate AI assessment")
|
||||
return None, None
|
||||
|
||||
async def _generate_ai_assessment_with_timeout(
|
||||
self, session: AISession
|
||||
) -> tuple[str | None, dict[str, Any] | None]:
|
||||
"""Generate optional escalation assessment within the click-path budget."""
|
||||
Returns a dict with summary_prose, what_we_know, likely_cause,
|
||||
suggested_steps, and confidence. Returns None on timeout or error.
|
||||
Replaces the old _generate_ai_assessment + _generate_ai_assessment_with_timeout
|
||||
pair, which returned freeform prose with no usable structured fields.
|
||||
"""
|
||||
timeout = settings.ESCALATION_AI_ASSESSMENT_TIMEOUT_SECONDS
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
self._generate_ai_assessment(session),
|
||||
self._generate_handoff_summary_inner(session),
|
||||
timeout=timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"Escalation AI assessment timed out after %ss for session %s",
|
||||
"Handoff summary timed out after %ss for session %s",
|
||||
timeout,
|
||||
session.id,
|
||||
)
|
||||
return None, None
|
||||
return None
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Handoff summary failed for session %s", session.id
|
||||
)
|
||||
return None
|
||||
|
||||
async def _generate_handoff_summary_inner(
|
||||
self, session: AISession
|
||||
) -> dict[str, Any]:
|
||||
steps = session.steps or []
|
||||
steps_tried = []
|
||||
for step in sorted(steps, key=lambda s: s.step_order):
|
||||
content = step.content or {}
|
||||
text = content.get("text", "").strip()
|
||||
if not text:
|
||||
continue
|
||||
entry = text
|
||||
if step.selected_option:
|
||||
entry += f" → {step.selected_option}"
|
||||
elif step.free_text_input:
|
||||
entry += f" → {step.free_text_input[:100]}"
|
||||
elif step.was_skipped:
|
||||
entry += " (skipped)"
|
||||
steps_tried.append(entry)
|
||||
steps_text = (
|
||||
"\n".join(f"- {s}" for s in steps_tried[:15])
|
||||
or "No diagnostic steps recorded."
|
||||
)
|
||||
|
||||
msgs = session.conversation_messages or []
|
||||
recent_msgs = "\n".join(
|
||||
f"[{m.get('role', '?')}]: {m.get('content', '')[:200]}"
|
||||
for m in msgs[-10:]
|
||||
)
|
||||
|
||||
prompt = (
|
||||
"Generate a structured escalation handoff summary.\n\n"
|
||||
f"Problem: {session.problem_summary or 'Unknown'}\n"
|
||||
f"Domain: {session.problem_domain or 'Unknown'}\n"
|
||||
f"Escalation reason: {session.escalation_reason or 'Not provided'}\n\n"
|
||||
f"Diagnostic steps taken:\n{steps_text}\n\n"
|
||||
f"Recent conversation:\n{recent_msgs}\n\n"
|
||||
"Respond with ONLY a valid JSON object matching this schema exactly:\n"
|
||||
'{"summary_prose": "<2-3 sentences suitable for PSA ticket notes>",\n'
|
||||
' "what_we_know": ["<confirmed fact 1>", "<confirmed fact 2>"],\n'
|
||||
' "likely_cause": "<one sentence root cause hypothesis>",\n'
|
||||
' "suggested_steps": ["<next step 1>", "<next step 2>"],\n'
|
||||
' "confidence": "<low or medium or high>"}'
|
||||
)
|
||||
|
||||
provider = get_ai_provider(settings.get_model_for_action("escalation_package"))
|
||||
raw, _, _ = await provider.generate_json(
|
||||
system_prompt=(
|
||||
"You are a diagnostic assessment generator for MSP tech support escalations. "
|
||||
"Always respond with valid JSON and nothing else. "
|
||||
"Be concise and factual."
|
||||
),
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=700,
|
||||
)
|
||||
|
||||
cleaned = raw.strip()
|
||||
if cleaned.startswith("```"):
|
||||
lines = cleaned.split("\n", 1)
|
||||
cleaned = lines[1] if len(lines) > 1 else cleaned
|
||||
if cleaned.endswith("```"):
|
||||
cleaned = cleaned[:-3].rstrip()
|
||||
|
||||
result = json.loads(cleaned)
|
||||
|
||||
if not isinstance(result.get("suggested_steps"), list):
|
||||
result["suggested_steps"] = []
|
||||
if not isinstance(result.get("what_we_know"), list):
|
||||
result["what_we_know"] = []
|
||||
if result.get("confidence") not in ("low", "medium", "high"):
|
||||
result["confidence"] = "medium"
|
||||
if not isinstance(result.get("summary_prose"), str) or not result.get("summary_prose"):
|
||||
result["summary_prose"] = result.get("likely_cause", "Assessment generated.")
|
||||
if not isinstance(result.get("likely_cause"), str):
|
||||
result["likely_cause"] = ""
|
||||
|
||||
return result
|
||||
|
||||
async def generate_briefing(
|
||||
self, handoff_id: UUID, claiming_user_id: UUID
|
||||
@@ -671,37 +752,29 @@ async def enrich_escalation_async(handoff_id: UUID, user_id: UUID) -> None:
|
||||
|
||||
manager = HandoffManager(db)
|
||||
|
||||
# Build the enhanced package (Sonnet). Don't fail the whole
|
||||
# task if it errors — the assessment is independently useful.
|
||||
# Single consolidated AI call — replaces the old
|
||||
# _generate_ai_assessment + _build_enhanced_escalation_package pair.
|
||||
try:
|
||||
enhanced_pkg = await manager._build_enhanced_escalation_package(
|
||||
session, user_id
|
||||
)
|
||||
if enhanced_pkg:
|
||||
enhanced_pkg["intent"] = "escalate"
|
||||
enhanced_pkg["engineer_notes"] = handoff.engineer_notes
|
||||
enhanced_pkg["handoff_id"] = str(handoff.id)
|
||||
if isinstance(session.escalation_package, dict):
|
||||
enhanced_pkg.setdefault(
|
||||
"snapshot", session.escalation_package.get("snapshot")
|
||||
)
|
||||
session.escalation_package = enhanced_pkg
|
||||
summary = await manager._generate_handoff_summary(session)
|
||||
if summary:
|
||||
# ai_assessment (text) holds the PSA prose for backward compat
|
||||
# (push_to_psa reads it; generate_status_update falls back to it).
|
||||
handoff.ai_assessment = summary.get("summary_prose")
|
||||
handoff.ai_assessment_data = summary
|
||||
# Keep suggested_next_steps in escalation_package so
|
||||
# psa_documentation_service can read it without a handoff join.
|
||||
existing_pkg = (
|
||||
session.escalation_package
|
||||
if isinstance(session.escalation_package, dict)
|
||||
else {}
|
||||
)
|
||||
session.escalation_package = {
|
||||
**existing_pkg,
|
||||
"suggested_next_steps": summary.get("suggested_steps", []),
|
||||
}
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"enrich_escalation_async: enhanced package build failed for handoff %s",
|
||||
handoff_id,
|
||||
)
|
||||
|
||||
# Generate the diagnostic AI assessment.
|
||||
try:
|
||||
ai_assessment, ai_assessment_data = (
|
||||
await manager._generate_ai_assessment_with_timeout(session)
|
||||
)
|
||||
handoff.ai_assessment = ai_assessment
|
||||
handoff.ai_assessment_data = ai_assessment_data
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"enrich_escalation_async: assessment generation failed for handoff %s",
|
||||
"enrich_escalation_async: summary generation failed for handoff %s",
|
||||
handoff_id,
|
||||
)
|
||||
|
||||
@@ -714,7 +787,7 @@ async def enrich_escalation_async(handoff_id: UUID, user_id: UUID) -> None:
|
||||
"type": "handoff_assessment_ready",
|
||||
"handoff_id": str(handoff.id),
|
||||
"session_id": str(handoff.session_id),
|
||||
"has_assessment": handoff.ai_assessment is not None,
|
||||
"has_assessment": handoff.ai_assessment_data is not None,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
|
||||
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}")
|
||||
|
||||
|
||||
@@ -583,10 +583,14 @@ async def send_chat_message(
|
||||
|
||||
Returns (ai_content, suggested_flows, session, fork_metadata, actions_data, questions_data).
|
||||
"""
|
||||
from sqlalchemy import or_
|
||||
result = await db.execute(
|
||||
select(AISession).where(
|
||||
AISession.id == session_id,
|
||||
AISession.user_id == user_id,
|
||||
or_(
|
||||
AISession.user_id == user_id,
|
||||
AISession.escalated_to_id == user_id,
|
||||
),
|
||||
AISession.session_type == "chat",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
290
backend/tests/test_account_invite_lookup.py
Normal file
290
backend/tests/test_account_invite_lookup.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Tests for the public GET /accounts/invites/{code}/lookup endpoint
|
||||
(consumed by the /accept-invite page on the frontend)."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.account_invite import AccountInvite
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_lookup_returns_account_info_for_valid_code(
|
||||
client, test_db, test_user, auth_headers
|
||||
):
|
||||
"""A freshly-created, unused, unexpired invite resolves to the inviter's
|
||||
account name + the inviter's display name + the invited email + role."""
|
||||
with patch(
|
||||
"app.core.email.EmailService.send_account_invite_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
create_resp = await client.post(
|
||||
"/api/v1/accounts/me/invites",
|
||||
json={"email": "lookup@example.com", "role": "engineer"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.json()
|
||||
code = create_resp.json()["code"]
|
||||
|
||||
response = await client.get(f"/api/v1/accounts/invites/{code}/lookup")
|
||||
assert response.status_code == 200, response.json()
|
||||
body = response.json()
|
||||
|
||||
assert body["invited_email"] == "lookup@example.com"
|
||||
assert body["role"] == "engineer"
|
||||
assert body["inviter_name"] == test_user["user_data"]["name"]
|
||||
# account_name is whatever the test_user fixture seeded for the account.
|
||||
assert isinstance(body["account_name"], str) and body["account_name"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invite_lookup_returns_404_for_invalid_or_expired_code(
|
||||
client, test_db, test_user
|
||||
):
|
||||
"""Three failure modes (unknown code, expired, revoked, used) all collapse
|
||||
to the same 404 + invite_invalid_or_expired_or_revoked error code."""
|
||||
invited_by_id = uuid.UUID(test_user["user_data"]["id"])
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
|
||||
# 1) Unknown code
|
||||
unknown = await client.get("/api/v1/accounts/invites/DOESNOTEXIST/lookup")
|
||||
assert unknown.status_code == 404
|
||||
assert unknown.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked"
|
||||
|
||||
# 2) Expired
|
||||
expired_invite = AccountInvite(
|
||||
account_id=account_id,
|
||||
invited_by_id=invited_by_id,
|
||||
email="expired@example.com",
|
||||
code="EXPIREDLOOKUP01",
|
||||
role="engineer",
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(days=1),
|
||||
)
|
||||
test_db.add(expired_invite)
|
||||
await test_db.commit()
|
||||
expired = await client.get("/api/v1/accounts/invites/EXPIREDLOOKUP01/lookup")
|
||||
assert expired.status_code == 404
|
||||
assert expired.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked"
|
||||
|
||||
# 3) Revoked
|
||||
revoked_invite = AccountInvite(
|
||||
account_id=account_id,
|
||||
invited_by_id=invited_by_id,
|
||||
email="revoked@example.com",
|
||||
code="REVOKEDLOOKUP01",
|
||||
role="engineer",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
revoked_at=datetime.now(timezone.utc),
|
||||
)
|
||||
test_db.add(revoked_invite)
|
||||
await test_db.commit()
|
||||
revoked = await client.get("/api/v1/accounts/invites/REVOKEDLOOKUP01/lookup")
|
||||
assert revoked.status_code == 404
|
||||
assert revoked.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked"
|
||||
|
||||
# 4) Already used
|
||||
used_invite = AccountInvite(
|
||||
account_id=account_id,
|
||||
invited_by_id=invited_by_id,
|
||||
email="used@example.com",
|
||||
code="USEDLOOKUP01",
|
||||
role="engineer",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
|
||||
accepted_by_id=invited_by_id,
|
||||
used_at=datetime.now(timezone.utc),
|
||||
)
|
||||
test_db.add(used_invite)
|
||||
await test_db.commit()
|
||||
used = await client.get("/api/v1/accounts/invites/USEDLOOKUP01/lookup")
|
||||
assert used.status_code == 404
|
||||
assert used.json()["detail"]["error"] == "invite_invalid_or_expired_or_revoked"
|
||||
|
||||
# Sanity: rows survived (no destructive side effects).
|
||||
persisted = (
|
||||
await test_db.execute(
|
||||
select(AccountInvite).where(
|
||||
AccountInvite.code.in_(
|
||||
["EXPIREDLOOKUP01", "REVOKEDLOOKUP01", "USEDLOOKUP01"]
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(persisted) == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_callback_links_invite_when_account_invite_code_supplied(
|
||||
client, test_db, test_user, auth_headers, monkeypatch
|
||||
):
|
||||
"""Brand-new OAuth user with account_invite_code joins the invited account
|
||||
instead of getting a personal one. Invite is marked used."""
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.services.oauth_providers import OAuthProfile
|
||||
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
|
||||
|
||||
with patch(
|
||||
"app.core.email.EmailService.send_account_invite_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
create_resp = await client.post(
|
||||
"/api/v1/accounts/me/invites",
|
||||
json={"email": "oauth-invite@example.com", "role": "engineer"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
code = create_resp.json()["code"]
|
||||
inviter_account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
|
||||
profile = OAuthProfile(
|
||||
provider_subject="google_invite_subject_1",
|
||||
email="oauth-invite@example.com",
|
||||
name="OAuth Invitee",
|
||||
)
|
||||
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",
|
||||
"account_invite_code": code,
|
||||
"invited_email": "oauth-invite@example.com",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.json()
|
||||
assert response.json()["is_new_user"] is True
|
||||
|
||||
user = (
|
||||
await test_db.execute(
|
||||
select(User).where(User.email == "oauth-invite@example.com")
|
||||
)
|
||||
).scalar_one()
|
||||
assert user.account_id == inviter_account_id
|
||||
assert user.account_role == "engineer"
|
||||
|
||||
invite = (
|
||||
await test_db.execute(
|
||||
select(AccountInvite).where(AccountInvite.code == code)
|
||||
)
|
||||
).scalar_one()
|
||||
assert invite.used_at is not None
|
||||
assert invite.accepted_by_id == user.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_callback_existing_email_with_invite_returns_400(
|
||||
client, test_db, test_user, auth_headers, monkeypatch
|
||||
):
|
||||
"""If a user already exists with the invited email (e.g., previously
|
||||
registered via password), arriving via /accept-invite OAuth must NOT
|
||||
silently link the OAuth identity to their existing account and skip the
|
||||
invite. Surface email_already_registered_use_login so the user signs in
|
||||
and accepts the invite from the dashboard instead."""
|
||||
from app.core.config import settings
|
||||
from app.services.oauth_providers import OAuthProfile
|
||||
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
|
||||
|
||||
# 1) Pre-existing user with a password (separate from the inviter).
|
||||
existing_email = "already-here@example.com"
|
||||
register_resp = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": existing_email,
|
||||
"password": "PreviousPassword123!",
|
||||
"name": "Already Here",
|
||||
},
|
||||
)
|
||||
assert register_resp.status_code in (200, 201), register_resp.json()
|
||||
|
||||
# 2) Inviter creates an invite for that exact email.
|
||||
with patch(
|
||||
"app.core.email.EmailService.send_account_invite_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
create_resp = await client.post(
|
||||
"/api/v1/accounts/me/invites",
|
||||
json={"email": existing_email, "role": "engineer"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.json()
|
||||
code = create_resp.json()["code"]
|
||||
|
||||
# 3) The existing user does Google OAuth and the callback receives the
|
||||
# invite code. Backend must reject — not link silently.
|
||||
profile = OAuthProfile(
|
||||
provider_subject="google_existing_subject_1",
|
||||
email=existing_email,
|
||||
name="Already Here",
|
||||
)
|
||||
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",
|
||||
"account_invite_code": code,
|
||||
"invited_email": existing_email,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400, response.json()
|
||||
assert (
|
||||
response.json()["detail"]["error"] == "email_already_registered_use_login"
|
||||
)
|
||||
|
||||
# 4) Sanity: the invite was NOT consumed.
|
||||
invite = (
|
||||
await test_db.execute(
|
||||
select(AccountInvite).where(AccountInvite.code == code)
|
||||
)
|
||||
).scalar_one()
|
||||
assert invite.used_at is None
|
||||
assert invite.accepted_by_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_callback_invite_email_mismatch_returns_400(
|
||||
client, test_db, test_user, auth_headers, monkeypatch
|
||||
):
|
||||
"""If the OAuth profile's email differs from the invite's email, the
|
||||
backend rejects the link with invite_email_mismatch (mirrors register)."""
|
||||
from app.core.config import settings
|
||||
from app.services.oauth_providers import OAuthProfile
|
||||
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "client_dummy")
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_SECRET", "secret_dummy")
|
||||
|
||||
with patch(
|
||||
"app.core.email.EmailService.send_account_invite_email",
|
||||
new_callable=AsyncMock,
|
||||
return_value=True,
|
||||
):
|
||||
create_resp = await client.post(
|
||||
"/api/v1/accounts/me/invites",
|
||||
json={"email": "expected@example.com", "role": "engineer"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
code = create_resp.json()["code"]
|
||||
|
||||
profile = OAuthProfile(
|
||||
provider_subject="google_invite_subject_2",
|
||||
email="different@example.com",
|
||||
name="Wrong Email",
|
||||
)
|
||||
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",
|
||||
"account_invite_code": code,
|
||||
"invited_email": "expected@example.com",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400, response.json()
|
||||
assert response.json()["detail"]["error"] == "invite_email_mismatch"
|
||||
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):
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""Integration tests for admin plan limits and account override endpoints."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.plan_billing import PlanBilling
|
||||
|
||||
|
||||
class TestAdminPlanLimits:
|
||||
@@ -56,3 +61,204 @@ class TestAdminPlanLimits:
|
||||
"""Non-admin gets 403."""
|
||||
response = await client.get("/api/v1/admin/plan-limits", headers=auth_headers)
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_plan_limits_get_includes_plan_billing_fields_when_present(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_db
|
||||
):
|
||||
"""GET /admin/plan-limits returns plan_billing fields when a row exists,
|
||||
and None for plans that don't have one yet."""
|
||||
# Seed a plan_billing row for "pro".
|
||||
existing = (await test_db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == "pro")
|
||||
)).scalar_one_or_none()
|
||||
if existing is None:
|
||||
test_db.add(PlanBilling(
|
||||
plan="pro",
|
||||
display_name="Pro",
|
||||
description="For working teams",
|
||||
monthly_price_cents=4900,
|
||||
annual_price_cents=49000,
|
||||
stripe_product_id="prod_seed",
|
||||
stripe_monthly_price_id="price_seed_m",
|
||||
stripe_annual_price_id="price_seed_a",
|
||||
is_public=True,
|
||||
is_archived=False,
|
||||
sort_order=10,
|
||||
))
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/admin/plan-limits", headers=admin_auth_headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
plans_by_name = {p["plan"]: p for p in response.json()}
|
||||
|
||||
assert "pro" in plans_by_name
|
||||
pro = plans_by_name["pro"]
|
||||
assert pro["display_name"] == "Pro"
|
||||
assert pro["monthly_price_cents"] == 4900
|
||||
assert pro["stripe_monthly_price_id"] == "price_seed_m"
|
||||
assert pro["is_public"] is True
|
||||
assert pro["is_archived"] is False
|
||||
assert pro["sort_order"] == 10
|
||||
|
||||
# A plan without a plan_billing row should still return, with None
|
||||
# billing fields.
|
||||
if "free" in plans_by_name:
|
||||
free = plans_by_name["free"]
|
||||
# free has no plan_billing row in the seed → fields are None.
|
||||
no_billing_row = (await test_db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == "free")
|
||||
)).scalar_one_or_none() is None
|
||||
if no_billing_row:
|
||||
assert free["display_name"] is None
|
||||
assert free["monthly_price_cents"] is None
|
||||
assert free["stripe_product_id"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_plan_limits_put_creates_plan_billing_row(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_db
|
||||
):
|
||||
"""PUT /admin/plan-limits upserts a plan_billing row when billing
|
||||
fields are included in the body."""
|
||||
# Ensure no plan_billing row exists for "team" yet.
|
||||
existing = (await test_db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == "team")
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
await test_db.delete(existing)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.put(
|
||||
"/api/v1/admin/plan-limits",
|
||||
json={
|
||||
"plan": "team",
|
||||
"max_trees": None,
|
||||
"max_sessions_per_month": None,
|
||||
"max_users": None,
|
||||
"custom_branding": True,
|
||||
"priority_support": True,
|
||||
"export_formats": ["markdown", "text", "pdf"],
|
||||
"display_name": "Team",
|
||||
"description": "For growing shops",
|
||||
"monthly_price_cents": 9900,
|
||||
"annual_price_cents": 99000,
|
||||
"stripe_product_id": "prod_team_test",
|
||||
"stripe_monthly_price_id": "price_team_m",
|
||||
"stripe_annual_price_id": "price_team_a",
|
||||
"is_public": True,
|
||||
"is_archived": False,
|
||||
"sort_order": 20,
|
||||
},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["display_name"] == "Team"
|
||||
assert body["monthly_price_cents"] == 9900
|
||||
assert body["stripe_product_id"] == "prod_team_test"
|
||||
assert body["sort_order"] == 20
|
||||
|
||||
# Confirm the row was actually persisted.
|
||||
await test_db.commit() # ensure session sees other-session writes
|
||||
pb = (await test_db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == "team")
|
||||
)).scalar_one_or_none()
|
||||
assert pb is not None
|
||||
assert pb.display_name == "Team"
|
||||
assert pb.monthly_price_cents == 9900
|
||||
assert pb.stripe_monthly_price_id == "price_team_m"
|
||||
assert pb.is_public is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_plan_limits_put_does_not_null_out_required_fields(
|
||||
self, client: AsyncClient, admin_auth_headers: dict, test_db
|
||||
):
|
||||
"""PUT /admin/plan-limits must not NULL out NOT NULL columns on the
|
||||
plan_billing row when the caller passes explicit nulls. The set of
|
||||
guarded fields is {display_name, is_public, is_archived, sort_order}.
|
||||
"""
|
||||
# Seed a plan_billing row for "team" with non-default values for every
|
||||
# NOT NULL field so we can detect any clobbering.
|
||||
existing = (await test_db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == "team")
|
||||
)).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
await test_db.delete(existing)
|
||||
await test_db.commit()
|
||||
|
||||
seeded = PlanBilling(
|
||||
plan="team",
|
||||
display_name="Team Seeded",
|
||||
is_public=False,
|
||||
is_archived=True,
|
||||
sort_order=5,
|
||||
)
|
||||
test_db.add(seeded)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.put(
|
||||
"/api/v1/admin/plan-limits",
|
||||
json={
|
||||
"plan": "team",
|
||||
"max_trees": None,
|
||||
"max_sessions_per_month": None,
|
||||
"max_users": None,
|
||||
"custom_branding": True,
|
||||
"priority_support": True,
|
||||
"export_formats": ["markdown", "text"],
|
||||
# Explicit nulls for every NOT NULL plan_billing field.
|
||||
"display_name": None,
|
||||
"is_public": None,
|
||||
"is_archived": None,
|
||||
"sort_order": None,
|
||||
},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
# Confirm the seeded NOT NULL values were preserved.
|
||||
await test_db.commit() # ensure session sees writes from the request
|
||||
pb = (await test_db.execute(
|
||||
select(PlanBilling).where(PlanBilling.plan == "team")
|
||||
)).scalar_one_or_none()
|
||||
assert pb is not None
|
||||
assert pb.display_name == "Team Seeded"
|
||||
assert pb.is_public is False
|
||||
assert pb.is_archived is True
|
||||
assert pb.sort_order == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_plan_limits_put_invalidates_billing_cache(
|
||||
self, client: AsyncClient, admin_auth_headers: dict
|
||||
):
|
||||
"""PUT /admin/plan-limits calls BillingService.invalidate_billing_cache
|
||||
with the account_ids on the affected plan."""
|
||||
# Patch the staticmethod on the class. The endpoint imports
|
||||
# BillingService at module load, so patch the symbol on the class
|
||||
# itself — both the import and the dotted reference resolve to it.
|
||||
with patch(
|
||||
"app.api.endpoints.admin_plan_limits.BillingService.invalidate_billing_cache",
|
||||
new_callable=AsyncMock,
|
||||
) as spy:
|
||||
response = await client.put(
|
||||
"/api/v1/admin/plan-limits",
|
||||
json={
|
||||
"plan": "pro",
|
||||
"max_trees": 25,
|
||||
"max_sessions_per_month": 500,
|
||||
"max_users": 10,
|
||||
"custom_branding": True,
|
||||
"priority_support": True,
|
||||
"export_formats": ["markdown", "text"],
|
||||
},
|
||||
headers=admin_auth_headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
spy.assert_awaited_once()
|
||||
(account_ids_arg,) = spy.await_args.args
|
||||
# admin fixture seeds an active Pro Subscription, so we expect at
|
||||
# least one account_id in the invalidation list.
|
||||
assert isinstance(account_ids_arg, list)
|
||||
assert len(account_ids_arg) >= 1
|
||||
|
||||
43
backend/tests/test_beta_signup_redirect.py
Normal file
43
backend/tests/test_beta_signup_redirect.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Integration tests for the legacy /beta-signup redirect.
|
||||
|
||||
Phase 2 retires the public beta-signup form in favor of the regular
|
||||
register flow. The endpoint stays mounted but answers with a 307 to
|
||||
the absolute frontend `/register?from=beta` URL so any external links
|
||||
keep working. There is no `beta_signup` table to migrate — the old
|
||||
endpoint only fired an email notification — so this test only covers
|
||||
the redirect contract.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_beta_signup_redirects_to_register(client, monkeypatch):
|
||||
"""POST /beta-signup returns 307 to the absolute frontend register URL."""
|
||||
monkeypatch.setattr(settings, "FRONTEND_URL", "https://example.com")
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/beta-signup",
|
||||
json={"email": "anyone@example.com"},
|
||||
)
|
||||
|
||||
assert response.status_code == 307, response.text
|
||||
assert (
|
||||
response.headers["location"]
|
||||
== "https://example.com/register?from=beta"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_beta_signup_redirect_ignores_body(client, monkeypatch):
|
||||
"""Redirect fires regardless of payload — no validation on the legacy route."""
|
||||
monkeypatch.setattr(settings, "FRONTEND_URL", "https://example.com")
|
||||
|
||||
response = await client.post("/api/v1/beta-signup", json={})
|
||||
assert response.status_code == 307
|
||||
assert (
|
||||
response.headers["location"]
|
||||
== "https://example.com/register?from=beta"
|
||||
)
|
||||
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
|
||||
83
backend/tests/test_billing_portal.py
Normal file
83
backend/tests/test_billing_portal.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.account import Account
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_billing_portal_returns_url_for_account_with_stripe_customer(
|
||||
client, test_db, test_user, auth_headers, monkeypatch
|
||||
):
|
||||
"""Happy path: account has a stripe_customer_id and Stripe is configured →
|
||||
GET /billing/portal-session returns the portal URL."""
|
||||
from app.core.config import settings
|
||||
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
|
||||
monkeypatch.setattr(settings, "FRONTEND_URL", "https://app.example.com")
|
||||
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
account = (await test_db.execute(
|
||||
select(Account).where(Account.id == account_id)
|
||||
)).scalar_one()
|
||||
account.stripe_customer_id = "cus_test_456"
|
||||
await test_db.commit()
|
||||
|
||||
fake_session = MagicMock()
|
||||
fake_session.url = "https://billing.stripe.com/p/session/test_abc"
|
||||
|
||||
with patch(
|
||||
"stripe.billing_portal.Session.create",
|
||||
return_value=fake_session,
|
||||
) as portal_mock:
|
||||
response = await client.get(
|
||||
"/api/v1/billing/portal-session",
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.json()
|
||||
assert response.json() == {"url": "https://billing.stripe.com/p/session/test_abc"}
|
||||
portal_mock.assert_called_once()
|
||||
call_kwargs = portal_mock.call_args.kwargs
|
||||
assert call_kwargs["customer"] == "cus_test_456"
|
||||
assert call_kwargs["return_url"] == "https://app.example.com/account/billing"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_billing_portal_returns_503_when_stripe_not_configured(
|
||||
client, test_db, test_user, auth_headers, monkeypatch
|
||||
):
|
||||
"""STRIPE_SECRET_KEY unset → settings.stripe_enabled is False → 503."""
|
||||
from app.core.config import settings
|
||||
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", None)
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/billing/portal-session",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"]["error"] == "stripe_not_configured"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_billing_portal_returns_400_when_account_has_no_stripe_customer(
|
||||
client, test_db, test_user, auth_headers, monkeypatch
|
||||
):
|
||||
"""Account with no stripe_customer_id (never completed checkout) → 400
|
||||
with `no_stripe_customer` error."""
|
||||
from app.core.config import settings
|
||||
monkeypatch.setattr(settings, "STRIPE_SECRET_KEY", "sk_test_dummy")
|
||||
|
||||
# test_user fixture seeds an account with no stripe_customer_id by default.
|
||||
account_id = uuid.UUID(test_user["user_data"]["account_id"])
|
||||
account = (await test_db.execute(
|
||||
select(Account).where(Account.id == account_id)
|
||||
)).scalar_one()
|
||||
assert account.stripe_customer_id is None
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/billing/portal-session",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json()["detail"]["error"] == "no_stripe_customer"
|
||||
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
|
||||
100
backend/tests/test_config_public.py
Normal file
100
backend/tests/test_config_public.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Integration tests for the public runtime config endpoint.
|
||||
|
||||
Covers GET /api/v1/config/public and the SELF_SERVE_ENABLED interaction
|
||||
with the existing /auth/register invite-code gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class TestConfigPublic:
|
||||
"""GET /api/v1/config/public — anonymous, no auth."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_config_public_returns_self_serve_flag(
|
||||
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Endpoint reflects the current SELF_SERVE_ENABLED setting and the
|
||||
configured OAuth providers, with no auth required."""
|
||||
# Default-off: SELF_SERVE_ENABLED is False unless explicitly set.
|
||||
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
|
||||
monkeypatch.setattr(settings, "MS_CLIENT_ID", None)
|
||||
|
||||
response = await client.get("/api/v1/config/public")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body == {"self_serve_enabled": False, "oauth_providers": []}
|
||||
|
||||
# Flip it on, with both OAuth providers configured.
|
||||
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", True)
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", "google-test-id")
|
||||
monkeypatch.setattr(settings, "MS_CLIENT_ID", "ms-test-id")
|
||||
|
||||
response = await client.get("/api/v1/config/public")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["self_serve_enabled"] is True
|
||||
assert body["oauth_providers"] == ["google", "microsoft"]
|
||||
|
||||
# Only Microsoft configured.
|
||||
monkeypatch.setattr(settings, "GOOGLE_CLIENT_ID", None)
|
||||
monkeypatch.setattr(settings, "MS_CLIENT_ID", "ms-test-id")
|
||||
response = await client.get("/api/v1/config/public")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["oauth_providers"] == ["microsoft"]
|
||||
|
||||
|
||||
class TestRegisterInviteCodeGate:
|
||||
"""Regression + new-behavior tests for /auth/register vs SELF_SERVE_ENABLED."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_invite_code_required_when_self_serve_disabled(
|
||||
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Pre-self-serve behavior: REQUIRE_INVITE_CODE=True without an
|
||||
invite code (and no account-invite) must still 400."""
|
||||
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
|
||||
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", False)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "no-invite@example.com",
|
||||
"password": "SecurePass123!",
|
||||
"name": "No Invite",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "invite code is required" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_register_invite_code_optional_when_self_serve_enabled(
|
||||
self, client: AsyncClient, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Self-serve on: registration succeeds with no invite code even
|
||||
when REQUIRE_INVITE_CODE is True. The user, personal account, and
|
||||
a Pro trial subscription are all created."""
|
||||
monkeypatch.setattr(settings, "REQUIRE_INVITE_CODE", True)
|
||||
monkeypatch.setattr(settings, "SELF_SERVE_ENABLED", True)
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={
|
||||
"email": "self-serve@example.com",
|
||||
"password": "SecurePass123!",
|
||||
"name": "Self Serve",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert body["email"] == "self-serve@example.com"
|
||||
assert body["account_role"] == "owner"
|
||||
assert "account_id" in body
|
||||
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
|
||||
@@ -15,16 +15,15 @@ def stub_ai_assessment():
|
||||
"""Keep handoff tests focused on handoff behavior, not external AI calls."""
|
||||
with patch.object(
|
||||
HandoffManager,
|
||||
"_generate_ai_assessment",
|
||||
"_generate_handoff_summary",
|
||||
new=AsyncMock(
|
||||
return_value=(
|
||||
"Stub escalation assessment",
|
||||
{
|
||||
"likely_cause": "Stub",
|
||||
"suggested_steps": [],
|
||||
"confidence": "medium",
|
||||
},
|
||||
)
|
||||
return_value={
|
||||
"summary_prose": "Stub escalation assessment",
|
||||
"what_we_know": [],
|
||||
"likely_cause": "Stub",
|
||||
"suggested_steps": [],
|
||||
"confidence": "medium",
|
||||
}
|
||||
),
|
||||
):
|
||||
yield
|
||||
@@ -100,6 +99,7 @@ async def test_create_escalate_handoff(client: AsyncClient, test_user, auth_head
|
||||
assert session.status == "escalated"
|
||||
assert session.escalation_package is not None
|
||||
assert "branch_map" in session.escalation_package or "snapshot" in session.escalation_package
|
||||
assert session.escalation_package["handoff_id"] == str(handoff.id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -120,9 +120,9 @@ async def test_create_escalate_handoff_does_not_wait_on_slow_ai_assessment(
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
async def slow_assessment(self, session):
|
||||
async def slow_summary(self, session):
|
||||
await asyncio.sleep(0.2)
|
||||
return "too slow", {"confidence": "medium"}
|
||||
return {"summary_prose": "too slow", "confidence": "medium"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.services.handoff_manager.settings."
|
||||
@@ -131,8 +131,8 @@ async def test_create_escalate_handoff_does_not_wait_on_slow_ai_assessment(
|
||||
)
|
||||
with patch.object(
|
||||
HandoffManager,
|
||||
"_generate_ai_assessment",
|
||||
new=slow_assessment,
|
||||
"_generate_handoff_summary_inner",
|
||||
new=slow_summary,
|
||||
):
|
||||
manager = HandoffManager(test_db)
|
||||
handoff = await manager.create_handoff(
|
||||
@@ -182,7 +182,7 @@ async def test_claim_session(client: AsyncClient, test_user, test_admin, auth_he
|
||||
claiming_user_id=test_admin["user_data"]["id"],
|
||||
)
|
||||
|
||||
assert claimed.claimed_by == test_admin["user_data"]["id"]
|
||||
assert str(claimed.claimed_by) == test_admin["user_data"]["id"]
|
||||
assert claimed.claimed_at is not None
|
||||
|
||||
await test_db.refresh(session)
|
||||
@@ -213,6 +213,15 @@ async def test_claim_session_conflict_raises_already_claimed(
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
loser = User(
|
||||
email="race-loser@example.com",
|
||||
password_hash="x",
|
||||
name="Race Loser",
|
||||
role="engineer",
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
account_role="engineer",
|
||||
)
|
||||
test_db.add(loser)
|
||||
await test_db.flush()
|
||||
|
||||
manager = HandoffManager(test_db)
|
||||
@@ -229,16 +238,16 @@ async def test_claim_session_conflict_raises_already_claimed(
|
||||
claiming_user_id=test_admin["user_data"]["id"],
|
||||
)
|
||||
|
||||
# Second claim by a different user — owner of the original session,
|
||||
# standing in for "the other senior who lost the race."
|
||||
# Second claim by a different user — standing in for the other senior who
|
||||
# lost the race.
|
||||
with pytest.raises(HandoffAlreadyClaimedError) as exc_info:
|
||||
await manager.claim_session(
|
||||
handoff_id=handoff.id,
|
||||
claiming_user_id=test_user["user_data"]["id"],
|
||||
claiming_user_id=loser.id,
|
||||
)
|
||||
|
||||
err = exc_info.value
|
||||
assert err.claimed_by_id == test_admin["user_data"]["id"]
|
||||
assert str(err.claimed_by_id) == test_admin["user_data"]["id"]
|
||||
assert err.claimed_by_name # populated from User.name
|
||||
assert err.claimed_at is not None
|
||||
|
||||
@@ -279,7 +288,40 @@ async def test_claim_session_idempotent_for_same_user(
|
||||
claiming_user_id=test_admin["user_data"]["id"],
|
||||
)
|
||||
|
||||
assert first.claimed_by == second.claimed_by == test_admin["user_data"]["id"]
|
||||
assert str(first.claimed_by) == str(second.claimed_by) == test_admin["user_data"]["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_session_rejects_self_claim(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""The engineer who escalated a session cannot pick up their own handoff."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="active",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
manager = HandoffManager(test_db)
|
||||
handoff = await manager.create_handoff(
|
||||
session_id=session.id,
|
||||
intent="escalate",
|
||||
engineer_notes="Need help",
|
||||
user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
await manager.claim_session(
|
||||
handoff_id=handoff.id,
|
||||
claiming_user_id=test_user["user_data"]["id"],
|
||||
)
|
||||
|
||||
|
||||
# ─── Notification dispatch ────────────────────────────────────────────────────
|
||||
|
||||
@@ -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("""
|
||||
|
||||
196
backend/tests/test_oauth_callbacks.py
Normal file
196
backend/tests/test_oauth_callbacks.py
Normal file
@@ -0,0 +1,196 @@
|
||||
import uuid
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
from sqlalchemy import select
|
||||
from app.core.security import decode_token, hash_token
|
||||
from app.models.user import User
|
||||
from app.models.oauth_identity import OAuthIdentity
|
||||
from app.models.refresh_token import RefreshToken
|
||||
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"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_google_callback_stores_refresh_token_jti(
|
||||
client, test_db, monkeypatch
|
||||
):
|
||||
"""A successful Google OAuth callback must persist the refresh-token JTI
|
||||
in the refresh_tokens table — otherwise /auth/refresh rejects it."""
|
||||
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_jti_test",
|
||||
email="jtitest@example.com",
|
||||
name="JTI Test",
|
||||
)
|
||||
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()
|
||||
refresh_token_str = body["refresh_token"]
|
||||
|
||||
payload = decode_token(refresh_token_str)
|
||||
assert payload is not None
|
||||
jti = payload["jti"]
|
||||
token_hash = hash_token(jti)
|
||||
|
||||
user = (await test_db.execute(
|
||||
select(User).where(User.email == "jtitest@example.com")
|
||||
)).scalar_one()
|
||||
|
||||
stored = (await test_db.execute(
|
||||
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||||
)).scalar_one_or_none()
|
||||
assert stored is not None, "OAuth callback did not persist refresh-token JTI"
|
||||
assert stored.user_id == user.id
|
||||
assert stored.revoked_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_refresh_works_after_oauth_signup(
|
||||
client, test_db, monkeypatch
|
||||
):
|
||||
"""End-to-end: OAuth callback issues a refresh token; calling /auth/refresh
|
||||
with that token must succeed (not be rejected as revoked)."""
|
||||
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_refresh_test",
|
||||
email="refresh-after-oauth@example.com",
|
||||
name="Refresh After OAuth",
|
||||
)
|
||||
with patch("app.api.endpoints.oauth.google_exchange_code", return_value=profile):
|
||||
callback_resp = await client.post(
|
||||
"/api/v1/auth/google/callback", json={"code": "auth_code_xyz"}
|
||||
)
|
||||
assert callback_resp.status_code == 200, callback_resp.json()
|
||||
refresh_token_str = callback_resp.json()["refresh_token"]
|
||||
|
||||
refresh_resp = await client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
headers={"Authorization": f"Bearer {refresh_token_str}"},
|
||||
)
|
||||
assert refresh_resp.status_code == 200, refresh_resp.json()
|
||||
refreshed = refresh_resp.json()
|
||||
assert refreshed["access_token"]
|
||||
assert refreshed["refresh_token"]
|
||||
# Token rotation: new refresh token differs from the original.
|
||||
assert refreshed["refresh_token"] != refresh_token_str
|
||||
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"]
|
||||
@@ -1,6 +1,11 @@
|
||||
"""Tests for onboarding status endpoints."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -21,6 +26,42 @@ async def test_onboarding_status_fresh_user(client, auth_headers):
|
||||
assert data["connected_psa"] is False
|
||||
assert data["is_team_user"] is False
|
||||
assert data["dismissed"] is False
|
||||
# Phase 2 fields default to false on a fresh, unverified user with no wizard progress.
|
||||
assert data["email_verified"] is False
|
||||
assert data["shop_setup_done"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_status_includes_email_verified_and_shop_setup_done(
|
||||
client, auth_headers, test_user, test_db
|
||||
):
|
||||
"""email_verified flips when email_verified_at is set; shop_setup_done flips at step >= 1."""
|
||||
# Sanity-check baseline.
|
||||
response = await client.get(
|
||||
"/api/v1/users/onboarding-status",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email_verified"] is False
|
||||
assert data["shop_setup_done"] is False
|
||||
|
||||
# Mutate the underlying user, then re-fetch.
|
||||
user_email = test_user["email"]
|
||||
result = await test_db.execute(select(User).where(User.email == user_email))
|
||||
user = result.scalar_one()
|
||||
user.email_verified_at = datetime.now(tz=timezone.utc)
|
||||
user.onboarding_step_completed = 1
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get(
|
||||
"/api/v1/users/onboarding-status",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["email_verified"] is True
|
||||
assert data["shop_setup_done"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
149
backend/tests/test_onboarding_step.py
Normal file
149
backend/tests/test_onboarding_step.py
Normal file
@@ -0,0 +1,149 @@
|
||||
"""Tests for welcome-wizard onboarding-step endpoints (Phase 2)."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.models.account import Account
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_step1_complete_writes_account_name_and_team_size_and_role(
|
||||
client, auth_headers, test_db, test_user
|
||||
):
|
||||
"""Step 1 + complete writes account.name + team_size_bucket + user.role_at_signup
|
||||
and advances onboarding_step_completed to 1."""
|
||||
response = await client.patch(
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"step": 1,
|
||||
"action": "complete",
|
||||
"data": {
|
||||
"company_name": "Acme MSP",
|
||||
"team_size_bucket": "3-5",
|
||||
"role_at_signup": "owner",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["onboarding_step_completed"] == 1
|
||||
assert data["onboarding_dismissed"] is False
|
||||
|
||||
# Verify persisted writes
|
||||
account_id = test_user["user_data"]["account_id"]
|
||||
user_email = test_user["email"]
|
||||
|
||||
acct = (
|
||||
await test_db.execute(select(Account).where(Account.id == account_id))
|
||||
).scalar_one()
|
||||
assert acct.name == "Acme MSP"
|
||||
assert acct.team_size_bucket == "3-5"
|
||||
|
||||
user = (
|
||||
await test_db.execute(select(User).where(User.email == user_email))
|
||||
).scalar_one()
|
||||
assert user.role_at_signup == "owner"
|
||||
assert user.onboarding_step_completed == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_step2_skip_advances_without_psa(
|
||||
client, auth_headers, test_db, test_user
|
||||
):
|
||||
"""Step 2 + skip ignores data entirely and only advances the step counter
|
||||
(no primary_psa write)."""
|
||||
# Capture original account.primary_psa so we can assert it's untouched.
|
||||
account_id = test_user["user_data"]["account_id"]
|
||||
acct_before = (
|
||||
await test_db.execute(select(Account).where(Account.id == account_id))
|
||||
).scalar_one()
|
||||
psa_before = acct_before.primary_psa # likely None
|
||||
|
||||
# Advance step 1 first so step 2 is allowed.
|
||||
r1 = await client.patch(
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
headers=auth_headers,
|
||||
json={"step": 1, "action": "skip"},
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
|
||||
# Skip step 2 — even if data is present it must be ignored.
|
||||
r2 = await client.patch(
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"step": 2,
|
||||
"action": "skip",
|
||||
"data": {"primary_psa": "connectwise"},
|
||||
},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["onboarding_step_completed"] == 2
|
||||
|
||||
# Re-fetch account: primary_psa must NOT have been written.
|
||||
test_db.expire_all()
|
||||
acct_after = (
|
||||
await test_db.execute(select(Account).where(Account.id == account_id))
|
||||
).scalar_one()
|
||||
assert acct_after.primary_psa == psa_before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_step_cannot_decrease(client, auth_headers):
|
||||
"""A step=2 PATCH followed by step=1 must return 400."""
|
||||
# Advance to step 2.
|
||||
r1 = await client.patch(
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
headers=auth_headers,
|
||||
json={"step": 1, "action": "skip"},
|
||||
)
|
||||
assert r1.status_code == 200, r1.text
|
||||
r2 = await client.patch(
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
headers=auth_headers,
|
||||
json={"step": 2, "action": "skip"},
|
||||
)
|
||||
assert r2.status_code == 200, r2.text
|
||||
assert r2.json()["onboarding_step_completed"] == 2
|
||||
|
||||
# Try to go back to step 1 — must fail.
|
||||
r3 = await client.patch(
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
headers=auth_headers,
|
||||
json={"step": 1, "action": "skip"},
|
||||
)
|
||||
assert r3.status_code == 400, r3.text
|
||||
|
||||
# Idempotent re-PATCH of same step succeeds.
|
||||
r4 = await client.patch(
|
||||
"/api/v1/users/me/onboarding-step",
|
||||
headers=auth_headers,
|
||||
json={"step": 2, "action": "skip"},
|
||||
)
|
||||
assert r4.status_code == 200, r4.text
|
||||
assert r4.json()["onboarding_step_completed"] == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_dismiss_rest_sets_flag(
|
||||
client, auth_headers, test_db, test_user
|
||||
):
|
||||
"""POST /users/me/onboarding-dismiss-rest sets users.onboarding_dismissed=TRUE."""
|
||||
response = await client.post(
|
||||
"/api/v1/users/me/onboarding-dismiss-rest",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
data = response.json()
|
||||
assert data["onboarding_dismissed"] is True
|
||||
# step counter is whatever it was (None for a fresh user).
|
||||
assert "onboarding_step_completed" in data
|
||||
|
||||
# Verify persisted.
|
||||
user_email = test_user["email"]
|
||||
user = (
|
||||
await test_db.execute(select(User).where(User.email == user_email))
|
||||
).scalar_one()
|
||||
assert user.onboarding_dismissed is True
|
||||
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
|
||||
132
backend/tests/test_plans_public.py
Normal file
132
backend/tests/test_plans_public.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Integration tests for the public plans endpoint.
|
||||
|
||||
Covers GET /api/v1/plans/public — the marketing /pricing page data source.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import delete
|
||||
|
||||
from app.models.plan_billing import PlanBilling
|
||||
from app.models.plan_limits import PlanLimits
|
||||
|
||||
|
||||
async def _seed_plan_limits(test_db, plan: str, max_users: int | None) -> None:
|
||||
"""Ensure a plan_limits row exists for the given plan name."""
|
||||
existing = await test_db.get(PlanLimits, plan)
|
||||
if existing is None:
|
||||
test_db.add(
|
||||
PlanLimits(
|
||||
plan=plan,
|
||||
max_trees=None,
|
||||
max_sessions_per_month=None,
|
||||
max_users=max_users,
|
||||
custom_branding=False,
|
||||
priority_support=False,
|
||||
export_formats=["markdown", "text"],
|
||||
)
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
|
||||
class TestGetPlansPublic:
|
||||
"""GET /api/v1/plans/public — anonymous, no auth."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_plans_public_returns_only_is_public_rows(
|
||||
self, client: AsyncClient, test_db
|
||||
):
|
||||
"""Rows with is_public=False or is_archived=True must NOT appear."""
|
||||
# Wipe any existing billing rows so this test owns the fixture state.
|
||||
await test_db.execute(delete(PlanBilling))
|
||||
await test_db.commit()
|
||||
|
||||
await _seed_plan_limits(test_db, "starter", 3)
|
||||
await _seed_plan_limits(test_db, "pro", 10)
|
||||
await _seed_plan_limits(test_db, "internal", None)
|
||||
await _seed_plan_limits(test_db, "legacy", 5)
|
||||
|
||||
test_db.add_all(
|
||||
[
|
||||
PlanBilling(
|
||||
plan="starter",
|
||||
display_name="Starter",
|
||||
monthly_price_cents=1900,
|
||||
is_public=True,
|
||||
is_archived=False,
|
||||
sort_order=10,
|
||||
),
|
||||
PlanBilling(
|
||||
plan="pro",
|
||||
display_name="Pro",
|
||||
monthly_price_cents=4900,
|
||||
is_public=True,
|
||||
is_archived=False,
|
||||
sort_order=20,
|
||||
),
|
||||
PlanBilling(
|
||||
plan="internal",
|
||||
display_name="Internal",
|
||||
is_public=False, # hidden
|
||||
is_archived=False,
|
||||
sort_order=30,
|
||||
),
|
||||
PlanBilling(
|
||||
plan="legacy",
|
||||
display_name="Legacy",
|
||||
is_public=True,
|
||||
is_archived=True, # archived
|
||||
sort_order=40,
|
||||
),
|
||||
]
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get("/api/v1/plans/public")
|
||||
assert response.status_code == 200
|
||||
plans = response.json()
|
||||
plan_names = {p["plan"] for p in plans}
|
||||
|
||||
assert "starter" in plan_names
|
||||
assert "pro" in plan_names
|
||||
assert "internal" not in plan_names
|
||||
assert "legacy" not in plan_names
|
||||
|
||||
# Schema sanity check
|
||||
starter = next(p for p in plans if p["plan"] == "starter")
|
||||
assert starter["display_name"] == "Starter"
|
||||
assert starter["monthly_price_cents"] == 1900
|
||||
assert starter["max_seats"] == 3
|
||||
assert starter["is_public"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_plans_public_orders_by_sort_order_then_plan(
|
||||
self, client: AsyncClient, test_db
|
||||
):
|
||||
"""Result must be ordered by sort_order ASC, then plan name ASC."""
|
||||
await test_db.execute(delete(PlanBilling))
|
||||
await test_db.commit()
|
||||
|
||||
# plan_limits rows for FK satisfaction
|
||||
for name in ("alpha", "bravo", "charlie", "delta"):
|
||||
await _seed_plan_limits(test_db, name, None)
|
||||
|
||||
# Two with sort_order=10 (charlie should come before delta by plan ASC),
|
||||
# one with sort_order=5 (alpha first overall),
|
||||
# one with sort_order=20 (bravo last).
|
||||
test_db.add_all(
|
||||
[
|
||||
PlanBilling(plan="charlie", display_name="C", sort_order=10, is_public=True, is_archived=False),
|
||||
PlanBilling(plan="delta", display_name="D", sort_order=10, is_public=True, is_archived=False),
|
||||
PlanBilling(plan="alpha", display_name="A", sort_order=5, is_public=True, is_archived=False),
|
||||
PlanBilling(plan="bravo", display_name="B", sort_order=20, is_public=True, is_archived=False),
|
||||
]
|
||||
)
|
||||
await test_db.commit()
|
||||
|
||||
response = await client.get("/api/v1/plans/public")
|
||||
assert response.status_code == 200
|
||||
ordered = [p["plan"] for p in response.json()]
|
||||
assert ordered == ["alpha", "charlie", "delta", "bravo"]
|
||||
134
backend/tests/test_sales_leads.py
Normal file
134
backend/tests/test_sales_leads.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Integration tests for the public Talk-to-Sales endpoint.
|
||||
|
||||
POST /api/v1/sales-leads — no auth, rate-limited 5/hour per IP.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sales_lead_creates_row_and_sends_notification_email(client, test_db):
|
||||
"""Happy path: row inserted, notification email fired, 201 returned."""
|
||||
|
||||
payload = {
|
||||
"email": "buyer@acme.example",
|
||||
"name": "Pat Buyer",
|
||||
"company": "Acme MSP",
|
||||
"team_size": "11-50",
|
||||
"message": "We're evaluating ResolutionFlow for our NOC team.",
|
||||
"source": "pricing_page",
|
||||
"posthog_distinct_id": "ph_distinct_123",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.api.endpoints.sales_leads.EmailService.send_sales_lead_notification",
|
||||
new=AsyncMock(return_value=True),
|
||||
) as mock_email:
|
||||
response = await client.post("/api/v1/sales-leads", json=payload)
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
body = response.json()
|
||||
assert body["status"] == "received"
|
||||
assert "id" in body
|
||||
|
||||
# Notification email was attempted (asyncio.create_task — give it a tick).
|
||||
import asyncio
|
||||
await asyncio.sleep(0)
|
||||
await asyncio.sleep(0)
|
||||
assert mock_email.await_count == 1
|
||||
kwargs = mock_email.await_args.kwargs
|
||||
assert kwargs["to_email"] # default placeholder until cutover
|
||||
assert kwargs["lead"].email == "buyer@acme.example"
|
||||
assert kwargs["lead"].source == "pricing_page"
|
||||
|
||||
# Row was inserted with normalized email + all fields preserved.
|
||||
result = await test_db.execute(
|
||||
sa.text("SELECT email, name, company, team_size, message, source, posthog_distinct_id, status FROM sales_leads")
|
||||
)
|
||||
rows = result.all()
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row.email == "buyer@acme.example"
|
||||
assert row.name == "Pat Buyer"
|
||||
assert row.company == "Acme MSP"
|
||||
assert row.team_size == "11-50"
|
||||
assert row.message == "We're evaluating ResolutionFlow for our NOC team."
|
||||
assert row.source == "pricing_page"
|
||||
assert row.posthog_distinct_id == "ph_distinct_123"
|
||||
assert row.status == "new"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sales_lead_email_failure_does_not_fail_request(client, test_db):
|
||||
"""If the email send raises, the API still returns 201 and the row persists."""
|
||||
|
||||
payload = {
|
||||
"email": "buyer2@acme.example",
|
||||
"name": "Sam Lead",
|
||||
"company": "Acme MSP",
|
||||
"source": "register_footer",
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.api.endpoints.sales_leads.EmailService.send_sales_lead_notification",
|
||||
new=AsyncMock(side_effect=RuntimeError("resend exploded")),
|
||||
):
|
||||
response = await client.post("/api/v1/sales-leads", json=payload)
|
||||
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
# Row must still be persisted even though email failed.
|
||||
import asyncio
|
||||
await asyncio.sleep(0)
|
||||
result = await test_db.execute(
|
||||
sa.text("SELECT count(*) FROM sales_leads WHERE email = 'buyer2@acme.example'")
|
||||
)
|
||||
assert result.scalar() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sales_lead_rate_limited_after_5_per_hour(client):
|
||||
"""The 6th submission within an hour from the same IP returns 429.
|
||||
|
||||
The default `limiter` is disabled in tests (DEBUG=true). We re-enable it
|
||||
for this test, then reset its state on teardown so other tests aren't
|
||||
affected.
|
||||
"""
|
||||
from app.core.rate_limit import limiter
|
||||
|
||||
was_enabled = limiter.enabled
|
||||
limiter.enabled = True
|
||||
try:
|
||||
limiter.reset()
|
||||
|
||||
with patch(
|
||||
"app.api.endpoints.sales_leads.EmailService.send_sales_lead_notification",
|
||||
new=AsyncMock(return_value=True),
|
||||
):
|
||||
for i in range(5):
|
||||
payload = {
|
||||
"email": f"lead{i}@acme.example",
|
||||
"name": f"Lead {i}",
|
||||
"company": "Acme MSP",
|
||||
"source": "landing_page",
|
||||
}
|
||||
resp = await client.post("/api/v1/sales-leads", json=payload)
|
||||
assert resp.status_code == 201, f"submission {i}: {resp.text}"
|
||||
|
||||
# 6th should be rate-limited.
|
||||
resp = await client.post(
|
||||
"/api/v1/sales-leads",
|
||||
json={
|
||||
"email": "lead6@acme.example",
|
||||
"name": "Lead 6",
|
||||
"company": "Acme MSP",
|
||||
"source": "landing_page",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 429, resp.text
|
||||
finally:
|
||||
limiter.reset()
|
||||
limiter.enabled = was_enabled
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import select
|
||||
from app.api.endpoints.session_handoffs import stream_escalations
|
||||
from app.core.escalation_bus import bus as escalation_bus
|
||||
from app.models.ai_session import AISession
|
||||
from app.models.session_handoff import SessionHandoff
|
||||
from app.models.user import User
|
||||
from app.services.handoff_manager import HandoffManager
|
||||
|
||||
@@ -23,16 +24,15 @@ def stub_ai_assessment():
|
||||
"""Endpoint tests should not wait on the external AI assessment path."""
|
||||
with patch.object(
|
||||
HandoffManager,
|
||||
"_generate_ai_assessment",
|
||||
"_generate_handoff_summary",
|
||||
new=AsyncMock(
|
||||
return_value=(
|
||||
"Stub escalation assessment",
|
||||
{
|
||||
"likely_cause": "Stub",
|
||||
"suggested_steps": [],
|
||||
"confidence": "medium",
|
||||
},
|
||||
)
|
||||
return_value={
|
||||
"summary_prose": "Stub escalation assessment",
|
||||
"what_we_know": [],
|
||||
"likely_cause": "Stub",
|
||||
"suggested_steps": [],
|
||||
"confidence": "medium",
|
||||
}
|
||||
),
|
||||
):
|
||||
yield
|
||||
@@ -197,8 +197,19 @@ async def test_claim_allowed_for_engineer_role(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""POST /handoffs/{id}/claim succeeds for engineer-or-admin roles."""
|
||||
original_engineer = User(
|
||||
email="original-engineer@example.com",
|
||||
password_hash="x",
|
||||
name="Original Engineer",
|
||||
role="engineer",
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
account_role="engineer",
|
||||
)
|
||||
test_db.add(original_engineer)
|
||||
await test_db.flush()
|
||||
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
user_id=original_engineer.id,
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
@@ -208,21 +219,106 @@ async def test_claim_allowed_for_engineer_role(
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.commit()
|
||||
await test_db.flush()
|
||||
|
||||
create_resp = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/handoff",
|
||||
headers=auth_headers,
|
||||
json={"intent": "escalate", "engineer_notes": "Need help"},
|
||||
handoff = SessionHandoff(
|
||||
session_id=session.id,
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
handed_off_by=original_engineer.id,
|
||||
intent="escalate",
|
||||
snapshot={"problem_summary": "test"},
|
||||
engineer_notes="Need help",
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
handoff_id = create_resp.json()["id"]
|
||||
test_db.add(handoff)
|
||||
await test_db.commit()
|
||||
|
||||
# Default test_user role is "owner", which passes engineer-or-admin.
|
||||
claim_resp = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff_id}/claim",
|
||||
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff.id}/claim",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert claim_resp.status_code == 200
|
||||
assert claim_resp.json()["claimed_by"] == test_user["user_data"]["id"]
|
||||
assert claim_resp.json()["claimed_at"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_rejects_self_claim(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""POST /handoffs/{id}/claim returns 403 for the original escalator."""
|
||||
session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="guided",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "test"},
|
||||
status="escalated",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(session)
|
||||
await test_db.flush()
|
||||
|
||||
handoff = SessionHandoff(
|
||||
session_id=session.id,
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
handed_off_by=test_user["user_data"]["id"],
|
||||
intent="escalate",
|
||||
snapshot={"problem_summary": "test"},
|
||||
engineer_notes="Need help",
|
||||
)
|
||||
test_db.add(handoff)
|
||||
await test_db.commit()
|
||||
|
||||
claim_resp = await client.post(
|
||||
f"/api/v1/ai-sessions/{session.id}/handoffs/{handoff.id}/claim",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert claim_resp.status_code == 403
|
||||
assert "own handoff" in claim_resp.json()["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_escalation_queue_excludes_own_escalations(
|
||||
client: AsyncClient, test_user, auth_headers, test_db
|
||||
):
|
||||
"""The post-escalation dashboard queue should not show your own handoff."""
|
||||
own_session = AISession(
|
||||
user_id=test_user["user_data"]["id"],
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="chat",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "own"},
|
||||
status="escalated",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
other_engineer = User(
|
||||
email="other-engineer@example.com",
|
||||
password_hash="x",
|
||||
name="Other Engineer",
|
||||
role="engineer",
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
account_role="engineer",
|
||||
)
|
||||
test_db.add_all([own_session, other_engineer])
|
||||
await test_db.flush()
|
||||
other_session = AISession(
|
||||
user_id=other_engineer.id,
|
||||
account_id=test_user["user_data"]["account_id"],
|
||||
session_type="chat",
|
||||
intake_type="free_text",
|
||||
intake_content={"text": "other"},
|
||||
status="escalated",
|
||||
confidence_tier="discovery",
|
||||
conversation_messages=[],
|
||||
)
|
||||
test_db.add(other_session)
|
||||
await test_db.commit()
|
||||
|
||||
resp = await client.get("/api/v1/ai-sessions/escalation-queue", headers=auth_headers)
|
||||
assert resp.status_code == 200
|
||||
ids = {item["id"] for item in resp.json()}
|
||||
assert str(own_session.id) not in ids
|
||||
assert str(other_session.id) in ids
|
||||
|
||||
319
backend/tests/test_stripe_webhook_handler.py
Normal file
319
backend/tests/test_stripe_webhook_handler.py
Normal file
@@ -0,0 +1,319 @@
|
||||
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
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Atomic-idempotency regression tests
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_event_handler_failure_does_not_persist_idempotency_mark(
|
||||
test_db, test_user,
|
||||
):
|
||||
"""If the handler raises, the StripeEvent row must NOT be persisted —
|
||||
otherwise Stripe's retry will be silently dropped as a duplicate and the
|
||||
subscription state will desync from Stripe."""
|
||||
from app.services.billing import BillingService
|
||||
from app.models.stripe_event import StripeEvent
|
||||
|
||||
event_id = "evt_handler_fail_1"
|
||||
payload = {"data": {"object": {
|
||||
"id": "sub_doesnotmatter",
|
||||
"status": "active",
|
||||
"current_period_start": 1714521600,
|
||||
"current_period_end": 1717113600,
|
||||
"items": {"data": [{"quantity": 1}]},
|
||||
"cancel_at_period_end": False,
|
||||
}}}
|
||||
|
||||
boom = RuntimeError("simulated handler failure")
|
||||
with patch(
|
||||
"app.services.billing._handle_subscription_updated",
|
||||
side_effect=boom,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="simulated handler failure"):
|
||||
await BillingService.apply_subscription_event(
|
||||
test_db,
|
||||
event_id=event_id,
|
||||
event_type="customer.subscription.updated",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
# The StripeEvent row must not exist — handler raised, the entire
|
||||
# transaction (idempotency mark + partial mutations) was rolled back.
|
||||
row = (await test_db.execute(
|
||||
select(StripeEvent).where(StripeEvent.id == event_id)
|
||||
)).scalar_one_or_none()
|
||||
assert row is None, (
|
||||
"StripeEvent row was persisted despite handler failure — "
|
||||
"Stripe retry will be silently dropped"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_event_retry_after_failure_succeeds(
|
||||
test_db, test_user,
|
||||
):
|
||||
"""A failed first attempt followed by a successful retry must apply state.
|
||||
This is the core Stripe webhook retry contract."""
|
||||
from app.services.billing import BillingService
|
||||
from app.models.stripe_event import StripeEvent
|
||||
|
||||
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",
|
||||
stripe_subscription_id="sub_retry",
|
||||
))
|
||||
await test_db.commit()
|
||||
|
||||
event_id = "evt_retry_1"
|
||||
payload = {"data": {"object": {
|
||||
"id": "sub_retry",
|
||||
"status": "active",
|
||||
"current_period_start": 1714521600,
|
||||
"current_period_end": 1717113600,
|
||||
"items": {"data": [{"quantity": 3}]},
|
||||
"cancel_at_period_end": False,
|
||||
}}}
|
||||
|
||||
# First attempt — handler raises mid-flight.
|
||||
with patch(
|
||||
"app.services.billing._handle_subscription_updated",
|
||||
side_effect=RuntimeError("transient blip"),
|
||||
):
|
||||
with pytest.raises(RuntimeError):
|
||||
await BillingService.apply_subscription_event(
|
||||
test_db,
|
||||
event_id=event_id,
|
||||
event_type="customer.subscription.updated",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
# No idempotency mark, sub still trialing.
|
||||
row = (await test_db.execute(
|
||||
select(StripeEvent).where(StripeEvent.id == event_id)
|
||||
)).scalar_one_or_none()
|
||||
assert row is None
|
||||
sub = (await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account_id)
|
||||
)).scalar_one()
|
||||
assert sub.status == "trialing"
|
||||
|
||||
# Second attempt — same event_id, handler succeeds.
|
||||
applied = await BillingService.apply_subscription_event(
|
||||
test_db,
|
||||
event_id=event_id,
|
||||
event_type="customer.subscription.updated",
|
||||
payload=payload,
|
||||
)
|
||||
assert applied is True
|
||||
|
||||
# Idempotency mark now persisted, sub state reconciled.
|
||||
row = (await test_db.execute(
|
||||
select(StripeEvent).where(StripeEvent.id == event_id)
|
||||
)).scalar_one()
|
||||
assert row.id == event_id
|
||||
await test_db.refresh(sub)
|
||||
assert sub.status == "active"
|
||||
assert sub.seat_limit == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_event_duplicate_event_id_skips(
|
||||
test_db, test_user,
|
||||
):
|
||||
"""Two successful invocations with the same event_id must not double-apply.
|
||||
Second call returns False; mutations are not repeated."""
|
||||
from app.services.billing import BillingService
|
||||
|
||||
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",
|
||||
stripe_subscription_id="sub_dup",
|
||||
))
|
||||
await test_db.commit()
|
||||
|
||||
event_id = "evt_dedupe_1"
|
||||
payload = {"data": {"object": {
|
||||
"id": "sub_dup",
|
||||
"status": "active",
|
||||
"current_period_start": 1714521600,
|
||||
"current_period_end": 1717113600,
|
||||
"items": {"data": [{"quantity": 7}]},
|
||||
"cancel_at_period_end": False,
|
||||
}}}
|
||||
|
||||
applied1 = await BillingService.apply_subscription_event(
|
||||
test_db,
|
||||
event_id=event_id,
|
||||
event_type="customer.subscription.updated",
|
||||
payload=payload,
|
||||
)
|
||||
assert applied1 is True
|
||||
|
||||
sub = (await test_db.execute(
|
||||
select(Subscription).where(Subscription.account_id == account_id)
|
||||
)).scalar_one()
|
||||
assert sub.status == "active"
|
||||
assert sub.seat_limit == 7
|
||||
|
||||
# Mutate locally so we can prove the second call doesn't re-run the handler.
|
||||
sub.seat_limit = 99
|
||||
await test_db.commit()
|
||||
|
||||
applied2 = await BillingService.apply_subscription_event(
|
||||
test_db,
|
||||
event_id=event_id,
|
||||
event_type="customer.subscription.updated",
|
||||
payload=payload,
|
||||
)
|
||||
assert applied2 is False
|
||||
|
||||
await test_db.refresh(sub)
|
||||
# Handler did NOT run again — our local mutation is preserved.
|
||||
assert sub.seat_limit == 99
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user