Let operators complete or delete sign-ups

Task 10412 · 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: 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. Done means (epic): on a seeded stack, an email-only sign-up and a draft org both appear in Customers with their Stage; the Trial count equals the Customers list filtered by Trial; an operator verifies the email, fills the profile, approves, and archives or deletes a sign-up through the UI; every action is audited; the security-sensitive verify route passes the security review lane; `./dc check quick` and the changed-area tests are green. ## Settled decisions - D2 ("Verify creates the org"): operator *Verify email* (row ⋯ menu, new operator-only route) marks the `email_signups` row verified, then creates the draft org and its owner with no password; from then on it is an ordinary draft customer edited on the customer page; the owner sets a password through the existing reset/invite link. - D6 ("Delete only" — "No extra buttons like Archive. Delete"): no Archive button. For an org-backed sign-up the existing ⋯ *Delete* is the soft delete, Restore works as for any customer. For an email-only sign-up *Delete* removes the `email_signups` row and is audited. - D7: operator may submit and approve without the applicant's clickwrap; approving records NO acceptance on the owner's behalf (the sibling terms-gate child's predicate relies on this). - Architect Q2 ("Approve does both"): ⋯ *Review sign-up* → Approve on a Sign-up row stamps the draft submitted (operator named in audit, no clickwrap) and approves it in one transaction, then the normal post-approval work. Q3: after Verify the row is a draft at *Profile* (or *Plan* when the stored profile is complete). Q4: paid plan with no card is approvable; no subscription starts. ## Depends on the roster child (index 0) It provides the pinned `GET /api/customers` row shape (`kind: "org"|"email_signup"`, `stage`, `signup` facts, email rows with `id = email_signups.id`) that the ⋯ items hang on, and `apps/api/app/signup_stage.py` that the approve-completeness check and the PATCH response import. ## Approach (Architecture §2.4, §2.7 Overview) All routes operator-only: `require_operator` + `@feature("system", ...)` as `signup_backlog.py`; every write calls `record_write` with the operator as actor. - **Verify email** `POST /api/operator/signups/{signup_id}/verify` (`signup_backlog.py`, `feature("system","edit")`) → `200 {"org_id"}`. Lock the open row (`open_row_by_id(lock=True)`), stamp `verified_at` spending both secrets as `_verified` does; audit `signup_email_verified` (`actor=operator`, `data.by="operator"`). Then `signup.create_signup(pending=PendingSignup("email", str(row.id), row.email), application, acceptance=None, plan_key=row.profile.get("plan_key") if row.profile else None, password_hash=row.password_hash)` on a session of its own (the `_resume` pattern); audit `signup_draft_created` in the new org. Application from `signup_draft.stored_profile(row.profile)`, else minimal (`account_type=individual`, empty names, `org_name = signup.suggested_org_name(pending)` or the email local part); relax `Application` only for this caller, never write placeholder names. Idempotent: verified row whose email already has a draft returns that `org_id` (`signup.draft_org_id`). 404 unknown/consumed; 409 `account_exists` (`signup.existing_user`). - **Delete email sign-up** `DELETE /api/operator/signups/{signup_id}` → 204; hard-deletes the open row; audit `signup_email_deleted` in org zero; 409 when a draft org exists for the email; 404 unknown/consumed. - **Edit the sign-up** `PATCH /api/customers/{org_id}/signup` (`customer_roster.py`, `superadmin_only(Tenancy.OPERATOR)` + `require_operator`). Optional body: `first_name, last_name, account_type, org_name, country, occupation, intended_use, plan_key`. 409 unless `lifecycle == pending`. `plan_key` checked with `signup_plans.active_row` and `self_serve_rules.refuse_second_free`; sets `org_signups.plan_key` and `plan_confirmed_at`. Runs under `seam.bound_to(org_id)`. Audit `signup_edited` with `data.fields=[names changed]` (names only, no values). Returns the customer row with the new `stage`. - **Approve a draft**: `routes/orgs.py::_pending_for_decision` gains `allow_draft: bool`; approve passes True, reject keeps refusing drafts. Approve refuses a draft at stage `profile` with 409 `{"code":"signup_incomplete"}` (card shows "Fill the sign-up first"). For a draft, approve first calls new `signup_draft.submit_by_operator(session, org_id, operator)`: stamps `submitted_at`/`plan_confirmed_at`/`signed_up_at`, consumes an open email row, audit `org_signup` with actor operator and `data.submitted_by="operator"`, writes **no** `LegalAcceptance`; then the existing `org_approval.approve` in the same transaction. The approve body's existing `plan_key` governs, else `apply_choice`'s default. Bump the owner's `session_epoch`. - **Delete/restore a draft**: existing `DELETE /api/orgs/{id}` and `POST /restore`, unchanged (ADR-0163 soft delete). - **Web**: email rows' ⋯ menu adds *Verify email* (then navigates to `/customers/{org_id}`) and *Delete* (confirm names the email) beside the roster child's *Resend code*; draft rows get *Review sign-up* (approve) plus the existing menu. `OverviewTab.tsx`: for a `signup` or `pending` org, a *Sign-up* section of `DetailField`s editing in place behind the page's one Save bar — account type, owner first/last name, org name, country, occupation, intended use, plan (picker over offered and active plans) — calling the PATCH; the Stage chip refreshes after save. No modal, no *Advance* control (`docs/standards/ui-style-guide.md`). - **Security review lane** over the verify, delete-email-signup and `PATCH /signup` routes (`docs/policies/code-review-policy.md` §Autonomous review profile). - **Last child to land on the dev oracles**: after merge, re-run on `app.dev.jaah.ai` the draft-bearing flows the phase-1 children could only do best-effort — AC1 (draft row with its Stage), AC2, AC5 (Trial count with a draft present), and, if the terms-gate child has merged, AC9/AC10 — seeding the draft by operator Verify on an email-only row then editing its plan. Report results; file a follow-up for any failure that is not this child's. 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). AC9 owner sign-in on dev — the seeded owner's own password via the reset link to an address the tester controls — deferred: depends on a reachable mailbox; the API test is the gating oracle. AC10 billing hold on dev — dev Stripe test mode through the console; API test with gateway faked — verified for the API test. API and unit tests, `./dc check quick` — worktree only — verified. AC17 Jaah Deploy watch — `jaah-forge` — verified by the charter's forge contract. Dev seeding: the email-only row via the public sign-up page stopping before the code; the draft via operator Verify email on a second email-only row, then editing its plan (no mailbox needed). 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/*`), apart from honouring an operator-created draft when its owner resumes. 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 let tenants reach the verify or delete-email-signup routes; they are operator only (`require_operator` + `system` feature, as `signup_backlog.py`). Do NOT set or reveal a user password from the operator side. Do NOT write a `LegalAcceptance` in `submit_by_operator`. Do NOT build the terms gate/billing hold or the purge retirement (sibling children). No feature flag. Done means: - AC6 Verify email. On an email-only row, ⋯ → *Verify email*. The row becomes a draft org at Stage **Profile**, or **Plan** when the stored profile is complete (amended by Architect Q3 "Skip Password" from "at Stage **Password**"), its customer page opens for editing, and the audit records `actor = the operator`. A non-operator or tenant caller of the route gets 403 or 404. A second call is idempotent: no second org. → verify: browser flow, Audit › the org shows the event; API tests for authz, idempotency and an already-consumed row. - AC7 Complete by editing. On the draft's customer page, the operator fills the profile and org data and the plan by editing in place. The Stage advances with each saved edit. No "advance" control exists anywhere. → verify: browser flow; `grep -rni "advance" apps/web/src/pages/customers` shows no new control. - AC8 Submit and approve. The operator approves the draft through the existing review/approve path. The org becomes enabled at Stage **Approved**, and the submit and approve audits name the operator. → verify: browser flow and the Audit tab. API tests also: approve of a `profile`-stage draft → 409 `signup_incomplete`; reject still refuses a draft; no `LegalAcceptance` row after operator approve. - AC11 Delete and archive. ⋯ *Delete* on a draft soft-deletes it: it is listed under *Deleted* with Restore, and it leaves the Subscribers count. ⋯ *Delete* on an email-only row removes it. Both are audited with the actor. No Archive button exists. → verify: browser flow; API tests (incl. 409 when the email has a draft org). - AC14 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 Every action audited. Verify, edit, submit, approve, delete and resend each write an activity event naming the operator. → verify: API tests; Audit on dev. - AC16 (verify, delete, PATCH) Security lane. The verify route, the delete-email-signup route and `PATCH /signup` pass the autonomous security review lane (`docs/policies/code-review-policy.md` §Autonomous review profile). → verify: the review report attached to the PR. - Dev re-run (last-to-land): AC1, AC2, AC5 (and AC9/AC10 when available) pass on `app.dev.jaah.ai` with a draft present; cite screenshots/figures. - Quality bar (tightened): the Overview Sign-up section reads as ordinary edit-in-place fields behind the page's one Save bar at desktop and phone width; errors (409 `signup_incomplete`, `account_exists`) show an actionable message, never a raw code. - 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`.

Outcome

Operators can now verify, edit in place, approve and delete unfinished sign-ups from Customers, all audited. PR #7065 merged (491def8be); quick gate and review lanes green, Jaah Deploy succeeded, dev AC1/2/5/11 pass. Follow-up draft #10461. Memory: used mem-25288; proposed 1 gotcha.

Pull request

Artifacts

Activity

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