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
18 changes: 18 additions & 0 deletions backend/campaigns/migrations/0011_campaignlead_last_sent_at.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.0.14 on 2026-07-11 18:13

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('campaigns', '0010_custom_mailbox_security_updates'),
]

operations = [
migrations.AddField(
model_name='campaignlead',
name='last_sent_at',
field=models.DateTimeField(blank=True, help_text='Timestamp of the most recent message/call actually sent to the lead on this campaign.', null=True),
),
]
4 changes: 4 additions & 0 deletions backend/campaigns/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ class CampaignLead(TenantModel):
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='ENROLLED')
next_execution_time = models.DateTimeField(null=True, blank=True)
last_sent_message_id = models.CharField(max_length=255, null=True, blank=True)
last_sent_at = models.DateTimeField(
null=True, blank=True,
help_text='Timestamp of the most recent message/call actually sent to the lead on this campaign.',
)
last_opened_at = models.DateTimeField(null=True, blank=True)
last_clicked_at = models.DateTimeField(null=True, blank=True)
last_replied_at = models.DateTimeField(null=True, blank=True)
Expand Down
13 changes: 11 additions & 2 deletions backend/campaigns/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,9 @@ def _execute_sms_step(clead, step, now=None):
clead.save(update_fields=['next_execution_time'])
return

clead.last_sent_at = now
clead.save(update_fields=['last_sent_at'])

_advance_to_next_step(clead, step, now=now)


Expand All @@ -499,9 +502,14 @@ def _execute_call_step(clead, step, now=None):
try:
sid = initiate_call(phone, call_script or None)
logger.info(f"Call initiated to {clead.lead.email} ({phone}) | sid={sid}")
clead.last_sent_at = now
clead.save(update_fields=['last_sent_at'])
except RuntimeError:

# No Twilio credentials configured — this still counts as the lead
# being contacted, just via a manual task instead of an automated call.
logger.info(f"CALL step (manual) for {clead.lead.email} ({phone}): {call_script or 'No script'}")
clead.last_sent_at = now
clead.save(update_fields=['last_sent_at'])
except Exception as err:
logger.error(f"Call failed for {clead.lead.email}: {err}")

Expand Down Expand Up @@ -611,7 +619,8 @@ def send_email_step(campaign_lead_id, step_id):
else:
raise RuntimeError(f"Unsupported email provider: {account.provider}")
clead.last_sent_message_id = message_id
clead.save(update_fields=['last_sent_message_id'])
clead.last_sent_at = timezone.now()
clead.save(update_fields=['last_sent_message_id', 'last_sent_at'])
except Exception as send_err:
logger.error(f"Email send failed for {clead.lead.email}: {send_err}")
# Restore next_execution_time so the lead can be retried later.
Expand Down
9 changes: 9 additions & 0 deletions backend/leads/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ class Meta:

class LeadSerializer(serializers.ModelSerializer):
tags = serializers.SerializerMethodField()
# Sourced from the `last_contacted_at` annotation added by LeadViewSet.get_queryset.
# A SerializerMethodField (not a plain DateTimeField) because a bare Lead instance
# returned from create/update won't have this annotation and would raise
# AttributeError — this degrades gracefully to None instead, same pattern as `tags`.
last_contacted_at = serializers.SerializerMethodField()
# Write-only field: accept a list of Tag UUIDs to set on the lead.
tag_ids = serializers.ListField(
child=serializers.UUIDField(),
Expand All @@ -26,13 +31,17 @@ class Meta:
'id', 'email', 'first_name', 'last_name', 'company', 'phone',
'linkedin_url', 'custom_data', 'custom_variables',
'global_unsubscribe', 'score', 'tags', 'tag_ids', 'created_at',
'last_contacted_at',
]
read_only_fields = ['organization', 'score']

def get_tags(self, obj):
tags = Tag.objects.filter(tagged_leads__lead=obj)
return TagSerializer(tags, many=True).data

def get_last_contacted_at(self, obj):
return getattr(obj, 'last_contacted_at', None)

