Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions client/src/components/property/ExpiringLeases.tsx
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>
);
}
Comment on lines +16 to +46
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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
Verify each finding against the current code and only fix it if needed.

In `@client/src/components/property/ExpiringLeases.tsx` around lines 16 - 46, The
component currently treats failed fetches the same as an empty result; update
ExpiringLeases to handle the error state from useExpiringLeases: read isError
and error (or equivalent) from the hook alongside isLoading and leases, and when
isError is true render a Card (same header) showing an error message and
optional retry hint or error.message to distinguish API failures from "no
leases"; ensure you reference useExpiringLeases, leases, isLoading, isError and
error when implementing this change.


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>
);
}
23 changes: 23 additions & 0 deletions client/src/hooks/use-property.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExpiringLease[]>({
queryKey: [`/api/leases/expiring?days=${days}`, tenantId],
enabled: !!tenantId,
staleTime: 10 * 60 * 1000,
});
}

export function useSendPropertyAdvice(propertyId: string) {
return useMutation<AIAdviceResponse, Error, string>({
mutationFn: (message: string) =>
Expand Down
6 changes: 4 additions & 2 deletions client/src/pages/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useExpiringLeases is imported but never used in this file. Removing it avoids dead imports and keeps the module’s public surface clearer.

Suggested change
import ExpiringLeases from '@/components/property/ExpiringLeases';

Copilot uses AI. Check for mistakes.
import { useActionQueue, type ActionItem, type ActionSeverity } from '@/hooks/use-action-queue';
import { useConnectionHealth } from '@/hooks/use-connection-health';
import { useConsolidatedReport } from '@/hooks/use-reports';
Expand Down Expand Up @@ -593,9 +594,10 @@ export default function Dashboard() {
)}
</div>

{/* Right column: health + connections + orbital */}
{/* Right column: health + leases + connections + orbital */}
<div className="space-y-5">
<DataHealth />
{tenantId && <ExpiringLeases />}
<ConnectionPulse />
<OrbitalPreview />
</div>
Expand Down
12 changes: 12 additions & 0 deletions deploy/system-wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"

Expand Down
4 changes: 3 additions & 1 deletion server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 ──
Expand Down
103 changes: 103 additions & 0 deletions server/lib/lease-expiration.ts
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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notification window selection is inconsistent with the SMS templates: getExpiringLeases(window.days) returns leases expiring anytime within that cutoff, so on first rollout (or after cron downtime) a lease expiring in ~10–20 days will trigger 90/60/30 notifications immediately, including sending “expires in 60 days” messages when it’s not true. Consider enforcing disjoint date ranges per window (e.g., 61–90, 31–60, 0–30 days remaining) or selecting the most urgent applicable window per lease per run.

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +55
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how getExpiringLeases is implemented to understand the range behavior
ast-grep --pattern $'getExpiringLeases($DAYS) {
  $$$
}'

# Also search for the method signature and implementation
rg -n -A 20 'getExpiringLeases' --type ts

Repository: chittyapps/chittyfinance

Length of output: 4261


🏁 Script executed:

# Find NOTIFICATION_WINDOWS definition
rg -n -B 5 -A 10 'NOTIFICATION_WINDOWS' --type ts server/lib/lease-expiration.ts

Repository: chittyapps/chittyfinance

Length of output: 1699


🏁 Script executed:

# Get full context around the processing loop in lease-expiration.ts
head -100 server/lib/lease-expiration.ts | tail -70

Repository: chittyapps/chittyfinance

Length of output: 2952


Multiple confusing notifications will be sent when a lease falls into multiple notification windows.

When a lease has 45 days remaining, getExpiringLeases(90) and getExpiringLeases(60) both return it in the same cron execution. The deduplication at line 54 only checks for matching taskTitles within the current window, so it cannot prevent creating both the 90-day and 60-day tasks (which have different titles). This results in a single tenant receiving multiple notifications simultaneously (e.g., "expiring in 90 days" and "expiring in 60 days").

Suggested approaches:

  1. Track already-processed lease IDs within a single run
  2. Query for exclusive ranges (e.g., 60 < days ≤ 90)
  3. Process windows in ascending order (30→60→90) and skip leases that matched a smaller window
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/lib/lease-expiration.ts` around lines 46 - 55, The loop over
NOTIFICATION_WINDOWS can create multiple notifications for the same lease
because getExpiringLeases(window.days) is called per window and dedupe only
checks taskTitle; fix by tracking already-processed lease IDs within a single
run: create a Set (e.g., processedLeaseIds) outside the for (const window of
NOTIFICATION_WINDOWS) loop, then before handling each { lease, unit, property }
check if processedLeaseIds.has(lease.id) and skip if true, and after creating a
notification/task add processedLeaseIds.add(lease.id); update code around
getExpiringLeases, the inner loop, and where tasks are created (references:
NOTIFICATION_WINDOWS, getExpiringLeases, storage.getTasksByRelation,
existingTasks, window.taskTitle, lease.id).


// 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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current approach does an N+1 query pattern (getTasksByRelation per lease) and then does email/SMS sends sequentially inside nested loops. For a tenant set with many leases, this can exceed scheduled event time limits and/or generate significant DB load. Consider: (1) querying existing notification tasks in bulk for the window (or doing a SELECT 1 ... WHERE relatedTo/relatedId/title LIMIT 1), and (2) sending notifications with bounded concurrency (e.g., a small promise pool) so you don’t serialize all network calls.

Copilot uses AI. Check for mistakes.
}

return stats;
}
Comment on lines +27 to +103
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New scheduled notification logic is being introduced without corresponding tests. Given the repo already has server-side route/storage tests (Vitest), it would be valuable to add tests that cover window selection (30/60/90 behavior), deduplication, and “backfill” behavior when a lease is already close to expiry at first run.

Copilot uses AI. Check for mistakes.
34 changes: 34 additions & 0 deletions server/routes/leases.ts
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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.ts

Repository: chittyapps/chittyfinance

Length of output: 177


🏁 Script executed:

find . -path "*shared/schema*" -o -path "*@shared/schema*" | head -20

Repository: chittyapps/chittyfinance

Length of output: 86


🏁 Script executed:

fd -t f "schema" --full-path | grep -E "(shared|@shared)" | head -20

Repository: chittyapps/chittyfinance

Length of output: 109


🏁 Script executed:

# Look at the shared directory structure
git ls-files | grep -E "^shared/" | head -30

Repository: chittyapps/chittyfinance

Length of output: 109


🏁 Script executed:

wc -l shared/schema.ts shared/finance.schema.ts

Repository: chittyapps/chittyfinance

Length of output: 133


🏁 Script executed:

cat -n shared/schema.ts

Repository: chittyapps/chittyfinance

Length of output: 17196


🏁 Script executed:

cat -n shared/finance.schema.ts

Repository: chittyapps/chittyfinance

Length of output: 987


🏁 Script executed:

cat -n server/routes/leases.ts

Repository: chittyapps/chittyfinance

Length of output: 1379


🏁 Script executed:

# Check other routes for schema validation patterns
fd -t f "\.ts$" server/routes/ | head -10

Repository: 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 -50

Repository: 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=ts

Repository: 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 -30

Repository: chittyapps/chittyfinance

Length of output: 809


🏁 Script executed:

# Compare server/db/schema.ts with shared/schema.ts
cat server/db/schema.ts

Repository: 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) || true

Repository: chittyapps/chittyfinance

Length of output: 111


Use Zod schema validation for the days query parameter instead of manual parseInt.

The manual parsing with parseInt and isNaN check does not follow the project's standardized validation approach. Per coding guidelines, all backend inputs must be validated using Zod schemas.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
}
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 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;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/routes/leases.ts` around lines 9 - 13, Replace the manual
parseInt/isNaN check for the days query param with a Zod schema: create a schema
(e.g., daysQuerySchema) using
z.coerce.number().int().min(1).max(365).optional().default(90) (or equivalent
preprocess) and call schema.parse or safeParse on c.req.query() to obtain the
validated days value instead of using parseInt; on validation failure return the
same 400 response with the Zod error details, and update references to the local
variable days to use the parsed value from the schema (look for uses of days and
c.req.query in this route handler).


const expiring = await storage.getExpiringLeases(days);
Comment on lines +9 to +15
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new route calls storage.getExpiringLeases(days) without scoping to the active tenant (c.get('tenantId')). Since tenantMiddleware only validates membership (it does not scope the storage instance), the endpoint currently returns leases for all tenants, leaking PII. Fix by passing tenantId into storage and enforcing properties.tenantId = tenantId in the query.

Suggested change
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 uses AI. Check for mistakes.

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),
),
})),
);
});
Comment on lines +6 to +34
Copy link

