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
1 change: 1 addition & 0 deletions backend/controllers/session_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ def session_me():
"emailNotifications": normalized["emailNotifications"],
"smsNotifications": normalized["smsNotifications"],
"hasPhone": normalized["hasPhone"],
"hasSmsPhone": normalized["hasSmsPhone"],
}
),
200,
Expand Down
1 change: 1 addition & 0 deletions backend/services/member_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,5 @@ def update_member_notification_preferences(
"emailNotifications": normalized["emailNotifications"],
"smsNotifications": normalized["smsNotifications"],
"hasPhone": normalized["hasPhone"],
"hasSmsPhone": normalized["hasSmsPhone"],
}
14 changes: 6 additions & 8 deletions backend/services/notifications/channels/sms_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Union

from services.notifications.providers.sms_provider import SMSProvider
from validators import is_e164_phone
from services.notifications.types import ChannelResult, SpecialCaseNotificationPayload, WeeklySummaryData, WeeklySummaryNotificationPayload


Expand All @@ -14,9 +15,12 @@ def __init__(self, provider: SMSProvider) -> None:
self.provider = provider

def send(self, payload: WeeklySummaryNotificationPayload | SpecialCaseNotificationPayload) -> ChannelResult:
phone = self._resolve_phone(payload)
if not phone:
raw_phone = payload["user"].get("phone")
if not isinstance(raw_phone, str) or not raw_phone.strip():
return {"channel": self.channel, "status": "skipped", "error": "missing phone"}
phone = raw_phone.strip()
if not is_e164_phone(phone):
return {"channel": self.channel, "status": "skipped", "error": "invalid phone for SMS"}

if "summary" in payload:
body = self._build_weekly_summary_body(payload)
Expand All @@ -37,12 +41,6 @@ def send(self, payload: WeeklySummaryNotificationPayload | SpecialCaseNotificati
"error": str(exc),
}

def _resolve_phone(self, payload: WeeklySummaryNotificationPayload | SpecialCaseNotificationPayload) -> str:
raw_phone = payload["user"].get("phone")
if not isinstance(raw_phone, str):
return ""
return raw_phone.strip()

def _build_weekly_summary_body(self, payload: WeeklySummaryNotificationPayload) -> str:
summary = payload["summary"]
week_start = self._format_summary_date(summary["weekStart"])
Expand Down
12 changes: 10 additions & 2 deletions backend/services/user_preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from typing import Any

from errors import APIError
from validators import is_e164_phone

UNSET = object()
_EMAIL_PREF = "email"
Expand Down Expand Up @@ -71,14 +72,21 @@ def normalize_effective_notification_state(
effective_prefs.discard(_SMS_PREF)

has_phone = effective_phone is not None
if not has_phone:
has_sms_phone = bool(effective_phone and is_e164_phone(effective_phone))
if not has_sms_phone:
if explicit_sms_enable:
raise APIError(400, "SMS notifications require a valid phone number")
if not has_phone:
raise APIError(400, "SMS notifications require a valid phone number")
raise APIError(
400,
"SMS notifications require a phone number in E.164 format (for example +15551234567)",
)
effective_prefs.discard(_SMS_PREF)

return {
"phone": effective_phone,
"hasPhone": has_phone,
"hasSmsPhone": has_sms_phone,
"notifPrefs": _to_ordered_storage_prefs(effective_prefs),
"emailNotifications": _EMAIL_PREF in effective_prefs,
"smsNotifications": _SMS_PREF in effective_prefs,
Expand Down
17 changes: 15 additions & 2 deletions backend/tests/unit/test_admin_session_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ def test_session_me_returns_profile(self):
"emailNotifications": True,
"smsNotifications": False,
"hasPhone": True,
"hasSmsPhone": True,
},
)

Expand Down Expand Up @@ -493,7 +494,13 @@ def test_member_preferences_patches_boolean_toggle(self):
with self.client.session_transaction() as sess:
sess["user_id"] = user_id

