From 8bfa110c9e2a7f24ca0baf10876fca12fb55d028 Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Thu, 26 Mar 2026 05:17:14 +0000 Subject: [PATCH 1/3] feat: lease expiration notifications via cron trigger + API endpoint Add automated lease expiration monitoring: - Daily cron trigger (9 AM UTC) checks for leases expiring within 30/60/90 days - Creates deduped tasks linked to each lease with urgency-based priority - Sends email (SendGrid) and SMS (Twilio) reminders when configured - New GET /api/leases/expiring?days=N endpoint for frontend consumption - ExpiringLeases dashboard widget with urgency badges - Wrangler cron triggers added to all 3 env blocks Co-Authored-By: Claude Opus 4.6 --- .../components/property/ExpiringLeases.tsx | 83 ++++++++++++++ client/src/hooks/use-property.ts | 23 ++++ client/src/pages/Dashboard.tsx | 6 +- deploy/system-wrangler.toml | 12 ++ server/app.ts | 4 +- server/lib/lease-expiration.ts | 103 ++++++++++++++++++ server/routes/leases.ts | 34 ++++++ server/storage/system.ts | 34 ++++++ server/worker.ts | 12 ++ 9 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 client/src/components/property/ExpiringLeases.tsx create mode 100644 server/lib/lease-expiration.ts create mode 100644 server/routes/leases.ts diff --git a/client/src/components/property/ExpiringLeases.tsx b/client/src/components/property/ExpiringLeases.tsx new file mode 100644 index 0000000..15905f9 --- /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 urgencyBadge(days: number) { + if (days <= 30) return {days}d; + if (days <= 60) return {days}d; + return {days}d; +} + +export default function ExpiringLeases() { + const { data: leases, isLoading } = useExpiringLeases(90); + + if (isLoading) { + return ( + + + + Expiring Leases + + + + + + + ); + } + + if (!leases || leases.length === 0) { + return ( + + + + Expiring Leases + + + +

No leases expiring in the next 90 days.

+
+
+ ); + } + + return ( + + + + Expiring Leases ({leases.length}) + + + + + + + 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()} + {urgencyBadge(l.daysRemaining)} + + ))} + +
+
+
+ ); +} diff --git a/client/src/hooks/use-property.ts b/client/src/hooks/use-property.ts index f909f73..753216e 100644 --- a/client/src/hooks/use-property.ts +++ b/client/src/hooks/use-property.ts @@ -269,6 +269,29 @@ export function useUpdateLease(propertyId: string, leaseId: string) { }); } +export interface ExpiringLease { + leaseId: string; + tenantName: string; + tenantEmail: string | null; + tenantPhone: string | null; + 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..5211043 --- /dev/null +++ b/server/lib/lease-expiration.ts @@ -0,0 +1,103 @@ +/** + * Lease expiration notification service. + * + * Runs via Cloudflare Cron Triggers (scheduled handler in worker.ts). + * Checks for leases expiring within 30/60/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'; + +const NOTIFICATION_WINDOWS = [ + { days: 90, taskTitle: 'Lease expiring in 90 days', smsTemplate: 'lease_reminder_90' as const }, + { days: 60, taskTitle: 'Lease expiring in 60 days', smsTemplate: 'lease_reminder_60' as const }, + { days: 30, taskTitle: 'Lease expiring in 30 days', smsTemplate: 'lease_reminder_30' as const }, +]; + +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<{ + checked: number; + tasksCreated: number; + emailsSent: number; + smsSent: number; +}> { + const db = createDb(env.DATABASE_URL); + const storage = new SystemStorage(db); + + const stats = { checked: 0, tasksCreated: 0, emailsSent: 0, smsSent: 0 }; + + // Set up optional notification clients + 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; + + // Process each notification window (90, 60, 30 days) + for (const window of NOTIFICATION_WINDOWS) { + const expiring = await storage.getExpiringLeases(window.days); + stats.checked += expiring.length; + + for (const { lease, unit, property } of expiring) { + // Dedup: check if a task already exists for this lease + window + const existingTasks = await storage.getTasksByRelation('lease', lease.id); + const alreadyNotified = existingTasks.some((t) => t.title === window.taskTitle); + if (alreadyNotified) continue; + + // Create task for property manager + await storage.createTask({ + tenantId: property.tenantId, + title: window.taskTitle, + description: `${lease.tenantName}'s lease at ${property.name} (Unit ${unit.unitNumber || 'N/A'}) expires ${formatDate(lease.endDate)}.`, + dueDate: lease.endDate, + priority: window.days <= 30 ? 'urgent' : window.days <= 60 ? 'high' : 'medium', + status: 'pending', + relatedTo: 'lease', + relatedId: lease.id, + metadata: { + notificationWindow: window.days, + leaseEndDate: formatDate(lease.endDate), + propertyId: property.id, + unitId: unit.id, + }, + }); + stats.tasksCreated++; + + const endDateStr = formatDate(lease.endDate); + + // Send email notification if tenant has email and SendGrid is configured + 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) { + console.error(`Failed to send lease reminder email to ${lease.tenantEmail}:`, err); + } + } + + // Send SMS notification if tenant has phone and Twilio is configured + if (twilio && lease.tenantPhone) { + try { + const smsBody = SMS_TEMPLATES[window.smsTemplate](lease.tenantName, endDateStr); + await twilio.sendSms(lease.tenantPhone, smsBody); + stats.smsSent++; + } catch (err) { + console.error(`Failed to send lease reminder SMS to ${lease.tenantPhone}:`, err); + } + } + } + } + + return stats; +} diff --git a/server/routes/leases.ts b/server/routes/leases.ts new file mode 100644 index 0000000..55d0840 --- /dev/null +++ b/server/routes/leases.ts @@ -0,0 +1,34 @@ +import { Hono } from 'hono'; +import type { HonoEnv } from '../env'; + +export const leaseRoutes = new Hono(); + +// GET /api/leases/expiring — list leases expiring within N days (default 90) +leaseRoutes.get('/api/leases/expiring', async (c) => { + const storage = c.get('storage'); + 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); + } + + const expiring = await storage.getExpiringLeases(days); + + return c.json( + expiring.map(({ lease, unit, property }) => ({ + leaseId: lease.id, + tenantName: lease.tenantName, + tenantEmail: lease.tenantEmail, + tenantPhone: lease.tenantPhone, + 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()) / (24 * 60 * 60 * 1000), + ), + })), + ); +}); diff --git a/server/storage/system.ts b/server/storage/system.ts index adf51ce..d4b6217 100755 --- a/server/storage/system.ts +++ b/server/storage/system.ts @@ -351,6 +351,40 @@ export class SystemStorage { .where(and(inArray(schema.leases.unitId, unitIds), eq(schema.leases.status, 'active'))); } + async getExpiringLeases(withinDays: number) { + const now = new Date(); + const cutoff = new Date(now.getTime() + withinDays * 24 * 60 * 60 * 1000); + 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( + eq(schema.leases.status, 'active'), + sql`${schema.leases.endDate} >= ${now}`, + sql`${schema.leases.endDate} <= ${cutoff}`, + ), + ) + .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..fe2c1f7 100755 --- a/server/worker.ts +++ b/server/worker.ts @@ -1,10 +1,22 @@ 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) { + // Daily cron: check lease expirations and send notifications + ctx.waitUntil( + processLeaseExpirations(env).then((stats) => { + console.log('Lease expiration check complete:', JSON.stringify(stats)); + }).catch((err) => { + console.error('Lease expiration check failed:', err); + }), + ); + }, } satisfies ExportedHandler; // Re-export the Agent DO class so Wrangler can bind it From bbf400ac7051446c7beba21438d4858168cf1960 Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Thu, 26 Mar 2026 21:58:38 +0000 Subject: [PATCH 2/3] fix: tenant-scope expiring leases + action queue integration - getExpiringLeases() now accepts optional tenantId for proper isolation - Lease route passes tenantId from middleware context - Action queue surfaces expiring leases as critical (<=30d) and warning (31-60d) items - PropertyDetailPanel shows lease end date column with urgency badge Co-Authored-By: Claude Opus 4.6 --- .../property/PropertyDetailPanel.tsx | 11 ++++++ client/src/hooks/use-action-queue.ts | 36 +++++++++++++++++-- server/routes/leases.ts | 3 +- server/storage/system.ts | 18 +++++----- 4 files changed, 57 insertions(+), 11 deletions(-) diff --git a/client/src/components/property/PropertyDetailPanel.tsx b/client/src/components/property/PropertyDetailPanel.tsx index a7f033c..e02f9ce 100644 --- a/client/src/components/property/PropertyDetailPanel.tsx +++ b/client/src/components/property/PropertyDetailPanel.tsx @@ -260,6 +260,7 @@ export default function PropertyDetailPanel({ propertyId, onClose }: PropertyDet Sq Ft Rent Tenant + Lease End Status @@ -277,6 +278,16 @@ export default function PropertyDetailPanel({ propertyId, onClose }: PropertyDet {formatCurrency(parseFloat(u.monthlyRent || '0'))} {lease?.tenantName || '\u2014'} + + {lease ? (() => { + const days = Math.ceil((new Date(lease.endDate).getTime() - Date.now()) / 86_400_000); + return ( + + {new Date(lease.endDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' })} + + ); + })() : '\u2014'} + {lease ? 'Leased' : 'Vacant'} diff --git a/client/src/hooks/use-action-queue.ts b/client/src/hooks/use-action-queue.ts index 2fc1108..fb29eab 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,37 @@ export function useActionQueue() { } } + // Expiring leases — grouped by urgency tier + if (tenantId && expiringLeases && expiringLeases.length > 0) { + const critical = expiringLeases.filter((l) => l.daysRemaining <= 30); + const warning = expiringLeases.filter((l) => l.daysRemaining > 30 && l.daysRemaining <= 60); + + if (critical.length > 0) { + queue.push({ + id: 'expiring-leases-critical', + type: 'expiring_lease', + severity: 'critical', + title: `${critical.length} lease${critical.length !== 1 ? 's' : ''} expiring within 30 days`, + detail: critical.map((l) => l.tenantName).slice(0, 2).join(', ') + (critical.length > 2 ? ` +${critical.length - 2} more` : ''), + count: critical.length, + actionLabel: 'View', + actionHref: '/properties', + }); + } + if (warning.length > 0) { + queue.push({ + id: 'expiring-leases-warning', + type: 'expiring_lease', + severity: 'warning', + title: `${warning.length} lease${warning.length !== 1 ? 's' : ''} expiring in 31\u201360 days`, + detail: warning.map((l) => l.tenantName).slice(0, 2).join(', ') + (warning.length > 2 ? ` +${warning.length - 2} more` : ''), + count: warning.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 +228,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/server/routes/leases.ts b/server/routes/leases.ts index 55d0840..7509c64 100644 --- a/server/routes/leases.ts +++ b/server/routes/leases.ts @@ -6,13 +6,14 @@ export const leaseRoutes = new Hono(); // GET /api/leases/expiring — list leases expiring within N days (default 90) 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); } - const expiring = await storage.getExpiringLeases(days); + const expiring = await storage.getExpiringLeases(days, tenantId); return c.json( expiring.map(({ lease, unit, property }) => ({ diff --git a/server/storage/system.ts b/server/storage/system.ts index d4b6217..08e4e68 100755 --- a/server/storage/system.ts +++ b/server/storage/system.ts @@ -351,9 +351,17 @@ export class SystemStorage { .where(and(inArray(schema.leases.unitId, unitIds), eq(schema.leases.status, 'active'))); } - async getExpiringLeases(withinDays: number) { + async getExpiringLeases(withinDays: number, tenantId?: string) { const now = new Date(); const cutoff = new Date(now.getTime() + withinDays * 24 * 60 * 60 * 1000); + const conditions = [ + eq(schema.leases.status, 'active'), + sql`${schema.leases.endDate} >= ${now}`, + sql`${schema.leases.endDate} <= ${cutoff}`, + ]; + if (tenantId) { + conditions.push(eq(schema.properties.tenantId, tenantId)); + } return this.db .select({ lease: schema.leases, @@ -363,13 +371,7 @@ export class SystemStorage { .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( - eq(schema.leases.status, 'active'), - sql`${schema.leases.endDate} >= ${now}`, - sql`${schema.leases.endDate} <= ${cutoff}`, - ), - ) + .where(and(...conditions)) .orderBy(schema.leases.endDate); } From b723396535fa97ea0240ef5cc536786be36768e0 Mon Sep 17 00:00:00 2001 From: chitcommit <208086304+chitcommit@users.noreply.github.com> Date: Thu, 26 Mar 2026 23:43:16 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?non-overlapping=20windows,=20error=20tracking,=20PII=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix duplicate notifications: use non-overlapping date ranges (0-30, 31-60, 61-90) so each lease matches exactly one window - Fix silent cron failures: await processLeaseExpirations directly instead of ctx.waitUntil so Cloudflare detects cron failures - Add failure counters (emailsFailed, smsFailed, errors[]) to stats so notification failures are visible, not silently swallowed - Wrap per-lease processing in try-catch so one DB error doesn't kill the entire batch - Add DATABASE_URL guard before Neon connection attempt - Log when SendGrid/Twilio are not configured (disambiguates "0 sent") - Remove tenant PII (email, phone) from /api/leases/expiring response — frontend doesn't use them - Add try-catch to API route to prevent raw DB errors leaking to clients - Simplifications: extract shared Header component, replace IIFE with named LeaseEndBadge, consolidate action queue tiers, extract MS_PER_DAY constant Co-Authored-By: Claude Opus 4.6 --- .../components/property/ExpiringLeases.tsx | 40 +++--- .../property/PropertyDetailPanel.tsx | 24 ++-- client/src/hooks/use-action-queue.ts | 34 ++--- client/src/hooks/use-property.ts | 2 - server/lib/lease-expiration.ts | 135 ++++++++++-------- server/routes/leases.ts | 42 +++--- server/storage/system.ts | 9 +- server/worker.ts | 13 +- 8 files changed, 161 insertions(+), 138 deletions(-) diff --git a/client/src/components/property/ExpiringLeases.tsx b/client/src/components/property/ExpiringLeases.tsx index 15905f9..9635c24 100644 --- a/client/src/components/property/ExpiringLeases.tsx +++ b/client/src/components/property/ExpiringLeases.tsx @@ -6,10 +6,20 @@ import { Clock } from 'lucide-react'; import { useExpiringLeases } from '@/hooks/use-property'; import { formatCurrency } from '@/lib/utils'; -function urgencyBadge(days: number) { - if (days <= 30) return {days}d; - if (days <= 60) return {days}d; - return {days}d; +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() { @@ -18,11 +28,7 @@ export default function ExpiringLeases() { if (isLoading) { return ( - - - Expiring Leases - - +
@@ -33,11 +39,7 @@ export default function ExpiringLeases() { if (!leases || leases.length === 0) { return ( - - - Expiring Leases - - +

No leases expiring in the next 90 days.

@@ -47,11 +49,7 @@ export default function ExpiringLeases() { return ( - - - Expiring Leases ({leases.length}) - - +
@@ -72,7 +70,9 @@ export default function ExpiringLeases() { {l.unitNumber || '\u2014'} {formatCurrency(parseFloat(l.monthlyRent))} {new Date(l.endDate).toLocaleDateString()} - {urgencyBadge(l.daysRemaining)} + + {l.daysRemaining}d + ))} diff --git a/client/src/components/property/PropertyDetailPanel.tsx b/client/src/components/property/PropertyDetailPanel.tsx index e02f9ce..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; @@ -279,14 +294,7 @@ export default function PropertyDetailPanel({ propertyId, onClose }: PropertyDet {lease?.tenantName || '\u2014'} - {lease ? (() => { - const days = Math.ceil((new Date(lease.endDate).getTime() - Date.now()) / 86_400_000); - return ( - - {new Date(lease.endDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' })} - - ); - })() : '\u2014'} + {lease ? : '\u2014'} diff --git a/client/src/hooks/use-action-queue.ts b/client/src/hooks/use-action-queue.ts index fb29eab..c358f47 100644 --- a/client/src/hooks/use-action-queue.ts +++ b/client/src/hooks/use-action-queue.ts @@ -180,29 +180,21 @@ export function useActionQueue() { // Expiring leases — grouped by urgency tier if (tenantId && expiringLeases && expiringLeases.length > 0) { - const critical = expiringLeases.filter((l) => l.daysRemaining <= 30); - const warning = expiringLeases.filter((l) => l.daysRemaining > 30 && l.daysRemaining <= 60); - - if (critical.length > 0) { - queue.push({ - id: 'expiring-leases-critical', - type: 'expiring_lease', - severity: 'critical', - title: `${critical.length} lease${critical.length !== 1 ? 's' : ''} expiring within 30 days`, - detail: critical.map((l) => l.tenantName).slice(0, 2).join(', ') + (critical.length > 2 ? ` +${critical.length - 2} more` : ''), - count: critical.length, - actionLabel: 'View', - actionHref: '/properties', - }); - } - if (warning.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: 'expiring-leases-warning', + id: tier.id, type: 'expiring_lease', - severity: 'warning', - title: `${warning.length} lease${warning.length !== 1 ? 's' : ''} expiring in 31\u201360 days`, - detail: warning.map((l) => l.tenantName).slice(0, 2).join(', ') + (warning.length > 2 ? ` +${warning.length - 2} more` : ''), - count: warning.length, + 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', }); diff --git a/client/src/hooks/use-property.ts b/client/src/hooks/use-property.ts index 753216e..08ff525 100644 --- a/client/src/hooks/use-property.ts +++ b/client/src/hooks/use-property.ts @@ -272,8 +272,6 @@ export function useUpdateLease(propertyId: string, leaseId: string) { export interface ExpiringLease { leaseId: string; tenantName: string; - tenantEmail: string | null; - tenantPhone: string | null; endDate: string; monthlyRent: string; unitNumber: string | null; diff --git a/server/lib/lease-expiration.ts b/server/lib/lease-expiration.ts index 5211043..b78d1df 100644 --- a/server/lib/lease-expiration.ts +++ b/server/lib/lease-expiration.ts @@ -2,7 +2,7 @@ * Lease expiration notification service. * * Runs via Cloudflare Cron Triggers (scheduled handler in worker.ts). - * Checks for leases expiring within 30/60/90 days and: + * 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 */ @@ -13,29 +13,41 @@ 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 = [ - { days: 90, taskTitle: 'Lease expiring in 90 days', smsTemplate: 'lease_reminder_90' as const }, - { days: 60, taskTitle: 'Lease expiring in 60 days', smsTemplate: 'lease_reminder_60' as const }, - { days: 30, taskTitle: 'Lease expiring in 30 days', smsTemplate: 'lease_reminder_30' as const }, + { 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<{ - checked: number; - tasksCreated: number; - emailsSent: number; - smsSent: number; -}> { +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 = { checked: 0, tasksCreated: 0, emailsSent: 0, smsSent: 0 }; + const stats: LeaseExpirationStats = { + checked: 0, tasksCreated: 0, emailsSent: 0, smsSent: 0, + emailsFailed: 0, smsFailed: 0, errors: [], + }; - // Set up optional notification clients const sendgrid = env.SENDGRID_API_KEY && env.SENDGRID_FROM_EMAIL ? new SendGridClient({ apiKey: env.SENDGRID_API_KEY, fromEmail: env.SENDGRID_FROM_EMAIL }) : null; @@ -43,58 +55,67 @@ export async function processLeaseExpirations(env: Env): Promise<{ ? new TwilioClient({ accountSid: env.TWILIO_ACCOUNT_SID, authToken: env.TWILIO_AUTH_TOKEN, fromNumber: env.TWILIO_PHONE_NUMBER }) : null; - // Process each notification window (90, 60, 30 days) + 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.days); + const expiring = await storage.getExpiringLeases(window.maxDays, undefined, window.minDays); stats.checked += expiring.length; for (const { lease, unit, property } of expiring) { - // Dedup: check if a task already exists for this lease + window - const existingTasks = await storage.getTasksByRelation('lease', lease.id); - const alreadyNotified = existingTasks.some((t) => t.title === window.taskTitle); - if (alreadyNotified) continue; - - // Create task for property manager - await storage.createTask({ - tenantId: property.tenantId, - title: window.taskTitle, - description: `${lease.tenantName}'s lease at ${property.name} (Unit ${unit.unitNumber || 'N/A'}) expires ${formatDate(lease.endDate)}.`, - dueDate: lease.endDate, - priority: window.days <= 30 ? 'urgent' : window.days <= 60 ? 'high' : 'medium', - status: 'pending', - relatedTo: 'lease', - relatedId: lease.id, - metadata: { - notificationWindow: window.days, - leaseEndDate: formatDate(lease.endDate), - propertyId: property.id, - unitId: unit.id, - }, - }); - stats.tasksCreated++; - - const endDateStr = formatDate(lease.endDate); - - // Send email notification if tenant has email and SendGrid is configured - 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) { - console.error(`Failed to send lease reminder email to ${lease.tenantEmail}:`, err); + 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}`); + } } - } - // Send SMS notification if tenant has phone and Twilio is configured - if (twilio && lease.tenantPhone) { - try { - const smsBody = SMS_TEMPLATES[window.smsTemplate](lease.tenantName, endDateStr); - await twilio.sendSms(lease.tenantPhone, smsBody); - stats.smsSent++; - } catch (err) { - console.error(`Failed to send lease reminder SMS to ${lease.tenantPhone}:`, err); + 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}`); } } } diff --git a/server/routes/leases.ts b/server/routes/leases.ts index 7509c64..f983c91 100644 --- a/server/routes/leases.ts +++ b/server/routes/leases.ts @@ -1,9 +1,10 @@ import { Hono } from 'hono'; import type { HonoEnv } from '../env'; +const MS_PER_DAY = 86_400_000; + export const leaseRoutes = new Hono(); -// GET /api/leases/expiring — list leases expiring within N days (default 90) leaseRoutes.get('/api/leases/expiring', async (c) => { const storage = c.get('storage'); const tenantId = c.get('tenantId'); @@ -13,23 +14,26 @@ leaseRoutes.get('/api/leases/expiring', async (c) => { return c.json({ error: 'days must be between 1 and 365' }, 400); } - const expiring = await storage.getExpiringLeases(days, tenantId); + try { + const expiring = await storage.getExpiringLeases(days, tenantId); - return c.json( - expiring.map(({ lease, unit, property }) => ({ - leaseId: lease.id, - tenantName: lease.tenantName, - tenantEmail: lease.tenantEmail, - tenantPhone: lease.tenantPhone, - 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()) / (24 * 60 * 60 * 1000), - ), - })), - ); + 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 08e4e68..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,12 +353,13 @@ export class SystemStorage { .where(and(inArray(schema.leases.unitId, unitIds), eq(schema.leases.status, 'active'))); } - async getExpiringLeases(withinDays: number, tenantId?: string) { + async getExpiringLeases(withinDays: number, tenantId?: string, minDays?: number) { const now = new Date(); - const cutoff = new Date(now.getTime() + withinDays * 24 * 60 * 60 * 1000); + 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} >= ${now}`, + sql`${schema.leases.endDate} >= ${floor}`, sql`${schema.leases.endDate} <= ${cutoff}`, ]; if (tenantId) { diff --git a/server/worker.ts b/server/worker.ts index fe2c1f7..d5fb946 100755 --- a/server/worker.ts +++ b/server/worker.ts @@ -8,14 +8,11 @@ export default { fetch: app.fetch, async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) { - // Daily cron: check lease expirations and send notifications - ctx.waitUntil( - processLeaseExpirations(env).then((stats) => { - console.log('Lease expiration check complete:', JSON.stringify(stats)); - }).catch((err) => { - console.error('Lease expiration check failed:', err); - }), - ); + 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;