Skip to content
Merged
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
29 changes: 29 additions & 0 deletions services/supabase/email_sends/insert_email_send.py
Original file line number Diff line number Diff line change
@@ -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
96 changes: 96 additions & 0 deletions services/supabase/email_sends/test_insert_email_send.py
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions services/supabase/email_sends/test_update_email_send.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions services/supabase/email_sends/update_email_send.py
Original file line number Diff line number Diff line change
@@ -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()
58 changes: 58 additions & 0 deletions services/webhook/handle_installation_deleted_or_suspended.py
Original file line number Diff line number Diff line change
@@ -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)
24 changes: 18 additions & 6 deletions services/webhook/new_pr_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down
Loading