Self-serve signup: account, owner and plan in one request
Go · Go · intermediate · modification
Adds self-serve signup so sales no longer creates trial accounts by hand. One request creates the account, its owner and a trial subscription to the plan picked on the pricing page, all in a single transaction, so a failed signup leaves nothing half-created. Validation reuses the signup form's `Request.Normalize`, a duplicate email gets a 409, a plan code that no longer exists gets a 422 with a clear message, and the owner receives the welcome email with the sign-in link. A mail provider outage is logged and does not fail the signup.
Go 1.22 API on `net/http` (method and path patterns) and `database/sql` with the pgx stdlib driver on PostgreSQL 16, default isolation (read committed). `deps` in `cmd/api/main.go` (unchanged) already carries `DB *sql.DB`, `Mailer mail.Sender`, `Log *slog.Logger` and the `PublicLimits` middleware (per-IP rate limit and bot check on every public route). Existing, unchanged: `signup.Request` with `Normalize() error` (trims fields, lowercases the email, returns an error whose message is safe to show); `auth.HashPassword` (argon2id); `db.IsUniqueViolation` / `db.IsForeignKeyViolation` (SQLSTATE 23505 / 23503, false for nil). Every writer of `users.email` stores it lowercased via `Normalize`, so the plain unique column is enough; `httpx.WriteJSON` and `httpx.WriteError` (writes `{"error": msg}`); `mail.Welcome(to, name, company)` builds the welcome message with the login link. `mail.Sender.Send(ctx, msg)` posts to the mail provider's HTTPS API with its own 10-second timeout; a message the provider accepted cannot be recalled. Schema: `plans` holds only the public self-serve plans, and a trial of any of them is fine; `users.email` is the only unique constraint a signup can hit; `subscriptions.account_id` references `accounts(id)` and `subscriptions.plan_code` references `plans(code)`.
Requirements
- `POST /api/signup` (public) takes a JSON body `{company, name, email, password, plan}`, checked and normalised by the existing `Request.Normalize` (400 with its message when it fails). Anything after the JSON object is ignored, as in every handler of this service. On success it answers 201 with `{"accountId": <id>}`; the owner signs in afterwards on the login page.
- A signup is all or nothing (a commit whose outcome is unknown after a lost connection is out of scope): the account, its owner user (role `owner`) and a `trialing` subscription to the chosen plan are created in one database transaction, and after any failure none of the three exists.
- `plan` is a plan code from the pricing page. Retired codes are deleted from `plans`, so a pricing page cached before a price change can still send one; `subscriptions.plan_code` references `plans(code)`, and the handler answers that foreign-key violation with 422 `{"error": "unknown plan"}`. An email that already has a user answers 409 (a product decision: the form then tells the visitor to sign in instead). Any other failure answers 500 with a generic message; the detail goes to the log.
- The new owner gets the welcome email for a signup that succeeded, and never for a signup that failed: everyone who receives it must be able to sign in with the address it was sent to. Delivery is best effort: when the mail provider returns an error the signup still succeeds, the error is logged with the user id, and support resends the email by hand.
Files touched
- cmd/api/routes.go
- internal/signup/handler.go
--- cmd/api/routes.go
package main
-import "net/http"
+import (
+ "net/http"
+
+ "example.com/workspace/internal/signup"
+)
func routes(d deps) http.Handler {
})
mux.Handle("POST /api/login", d.Login)
+ mux.Handle("POST /api/signup", &signup.Handler{DB: d.DB, Mailer: d.Mailer, Log: d.Log})
mux.Handle("GET /api/me", d.Me)
return d.PublicLimits(mux)
--- internal/signup/handler.go
+package signup
+
+import (
+ "database/sql"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+
+ "example.com/workspace/internal/auth"
+ "example.com/workspace/internal/db"
+ "example.com/workspace/internal/httpx"
+ "example.com/workspace/internal/mail"
+)