package api import ( "context" "errors" "net/http" "regexp" "strings" "time" "github.com/jackc/pgx/v5/pgconn" ) // SiteBroker is the broker-side half of creating a shop. It is the // internal/broker package's interface, redeclared here so this package does // not import a paho dependency for the sake of one method. type SiteBroker interface { EnsureSite(ctx context.Context, username, password string) error DeleteSite(ctx context.Context, username string) error } // Same rule the database enforces (sites_slug_format), checked here so the // caller gets a sentence instead of a constraint name. var slugRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,30}[a-z0-9]$`) // POST /api/sites - an owner opens a shop. // // Until this existed a shop was `provision site` on the server's command line // followed by a hand edit of the broker's password file. That made every new // branch a support ticket, and it was the last piece of onboarding that could // not be done from the product. The row and the broker login are created // together here; if the broker will not take the login, the row is removed // again and the caller is told, because a shop that exists in the database and // not on the broker is one whose PC enrols fine and never delivers a visit. func (s *Server) handleCreateSite(w http.ResponseWriter, r *http.Request) { p := PrincipalFrom(r.Context()) // Owner, not manager: a shop is a billing and tenancy object, not a // setting. Managers can set up the PC and cameras once it exists. if p.Role != "owner" || p.ClientID == "" { writeErr(w, http.StatusForbidden, "forbidden", "Only the owner can open a new shop.") return } if s.Broker == nil { writeErr(w, http.StatusServiceUnavailable, "broker_unavailable", "This server is not connected to a broker that can register shops. Contact support.") return } var in NewSiteInput if err := decode(w, r, &in); err != nil { badRequest(w, err.Error()) return } in.Name = clip(trim(in.Name), 120) if in.Name == "" { badRequest(w, "Give the shop a name.") return } in.Slug = slugify(in.Slug) if in.Slug == "" { in.Slug = slugify(in.Name) } if !slugRe.MatchString(in.Slug) { badRequest(w, "The short name must be 3-32 characters: lower-case letters, digits and dashes.") return } tz := strings.TrimSpace(in.Timezone) if tz == "" { tz = "Asia/Kolkata" } if _, err := time.LoadLocation(tz); err != nil { badRequest(w, "Unknown timezone. Use an IANA name such as Asia/Kolkata.") return } site, err := s.Store.CreateSite(r.Context(), p.ClientID, in.Slug, in.Name, tz) if err != nil { var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { writeErr(w, http.StatusConflict, "conflict", "A shop with that short name already exists.") return } if errors.Is(err, ErrNoSecrets) { writeErr(w, http.StatusServiceUnavailable, "no_encryption_key", "This server has no encryption key, so a shop's broker password cannot be stored. Contact support.") return } s.serverError(w, "create site", err) return } if err := s.Broker.EnsureSite(r.Context(), site.Username, site.Password); err != nil { s.logf("create site %s: broker registration failed, removing the row: %v", site.Slug, err) if derr := s.Store.DeleteNewSite(r.Context(), p.ClientID, site.SiteID); derr != nil { s.logf("create site %s: could not remove the row after broker failure: %v", site.Slug, derr) } writeErr(w, http.StatusBadGateway, "broker_unavailable", "The broker did not accept the new shop, so it was not created. Try again in a moment; if it keeps failing, contact support.") return } s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "site.created", Entity: "site", EntityID: site.SiteID, Detail: map[string]any{"slug": site.Slug, "name": site.Name, "timezone": site.Timezone}, }) writeJSON(w, http.StatusCreated, site) }