Copilot AI Mar 26, 2026

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.

Copilot uses AI. Check for mistakes.
34 changes: 34 additions & 0 deletions server/storage/system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getExpiringLeases() returns leases across all tenants. Because leases don’t have a tenantId column (tenant scoping is via units -> properties.tenantId), this method needs a tenant filter when used by authenticated API routes; otherwise callers can enumerate other tenants’ leases/PII. Consider changing the signature to require tenantId (and add eq(schema.properties.tenantId, tenantId)), and add a separate admin/cron-only method if you truly need cross-tenant results.

Copilot uses AI. Check for mistakes.
}
Comment on lines +354 to +374
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Missing tenant filter in getExpiringLeases can leak cross-tenant lease data.

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
Verify each finding against the current code and only fix it if needed.

In `@server/storage/system.ts` around lines 354 - 374, getExpiringLeases currently
returns leases across all tenants; update its signature to accept a tenantId
parameter and add a tenant filter (e.g., eq(schema.leases.tenantId, tenantId))
to the where clause in getExpiringLeases, then update the caller in
server/routes/leases.ts to read tenantId from the request context
(c.get('tenantId')) and pass it into storage.getExpiringLeases so queries are
scoped to the requesting tenant; ensure parameter types and any tests are
updated accordingly.


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
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getTasksByRelation() is not scoped by tenant. Even if it’s currently only used for cron deduplication, leaving it unscoped makes it easy to accidentally use in a request path later and leak cross-tenant task data. Consider requiring tenantId and filtering by schema.tasks.tenantId, or at least naming it getTasksByRelationUnsafe/documenting intended use.

Copilot uses AI. Check for mistakes.
}
Comment on lines +354 to +386
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SystemStorage has an existing test suite (server/__tests__/storage-system.test.ts), but these new query helpers aren’t covered. Adding tests for getExpiringLeases() (including tenant scoping once fixed) and getTasksByRelation() would help prevent cross-tenant leaks/regressions and verify date boundary behavior.

Copilot uses AI. Check for mistakes.

// ── PROPERTY FINANCIALS ──

async getPropertyTransactions(propertyId: string, tenantId: string, since?: string, until?: string) {
Expand Down
Loading
Loading