Day 2 — Worktree C — Vertical Dashboard
You are a Sonnet subagent in an isolated worktree. You have NO conversation history. This document is your full brief.
Run model + setup
- Model: Sonnet (you).
- Subagent type: general-purpose.
- Working dir: temp worktree. From there:
git fetch origingit checkout -b feat/verticals-platform-day2-C-vertical-dash origin/feat/verticals-platform-day1-foundation
- DO NOT push to
mainorfeat/verticals-platform-day1-foundation. PR back tofeat/verticals-platform-day1-foundation. DO NOT merge own PR.
Read first (in order)
C:/dev/knowledge/specs/day2-verticals-platform/00-MAIN-ORCHESTRATOR.mdC:/Users/johnm/.claude/plans/steady-munching-penguin.md— sections "Layer 5 — Vertical Dashboards" and "Phase 5 (Vertical Dashboards)" verification.C:/dev/src/middleware.tslines 106–197 — current hostname rewrite pattern. You add a new entry.C:/dev/src/app/admin/[token]/page.tsx— current church admin entry. You add VerticalProfile lookup.C:/dev/src/app/admin/[token]/components/AdminDashboard.tsx— client shell. Wrap in VerticalProvider.C:/dev/src/app/admin/[token]/components/AdminDashboard.rbac.ts— Model C RBAC tab gating. Read but don't change behavior; the new VerticalProfile feeds it the right tabs.C:/dev/src/app/admin/funeral/[token]/— current funeral admin v0.1 (hardcoded route). You'll deprecate this with a 308 redirect.C:/dev/src/lib/brand.ts— singleton church brand. Don't change.C:/dev/src/lib/brands/funeralwiseai.ts— Layer 2 funeral brand profile. Reuse this in the registry.C:/dev/src/lib/tenant-config.ts— Day 1 schema. The VerticalProfile reads from this.
Scope
C.1 — src/lib/verticals/types.ts
Define the VerticalProfile interface:
import type { BrandProfile } from '@/lib/brands/types';
import type { Vertical, TenantConfig } from '@/lib/tenant-config';
export interface VerticalProfile {
key: Vertical;
brand: BrandProfile;
hostname: string; // 'churchwiseai.com' | 'funeralwiseai.com'
adminPath: '/admin/[token]'; // canonical admin route
legacyAdminPath?: string; // e.g. '/admin/funeral/[token]' to 308-redirect
// Tab list — order = display order. Each tab knows its capability gate + queries.
tabs: TabSpec[];
// What to call people / events in this vertical
terminology: {
visitor: string; // 'visitor' | 'family' | 'patient' | 'guest' | 'client'
callback: string; // 'pastoral callback' | 'at-need callback' | 'consult callback'
teamMember: string; // 'team member' | 'staff' | 'associate'
director: string; // 'pastor' | 'director' | 'doctor' | 'attorney'
organization: string; // 'church' | 'funeral home' | 'practice' | ...
};
planKeys: string[]; // ['cwa_starter_voice','cwa_pro_chat',...] OR ['fwa_starter','fwa_demo_prospect']
rbacRoleLabels: Partial<Record<TeamRole, string>>; // 'admin' → 'Pastor' (church) vs 'Director' (funeral)
// Data accessors — server-side only. Each takes tenant_id and returns rows.
callbackQueueQuery: (tenantId: string) => Promise<CallbackRow[]>;
voiceCallsQuery: (tenantId: string) => Promise<CallLog[]>;
inboxFeedQuery: (tenantId: string, opts: InboxFeedOpts) => Promise<InboxItem[]>;
}
export interface TabSpec {
key: string;
label: string;
capability: string; // RBAC capability name from AdminDashboard.rbac.ts
componentPath: string; // dynamic import path
}
C.2 — src/lib/verticals/registry.ts
import { churchProfile } from './church';
import { funeralProfile } from './funeral';
import type { Vertical, VerticalProfile } from './types';
const REGISTRY: Record<Vertical, VerticalProfile | null> = {
church: churchProfile,
funeral: funeralProfile,
// Future: vet, dental, restaurant, law, real_estate
vet: null,
dental: null,
restaurant: null,
law: null,
real_estate: null,
};
export function getVerticalProfile(v: Vertical): VerticalProfile {
const p = REGISTRY[v];
if (!p) throw new Error(`No VerticalProfile for vertical=${v}. Add to src/lib/verticals/${v}.ts.`);
return p;
}
export function getVerticalByHostname(hostname: string): VerticalProfile | null {
for (const p of Object.values(REGISTRY)) {
if (p && hostname.includes(p.hostname.replace('https://', ''))) return p;
}
return null;
}
C.3 — src/lib/verticals/church.ts
Pull tabs from existing AdminDashboard.tsx:ALL_TABS (currently 6: Home, Inbox, Train AI, Social, Website, Upgrade). Same labels, same capabilities. Hostname churchwiseai.com. Plan keys: ['cwa_starter_voice','cwa_starter_chat','cwa_pro_chat','cwa_pro_voice','cwa_suite_both','cwa_pro_website',...] per src/lib/pricing.ts.
Terminology:
visitor: 'visitor', callback: 'pastoral callback', teamMember: 'team member',
director: 'pastor', organization: 'church'
callbackQueueQuery etc. wrap existing queries from src/lib/premium-queries.ts.
C.4 — src/lib/verticals/funeral.ts
Mirrors church but funeral-flavored. Hostname funeralwiseai.com. Plan keys: ['fwa_starter','fwa_demo_prospect',...] per src/lib/funeral-pricing.ts. Tabs:
| Tab | Label | Capability | Component |
|---|---|---|---|
| home | Home | inbox:calls:read | funeral/HomeTab |
| inbox | Inbox | inbox:calls:read | shared InboxTab (vertical-aware) |
| train | Train AI | train:safety:edit | shared TrainAITab (vertical-aware copy) |
| settings | Settings | settings:profile:edit | shared SettingsTab |
(Phase 2 adds care and website tabs for funeral. Not in this worktree.)
Terminology:
visitor: 'family', callback: 'at-need callback', teamMember: 'staff',
director: 'director', organization: 'funeral home'
Funeral-specific callbackQueueQuery filters voice_callback_requests where tenant_id = ? AND treats urgency pastoral_emergency and at_need as priority. Funeral data adapter: src/lib/funeral-queries.ts (create if doesn't exist; mirror src/lib/premium-queries.ts pattern).
C.5 — Hostname rewrite for funeralwiseai.com/admin
In src/middleware.ts find the funeralwiseai.com block (around lines 129–148 per the recon report). Update so /admin/* paths under funeralwiseai.com rewrite to /admin/* (NOT to /funeralwiseai/admin/*). The /admin/[token] route handles both verticals via VerticalProfile lookup.
Pseudo-code:
if (hostname.includes('funeralwiseai.com') || hostname.startsWith('funeralwiseai.localhost')) {
// Existing rewrite for marketing pages:
if (path === '/' || path.startsWith('/how-it-works') || path.startsWith('/pricing') || ...) {
return NextResponse.rewrite(new URL(`/funeralwiseai${path === '/' ? '' : path}`, request.url));
}
// New: admin paths use the shared /admin/[token] route
if (path.startsWith('/admin/')) {
// No rewrite needed — path is already /admin/[token], let it through
return NextResponse.next();
}
// 308-redirect legacy hardcoded /admin/funeral/[token] paths to funeralwiseai.com/admin/[token]
// (handled in C.7)
// ... rest of existing
}
In src/app/admin/[token]/page.tsx, when reading the request hostname, use getVerticalByHostname(host) to determine the vertical. Fall back to vertical from tenant_voice_agents.vertical for the looked-up token. If they conflict (token's vertical doesn't match hostname), return 404.
C.6 — src/app/admin/[token]/page.tsx reads VerticalProfile
const vertical = getVerticalByHostname(headers().get('host') ?? '') ?? await getVerticalFromToken(token);
const profile = getVerticalProfile(vertical.key);
const tenantConfig = await loadTenantConfigForToken(token, vertical.key);
return (
<AdminDashboard
profile={profile}
tenantConfig={tenantConfig}
token={token}
/>
);
loadTenantConfigForToken is a small helper that reads tenant_voice_agents (or church_voice_agents view for legacy church rows) + identity table (premium_churches OR premium_funeral_homes) and adapts to TenantConfig via src/lib/tenant-config.ts:adaptTenantVoiceAgentRow.
C.7 — AdminDashboard.tsx becomes vertical-aware
Wrap the shell in <VerticalProvider profile={profile}>. Tabs are now sourced from profile.tabs (not the hardcoded ALL_TABS). RBAC gate via canSeeTab(profile.tabs[i].capability, capsSet).
Inbox tab + Train AI tab + Settings tab become VERTICAL-AWARE — they read useVertical() from context to swap labels:
- Church: "Prayer requests" / "Visitor contacts"
- Funeral: "Family inquiries" / "At-need callbacks"
The 308-redirect from /admin/funeral/[token]/* to funeralwiseai.com/admin/[token]/* lives in src/app/admin/funeral/[token]/page.tsx (delete the existing v0.1 contents and replace with a redirect):
import { redirect } from 'next/navigation';
export default function LegacyFuneralAdminRedirect({ params }: { params: { token: string } }) {
redirect(`https://funeralwiseai.com/admin/${params.token}`);
}
(Plus a similar redirect in src/app/admin/funeral/[token]/[...rest]/page.tsx for any sub-paths.)
C.8 — Inbox tab vertical-aware queries
The Inbox tab currently shows voice calls + prayer + visitor + callback + safety chips. Make it use profile.inboxFeedQuery(tenantId, opts):
- Church: pulls voice + chatbot conversations from voice_call_logs + chatbot_conversations + voice_prayer_requests + voice_visitor_contacts + voice_callback_requests + crisis_events (filtered to tenant_id where vertical='church').
- Funeral: same shape but vertical='funeral' filter.
Both verticals show chatbot AND voice conversations side by side. Phase 1 (this worktree) just gets the queries + filters working — UI polish is acceptable to be minimal.
C.9 — Vertical-template acceptance test (Phase 6 prep)
Create a stub src/lib/verticals/vet.ts that's just enough to render the shell:
import type { VerticalProfile } from './types';
export const vetProfile: VerticalProfile = {
key: 'vet',
// ... minimal stub ...
};
Update registry.ts to register it. This is the acceptance test for the abstraction: if rendering a vertical requires editing 5 files, the abstraction is wrong; if it requires editing 1 file (this stub), the abstraction is right.
DO NOT actually wire vet brand/queries — Phase 2/6 work. Just enough so getVerticalProfile('vet') doesn't throw.
Files you will modify
src/middleware.ts # add /admin path-through for funeralwiseai.com
src/app/admin/[token]/page.tsx # read VerticalProfile by hostname/token
src/app/admin/[token]/components/AdminDashboard.tsx # wrap in VerticalProvider, source tabs from profile
src/app/admin/[token]/components/AdminDashboard.rbac.ts # accept profile-driven tab list
src/app/admin/[token]/components/InboxTab.tsx (or eq.) # vertical-aware queries
src/app/admin/funeral/[token]/page.tsx # 308-redirect to funeralwiseai.com/admin/[token]
Files you will create
src/lib/verticals/types.ts
src/lib/verticals/registry.ts
src/lib/verticals/church.ts
src/lib/verticals/funeral.ts
src/lib/verticals/vet.ts # stub
src/lib/funeral-queries.ts # if doesn't exist; mirror premium-queries.ts
src/components/admin/VerticalProvider.tsx
Files NOT to touch
voice-agent-livekit/**— Worktrees A and B own voice agent.src/app/api/chatbot/stream/route.ts— Worktrees A and B own this.src/lib/tenant-config.ts— Day 1 stable.src/app/founder/[token]/voice-clients/**— Worktree D.scripts/sync-voice-clients.ts— Worktree D.src/lib/brand.ts— singleton, don't touch.- DB schema — already provisioned Day 1.
Verification (run before opening PR)
# Type-check the new modules
npx tsc --noEmit --skipLibCheck src/lib/verticals/types.ts src/lib/verticals/registry.ts src/lib/verticals/church.ts src/lib/verticals/funeral.ts src/lib/verticals/vet.ts
# Build (catches dynamic imports)
pnpm build
# Hostname rewrite manual check (locally):
# - localhost:3002/admin/<token> → loads church VerticalProfile (default)
# - funeralwiseai.localhost:3002/admin/<token> → loads funeral VerticalProfile
# - localhost:3002/admin/funeral/<token> → 308 redirect to funeralwiseai.com/admin/<token>
# Vet stub registers without throwing:
node -e "
import('./src/lib/verticals/registry.ts').then(({getVerticalProfile}) => {
console.log(getVerticalProfile('vet'));
});
"
When done
git add -A
git commit -m "feat(verticals): Day 2 Worktree C — VerticalProfile registry + funeralwiseai.com/admin route"
git push -u origin feat/verticals-platform-day2-C-vertical-dash
gh pr create \
--base feat/verticals-platform-day1-foundation \
--title "feat(verticals): Day 2 C — VerticalProfile registry + funeralwiseai.com admin route + vertical-aware Inbox" \
--body "$(cat <<'EOF'
## Summary
Day 2 Worktree C. Builds the VerticalProfile registry (`src/lib/verticals/`) so every vertical's admin shell reuses one `/admin/[token]` route. Adds `funeralwiseai.com/admin/[token]` hostname rewrite. Funeral tab shells (Home/Inbox/Train AI/Settings). Inbox tab pulls voice + chatbot conversations vertical-filtered. 308-redirects the legacy `/admin/funeral/[token]` route. Stubs a `vet.ts` profile as the acceptance test for the abstraction.
See `knowledge/specs/day2-verticals-platform/03-WORKTREE-C-vertical-dash.md` for the full spec.
## Test plan
- [ ] `pnpm build` succeeds
- [ ] Manual: `funeralwiseai.localhost:3002/admin/<token>` → funeral-themed dashboard renders
- [ ] Manual: `localhost:3002/admin/<church-token>` → church-themed dashboard unaffected
- [ ] Manual: `localhost:3002/admin/funeral/<old-token>` → 308 redirect
- [ ] Manual: `getVerticalProfile('vet')` returns the stub without throwing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
EOF
)"
Then in your final message back to the orchestrator (≤300 words):
- PR URL
- Commit SHA(s)
- Verification status
- Any deviations + why
- Anything for Day 3 integration
Hard rules
- NEVER touch voice-agent-livekit/**.
- NEVER push to
mainor tofeat/verticals-platform-day1-foundation. - NEVER write to production database (read-only via Supabase MCP for schema verification).
You're cleared to start.