Skip to main content

Acceptance Spec — Local Business: Photos & Branding Editors

  • Status: SHIPPED 2026-06-09 (founder-requested; retroactive spec — see status note above).
  • Owner: ChurchWiseAI (CWA) / WiseAI Agency vertical.
  • Surface: the WiseAI Agency customer dashboard wiseaiagency.com/business/[token]Setup tab (the /business/[token] route), plus the public service-business site /s/[slug] (ServiceBusinessTemplate → HomeServicesDemo).
  • Feature Registry id: wiseaiagency-local-business-media
  • Plan gating: none beyond the dashboard itself. The Setup tab is always shown for an active business. Editors render read-only for paused/cancelled businesses (no upload/remove controls).

Why

Church Pro Websites can upload a logo, hero slideshow, ministry photos, staff photos, and per-page images. The service-business (WiseAI Agency) Pro Websites — a separate stack — shipped with no image upload anywhere: every image was a trade/template default. A plumber, vet, or dentist could not show their own logo, their real work, their team, or their services. The public template (HomeServicesDemo) already rendered all four image types from the data model; the gap was purely the dashboard editors + persistence. This spec covers closing that gap.

Founder request, 2026-06-08: "we have our service business pro websites as well. can we add this feature/fix to those as well?" → all four surfaces (logo, hero photos, staff photos, service photos).

Data model — where each image persists (no new columns)

Four surfaces, two stores. All reads happen in HomeServicesDemo; all writes go through one shared upload route plus the per-store save route.

SurfaceStored atSaved viaRendered by (HomeServicesDemo)
Logolocal_businesses.metadata.logo_url (string)PATCH /api/local-business/brandingheader logo (premium.logo_url)
Hero photoslocal_businesses.metadata.custom_slideshow_urls (string[], ≤ 8)PATCH /api/local-business/brandinghero Ken-Burns slideshow (premium.custom_slideshow_urls, overrides the trade default)
Service photoslocal_businesses.metadata.service_groups[].image_urlPATCH /api/local-business/branding (full service_groups array)service-category card banner (group.image_url)
Staff photoslocal_business_setup_profiles.staff_contacts[].photo_urlPATCH /api/local-business/setup-profile (full staff_contacts array)"Meet the team" card (staff[].photo_url)

metadata writes use the atomic lb_merge_metadata(p_id, p_patch) Postgres function (metadata || p_patch), NOT a JS read-modify-write — two partial patches in quick succession (e.g. logo then hero) must not clobber each other's keys over pooled connections. Migration: 2026-06-08-lb-merge-metadata-fn.sql.

Upload primitive — POST /api/upload/local-business-image

The single shared uploader for all four surfaces.

  • Auth: per-business Authorization: Bearer <token> (local_businesses.admin_token, or the founder override) via requireLocalBusinessApiAuth. No CSRF check — auth is a bearer secret in a header, not an ambient cookie, so it is not forgeable cross-site. Every upload is scoped to the caller's business_id (scopeBusinessId); a business can only write under its own storage prefix.
  • Input: one file or many files; kind{logo, gallery, staff, service} namespaces the path. 1–8 files, ≤ 15 MB each, image/jpeg|png|webp only.
  • Storage: wiseaiagency-media bucket, path business-photos/{business_id}/{kind}/{uuid}.{ext} (uuid filename so re-uploads never collide).
  • Output: { url, urls } only. Does NOT mutate the business — the caller persists the returned URL(s) via /branding (logo/gallery/service) or /setup-profile (staff).

Persistence routes

  • /api/local-business/branding (GET + PATCH) — reads/writes logo_url, custom_slideshow_urls (≤ 8), and service_groups (≤ 24, each with a per-group image_url). Per-business Bearer auth + business_id scoping. Atomic merge; preserves unrelated metadata keys (category, showcase, sample_call_audio_url, …). Validation: every URL must be http(s) and ≤ 1000 chars; each service group must have a name; image_url is http(s) or null.
  • /api/local-business/setup-profile (PATCH, existing) — the staff-photo path sends the full staff_contacts array with updated photo_url values.

Admin editor (Setup tab, BusinessDashboard)

Three sections render in the Setup tab above the existing setup-wizard display, each read-only for paused/cancelled businesses:

  1. BrandingPhotosSection (lb-branding-section) — self-contained; GETs /branding on mount.
    • Logo: preview (lb-logo-preview) + upload/replace (lb-logo-upload, lb-logo-file) + remove. Single image → logo_url.
    • Hero photos: multi-upload (lb-gallery-upload, lb-gallery-file), grid (lb-gallery-grid) with per-photo reorder + remove, ≤ 8 → custom_slideshow_urls.
  2. StaffPhotosSection (lb-staff-section) — props-driven; receives staff_contacts + an onSave that PATCHes setup-profile. Lists EXISTING staff only (one row each, lb-staff-row); per-row headshot upload (lb-staff-upload-{i}, shared lb-staff-file, preview lb-staff-photo-{i}) + remove. Photo-only — names/roles come from the setup wizard, not here. Empty state lb-staff-empty.
  3. ServicePhotosSection (lb-service-section) — self-contained; GETs /branding service_groups. Lists EXISTING service categories (lb-service-row); per-row card-image upload (lb-service-upload-{i}, shared lb-service-file, preview lb-service-photo-{i}) + remove. Photo-only. Empty state lb-service-empty.

All three update their thumbnails optimistically (UI shows the image before the persistence write returns) and revert on save failure. Errors surface inline (lb-*-error).

Public display (HomeServicesDemo, no change required)

The template already consumes every field: logo in the sticky header; hero photos as the standalone Ken-Burns slideshow (a business's own photos win over the vertical-default stock slideshow); each service-group card shows image_url as a banner (icon fallback when null); each team card shows photo_url (icon fallback).

Graceful degradation (regression guards)

  • A business with no uploaded media renders exactly as before — trade-default hero slideshow, no logo, icon-only team/service cards.
  • Empty service_groups (the demo falls back to the flat services grid) → the Service-photos editor shows its empty state and the public site is unchanged.
  • No staff → Team-photos editor shows its empty state.
  • Atomic metadata merge guarantees uploading one surface never wipes another.

Verification (evidence-or-nothing)

  • tsc + pnpm build green (both PRs); all CI gates green.
  • Playwright on the deployed preview against the demo-lb-playground business (id …00aa, slug wiseai-demo-home-services-playground-demo, seeded with 2 staff + 2 service groups): upload logo + 2 hero photos + 1 staff headshot + 1 service photo, assert each upload returns 200, the editor shows the thumbnail, the value persists (logo/gallery/service to metadata, staff to the setup profile), the public /s/[slug] renders the uploaded images, and an unauthenticated branding write is rejected (401). DB rows confirmed.
  • Test note: the editors update thumbnails optimistically, so e2e must wait for the persistence PATCH (/branding or /setup-profile), NOT the thumbnail, before asserting saved state.