def _set_tags(self, lead, tag_ids):
"""Replace the lead's tags with the given list of Tag UUIDs."""
org = lead.organization
Expand Down
15 changes: 13 additions & 2 deletions backend/leads/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from django.db.models import Q
from django.db.models import F, Max, Q
from rest_framework import viewsets, parsers, status
from rest_framework.pagination import PageNumberPagination
from rest_framework.decorators import action
Expand Down Expand Up @@ -41,8 +41,12 @@ def get_queryset(self):
?created_before=YYYY-MM-DD — leads created on or before this date
?status=active|unsubscribed — filter by subscription status
?search=<text> — filter by name / email / company
?ordering=last_contacted_at | -last_contacted_at — sort by most recent
contact time (never-contacted leads last)
"""
qs = Lead.objects.filter(organization=self.request.user.organization)
qs = Lead.objects.filter(organization=self.request.user.organization).annotate(
last_contacted_at=Max('campaigns__last_sent_at')
)
params = self.request.query_params

# ── Tag filter ──────────────────────────────────────────────────────
Expand Down Expand Up @@ -78,6 +82,13 @@ def get_queryset(self):
| Q(company__icontains=search)
)

# ── Ordering ────────────────────────────────────────────────────────
ordering = params.get('ordering', '').strip()
if ordering == 'last_contacted_at':
qs = qs.order_by(F('last_contacted_at').asc(nulls_last=True))
elif ordering == '-last_contacted_at':
qs = qs.order_by(F('last_contacted_at').desc(nulls_last=True))

return qs.distinct()

def perform_create(self, serializer):
Expand Down
55 changes: 53 additions & 2 deletions frontend/leads.html
Original file line number Diff line number Diff line change
Expand Up @@ -365,11 +365,22 @@
<th>Tags</th>
<th>Score</th>
<th>Status</th>
<th>
<button
type="button"
id="sort-last-contacted"
class="btn btn-sm btn-link p-0 text-decoration-none text-reset fw-semibold d-inline-flex align-items-center gap-1"
aria-label="Sort by last contacted"
aria-sort="none">
Last Contacted
<i class="bi bi-arrow-down-up small" aria-hidden="true"></i>
</button>
</th>
</tr>
</thead>
<tbody id="leads-table-body">
<tr>
<td colspan="7" class="text-center py-4 text-muted">
<td colspan="8" class="text-center py-4 text-muted">
<div class="spinner-border spinner-border-sm text-primary me-2" role="status" aria-label="Loading leads"></div>
Loading leads...
</td>
Expand Down Expand Up @@ -455,6 +466,7 @@ <h5 class="modal-title fw-bold" id="importErrorsModalLabel">Import error log</h5
let allImportJobs = []; // import job history
let selectedTagIds = new Set(); // tags selected in the filter panel
let activeFilters = {}; // last applied filter state
let sortOrdering = null; // current 'ordering' param, e.g. '-last_contacted_at' | 'last_contacted_at' | null

// ─── Utilities ──────────────────────────────────────────────────────

Expand Down Expand Up @@ -548,7 +560,7 @@ <h5 class="modal-title fw-bold" id="importErrorsModalLabel">Import error log</h5
if (leads.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="7" class="text-center py-5">
<td colspan="8" class="text-center py-5">
<div class="empty-state">
<i class="bi bi-people display-4 text-secondary" aria-hidden="true"></i>
<h5 class="mt-3 mb-2">No leads found</h5>
Expand Down Expand Up @@ -581,6 +593,9 @@ <h5 class="mt-3 mb-2">No leads found</h5>
<td>${lead.global_unsubscribe
? '<span class="chip" style="background:#fee2e2;color:#b91c1c;">Unsubscribed</span>'
: '<span class="chip" style="background:#dcfce7;color:#166534;">Active</span>'}</td>
<td>${lead.last_contacted_at
? formatTimestamp(lead.last_contacted_at)
: '<span class="text-muted">Never</span>'}</td>
</tr>
`).join('');

Expand Down Expand Up @@ -705,6 +720,8 @@ <h5 class="mt-3 mb-2">No leads found</h5>
const statusEl = document.querySelector('input[name="filter-status"]:checked');
if (statusEl && statusEl.value) params.status = statusEl.value;

if (sortOrdering) params.ordering = sortOrdering;

return params;
}

Expand Down Expand Up @@ -778,6 +795,37 @@ <h5 class="mt-3 mb-2">No leads found</h5>
updateFilterIndicator({});
}

// ─── Sorting (Last Contacted column) ───────────────────────────────

function updateSortIcon() {
const btn = document.getElementById('sort-last-contacted');
const icon = btn.querySelector('i');
icon.className = 'bi small';
if (sortOrdering === '-last_contacted_at') {
icon.classList.add('bi-sort-down');
btn.setAttribute('aria-sort', 'descending');
} else if (sortOrdering === 'last_contacted_at') {
icon.classList.add('bi-sort-up');
btn.setAttribute('aria-sort', 'ascending');
} else {
icon.classList.add('bi-arrow-down-up');
btn.setAttribute('aria-sort', 'none');
}
}

function toggleLastContactedSort() {
// Cycle: unsorted → most recent first → least recent first → unsorted
if (sortOrdering === '-last_contacted_at') {
sortOrdering = 'last_contacted_at';
} else if (sortOrdering === 'last_contacted_at') {
sortOrdering = null;
} else {
sortOrdering = '-last_contacted_at';
}
updateSortIcon();
applyFilters();
}

// ─── Data loading ────────────────────────────────────────────────────

async function loadLeads() {
Expand Down Expand Up @@ -834,8 +882,11 @@ <h5 class="mt-3 mb-2">No leads found</h5>
});

document.getElementById('apply-filters-btn').addEventListener('click', applyFilters);
document.getElementById('sort-last-contacted').addEventListener('click', toggleLastContactedSort);
document.getElementById('clear-filters-btn').addEventListener('click', async () => {
clearFilters();
sortOrdering = null;
updateSortIcon();
try {
const leads = await fetchLeadsWithFilters({});
allLeads = leads;
Expand Down