A shop can be renamed and, while empty, removed - from head office
The display name was always meant to be editable and the slug frozen;
until now neither had a way in. PATCH /api/sites/{site} takes a name
and a timezone (manager and above), DELETE removes an empty shop
(owner). The shop drawer in head office gets both, with the short name
shown read-only and the reason beside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
@@ -159,6 +159,9 @@ type Store interface {
|
||||
// one opened by mistake - and returns its broker username. A shop with
|
||||
// history is closed, not deleted.
|
||||
DeleteEmptySite(ctx context.Context, clientID, siteID string) (string, error)
|
||||
// UpdateSite changes what a person reads - the name, the timezone. Never
|
||||
// the slug: the shop PC and the broker ACL are keyed on it.
|
||||
UpdateSite(ctx context.Context, clientID, siteID string, in SiteUpdate) (SiteHealth, error)
|
||||
|
||||
// --- enrolment ---
|
||||
RedeemEnrolment(ctx context.Context, hash []byte) (Enrolment, error)
|
||||
@@ -301,6 +304,7 @@ func (s *Server) Routes() *http.ServeMux {
|
||||
mux.HandleFunc("GET /api/sites", s.authed(s.handleSites))
|
||||
mux.HandleFunc("POST /api/sites", s.authed(s.handleCreateSite))
|
||||
mux.HandleFunc("DELETE /api/sites/{site}", s.authed(s.handleDeleteSite))
|
||||
mux.HandleFunc("PATCH /api/sites/{site}", s.authed(s.handleUpdateSite))
|
||||
|
||||
// Cameras, onboarded from head office. The shop PC still does the
|
||||
// connecting - it is the only thing on the camera's network - so these
|
||||
|
||||
@@ -554,6 +554,23 @@ func (f *fakeStore) DeleteClient(_ context.Context, clientID string) (ClientRow,
|
||||
return ClientRow{}, nil, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) UpdateSite(_ context.Context, _ string, siteID string, in SiteUpdate) (SiteHealth, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i := range f.sites {
|
||||
if f.sites[i].SiteID == siteID {
|
||||
if in.Name != nil {
|
||||
f.sites[i].Name = *in.Name
|
||||
}
|
||||
if in.Timezone != nil {
|
||||
f.sites[i].Timezone = *in.Timezone
|
||||
}
|
||||
return f.sites[i], nil
|
||||
}
|
||||
}
|
||||
return SiteHealth{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (f *fakeStore) DeleteEmptySite(_ context.Context, _ string, siteID string) (string, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
@@ -257,3 +258,57 @@ func (s *Server) handleDeleteSite(w http.ResponseWriter, r *http.Request) {
|
||||
// ErrSiteInUse is returned by DeleteEmptySite for a shop that has anything
|
||||
// under it.
|
||||
var ErrSiteInUse = errors.New("site has cameras or visits")
|
||||
|
||||
// PATCH /api/sites/{site} - rename a shop or change its timezone. Owner or
|
||||
// manager. The slug is not in the body and would be refused by the database
|
||||
// if it were: it is what the shop PC calls itself and a segment of the broker
|
||||
// topic, and renaming it would orphan both.
|
||||
func (s *Server) handleUpdateSite(w http.ResponseWriter, r *http.Request) {
|
||||
p := PrincipalFrom(r.Context())
|
||||
if !p.CanManageSites() || p.ClientID == "" {
|
||||
writeErr(w, http.StatusForbidden, "forbidden", "Only a manager or the owner can change a shop.")
|
||||
return
|
||||
}
|
||||
site, ok := s.resolveSite(w, r, r.PathValue("site"))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in SiteUpdate
|
||||
if err := decode(w, r, &in); err != nil {
|
||||
badRequest(w, err.Error())
|
||||
return
|
||||
}
|
||||
if in.Name != nil {
|
||||
n := clip(trim(*in.Name), 120)
|
||||
if n == "" {
|
||||
badRequest(w, "The shop needs a name.")
|
||||
return
|
||||
}
|
||||
in.Name = &n
|
||||
}
|
||||
if in.Timezone != nil {
|
||||
if _, err := time.LoadLocation(strings.TrimSpace(*in.Timezone)); err != nil {
|
||||
badRequest(w, "Unknown timezone. Use an IANA name such as Asia/Kolkata.")
|
||||
return
|
||||
}
|
||||
}
|
||||
if in.Name == nil && in.Timezone == nil {
|
||||
badRequest(w, "Nothing to change: give a name or a timezone.")
|
||||
return
|
||||
}
|
||||
out, err := s.Store.UpdateSite(r.Context(), p.ClientID, site, in)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
|
||||
return
|
||||
}
|
||||
s.serverError(w, "update site", err)
|
||||
return
|
||||
}
|
||||
s.Store.Audit(r.Context(), AuditEntry{
|
||||
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
|
||||
Action: "site.updated", Entity: "site", EntityID: site,
|
||||
Detail: map[string]any{"name": out.Name, "timezone": out.Timezone},
|
||||
})
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
@@ -108,3 +108,22 @@ func TestNoBrokerConfiguredSaysSo(t *testing.T) {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAManagerRenamesAShopButTheSlugStays(t *testing.T) {
|
||||
s, fs := newServer(t)
|
||||
seedUser(fs)
|
||||
seedSite(fs)
|
||||
sess := login(t, s, "manager@acme.com", "correct horse battery")
|
||||
rec := do(t, s, "PATCH", "/api/sites/chennai", sess.Token, map[string]any{"name": "TeNext Coimbatore"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var out SiteHealth
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &out)
|
||||
if out.Name != "TeNext Coimbatore" || out.Slug != "chennai" {
|
||||
t.Fatalf("renamed wrong: %+v", out)
|
||||
}
|
||||
if rec := do(t, s, "PATCH", "/api/sites/chennai", sess.Token, map[string]any{"timezone": "Mars/Olympus"}); rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("bad timezone accepted: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +184,13 @@ type NewSite struct {
|
||||
Password string `json:"-"`
|
||||
}
|
||||
|
||||
// SiteUpdate is the editable part of a shop. Both optional; an absent field
|
||||
// is left alone.
|
||||
type SiteUpdate struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Timezone *string `json:"timezone,omitempty"`
|
||||
}
|
||||
|
||||
type SiteHealth struct {
|
||||
SiteID string `json:"site_id"`
|
||||
Slug string `json:"slug"`
|
||||
|
||||
Reference in New Issue
Block a user