expected = {"id": user_id, "emailNotifications": True, "smsNotifications": False, "hasPhone": True}
expected = {
"id": user_id,
"emailNotifications": True,
"smsNotifications": False,
"hasPhone": True,
"hasSmsPhone": True,
}
with patch("controllers.auth_guard.find_user", return_value=user_doc), patch(
"controllers.member_controller.update_member_notification_preferences", return_value=expected
) as prefs_mock:
Expand Down Expand Up @@ -521,7 +528,13 @@ def test_member_preferences_accepts_partial_sms_patch(self):
with self.client.session_transaction() as sess:
sess["user_id"] = user_id

expected = {"id": user_id, "emailNotifications": True, "smsNotifications": True, "hasPhone": True}
expected = {
"id": user_id,
"emailNotifications": True,
"smsNotifications": True,
"hasPhone": True,
"hasSmsPhone": True,
}
with patch("controllers.auth_guard.find_user", return_value=user_doc), patch(
"controllers.member_controller.update_member_notification_preferences", return_value=expected
) as prefs_mock:
Expand Down
42 changes: 42 additions & 0 deletions backend/tests/unit/test_member_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ def test_update_member_preferences_partial_patch_leaves_omitted_fields_unchanged
"emailNotifications": True,
"smsNotifications": True,
"hasPhone": True,
"hasSmsPhone": True,
},
)

Expand Down Expand Up @@ -67,6 +68,47 @@ def test_update_member_preferences_returns_email_sms_and_has_phone(self):
"emailNotifications": True,
"smsNotifications": False,
"hasPhone": False,
"hasSmsPhone": False,
},
)

def test_update_member_preferences_rejects_sms_when_phone_is_not_e164(self):
user = {"_id": ObjectId(), "notifPrefs": ["email"], "phone": "4125551234"}

with patch("services.member_service.update_notif_prefs") as update_mock:
with self.assertRaises(APIError) as ctx:
update_member_notification_preferences(
user=user,
sms_notifications=True,
)

self.assertEqual(ctx.exception.status_code, 400)
self.assertEqual(
ctx.exception.message,
"SMS notifications require a phone number in E.164 format (for example +15551234567)",
)
update_mock.assert_not_called()

def test_update_member_preferences_returns_has_sms_phone_false_for_naive_phone(self):
user_id = ObjectId()
user = {"_id": user_id, "notifPrefs": ["email"], "phone": "4125551234"}

with patch("services.member_service.update_notif_prefs") as update_mock:
response = update_member_notification_preferences(
user=user,
email_notifications=True,
)

update_mock.assert_called_once()
self.assertEqual(update_mock.call_args.args[1], ["email"])
self.assertEqual(
response,
{
"id": str(user_id),
"emailNotifications": True,
"smsNotifications": False,
"hasPhone": True,
"hasSmsPhone": False,
},
)

Expand Down
23 changes: 23 additions & 0 deletions backend/tests/unit/test_sms_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,29 @@ def test_send_weekly_summary_returns_skipped_when_phone_missing(self):
self.assertIn("missing phone", result["error"])
self.assertEqual(provider.calls, [])

def test_send_returns_skipped_when_phone_is_not_e164(self):
provider = FakeSMSProvider()
channel = SMSChannel(provider)

result = channel.send(
{
"user": {
"id": str(ObjectId()),
"email": "member@example.com",
"fullname": "Member User",
"phone": "4125551234",
},
"triggeredBy": "admin",
"templateType": "mail-arrived",
"mailRequest": None,
}
)

self.assertEqual(result["channel"], "sms")
self.assertEqual(result["status"], "skipped")
self.assertIn("invalid phone for SMS", result["error"])
self.assertEqual(provider.calls, [])

def test_send_special_case_formats_body_and_calls_provider(self):
provider = FakeSMSProvider()
channel = SMSChannel(provider)
Expand Down
33 changes: 33 additions & 0 deletions backend/tests/unit/test_user_preferences.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def test_normalize_effective_preferences_merges_current_and_patch(self):
self.assertTrue(result["emailNotifications"])
self.assertTrue(result["smsNotifications"])
self.assertTrue(result["hasPhone"])
self.assertTrue(result["hasSmsPhone"])

