diff --git a/backend/campaigns/migrations/0010_campaignlead_activity_timestamps.py b/backend/campaigns/migrations/0010_campaignlead_activity_timestamps.py new file mode 100644 index 0000000..5a91cab --- /dev/null +++ b/backend/campaigns/migrations/0010_campaignlead_activity_timestamps.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('campaigns', '0009_campaign_cached_counters'), + ('campaigns', '0009_campaignlead_bounce_metadata'), + ] + + operations = [ + migrations.AddField( + model_name='campaignlead', + name='last_bounced_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='campaignlead', + name='last_sent_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/backend/campaigns/models.py b/backend/campaigns/models.py index 161b5df..a047da6 100644 --- a/backend/campaigns/models.py +++ b/backend/campaigns/models.py @@ -104,9 +104,11 @@ 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) 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) + last_bounced_at = models.DateTimeField(null=True, blank=True) bounce_type = models.CharField(max_length=32, null=True, blank=True) bounce_code = models.CharField(max_length=64, null=True, blank=True) bounce_reason = models.TextField(null=True, blank=True) diff --git a/backend/campaigns/tasks.py b/backend/campaigns/tasks.py index de16c24..ed83349 100644 --- a/backend/campaigns/tasks.py +++ b/backend/campaigns/tasks.py @@ -1,13 +1,15 @@ -import logging from datetime -import timedelta from celery -import shared_task from django.conf -import settings as django_settings from django.utils -import timezone from .ai -import _apply_merge_tags, personalize_email from .gmail_service -import build_unsubscribe_url, check_for_replies, send_gmail from .sms_service -import send_sms, initiate_call from .models -import CampaignLead, SequenceStep from leads.models -import BlockedDomain, normalize_domain +import logging +from datetime import timedelta + +from celery import shared_task +from django.conf import settings as django_settings +from django.utils import timezone + +from .ai import _apply_merge_tags, personalize_email +from .gmail_service import build_unsubscribe_url, check_for_replies, send_gmail +from .sms_service import send_sms, initiate_call +from .models import CampaignLead, SequenceStep +from leads.models import BlockedDomain, normalize_domain import urllib.parse @@ -501,6 +503,7 @@ def send_email_step(campaign_lead_id, step_id): # ------------------------------------------- account = clead.campaign.connected_account + sent_at = timezone.now() if account: try: message_id = send_gmail( @@ -511,7 +514,8 @@ def send_email_step(campaign_lead_id, step_id): unsubscribe_url=build_unsubscribe_url(clead.lead), ) clead.last_sent_message_id = message_id - clead.save(update_fields=['last_sent_message_id']) + clead.last_sent_at = sent_at + clead.save(update_fields=['last_sent_message_id', 'last_sent_at']) logger.info(f"Gmail SENT to {clead.lead.email} | msg_id={message_id}") except Exception as gmail_err: logger.error(f"Gmail API send failed for {clead.lead.email}: {gmail_err}") @@ -520,6 +524,8 @@ def send_email_step(campaign_lead_id, step_id): clead.save(update_fields=['next_execution_time']) return else: + clead.last_sent_at = sent_at + clead.save(update_fields=['last_sent_at']) logger.info(f"Mock SENDING EMAIL to {clead.lead.email} | Subject: {subject}") _advance_to_next_step(clead, step) @@ -692,4 +698,4 @@ def poll_gmail_for_replies(): logger.info(f"Reply detected for {clead.lead.email} in campaign {clead.campaign.name}") _maybe_mark_campaign_completed(clead.campaign) - return f"Detected {total_replies} new replies." \ No newline at end of file + return f"Detected {total_replies} new replies." diff --git a/backend/campaigns/tests.py b/backend/campaigns/tests.py index 1d96075..01810a3 100644 --- a/backend/campaigns/tests.py +++ b/backend/campaigns/tests.py @@ -583,6 +583,42 @@ def test_send_email_step_does_not_advance_when_gmail_send_fails(self): self.assertIsNone(campaign_lead.last_sent_message_id) self.assertGreater(campaign_lead.next_execution_time, timezone.now()) + @patch('campaigns.tasks._advance_to_next_step') + def test_send_email_step_records_sent_timestamp_for_mock_delivery(self, mocked_advance): + campaign = Campaign.objects.create( + organization=self.organization, + name='Mock send flow', + status='ACTIVE', + ) + email_step = SequenceStep.objects.create( + organization=self.organization, + campaign=campaign, + step_order=1, + channel_type='EMAIL', + delay_minutes=0, + template_subject='Hello', + template_body='Hi there', + ) + lead = Lead.objects.create( + organization=self.organization, + email='mock-send@acme.test', + ) + campaign_lead = CampaignLead.objects.create( + organization=self.organization, + campaign=campaign, + lead=lead, + current_step=email_step, + status='ACTIVE', + next_execution_time=timezone.now() - timedelta(minutes=1), + ) + + send_email_step(campaign_lead.id, email_step.id) + + campaign_lead.refresh_from_db() + self.assertIsNotNone(campaign_lead.last_sent_at) + self.assertIsNone(campaign_lead.last_sent_message_id) + mocked_advance.assert_called_once() + def test_condition_reply_step_stops_sequence_when_reply_detected(self): campaign = Campaign.objects.create( organization=self.organization, @@ -1257,6 +1293,7 @@ def test_email_webhook_persists_bounce_metadata(self): self.assertEqual(campaign_lead.bounce_type, 'soft') self.assertEqual(campaign_lead.bounce_code, 'mailbox_full') self.assertEqual(campaign_lead.bounce_reason, 'Mailbox full') + self.assertIsNotNone(campaign_lead.last_bounced_at) def test_dashboard_analytics_isolates_data_by_tenant(self): org2 = Organization.objects.create(name='Other Corp') diff --git a/backend/campaigns/views.py b/backend/campaigns/views.py index 2a11bf0..1a24c83 100644 --- a/backend/campaigns/views.py +++ b/backend/campaigns/views.py @@ -286,10 +286,12 @@ def leads(self, request, pk=None): 'email': cl.lead.email, 'status': cl.status, 'current_step': cl.current_step.step_order if cl.current_step else None, - 'last_sent_at': cl.last_sent_message_id, + 'last_sent_at': cl.last_sent_at.isoformat() if cl.last_sent_at else None, + 'last_sent_message_id': cl.last_sent_message_id, 'last_opened_at': cl.last_opened_at.isoformat() if cl.last_opened_at else None, 'last_clicked_at': cl.last_clicked_at.isoformat() if cl.last_clicked_at else None, 'last_replied_at': cl.last_replied_at.isoformat() if cl.last_replied_at else None, + 'last_bounced_at': cl.last_bounced_at.isoformat() if cl.last_bounced_at else None, 'next_execution': cl.next_execution_time.isoformat() if cl.next_execution_time else None, }) @@ -436,6 +438,7 @@ def post(self, request, *args, **kwargs): for cl in cleads: if event_type == 'bounce': cl.status = 'BOUNCED' + cl.last_bounced_at = now if bounce_details['bounce_type']: cl.bounce_type = bounce_details['bounce_type'] if bounce_details['bounce_code']: @@ -447,6 +450,7 @@ def post(self, request, *args, **kwargs): 'bounce_type', 'bounce_code', 'bounce_reason', + 'last_bounced_at', ]) logger.info( 'Webhook bounce processed for email=%s type=%s code=%s reason=%s', @@ -820,4 +824,4 @@ def get(self, request, *args, **kwargs): # Original Destination par redirect karna decoded_dest = urllib.parse.unquote(dest_url) return HttpResponseRedirect(decoded_dest) -# ------------------------------------------ \ No newline at end of file +# ------------------------------------------ diff --git a/backend/leads/tests.py b/backend/leads/tests.py index c132c55..e17f0ce 100644 --- a/backend/leads/tests.py +++ b/backend/leads/tests.py @@ -1,7 +1,9 @@ from django.core.files.uploadedfile import SimpleUploadedFile +from datetime import timedelta from rest_framework import status from rest_framework.test import APITestCase +from campaigns.models import Campaign, CampaignLead from leads.models import BlockedDomain, Lead, Tag, LeadTag, LeadImportJob from leads.tasks import import_leads_from_csv from tenants.models import Organization @@ -461,3 +463,64 @@ def test_filter_does_not_leak_other_org_leads(self): resp = self._get() emails = {l['email'] for l in resp.data} self.assertNotIn('spy@example.com', emails) + + +class LeadTimelineTests(APITestCase): + def setUp(self): + self.org = Organization.objects.create(name='Timeline Org') + self.user = _make_user(self.org, email='timeline@example.com') + self.client.force_authenticate(self.user) + self.lead = _make_lead( + self.org, + 'timeline@example.com', + first_name='Taylor', + last_name='Lane', + company='Orbit Labs', + score=88, + ) + self.campaign = Campaign.objects.create( + organization=self.org, + name='Outbound 1', + status='ACTIVE', + ) + + base = self.lead.created_at + self.campaign_lead = CampaignLead.objects.create( + organization=self.org, + campaign=self.campaign, + lead=self.lead, + status='BOUNCED', + last_sent_message_id='msg-123', + last_sent_at=base + timedelta(minutes=5), + last_opened_at=base + timedelta(minutes=10), + last_clicked_at=base + timedelta(minutes=15), + last_replied_at=base + timedelta(minutes=20), + last_bounced_at=base + timedelta(minutes=25), + bounce_type='soft', + bounce_code='mailbox_full', + bounce_reason='Mailbox full', + ) + + def test_timeline_returns_chronological_event_history(self): + response = self.client.get(f'/api/v1/leads/{self.lead.id}/timeline/') + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['lead']['email'], 'timeline@example.com') + + events = response.data['events'] + self.assertEqual( + [event['type'] for event in events], + [ + 'lead_created', + 'campaign_enrolled', + 'email_sent', + 'email_opened', + 'link_clicked', + 'reply_received', + 'email_bounced', + ], + ) + self.assertFalse(events[2]['estimated']) + self.assertEqual(events[2]['campaign'], 'Outbound 1') + self.assertIn('msg-123', events[2]['description']) + self.assertIn('mailbox_full', events[-1]['description']) diff --git a/backend/leads/views.py b/backend/leads/views.py index 1d591a2..fa67597 100644 --- a/backend/leads/views.py +++ b/backend/leads/views.py @@ -3,6 +3,7 @@ from rest_framework.pagination import PageNumberPagination from rest_framework.decorators import action from rest_framework.response import Response +from campaigns.models import CampaignLead from users.permissions import IsOrgManager from .models import BlockedDomain, Lead, LeadImportJob, Tag, LeadTag from .serializers import BlockedDomainSerializer, LeadImportJobSerializer, LeadSerializer, TagSerializer @@ -85,7 +86,7 @@ def perform_create(self, serializer): @action(detail=False, methods=['delete'], url_path='delete-all') def delete_all(self, request): - deleted_count, _ = self.get_queryset().delete() + deleted_count, _ = Lead.objects.filter(organization=request.user.organization).delete() return Response( {"message": f"Successfully deleted {deleted_count} leads."}, status=status.HTTP_200_OK, @@ -152,6 +153,131 @@ def assign_tags(self, request, pk=None): updated_tags = Tag.objects.filter(tagged_leads__lead=lead) return Response(TagSerializer(updated_tags, many=True).data, status=status.HTTP_200_OK) + @action(detail=True, methods=['get']) + def timeline(self, request, pk=None): + lead = self.get_object() + events = [] + + def add_event(event_type, title, timestamp, description='', campaign=None, estimated=False, icon='bi-clock'): + if not timestamp: + return + events.append({ + 'type': event_type, + 'title': title, + 'timestamp': timestamp.isoformat(), + 'description': description, + 'campaign': campaign, + 'estimated': estimated, + 'icon': icon, + }) + + add_event( + 'lead_created', + 'Lead added', + lead.created_at, + 'The lead record was created in the workspace.', + icon='bi-person-plus', + ) + + campaign_leads = ( + CampaignLead.objects + .filter(lead=lead, organization=request.user.organization) + .select_related('campaign', 'current_step') + .order_by('created_at', 'updated_at') + ) + + for campaign_lead in campaign_leads: + campaign_name = campaign_lead.campaign.name + add_event( + 'campaign_enrolled', + f'Enrolled in {campaign_name}', + campaign_lead.created_at, + f'Lead joined campaign "{campaign_name}".', + campaign=campaign_name, + icon='bi-bullseye', + ) + + sent_timestamp = campaign_lead.last_sent_at or campaign_lead.created_at + sent_estimated = campaign_lead.last_sent_at is None + add_event( + 'email_sent', + f'Email sent in {campaign_name}', + sent_timestamp, + ( + f'Email message ID: {campaign_lead.last_sent_message_id}' + if campaign_lead.last_sent_message_id + else 'Email was sent from the campaign sequence.' + ) + (' Timestamp estimated from campaign enrollment.' if sent_estimated else ''), + campaign=campaign_name, + estimated=sent_estimated, + icon='bi-send', + ) + + add_event( + 'email_opened', + f'Email opened in {campaign_name}', + campaign_lead.last_opened_at, + 'The lead opened the campaign email.', + campaign=campaign_name, + icon='bi-envelope-open', + ) + + add_event( + 'link_clicked', + f'Link clicked in {campaign_name}', + campaign_lead.last_clicked_at, + 'The lead clicked a tracked link.', + campaign=campaign_name, + icon='bi-cursor', + ) + + add_event( + 'reply_received', + f'Reply received in {campaign_name}', + campaign_lead.last_replied_at, + 'A reply was detected for this campaign lead.', + campaign=campaign_name, + icon='bi-chat-dots', + ) + + add_event( + 'email_bounced', + f'Email bounced in {campaign_name}', + campaign_lead.last_bounced_at, + ( + ( + 'Bounce details: ' + + ', '.join( + part for part in [ + campaign_lead.bounce_type and f"type={campaign_lead.bounce_type}", + campaign_lead.bounce_code and f"code={campaign_lead.bounce_code}", + ] + if part + ) + ).strip() + + (f" Reason: {campaign_lead.bounce_reason}" if campaign_lead.bounce_reason else '') + ) + if (campaign_lead.bounce_type or campaign_lead.bounce_code or campaign_lead.bounce_reason) + else 'The email bounced.', + campaign=campaign_name, + icon='bi-exclamation-triangle', + ) + + events.sort(key=lambda item: item['timestamp']) + + return Response({ + 'lead': { + 'id': str(lead.id), + 'name': f"{lead.first_name or ''} {lead.last_name or ''}".strip() or lead.email, + 'email': lead.email, + 'company': lead.company, + 'score': lead.score, + 'global_unsubscribe': lead.global_unsubscribe, + 'created_at': lead.created_at.isoformat() if lead.created_at else None, + }, + 'events': events, + }, status=status.HTTP_200_OK) + class LeadImportJobViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = LeadImportJobSerializer diff --git a/frontend/campaign-builder.html b/frontend/campaign-builder.html index 25f873b..15f4cd1 100644 --- a/frontend/campaign-builder.html +++ b/frontend/campaign-builder.html @@ -1246,6 +1246,7 @@
| - + | Name | |||
|---|---|---|---|---|
|
- No leads found in your organization. - - - Go to Import Leads - +${leadTagFilter ? 'No leads match the selected tag filter.' : 'No leads found in your organization.'} + ${leadTagFilter ? ` + + ` : ` + + + Go to Import Leads + + `} |
||||