Add & Edit Centre — Admin Panel Plan
ClassPulse AI · Admin Panel · Phase 1

Add & edit a centre without creating another orphan

Two of the seven centres on UAT are already junk — Pending Setup with 0 students, and a second created the same day. They are abandoned signups. The whole point of an admin "Add Centre" is to create a centre completely, in one transaction, or not at all.

01 — The trapDo not reuse the signup flow

When I created Growth World Classes I used the signup path, because it is the only one that exists. It works, but it is the wrong shape for an admin button.

POST /auth/otp/send does something surprising: if the phone is unknown it creates a placeholder centre and an owner user as a side effect of asking for an OTP (auth.service.ts:38-54). Name "Pending Setup", slug pending-<timestamp>. If the person never verifies, that centre stays forever.

That is exactly where your two orphans came from. Three separate auth paths do this (:38, :215, :363), and the cleanup branch in invite.service.ts looks for placeholder names 'Unassigned' and 'My Coaching Center' — neither of which anything ever writes. So nothing ever collects them.

The rule for the Add button Admin create must never go through /auth/otp/send. It must be one transactional endpoint that either produces a fully-formed centre with a working owner login, or writes nothing at all. No OTP is sent, no half-state is possible.

02 — FlowWhat happens when you click Add Centre

Everything that can be rejected is rejected before the transaction opens. Inside the transaction there are only writes. Side effects that can fail without corrupting anything happen after the commit.

Form 5 sections, live validation Pre-flight — can reject 1 · slug not reserved (21 names) 2 · slug not already taken 3 · owner phone not already attached to another centre 4 · idempotency key unused nothing written yet 409 / 400 — form shows the field ONE transaction insert core_coaching_centers insert core_users (CENTER_OWNER) link user.coaching_center_id set onboardingStep all or nothing any failure → ROLLBACK, no orphan Centre exists · owner can log in 201 + the new centre After commit — allowed to fail • register <slug>.classpulseai.com as a Firebase authorized domain, or OTP login breaks • write the audit row (who / what / before→after) • optional: upload the logo (separate endpoint, reuses the 512×512 WebP pipeline) each failure is REPORTED on the centre row, never rolled back — a missing auth domain is fixable; a half-created centre is not Why the split: Firebase domain registration is a network call to Google. Inside the transaction it would hold a DB lock open across an external round-trip, and a Google timeout would roll back a perfectly good centre. Outside, it degrades to a warning the panel can show and you can retry. The existing firebase-domains.service.ts is fire-and-forget with FOUR silent failure modes and a lost-update race — it must not stay silent here.
The shape that matters: reject before you write, write atomically, then do the fallible things where failure is recoverable. Read the existing signup path against this and the orphans explain themselves — it writes first and validates never.

03 — The formFive sections, every field justified

Only what is genuinely needed to make a working tenant. Everything else the centre can set itself later in Settings.

