Skip to content

feat: lease expiration notifications via cron + API#70

Merged
chitcommit merged 3 commits intomainfrom
feat/lease-expiration-notifications
Mar 27, 2026
Merged

feat: lease expiration notifications via cron + API#70
chitcommit merged 3 commits intomainfrom
feat/lease-expiration-notifications

Conversation

@chitcommit
Copy link
Copy Markdown
Contributor

@chitcommit chitcommit commented Mar 26, 2026

Summary

  • Daily cron trigger (9 AM UTC via Cloudflare Scheduled handler) checks for leases expiring within 30/60/90 days
  • Task deduplication: creates relatedTo='lease' tasks with window-specific titles, skips if already notified
  • Email/SMS: uses existing SendGrid (lease_reminder) and Twilio (lease_reminder_30/60/90) templates when configured
  • New API endpoint: GET /api/leases/expiring?days=N returns leases with tenant, property, unit, and daysRemaining
  • Dashboard widget: ExpiringLeases component shows upcoming expirations with urgency badges (red <=30d, default <=60d, muted <=90d)
  • Wrangler cron: [triggers] crons = ["0 9 * * *"] added to top-level + all 3 env blocks

Test plan

  • npm run check passes (TypeScript clean)
  • npm run build succeeds
  • Verify GET /api/leases/expiring?days=90 returns correct data shape
  • Verify cron trigger fires via wrangler dev --test-scheduled
  • Verify task dedup: running cron twice does not create duplicate tasks
  • Verify ExpiringLeases widget renders on dashboard when tenant is selected
  • Verify email/SMS only sent when SendGrid/Twilio env vars are configured

Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Dashboard: "Expiring Leases" card showing upcoming expirations (tenant, property, unit, rent, end date, remaining days with urgency badges)
    • Units & Leases table: added "Lease End" column with urgency badges
    • Action list: adds expiring-lease items grouped by urgency with quick-view action
    • Background checks: daily scheduled job that creates tasks and sends tenant email/SMS notifications for 90/60/30-day windows

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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 26, 2026 05:17
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages bot commented Mar 26, 2026

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
chittyfinance b723396 Mar 26 2026, 11:44 PM

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 26, 2026

📝 Walkthrough

Walkthrough

Adds end-to-end lease-expiration features: frontend widget and hook, new API route and storage queries, a scheduled worker that creates tasks and optionally sends email/SMS, and deployment cron entries to run the processor daily.

Changes

Cohort / File(s) Summary
Frontend — Widget & Panels
client/src/components/property/ExpiringLeases.tsx, client/src/components/property/PropertyDetailPanel.tsx, client/src/pages/Dashboard.tsx
New ExpiringLeases card component and badge UI; Dashboard inserts widget in right column; Property detail table gains “Lease End” badge column.
Frontend — Hooks / Action Queue
client/src/hooks/use-property.ts, client/src/hooks/use-action-queue.ts
Added ExpiringLease type and useExpiringLeases(days) query hook; action-queue now consumes useExpiringLeases(90) and adds expiring_lease items split into critical/warning tiers.
API Routes
server/routes/leases.ts, server/app.ts
New leaseRoutes Hono sub-app with GET /api/leases/expiring?days= (validates 1–365); mounted in app and /api/leases added to protected prefixes.
Storage Layer
server/storage/system.ts
Added SystemStorage.getExpiringLeases(withinDays, tenantId?, minDays?) (joins leases→units→properties, filters by endDate window) and getTasksByRelation(relatedTo, relatedId) for deduplication; added MS_PER_DAY constant.
Lease Expiration Processor
server/lib/lease-expiration.ts, server/worker.ts
New processLeaseExpirations(env) implementing 0–30/31–60/61–90 windows, task dedupe/creation, optional SendGrid/Twilio notifications, and aggregated stats; worker scheduled handler calls it and logs results.
Deployment
deploy/system-wrangler.toml
Added cron trigger "0 9 * * *" at top-level and for dev, staging, production to schedule daily runs.

Sequence Diagram

sequenceDiagram
    participant Cron as Scheduler (Cron)
    participant Worker as Worker (server/worker.ts)
    participant Storage as Database (SystemStorage)
    participant Tasks as Task System (createTask)
    participant Email as SendGrid
    participant SMS as Twilio

    Cron->>Worker: scheduled trigger (09:00 UTC)
    Worker->>Storage: getExpiringLeases(window)
    Storage-->>Worker: expiring lease rows

    loop per window (0–30,31–60,61–90)
        loop per lease
            Worker->>Storage: getTasksByRelation('lease', leaseId)
            Storage-->>Worker: existing tasks
            alt no existing task for this window
                Worker->>Tasks: create task (priority, metadata, dueDate)
                Tasks-->>Worker: task created
                Worker->>Email: send tenant email (if configured)
                Email-->>Worker: success / error
                Worker->>SMS: send tenant SMS (if configured)
                SMS-->>Worker: success / error
            end
        end
    end

    Worker-->>Cron: log stats (checked, tasksCreated, emailsSent, smsSent, errors)
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

