Skip to main content

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 origin
    git checkout -b feat/verticals-platform-day2-C-vertical-dash origin/feat/verticals-platform-day1-foundation
  • DO NOT push to main or feat/verticals-platform-day1-foundation. PR back to feat/verticals-platform-day1-foundation. DO NOT merge own PR.

Read first (in order)

  1. C:/dev/knowledge/specs/day2-verticals-platform/00-MAIN-ORCHESTRATOR.md
  2. C:/Users/johnm/.claude/plans/steady-munching-penguin.md — sections "Layer 5 — Vertical Dashboards" and "Phase 5 (Vertical Dashboards)" verification.
  3. C:/dev/src/middleware.ts lines 106–197 — current hostname rewrite pattern. You add a new entry.
  4. C:/dev/src/app/admin/[token]/page.tsx — current church admin entry. You add VerticalProfile lookup.
  5. C:/dev/src/app/admin/[token]/components/AdminDashboard.tsx — client shell. Wrap in VerticalProvider.
  6. 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.
  7. C:/dev/src/app/admin/funeral/[token]/ — current funeral admin v0.1 (hardcoded route). You'll deprecate this with a 308 redirect.
  8. C:/dev/src/lib/brand.ts — singleton church brand. Don't change.
  9. C:/dev/src/lib/brands/funeralwiseai.ts — Layer 2 funeral brand profile. Reuse this in the registry.
  10. 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:

TabLabelCapabilityComponent
homeHomeinbox:calls:readfuneral/HomeTab
inboxInboxinbox:calls:readshared InboxTab (vertical-aware)
trainTrain AItrain:safety:editshared TrainAITab (vertical-aware copy)
settingsSettingssettings:profile:editshared 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 main or to feat/verticals-platform-day1-foundation.
  • NEVER write to production database (read-only via Supabase MCP for schema verification).

You're cleared to start.