FieldRuleWhy it is here
1 · Identity
Centre namerequired3–100 chars, trimmedShown on login, sidebar, reports
URL slugrequired^[a-z0-9]+(-[a-z0-9]+)*$, 3–63, unique, not one of 21 reservedThe subdomain. 63 is the DNS label limit. Auto-derived from the name, editable, checked live against GET /core/centers/slug-available/:slugwhich already exists.
2 · Owner — this creates a login
Owner namerequired2–100Appears as the owner in this very table
Owner phonerequired^\+91[6-9]\d{9}$This is the login. Must not already belong to another centre — checked pre-flight. The form shows the +91 prefix fixed, because the backend rejects a bare 10-digit number.
Owner emailoptionalemailOnly route for email OTP; 4 of 7 existing centres have none
3 · Academics
Cityoptional2–255Shown in this table; useful for telling two similarly-named centres apart
Boardsoptionalfree text, max 10, each 2–30, deduped case-insensitivelyDeliberately free text — Growth World needed "UP Board", which no fixed enum had. Offer the common ones as chips plus a free field.
Subjectsoptionalenum: PHYSICS · CHEMISTRY · MATHEMATICS · BIOLOGYA closed enum, unlike boards. Multi-select checkboxes, not free text.
4 · Branding
Logooptionalimage, ≤5 MB, no SVGUploaded after create, via POST /admin/centers/:id/logo, so it reuses the existing 512×512 WebP conversion. SVG is blocked deliberately — stored-XSS.
Brand colouroptional^#[0-9A-Fa-f]{6}$Defaults to #2563EB. See the caveat below — this is only half-wired in the app.
5 · Plan & defaults
Plandefaultfree_trialAll 7 existing centres are free_trial. Billing is a separate, guarded surface — not this form.
Results visibilitydefaultimmediate | after_end | manualAlready a per-centre setting; sensible to set at creation
Be honest in the UI about brand colour Only --color-primary-500 and --brand-color are written at runtime. The 50/100/600/700 steps stay hard-coded blue in index.css, so an orange tenant gets orange buttons on blue-tinted backgrounds with blue hovers. Either fix the token layer first or label the field "accent colour (partial)". Do not present it as full theming.
Not on this form Feature flags. That column already has three uncoordinated writers — the admin panel, the centre's own owner with @IsObject() and no key validation, and the billing admin writing a namespace nothing reads. Adding a fourth without deciding precedence makes a live bug worse. It needs its own decision.

04 — APIFour endpoints

# All under the existing admin guard (x-admin-key). Audit row on every write.

POST   /api/v1/admin/centers
  # header: Idempotency-Key: <uuid>  — a double-clicked button must not make two centres
  { name, slug, ownerName, ownerPhone, ownerEmail?, city?, boardTypes?[],
    subjects?[], primaryColor?, defaultShowResults? }
  → 201 { center, owner }
  → 409 slug_reserved | slug_taken | phone_in_use   # distinct codes, so the form
  → 400 validation                                  # can highlight the right field

PATCH  /api/v1/admin/centers/:id
  # everything EXCEPT slug — see below
  { name?, city?, boardTypes?, subjects?, primaryColor?, defaultShowResults? }
  → 200 { center }

PATCH  /api/v1/admin/centers/:id/slug       separate on purpose
  { slug, confirm: true }
  → 200 { center, warnings: [ "old_links_break", "firebase_domain_pending" ] }
  → 409 slug_reserved | slug_taken

POST   /api/v1/admin/centers/:id/logo       # multipart, reuses upload.service.ts
  → 200 { url }   # 512×512 WebP, SVG rejected

Almost all the validation exists already — centers.service.ts has RESERVED_SLUGS, isSlugAvailable() and the conflict handling, and upload.service.ts has the image pipeline. This is mostly exposing what is written, not writing it.


05 — EditSame form, one dangerous field fenced off

Edit reuses the whole form except the Owner section, which becomes read-only — changing who owns a centre is a transfer, not an edit, and it needs its own thinking.

Slug lives in a danger zone

Renaming a slug is the one action on this screen that breaks things for real users:

So: separate section, separate confirm that requires typing the new slug, and the warnings listed literally rather than a generic "are you sure".

Worth adding, cheaply Keep old slugs. A center_slug_history table plus a redirect on unknown-slug lookup turns a broken link into a working one. It is a small table and one branch in the by-slug handler, and it converts the scariest field on this form into a reversible one.

While you are in there

Two of your seven centres are abandoned placeholders. Add a delete for centres with zero students and a pending- slug — it is the safest possible delete, and it stops the list becoming unreadable as you onboard more.


06 — Order of work

StepEst.Deliverable
1 · PATCH /admin/centers/:id + logo endpoint~2hEdit works for name, city, boards, subjects, colour, logo
2 · center-detail.tsx → form~2hYou can change the name from the panel — the thing you asked for
3 · POST /admin/centers transactional~3hAdd Centre, atomic, no orphans
4 · Add-centre form page~3hThe 5-section form with live slug check
5 · Slug endpoint + danger zone~2hRename with real warnings
6 · Audit rows + orphan delete~2hTraceability, and a tidy list

Roughly 1.5–2 days total. Step 2 is the first thing you can actually use, so I would ship 1–2 and let you try it before I build the create flow.