🐰 I nibble codes and chase the clocks that chime,
Ninety, sixty, thirty—hops through time,
Tasks sprout like carrots, emails take flight,
A daily cron at nine keeps things polite,
Hooray—leases tracked, all tidy and prime! 🥕📬

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: lease expiration notifications via cron + API' accurately captures the core feature additions: scheduled lease expiration checks and the corresponding API endpoint for retrieving expiring leases.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/lease-expiration-notifications

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link
Copy Markdown

claude bot commented Mar 26, 2026

PR Review: feat: lease expiration notifications via cron + API

Overall this is solid work — the cron integration, dedup logic, and component design are well-structured. A few issues need attention before merging.


🔴 Critical: Tenant isolation missing in getExpiringLeases

server/storage/system.tsgetExpiringLeases queries all active leases across all tenants with no tenantId filter:

// No tenant filter — returns data from ALL tenants
.where(and(
  eq(schema.leases.status, 'active'),
  sql`${schema.leases.endDate} >= ${now}`,
  sql`${schema.leases.endDate} <= ${cutoff}`,
))

server/routes/leases.ts then calls this directly from a per-tenant request context:

const expiring = await storage.getExpiringLeases(days);

A user in Tenant A can see Tenant B's lease data (tenant names, emails, phones, rent amounts). This needs a tenantId overload or a separate method for the API path.

Suggested fix: add a tenantId parameter to getExpiringLeases (nullable for cron use), and in the route pass the caller's tenant from context:

const tenantId = c.get('tenantId'); // from callerContext middleware
const expiring = await storage.getExpiringLeases(days, tenantId);

🟡 Performance: 3 redundant DB round trips in cron + N×3 task queries

server/lib/lease-expiration.ts: The cron calls storage.getExpiringLeases(90), then getExpiringLeases(60), then getExpiringLeases(30) — each returns a superset of the next. This is 3 DB round trips returning overlapping data. Query once for 90 days and partition in code:

const allExpiring = await storage.getExpiringLeases(90);
for (const window of NOTIFICATION_WINDOWS) {
  const inWindow = allExpiring.filter(({ lease }) => daysUntil(lease.endDate) <= window.days);
  // ...
}

Additionally, getTasksByRelation is called once per lease per window — with many leases that's a lot of round trips. Consider a single batch query for all relevant lease IDs at the start of the cron run.


🟡 Dead import in Dashboard.tsx

useExpiringLeases is imported but never used directly in Dashboard.tsx — the ExpiringLeases component handles its own data fetching internally:

// Dashboard.tsx line 12 — unused import
import { usePortfolioSummary, useExpiringLeases } from '@/hooks/use-property';

🟡 stats.checked is overcounted

A lease expiring in 20 days passes through all three windows (90, 60, 30) so it's counted 3× in stats.checked. This makes the log output misleading. Consider counting unique leases, or rename to checks to reflect it's the number of evaluations.


🟠 Missing error handling in route

server/routes/leases.ts: No try/catch. A DB error will bubble up as an unhandled 500. Other Hono routes in this codebase wrap storage calls:

try {
  const expiring = await storage.getExpiringLeases(days);
  return c.json(expiring.map(...));
} catch (err) {
  console.error('Failed to fetch expiring leases:', err);
  return c.json({ error: 'Internal server error' }, 500);
}

🟢 Minor nits

  • daysRemaining in the route is computed at response time after the DB query — could theoretically be 0 for a lease that just crossed the threshold. Consider computing it from the same now reference used by getExpiringLeases, or better, compute it in the storage method.
  • The crons trigger in [triggers] (top-level) and [env.dev.triggers] both fire in wrangler dev — the top-level may be redundant. Worth confirming Wrangler's merge behavior per env.
  • formatDate in lease-expiration.ts is a simple utility that likely duplicates date formatting elsewhere — worth checking if there's a shared util.

Summary

Severity Issue
🔴 Critical getExpiringLeases has no tenant filter — cross-tenant data leak in API
🟡 Medium 3 redundant DB queries + N×3 getTasksByRelation calls in cron
🟡 Medium useExpiringLeases dead import in Dashboard.tsx
🟡 Medium stats.checked overcounts leases
🟠 Minor Missing try/catch in route handler

The tenant isolation issue is a blocker. Everything else can be addressed in follow-up or in this PR. The overall design — cron handler, dedup by task title, opt-in email/SMS, dashboard widget — is clean and matches the existing patterns well.

Reviewed by Claude Sonnet 4.6

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds lease-expiration visibility and notifications to the system: a daily Cloudflare Scheduled handler generates lease-related tasks and optionally sends tenant reminders, plus a new API + dashboard widget for expiring leases.

