Rotas públicas — slug, vanity URL e custom domain
Status: corrigir · Escopo: Contrato de rotas públicas e identidade por slug (canal web) · Atualizado em: 2026-08-04
Contrato de identidade pública do canal web (
/book/…,embed.js). Separa ID técnico estável (roteamento) de marca visível (nome bonito na UI e, depois, na URL).Código hoje:
demo_sites·demo-sites.repo.ts·public-book-route.ts
| Camada | Descrição | Status |
|---|---|---|
| L0 — Slug técnico global | demo_sites.slug UNIQUE → (tenant_id, unit_id) | ✅ |
| L1 — Vanity path | URLs legíveis (/book/glow-aesthetics) + aliases | ✅ parcial — public_site_routes + resolver |
| L2 — Custom domain | book.cliente.com via hostname lookup | ⏳ Ato 3 (E32.T7) |
| 1 filial = 1 site canônico | UNIQUE (tenant_id, unit_id) em demo_sites | ⏳ migration |
Princípios
Seção intitulada “Princípios”- Slug ≠ marca. O visitante vê
business_name+ tema no widget; o slug é chave de roteamento (pode ficar só nodata-slugdo script). - 1 slug público → 1 filial (
unit_id). Empresa (tenant_id) com N filiais = N rows emdemo_sites, N slugs distintos. - Unicidade global do slug. Duas clínicas na plataforma nunca compartilham o mesmo slug — evita colisão em URLs compartilhadas e embed.
- Aliases não quebram embed antigo. Renomear vanity URL não exige trocar script no site do cliente (slug técnico ou
site_idestável). - Custom domain é Ato 3, não gate do 1º design partner — vanity path (L1) cobre “nome bonito” antes disso.
Modelo atual (L0)
Seção intitulada “Modelo atual (L0)”flowchart LR URL["/book/glow-medspa"] --> DS[demo_sites.slug] EMB["embed.js data-slug"] --> DS DS --> T[tenant_id empresa] DS --> U[unit_id filial] U --> BH[business_hours slots] U --> OFF[service_offerings] U --> CH[channel web session]| Campo | Tabela | Escopo | Exemplo |
|---|---|---|---|
| Slug público | demo_sites.slug | Global UNIQUE | glow-medspa |
| Slug da empresa | tenants.slug | Global UNIQUE | glow-medspa |
| Slug da filial | units.slug | UNIQUE por tenant | main, downtown |
| Slug de serviço | service_offerings.slug | UNIQUE por (tenant, unit) | new-client-consult |
Não confundir: service_offerings.slug é serviço bookável dentro da filial; demo_sites.slug é a filial inteira na web.
Lookup (sem RLS — bootstrap público):
SELECT tenant_id, unit_id FROM demo_sitesWHERE slug = $1 AND active = true;Migration: 0024_demo_web_channel.sql.
Multi-filial (mesma empresa)
Seção intitulada “Multi-filial (mesma empresa)”| Filial | unit_id | demo_sites.slug (hoje) | Vanity sugerido (L1) |
|---|---|---|---|
| Austin | …002 | glow-austin | /book/glow-aesthetics/austin |
| Dallas | …003 | glow-dallas | /book/glow-aesthetics/dallas |
Cada site cola seu embed com o slug da filial:
<script src="https://ORIGIN/embed.js" data-slug="glow-austin"></script>Rotas HTTP do wedge (hoje)
Seção intitulada “Rotas HTTP do wedge (hoje)”Além da identidade por slug, o Worker expõe estas sub-rotas sob /book/:slug (e /demo/:slug legado):
| Método | Sub-rota | Handler | Status |
|---|---|---|---|
| GET | (vazio) ou /widget | handleDemoPageGet | ✅ |
| GET | /config | handleDemoConfigGet | ✅ |
| POST | /chat | handleDemoChatPost | ✅ |
| GET | /availability?session_id= | handleDemoAvailabilityGet | ✅ |
| GET | /history?session_id= | handleDemoHistoryGet | ✅ parcial — texto only |
| POST | /contact | handleDemoContactPost | ✅ parcial |
| POST | /reschedule-session | handleDemoRescheduleSessionPost | ✅ |
| POST | /close-conversation | handleDemoCloseConversationPost | ✅ — runtime_state=closed + rotaciona session_id |
| GET·POST | /checkout | handleDemoCheckout{Get,Post} | ✅ — depósito Stripe/Asaas |
| GET | /payment-status?session_id= | handleDemoPaymentStatusGet | ✅ |
Mapa completo (embed postMessage, agent loop, config layers): web-booking-wedge.md.
Evolução L1 — Vanity paths (E32.T6)
Seção intitulada “Evolução L1 — Vanity paths (E32.T6)”Problema: slug técnico na URL (glow-medspa) não escala branding; cliente quer path legível.
Solução: tabela de rotas públicas — slug técnico permanece; vanity é camada extra.
Schema proposto
Seção intitulada “Schema proposto”CREATE TABLE public_site_routes ( id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), demo_site_id uuid NOT NULL REFERENCES demo_sites(id) ON DELETE CASCADE, route_key text NOT NULL UNIQUE, -- normalizado: lowercase, hyphen kind text NOT NULL CHECK (kind IN ('primary','alias')), active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now());CREATE UNIQUE INDEX uq_public_site_routes_one_primary ON public_site_routes (demo_site_id) WHERE kind = 'primary' AND active = true;ALTER TABLE demo_sites ADD CONSTRAINT uq_demo_sites_tenant_unit UNIQUE (tenant_id, unit_id);route_key | Resolve para |
|---|---|
glow-aesthetics | demo_site Austin (primary) |
glow-aesthetics-austin | alias → mesmo site |
glow-medspa | slug legado em demo_sites.slug (backward compat) |
Resolução no Worker (ordem)
Seção intitulada “Resolução no Worker (ordem)”Host≠ default → L2 hostname table (futuro).- Path
/book/:route_key/...→public_site_routes.route_key. - Fallback →
demo_sites.slug(comportamento atual).
Naming guidelines (onboarding)
Seção intitulada “Naming guidelines (onboarding)”| ✅ Bom | ❌ Evitar |
|---|---|
lakeview-med-spa | tenant_018fa101 |
glow-aesthetics-austin | glow-demo-001 |
{marca}-{cidade} | slugs genéricos colidindo entre clientes |
Evolução L2 — Custom domain (E32.T7, Ato 3)
Seção intitulada “Evolução L2 — Custom domain (E32.T7, Ato 3)”Para design partner grande / whitelabel (docs/archive/master-strategy-v1.md §6b, docs/archive/competitive-matrix-v1.md §8).
CREATE TABLE public_hostnames ( id uuid PRIMARY KEY DEFAULT uuid_generate_v7(), demo_site_id uuid NOT NULL REFERENCES demo_sites(id) ON DELETE CASCADE, hostname text NOT NULL UNIQUE, -- book.glowaesthetics.com ssl_status text NOT NULL DEFAULT 'pending', verified_at timestamptz, active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now());Fluxo:
- Cliente CNAME
book.cliente.com→sites.tactflow.io(Cloudflare for SaaS). - Worker: header
Host→public_hostnames→demo_site_id→ tenant scope. - Embed no site do cliente: script no domínio deles; iframe pode ser same-origin ou cross-origin com CSP.
Gate: Ato 2+ ou objeção recorrente na trilha G; não antes do 1º case pago.
O que o visitante vê vs o que é técnico
Seção intitulada “O que o visitante vê vs o que é técnico”| Superfície | Hoje | Com L1 | Com L2 |
|---|---|---|---|
| URL no browser | tactflow.io/book/glow-medspa | tactflow.io/book/glow-aesthetics | book.glowaesthetics.com |
| Título / header widget | business_name | idem | idem |
| Atributo do script | data-slug="glow-medspa" | data-slug ou futuro data-site-id | idem |
| Stripe return URL | PUBLIC_BASE_URL/book/:slug?… | canonical route ou hostname | hostname do cliente |
Nome bonito já existe na UI via demo_sites.business_name e config.theme. L1/L2 só melhoram URL e confiança — não bloqueiam Ato 1.
Definition of done
Seção intitulada “Definition of done”L0 (hoje)
Seção intitulada “L0 (hoje)”-
demo_sites.slugUNIQUE global -
findBySlug→runInTenantScope -
UNIQUE (tenant_id, unit_id)— migration pendente
L1 (E32.T6)
Seção intitulada “L1 (E32.T6)”- Tabela
public_site_routes+ seeder (0027, harness +seed:demo) - Resolver unificado (route_key → demo_site, slug legado fallback)
- Slug legado continua funcionando
- Aliases adicionais + doc onboarding naming
L2 (E32.T7)
Seção intitulada “L2 (E32.T7)”- Tabela
public_hostnames+ verificação DNS - Worker routing por
Host - Stripe redirect URLs respeitam hostname do site
Links relacionados
Seção intitulada “Links relacionados”web-booking-wedge.md— mapa operacional embed + APIsdocs/archive/product-today.md— narrativa do wedge shippedclient-config-surface.md—demo_sites.config, depósito, tema (orchestration)docs/archive/v5-walking-skeleton-runbook.md— embed installroadmap.md— E32.T6, E32.T7docs/archive/master-strategy-v1.md§6b — marca por vertical / WL
Criado: 2026-06-12 · owner: founder