Show sign-ups in Customers roster

Task 10410 · Done · Coder · created · updated

## Original requirement (verbatim, epic Task 10379 → 10399) Ask (operator, from Task 10379): the Trial plan's "Subscribers" count includes orgs the operator cannot find in Customers: unsubmitted sign-up drafts (`signup.drafts()`, hidden by `signup.not_a_draft()` in `apps/api/app/routes/customer_roster.py::_org_filters`) and email sign-ups with no org yet (the separate Sign-ups tab). Make every unfinished sign-up visible and actionable in the one Customers roster. Do not add new buttons; reuse existing patterns (roster row, row menu, the customer's edit-in-place page). Requirements: 1. Merge the Sign-ups tab into the Customers roster. Each unfinished sign-up (email-only or draft org) is a row with status "Sign-up" and a Stage column (Verify email → Password → Profile → Plan → Card → Submitted → Approved; `signup_draft.STEPS`). A status filter value shows sign-ups alone. Remove the separate Sign-ups tab. 2. The plan "Subscribers" count (`billing_plans._members`) and the Customers plan filter count the same orgs, so the count always matches the list. Soft-deleted orgs stay excluded. 3. An operator can complete a sign-up on the user's behalf: verify the email (new operator-only route, audited with the actor), fill the required profile/org data by editing the customer, and submit and approve the org. 4. An operator can archive an unfinished sign-up by editing the customer's data (the same soft-delete/archive as any customer), or delete it outright, from the existing row/detail menus. 5. Advancing a stage is an ordinary edit of the customer, not a separate "advance" function. **This child covers requirements 1 and 2 (and the roster half of the "no new buttons" constraint).** Requirements 3–5 belong to sibling children. ## Settled decisions you build on (PRD Task 10397, Architecture Task 10398) - D1: email-only and draft orgs have status **Sign-up**; submitted applications keep **Pending** with Approve/Reject cards. **Stage column on every row**: a sign-up's step; `Submitted` for pending orgs; `Approved` for enabled/disabled/rejected; a deleted row shows the stage it had. - D3 labels: Verify email → Password → Profile → Plan → Billing details → Card → Submitted → Approved (free plan skips Billing details and Card). - D5: `_members` and the roster plan filter count orgs with `deleted_at IS NULL`, drafts included, operator-plane orgs excluded; an email-only sign-up has no plan. - D9: *Resend code* moves to the email-only row's ⋯ menu; code-email SES status + reason, sends, code sent, started go into the row's hover/tap facts; summary line "N unfinished · N unverified · N verified, no org" shows in the roster summary while the Sign-up filter is on. - Assumed: email-only row title = the email, slug band empty; search `q` matches sign-up emails; default "Any status" includes sign-ups. - Architect Q1: a draft and a pending org show and count under their chosen plan (`org_signups.plan_key`). Q3: an org-backed draft never shows *Password* (only an email-only row does). ## Approach (Architecture §2.1–2.3, §2.7 roster part) - **Stage, single home: new `apps/api/app/signup_stage.py`.** `Stage = Literal["verify_email","password","profile","plan","billing_details","card","submitted","approved"]`. `stage_of_email(row)`: `verified_at` NULL → verify_email; no `password_hash` → password; else → profile. `stage_of_org(org, signup, owner, plan_paid)`, first match wins: not a draft → `pending` ⇒ submitted, otherwise (or no `org_signups` row) approved; draft with any `signup_draft.PROFILE_FIELDS[account_type]` answer empty or `org_signups.country` empty → profile; `plan_confirmed_at` NULL or free plan → plan; paid and `org_signups.billing_saved_at` NULL → billing_details else card. Never call Stripe from the roster. - **Additive migration**: nullable timestamptz `org_signups.billing_saved_at`. Stamp it in `signup_draft.save_billing` and in the operator details save (`routes/billing_orgs.py:628`) when the org has an unsubmitted `org_signups` row. No backfill (deferred). - **Counted plan, single home `app/signup_plans.py::counted_plan_key()`**: `coalesce(case((Org.lifecycle == ORG_PENDING, select(OrgSignup.plan_key).where(OrgSignup.org_id == Org.id).scalar_subquery()), else_=None), Org.plan_key)`. `billing_plans._members` → `select(counted_plan_key(), count()).select_from(Org).where(Org.deleted_at IS NULL, Org.id NOT IN OPERATOR_PLANE_ORG_IDS)` grouped by it. Roster plan filter uses the same expression plus the existing operator-plane exclusion; `PlanOut` for pending/draft rows names the counted plan. - **Roster (`routes/customer_roster.py`)**: `Status` gains `"signup"`; `_status` returns signup for a non-deleted draft. `_org_filters` drops the unconditional `not_a_draft()`: `status=signup` → drafts only; `status=pending` → pending AND not a draft; other values exclude drafts; None includes drafts and email rows. Email rows included only when `status in (None,"signup")` and no plan/billing/hostings/attention/org filter, sourced from new `email_signup.open_unclaimed(session, q)` (`consumed_at IS NULL`, org zero, **no draft org for the same email** — anti-join `identities.email` → `admin_users` → `drafts()`; `q` matches the email), capped at 500 newest first (summary counts stay exact). Once email rows are present, build every row and page the combined list through `list_page`. Default order: operator org, pending (oldest first), sign-ups (newest first), rest by name. Batch owner and `org_signups` reads for kept ids (one query each); no per-row query. Refactor `email_signup.unfinished`'s last-mail lookup into `email_signup.facts(session, rows)`. - **Pinned contract — `GET /api/customers`** (sibling child "Let operators complete or delete sign-ups" consumes it; do not deviate): ```py Status = Literal["signup","pending","enabled","disabled","rejected","deleted"] Stage = Literal["verify_email","password","profile","plan","billing_details","card","submitted","approved"] class SignupFactsOut(BaseModel): # email-only rows only email: str; started_at: datetime; code_sent_at: Optional[datetime]; sends: int verified_at: Optional[datetime]; last_email: Optional[LastEmailOut] # {status, detail, at} class CustomerRowOut(...): # existing fields kept kind: Literal["org","email_signup"] = "org" stage: Stage signup: Optional[SignupFactsOut] = None # email_signup rows: id = email_signups.id, name = email, slug = "", plan None, products/users/activity 0, billing fields None, pending False class SignupCountsOut(BaseModel): total: int; unverified: int; verified: int class CustomersOut(...): signups: Optional[SignupCountsOut] = None # set only when status == "signup" ``` `total` = email rows + drafts; `unverified` = email rows with `verified_at` NULL; `verified` = email rows with `verified_at` set. - **Resend audit (critic finding — not audited today)**: keep `POST /api/operator/signups/{id}/resend` behaviour, but add a `record_write` naming the operator as actor (kind e.g. `signup_code_resent`, with its `activity_kinds.py` entry) and an API test asserting the event. - **Web (`apps/web/src/pages/customers/`, `CustomersPage.tsx`, `lib/customers.ts`)**: `STATUS_CHIP`/`STATUS_OPTIONS` gain *Sign-up*; Stage column on every row from one label map; email rows have no link target and their ⋯ menu holds *Resend code* (429 → "Try again in Ns") — the sibling child adds *Verify email*/*Delete*; hover/tap facts (`notes`) carry code email status + reason, sends, code sent, started; summary line renders while `status=signup`; titles use the feed's ellipsis at a readable width (never truncated to a few characters). **Tab removal**: delete `SignupsTab.tsx`, `SignupsTab.test.tsx`, `signupsApi.ts` (resend moves into `lib/customers.ts`), remove `signups` from `CUSTOMERS_LIST_TABS`; `?tab=signups` redirects to `/customers?status=signup` via `legacyListUrl`. Follow `docs/standards/ui-style-guide.md`. - Keep `apps/api/app/routes/signup_backlog.py`'s other routes; the sibling child adds verify/delete there. Credentials & reach: UI flows AC1–AC12, AC18 on `app.dev.jaah.ai` as operator — `org/console/dev-login` (operator login; alt `me/jaah/debug/main/operator-login`); dev control plane — verified (credential fetch succeeded from node szkhj3g7; value never recorded). API and unit tests, `./dc check quick` — the worktree only — verified. AC17 Jaah Deploy watch — `jaah-forge` (pre-authenticated for the task) — verified by the charter's forge contract. Out of scope: Do NOT change billing or Stripe pricing logic, plan push or hostings (Task 10379 owns them). Do NOT change the tenant-side sign-up flow's UI pages (`apps/web/src/pages/signup/*`). Do NOT add an Archive button, an "Advance stage" control, or any new toolbar button. Do NOT change the Tokens or Plan terms check tabs. Do NOT build the operator verify/delete/edit/approve-a-draft routes, the Overview Sign-up section, the terms gate/billing hold, or the purge retirement — sibling children own them. No feature flag. Done means: - AC1 Sign-ups are in the roster. Open Customers. Both seeded sign-ups appear with status chip **Sign-up**. The email-only row shows Stage **Verify email** and the draft shows its step (e.g. **Plan**). Titles show in full, with no truncation to a few characters. → verify: browser flow on dev, then a screenshot. (Draft half on dev is best-effort — a dev draft needs the sibling child's operator Verify or a mailbox; seed a draft on the local slot stack for the screenshot and let the API tests decide.) - AC2 Stage on every row. A pending org shows **Submitted**, an enabled customer shows **Approved**, and every Stage cell is one of the D3 labels. → verify: the same flow; plus a unit test over every `STEPS` value, every org lifecycle, and email-only verified and unverified, each mapped to a label (no state unmapped). - AC3 Sign-up filter. Status filter → *Sign-up* lists only sign-up rows and shows the D9 summary line. *Any status* includes them. Other values exclude them. → verify: browser flow; API test `GET /api/customers?status=signup` returns only sign-ups. - AC4 Tab removed. Customers shows no Sign-ups tab. `/customers?tab=signups` lands on the roster with the Sign-up filter on. `SignupsTab.tsx` is deleted and `signups` is gone from `customerTabs.ts`. → verify: browser; `grep -rn "SignupsTab" apps/web/src` returns nothing. - AC5 Count equals list. For every plan, Products › plan › **Subscribers** equals the total of Customers filtered by that plan under *Any status*. This holds with drafts present, and operator-plane and soft-deleted orgs are excluded from both. → verify: on dev, compare the Trial Subscribers figure with the Trial-filtered roster total; an API test asserts `member_count == /api/customers?plan=<key>` total across draft, pending, enabled, deleted and operator orgs. (The API test is the deciding oracle; the dev comparison "with drafts present" is best-effort until the sibling child lands.) - AC12 Resend and facts. ⋯ *Resend code* on an email-only row sends the code and shows a notice, with the cooldown respected (429 → "Try again in Ns"). Hovering the name, or tapping the disc on a phone, shows the code email's status and SES reason, sends, code sent and started. → verify: browser flow at desktop and phone width. - AC14 (roster part) No new buttons. Every new action is an item in an existing ⋯ menu or an edit-in-place field. → verify: review the diff for new top-level buttons or toolbars. - AC15 (resend part) Resend writes an activity event naming the operator. → verify: API test. - Tests also cover: `q` matches a sign-up email; a draft with an open email row is listed once (no double row); email rows capped at 500 with exact summary counts. - Quality bar (tightened): Stage and status chips read as their full labels, and hover/tap facts are legible at phone width (390px) with no overflow. A literally present but clipped or unreadable cell fails. - AC17 Gate. `./dc check quick` and the changed-area tests are green, and Jaah Deploy of the merged PR succeeds. → verify: gate output, then the deploy run on GitHub. - AC18 Available on dev. Everything above is on by default with no flag, reachable on `app.dev.jaah.ai`. → verify: the flows above on dev.

Outcome

Customers roster now lists email-only and draft sign-ups (status Sign-up) with a Stage column on every row and a Sign-up filter. The Sign-ups tab is removed. Subscribers count matches the plan-filtered list, and resend is audited. Quick gate and changed-area tests green; review lane passed; visual check at desktop and 390px OK. PR #7062 queued to the merge lane after a rebase. Memory: none applied.

Pull request

Artifacts

Activity

  • Draft
  • Ready
  • Runner started
  • Runner ended (done)