Changes:

  • Add a daily scheduled (cron) worker handler that scans for expiring leases and creates deduped tasks; optionally sends SendGrid/Twilio reminders.
  • Add GET /api/leases/expiring?days=N and underlying storage queries to return expiring leases with daysRemaining.
  • Add a dashboard widget to display expiring leases with urgency badges.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
server/worker.ts Adds scheduled() handler to run lease expiration processing via cron.
server/lib/lease-expiration.ts Implements the cron-driven lease expiration scan, task creation, and optional email/SMS notifications.
server/storage/system.ts Adds expiring-lease query and a task lookup helper used for notification dedup.
server/routes/leases.ts Introduces GET /api/leases/expiring endpoint.
server/app.ts Mounts new lease routes and protects /api/leases with auth+tenant middleware.
deploy/system-wrangler.toml Adds cron trigger configuration for scheduled execution across envs.
client/src/hooks/use-property.ts Adds useExpiringLeases() hook + response typing.
client/src/components/property/ExpiringLeases.tsx Adds dashboard card/table UI for expiring leases.
client/src/pages/Dashboard.tsx Places the widget on the dashboard right column (tenant-selected).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
server/lib/lease-expiration.ts (1)

78-98: LGTM - Resilient notification handling with proper error isolation.

Good pattern: catching and logging individual notification failures allows processing to continue for remaining leases. This prevents a single email/SMS failure from blocking the entire batch.

One minor observation: errors are logged to console but there's no aggregation of failed notifications in the returned stats. Consider tracking emailsFailed and smsFailed if monitoring notification reliability is important.

