clavitor/clavitor.ai/onboarding.go

323 lines
9.4 KiB
Go

// Onboarding flow: signup → profile → plan → checkout
//
// Session is a signed cookie containing the in-progress onboarding state.
// HMAC-SHA256 keyed with ONBOARDING_SECRET (auto-generated if missing).
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
)
const onboardingCookieName = "clv_onb"
const onboardingCookieMaxAge = 3600 // 1 hour to complete the flow
// onboardingSecret is the HMAC key for signing the session cookie.
// Loaded from env or generated at startup (sessions are short-lived so
// regeneration on restart only kicks people back to step 1).
var onboardingSecret []byte
func initOnboardingSecret() {
if s := readEnv("ONBOARDING_SECRET"); s != "" {
onboardingSecret = []byte(s)
return
}
buf := make([]byte, 32)
rand.Read(buf)
onboardingSecret = buf
log.Println("onboarding: generated ephemeral session secret (set ONBOARDING_SECRET to persist)")
}
// onboardingState is what we carry across pages in the signed cookie.
// Kept small — Paddle billing fields are written to corporate.db immediately.
type onboardingState struct {
Email string `json:"email"`
Provider string `json:"provider"` // "email", "google", "apple", "meta"
CustomerID string `json:"customer_id,omitempty"` // ctm_xxx after step 1
AddressID string `json:"address_id,omitempty"` // add_xxx after step 2
CreatedAt int64 `json:"created_at"`
}
func setOnboardingCookie(w http.ResponseWriter, st *onboardingState) {
st.CreatedAt = time.Now().Unix()
payload, _ := json.Marshal(st)
encoded := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, onboardingSecret)
mac.Write([]byte(encoded))
sig := hex.EncodeToString(mac.Sum(nil))
value := encoded + "." + sig
http.SetCookie(w, &http.Cookie{
Name: onboardingCookieName,
Value: value,
Path: "/",
MaxAge: onboardingCookieMaxAge,
HttpOnly: true,
Secure: !devMode,
SameSite: http.SameSiteLaxMode,
})
}
func getOnboardingState(r *http.Request) *onboardingState {
c, err := r.Cookie(onboardingCookieName)
if err != nil {
return nil
}
parts := strings.SplitN(c.Value, ".", 2)
if len(parts) != 2 {
return nil
}
mac := hmac.New(sha256.New, onboardingSecret)
mac.Write([]byte(parts[0]))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(parts[1])) {
return nil
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return nil
}
var st onboardingState
if err := json.Unmarshal(payload, &st); err != nil {
return nil
}
// Expire stale sessions
if time.Now().Unix()-st.CreatedAt > onboardingCookieMaxAge {
return nil
}
return &st
}
func clearOnboardingCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: onboardingCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: !devMode,
SameSite: http.SameSiteLaxMode,
})
}
// readEnv is a thin wrapper to make testing easier later.
func readEnv(key string) string {
return os.Getenv(key)
}
// --- handlers ---
// handleSignupStart: GET /signup — renders the email/social choice page.
// (Wired in main.go via the existing /signup route.)
// handleSignupEmail: POST /onboarding/email — user typed an email address.
// Creates the customer in DB+Paddle, sets the cookie, redirects to /onboarding/profile.
func handleSignupEmail(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "method not allowed", 405)
return
}
if corpDB == nil {
jsonError(w, "onboarding offline (corporate.db not available)", 503)
return
}
var req struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid json", 400)
return
}
email := strings.ToLower(strings.TrimSpace(req.Email))
if !looksLikeEmail(email) {
jsonError(w, "invalid email", 400)
return
}
// If a customer with this email exists, reuse it (resume onboarding).
ourID, _, found := findCustomerByEmail(email)
if !found {
res := tlwCreateCustomer(map[string]interface{}{"email": email})
if res.Err != nil {
log.Printf("onboarding: tlwCreateCustomer failed for %s: %v", email, res.Err)
jsonError(w, "could not create account", 500)
return
}
ourID = res.OurID
}
setOnboardingCookie(w, &onboardingState{
Email: email,
Provider: "email",
CustomerID: ourID,
})
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true,"next":"/onboarding/profile"}`))
}
// handleOnboardingProfile: POST /onboarding/profile — name + address.
// Updates the customer name and creates the billing address.
func handleOnboardingProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "method not allowed", 405)
return
}
st := getOnboardingState(r)
if st == nil || st.CustomerID == "" {
jsonError(w, "session expired", 401)
return
}
var req struct {
Name string `json:"name"`
Address1 string `json:"address1"`
City string `json:"city"`
Region string `json:"region"`
Zip string `json:"zip"`
CountryCode string `json:"country_code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid json", 400)
return
}
if req.Name == "" || req.CountryCode == "" {
jsonError(w, "name and country are required", 400)
return
}
if len(req.CountryCode) != 2 {
jsonError(w, "country must be a 2-letter ISO code", 400)
return
}
// Update the customer name in our DB. (Paddle update is best-effort:
// the customer was created with email only; we PATCH it on the next
// /customers/{id} call. For now, just update locally — Paddle picks up
// the name when we send the address creation.)
corpDB.Exec(`UPDATE customers SET name = ?, updated_at = ? WHERE id = ?`,
req.Name, time.Now().Unix(), st.CustomerID)
// Create the address via TLW (writes to DB + Paddle).
addr := map[string]interface{}{
"customer_id": st.CustomerID,
"country_code": strings.ToUpper(req.CountryCode),
}
if req.City != "" {
addr["city"] = req.City
}
if req.Region != "" {
addr["region"] = req.Region
}
if req.Zip != "" {
addr["zip_code"] = req.Zip
}
if req.Address1 != "" {
addr["description"] = req.Address1
}
res := tlwCreateAddress(addr)
if res.Err != nil {
log.Printf("onboarding: tlwCreateAddress failed: %v", res.Err)
jsonError(w, "could not save address", 500)
return
}
st.AddressID = res.OurID
setOnboardingCookie(w, st)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true,"next":"/onboarding/plan"}`))
}
// handleOnboardingPlan: POST /onboarding/plan — user picked a subscription tier.
// Looks up the Paddle price ID, creates a transaction, returns the checkout URL.
func handleOnboardingPlan(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "method not allowed", 405)
return
}
st := getOnboardingState(r)
if st == nil || st.CustomerID == "" || st.AddressID == "" {
jsonError(w, "session expired", 401)
return
}
var req struct {
Plan string `json:"plan"` // e.g. "personal", "family", "pro"
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid json", 400)
return
}
priceID := lookupPriceID(req.Plan, "usd")
if priceID == "" {
jsonError(w, "unknown plan", 400)
return
}
// Need the Paddle IDs for both customer and address to create a transaction.
var paddleCust, paddleAddr string
corpDB.QueryRow(`SELECT COALESCE(paddle_id,'') FROM customers WHERE id = ?`, st.CustomerID).Scan(&paddleCust)
corpDB.QueryRow(`SELECT COALESCE(paddle_id,'') FROM addresses WHERE id = ?`, st.AddressID).Scan(&paddleAddr)
if paddleCust == "" || paddleAddr == "" {
// Paddle wasn't configured / sync failed. Don't fail — let the user
// reach the success page; the operator can retry the sync from admin.
clearOnboardingCookie(w)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"ok":true,"checkout_url":"/onboarding/done?pending=1"}`))
return
}
_, checkoutURL, err := tlwCreateTransaction(paddleCust, paddleAddr, priceID)
if err != nil {
log.Printf("onboarding: tlwCreateTransaction failed: %v", err)
jsonError(w, "could not create checkout", 500)
return
}
// Onboarding state is no longer needed — Paddle takes over.
clearOnboardingCookie(w)
w.Header().Set("Content-Type", "application/json")
resp := map[string]interface{}{"ok": true, "checkout_url": checkoutURL}
json.NewEncoder(w).Encode(resp)
}
// lookupPriceID maps a plan name to a Paddle price ID. Hardcoded for now —
// will move to DB-backed config later. Mirrors admin/paddle_checkout.go.
func lookupPriceID(plan, currency string) string {
key := strings.ToLower(plan) + "_yearly_" + strings.ToLower(currency)
priceIDs := map[string]string{
"personal_yearly_usd": "pri_01knejm7ft2ska5r4qff2gm9r4",
"family_yearly_usd": "pri_01knejm7xs9kqt0vn61dx6q808",
"pro_yearly_usd": "pri_01knejm8djq4p63rmsxze7by58",
}
return priceIDs[key]
}
func looksLikeEmail(s string) bool {
if len(s) < 3 || len(s) > 254 {
return false
}
at := strings.Index(s, "@")
if at < 1 || at == len(s)-1 {
return false
}
if !strings.Contains(s[at:], ".") {
return false
}
return true
}
func jsonError(w http.ResponseWriter, msg string, status int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprintf(w, `{"ok":false,"error":%q}`, msg)
}