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:
2026-09-19 16:11:07 +05:30
parent 4ac08e5a85
commit ca8de7addf
13 changed files with 210 additions and 18 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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"`

View File

@@ -146,3 +146,15 @@ func (s *Store) DeleteEmptySite(ctx context.Context, clientID, siteID string) (s
}
return username, tx.Commit(ctx)
}
func (s *Store) UpdateSite(ctx context.Context, clientID, siteID string, in api.SiteUpdate) (api.SiteHealth, error) {
var out api.SiteHealth
err := s.pool.QueryRow(ctx, `
UPDATE sites
SET name = COALESCE($3, name),
timezone = COALESCE($4, timezone)
WHERE id = $1::uuid AND client_id = $2::uuid
RETURNING id::text, slug, name, timezone`, siteID, clientID, in.Name, in.Timezone).
Scan(&out.SiteID, &out.Slug, &out.Name, &out.Timezone)
return out, err
}

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="color-scheme" content="dark" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>Behavision</title>
<script type="module" crossorigin src="/assets/index-CAACw-qR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-KC4SOUb9.css">
<script type="module" crossorigin src="/assets/index-Ckr5hGZd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D4KGRSVS.css">
</head>
<body>
<div id="root"></div>

View File

@@ -206,6 +206,8 @@ export const api = {
sites: () => send('GET', '/api/sites'),
createSite: (input) => send('POST', '/api/sites', input),
updateSite: (site, input) => send('PATCH', `/api/sites/${encodeURIComponent(site)}`, input),
deleteSite: (site) => send('DELETE', `/api/sites/${encodeURIComponent(site)}`),
// The live arrivals feed. `cursor` is opaque and must be echoed back.
arrivals: (params) => send('GET', '/api/visits' + qs(params)),

View File

@@ -568,3 +568,7 @@ button.ghost.danger:hover { border-color: var(--bad); }
letter-spacing: .04em; text-transform: uppercase; }
.ask-btn .mark { width: 18px; height: 18px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
button.ghost.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
input[readonly] { opacity: .6; }

View File

@@ -8,7 +8,7 @@ import { api } from '../api.js'
// recognising almost nobody. Every step names what to do when it fails, and the
// shop is only "working" when all of them pass: a partial pass is not a working
// shop, and calling it one is how that site got signed off.
export default function SiteCheck({ site, onClose }) {
export default function SiteCheck({ site, user, onClose, onChanged }) {
const [result, setResult] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
@@ -89,6 +89,7 @@ export default function SiteCheck({ site, onClose }) {
)}
<ClaimPC site={site} />
<ShopSettings site={site} user={user} onChanged={onChanged} onClose={onClose} />
</div>
</aside>
</div>
@@ -169,3 +170,74 @@ function expiry(iso) {
if (days <= 0) return 'today'
return days === 1 ? 'tomorrow' : `in ${days} days`
}
// The shop's own details: rename, timezone, and - for a shop opened by mistake
// - removal. The short name is shown but not editable: it is what the shop PC
// calls itself and a segment of the broker topic, so renaming it would orphan
// both. The display name is what people read, and a system that cannot fix a
// typo in a shop's name has confused the two.
function ShopSettings({ site, user, onChanged, onClose }) {
const [name, setName] = useState(site.name)
const [tz, setTz] = useState(site.timezone || 'Asia/Kolkata')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [confirming, setConfirming] = useState(false)
const canManage = user?.role === 'owner' || user?.role === 'manager'
const isOwner = user?.role === 'owner'
if (!canManage) return null
const dirty = name.trim() !== site.name || tz.trim() !== (site.timezone || 'Asia/Kolkata')
const save = async (e) => {
e.preventDefault()
setBusy(true); setError('')
try {
await api.updateSite(site.slug || site.site_id, { name: name.trim(), timezone: tz.trim() })
onChanged?.()
} catch (err) { setError(err.message) } finally { setBusy(false) }
}
const remove = async () => {
setBusy(true); setError('')
try {
await api.deleteSite(site.slug || site.site_id)
onChanged?.(); onClose?.()
} catch (err) { setError(err.message); setConfirming(false) } finally { setBusy(false) }
}
return (
<section className="claim">
<h3>Shop details</h3>
<form onSubmit={save}>
<label className="field">
<span>Name</span>
<input value={name} onChange={e => setName(e.target.value)} required />
</label>
<label className="field">
<span>Short name</span>
<input value={site.slug} readOnly />
<span className="hint">Fixed: the shop PC and the broker are keyed on it.</span>
</label>
<label className="field">
<span>Timezone</span>
<input value={tz} onChange={e => setTz(e.target.value)} />
</label>
{error && <p className="error" role="alert">{error}</p>}
<div className="row">
<button className="primary" disabled={busy || !dirty}>{busy ? 'Saving…' : 'Save'}</button>
{isOwner && !confirming && (
<button type="button" className="ghost danger" onClick={() => setConfirming(true)}>Remove this shop…</button>
)}
</div>
</form>
{confirming && (
<div className="banner warn" style={{ marginTop: 12 }}>
<b>Remove {site.name}?</b>
<p className="sub">Only possible while it has no cameras and no visits. A shop with history is kept.</p>
<div className="row">
<button className="primary" disabled={busy} onClick={remove}>{busy ? 'Removing…' : 'Yes, remove it'}</button>
<button className="ghost" onClick={() => setConfirming(false)}>Keep it</button>
</div>
</div>
)}
</section>
)
}

View File

@@ -70,7 +70,7 @@ export default function Sites({ user }) {
{/* Reachable per shop, at last. The smoke test used to hang off a single
button on the camera screen that always checked sites[0], so with two
shops the second could not be checked at all. */}
{checking && <SiteCheck site={checking} onClose={() => setChecking(null)} />}
{checking && <SiteCheck site={checking} user={user} onClose={() => setChecking(null)} onChanged={reload} />}
{opening && <NewShop onClose={() => setOpening(false)}
onCreated={() => { setOpening(false); reload() }} />}
</>