Auto-Close Scheduler
The Auto-Close Scheduler is a background job that automatically closes stale leads that have had no activity for a configurable period. This prevents the queue from becoming cluttered with abandoned leads and helps maintain accurate metrics.
Overview
The ExternalLeadsAutoCloseScheduler is a Quartz.NET scheduled job that runs at regular intervals to identify and close leads meeting inactivity criteria. The system is designed to balance automation with preventing premature closure of valid leads.
Key Features
- Regular Execution: Runs every 5 minutes during business hours (9 AM - 8 PM)
- Smart Filtering: Excludes leads with active statuses (SV Fixed, Call Progress)
- Audited Closure: All auto-close actions are logged with timestamps and reasons
- Configurable Threshold: Age threshold can be adjusted (typically 30 days)
- Business Hours Only: Respects business operating hours to align with team schedules
Job Configuration
Cron Expression Details
Cron: 0 0/5 9-20 * * ?
Field Breakdown:
┌─────────────┬─────────────────────────────────────┐
│ Field │ Value & Meaning │
├─────────────┼─────────────────────────────────────┤
│ Seconds │ 0 (at 00 seconds) │
│ Minutes │ 0/5 (every 5 minutes) │
│ Hours │ 9-20 (9 AM through 8 PM) │
│ Day of Month│ * (every day) │
│ Month │ * (every month) │
│ Day of Week │ ? (no specific day) │
└─────────────┴─────────────────────────────────────┘
Executions per day: 144 times (12 hours × 12 executions/hour)
First run: 9:00 AM, Last run: 8:00 PMAuto-Close Decision Logic
Lead Age Calculation
The system calculates lead age using the most recent activity date:
-- Determine age for auto-close consideration
DECLARE @LeadAgeThresholdDays INT = 30;
DECLARE @CutoffDate DATETIME = DATEADD(DAY, -@LeadAgeThresholdDays, GETDATE());
SELECT
LeadId,
Mobile,
Name,
Status,
CreatedDate,
ModifiedDate,
-- Use the more recent of created or modified date
CASE
WHEN ModifiedDate > CreatedDate THEN ModifiedDate
ELSE CreatedDate
END as LastActivityDate,
DATEDIFF(DAY,
CASE
WHEN ModifiedDate > CreatedDate THEN ModifiedDate
ELSE CreatedDate
END,
GETDATE()) as DaysOld
FROM ExternalLeads
WHERE IsActive = 1
AND CASE
WHEN ModifiedDate > CreatedDate THEN ModifiedDate
ELSE CreatedDate
END < @CutoffDate
AND Status NOT IN ('SV_FIXED', 'CP')Protected Lead Statuses
Certain lead statuses are NEVER auto-closed, regardless of age:
| Status | Reason | Max Age | Action |
|---|---|---|---|
| SV Fixed | Successful conversion - permanent | N/A | Never auto-close |
| Call Progress | Active engagement ongoing | N/A | Never auto-close |
| Pending AI Call | Queued for automated calling | 7 days | Auto-close after 7 days + failed attempts |
| On Hold | Manager-requested hold | Variable | Check hold expiry date |
| Pending Follow-up | Follow-up scheduled | Until after review date | Auto-close only if follow-up also overdue |
Scheduler Timeline & Execution Flow
Execution Process
Auto-Close Audit Record
Every auto-close action is logged for compliance and history:
Audit Record Example
AuditId: 12547
LeadId: 98765
LeadName: Rajesh Kumar
LeadMobile: 98765-43210
PreviousStatus: NO_RESPONSE
NewStatus: AUTO_CLOSED
CloseReason: No activity for 30+ days. Status: NO_RESPONSE. Last contact: 2026-02-07
DaysActive: 42
LastActivityDate: 2026-02-07 14:30:00
AutoClosedDate: 2026-03-21 09:05:00
SchedulerVersion: v2.1.0
LeadsProcessedBatch: 23Configuration & Thresholds
The scheduler uses configurable parameters that can be adjusted without code changes:
public class AutoCloseSchedulerConfig
{
// Days of inactivity before auto-close
public int LeadAgeThresholdDays { get; set; } = 30;
// Minimum age for Pending_AI_Call (failed auto-calls)
public int AICallFailureThresholdDays { get; set; } = 7;
// Business hours (24-hour format)
public TimeOnly JobStartHour { get; set; } = new TimeOnly(9, 0); // 9 AM
public TimeOnly JobEndHour { get; set; } = new TimeOnly(20, 0); // 8 PM
// Batch processing to avoid overwhelming database
public int MaxLeadsPerExecution { get; set; } = 100;
// Prevent concurrent executions
public bool PreventConcurrentExecutions { get; set; } = true;
// Notification settings
public bool NotifyTeleCallers { get; set; } = true;
public bool NotifyManagers { get; set; } = true;
}Error Handling & Resilience
public async Task Execute(IJobExecutionContext context)
{
try
{
// Check if already running (concurrency guard)
if (_isExecuting)
{
context.JobDetail.JobDataMap.Put("Status", "SKIPPED_CONCURRENT");
return;
}
_isExecuting = true;
var staleLeads = FindStaleLeads();
if (staleLeads.Count == 0)
{
context.JobDetail.JobDataMap.Put("Status", "NO_STALE_LEADS");
return;
}
using (var transaction = _db.BeginTransaction())
{
try
{
foreach (var lead in staleLeads)
{
lead.Status = "AUTO_CLOSED";
lead.IsActive = false;
lead.ModifiedDate = DateTime.Now;
_db.ExternalLeads.Update(lead);
LogAutoClose(lead, "Age threshold exceeded");
}
await _db.SaveChangesAsync();
transaction.Commit();
context.JobDetail.JobDataMap.Put("Status", "COMPLETED");
context.JobDetail.JobDataMap.Put("LeadsProcessed", staleLeads.Count);
}
catch (Exception ex)
{
transaction.Rollback();
_logger.LogError($"Auto-close transaction failed: {ex.Message}");
context.JobDetail.JobDataMap.Put("Status", "ERROR");
throw;
}
}
}
finally
{
_isExecuting = false;
}
}Monitoring & Metrics
Track the scheduler's performance via these metrics:
| Metric | Alert Threshold | Action |
|---|---|---|
| Execution Duration | > 2 minutes | Investigate query performance |
| Leads Processed Per Run | < 5 (unusual) | Check filtering logic |
| Failed Transactions | > 0 | Immediate escalation |
| Audit Log Size | > 10K/week | Archive old logs |
| Unintended Closures | > 0.5% | Review filtering rules |
Related Documentation
- Follow-up Workflow: See how open follow-ups prevent auto-close
- AI Lead Posting: Understand Pending_AI_Call status and auto-close thresholds
- Manager Review: View auto-closed metrics in performance dashboards
- Database Tables: Complete schema for ExternalLeads and AutoCloseAuditLog