🤖 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 78 - 98, Add failure counters to
the notification stats and increment them in the respective catch blocks: ensure
the stats object (used by lease-expiration.ts) includes emailsFailed and
smsFailed initialized to 0, then in the email catch block increment
stats.emailsFailed and in the SMS catch block increment stats.smsFailed; use the
existing symbols (sendgrid, twilio, EMAIL_TEMPLATES, SMS_TEMPLATES,
window.smsTemplate, stats) so the try/catch blocks keep logging errors but also
aggregate failures for reporting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/src/components/property/ExpiringLeases.tsx`:
- Around line 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.

In `@server/lib/lease-expiration.ts`:
- Around line 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).

In `@server/routes/leases.ts`:
- Around line 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).

In `@server/storage/system.ts`:
- Around line 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.

In `@server/worker.ts`:
- Around line 13-17: Replace the .then()/.catch() promise chain on
processLeaseExpirations(env) with an async IIFE passed into ctx.waitUntil();
specifically, call ctx.waitUntil((async () => { try { const stats = await
processLeaseExpirations(env); console.log('Lease expiration check complete:',
JSON.stringify(stats)); } catch (err) { console.error('Lease expiration check
failed:', err); } })()); this uses async/await and preserves the original
logging while ensuring the task is awaited by the worker runtime (referencing
processLeaseExpirations and ctx in server/worker.ts).

---

Nitpick comments:
In `@server/lib/lease-expiration.ts`:
- Around line 78-98: Add failure counters to the notification stats and
increment them in the respective catch blocks: ensure the stats object (used by
lease-expiration.ts) includes emailsFailed and smsFailed initialized to 0, then
in the email catch block increment stats.emailsFailed and in the SMS catch block
increment stats.smsFailed; use the existing symbols (sendgrid, twilio,
EMAIL_TEMPLATES, SMS_TEMPLATES, window.smsTemplate, stats) so the try/catch
blocks keep logging errors but also aggregate failures for reporting.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0319498e-f11d-47c0-b70d-3b8e7c599f78

📥 Commits

Reviewing files that changed from the base of the PR and between 34f4728 and 8bfa110.

📒 Files selected for processing (9)
  • client/src/components/property/ExpiringLeases.tsx
  • client/src/hooks/use-property.ts
  • client/src/pages/Dashboard.tsx
  • deploy/system-wrangler.toml
  • server/app.ts
  • server/lib/lease-expiration.ts
  • server/routes/leases.ts
  • server/storage/system.ts
  • server/worker.ts

- 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 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 26, 2026

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== server/lib/lease-expiration.ts ==="
cat server/lib/lease-expiration.ts

echo ""
echo "=== server/routes/leases.ts ==="
cat server/routes/leases.ts

echo ""
echo "=== server/storage/system.ts (getExpiringLeases and getTasksByRelation) ==="
cat server/storage/system.ts

echo ""
echo "=== server/worker.ts ==="
cat server/worker.ts

echo ""
echo "=== server/app.ts ==="
cat server/app.ts

echo ""
echo "=== client/src/hooks/use-property.ts ==="
cat client/src/hooks/use-property.ts

echo ""
echo "=== client/src/components/property/ExpiringLeases.tsx ==="
cat client/src/components/property/ExpiringLeases.tsx

echo ""
echo "=== deploy/system-wrangler.toml ==="
cat deploy/system-wrangler.toml

echo ""
echo "=== package.json (check for new deps) ==="
cat package.json

echo ""
echo "=== Check for hardcoded secrets or credentials ==="
rg -n "(api_key|apikey|password|secret|token|AUTH|SENDGRID|TWILIO)" --type ts -i -C2 server/lib/lease-expiration.ts

echo ""
echo "=== Check env var usage patterns ==="
rg -n "env\." server/lib/lease-expiration.ts

echo ""
echo "=== Check if /api/leases is protected in app.ts ==="
rg -n "leases\|protectedRoutes\|authMiddleware" server/app.ts

Length of output: 58018


@github-actions[bot] — Here is the security-focused review of PR #70:


Security Review: PR #70 — Lease Expiration Notifications

✅ Positives

Area Finding
Authentication /api/leases and /api/leases/* are correctly included in protectedPrefixes in server/app.ts, applying auth + tenant + storage middleware.
Input validation The days query param is validated as an integer between 1–365 before use.
SQL injection Drizzle ORM parameterized queries throughout — no raw SQL interpolation.
Credential storage All secrets (SENDGRID_API_KEY, TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, etc.) are read exclusively from the env object (Cloudflare Worker secrets). No hardcoded credentials.
Supply chain Zero new npm dependencies introduced. SendGridClient and TwilioClient are existing internal abstractions.
Cron attack surface The scheduled handler is invoked by Cloudflare's scheduler only — there is no externally-triggerable HTTP surface.
Tenant scoping (API) getExpiringLeases(days, tenantId) correctly passes the authenticated tenantId from context, scoping API results to the calling tenant.

🔴 High Priority Issues

1. PII Logged in Error Messages (server/lib/lease-expiration.ts, Lines ~85–96)

Email addresses and phone numbers are interpolated directly into console.error log lines:

console.error(`Failed to send lease reminder email to ${lease.tenantEmail}:`, err);
console.error(`Failed to send lease reminder SMS to ${lease.tenantPhone}:`, err);

Logs containing PII (email, phone) may be retained by Cloudflare's logging infrastructure (chittytrack tail consumer) and could constitute a GDPR/compliance violation.

Recommendation: Redact or omit PII from log messages:

console.error(`Failed to send lease reminder email (leaseId: ${lease.id}):`, err);
console.error(`Failed to send lease reminder SMS (leaseId: ${lease.id}):`, err);

2. CPU Limit Too Low for Cron Workload (deploy/system-wrangler.toml, Line ~88)

[limits]
cpu_ms = 50

The 50ms CPU limit applies globally, including to the scheduled handler. The cron iterates over 3 notification windows × N leases, each requiring multiple DB round-trips and potentially HTTP calls to SendGrid/Twilio. A property portfolio with even a modest number of expiring leases will hit this limit and be silently terminated mid-run, causing missed notifications with no observable error.

Recommendation: Raise the limit or move it to a non-cron-applicable block. Per Cloudflare's documentation, scheduled handlers are subject to CPU limits. A value of cpu_ms = 50 is appropriate only for very lightweight request handlers.


🟠 Medium Priority Issues

3. PII Exposed in API Response (server/routes/leases.ts, Lines ~17–30)

GET /api/leases/expiring returns tenantEmail and tenantPhone in the JSON payload:

tenantEmail: lease.tenantEmail,
tenantPhone: lease.tenantPhone,

While this endpoint is authenticated, any property manager role can retrieve contact details for all tenants with expiring leases. Depending on your RBAC model, this may over-expose PII. The frontend ExpiringLeases component does not display email or phone — they are fetched but unused.

Recommendation: Either omit these fields from the response unless required by consumers, or implement field-level permissions.

4. getTasksByRelation Lacks Tenant Isolation (server/storage/system.ts)

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),
    ));
}

Unlike every other task query in SystemStorage (e.g., getTasks(tenantId)), this method has no tenant filter. The cron's deduplication logic relies on this method. While UUID collision between tenants is highly improbable, this is architecturally inconsistent and could behave incorrectly in multi-tenant edge cases.

Recommendation: Add an optional tenantId parameter:

async getTasksByRelation(relatedTo: string, relatedId: string, tenantId?: string) {
  const conditions = [
    eq(schema.tasks.relatedTo, relatedTo),
    eq(schema.tasks.relatedId, relatedId),
  ];
  if (tenantId) conditions.push(eq(schema.tasks.tenantId, tenantId));
  return this.db.select().from(schema.tasks).where(and(...conditions));
}

Then pass property.tenantId in the cron's dedup call.

5. Staging Environment Shares Production KV Namespace ID (deploy/system-wrangler.toml)

[env.staging.kv_namespaces]
binding = "FINANCE_KV"
id = "517b63be4d7144c197b5bdf851f12041"   # ← same as production

The staging environment uses the same KV namespace ID as production. If staging's DATABASE_URL secret also points to the production database, the staging cron (firing daily at 9 AM UTC) will send real notifications to real tenants from the staging worker.

Recommendation: Confirm that DATABASE_URL in the staging environment points to a staging database, and use a dedicated staging KV namespace ID.


🟡 Low Priority Issues

6. Cloudflare Account ID Committed to Repository (deploy/system-wrangler.toml, Line 18)

account_id = "0bc21e3a5a9de1a4cc843be9c3e98121"

Cloudflare does not treat account_id as a secret, but committing it to a (potentially public) repository makes it trivially discoverable. Combined with a stolen API token, it reduces the number of steps an attacker needs.

Recommendation: Move account_id to a CI/CD environment variable (CLOUDFLARE_ACCOUNT_ID) and reference it via wrangler deploy --account-id $CLOUDFLARE_ACCOUNT_ID rather than embedding it in the TOML.

7. No Retry or Dead-Letter Mechanism for Failed Notifications

Email and SMS failures are caught and logged, but there is no retry. If SendGrid or Twilio is temporarily unavailable during the cron window, the task is still created (preventing re-notification on the next run), but the tenant never receives their email/SMS.

Recommendation: Consider tracking notification delivery state in the task's metadata field, or use a separate retry queue. At minimum, document this known gap.

8. Cross-Tenant Cron Scope (Informational)

processLeaseExpirations calls storage.getExpiringLeases(window.days) without a tenantId, so it reads active leases across all tenants in the database. This appears intentional for a system-level cron, but it is not documented. Tasks are created with the correct property.tenantId, so there is no cross-tenant write risk — only read.

Recommendation: Add a comment clarifying this is intentional system-wide behavior, distinguishing it from the per-tenant API endpoint.


Breaking API Changes

None. The new GET /api/leases/expiring endpoint is purely additive. The getExpiringLeases storage method signature change (tenantId?: string added as an optional second param) is backward-compatible.


Summary

Category Result
Security implications ⚠️ PII in logs, over-exposed PII in API response, missing tenant filter in dedup
Credential exposure ✅ No hardcoded secrets; minor: account_id in TOML
Supply chain ✅ No new dependencies
Breaking changes ✅ None
Cron reliability ⚠️ 50ms CPU cap likely insufficient; no retry for notification failures

🧠 Learnings used
Learnt from: CR
Repo: chittyapps/chittyfinance PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-07T14:48:32.602Z
Learning: Keep PRs small and focused; include summary, rationale, and screenshots for UI changes; link related issues; document env/DB changes and run `npm run db:push` when schema updates
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@claude
Copy link
Copy Markdown

claude bot commented Mar 26, 2026

PR Review: feat: lease expiration notifications via cron + API

Overall, this is a well-structured implementation of the Phase 4 lease expiration feature. The integration points (cron handler, storage methods, API route, frontend widget, action queue) all fit cleanly into existing patterns. A few issues need attention before merge.


🐛 Bug — Email/SMS sent 3× for near-expiring leases (must fix)

server/lib/lease-expiration.ts iterates over NOTIFICATION_WINDOWS in order: 90 → 60 → 30 days. A lease with 25 days remaining matches all three windows in a single cron run. On the first execution, it creates 3 tasks and sends 3 emails + 3 SMS messages to the tenant.

The dedup check (alreadyNotified) only prevents re-firing the same window on a future day — it doesn't prevent three simultaneous notifications from the same run.

Fix: process only the most specific (smallest) window that matches each lease. The simplest approach is to query once for 90-day expiries, sort ascending by daysRemaining, and assign each lease to exactly one bucket:

// In processLeaseExpirations — fetch once, classify in memory
const allExpiring = await storage.getExpiringLeases(90);
for (const { lease, unit, property } of allExpiring) {
  const days = Math.ceil((new Date(lease.endDate).getTime() - Date.now()) / 86_400_000);
  const window = NOTIFICATION_WINDOWS.find((w) => days <= w.days); // first match = smallest window
  if (!window) continue;
  // ... rest of logic unchanged
}

🐛 N+1 queries in cron deduplication (should fix)

getTasksByRelation is called inside a nested loop (3 windows × N leases), resulting in up to 3N round-trips per cron invocation. At scale this will be slow.

Pre-fetch all relatedTo='lease' tasks for the relevant property once before the loop, or batch the lookup:

const allLeaseTasks = await storage.getTasksByRelation('lease');
// then filter in memory: allLeaseTasks.filter(t => t.relatedId === lease.id)

⚠️ PII over-exposure in API response

GET /api/leases/expiring returns tenantEmail and tenantPhone but neither field is rendered by ExpiringLeases.tsx or used anywhere in the frontend. Exposing contact info to the browser unnecessarily increases PII surface area.

Consider omitting those fields from the route response, or adding a separate internal-only storage method for the cron path that includes contact info.


⚠️ Missing index on leases.endDate

getExpiringLeases filters on (status = 'active' AND endDate BETWEEN now AND cutoff). The status index exists, but PostgreSQL will need to scan all active leases to apply the date filter. As the lease table grows, add a compound index:

// database/system.schema.ts
endDateStatusIdx: index('leases_end_date_status_idx').on(table.endDate, table.status),

🔧 Redundant import in Dashboard.tsx

useExpiringLeases is imported at the Dashboard level but never called there — the ExpiringLeases component manages its own data fetch. TanStack Query will deduplicate the network request, so this is harmless, but the unused import should be removed.

-import { usePortfolioSummary, useExpiringLeases } from '@/hooks/use-property';
+import { usePortfolioSummary } from '@/hooks/use-property';

🔧 Inline IIFE in PropertyDetailPanel.tsx

{lease ? (() => {
  const days = Math.ceil(...);
  return <Badge ...>{...}</Badge>;
})() : '—'}

Extract this to a helper at the top of the file (alongside urgencyBadge in ExpiringLeases.tsx) for readability. The inline IIFE pattern is harder to read and can't be independently tested.


🔧 window variable shadows browser global

In lease-expiration.ts:

for (const window of NOTIFICATION_WINDOWS) {

window shadows the browser/Workers global. Rename to notifWindow or win to avoid confusion, even though Workers don't expose a meaningful window object.


✅ What's done well

  • Cron handler correctly uses ctx.waitUntil so the Worker doesn't terminate before async work completes.
  • Task deduplication approach (matching on relatedTo + relatedId + title) is clean and doesn't require a new schema column.
  • useExpiringLeases hook correctly sets staleTime: 10 * 60 * 1000 to avoid hammering the API on every render cycle.
  • Action queue integration groups by urgency tier with correct severity mapping.
  • days validation in the route (1–365) is good defensive practice.
  • SendGrid/Twilio clients are correctly guarded (if (sendgrid && lease.tenantEmail)) so missing env vars fail silently in dev.
  • Wrangler cron added to all 4 TOML blocks (top-level + dev/staging/production) — no environment will be missed.

Summary: The email/SMS triple-send bug is the only blocker. The N+1 query and PII exposure are worth fixing before this sees production traffic. Everything else is polish.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
client/src/components/property/PropertyDetailPanel.tsx (1)

281-290: Consider extracting the shared urgency badge logic to reduce duplication.

The badge variant calculation (days <= 30 → destructive, days <= 60 → default, else secondary) is duplicated here and in ExpiringLeases.tsx (the urgencyBadge function at lines 9-14). Extracting this to a shared utility would ensure consistent thresholds across components.

Additionally, the magic number 86_400_000 (milliseconds per day) could be a named constant.

♻️ Suggested refactor

Create a shared utility (e.g., client/src/lib/lease-utils.ts):

const MS_PER_DAY = 24 * 60 * 60 * 1000;

export function getDaysRemaining(endDate: string | Date): number {
  return Math.ceil((new Date(endDate).getTime() - Date.now()) / MS_PER_DAY);
}

export function getLeaseUrgencyVariant(daysRemaining: number): 'destructive' | 'default' | 'secondary' {
  if (daysRemaining <= 30) return 'destructive';
  if (daysRemaining <= 60) return 'default';
  return 'secondary';
}

Then use it in both components:

-{lease ? (() => {
-  const days = Math.ceil((new Date(lease.endDate).getTime() - Date.now()) / 86_400_000);
-  return (
-    <Badge variant={days <= 30 ? 'destructive' : days <= 60 ? 'default' : 'secondary'}>
-      {new Date(lease.endDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' })}
-    </Badge>
-  );
-})() : '\u2014'}
+{lease ? (() => {
+  const days = getDaysRemaining(lease.endDate);
+  return (
+    <Badge variant={getLeaseUrgencyVariant(days)}>
+      {new Date(lease.endDate).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' })}
+    </Badge>
+  );
+})() : '\u2014'}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/components/property/PropertyDetailPanel.tsx` around lines 281 -
290, Extract the duplicated badge/urgency logic into a shared utility (e.g.,
create client/src/lib/lease-utils.ts) that defines a named MS_PER_DAY constant
and exports two helpers: getDaysRemaining(endDate: string | Date) which computes
Math.ceil((new Date(endDate).getTime() - Date.now()) / MS_PER_DAY), and
getLeaseUrgencyVariant(daysRemaining: number) which returns 'destructive' for
days <= 30, 'default' for days <= 60, otherwise 'secondary'; then replace the
inline calculation in PropertyDetailPanel (the anonymous function rendering the
Badge) and the existing urgencyBadge in ExpiringLeases.tsx to call
getDaysRemaining and getLeaseUrgencyVariant so both components use the single
source of truth for thresholds and the MS_PER_DAY constant.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/storage/system.ts`:
- Around line 378-388: getTasksByRelation currently queries schema.tasks without
tenant isolation which allows cross-tenant matches; change its signature to
require tenantId and add a where clause filtering schema.tasks.tenantId =
tenantId, then update the caller in lease-expiration (the call that passes
property.tenantId) to supply that tenantId so dedupe uses tenant-scoped results;
likewise audit and fix getTask (the getTask(id) method) to include tenantId
filtering and update any API routes that call getTask to pass/verify tenantId to
prevent cross-tenant access.

---

Nitpick comments:
In `@client/src/components/property/PropertyDetailPanel.tsx`:
- Around line 281-290: Extract the duplicated badge/urgency logic into a shared
utility (e.g., create client/src/lib/lease-utils.ts) that defines a named
MS_PER_DAY constant and exports two helpers: getDaysRemaining(endDate: string |
Date) which computes Math.ceil((new Date(endDate).getTime() - Date.now()) /
MS_PER_DAY), and getLeaseUrgencyVariant(daysRemaining: number) which returns
'destructive' for days <= 30, 'default' for days <= 60, otherwise 'secondary';
then replace the inline calculation in PropertyDetailPanel (the anonymous
function rendering the Badge) and the existing urgencyBadge in
ExpiringLeases.tsx to call getDaysRemaining and getLeaseUrgencyVariant so both
components use the single source of truth for thresholds and the MS_PER_DAY
constant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 78f8f0ad-f696-48e3-8334-65a1700e702f

📥 Commits

Reviewing files that changed from the base of the PR and between 8bfa110 and bbf400a.

📒 Files selected for processing (4)
  • client/src/components/property/PropertyDetailPanel.tsx
  • client/src/hooks/use-action-queue.ts
  • server/routes/leases.ts
  • server/storage/system.ts

@chitcommit chitcommit enabled auto-merge (squash) March 26, 2026 23:36
… removal

- 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 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 26, 2026 23:43
@github-actions
Copy link
Copy Markdown
Contributor

@coderabbitai review

Please evaluate:

  • Security implications
  • Credential exposure risk
  • Dependency supply chain concerns
  • Breaking API changes

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 26, 2026

The security review for PR #70 has been completed. Here is a consolidated summary across the four requested dimensions:


🔒 Security Implications

Severity Issue
🔴 High PII in logstenantEmail and tenantPhone are interpolated directly into console.error messages in server/lib/lease-expiration.ts (lines ~85–96). These may be retained by Cloudflare's tail consumer and could be a GDPR/compliance violation. Replace with leaseId in log messages.
🟠 Medium Over-exposed PII in API responseGET /api/leases/expiring returns tenantEmail and tenantPhone even though the ExpiringLeases frontend component never renders them. Omit these fields unless a consumer explicitly requires them.
🟠 Medium Missing tenant isolation in getTasksByRelation — Unlike all other SystemStorage task queries, this deduplication method has no tenantId filter. Add an optional tenantId parameter and pass property.tenantId from the cron.
🟡 Low Staging cron may fire against production data — The staging environment shares the same KV namespace ID as production. If DATABASE_URL in staging also points to production, the daily 9 AM UTC cron will send real notifications from staging. Confirm staging uses an isolated database and dedicated KV namespace.

🔑 Credential Exposure Risk

  • No hardcoded secrets. All credentials (SENDGRID_API_KEY, TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN, etc.) are read exclusively from the Cloudflare Worker env object.
  • 🟡 account_id committed to repo (deploy/system-wrangler.toml, line 18). While Cloudflare does not treat this as a secret, it reduces attacker effort if a token is ever compromised. Move it to a CI/CD environment variable (CLOUDFLARE_ACCOUNT_ID).

📦 Dependency Supply Chain

  • Zero new npm dependencies introduced. SendGridClient and TwilioClient are existing internal abstractions — no third-party packages were added to package.json.

🔁 Breaking API Changes

  • None. GET /api/leases/expiring is a purely additive endpoint. The getExpiringLeases(withinDays, tenantId?) storage method uses an optional second parameter, preserving backward compatibility.

⚙️ Reliability Concern (Bonus)

  • 🔴 50ms CPU limit ([limits] cpu_ms = 50 in deploy/system-wrangler.toml) applies to the scheduled handler. The cron iterates 3 windows × N leases with DB round-trips and external HTTP calls — this will silently terminate mid-run on any non-trivial portfolio. Raise this limit for the scheduled worker.
  • 🟡 No retry for failed notifications — When SendGrid/Twilio is temporarily unavailable, the task is still created (blocking future dedup), but the tenant never receives the notification. Consider tracking delivery state in metadata.

🧠 Learnings used
Learnt from: CR
Repo: chittyapps/chittyfinance PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-07T14:48:32.602Z
Learning: Keep PRs small and focused; include summary, rationale, and screenshots for UI changes; link related issues; document env/DB changes and run `npm run db:push` when schema updates

Learnt from: CR
Repo: chittyapps/chittyfinance PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-03-26T05:02:40.635Z
Learning: Applies to server/routes/properties.ts : Lease management endpoints must support rent roll queries showing tenant status, lease dates, and payment tracking
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@claude
Copy link
Copy Markdown

claude bot commented Mar 26, 2026

Code Review — PR #70: Lease Expiration Notifications

Overall this is well-structured work. The non-overlapping window design, dedup-by-title pattern, and graceful degradation when SendGrid/Twilio aren't configured are all solid choices. A few things to address before merging:


Bugs

1. Unused import in Dashboard.tsx

useExpiringLeases is imported but never called in the dashboard component — only ExpiringLeases (which internally calls the hook) is rendered.

- import { usePortfolioSummary, useExpiringLeases } from '@/hooks/use-property';
+ import { usePortfolioSummary } from '@/hooks/use-property';

2. Unhandled rejection in scheduled handler (server/worker.ts)

processLeaseExpirations throws if DATABASE_URL is missing. The scheduled handler has no try-catch, which means a misconfigured binding will produce an unhandled promise rejection in the Worker runtime.

async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {
  try {
    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`);
    }
  } catch (err) {
    console.error('[cron:lease-expiration] fatal error:', err);
  }
},

