From 2d1c42d9968d745249d8df8e1f39161453ad47e0 Mon Sep 17 00:00:00 2001 From: "@chitcommit" <208086304+chitcommit@users.noreply.github.com> Date: Fri, 13 Mar 2026 17:51:27 -0500 Subject: [PATCH] feat: add operating model workspace and tenant caller guards --- client/src/App.tsx | 12 +- client/src/__tests__/operating-model.test.ts | 47 ++ client/src/contexts/RoleContext.tsx | 26 +- client/src/hooks/use-operating-preferences.ts | 59 ++ client/src/lib/operating-model.ts | 335 +++++++++++ client/src/pages/Dashboard.tsx | 555 +++++++++++++----- client/src/pages/Settings.tsx | 535 +++++++++-------- server/__tests__/express-context.test.ts | 134 +++++ server/__tests__/middleware-caller.test.ts | 61 ++ server/__tests__/middleware-tenant.test.ts | 55 +- server/__tests__/routes-tenants.test.ts | 103 ++++ server/app.ts | 9 +- server/lib/chargeAutomation.ts | 1 + server/middleware/caller.ts | 27 + server/middleware/express-context.ts | 82 +++ server/middleware/tenant.ts | 16 + server/routes.ts | 28 +- server/routes/tenants.ts | 25 +- 18 files changed, 1674 insertions(+), 436 deletions(-) create mode 100644 client/src/__tests__/operating-model.test.ts create mode 100644 client/src/hooks/use-operating-preferences.ts create mode 100644 client/src/lib/operating-model.ts mode change 100755 => 100644 client/src/pages/Settings.tsx create mode 100644 server/__tests__/express-context.test.ts create mode 100644 server/__tests__/middleware-caller.test.ts create mode 100644 server/__tests__/routes-tenants.test.ts create mode 100644 server/middleware/caller.ts create mode 100644 server/middleware/express-context.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index ff1f5e5..696a0bf 100755 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -88,6 +88,12 @@ function App() { }); }, []); + const authContextValue = useMemo(() => ({ + user, + isAuthenticated: !!user, + isLoading: loading, + }), [user, loading]); + if (loading) { return (
@@ -101,12 +107,6 @@ function App() { ); } - const authContextValue = useMemo(() => ({ - user, - isAuthenticated: !!user, - isLoading: loading, - }), [user, loading]); - return ( diff --git a/client/src/__tests__/operating-model.test.ts b/client/src/__tests__/operating-model.test.ts new file mode 100644 index 0000000..b8b57a1 --- /dev/null +++ b/client/src/__tests__/operating-model.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { + buildFocusQueue, + DEFAULT_OPERATING_PREFERENCES, + getEnabledAgentCards, +} from '../lib/operating-model'; + +describe('operating model helpers', () => { + it('prioritizes verification failures and approvals in the focus queue', () => { + const queue = buildFocusQueue({ + role: 'cfo', + preferences: DEFAULT_OPERATING_PREFERENCES, + tasks: [ + { id: 'task-1', title: 'Review uncategorized transactions', priority: 'urgent', status: 'pending' }, + ], + workflows: [ + { id: 'wf-1', title: 'Approve roof repair', status: 'requested', costEstimate: '$1,250' }, + ], + checks: [ + { id: 'close-1', status: 'fail', message: '2 transactions remain uncategorized' }, + ], + }); + + expect(queue[0]?.title).toContain('uncategorized'); + expect(queue.some((item) => item.title.includes('Approve roof repair'))).toBe(true); + }); + + it('marks approval sentinel for attention when approvals are stalled', () => { + const cards = getEnabledAgentCards({ + role: 'user', + preferences: { + ...DEFAULT_OPERATING_PREFERENCES, + enabledAgentIds: ['approval-sentinel'], + }, + tasks: [], + workflows: [{ id: 'wf-1', title: 'Dispatch vendor', status: 'requested' }], + integrationsConfigured: 3, + checks: [], + }); + + expect(cards[0]).toMatchObject({ + id: 'approval-sentinel', + state: 'attention', + metric: '1 approvals waiting', + }); + }); +}); diff --git a/client/src/contexts/RoleContext.tsx b/client/src/contexts/RoleContext.tsx index b4a5852..2adb736 100644 --- a/client/src/contexts/RoleContext.tsx +++ b/client/src/contexts/RoleContext.tsx @@ -1,19 +1,11 @@ import { createContext, useContext, useState, ReactNode } from 'react'; +import { + ROLE_CONFIGS, + type RoleConfig, + type UserRole, +} from '@/lib/operating-model'; -export type UserRole = 'cfo' | 'accountant' | 'bookkeeper' | 'user'; - -export interface RoleConfig { - id: UserRole; - label: string; - description: string; -} - -export const ROLES: RoleConfig[] = [ - { id: 'cfo', label: 'CFO', description: 'Executive overview across all entities' }, - { id: 'accountant', label: 'Accountant', description: 'GL, reconciliation, and reporting' }, - { id: 'bookkeeper', label: 'Bookkeeper', description: 'Transaction entry and categorization' }, - { id: 'user', label: 'User', description: 'Personal expenses and approvals' }, -]; +export type { UserRole, RoleConfig } from '@/lib/operating-model'; interface RoleContextValue { currentRole: UserRole; @@ -29,7 +21,7 @@ const STORAGE_KEY = 'cf-current-role'; export function RoleProvider({ children }: { children: ReactNode }) { const [currentRole, setCurrentRoleState] = useState(() => { const saved = localStorage.getItem(STORAGE_KEY); - if (saved && ROLES.some(r => r.id === saved)) return saved as UserRole; + if (saved && ROLE_CONFIGS.some(r => r.id === saved)) return saved as UserRole; return 'cfo'; }); @@ -38,10 +30,10 @@ export function RoleProvider({ children }: { children: ReactNode }) { localStorage.setItem(STORAGE_KEY, role); }; - const roleConfig = ROLES.find(r => r.id === currentRole) || ROLES[0]; + const roleConfig = ROLE_CONFIGS.find(r => r.id === currentRole) || ROLE_CONFIGS[0]; return ( - + {children} ); diff --git a/client/src/hooks/use-operating-preferences.ts b/client/src/hooks/use-operating-preferences.ts new file mode 100644 index 0000000..41d766d --- /dev/null +++ b/client/src/hooks/use-operating-preferences.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; +import { + DEFAULT_OPERATING_PREFERENCES, + type OperatingPreferences, +} from '@/lib/operating-model'; + +const STORAGE_KEY = 'cf-operating-preferences-v1'; + +function loadPreferences(): OperatingPreferences { + if (typeof window === 'undefined') return DEFAULT_OPERATING_PREFERENCES; + + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULT_OPERATING_PREFERENCES; + + const parsed = JSON.parse(raw) as Partial; + return { + ...DEFAULT_OPERATING_PREFERENCES, + ...parsed, + enabledAgentIds: parsed.enabledAgentIds?.length + ? parsed.enabledAgentIds + : DEFAULT_OPERATING_PREFERENCES.enabledAgentIds, + }; + } catch { + return DEFAULT_OPERATING_PREFERENCES; + } +} + +export function useOperatingPreferences() { + const [preferences, setPreferences] = useState(loadPreferences); + + useEffect(() => { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)); + }, [preferences]); + + const updatePreferences = (patch: Partial) => { + setPreferences((current) => ({ ...current, ...patch })); + }; + + const toggleAgent = (agentId: string) => { + setPreferences((current) => { + const enabledAgentIds = current.enabledAgentIds.includes(agentId) + ? current.enabledAgentIds.filter((id) => id !== agentId) + : [...current.enabledAgentIds, agentId]; + return { ...current, enabledAgentIds }; + }); + }; + + const resetPreferences = () => { + setPreferences(DEFAULT_OPERATING_PREFERENCES); + }; + + return { + preferences, + updatePreferences, + toggleAgent, + resetPreferences, + }; +} diff --git a/client/src/lib/operating-model.ts b/client/src/lib/operating-model.ts new file mode 100644 index 0000000..f32e7e9 --- /dev/null +++ b/client/src/lib/operating-model.ts @@ -0,0 +1,335 @@ +export type UserRole = 'cfo' | 'accountant' | 'bookkeeper' | 'user'; +export type AutomationMode = 'human-led' | 'balanced' | 'delegated'; +export type DigestCadence = 'hourly' | 'daily' | 'weekly'; + +export interface RoleConfig { + id: UserRole; + label: string; + description: string; + headline: string; + focusAreas: string[]; + defaultAgentIds: string[]; + defaultScenarioId: string; +} + +export interface AccountableAgent { + id: string; + name: string; + ownerRole: UserRole; + summary: string; + automationLabel: string; + humanCheckpoint: string; +} + +export interface ScenarioDefinition { + id: string; + title: string; + description: string; + trigger: string; + outcome: string; + roles: UserRole[]; +} + +export interface OperatingPreferences { + leaderName: string; + automationMode: AutomationMode; + activeScenarioId: string; + digestCadence: DigestCadence; + autoCreateTasks: boolean; + autoEscalateApprovals: boolean; + requireHumanApproval: boolean; + enabledAgentIds: string[]; +} + +export interface SimpleTask { + id?: string | number; + title: string; + description?: string | null; + priority?: string | null; + status?: string | null; + completed?: boolean | null; + dueDate?: string | null; + metadata?: Record | null; +} + +export interface SimpleWorkflow { + id?: string | number; + title: string; + type?: string | null; + status?: string | null; + requestor?: string | null; + costEstimate?: string | null; +} + +export interface VerificationCheck { + id: string; + status: 'pass' | 'warn' | 'fail'; + message: string; +} + +export interface FocusItem { + id: string; + title: string; + detail: string; + lane: 'approvals' | 'exceptions' | 'delegation' | 'close'; + severity: 'high' | 'medium' | 'low'; +} + +export interface AgentCard { + id: string; + name: string; + ownerRole: UserRole; + summary: string; + state: 'active' | 'standby' | 'attention'; + metric: string; + checkpoint: string; +} + +export const ROLE_CONFIGS: RoleConfig[] = [ + { + id: 'cfo', + label: 'CFO', + description: 'Executive oversight across entities, approvals, and close readiness.', + headline: 'Lead the operating cadence and approve what agents cannot.', + focusAreas: ['cash posture', 'approvals', 'cross-entity risk'], + defaultAgentIds: ['cash-pulse', 'close-orchestrator', 'approval-sentinel'], + defaultScenarioId: 'month-end-close', + }, + { + id: 'accountant', + label: 'Accountant', + description: 'Own reconciliation, reporting controls, and tax prep.', + headline: 'Resolve exceptions quickly and keep the books certifiable.', + focusAreas: ['reconciliation', 'reporting', 'tax readiness'], + defaultAgentIds: ['reconciliation-bot', 'close-orchestrator', 'approval-sentinel'], + defaultScenarioId: 'tax-readiness', + }, + { + id: 'bookkeeper', + label: 'Bookkeeper', + description: 'Triage transaction quality, coding, and recurring work.', + headline: 'Keep the input layer clean so automation has something solid to run on.', + focusAreas: ['categorization', 'transaction hygiene', 'due items'], + defaultAgentIds: ['reconciliation-bot', 'vendor-router', 'cash-pulse'], + defaultScenarioId: 'vendor-triage', + }, + { + id: 'user', + label: 'Operator', + description: 'Single human lead coordinating accountable agents and approvals.', + headline: 'Run the team from one seat while preserving clear human accountability.', + focusAreas: ['handoffs', 'approvals', 'daily operating rhythm'], + defaultAgentIds: ['approval-sentinel', 'vendor-router', 'cash-pulse'], + defaultScenarioId: 'daily-ops', + }, +]; + +export const ACCOUNTABLE_AGENTS: AccountableAgent[] = [ + { + id: 'cash-pulse', + name: 'Cash Pulse', + ownerRole: 'cfo', + summary: 'Monitors liquidity, detects burn anomalies, and drafts actions before runway changes.', + automationLabel: 'cash anomaly watch', + humanCheckpoint: 'Leader approves funding moves and entity transfers.', + }, + { + id: 'reconciliation-bot', + name: 'Reconciliation Bot', + ownerRole: 'accountant', + summary: 'Clusters uncategorized activity, flags unreconciled lines, and proposes coding.', + automationLabel: 'daily reconciliation sweep', + humanCheckpoint: 'Accountant signs off on category changes above policy threshold.', + }, + { + id: 'vendor-router', + name: 'Vendor Router', + ownerRole: 'bookkeeper', + summary: 'Turns workflow requests into tasks, dispatches vendors, and tracks due dates.', + automationLabel: 'workflow to task routing', + humanCheckpoint: 'Bookkeeper or operator approves vendor spend before release.', + }, + { + id: 'approval-sentinel', + name: 'Approval Sentinel', + ownerRole: 'user', + summary: 'Holds approvals, escalates stale requests, and records who made the final call.', + automationLabel: 'policy-driven escalation', + humanCheckpoint: 'Named human leader remains final approver on escalations.', + }, + { + id: 'close-orchestrator', + name: 'Close Orchestrator', + ownerRole: 'accountant', + summary: 'Packages report preflight findings into role-based actions for close and tax readiness.', + automationLabel: 'close readiness packaging', + humanCheckpoint: 'CFO confirms readiness before filing or board review.', + }, +]; + +export const SCENARIOS: ScenarioDefinition[] = [ + { + id: 'month-end-close', + title: 'Month-End Close', + description: 'Sequence reconciliations, approvals, and entity checks into one operating run.', + trigger: 'Last 3 business days of the month', + outcome: 'Books closed with explicit human signoff and agent auditability.', + roles: ['cfo', 'accountant', 'bookkeeper', 'user'], + }, + { + id: 'tax-readiness', + title: 'Tax Readiness', + description: 'Convert reporting gaps and state-level issues into assignable remediation work.', + trigger: 'Readiness warnings or filing prep', + outcome: 'Ready-to-file posture with a concrete remediation path if blocked.', + roles: ['cfo', 'accountant'], + }, + { + id: 'vendor-triage', + title: 'Vendor Triage', + description: 'Handle maintenance or expense requests with queue ownership and approval policy.', + trigger: 'New workflow requests or cost overruns', + outcome: 'Approved, dispatched, and tracked vendor work without losing accountability.', + roles: ['bookkeeper', 'user', 'cfo'], + }, + { + id: 'daily-ops', + title: 'Daily Ops', + description: 'Single-seat operator view for inbox, approvals, automations, and agent health.', + trigger: 'Every workday', + outcome: 'One human lead can supervise the operating system without blind spots.', + roles: ['user', 'cfo', 'bookkeeper'], + }, +]; + +export const DEFAULT_OPERATING_PREFERENCES: OperatingPreferences = { + leaderName: 'NB', + automationMode: 'balanced', + activeScenarioId: 'daily-ops', + digestCadence: 'daily', + autoCreateTasks: true, + autoEscalateApprovals: true, + requireHumanApproval: true, + enabledAgentIds: ['cash-pulse', 'reconciliation-bot', 'approval-sentinel'], +}; + +export function getRoleConfig(role: UserRole): RoleConfig { + return ROLE_CONFIGS.find((item) => item.id === role) ?? ROLE_CONFIGS[0]; +} + +export function getScenario(role: UserRole, scenarioId?: string): ScenarioDefinition { + return ( + SCENARIOS.find((item) => item.id === scenarioId && item.roles.includes(role)) ?? + SCENARIOS.find((item) => item.roles.includes(role)) ?? + SCENARIOS[0] + ); +} + +export function getEnabledAgentCards(args: { + role: UserRole; + preferences: OperatingPreferences; + tasks: SimpleTask[]; + workflows: SimpleWorkflow[]; + integrationsConfigured: number; + checks: VerificationCheck[]; +}): AgentCard[] { + const { role, preferences, tasks, workflows, integrationsConfigured, checks } = args; + const highSeverityTasks = tasks.filter((task) => !task.completed && task.priority === 'urgent').length; + const stalledApprovals = workflows.filter((workflow) => workflow.status === 'requested').length; + const failingChecks = checks.filter((check) => check.status === 'fail').length; + + return ACCOUNTABLE_AGENTS + .filter((agent) => preferences.enabledAgentIds.includes(agent.id)) + .map((agent) => { + let state: AgentCard['state'] = 'active'; + let metric = `${integrationsConfigured} integrations online`; + + if (agent.id === 'approval-sentinel') { + metric = `${stalledApprovals} approvals waiting`; + state = stalledApprovals > 0 ? 'attention' : 'active'; + } + + if (agent.id === 'reconciliation-bot') { + metric = `${highSeverityTasks} urgent tasks in queue`; + state = highSeverityTasks > 0 ? 'attention' : 'active'; + } + + if (agent.id === 'close-orchestrator') { + metric = `${failingChecks} close blockers`; + state = failingChecks > 0 ? 'attention' : 'standby'; + } + + if (preferences.automationMode === 'human-led' && agent.ownerRole !== role) { + state = state === 'attention' ? 'attention' : 'standby'; + } + + return { + id: agent.id, + name: agent.name, + ownerRole: agent.ownerRole, + summary: agent.summary, + state, + metric, + checkpoint: agent.humanCheckpoint, + }; + }); +} + +export function buildFocusQueue(args: { + role: UserRole; + tasks: SimpleTask[]; + workflows: SimpleWorkflow[]; + checks: VerificationCheck[]; + preferences: OperatingPreferences; +}): FocusItem[] { + const { role, tasks, workflows, checks, preferences } = args; + const items: FocusItem[] = []; + + const openTasks = tasks.filter((task) => !task.completed && task.status !== 'completed'); + const pendingApprovals = workflows.filter((workflow) => workflow.status === 'requested'); + const activeWorkflows = workflows.filter((workflow) => workflow.status && workflow.status !== 'completed'); + + for (const check of checks.filter((item) => item.status !== 'pass').slice(0, 3)) { + items.push({ + id: `check-${check.id}`, + title: check.message, + detail: check.status === 'fail' ? 'Close blocker detected' : 'Needs review before automation proceeds', + lane: 'close', + severity: check.status === 'fail' ? 'high' : 'medium', + }); + } + + for (const workflow of pendingApprovals.slice(0, 2)) { + items.push({ + id: `workflow-${workflow.id ?? workflow.title}`, + title: workflow.title, + detail: workflow.costEstimate + ? `Awaiting approval · ${workflow.costEstimate}` + : 'Awaiting approval', + lane: 'approvals', + severity: 'high', + }); + } + + for (const task of openTasks.slice(0, 4)) { + items.push({ + id: `task-${task.id ?? task.title}`, + title: task.title, + detail: task.description || task.status || 'Queued work item', + lane: task.priority === 'urgent' ? 'exceptions' : 'delegation', + severity: task.priority === 'urgent' ? 'high' : task.priority === 'due_soon' ? 'medium' : 'low', + }); + } + + if (preferences.autoEscalateApprovals && pendingApprovals.length === 0) { + items.push({ + id: 'delegation-check', + title: `Keep ${getRoleConfig(role).label} delegation clean`, + detail: `${activeWorkflows.length} workflows currently in motion`, + lane: 'delegation', + severity: 'low', + }); + } + + return items.slice(0, 6); +} diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 76291fe..633bcd9 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -1,32 +1,55 @@ -import { useState } from 'react'; +import { useState, type FormEvent } from 'react'; import { Link } from 'wouter'; +import { useMutation, useQuery } from '@tanstack/react-query'; import { - DollarSign, TrendingUp, TrendingDown, BarChart3, Users, - ArrowUpRight, ArrowDownRight, Send, Loader2, - Plug, ChevronRight, Sparkles, Building2 + Activity, ArrowDownRight, ArrowUpRight, Bot, Building2, CheckCircle2, + ChevronRight, ClipboardCheck, Loader2, Plug, Send, ShieldCheck, + Sparkles, Users, Workflow, } from 'lucide-react'; -import { usePortfolioSummary } from '@/hooks/use-property'; -import { useTenantId, useTenant } from '@/contexts/TenantContext'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useTenant, useTenantId } from '@/contexts/TenantContext'; +import { useRole } from '@/contexts/RoleContext'; import { formatCurrency } from '@/lib/utils'; +import { usePortfolioSummary } from '@/hooks/use-property'; +import { useOperatingPreferences } from '@/hooks/use-operating-preferences'; +import { useConsolidatedReport } from '@/hooks/use-reports'; +import { + buildFocusQueue, + getEnabledAgentCards, + getScenario, + type SimpleTask, + type SimpleWorkflow, +} from '@/lib/operating-model'; import type { Transaction } from '@shared/schema'; -/* ─── KPI Metric Card ─── */ +interface FinancialSummary { + cashOnHand: number; + monthlyRevenue: number; + monthlyExpenses: number; + outstandingInvoices: number; +} + +interface IntegrationStatus { + configured: boolean; + label?: string; +} + function MetricCard({ - label, value, sub, icon: Icon, delta, delay, + label, + value, + sub, + delta, + icon: Icon, + delay, }: { label: string; value: string; sub: string; - icon: React.ElementType; delta?: { value: string; positive: boolean }; + icon: typeof Activity; delay: number; }) { return ( -
+
{label}
@@ -47,17 +70,125 @@ function MetricCard({ ); } -/* ─── Transaction Row ─── */ -function TxnRow({ title, description, amount, date }: { - title: string; description?: string; amount: number; date?: string; +function QueueCard({ + title, + items, +}: { + title: string; + items: ReturnType; +}) { + return ( +
+
+
+ + {title} +
+ {items.length} active +
+
+ {items.length === 0 ? ( +
+ Queue is clear. Keep agents running and approvals tight. +
+ ) : ( + items.map((item) => ( +
+
+
+

{item.title}

+

{item.detail}

+
+ + {item.lane} + +
+
+ )) + )} +
+
+ ); +} + +function AgentGrid({ + items, +}: { + items: ReturnType; +}) { + return ( +
+
+
+ + Accountable Agents +
+ human-led +
+
+ {items.map((agent) => ( +
+
+
+
+

{agent.name}

+ + {agent.ownerRole} + +
+

{agent.summary}

+
+ +
+
+ {agent.metric} + checkpoint +
+

{agent.checkpoint}

+
+ ))} +
+
+ ); +} + +function TxnRow({ + title, + description, + amount, + date, +}: { + title: string; + description?: string; + amount: number; + date?: string; }) { const positive = amount >= 0; return (
{positive - ? - : + ? + : }
@@ -72,11 +203,10 @@ function TxnRow({ title, description, amount, date }: { ); } -/* ─── AI Chat Inline ─── */ function AIQuickChat() { const [input, setInput] = useState(''); const [messages, setMessages] = useState>([ - { role: 'assistant', content: 'Ready. Ask about cash flow, optimization, or property performance.' }, + { role: 'assistant', content: 'Ready. Ask for queue triage, close blockers, or an execution plan for your current scenario.' }, ]); const ask = useMutation({ @@ -89,36 +219,34 @@ function AIQuickChat() { if (!r.ok) return { content: 'Unable to reach AI advisor.' }; return r.json() as Promise<{ content: string }>; }, - onSuccess: (data) => setMessages(prev => [...prev, { role: 'assistant', content: data.content }]), + onSuccess: (data) => setMessages((prev) => [...prev, { role: 'assistant', content: data.content }]), }); - const send = (e: React.FormEvent) => { - e.preventDefault(); + const send = (event: FormEvent) => { + event.preventDefault(); if (!input.trim() || ask.isPending) return; - setMessages(prev => [...prev, { role: 'user', content: input }]); + setMessages((prev) => [...prev, { role: 'user', content: input }]); ask.mutate(input); setInput(''); }; return ( -
- {/* Header */} +
AI CFO - GPT-4o + scenario assist
- {/* Messages */}
- {messages.map((m, i) => ( -
+ {messages.map((message, index) => ( +
- {m.content} + {message.content}
))} @@ -131,12 +259,11 @@ function AIQuickChat() { )}
- {/* Input */}
setInput(e.target.value)} - placeholder="Ask about your finances..." + onChange={(event) => setInput(event.target.value)} + placeholder="Ask how to run this queue..." disabled={ask.isPending} className="flex-1 bg-[hsl(var(--cf-raised))] text-sm text-[hsl(var(--cf-text))] placeholder:text-[hsl(var(--cf-text-muted))] rounded-md px-3 py-2 border border-[hsl(var(--cf-border-subtle))] focus:border-[hsl(var(--cf-lime)/0.4)] focus:outline-none transition-colors" /> @@ -152,26 +279,24 @@ function AIQuickChat() { ); } -/* ─── Integration Status Strip ─── */ -function IntegrationStrip() { - const { data } = useQuery>({ - queryKey: ['/api/integrations/status'], - staleTime: 60_000, - }); - +function IntegrationStrip({ + data, +}: { + data?: Record; +}) { const services = [ - { key: 'mercury', label: 'Mercury', color: '--cf-cyan' }, - { key: 'wave', label: 'Wave', color: '--cf-violet' }, - { key: 'stripe', label: 'Stripe', color: '--cf-amber' }, - { key: 'openai', label: 'OpenAI', color: '--cf-lime' }, + { key: 'mercury', label: 'Mercury' }, + { key: 'wave', label: 'Wave' }, + { key: 'stripe', label: 'Stripe' }, + { key: 'openai', label: 'OpenAI' }, ]; return ( -
+
- Integrations + Automation Surface
@@ -180,14 +305,15 @@ function IntegrationStrip() {
- {services.map(s => { - const configured = data?.[s.key]?.configured ?? false; + {services.map((service) => { + const configured = data?.[service.key]?.configured ?? false; return ( -
- + - {s.label} + {service.label} {configured ? 'live' : 'off'} @@ -199,142 +325,249 @@ function IntegrationStrip() { ); } -/* ─── Main Dashboard ─── */ export default function Dashboard() { const tenantId = useTenantId(); const { currentTenant } = useTenant(); + const { currentRole, roleConfig } = useRole(); + const { preferences } = useOperatingPreferences(); + const scenario = getScenario(currentRole, preferences.activeScenarioId); + const { data: portfolio, isLoading: portfolioLoading } = usePortfolioSummary(); + const { data: financialSummary } = useQuery({ + queryKey: ['/api/financial-summary'], + enabled: !tenantId, + }); const { data: transactions = [] } = useQuery({ - queryKey: ['/api/transactions', tenantId, { limit: 6 }], + queryKey: ['/api/transactions?limit=6'], + }); + const { data: tasks = [] } = useQuery({ + queryKey: ['/api/tasks'], + }); + const { data: workflows = [] } = useQuery({ + queryKey: ['/api/workflows'], enabled: !!tenantId, }); + const { data: integrationStatus } = useQuery>({ + queryKey: ['/api/integrations/status'], + staleTime: 60_000, + }); + const { data: reportData } = useConsolidatedReport( + tenantId + ? { + startDate: `${new Date().getFullYear()}-01-01`, + endDate: `${new Date().getFullYear()}-12-31`, + includeDescendants: true, + includeIntercompany: false, + } + : null, + ); - if (!tenantId) { - return ( -
-

Select a tenant to view the dashboard.

-
- ); - } - + const checks = reportData?.verificationChecklist ?? []; + const integrationsConfigured = Object.values(integrationStatus ?? {}).filter((item) => item.configured).length; + const openTaskCount = tasks.filter((task) => !task.completed && task.status !== 'completed').length; + const pendingApprovals = workflows.filter((workflow) => workflow.status === 'requested').length; + const agentCards = getEnabledAgentCards({ + role: currentRole, + preferences, + tasks, + workflows, + integrationsConfigured, + checks, + }); + const focusQueue = buildFocusQueue({ + role: currentRole, + tasks, + workflows, + checks, + preferences, + }); const recent = transactions.slice(0, 6); + const readyToFile = reportData?.preflight?.readyToFileTaxes ?? false; + const scenarioSummary = `${scenario.trigger} · ${scenario.outcome}`; + + const primaryValue = tenantId + ? formatCurrency(portfolio?.totalValue ?? 0) + : formatCurrency(financialSummary?.cashOnHand ?? 0); + const primarySub = tenantId + ? `${portfolio?.totalProperties ?? 0} properties` + : 'cash on hand'; + const secondaryValue = tenantId + ? formatCurrency(portfolio?.totalNOI ?? 0) + : formatCurrency(financialSummary?.monthlyRevenue ?? 0); + const secondarySub = tenantId ? 'Last 12 months' : 'monthly revenue'; + const tertiaryValue = tenantId + ? `${(portfolio?.avgCapRate ?? 0).toFixed(1)}%` + : formatCurrency(financialSummary?.monthlyExpenses ?? 0); + const tertiarySub = tenantId ? 'weighted cap rate' : 'monthly expenses'; + const quaternaryValue = tenantId + ? `${(portfolio?.occupancyRate ?? 0).toFixed(0)}%` + : formatCurrency(financialSummary?.outstandingInvoices ?? 0); + const quaternarySub = tenantId + ? `${portfolio?.occupiedUnits ?? 0}/${portfolio?.totalUnits ?? 0} units` + : 'outstanding invoices'; return ( -
- {/* Page Header */} -
-

- Financial Overview -

-

- {currentTenant?.name || 'All entities'} — {new Date().toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} -

+
+
+
+
+
+
+
+
+ {roleConfig.label} + + {preferences.automationMode} + +
+

+ Human-led finance operations, agent-accountable by design. +

+

+ {roleConfig.headline} +

+
+
+

Command Context

+

+ {currentTenant?.name || 'Standalone operator workspace'} +

+

+ Leader: {preferences.leaderName} · Digest: {preferences.digestCadence} +

+

+ Scenario: {scenario.title} +

+
+
+ +
+
+

Scenario Improvement

+

{scenario.title}

+

{scenario.description}

+

{scenarioSummary}

+
+
+

Approval Load

+

{pendingApprovals}

+

Requests waiting for human signoff

+
+
+

Close Posture

+
+ +

{readyToFile ? 'Ready to file' : 'Needs remediation'}

+
+

+ {checks.length} checks tracked for this operating cycle +

+
+
+
+
+ +
- {/* KPI Strip */}
- {/* Two Column: Activity + AI */} -
- {/* Left: Recent Activity (3 cols) */} -
- {/* Recent Transactions */} -
-
- Recent Activity - - - View all - - -
- {recent.length === 0 ? ( -
- No recent transactions -
- ) : ( -
- {recent.map((tx, i) => ( - - ))} -
- )} -
+
+ +
+ + +
+
- {/* Quick Property Summary */} - {portfolio && portfolio.properties.length > 0 && ( -
-
- Properties - - - Portfolio - - -
-
- {portfolio.properties.slice(0, 4).map(p => ( - -
-
- -
-
-

{p.name}

-

{p.city}, {p.state}

-
-
-

{formatCurrency(p.currentValue)}

-

{p.capRate.toFixed(1)}% cap

-
-
- - ))} -
+
+
+
+ Recent Activity + + + View all + + +
+ {recent.length === 0 ? ( +
+ No recent transactions +
+ ) : ( +
+ {recent.map((tx, index) => ( + + ))}
)}
- {/* Right: AI + Integrations (2 cols) */} -
- - +
+
+
+ + Role Playbook +
+ + + Tune + + +
+
+ {roleConfig.focusAreas.map((area) => ( +
+

{area}

+

+ Human lead remains accountable while agents compress the work into a reviewable queue. +

+
+ ))} +
+

Automation policy

+

+ Task creation: {preferences.autoCreateTasks ? 'on' : 'off'} · Escalations: {preferences.autoEscalateApprovals ? 'on' : 'off'} · Human approval: {preferences.requireHumanApproval ? 'required' : 'optional'} +

+
+
diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx old mode 100755 new mode 100644 index b5a22dd..96f5f87 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -1,255 +1,328 @@ -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { useQuery } from "@tanstack/react-query"; -import { Integration, User } from "@shared/schema"; -import { Skeleton } from "@/components/ui/skeleton"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Switch } from "@/components/ui/switch"; -import { getServiceColor, getServiceIcon } from "@/lib/utils"; -import { Badge } from "@/components/ui/badge"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; -import { useToast } from "@/hooks/use-toast"; -import MercuryAccounts from "@/components/integrations/MercuryAccounts"; +import { useEffect, useState, type ReactNode } from 'react'; +import { Bot, CheckCircle2, RotateCcw, Save, ShieldCheck, Users, Workflow } from 'lucide-react'; +import { useRole } from '@/contexts/RoleContext'; +import { useToast } from '@/hooks/use-toast'; +import { useOperatingPreferences } from '@/hooks/use-operating-preferences'; +import { + ACCOUNTABLE_AGENTS, + DEFAULT_OPERATING_PREFERENCES, + ROLE_CONFIGS, + SCENARIOS, + type AutomationMode, + type DigestCadence, + type OperatingPreferences, +} from '@/lib/operating-model'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +function Section({ + icon, + title, + subtitle, + children, +}: { + icon: ReactNode; + title: string; + subtitle: string; + children: ReactNode; +}) { + return ( +
+
+ {icon} +
+

{title}

+

{subtitle}

+
+
+
{children}
+
+ ); +} export default function Settings() { const { toast } = useToast(); - type MercuryAccount = { id: string; name: string; last4?: string; type?: string; currency?: string }; - // Get user data - const { data: user, isLoading: isLoadingUser } = useQuery({ - queryKey: ["/api/session"], - }); - - // Get integrations - const { data: integrations, isLoading: isLoadingIntegrations } = useQuery({ - queryKey: ["/api/integrations"], - }); - -// Fetch Mercury accounts (for tooltips / labels) - const { data: mercuryAccounts } = useQuery({ - queryKey: ["/api/mercury/accounts"], - }); + const { currentRole } = useRole(); + const { preferences, updatePreferences, resetPreferences } = useOperatingPreferences(); + const [draft, setDraft] = useState(preferences); + + useEffect(() => { + setDraft(preferences); + }, [preferences]); + + const saveDraft = () => { + updatePreferences(draft); + toast({ + title: 'Operating settings saved', + description: 'Leader, scenario, and automation policy are now applied to the workspace.', + }); + }; + + const resetDraft = () => { + resetPreferences(); + setDraft(DEFAULT_OPERATING_PREFERENCES); + toast({ + title: 'Operating settings reset', + description: 'Workspace returned to the default human-led operating model.', + }); + }; + + const updateDraft = (key: K, value: OperatingPreferences[K]) => { + setDraft((current) => ({ ...current, [key]: value })); + }; + + const saveAgents = () => { + updatePreferences({ enabledAgentIds: draft.enabledAgentIds }); + toast({ + title: 'Agent roster updated', + description: 'Accountable agent assignments were persisted for this operator seat.', + }); + }; + + const handleAgentToggle = (agentId: string) => { + setDraft((current) => ({ + ...current, + enabledAgentIds: current.enabledAgentIds.includes(agentId) + ? current.enabledAgentIds.filter((id) => id !== agentId) + : [...current.enabledAgentIds, agentId], + })); + }; + return ( -
- {/* Page Header */} -
-

- Settings -

- -
- Configure your account, integrations, and preferences. +
+
+
+

Settings

+

+ Configure the single human lead, accountable agents, and role-aware automations. +

+
+
+ +
- {/* Settings Content */} -
- - - Profile - Integrations - Notifications - - - - - - Profile Settings - - Update your account information and preferences. - - - - {isLoadingUser ? ( -
- - - - -
- ) : ( - <> -
- - -
-
- - -
-
- - -
-
- -
- - )} -
-
-
- - - - - Service Integrations - - Manage connections to your financial services and productivity tools. - - - - {isLoadingIntegrations ? ( -
- - - -
- ) : ( -
- {integrations?.map((integration) => ( -
-
-
- {getServiceIcon(integration.serviceType)} -
-
-

{integration.name}

-

- {integration.description} - {integration.serviceType === "mercury_bank" && ( - Managed via ChittyConnect - )} -

-
-
-
- {integration.serviceType === "mercury_bank" && (() => { - const selected: string[] = ((integration.credentials as any)?.selectedAccountIds || []) as string[]; - const names = (mercuryAccounts || []) - .filter(a => selected.includes(a.id)) - .map(a => `${a.name}${a.last4 ? ` • ${a.last4}` : ''}`); - const count = selected.length || 0; - return ( - - - - - {count} {count === 1 ? 'account' : 'accounts'} - - - -
- {names.length > 0 ? names.join('\n') : 'No account details available'} -
-
-
-
- ); - })()} -
- - -
- {integration.serviceType === "mercury_bank" && ( - <> - - - - - - )} - -
-
- ))} +
+
} + title="Leader Model" + subtitle="Who is accountable, how decisions flow, and what role view drives the shell." + > +
+
+ + updateDraft('leaderName', event.target.value)} + className="bg-[hsl(var(--cf-surface))] border-[hsl(var(--cf-border-subtle))]" + placeholder="Name" + /> +
+
+ +
+ {ROLE_CONFIGS.find((role) => role.id === currentRole)?.label} + active for this session +
+
+
- -
- )} - - +
+
+ + +
+
+ + +
+
-
- +
+
+

Escalation

+

+ {draft.autoEscalateApprovals ? 'Agent-driven' : 'Manual only'} +

- - - - - - Notification Preferences - - Control when and how you want to be notified. - - - -
-
-
-

Financial Alerts

-

- Receive alerts for unusual financial activity. -

-
- -
+
+

Approval Policy

+

+ {draft.requireHumanApproval ? 'Human required' : 'Auto-approved allowed'} +

+
+
+

Task Routing

+

+ {draft.autoCreateTasks ? 'Create automatically' : 'Manual capture'} +

+
+
+ -
-
-

Invoice Reminders

-

- Get notified when invoices are coming due. -

-
- -
+
} + title="Scenario Pack" + subtitle="Choose the operating scenario your team and agents optimize around." + > +
+ + +
-
+
+ {SCENARIOS.map((scenario) => { + const active = scenario.id === draft.activeScenarioId; + return ( +
+
-

AI CFO Insights

-

- Proactive financial advice from your AI assistant. -

+

{scenario.title}

+

{scenario.description}

- + {active && }
+
+ {scenario.roles.map((role) => ( + {role} + ))} +
+
+ ); + })} +
+
+
-
+
+
} + title="Accountable Agents" + subtitle="Enable agent roles, but keep named human checkpoints intact." + > +
+ {ACCOUNTABLE_AGENTS.map((agent) => { + const enabled = draft.enabledAgentIds.includes(agent.id); + return ( +
+
-

Account Activity

-

- Be notified about significant account activity. +

+

{agent.name}

+ {agent.ownerRole} +
+

{agent.summary}

+

+ {agent.automationLabel} · {agent.humanCheckpoint}

- -
- -
- + handleAgentToggle(agent.id)} />
- - - - + ); + })} +
+
+ +
+
+ +
} + title="Automation Policy" + subtitle="Define when the system can create work, escalate, or wait for human signoff." + > +
+
+
+

Auto-create tasks

+

Turn workflow and reporting findings into queue items automatically.

+
+ updateDraft('autoCreateTasks', checked)} /> +
+
+
+

Auto-escalate approvals

+

Escalate stale requests to the named human lead.

+
+ updateDraft('autoEscalateApprovals', checked)} /> +
+
+
+

Require human approval

+

Keep a named person accountable for high-risk actions.

+
+ updateDraft('requireHumanApproval', checked)} /> +
+
+ +
+

Current posture

+
+ {draft.automationMode} + {draft.digestCadence} digest + {draft.enabledAgentIds.length} agents enabled +
+
+
); -} \ No newline at end of file +} diff --git a/server/__tests__/express-context.test.ts b/server/__tests__/express-context.test.ts new file mode 100644 index 0000000..0d17cd2 --- /dev/null +++ b/server/__tests__/express-context.test.ts @@ -0,0 +1,134 @@ +import express, { type Request, type RequestHandler } from 'express'; +import { describe, expect, it, vi } from 'vitest'; +import { + createCallerContext, + createTenantAccessResolver, +} from '../middleware/express-context'; + +type ContextRequest = Request & { userId?: string; tenantId?: string }; + +async function runRequest(app: express.Express, setup?: (url: URL) => void, headers?: Record) { + const server = app.listen(0); + try { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Failed to bind test server'); + } + + const url = new URL(`http://127.0.0.1:${address.port}/test`); + setup?.(url); + const res = await fetch(url, { headers }); + return { + status: res.status, + body: await res.json(), + }; + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +function buildApp(middleware: RequestHandler) { + const app = express(); + app.get('/test', middleware, (req, res) => { + const contextReq = req as ContextRequest; + res.json({ + userId: contextReq.userId ?? null, + tenantId: contextReq.tenantId ?? null, + }); + }); + return app; +} + +describe('express caller context', () => { + it('loads the explicit caller from headers', async () => { + const app = buildApp( + createCallerContext({ + getUser: vi.fn().mockResolvedValue({ id: 'user-123' }), + getSessionUser: vi.fn(), + }), + ); + + const res = await runRequest(app, undefined, { 'X-Chitty-User-Id': 'user-123' }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ userId: 'user-123', tenantId: null }); + }); + + it('falls back to the session user when no explicit caller is provided', async () => { + const app = buildApp( + createCallerContext({ + getUser: vi.fn(), + getSessionUser: vi.fn().mockResolvedValue({ id: 'session-user' }), + }), + ); + + const res = await runRequest(app); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ userId: 'session-user', tenantId: null }); + }); + + it('returns 404 for an unknown explicit caller', async () => { + const app = buildApp( + createCallerContext({ + getUser: vi.fn().mockResolvedValue(undefined), + getSessionUser: vi.fn(), + }), + ); + + const res = await runRequest(app, undefined, { 'X-User-Id': 'missing-user' }); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'user_not_found' }); + }); +}); + +describe('express tenant access resolver', () => { + it('sets tenantId when the caller belongs to the tenant', async () => { + const app = express(); + app.get( + '/test', + ((req, _res, next) => { + (req as ContextRequest).userId = 'user-123'; + next(); + }) as RequestHandler, + createTenantAccessResolver({ + getUserTenants: vi.fn().mockResolvedValue([ + { tenant: { id: 'tenant-1' } }, + { tenant: { id: 'tenant-2' } }, + ]), + }), + (req, res) => res.json({ tenantId: (req as ContextRequest).tenantId }), + ); + + const res = await runRequest(app, undefined, { 'X-Tenant-ID': 'tenant-2' }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ tenantId: 'tenant-2' }); + }); + + it('rejects access when the caller is not a tenant member', async () => { + const app = express(); + app.get( + '/test', + ((req, _res, next) => { + (req as ContextRequest).userId = 'user-123'; + next(); + }) as RequestHandler, + createTenantAccessResolver({ + getUserTenants: vi.fn().mockResolvedValue([{ tenant: { id: 'tenant-1' } }]), + }), + (_req, res) => res.json({ ok: true }), + ); + + const res = await runRequest(app, undefined, { 'X-Tenant-ID': 'tenant-9' }); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ + error: 'forbidden', + message: 'Caller does not have access to tenant', + }); + }); +}); diff --git a/server/__tests__/middleware-caller.test.ts b/server/__tests__/middleware-caller.test.ts new file mode 100644 index 0000000..4ae7be9 --- /dev/null +++ b/server/__tests__/middleware-caller.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; +import type { HonoEnv } from '../env'; +import { callerContext } from '../middleware/caller'; + +describe('callerContext middleware', () => { + const env = { + CHITTY_AUTH_SERVICE_TOKEN: 'test-token', + DATABASE_URL: 'fake', + FINANCE_KV: {} as any, + FINANCE_R2: {} as any, + ASSETS: {} as any, + CF_AGENT: {} as any, + }; + + function buildApp(getUser = vi.fn()) { + const storage = { getUser }; + const app = new Hono(); + + app.use('/api/*', async (c, next) => { + c.set('storage', storage as any); + await next(); + }); + app.use('/api/*', callerContext); + app.get('/api/test', (c) => c.json({ userId: c.get('userId') })); + + return { app, getUser }; + } + + it('returns 400 when no caller header or query param is provided', async () => { + const { app } = buildApp(); + const res = await app.request('/api/test', {}, env); + + expect(res.status).toBe(400); + await expect(res.json()).resolves.toMatchObject({ + error: 'missing_user_id', + }); + }); + + it('returns 404 when the caller is not found', async () => { + const getUser = vi.fn().mockResolvedValue(undefined); + const { app } = buildApp(getUser); + const res = await app.request('/api/test', { + headers: { 'X-Chitty-User-Id': 'user-404' }, + }, env); + + expect(res.status).toBe(404); + expect(getUser).toHaveBeenCalledWith('user-404'); + }); + + it('loads the caller from the fallback x-user-id header', async () => { + const { app, getUser } = buildApp(vi.fn().mockResolvedValue({ id: 'user-123' })); + const res = await app.request('/api/test', { + headers: { 'X-User-Id': 'user-123' }, + }, env); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ userId: 'user-123' }); + expect(getUser).toHaveBeenCalledWith('user-123'); + }); +}); diff --git a/server/__tests__/middleware-tenant.test.ts b/server/__tests__/middleware-tenant.test.ts index afb94dd..0c499a7 100644 --- a/server/__tests__/middleware-tenant.test.ts +++ b/server/__tests__/middleware-tenant.test.ts @@ -1,11 +1,25 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { Hono } from 'hono'; import type { HonoEnv } from '../env'; import { tenantMiddleware } from '../middleware/tenant'; describe('tenantMiddleware', () => { - function buildApp() { + function buildApp(options?: { + userId?: string; + storage?: { + getUserTenants?: (userId: string) => Promise>; + }; + }) { const app = new Hono(); + app.use('/api/*', async (c, next) => { + if (options?.userId) { + c.set('userId', options.userId); + } + if (options?.storage) { + c.set('storage', options.storage as any); + } + await next(); + }); app.use('/api/*', tenantMiddleware); app.get('/api/test', (c) => c.json({ tenantId: c.get('tenantId') })); return app; @@ -43,4 +57,41 @@ describe('tenantMiddleware', () => { const res = await app.request('/api/test', {}, env); expect(res.status).toBe(400); }); + + it('allows the request when the caller belongs to the tenant', async () => { + const getUserTenants = vi.fn().mockResolvedValue([ + { tenant: { id: 'tenant-abc' } }, + { tenant: { id: 'tenant-other' } }, + ]); + const app = buildApp({ + userId: 'user-123', + storage: { getUserTenants }, + }); + + const res = await app.request('/api/test', { + headers: { 'X-Tenant-ID': 'tenant-abc' }, + }, env); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ tenantId: 'tenant-abc' }); + expect(getUserTenants).toHaveBeenCalledWith('user-123'); + }); + + it('returns 403 when the caller does not belong to the tenant', async () => { + const app = buildApp({ + userId: 'user-123', + storage: { + getUserTenants: vi.fn().mockResolvedValue([{ tenant: { id: 'tenant-other' } }]), + }, + }); + + const res = await app.request('/api/test', { + headers: { 'X-Tenant-ID': 'tenant-abc' }, + }, env); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toMatchObject({ + error: 'forbidden', + }); + }); }); diff --git a/server/__tests__/routes-tenants.test.ts b/server/__tests__/routes-tenants.test.ts new file mode 100644 index 0000000..6537a69 --- /dev/null +++ b/server/__tests__/routes-tenants.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest'; +import { Hono } from 'hono'; +import type { HonoEnv } from '../env'; +import { tenantRoutes } from '../routes/tenants'; + +describe('tenantRoutes', () => { + const env = { + CHITTY_AUTH_SERVICE_TOKEN: 'test-token', + DATABASE_URL: 'fake', + FINANCE_KV: {} as any, + FINANCE_R2: {} as any, + ASSETS: {} as any, + CF_AGENT: {} as any, + }; + + function buildApp(storageOverrides?: { + getUserTenants?: (userId: string) => Promise; role: string }>>; + getTenant?: (tenantId: string) => Promise | undefined>; + }) { + const storage = { + getUserTenants: storageOverrides?.getUserTenants ?? vi.fn().mockResolvedValue([]), + getTenant: storageOverrides?.getTenant ?? vi.fn().mockResolvedValue(undefined), + }; + + const app = new Hono(); + app.use('/api/tenants/*', async (c, next) => { + c.set('storage', storage as any); + c.set('userId', 'user-123'); + await next(); + }); + app.use('/api/tenants', async (c, next) => { + c.set('storage', storage as any); + c.set('userId', 'user-123'); + await next(); + }); + app.route('/', tenantRoutes); + + return { app, storage }; + } + + it('lists only the caller memberships and flattens the role onto each tenant', async () => { + const memberships = [ + { + tenant: { id: 'tenant-1', name: 'Alpha Holdings', slug: 'alpha' }, + role: 'owner', + }, + { + tenant: { id: 'tenant-2', name: 'Beta Ops', slug: 'beta' }, + role: 'accountant', + }, + ]; + const { app, storage } = buildApp({ + getUserTenants: vi.fn().mockResolvedValue(memberships), + }); + + const res = await app.request('/api/tenants', {}, env); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual([ + { id: 'tenant-1', name: 'Alpha Holdings', slug: 'alpha', role: 'owner' }, + { id: 'tenant-2', name: 'Beta Ops', slug: 'beta', role: 'accountant' }, + ]); + expect(storage.getUserTenants).toHaveBeenCalledWith('user-123'); + }); + + it('returns the tenant detail when the caller is a member', async () => { + const { app, storage } = buildApp({ + getUserTenants: vi.fn().mockResolvedValue([ + { tenant: { id: 'tenant-1', slug: 'alpha' }, role: 'owner' }, + ]), + getTenant: vi.fn().mockResolvedValue({ + id: 'tenant-1', + slug: 'alpha', + name: 'Alpha Holdings', + }), + }); + + const res = await app.request('/api/tenants/tenant-1', {}, env); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + id: 'tenant-1', + slug: 'alpha', + name: 'Alpha Holdings', + role: 'owner', + }); + expect(storage.getTenant).toHaveBeenCalledWith('tenant-1'); + }); + + it('returns 404 when the caller is not a member of the requested tenant', async () => { + const { app, storage } = buildApp({ + getUserTenants: vi.fn().mockResolvedValue([ + { tenant: { id: 'tenant-2' }, role: 'accountant' }, + ]), + }); + + const res = await app.request('/api/tenants/tenant-1', {}, env); + + expect(res.status).toBe(404); + await expect(res.json()).resolves.toEqual({ error: 'Tenant not found' }); + expect(storage.getTenant).not.toHaveBeenCalled(); + }); +}); diff --git a/server/app.ts b/server/app.ts index 2d641ba..cf064d2 100644 --- a/server/app.ts +++ b/server/app.ts @@ -5,6 +5,7 @@ import type { HonoEnv } from './env'; import type { MiddlewareHandler } from 'hono'; import { errorHandler } from './middleware/error'; import { serviceAuth } from './middleware/auth'; +import { callerContext } from './middleware/caller'; import { tenantMiddleware } from './middleware/tenant'; import { healthRoutes } from './routes/health'; import { docRoutes } from './routes/docs'; @@ -41,8 +42,8 @@ const storageMiddleware: MiddlewareHandler = async (c, next) => { await next(); }; -// Combined auth + tenant + storage middleware stack -const protectedRoute: MiddlewareHandler[] = [serviceAuth, tenantMiddleware, storageMiddleware]; +const authAndContext: MiddlewareHandler[] = [serviceAuth, storageMiddleware, callerContext]; +const protectedRoute: MiddlewareHandler[] = [...authAndContext, tenantMiddleware]; export function createApp() { const app = new Hono(); @@ -86,11 +87,13 @@ export function createApp() { // ── Protected API routes (auth + tenant + storage) ── // Register middleware for each protected path prefix const protectedPrefixes = [ - '/api/accounts', '/api/transactions', '/api/tenants', '/api/properties', + '/api/accounts', '/api/transactions', '/api/properties', '/api/integrations', '/api/tasks', '/api/ai-messages', '/api/ai', '/api/summary', '/api/mercury', '/api/github', '/api/charges', '/api/forensics', '/api/portfolio', '/api/import', '/api/reports', '/api/google', '/api/comms', '/api/workflows', '/mcp', ]; + app.use('/api/tenants', ...authAndContext); + app.use('/api/tenants/*', ...authAndContext); for (const prefix of protectedPrefixes) { app.use(prefix, ...protectedRoute); app.use(`${prefix}/*`, ...protectedRoute); diff --git a/server/lib/chargeAutomation.ts b/server/lib/chargeAutomation.ts index 86529e7..b5fc6f9 100755 --- a/server/lib/chargeAutomation.ts +++ b/server/lib/chargeAutomation.ts @@ -8,6 +8,7 @@ export interface ChargeDetails { category: string; recurring: boolean; nextChargeDate?: Date; + subscriptionId?: string; frequency: 'monthly' | 'quarterly' | 'annual' | 'irregular'; occurrences: number; } diff --git a/server/middleware/caller.ts b/server/middleware/caller.ts new file mode 100644 index 0000000..3233ccf --- /dev/null +++ b/server/middleware/caller.ts @@ -0,0 +1,27 @@ +import type { MiddlewareHandler } from 'hono'; +import type { HonoEnv } from '../env'; + +export const callerContext: MiddlewareHandler = async (c, next) => { + const userId = + c.req.header('x-chitty-user-id') ?? + c.req.header('x-user-id') ?? + c.req.query('userId') ?? + ''; + + if (!userId) { + return c.json({ + error: 'missing_user_id', + message: 'X-Chitty-User-Id header or userId query param required', + }, 400); + } + + const storage = c.get('storage'); + const user = await storage.getUser(userId); + + if (!user) { + return c.json({ error: 'user_not_found' }, 404); + } + + c.set('userId', user.id); + await next(); +}; diff --git a/server/middleware/express-context.ts b/server/middleware/express-context.ts new file mode 100644 index 0000000..1a79f2b --- /dev/null +++ b/server/middleware/express-context.ts @@ -0,0 +1,82 @@ +import type { NextFunction, Request, Response } from 'express'; + +type UserRecord = { id: string | number }; +type MembershipRecord = { tenant: { id: string } }; +type ContextRequest = Request & { userId?: string; tenantId?: string }; + +interface CallerStorage { + getSessionUser?: () => Promise; + getUser?: (id: string) => Promise; +} + +interface TenantStorage { + getUserTenants?: (userId: string) => Promise; +} + +export function getCallerId(req: Request): string { + const headerValue = req.header('x-chitty-user-id') ?? req.header('x-user-id'); + const queryValue = typeof req.query.userId === 'string' ? req.query.userId : ''; + return headerValue ?? queryValue ?? ''; +} + +export function createCallerContext(storage: CallerStorage) { + return async (req: Request, res: Response, next: NextFunction) => { + const contextReq = req as ContextRequest; + const callerId = getCallerId(contextReq); + + if (callerId) { + const user = await storage.getUser?.(callerId); + if (!user) { + return res.status(404).json({ error: 'user_not_found' }); + } + + contextReq.userId = String(user.id); + return next(); + } + + const sessionUser = await storage.getSessionUser?.(); + if (!sessionUser) { + return res.status(400).json({ + error: 'missing_user_id', + message: 'X-Chitty-User-Id header or userId query param required', + }); + } + + contextReq.userId = String(sessionUser.id); + return next(); + }; +} + +export function getTenantId(req: Request): string { + const headerValue = req.header('x-tenant-id'); + const queryValue = typeof req.query.tenantId === 'string' ? req.query.tenantId : ''; + return headerValue ?? queryValue ?? ''; +} + +export function createTenantAccessResolver(storage: TenantStorage) { + return async (req: Request, res: Response, next: NextFunction) => { + const contextReq = req as ContextRequest; + const tenantId = getTenantId(contextReq); + if (!tenantId) { + return res.status(400).json({ + error: 'missing_tenant_id', + message: 'X-Tenant-ID header or tenantId query param required', + }); + } + + if (contextReq.userId && storage.getUserTenants) { + const memberships = await storage.getUserTenants(String(contextReq.userId)); + const hasAccess = memberships.some((membership) => membership.tenant.id === tenantId); + + if (!hasAccess) { + return res.status(403).json({ + error: 'forbidden', + message: 'Caller does not have access to tenant', + }); + } + } + + contextReq.tenantId = tenantId; + return next(); + }; +} diff --git a/server/middleware/tenant.ts b/server/middleware/tenant.ts index cb8a44f..8484f31 100644 --- a/server/middleware/tenant.ts +++ b/server/middleware/tenant.ts @@ -11,6 +11,22 @@ export const tenantMiddleware: MiddlewareHandler = async (c, next) => { return c.json({ error: 'missing_tenant_id', message: 'X-Tenant-ID header or tenantId query param required' }, 400); } + const storage = c.get('storage'); + const userId = c.get('userId'); + + if (!storage || !userId || typeof storage.getUserTenants !== 'function') { + c.set('tenantId', tenantId); + await next(); + return; + } + + const memberships = await storage.getUserTenants(userId); + const hasAccess = memberships.some((membership) => membership.tenant.id === tenantId); + + if (!hasAccess) { + return c.json({ error: 'forbidden', message: 'Caller does not have access to tenant' }, 403); + } + c.set('tenantId', tenantId); await next(); }; diff --git a/server/routes.ts b/server/routes.ts index 8a362e8..94f8130 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -2,9 +2,7 @@ import express, { Express, Request, Response } from "express"; import { createServer, type Server } from "http"; import { storage } from "./storage"; -// Legacy Express shims — Hono middleware can't be used as Express middleware -const chittyConnectAuth = (_req: any, _res: any, next: any) => next(); -const resolveTenant = (_req: any, _res: any, next: any) => next(); +import { createCallerContext, createTenantAccessResolver } from "./middleware/express-context"; const serviceAuth = (_req: any, _res: any, next: any) => next(); import { getServiceBase } from "./lib/registry"; import { getServiceAuthHeader } from "./lib/chitty-connect"; @@ -57,6 +55,8 @@ import { toStringId } from "./lib/id-compat"; import { transformToUniversalFormat } from "./lib/universal"; // Legacy shims — original Hono middleware can't work as Express middleware const isAuthenticated = (_req: any, _res: any, next: any) => next(); +const chittyConnectAuth = createCallerContext(storage); +const resolveTenant = createTenantAccessResolver(storage); const MODE = process.env.MODE || 'standalone'; @@ -149,16 +149,26 @@ export async function registerRoutes(app: Express): Promise { // Tenant endpoints (system mode only) if (MODE === 'system') { api.get("/tenants", chittyConnectAuth, async (req: Request, res: Response) => { - const userId = req.userId || (await storage.getSessionUser())?.id; - if (!userId) return res.status(401).json({ error: 'Authentication required' }); - const tenants = await storage.getUserTenants(String(userId)); - res.json(tenants); + const memberships = await storage.getUserTenants(String(req.userId)); + res.json( + memberships.map((membership: any) => ({ + ...membership.tenant, + role: membership.role, + })), + ); }); api.get("/tenants/:id", chittyConnectAuth, async (req: Request, res: Response) => { + const memberships = await storage.getUserTenants(String(req.userId)); + const membership = memberships.find((item: any) => item.tenant.id === req.params.id); + if (!membership) return res.status(404).json({ error: 'Tenant not found' }); + const tenant = await storage.getTenant(req.params.id); if (!tenant) return res.status(404).json({ error: 'Tenant not found' }); - res.json(tenant); + res.json({ + ...tenant, + role: membership.role, + }); }); api.get("/accounts", chittyConnectAuth, resolveTenant, async (req: Request, res: Response) => { @@ -2043,4 +2053,4 @@ export async function registerRoutes(app: Express): Promise { const httpServer = createServer(app); return httpServer; -} \ No newline at end of file +} diff --git a/server/routes/tenants.ts b/server/routes/tenants.ts index 42d74e0..fc986ea 100644 --- a/server/routes/tenants.ts +++ b/server/routes/tenants.ts @@ -7,24 +7,35 @@ export const tenantRoutes = new Hono(); tenantRoutes.get('/api/tenants', async (c) => { const storage = c.get('storage'); const userId = c.get('userId'); + const memberships = await storage.getUserTenants(userId); - if (!userId) { - return c.json({ error: 'Authentication required' }, 401); - } - - const tenants = await storage.getUserTenants(userId); - return c.json(tenants); + return c.json( + memberships.map((membership) => ({ + ...membership.tenant, + role: membership.role, + })), + ); }); // GET /api/tenants/:id — get a single tenant by ID tenantRoutes.get('/api/tenants/:id', async (c) => { const storage = c.get('storage'); + const userId = c.get('userId'); const tenantId = c.req.param('id'); + const memberships = await storage.getUserTenants(userId); + const membership = memberships.find((item) => item.tenant.id === tenantId); + + if (!membership) { + return c.json({ error: 'Tenant not found' }, 404); + } const tenant = await storage.getTenant(tenantId); if (!tenant) { return c.json({ error: 'Tenant not found' }, 404); } - return c.json(tenant); + return c.json({ + ...tenant, + role: membership.role, + }); });