diff --git a/backend/campaigns/migrations/0011_campaignlead_last_sent_at.py b/backend/campaigns/migrations/0011_campaignlead_last_sent_at.py new file mode 100644 index 0000000..f4b9cd4 --- /dev/null +++ b/backend/campaigns/migrations/0011_campaignlead_last_sent_at.py @@ -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), + ), + ] diff --git a/backend/campaigns/models.py b/backend/campaigns/models.py index fcc1ef2..c9f32d5 100644 --- a/backend/campaigns/models.py +++ b/backend/campaigns/models.py @@ -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) diff --git a/backend/campaigns/tasks.py b/backend/campaigns/tasks.py index 032a81a..c67b3c8 100644 --- a/backend/campaigns/tasks.py +++ b/backend/campaigns/tasks.py @@ -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) @@ -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}") @@ -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. diff --git a/backend/leads/serializers.py b/backend/leads/serializers.py index 602ed08..1c2d949 100644 --- a/backend/leads/serializers.py +++ b/backend/leads/serializers.py @@ -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(), @@ -26,6 +31,7 @@ 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'] @@ -33,6 +39,9 @@ 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 diff --git a/backend/leads/views.py b/backend/leads/views.py index 1d591a2..0002b73 100644 --- a/backend/leads/views.py +++ b/backend/leads/views.py @@ -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 @@ -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= — 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 ────────────────────────────────────────────────────── @@ -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): diff --git a/frontend/leads.html b/frontend/leads.html index cda420d..ac80818 100644 --- a/frontend/leads.html +++ b/frontend/leads.html @@ -365,11 +365,22 @@ Tags Score Status + + + - +
Loading leads... @@ -455,6 +466,7 @@ Import error log - +
No leads found
@@ -581,6 +593,9 @@
No leads found
${lead.global_unsubscribe ? 'Unsubscribed' : 'Active'} + ${lead.last_contacted_at + ? formatTimestamp(lead.last_contacted_at) + : 'Never'} `).join(''); @@ -705,6 +720,8 @@
No leads found
const statusEl = document.querySelector('input[name="filter-status"]:checked'); if (statusEl && statusEl.value) params.status = statusEl.value; + if (sortOrdering) params.ordering = sortOrdering; + return params; } @@ -778,6 +795,37 @@
No leads found
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() { @@ -834,8 +882,11 @@
No leads found
}); 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;