Code Quality

3. urgencyVariant logic duplicated across two files

ExpiringLeases.tsx (urgencyVariant function) and PropertyDetailPanel.tsx (LeaseEndBadge inline logic) both implement the same 30/60/90-day tier mapping. Extract to a shared utility (e.g., client/src/lib/lease-utils.ts) so a threshold change doesn't require two edits.

4. N+1 query pattern in processLeaseExpirations

For every expiring lease, there's a separate getTasksByRelation call:

for (const { lease, unit, property } of expiring) {
  const existingTasks = await storage.getTasksByRelation('lease', lease.id);  // N queries

For small portfolios this is fine, but it's worth noting. A single query fetching all relatedTo='lease' tasks for the relevant lease IDs up front would be more efficient:

const leaseIds = expiring.map(({ lease }) => lease.id);
const allExistingTasks = await storage.getTasksByRelationBatch('lease', leaseIds);
// then filter locally per lease

5. ctx.waitUntil pattern for Cloudflare scheduled handlers

While not strictly required (the scheduled handler awaits completion), the idiomatic CF Workers pattern is to wrap long-running work in ctx.waitUntil() to ensure it completes even if the isolate is recycled early:

async scheduled(controller, env, ctx) {
  ctx.waitUntil(processLeaseExpirations(env).then(stats => {
    console.log('[cron:lease-expiration] complete:', JSON.stringify(stats));
  }).catch(err => {
    console.error('[cron:lease-expiration] fatal error:', err);
  }));
},

Minor Notes

  • PropertyDetailPanel.tsx LeaseEndBadge: The badge renders on all active leases (not just expiring ones), so a lease expiring in 2 years gets a secondary badge. This is probably intentional (always-visible end date), but worth confirming it won't feel noisy to users.

  • Dev cron fires at 9 AM UTC: [env.dev.triggers] includes the same cron. Might cause unexpected side effects during dev. Consider a different frequency or omitting it from dev.

  • Task dedup is TOCTOU-susceptible: The read-then-write dedup pattern has a theoretical race if cron overlaps itself (CF prevents this, but worth a comment). A database-level unique constraint on (relatedId, relatedTo, title) would be more robust.


Summary

Blocking Unused import, missing try-catch in scheduled handler
Non-blocking Shared urgency util, N+1 in cron, ctx.waitUntil pattern

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/lib/lease-expiration.ts`:
- Around line 95-113: The current catch blocks in lease-expiration build
messages that include PII (lease.tenantEmail and lease.tenantPhone) and push
them to stats.errors and console.error; change both places (the email catch and
the sms catch around twilio.sendSms and SMS_TEMPLATES) to redact or omit
tenantEmail/tenantPhone from the msg: compose messages that reference lease.id
and the error text only (e.g., "Email/SMS for lease <lease.id>: <err.message>")
and update stats.errors.push(...) and console.error(...) to use the scrubbed
message so no raw PII is logged or stored.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fd316276-caca-4d03-98f6-ee8723c6176f

📥 Commits

Reviewing files that changed from the base of the PR and between bbf400a and b723396.

📒 Files selected for processing (8)
  • client/src/components/property/ExpiringLeases.tsx
  • client/src/components/property/PropertyDetailPanel.tsx
  • client/src/hooks/use-action-queue.ts
  • client/src/hooks/use-property.ts
  • server/lib/lease-expiration.ts
  • server/routes/leases.ts
  • server/storage/system.ts
  • server/worker.ts
✅ Files skipped from review due to trivial changes (2)
  • client/src/components/property/PropertyDetailPanel.tsx
  • client/src/components/property/ExpiringLeases.tsx
🚧 Files skipped from review as they are similar to previous changes (5)
  • client/src/hooks/use-property.ts
  • server/worker.ts
  • server/routes/leases.ts
  • client/src/hooks/use-action-queue.ts
  • server/storage/system.ts

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@chitcommit chitcommit merged commit ffe1dab into main Mar 27, 2026
25 checks passed
@chitcommit chitcommit deleted the feat/lease-expiration-notifications branch March 27, 2026 21:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants