diff --git a/services/supabase/email_sends/insert_email_send.py b/services/supabase/email_sends/insert_email_send.py new file mode 100644 index 00000000..7d813248 --- /dev/null +++ b/services/supabase/email_sends/insert_email_send.py @@ -0,0 +1,29 @@ +from postgrest.exceptions import APIError + +from services.supabase.client import supabase +from utils.error.handle_exceptions import handle_exceptions + + +@handle_exceptions(default_return_value=None, raise_on_error=False) +def insert_email_send(owner_id: int, owner_name: str, email_type: str): + try: + result = ( + supabase.table("email_sends") + .insert( + { + "owner_id": owner_id, + "owner_name": owner_name, + "email_type": email_type, + } + ) + .execute() + ) + + if result.data and len(result.data) > 0: + return True + + return False + except APIError as e: + if e.code == "23505": + return False + raise diff --git a/services/supabase/email_sends/test_insert_email_send.py b/services/supabase/email_sends/test_insert_email_send.py new file mode 100644 index 00000000..4152ebef --- /dev/null +++ b/services/supabase/email_sends/test_insert_email_send.py @@ -0,0 +1,96 @@ +from unittest.mock import MagicMock, patch + +import pytest +from postgrest.exceptions import APIError + +from services.supabase.email_sends.insert_email_send import insert_email_send + + +@pytest.fixture +def mock_supabase_client(): + with patch("services.supabase.email_sends.insert_email_send.supabase") as mock: + mock_table = MagicMock() + mock_insert = MagicMock() + mock_execute = MagicMock() + + mock.table.return_value = mock_table + mock_table.insert.return_value = mock_insert + mock_insert.execute.return_value = mock_execute + + yield mock, mock_execute + + +def test_insert_email_send_success(mock_supabase_client): + mock, mock_execute = mock_supabase_client + mock_execute.data = [ + { + "id": 1, + "owner_id": 12345, + "owner_name": "test-user", + "email_type": "uninstall", + "created_at": "2025-12-18T00:00:00", + } + ] + + result = insert_email_send( + owner_id=12345, owner_name="test-user", email_type="uninstall" + ) + + assert result is True + mock.table.assert_called_once_with("email_sends") + mock.table.return_value.insert.assert_called_once_with( + {"owner_id": 12345, "owner_name": "test-user", "email_type": "uninstall"} + ) + mock.table.return_value.insert.return_value.execute.assert_called_once() + + +def test_insert_email_send_duplicate(mock_supabase_client): + _, mock_execute = mock_supabase_client + mock_execute.data = [] + + result = insert_email_send( + owner_id=12345, owner_name="test-user", email_type="uninstall" + ) + + assert result is False + + +def test_insert_email_send_duplicate_key_error_returns_false(mock_supabase_client): + mock, _ = mock_supabase_client + api_error = APIError( + {"code": "23505", "message": "duplicate key value violates unique constraint"} + ) + mock.table.return_value.insert.return_value.execute.side_effect = api_error + + result = insert_email_send( + owner_id=12345, owner_name="test-user", email_type="uninstall" + ) + + assert result is False + + +def test_insert_email_send_exception_returns_none(mock_supabase_client): + """DB errors return None so the email dedup is skipped (only False = duplicate).""" + mock, _ = mock_supabase_client + mock.table.return_value.insert.return_value.execute.side_effect = Exception( + "Database error" + ) + + result = insert_email_send( + owner_id=12345, owner_name="test-user", email_type="uninstall" + ) + + assert result is None + + +def test_insert_email_send_postgrest_server_error_returns_none(mock_supabase_client): + """PostgREST 502/500 returns None so the email dedup is skipped.""" + mock, _ = mock_supabase_client + api_error = APIError({"code": "502", "message": "JSON could not be generated"}) + mock.table.return_value.insert.return_value.execute.side_effect = api_error + + result = insert_email_send( + owner_id=12345, owner_name="test-user", email_type="uninstall" + ) + + assert result is None diff --git a/services/supabase/email_sends/test_update_email_send.py b/services/supabase/email_sends/test_update_email_send.py new file mode 100644 index 00000000..d64f0214 --- /dev/null +++ b/services/supabase/email_sends/test_update_email_send.py @@ -0,0 +1,39 @@ +from unittest.mock import MagicMock, patch + +from services.supabase.email_sends.update_email_send import update_email_send + + +@patch("services.supabase.email_sends.update_email_send.supabase") +def test_update_email_send_sets_resend_email_id(mock_supabase): + mock_table = MagicMock() + mock_update = MagicMock() + mock_eq1 = MagicMock() + mock_eq2 = MagicMock() + + mock_supabase.table.return_value = mock_table + mock_table.update.return_value = mock_update + mock_update.eq.return_value = mock_eq1 + mock_eq1.eq.return_value = mock_eq2 + mock_eq2.execute.return_value = MagicMock() + + update_email_send( + owner_id=12345, email_type="uninstall", resend_email_id="re_abc123" + ) + + mock_supabase.table.assert_called_once_with("email_sends") + mock_table.update.assert_called_once_with({"resend_email_id": "re_abc123"}) + mock_update.eq.assert_called_once_with("owner_id", 12345) + mock_eq1.eq.assert_called_once_with("email_type", "uninstall") + mock_eq2.execute.assert_called_once() + + +@patch("services.supabase.email_sends.update_email_send.supabase") +def test_update_email_send_exception_returns_none(mock_supabase): + """DB errors return None without raising.""" + mock_supabase.table.side_effect = Exception("Database error") + + result = update_email_send( + owner_id=12345, email_type="uninstall", resend_email_id="re_abc123" + ) + + assert result is None diff --git a/services/supabase/email_sends/update_email_send.py b/services/supabase/email_sends/update_email_send.py new file mode 100644 index 00000000..c2327aac --- /dev/null +++ b/services/supabase/email_sends/update_email_send.py @@ -0,0 +1,9 @@ +from services.supabase.client import supabase +from utils.error.handle_exceptions import handle_exceptions + + +@handle_exceptions(default_return_value=None, raise_on_error=False) +def update_email_send(owner_id: int, email_type: str, resend_email_id: str): + supabase.table("email_sends").update({"resend_email_id": resend_email_id}).eq( + "owner_id", owner_id + ).eq("email_type", email_type).execute() diff --git a/services/webhook/handle_installation_deleted_or_suspended.py b/services/webhook/handle_installation_deleted_or_suspended.py new file mode 100644 index 00000000..b376ab6e --- /dev/null +++ b/services/webhook/handle_installation_deleted_or_suspended.py @@ -0,0 +1,58 @@ +from services.aws.delete_scheduler import delete_scheduler +from services.aws.get_schedulers import get_schedulers_by_owner_id +from services.github.types.github_types import InstallationPayload +from services.resend.get_first_name import get_first_name +from services.resend.send_email import send_email +from services.resend.text.suspend_email import get_suspend_email_text +from services.resend.text.uninstall_email import get_uninstall_email_text +from services.slack.slack_notify import slack_notify +from services.supabase.email_sends.insert_email_send import insert_email_send +from services.supabase.email_sends.update_email_send import update_email_send +from services.supabase.installations.delete_installation import delete_installation +from services.supabase.users.get_user import get_user +from utils.error.handle_exceptions import handle_exceptions + + +@handle_exceptions(raise_on_error=False) +def handle_installation_deleted_or_suspended(payload: InstallationPayload, action: str): + owner_id = payload["installation"]["account"]["id"] + owner_name = payload["installation"]["account"]["login"] + sender_id = payload["sender"]["id"] + sender_name = payload["sender"]["login"] + + verb = "deleted" if action == "deleted" else "suspended" + slack_notify(f":skull: Installation {verb} by `{sender_name}` for `{owner_name}`") + + delete_installation( + installation_id=payload["installation"]["id"], + user_id=sender_id, + user_name=sender_name, + ) + + # Send email (deduplicated per sender) + email_type = "uninstall" if action == "deleted" else "suspend" + get_email_text = ( + get_uninstall_email_text if action == "deleted" else get_suspend_email_text + ) + is_new = insert_email_send( + owner_id=sender_id, owner_name=sender_name, email_type=email_type + ) + if is_new is not False: + user = get_user(sender_id) + email = user.get("email") if user else None + user_name = user.get("user_name", "") if user else "" + if email: + first_name = get_first_name(user_name) + subject, text = get_email_text(first_name) + result = send_email(to=email, subject=subject, text=text) + if result and result.get("id"): + update_email_send( + owner_id=sender_id, + email_type=email_type, + resend_email_id=result["id"], + ) + + # Delete AWS schedulers for this owner + schedulers_to_delete = get_schedulers_by_owner_id(owner_id) + for schedule_name in schedulers_to_delete: + delete_scheduler(schedule_name) diff --git a/services/webhook/new_pr_handler.py b/services/webhook/new_pr_handler.py index 203d4b73..eb6332d8 100644 --- a/services/webhook/new_pr_handler.py +++ b/services/webhook/new_pr_handler.py @@ -43,6 +43,8 @@ from services.resend.text.credits_depleted_email import get_credits_depleted_email_text from services.slack.slack_notify import slack_notify from services.supabase.create_user_request import create_user_request +from services.supabase.email_sends.insert_email_send import insert_email_send +from services.supabase.email_sends.update_email_send import update_email_send from services.supabase.credits.insert_credit import insert_credit from services.supabase.owners.get_owner import get_owner from services.supabase.owners.get_stripe_customer_id import get_stripe_customer_id @@ -622,15 +624,25 @@ async def handle_new_pr( total_seconds=int(end_time - current_time), ) - # Check if user just ran out of credits and send casual notification + # Check if user just ran out of credits and send casual notification (deduplicated per owner) if billing_type == "credit": owner = get_owner(owner_id=owner_id) if owner and owner["credit_balance_usd"] <= 0 and sender_id: - user = get_user(user_id=sender_id) - email = user.get("email") if user else None - if email: - subject, text = get_credits_depleted_email_text(sender_name) - send_email(to=email, subject=subject, text=text) + is_new = insert_email_send( + owner_id=owner_id, owner_name=owner_name, email_type="credits_depleted" + ) + if is_new is not False: + user = get_user(user_id=sender_id) + email = user.get("email") if user else None + if email: + subject, text = get_credits_depleted_email_text(sender_name) + result = send_email(to=email, subject=subject, text=text) + if result and result.get("id"): + update_email_send( + owner_id=owner_id, + email_type="credits_depleted", + resend_email_id=result["id"], + ) # End notification end_msg = "Completed" if is_completed else "@channel Failed" diff --git a/services/webhook/test_handle_installation_deleted_or_suspended.py b/services/webhook/test_handle_installation_deleted_or_suspended.py new file mode 100644 index 00000000..72669e61 --- /dev/null +++ b/services/webhook/test_handle_installation_deleted_or_suspended.py @@ -0,0 +1,184 @@ +# pyright: reportUnusedVariable=false + +from typing import cast +from unittest.mock import patch + +import pytest + +from services.github.types.github_types import InstallationPayload +from services.webhook.handle_installation_deleted_or_suspended import ( + handle_installation_deleted_or_suspended, +) + + +def _make_payload(action: str): + return { + "action": action, + "installation": { + "account": {"login": "test-owner", "id": 11111}, + "id": 12345, + }, + "sender": {"login": "test-sender", "id": 67890}, + } + + +@patch( + "services.webhook.handle_installation_deleted_or_suspended.get_schedulers_by_owner_id", + return_value=[], +) +@patch("services.webhook.handle_installation_deleted_or_suspended.update_email_send") +@patch( + "services.webhook.handle_installation_deleted_or_suspended.send_email", + return_value={"id": "re_abc"}, +) +@patch( + "services.webhook.handle_installation_deleted_or_suspended.get_first_name", + return_value="Test", +) +@patch( + "services.webhook.handle_installation_deleted_or_suspended.get_user", + return_value={"email": "u@example.com", "user_name": "Test User"}, +) +@patch( + "services.webhook.handle_installation_deleted_or_suspended.insert_email_send", + return_value=True, +) +@patch("services.webhook.handle_installation_deleted_or_suspended.delete_installation") +@patch("services.webhook.handle_installation_deleted_or_suspended.slack_notify") +def test_deleted_sends_uninstall_email( + mock_slack, + mock_delete_inst, + mock_insert_email, + mock_get_user, + _mock_get_first_name, + mock_send_email, + mock_update_email, + _mock_get_schedulers, +): + handle_installation_deleted_or_suspended( + payload=cast(InstallationPayload, _make_payload("deleted")), action="deleted" + ) + + mock_slack.assert_called_once_with( + ":skull: Installation deleted by `test-sender` for `test-owner`" + ) + mock_delete_inst.assert_called_once_with( + installation_id=12345, user_id=67890, user_name="test-sender" + ) + mock_insert_email.assert_called_once_with( + owner_id=67890, owner_name="test-sender", email_type="uninstall" + ) + mock_get_user.assert_called_once_with(67890) + mock_send_email.assert_called_once() + mock_update_email.assert_called_once_with( + owner_id=67890, email_type="uninstall", resend_email_id="re_abc" + ) + + +@patch( + "services.webhook.handle_installation_deleted_or_suspended.get_schedulers_by_owner_id", + return_value=[], +) +@patch("services.webhook.handle_installation_deleted_or_suspended.update_email_send") +@patch( + "services.webhook.handle_installation_deleted_or_suspended.send_email", + return_value={"id": "re_xyz"}, +) +@patch( + "services.webhook.handle_installation_deleted_or_suspended.get_first_name", + return_value="Test", +) +@patch( + "services.webhook.handle_installation_deleted_or_suspended.get_user", + return_value={"email": "u@example.com", "user_name": "Test User"}, +) +@patch( + "services.webhook.handle_installation_deleted_or_suspended.insert_email_send", + return_value=True, +) +@patch("services.webhook.handle_installation_deleted_or_suspended.delete_installation") +@patch("services.webhook.handle_installation_deleted_or_suspended.slack_notify") +def test_suspend_sends_suspend_email( + mock_slack, + mock_delete_inst, + mock_insert_email, + mock_get_user, + _mock_get_first_name, + mock_send_email, + mock_update_email, + _mock_get_schedulers, +): + handle_installation_deleted_or_suspended( + payload=cast(InstallationPayload, _make_payload("suspend")), action="suspend" + ) + + mock_slack.assert_called_once_with( + ":skull: Installation suspended by `test-sender` for `test-owner`" + ) + mock_delete_inst.assert_called_once_with( + installation_id=12345, user_id=67890, user_name="test-sender" + ) + mock_insert_email.assert_called_once_with( + owner_id=67890, owner_name="test-sender", email_type="suspend" + ) + mock_get_user.assert_called_once_with(67890) + mock_send_email.assert_called_once() + mock_update_email.assert_called_once_with( + owner_id=67890, email_type="suspend", resend_email_id="re_xyz" + ) + + +@patch( + "services.webhook.handle_installation_deleted_or_suspended.get_schedulers_by_owner_id", + return_value=[], +) +@patch("services.webhook.handle_installation_deleted_or_suspended.send_email") +@patch("services.webhook.handle_installation_deleted_or_suspended.get_user") +@patch( + "services.webhook.handle_installation_deleted_or_suspended.insert_email_send", + return_value=False, +) +@patch("services.webhook.handle_installation_deleted_or_suspended.delete_installation") +@patch("services.webhook.handle_installation_deleted_or_suspended.slack_notify") +def test_duplicate_email_skipped( + _mock_slack, + _mock_delete_inst, + _mock_insert_email, + mock_get_user, + mock_send_email, + _mock_get_schedulers, +): + handle_installation_deleted_or_suspended( + payload=cast(InstallationPayload, _make_payload("deleted")), action="deleted" + ) + + mock_get_user.assert_not_called() + mock_send_email.assert_not_called() + + +@pytest.mark.parametrize("action", ["deleted", "suspend"]) +@patch("services.webhook.handle_installation_deleted_or_suspended.delete_scheduler") +@patch( + "services.webhook.handle_installation_deleted_or_suspended.get_schedulers_by_owner_id", + return_value=["sched-1", "sched-2"], +) +@patch( + "services.webhook.handle_installation_deleted_or_suspended.insert_email_send", + return_value=False, +) +@patch("services.webhook.handle_installation_deleted_or_suspended.delete_installation") +@patch("services.webhook.handle_installation_deleted_or_suspended.slack_notify") +def test_schedulers_deleted( + _mock_slack, + _mock_delete_inst, + _mock_insert_email, + mock_get_schedulers, + mock_delete_scheduler, + action, +): + handle_installation_deleted_or_suspended( + payload=cast(InstallationPayload, _make_payload(action)), action=action + ) + + mock_get_schedulers.assert_called_once_with(11111) + assert mock_delete_scheduler.call_count == 2 diff --git a/services/webhook/test_new_pr_handler.py b/services/webhook/test_new_pr_handler.py index 4f23b966..b2cea19a 100644 --- a/services/webhook/test_new_pr_handler.py +++ b/services/webhook/test_new_pr_handler.py @@ -1439,6 +1439,8 @@ async def test_test_file_header_merge_no_change( mock_replace_remote.assert_not_called() +@patch("services.webhook.new_pr_handler.update_email_send") +@patch("services.webhook.new_pr_handler.insert_email_send", return_value=True) @patch("services.webhook.new_pr_handler.send_email") @pytest.mark.asyncio @patch("services.webhook.new_pr_handler.get_credits_depleted_email_text") @@ -1493,6 +1495,8 @@ async def test_credits_depleted_email_sent( mock_get_user, mock_get_email_text, mock_send_email, + _mock_insert_email_send, + _mock_update_email_send, ): mock_deconstruct.return_value = (_get_base_args(), None) mock_render_text.return_value = "Rendered body" diff --git a/services/webhook/test_webhook_handler.py b/services/webhook/test_webhook_handler.py index 519935c4..dd7fed98 100644 --- a/services/webhook/test_webhook_handler.py +++ b/services/webhook/test_webhook_handler.py @@ -102,6 +102,14 @@ def mock_handle_push(): yield mock +@pytest.fixture +def mock_handle_installation_deleted_or_suspended(): + with patch( + "services.webhook.webhook_handler.handle_installation_deleted_or_suspended" + ) as mock: + yield mock + + class TestHandleWebhookEvent: @pytest.mark.asyncio async def test_handle_webhook_event_no_action(self): @@ -147,7 +155,7 @@ async def test_handle_webhook_event_installation_created( @pytest.mark.asyncio async def test_handle_webhook_event_installation_deleted( - self, mock_slack_notify, mock_delete_installation + self, mock_handle_installation_deleted_or_suspended ): """Test handling of installation deleted event.""" payload = { @@ -161,18 +169,13 @@ async def test_handle_webhook_event_installation_deleted( await handle_webhook_event(event_name="installation", payload=payload) - mock_slack_notify.assert_called_once_with( - ":skull: Installation deleted by `test-sender` for `test-owner`" - ) - mock_delete_installation.assert_called_once_with( - installation_id=12345, - user_id=67890, - user_name="test-sender", + mock_handle_installation_deleted_or_suspended.assert_called_once_with( + payload=payload, action="deleted" ) @pytest.mark.asyncio async def test_handle_webhook_event_installation_suspended( - self, mock_slack_notify, mock_delete_installation + self, mock_handle_installation_deleted_or_suspended ): """Test handling of installation suspended event.""" payload = { @@ -186,13 +189,8 @@ async def test_handle_webhook_event_installation_suspended( await handle_webhook_event(event_name="installation", payload=payload) - mock_slack_notify.assert_called_once_with( - ":skull: Installation suspended by `test-sender` for `test-owner`" - ) - mock_delete_installation.assert_called_once_with( - installation_id=12345, - user_id=67890, - user_name="test-sender", + mock_handle_installation_deleted_or_suspended.assert_called_once_with( + payload=payload, action="suspend" ) @pytest.mark.asyncio diff --git a/services/webhook/webhook_handler.py b/services/webhook/webhook_handler.py index 2c8ca372..11a1d739 100644 --- a/services/webhook/webhook_handler.py +++ b/services/webhook/webhook_handler.py @@ -11,8 +11,6 @@ from payloads.github.pull_request_review_comment.types import ( PullRequestReviewCommentPayload, ) -from services.aws.delete_scheduler import delete_scheduler -from services.aws.get_schedulers import get_schedulers_by_owner_id from services.github.types.github_types import ( CheckSuiteCompletedPayload, InstallationPayload, @@ -21,21 +19,18 @@ PrLabeledPayload, ) from services.github.types.webhook.push import PushWebhookPayload -from services.resend.get_first_name import get_first_name -from services.resend.send_email import send_email -from services.resend.text.suspend_email import get_suspend_email_text -from services.resend.text.uninstall_email import get_uninstall_email_text from services.slack.slack_notify import slack_notify -from services.supabase.installations.delete_installation import delete_installation from services.supabase.installations.unsuspend_installation import ( unsuspend_installation, ) from services.supabase.usage.get_usage_by_pr import get_usage_by_pr from services.supabase.usage.update_usage import update_usage -from services.supabase.users.get_user import get_user from services.webhook.check_suite_handler import handle_check_suite from services.webhook.handle_coverage_report import handle_coverage_report from services.webhook.handle_installation import handle_installation_created +from services.webhook.handle_installation_deleted_or_suspended import ( + handle_installation_deleted_or_suspended, +) from services.webhook.handle_installation_repos_added import ( handle_installation_repos_added, ) @@ -97,65 +92,10 @@ async def handle_webhook_event( return # https://docs.github.com/en/webhooks/webhook-events-and-payloads?actionType=deleted#installation - if event_name == "installation" and action in ("deleted"): - typed_payload = cast(InstallationPayload, payload) - owner_id = typed_payload["installation"]["account"]["id"] - owner_name = typed_payload["installation"]["account"]["login"] - sender_name = typed_payload["sender"]["login"] - msg = f":skull: Installation deleted by `{sender_name}` for `{owner_name}`" - slack_notify(msg) - - delete_installation( - installation_id=typed_payload["installation"]["id"], - user_id=typed_payload["sender"]["id"], - user_name=typed_payload["sender"]["login"], - ) - - # Send uninstall email - user = get_user(typed_payload["sender"]["id"]) - email = user.get("email") if user else None - user_name = user.get("user_name", "") if user else "" - if email: - first_name = get_first_name(user_name) - subject, text = get_uninstall_email_text(first_name) - send_email(to=email, subject=subject, text=text) - - # Delete AWS schedulers for this owner - schedulers_to_delete = get_schedulers_by_owner_id(owner_id) - for schedule_name in schedulers_to_delete: - delete_scheduler(schedule_name) - - return - # https://docs.github.com/en/webhooks/webhook-events-and-payloads?actionType=suspend#installation - if event_name == "installation" and action in ("suspend"): + if event_name == "installation" and action in ("deleted", "suspend"): typed_payload = cast(InstallationPayload, payload) - owner_id = typed_payload["installation"]["account"]["id"] - owner_name = typed_payload["installation"]["account"]["login"] - sender_name = typed_payload["sender"]["login"] - msg = f":skull: Installation suspended by `{sender_name}` for `{owner_name}`" - slack_notify(msg) - - delete_installation( - installation_id=typed_payload["installation"]["id"], - user_id=typed_payload["sender"]["id"], - user_name=typed_payload["sender"]["login"], - ) - - # Send suspend email - user = get_user(typed_payload["sender"]["id"]) - email = user.get("email") if user else None - user_name = user.get("user_name", "") if user else "" - if email: - first_name = get_first_name(user_name) - subject, text = get_suspend_email_text(first_name) - send_email(to=email, subject=subject, text=text) - - # Delete AWS schedulers for this owner - schedulers_to_delete = get_schedulers_by_owner_id(owner_id) - for schedule_name in schedulers_to_delete: - delete_scheduler(schedule_name) - + handle_installation_deleted_or_suspended(payload=typed_payload, action=action) return # https://docs.github.com/en/webhooks/webhook-events-and-payloads?actionType=unsuspend#installation