package store import ( "context" "crypto/rand" "encoding/base32" "fmt" "strings" "time" "github.com/loyaly/behavision-server/internal/api" "github.com/loyaly/behavision-server/internal/auth" ) // CreateClientWithOwner creates a tenant and the account that owns it. // // ONE transaction, deliberately. A client row with no owner is a tenant nobody // can sign into, and it is invisible: it looks exactly like a normal client in // every list, so the operator finds out weeks later when the customer says // their login does not work. Rolling the whole thing back on a duplicate email // is the only outcome that leaves the database describing something real. func (s *Store) CreateClientWithOwner(ctx context.Context, in api.NewClientInput) ( api.NewClientResult, error) { var out api.NewClientResult password := in.Password if password == "" { // Generated rather than defaulted. An operator inventing a password for // somebody else invents a weak one and then sends it over chat. p, err := randomPassword() if err != nil { return out, err } password = p } hash, err := auth.HashPassword(password) if err != nil { // The policy message is user-facing text an operator can act on // ("password must be at least 8 characters"), so it travels out as-is. return out, err } tx, err := s.pool.Begin(ctx) if err != nil { return out, err } defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed // No ON CONFLICT DO UPDATE here, unlike the provisioning CLI. On this path // a clashing slug means the operator is about to hand someone else's tenant // to a new owner; it has to fail and say so. if err := tx.QueryRow(ctx, ` INSERT INTO clients (slug, name) VALUES ($1, $2) RETURNING id::text`, in.Slug, in.CompanyName).Scan(&out.ClientID); err != nil { return out, fmt.Errorf("create client: %w", err) } // The owner, not a manager: this is the account the customer runs their // business from, and it must be able to add the staff who come after it. if _, err := tx.Exec(ctx, ` INSERT INTO app_users (client_id, email, password_hash, full_name, role) VALUES ($1::uuid, $2, $3, $4, 'owner')`, out.ClientID, in.OwnerEmail, hash, in.OwnerName); err != nil { return out, fmt.Errorf("create owner: %w", err) } if err := tx.Commit(ctx); err != nil { return out, err } out.Slug, out.OwnerEmail, out.Password = in.Slug, in.OwnerEmail, password return out, nil } // ListClients is the platform-admin overview. // // Counts come from correlated subqueries rather than joins: a client with two // sites and three users would otherwise appear six times and be counted wrong // in whichever direction the operator's eye went first. func (s *Store) ListClients(ctx context.Context) ([]api.ClientRow, error) { rows, err := s.pool.Query(ctx, ` SELECT c.id::text, c.slug, c.name, c.created_at, (SELECT count(*) FROM sites si WHERE si.client_id = c.id), (SELECT count(*) FROM app_users au WHERE au.client_id = c.id) FROM clients c ORDER BY c.created_at DESC`) if err != nil { return nil, err } defer rows.Close() var out []api.ClientRow for rows.Next() { var c api.ClientRow var at time.Time if err := rows.Scan(&c.ID, &c.Slug, &c.Name, &at, &c.Sites, &c.Users); err != nil { return nil, err } c.CreatedAt = at.UTC().Format(time.RFC3339) out = append(out, c) } return out, rows.Err() } // randomPassword mints an owner's first password. // // base32 without padding, matching the rest of this system's generated // secrets: it gets read down a phone line and pasted into a form, and base64's // + / = survive neither. func randomPassword() (string, error) { b := make([]byte, 10) // 80 bits -> 16 characters if _, err := rand.Read(b); err != nil { return "", err } return strings.ToLower(base32.StdEncoding. WithPadding(base32.NoPadding).EncodeToString(b)), nil }