diff --git a/client/src/components/property/ExpiringLeases.tsx b/client/src/components/property/ExpiringLeases.tsx new file mode 100644 index 0000000..9635c24 --- /dev/null +++ b/client/src/components/property/ExpiringLeases.tsx @@ -0,0 +1,83 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Clock } from 'lucide-react'; +import { useExpiringLeases } from '@/hooks/use-property'; +import { formatCurrency } from '@/lib/utils'; + +function urgencyVariant(days: number): 'destructive' | 'default' | 'secondary' { + if (days <= 30) return 'destructive'; + if (days <= 60) return 'default'; + return 'secondary'; +} + +function Header({ count }: { count?: number }) { + return ( + + + Expiring Leases{count != null ? ` (${count})` : ''} + + + ); +} + +export default function ExpiringLeases() { + const { data: leases, isLoading } = useExpiringLeases(90); + + if (isLoading) { + return ( + + + + + + + ); + } + + if (!leases || leases.length === 0) { + return ( + + + + No leases expiring in the next 90 days. + + + ); + } + + return ( + + + + + + + Tenant + Property + Unit + Rent + End Date + Remaining + + + + {leases.map((l) => ( + + {l.tenantName} + {l.propertyName} + {l.unitNumber || '\u2014'} + {formatCurrency(parseFloat(l.monthlyRent))} + {new Date(l.endDate).toLocaleDateString()} + + {l.daysRemaining}d + + + ))} + + + + + ); +} diff --git a/client/src/components/property/PropertyDetailPanel.tsx b/client/src/components/property/PropertyDetailPanel.tsx index a7f033c..3c72ad8 100644 --- a/client/src/components/property/PropertyDetailPanel.tsx +++ b/client/src/components/property/PropertyDetailPanel.tsx @@ -26,6 +26,21 @@ import AddUnitDialog from '@/components/property/AddUnitDialog'; import AddLeaseDialog from '@/components/property/AddLeaseDialog'; import EditPropertyDialog from '@/components/property/EditPropertyDialog'; +const MS_PER_DAY = 86_400_000; +const LEASE_DATE_FORMAT: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: '2-digit' }; + +function LeaseEndBadge({ endDate }: { endDate: string }) { + const days = Math.ceil((new Date(endDate).getTime() - Date.now()) / MS_PER_DAY); + let variant: 'destructive' | 'default' | 'secondary' = 'secondary'; + if (days <= 30) variant = 'destructive'; + else if (days <= 60) variant = 'default'; + return ( + + {new Date(endDate).toLocaleDateString('en-US', LEASE_DATE_FORMAT)} + + ); +} + interface PropertyDetailPanelProps { propertyId: string | null; onClose: () => void; @@ -260,6 +275,7 @@ export default function PropertyDetailPanel({ propertyId, onClose }: PropertyDet Sq Ft Rent Tenant + Lease End Status @@ -277,6 +293,9 @@ export default function PropertyDetailPanel({ propertyId, onClose }: PropertyDet {formatCurrency(parseFloat(u.monthlyRent || '0'))} {lease?.tenantName || '\u2014'} + + {lease ? : '\u2014'} + {lease ? 'Leased' : 'Vacant'} diff --git a/client/src/hooks/use-action-queue.ts b/client/src/hooks/use-action-queue.ts index 2fc1108..c358f47 100644 --- a/client/src/hooks/use-action-queue.ts +++ b/client/src/hooks/use-action-queue.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTenantId } from '@/contexts/TenantContext'; -import { usePortfolioSummary } from './use-property'; +import { usePortfolioSummary, useExpiringLeases } from './use-property'; import { useConsolidatedReport } from './use-reports'; export type ActionSeverity = 'critical' | 'warning' | 'info'; @@ -38,6 +38,7 @@ export function useActionQueue() { const tenantId = useTenantId(); const { data: portfolio, isError: portfolioError } = usePortfolioSummary(); + const { data: expiringLeases } = useExpiringLeases(90); const { data: integrationStatus, isError: integrationError } = useQuery>({ queryKey: ['/api/integrations/status'], @@ -177,6 +178,29 @@ export function useActionQueue() { } } + // Expiring leases — grouped by urgency tier + if (tenantId && expiringLeases && expiringLeases.length > 0) { + const tiers: Array<{ id: string; severity: ActionSeverity; filter: (days: number) => boolean; title: string }> = [ + { id: 'expiring-leases-critical', severity: 'critical', filter: (d) => d <= 30, title: 'expiring within 30 days' }, + { id: 'expiring-leases-warning', severity: 'warning', filter: (d) => d > 30 && d <= 60, title: 'expiring in 31\u201360 days' }, + ]; + for (const tier of tiers) { + const matches = expiringLeases.filter((l) => tier.filter(l.daysRemaining)); + if (matches.length === 0) continue; + const names = matches.slice(0, 2).map((l) => l.tenantName).join(', '); + queue.push({ + id: tier.id, + type: 'expiring_lease', + severity: tier.severity, + title: `${matches.length} lease${matches.length !== 1 ? 's' : ''} ${tier.title}`, + detail: matches.length > 2 ? `${names} +${matches.length - 2} more` : names, + count: matches.length, + actionLabel: 'View', + actionHref: '/properties', + }); + } + } + // Close readiness blockers if (reportData?.preflight && !reportData.preflight.readyToFileTaxes) { const failCount = reportData.preflight.checks.filter((c) => c.status === 'fail').length; @@ -196,7 +220,7 @@ export function useActionQueue() { queue.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); return queue; - }, [portfolio, integrationStatus, reportData, hasDataError]); + }, [portfolio, integrationStatus, reportData, expiringLeases, hasDataError, tenantId]); return { items, diff --git a/client/src/hooks/use-property.ts b/client/src/hooks/use-property.ts index f909f73..08ff525 100644 --- a/client/src/hooks/use-property.ts +++ b/client/src/hooks/use-property.ts @@ -269,6 +269,27 @@ export function useUpdateLease(propertyId: string, leaseId: string) { }); } +export interface ExpiringLease { + leaseId: string; + tenantName: string; + endDate: string; + monthlyRent: string; + unitNumber: string | null; + propertyId: string; + propertyName: string; + address: string; + daysRemaining: number; +} + +export function useExpiringLeases(days = 90) { + const tenantId = useTenantId(); + return useQuery({ + queryKey: [`/api/leases/expiring?days=${days}`, tenantId], + enabled: !!tenantId, + staleTime: 10 * 60 * 1000, + }); +} + export function useSendPropertyAdvice(propertyId: string) { return useMutation({ mutationFn: (message: string) => diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 74c42d2..ab88101 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -8,7 +8,8 @@ import { } from 'lucide-react'; import { useTenant, useTenantId } from '@/contexts/TenantContext'; import { formatCurrency } from '@/lib/utils'; -import { usePortfolioSummary } from '@/hooks/use-property'; +import { usePortfolioSummary, useExpiringLeases } from '@/hooks/use-property'; +import ExpiringLeases from '@/components/property/ExpiringLeases'; import { useActionQueue, type ActionItem, type ActionSeverity } from '@/hooks/use-action-queue'; import { useConnectionHealth } from '@/hooks/use-connection-health'; import { useConsolidatedReport } from '@/hooks/use-reports'; @@ -593,9 +594,10 @@ export default function Dashboard() { )} - {/* Right column: health + connections + orbital */} + {/* Right column: health + leases + connections + orbital */} + {tenantId && } diff --git a/deploy/system-wrangler.toml b/deploy/system-wrangler.toml index 87baa42..0fd06a8 100755 --- a/deploy/system-wrangler.toml +++ b/deploy/system-wrangler.toml @@ -47,6 +47,9 @@ command = "npx vite build --outDir dist/public" directory = "../dist/public" binding = "ASSETS" +[triggers] +crons = ["0 9 * * *"] # Daily at 9:00 AM UTC — lease expiration check + [limits] cpu_ms = 50 @@ -81,6 +84,9 @@ NODE_ENV = "development" APP_VERSION = "2.0.0" CHITTYCONNECT_API_BASE = "https://connect.chitty.cc" +[env.dev.triggers] +crons = ["0 9 * * *"] + [[env.dev.tail_consumers]] service = "chittytrack" @@ -109,6 +115,9 @@ NODE_ENV = "staging" APP_VERSION = "2.0.0" CHITTYCONNECT_API_BASE = "https://connect.chitty.cc" +[env.staging.triggers] +crons = ["0 9 * * *"] + [[env.staging.tail_consumers]] service = "chittytrack" @@ -141,6 +150,9 @@ NODE_ENV = "production" APP_VERSION = "2.0.0" CHITTYCONNECT_API_BASE = "https://connect.chitty.cc" +[env.production.triggers] +crons = ["0 9 * * *"] + [[env.production.tail_consumers]] service = "chittytrack" diff --git a/server/app.ts b/server/app.ts index 9c0bd63..e71c7ae 100644 --- a/server/app.ts +++ b/server/app.ts @@ -33,6 +33,7 @@ import { reportRoutes } from './routes/reports'; import { googleRoutes, googleCallbackRoute } from './routes/google'; import { commsRoutes } from './routes/comms'; import { workflowRoutes } from './routes/workflows'; +import { leaseRoutes } from './routes/leases'; import { createDb } from './db/connection'; import { SystemStorage } from './storage/system'; @@ -93,7 +94,7 @@ export function createApp() { '/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', + '/api/google', '/api/comms', '/api/workflows', '/api/leases', '/mcp', ]; app.use('/api/tenants', ...authAndContext); app.use('/api/tenants/*', ...authAndContext); @@ -124,6 +125,7 @@ export function createApp() { app.route('/', googleRoutes); app.route('/', commsRoutes); app.route('/', workflowRoutes); + app.route('/', leaseRoutes); app.route('/', mcpRoutes); // ── Fallback: try static assets, then 404 ── diff --git a/server/lib/lease-expiration.ts b/server/lib/lease-expiration.ts new file mode 100644 index 0000000..b78d1df --- /dev/null +++ b/server/lib/lease-expiration.ts @@ -0,0 +1,124 @@ +/** + * Lease expiration notification service. + * + * Runs via Cloudflare Cron Triggers (scheduled handler in worker.ts). + * Checks for leases expiring in non-overlapping windows (0-30, 31-60, 61-90 days) and: + * 1. Creates a task linked to the lease (deduped by relatedTo/relatedId + title) + * 2. Optionally sends email (SendGrid) and SMS (Twilio) to the tenant + */ + +import type { Env } from '../env'; +import { createDb } from '../db/connection'; +import { SystemStorage } from '../storage/system'; +import { SendGridClient, EMAIL_TEMPLATES } from './sendgrid'; +import { TwilioClient, TEMPLATES as SMS_TEMPLATES } from './twilio'; + +// Non-overlapping windows: each lease matches exactly one window +const NOTIFICATION_WINDOWS = [ + { minDays: 0, maxDays: 30, taskTitle: 'Lease expiring in 30 days', smsTemplate: 'lease_reminder_30' as const, priority: 'urgent' as const }, + { minDays: 31, maxDays: 60, taskTitle: 'Lease expiring in 60 days', smsTemplate: 'lease_reminder_60' as const, priority: 'high' as const }, + { minDays: 61, maxDays: 90, taskTitle: 'Lease expiring in 90 days', smsTemplate: 'lease_reminder_90' as const, priority: 'medium' as const }, +]; + +export interface LeaseExpirationStats { + checked: number; + tasksCreated: number; + emailsSent: number; + smsSent: number; + emailsFailed: number; + smsFailed: number; + errors: string[]; +} + +function formatDate(d: Date | string): string { + const date = typeof d === 'string' ? new Date(d) : d; + return date.toISOString().split('T')[0]; +} + +export async function processLeaseExpirations(env: Env): Promise { + if (!env.DATABASE_URL) { + throw new Error('[lease-expiration] DATABASE_URL binding is not configured'); + } + + const db = createDb(env.DATABASE_URL); + const storage = new SystemStorage(db); + + const stats: LeaseExpirationStats = { + checked: 0, tasksCreated: 0, emailsSent: 0, smsSent: 0, + emailsFailed: 0, smsFailed: 0, errors: [], + }; + + const sendgrid = env.SENDGRID_API_KEY && env.SENDGRID_FROM_EMAIL + ? new SendGridClient({ apiKey: env.SENDGRID_API_KEY, fromEmail: env.SENDGRID_FROM_EMAIL }) + : null; + const twilio = env.TWILIO_ACCOUNT_SID && env.TWILIO_AUTH_TOKEN && env.TWILIO_PHONE_NUMBER + ? new TwilioClient({ accountSid: env.TWILIO_ACCOUNT_SID, authToken: env.TWILIO_AUTH_TOKEN, fromNumber: env.TWILIO_PHONE_NUMBER }) + : null; + + if (!sendgrid) console.warn('[lease-expiration] SendGrid not configured — email notifications disabled'); + if (!twilio) console.warn('[lease-expiration] Twilio not configured — SMS notifications disabled'); + + for (const window of NOTIFICATION_WINDOWS) { + const expiring = await storage.getExpiringLeases(window.maxDays, undefined, window.minDays); + stats.checked += expiring.length; + + for (const { lease, unit, property } of expiring) { + try { + const existingTasks = await storage.getTasksByRelation('lease', lease.id); + if (existingTasks.some((t) => t.title === window.taskTitle)) continue; + + const endDateStr = formatDate(lease.endDate); + + await storage.createTask({ + tenantId: property.tenantId, + title: window.taskTitle, + description: `${lease.tenantName}'s lease at ${property.name} (Unit ${unit.unitNumber || 'N/A'}) expires ${endDateStr}.`, + dueDate: lease.endDate, + priority: window.priority, + status: 'pending', + relatedTo: 'lease', + relatedId: lease.id, + metadata: { + notificationWindow: window.maxDays, + leaseEndDate: endDateStr, + propertyId: property.id, + unitId: unit.id, + }, + }); + stats.tasksCreated++; + + if (sendgrid && lease.tenantEmail) { + try { + const email = EMAIL_TEMPLATES.lease_reminder(lease.tenantName, property.name, endDateStr); + await sendgrid.sendEmail({ to: lease.tenantEmail, ...email }); + stats.emailsSent++; + } catch (err) { + stats.emailsFailed++; + const msg = `Email to ${lease.tenantEmail} for lease ${lease.id}: ${err instanceof Error ? err.message : String(err)}`; + stats.errors.push(msg); + console.error(`[lease-expiration] ${msg}`); + } + } + + if (twilio && lease.tenantPhone) { + try { + const smsBody = SMS_TEMPLATES[window.smsTemplate](lease.tenantName, endDateStr); + await twilio.sendSms(lease.tenantPhone, smsBody); + stats.smsSent++; + } catch (err) { + stats.smsFailed++; + const msg = `SMS to ${lease.tenantPhone} for lease ${lease.id}: ${err instanceof Error ? err.message : String(err)}`; + stats.errors.push(msg); + console.error(`[lease-expiration] ${msg}`); + } + } + } catch (err) { + const msg = `Lease ${lease.id} (tenant ${property.tenantId}): ${err instanceof Error ? err.message : String(err)}`; + stats.errors.push(msg); + console.error(`[lease-expiration] ${msg}`); + } + } + } + + return stats; +} diff --git a/server/routes/leases.ts b/server/routes/leases.ts new file mode 100644 index 0000000..f983c91 --- /dev/null +++ b/server/routes/leases.ts @@ -0,0 +1,39 @@ +import { Hono } from 'hono'; +import type { HonoEnv } from '../env'; + +const MS_PER_DAY = 86_400_000; + +export const leaseRoutes = new Hono(); + +leaseRoutes.get('/api/leases/expiring', async (c) => { + const storage = c.get('storage'); + const tenantId = c.get('tenantId'); + const days = parseInt(c.req.query('days') || '90', 10); + + if (isNaN(days) || days < 1 || days > 365) { + return c.json({ error: 'days must be between 1 and 365' }, 400); + } + + try { + const expiring = await storage.getExpiringLeases(days, tenantId); + + return c.json( + expiring.map(({ lease, unit, property }) => ({ + leaseId: lease.id, + tenantName: lease.tenantName, + endDate: lease.endDate, + monthlyRent: lease.monthlyRent, + unitNumber: unit.unitNumber, + propertyId: property.id, + propertyName: property.name, + address: property.address, + daysRemaining: Math.ceil( + (new Date(lease.endDate).getTime() - Date.now()) / MS_PER_DAY, + ), + })), + ); + } catch (err) { + console.error('[leases:expiring] Failed:', err); + return c.json({ error: 'Failed to retrieve expiring leases' }, 500); + } +}); diff --git a/server/storage/system.ts b/server/storage/system.ts index adf51ce..80bde14 100755 --- a/server/storage/system.ts +++ b/server/storage/system.ts @@ -2,6 +2,8 @@ import { eq, and, desc, sql, inArray } from 'drizzle-orm'; import type { Database } from '../db/connection'; import * as schema from '../db/schema'; +const MS_PER_DAY = 86_400_000; + export class SystemStorage { constructor(private db: Database) {} @@ -351,6 +353,43 @@ export class SystemStorage { .where(and(inArray(schema.leases.unitId, unitIds), eq(schema.leases.status, 'active'))); } + async getExpiringLeases(withinDays: number, tenantId?: string, minDays?: number) { + const now = new Date(); + const cutoff = new Date(now.getTime() + withinDays * MS_PER_DAY); + const floor = minDays ? new Date(now.getTime() + minDays * MS_PER_DAY) : now; + const conditions = [ + eq(schema.leases.status, 'active'), + sql`${schema.leases.endDate} >= ${floor}`, + sql`${schema.leases.endDate} <= ${cutoff}`, + ]; + if (tenantId) { + conditions.push(eq(schema.properties.tenantId, tenantId)); + } + return this.db + .select({ + lease: schema.leases, + unit: schema.units, + property: schema.properties, + }) + .from(schema.leases) + .innerJoin(schema.units, eq(schema.leases.unitId, schema.units.id)) + .innerJoin(schema.properties, eq(schema.units.propertyId, schema.properties.id)) + .where(and(...conditions)) + .orderBy(schema.leases.endDate); + } + + async getTasksByRelation(relatedTo: string, relatedId: string) { + return this.db + .select() + .from(schema.tasks) + .where( + and( + eq(schema.tasks.relatedTo, relatedTo), + eq(schema.tasks.relatedId, relatedId), + ), + ); + } + // ── PROPERTY FINANCIALS ── async getPropertyTransactions(propertyId: string, tenantId: string, since?: string, until?: string) { diff --git a/server/worker.ts b/server/worker.ts index 96331fe..d5fb946 100755 --- a/server/worker.ts +++ b/server/worker.ts @@ -1,10 +1,19 @@ import { createApp } from './app'; import type { Env } from './env'; +import { processLeaseExpirations } from './lib/lease-expiration'; const app = createApp(); export default { fetch: app.fetch, + + async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) { + const stats = await processLeaseExpirations(env); + console.log('[cron:lease-expiration] complete:', JSON.stringify(stats)); + if (stats.errors.length > 0) { + console.error(`[cron:lease-expiration] ${stats.errors.length} failures during processing`); + } + }, } satisfies ExportedHandler; // Re-export the Agent DO class so Wrangler can bind it
No leases expiring in the next 90 days.