-
Notifications
You must be signed in to change notification settings - Fork 0
feat: lease expiration notifications via cron + API #70
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <Badge variant="destructive">{days}d</Badge>; | ||
| if (days <= 60) return <Badge variant="default">{days}d</Badge>; | ||
| return <Badge variant="secondary">{days}d</Badge>; | ||
| } | ||
|
|
||
| export default function ExpiringLeases() { | ||
| const { data: leases, isLoading } = useExpiringLeases(90); | ||
|
|
||
| if (isLoading) { | ||
| return ( | ||
| <Card> | ||
| <CardHeader> | ||
| <CardTitle className="text-base flex items-center gap-2"> | ||
| <Clock className="h-4 w-4" /> Expiring Leases | ||
| </CardTitle> | ||
| </CardHeader> | ||
| <CardContent> | ||
| <Skeleton className="h-24" /> | ||
| </CardContent> | ||
| </Card> | ||
| ); | ||
| } | ||
|
|
||
| if (!leases || leases.length === 0) { | ||
| return ( | ||
| <Card> | ||
| <CardHeader> | ||
| <CardTitle className="text-base flex items-center gap-2"> | ||
| <Clock className="h-4 w-4" /> Expiring Leases | ||
| </CardTitle> | ||
| </CardHeader> | ||
| <CardContent> | ||
| <p className="text-sm text-muted-foreground">No leases expiring in the next 90 days.</p> | ||
| </CardContent> | ||
| </Card> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <Card> | ||
| <CardHeader> | ||
| <CardTitle className="text-base flex items-center gap-2"> | ||
| <Clock className="h-4 w-4" /> Expiring Leases ({leases.length}) | ||
| </CardTitle> | ||
| </CardHeader> | ||
| <CardContent> | ||
| <Table> | ||
| <TableHeader> | ||
| <TableRow> | ||
| <TableHead>Tenant</TableHead> | ||
| <TableHead>Property</TableHead> | ||
| <TableHead>Unit</TableHead> | ||
| <TableHead className="text-right">Rent</TableHead> | ||
| <TableHead>End Date</TableHead> | ||
| <TableHead className="text-right">Remaining</TableHead> | ||
| </TableRow> | ||
| </TableHeader> | ||
| <TableBody> | ||
| {leases.map((l) => ( | ||
| <TableRow key={l.leaseId}> | ||
| <TableCell className="font-medium">{l.tenantName}</TableCell> | ||
| <TableCell>{l.propertyName}</TableCell> | ||
| <TableCell>{l.unitNumber || '\u2014'}</TableCell> | ||
| <TableCell className="text-right">{formatCurrency(parseFloat(l.monthlyRent))}</TableCell> | ||
| <TableCell>{new Date(l.endDate).toLocaleDateString()}</TableCell> | ||
| <TableCell className="text-right">{urgencyBadge(l.daysRemaining)}</TableCell> | ||
| </TableRow> | ||
| ))} | ||
| </TableBody> | ||
| </Table> | ||
| </CardContent> | ||
| </Card> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
|
|
@@ -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 ExpiringLeases from '@/components/property/ExpiringLeases'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
|
Comment on lines
+46
to
+55
|
||
|
|
||
| // 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); | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+51
to
+99
|
||
| } | ||
|
|
||
| return stats; | ||
| } | ||
|
Comment on lines
+27
to
+103
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,34 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||
| import { Hono } from 'hono'; | ||||||||||||||||||||||||||||||||||||||||||||||||
| import type { HonoEnv } from '../env'; | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| export const leaseRoutes = new Hono<HonoEnv>(); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| // 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); | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+9
to
+13
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Verify manual parsing vs schema-based validation in this route.
rg -nP --type=ts 'parseInt\(|isNaN\(|safeParse|z\.coerce\.number|@shared/schema' server/routes/leases.tsRepository: chittyapps/chittyfinance Length of output: 177 🏁 Script executed: find . -path "*shared/schema*" -o -path "*@shared/schema*" | head -20Repository: chittyapps/chittyfinance Length of output: 86 🏁 Script executed: fd -t f "schema" --full-path | grep -E "(shared|@shared)" | head -20Repository: chittyapps/chittyfinance Length of output: 109 🏁 Script executed: # Look at the shared directory structure
git ls-files | grep -E "^shared/" | head -30Repository: chittyapps/chittyfinance Length of output: 109 🏁 Script executed: wc -l shared/schema.ts shared/finance.schema.tsRepository: chittyapps/chittyfinance Length of output: 133 🏁 Script executed: cat -n shared/schema.tsRepository: chittyapps/chittyfinance Length of output: 17196 🏁 Script executed: cat -n shared/finance.schema.tsRepository: chittyapps/chittyfinance Length of output: 987 🏁 Script executed: cat -n server/routes/leases.tsRepository: chittyapps/chittyfinance Length of output: 1379 🏁 Script executed: # Check other routes for schema validation patterns
fd -t f "\.ts$" server/routes/ | head -10Repository: chittyapps/chittyfinance Length of output: 306 🏁 Script executed: # Search for query parameter validation patterns in other routes
rg -n "safeParse|z\.object|c\.req\.query" server/routes/ --type=ts -A 2 | head -50Repository: chittyapps/chittyfinance Length of output: 3449 🏁 Script executed: # Check if there are any pre-defined query parameter validation schemas anywhere
rg -n "z\.object.*query|QuerySchema|query.*z\." server/ --type=tsRepository: chittyapps/chittyfinance Length of output: 50 🏁 Script executed: # Verify the import path to `@shared/schema` works from server/routes
head -20 server/routes/properties.ts | grep -E "^import|^export"Repository: chittyapps/chittyfinance Length of output: 351 🏁 Script executed: # Check what path is used for schema imports across different routes
rg -n "from.*schema" server/routes/*.ts | grep -E "import.*schema"Repository: chittyapps/chittyfinance Length of output: 187 🏁 Script executed: # Verify the tsconfig paths to understand `@shared` vs ../db
cat tsconfig.json | grep -A 5 "paths"Repository: chittyapps/chittyfinance Length of output: 161 🏁 Script executed: # Check what db/schema actually is and where shared/schema.ts exists
ls -la server/db/ 2>/dev/null || echo "No server/db directory"Repository: chittyapps/chittyfinance Length of output: 300 🏁 Script executed: # Check tsconfig baseUrl to understand path resolution
cat tsconfig.json | head -30Repository: chittyapps/chittyfinance Length of output: 809 🏁 Script executed: # Compare server/db/schema.ts with shared/schema.ts
cat server/db/schema.tsRepository: chittyapps/chittyfinance Length of output: 929 🏁 Script executed: # Check if `@shared/schema` is the same as server/db/schema or different
head -50 shared/schema.ts | diff -u - <(head -50 server/db/schema.ts) || trueRepository: chittyapps/chittyfinance Length of output: 111 Use Zod schema validation for the The manual parsing with Suggested fix+import { z } from 'zod';
import { Hono } from 'hono';
import type { HonoEnv } from '../env';
export const leaseRoutes = new Hono<HonoEnv>();
+
+const expiringLeasesQuerySchema = z.object({
+ days: z.coerce.number().int().min(1).max(365).default(90),
+});
// 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) {
+ const parsed = expiringLeasesQuerySchema.safeParse({ days: c.req.query('days') });
+ if (!parsed.success) {
return c.json({ error: 'days must be between 1 and 365' }, 400);
}
+ const { days } = parsed.data;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| const expiring = await storage.getExpiringLeases(days); | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+9
to
+15
|
||||||||||||||||||||||||||||||||||||||||||||||||
| 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 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(tenantId, days); |
Copilot
AI
Mar 26, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This new API route should have tests (similar to other route tests under server/__tests__) to validate: (1) tenant isolation (can’t see other tenants’ leases), (2) days validation bounds, and (3) response shape including daysRemaining correctness.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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); | ||
|
Comment on lines
+354
to
+373
|
||
| } | ||
|
Comment on lines
+354
to
+374
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing tenant filter in This method is used by tenant-scoped API flow, but it currently queries all tenants. Add tenant scoping support and pass tenantId from the route. Proposed fix- 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);
- return this.db
+ const baseQuery = 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);
+ .innerJoin(schema.properties, eq(schema.units.propertyId, schema.properties.id));
+
+ const dateRange = and(
+ eq(schema.leases.status, 'active'),
+ sql`${schema.leases.endDate} >= ${now}`,
+ sql`${schema.leases.endDate} <= ${cutoff}`,
+ );
+
+ return tenantId
+ ? baseQuery.where(and(dateRange, eq(schema.properties.tenantId, tenantId))).orderBy(schema.leases.endDate)
+ : baseQuery.where(dateRange).orderBy(schema.leases.endDate);
}// server/routes/leases.ts
const tenantId = c.get('tenantId');
const expiring = await storage.getExpiringLeases(days, tenantId);As per coding guidelines: "Multi-tenant routes must enforce tenant isolation by checking tenant_id and user permissions via middleware before accessing data". 🤖 Prompt for AI Agents |
||
|
|
||
| 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), | ||
| ), | ||
| ); | ||
|
Comment on lines
+376
to
+385
|
||
| } | ||
|
Comment on lines
+354
to
+386
|
||
|
|
||
| // ── PROPERTY FINANCIALS ── | ||
|
|
||
| async getPropertyTransactions(propertyId: string, tenantId: string, since?: string, until?: string) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add an explicit error state for failed lease fetches.
Right now, request failures fall through to the same UI as “no expiring leases,” which can hide backend/API issues.
Proposed fix
export default function ExpiringLeases() { - const { data: leases, isLoading } = useExpiringLeases(90); + const { data: leases, isLoading, isError } = useExpiringLeases(90); @@ if (!leases || leases.length === 0) { @@ } + + if (isError) { + return ( + <Card> + <CardHeader> + <CardTitle className="text-base flex items-center gap-2"> + <Clock className="h-4 w-4" /> Expiring Leases + </CardTitle> + </CardHeader> + <CardContent> + <p className="text-sm text-muted-foreground">Could not load expiring leases. Please retry.</p> + </CardContent> + </Card> + ); + }🤖 Prompt for AI Agents