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.
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.
/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.
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.
Only what is genuinely needed to make a working tenant. Everything else the centre can set itself later in Settings.
| Field | Rule | Why it is here | |
|---|---|---|---|
| 1 · Identity | |||
| Centre name | required | 3–100 chars, trimmed | Shown on login, sidebar, reports |
| URL slug | required | ^[a-z0-9]+(-[a-z0-9]+)*$, 3–63, unique, not one of 21 reserved | The subdomain. 63 is the DNS label limit. Auto-derived from the name, editable, checked live against GET /core/centers/slug-available/:slug — which already exists. |
| 2 · Owner — this creates a login | |||
| Owner name | required | 2–100 | Appears as the owner in this very table |
| Owner phone | required | ^\+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 email | optional | Only route for email OTP; 4 of 7 existing centres have none | |
| 3 · Academics | |||
| City | optional | 2–255 | Shown in this table; useful for telling two similarly-named centres apart |
| Boards | optional | free text, max 10, each 2–30, deduped case-insensitively | Deliberately free text — Growth World needed "UP Board", which no fixed enum had. Offer the common ones as chips plus a free field. |
| Subjects | optional | enum: PHYSICS · CHEMISTRY · MATHEMATICS · BIOLOGY | A closed enum, unlike boards. Multi-select checkboxes, not free text. |
| 4 · Branding | |||
| Logo | optional | image, ≤5 MB, no SVG | Uploaded after create, via POST /admin/centers/:id/logo, so it reuses the existing 512×512 WebP conversion. SVG is blocked deliberately — stored-XSS. |
| Brand colour | optional | ^#[0-9A-Fa-f]{6}$ | Defaults to #2563EB. See the caveat below — this is only half-wired in the app. |
| 5 · Plan & defaults | |||
| Plan | default | free_trial | All 7 existing centres are free_trial. Billing is a separate, guarded surface — not this form. |
| Results visibility | default | immediate | after_end | manual | Already a per-centre setting; sensible to set at creation |
--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.
@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.
# 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.
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.
Renaming a slug is the one action on this screen that breaks things for real users:
localStorage center object included — is now staleSo: separate section, separate confirm that requires typing the new slug, and the warnings listed literally rather than a generic "are you sure".
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.
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.
| Step | Est. | Deliverable |
|---|---|---|
1 · PATCH /admin/centers/:id + logo endpoint | ~2h | Edit works for name, city, boards, subjects, colour, logo |
2 · center-detail.tsx → form | ~2h | You can change the name from the panel — the thing you asked for |
3 · POST /admin/centers transactional | ~3h | Add Centre, atomic, no orphans |
| 4 · Add-centre form page | ~3h | The 5-section form with live slug check |
| 5 · Slug endpoint + danger zone | ~2h | Rename with real warnings |
| 6 · Audit rows + orphan delete | ~2h | Traceability, 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.