What β move the account-signup logic out of the HTTP controller into a service, so the controller only handles the request and the response.
Why β signup is currently 248 lines at cyclomatic complexity 61, the second-heaviest function in the stack. It mixes HTTP concerns with capacity gating, invite eligibility, user creation, email verification and organization provisioning, which makes every one of those untestable without an HTTP round-trip.
Scope β modules/auth/controllers/auth.controller.js:67-314 (verified on master by AST parse). Extract to modules/auth/services/auth.signup.service.js; the controller keeps only the response block and error mapping.
This issue deliberately contains no code sketch. Two review rounds found that pre-written sketches for this function encoded wrong call signatures. Read the function, move it, and preserve the invariants below.
Invariants that must survive β verify each against the code before you finish:
- The
422 fallback is responses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err). The trailing (err) is load-bearing: it drives errorCode derivation, the description fallback chain, and the serialized error field attached in non-production environments. Keep the title, the errors.getMessage derivation and the trailing argument exactly.
isMailerConfigured and sendVerificationEmail are controller-local helpers, not exports of the mailer lib. Both have other callers outside signup β locate every call site before moving them, and re-import both wherever they are still used. Moving one and forgetting the other breaks live endpoints.
- Invite lifecycle:
finalize must remain the last pre-response step and must run after organization provisioning. The organization-failure path must still call release(). A return shape of {user, invite} alone is insufficient β the response block also reads the organization result.
- Every distinct error status inside the moved region keeps its own status, title and message. Do not collapse them.
Definition of done: the existing auth unit + integration + e2e suites pass with the same counts as before the change, plus new direct unit tests for the gates that previously needed an HTTP round-trip (capacity rejection, invite release on capacity gate, invite release on organization failure, no auto-verify for an invited account when the mailer is off, finalize ordering).
Automerge: no
Scope: validated 2026-07-28
Created via /dev:issue
Baseline: line numbers and counts here were verified against master @ c8049d69 (2026-07-28). Master moves β re-verify against current master before starting; the invariants above are what must hold regardless of where the code has drifted to.
What β move the account-signup logic out of the HTTP controller into a service, so the controller only handles the request and the response.
Why β
signupis currently 248 lines at cyclomatic complexity 61, the second-heaviest function in the stack. It mixes HTTP concerns with capacity gating, invite eligibility, user creation, email verification and organization provisioning, which makes every one of those untestable without an HTTP round-trip.Scope β
modules/auth/controllers/auth.controller.js:67-314(verified on master by AST parse). Extract tomodules/auth/services/auth.signup.service.js; the controller keeps only the response block and error mapping.This issue deliberately contains no code sketch. Two review rounds found that pre-written sketches for this function encoded wrong call signatures. Read the function, move it, and preserve the invariants below.
Invariants that must survive β verify each against the code before you finish:
422fallback isresponses.error(res, 422, 'Unprocessable Entity', errors.getMessage(err))(err). The trailing(err)is load-bearing: it driveserrorCodederivation, the description fallback chain, and the serializederrorfield attached in non-production environments. Keep the title, theerrors.getMessagederivation and the trailing argument exactly.isMailerConfiguredandsendVerificationEmailare controller-local helpers, not exports of the mailer lib. Both have other callers outsidesignupβ locate every call site before moving them, and re-import both wherever they are still used. Moving one and forgetting the other breaks live endpoints.finalizemust remain the last pre-response step and must run after organization provisioning. The organization-failure path must still callrelease(). A return shape of{user, invite}alone is insufficient β the response block also reads the organization result.Definition of done: the existing auth unit + integration + e2e suites pass with the same counts as before the change, plus new direct unit tests for the gates that previously needed an HTTP round-trip (capacity rejection, invite release on capacity gate, invite release on organization failure, no auto-verify for an invited account when the mailer is off, finalize ordering).
Automerge: no
Scope: validated 2026-07-28
Created via /dev:issue
Baseline: line numbers and counts here were verified against
master @ c8049d69(2026-07-28). Master moves β re-verify against current master before starting; the invariants above are what must hold regardless of where the code has drifted to.