def test_normalize_effective_preferences_treats_whitespace_phone_as_missing(self):
current_user = {
Expand All @@ -36,6 +37,7 @@ def test_normalize_effective_preferences_treats_whitespace_phone_as_missing(self
self.assertTrue(result["emailNotifications"])
self.assertFalse(result["smsNotifications"])
self.assertFalse(result["hasPhone"])
self.assertFalse(result["hasSmsPhone"])

def test_normalize_effective_preferences_rejects_text_when_explicitly_enabled_without_phone(self):
current_user = {
Expand All @@ -52,6 +54,37 @@ def test_normalize_effective_preferences_rejects_text_when_explicitly_enabled_wi
self.assertEqual(ctx.exception.status_code, 400)
self.assertEqual(ctx.exception.message, "SMS notifications require a valid phone number")

def test_normalize_effective_preferences_strips_text_when_phone_is_not_e164(self):
current_user = {
"notifPrefs": ["email", "text"],
"phone": "4125551234",
}

result = normalize_effective_notification_state(current_user=current_user)

self.assertEqual(result["notifPrefs"], ["email"])
self.assertFalse(result["smsNotifications"])
self.assertTrue(result["hasPhone"])
self.assertFalse(result["hasSmsPhone"])

def test_normalize_effective_preferences_rejects_text_when_phone_is_naive_us_number(self):
current_user = {
"notifPrefs": [],
"phone": "4125551234",
}

with self.assertRaises(APIError) as ctx:
normalize_effective_notification_state(
current_user=current_user,
sms_notifications_patch=True,
)

self.assertEqual(ctx.exception.status_code, 400)
self.assertEqual(
ctx.exception.message,
"SMS notifications require a phone number in E.164 format (for example +15551234567)",
)


if __name__ == "__main__":
unittest.main()
37 changes: 37 additions & 0 deletions backend/tests/unit/test_user_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,43 @@ def test_update_user_rejects_text_pref_when_effective_phone_missing(self):
self.assertEqual(ctx.exception.status_code, 400)
self.assertEqual(ctx.exception.message, "SMS notifications require a valid phone number")

def test_update_user_rejects_text_pref_when_phone_is_not_e164(self):
user_id = ObjectId()

with patch(
"services.user_service.find_user",
return_value={"_id": user_id, "notifPrefs": [], "phone": "4125551234"},
), patch(
"services.user_service.build_user_patch",
return_value={"notifPrefs": ["text"]},
):
with self.assertRaises(APIError) as ctx:
update_user(user_id, payload={"notifPrefs": ["text"]})

self.assertEqual(ctx.exception.status_code, 400)
self.assertEqual(
ctx.exception.message,
"SMS notifications require a phone number in E.164 format (for example +15551234567)",
)

def test_update_user_auto_removes_text_pref_when_phone_patched_to_non_e164(self):
user_id = ObjectId()
current = {"_id": user_id, "notifPrefs": ["email", "text"], "phone": "+15550001111"}
persisted = {"_id": user_id, "notifPrefs": ["email"], "phone": "4125551234"}

with patch("services.user_service.find_user", return_value=current), patch(
"services.user_service.build_user_patch",
return_value={"phone": "4125551234"},
), patch(
"services.user_service.update_user_with_mailbox_sync",
return_value=persisted,
) as update_mock:
response = update_user(user_id, payload={"phone": "4125551234"})

self.assertEqual(response, persisted)
self.assertEqual(update_mock.call_args.kwargs["patch"]["phone"], "4125551234")
self.assertEqual(update_mock.call_args.kwargs["patch"]["notifPrefs"], ["email"])

def test_update_user_auto_removes_text_pref_when_phone_cleared(self):
user_id = ObjectId()
current = {"_id": user_id, "notifPrefs": ["email", "text"], "phone": "+15550001111"}
Expand Down
14 changes: 13 additions & 1 deletion backend/tests/unit/test_validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from bson import ObjectId

from errors import APIError
from validators import normalize_email, parse_distinct_object_ids, parse_enum_set
from validators import is_e164_phone, normalize_email, parse_distinct_object_ids, parse_enum_set


class ValidatorTests(unittest.TestCase):
Expand All @@ -30,6 +30,18 @@ def test_parse_enum_set_rejects_invalid(self):
with self.assertRaises(APIError):
parse_enum_set(["email", "push"], field_name="notifPrefs", allowed={"email", "text"})

def test_is_e164_phone_accepts_plus_and_digits(self):
self.assertTrue(is_e164_phone("+15550001111"))
self.assertTrue(is_e164_phone(" +441234567890 "))

def test_is_e164_phone_rejects_naive_or_invalid(self):
self.assertFalse(is_e164_phone("4125551234"))
self.assertFalse(is_e164_phone("+0123456789"))
self.assertFalse(is_e164_phone("+1"))
self.assertFalse(is_e164_phone("+1abc"))
self.assertFalse(is_e164_phone(""))
self.assertFalse(is_e164_phone("+1" + "0" * 15))


if __name__ == "__main__":
unittest.main()
15 changes: 13 additions & 2 deletions backend/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,22 @@ def optional_phone(value: Any) -> str | None:
phone = value.strip()
if not phone:
return None
if not phone.startswith("+") or not phone[1:].isdigit():
raise APIError(422, "phone must use E.164 format")
return phone


def is_e164_phone(value: str) -> bool:
"""True if ``value`` is a plausible E.164 destination (e.g. Twilio ``to``)."""
s = value.strip()
if len(s) < 2 or not s.startswith("+"):
return False
digits = s[1:]
if not digits.isdigit() or digits[0] == "0":
return False
if not 2 <= len(digits) <= 15:
return False
return True


def parse_distinct_object_ids(values: Any, field_name: str) -> list[ObjectId]:
if values is None:
return []
Expand Down
1 change: 1 addition & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
return raw.endsWith("/") ? raw : `${raw}/`;
})();

export function stripAuthBootstrapParams(url: string): string {

Check warning on line 38 in frontend/src/App.tsx

View workflow job for this annotation

GitHub Actions / build (frontend)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const current = new URL(url);
let changed = false;
for (const key of [...OPTIX_BOOTSTRAP_QUERY_KEYS, ...MAGIC_LINK_QUERY_KEYS]) {
Expand All @@ -49,7 +49,7 @@
return `${current.pathname}${nextSearch ? `?${nextSearch}` : ""}${current.hash}`;
}

export function stripAuthBootstrapParamsFromWindow(): void {

Check warning on line 52 in frontend/src/App.tsx

View workflow job for this annotation

GitHub Actions / build (frontend)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const nextUrl = stripAuthBootstrapParams(window.location.href);
if (nextUrl !== `${window.location.pathname}${window.location.search}${window.location.hash}`) {
window.history.replaceState({}, "", nextUrl);
Expand All @@ -60,7 +60,7 @@
window.location.replace(APP_ROOT_PATH);
}

export function getMagicLinkParams(search: string): { tokenId: string; signature: string } | null {

Check warning on line 63 in frontend/src/App.tsx

View workflow job for this annotation

GitHub Actions / build (frontend)

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
const params = new URLSearchParams(search);
const tokenId = params.get("token_id");
const signature = params.get("signature");
Expand All @@ -80,6 +80,7 @@
emailNotifications: session.emailNotifications,
smsNotifications: session.smsNotifications,
hasPhone: session.hasPhone,
hasSmsPhone: session.hasSmsPhone,
};
}

Expand Down
3 changes: 3 additions & 0 deletions frontend/src/lib/api/contracts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export interface ApiSessionMe {
emailNotifications: boolean;
smsNotifications: boolean;
hasPhone: boolean;
/** True when stored phone is usable as an SMS destination (E.164). */
hasSmsPhone: boolean;
}

export interface ApiMemberMailboxDay {
Expand All @@ -79,6 +81,7 @@ export interface ApiMemberPreferences {
emailNotifications: boolean;
smsNotifications: boolean;
hasPhone: boolean;
hasSmsPhone: boolean;
}

export type ApiMailRequestStatus = "ACTIVE" | "CANCELLED" | "RESOLVED";
Expand Down
Loading
Loading