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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ Post-deploy checks:
* scheduler can still hit backend internally
* Grafana still loads

## Optix Login Bootstrap

`POST /api/optix-token` is the Optix-authenticated login/bootstrap path.

On each successful Optix login:

* refresh Optix-owned user fields only when the incoming values changed
* refresh existing team names and team mailbox display names only when the incoming values changed
* preserve Avenu-owned user preferences such as `notifPrefs`

This keeps local identity data current without rewriting unchanged rows on every login.

## Learn more

* Architecture overview: `docs/01-architecture/overview.md`
Expand Down
11 changes: 9 additions & 2 deletions backend/repositories/teams_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from config import teams_collection, users_collection
from errors import APIError
from models import build_mailbox_doc, build_team_create
from models import build_mailbox_doc, build_team_create, build_team_patch

from .common import run_in_transaction
from .mail_repository import delete_by_mailbox
Expand Down Expand Up @@ -103,7 +103,14 @@ def work(session: Any) -> None:
def ensure_team_from_external_identity(*, optix_id: int, name: str) -> dict[str, Any]:
existing = find_team_by_optix_id(optix_id)
if existing is not None:
return existing
candidate_patch = build_team_patch({"name": name})
if existing.get("name") == candidate_patch["name"]:
return existing
patch = {
"name": candidate_patch["name"],
"updatedAt": candidate_patch["updatedAt"],
}
return update_team_with_mailbox_sync(team_id=existing["_id"], patch=patch)

team_doc = build_team_create({"optixId": optix_id, "name": name})
mailbox_doc = build_mailbox_doc(owner_type="team", ref_id=ObjectId(), display_name=team_doc["name"])
Expand Down
31 changes: 27 additions & 4 deletions backend/repositories/users_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
from .mail_repository import delete_by_mailbox
from .mailboxes_repository import delete_mailbox, find_owner_mailbox, insert_mailbox, update_owner_display_name
from .teams_repository import count_by_ids
from services.user_preferences import normalize_effective_notification_state

DEFAULT_EXTERNAL_IDENTITY_NOTIF_PREFS = ["email"]


def list_users() -> list[dict[str, Any]]:
Expand Down Expand Up @@ -152,7 +155,6 @@ def upsert_user_from_external_identity(
phone: str,
is_admin: bool,
team_ids: list[ObjectId],
notif_prefs: list[str],
) -> tuple[dict[str, Any], bool]:
existing = find_user_by_optix_id(optix_id)
if existing is None:
Expand All @@ -164,7 +166,7 @@ def upsert_user_from_external_identity(
"phone": phone,
"isAdmin": is_admin,
"teamIds": team_ids,
"notifPrefs": notif_prefs,
"notifPrefs": DEFAULT_EXTERNAL_IDENTITY_NOTIF_PREFS,
}
)
mailbox_doc = build_mailbox_doc(owner_type="user", ref_id=ObjectId(), display_name=user_doc["fullname"])
Expand All @@ -174,16 +176,37 @@ def upsert_user_from_external_identity(
except DuplicateKeyError as exc:
raise APIError(409, "user with same optixId or email already exists") from exc

patch = build_user_patch(
candidate_patch = build_user_patch(
{
"fullname": fullname,
"email": email,
"phone": phone,
"isAdmin": is_admin,
"teamIds": team_ids,
"notifPrefs": notif_prefs,
}
)
patch: dict[str, Any] = {}
for key in ("fullname", "email", "phone", "isAdmin"):
if existing.get(key) != candidate_patch.get(key):
patch[key] = candidate_patch[key]

if "phone" in patch:
normalized = normalize_effective_notification_state(
current_user=existing,
phone_patch=candidate_patch["phone"],
)
if normalized["notifPrefs"] != existing.get("notifPrefs", []):
patch["notifPrefs"] = normalized["notifPrefs"]

current_team_ids = {str(team_id) for team_id in existing.get("teamIds", [])}
next_team_ids = {str(team_id) for team_id in candidate_patch.get("teamIds", [])}
if current_team_ids != next_team_ids:
patch["teamIds"] = candidate_patch["teamIds"]

if not patch:
return existing, False

patch["updatedAt"] = candidate_patch["updatedAt"]
try:
updated = update_user_with_mailbox_sync(user_id=existing["_id"], patch=patch)
return updated, False
Expand Down
1 change: 0 additions & 1 deletion backend/services/identity_sync_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,5 @@ def sync_optix_identity(*, token: str) -> tuple[bool, dict[str, Any]]:
phone=user_info.get("phone", ""),
is_admin=bool(user_info.get("is_admin", False)),
team_ids=team_ids,
notif_prefs=["email"],
)
return created, user_doc
48 changes: 48 additions & 0 deletions backend/tests/unit/test_identity_sync_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ def test_sync_optix_identity_creates_new_user_and_team(self):
kwargs = upsert_user_mock.call_args.kwargs
self.assertEqual(kwargs["optix_id"], 123)
self.assertEqual(kwargs["team_ids"], [team_id])
self.assertNotIn("notif_prefs", kwargs)

def test_sync_optix_identity_updates_existing_user(self):
user_id = ObjectId()
Expand Down Expand Up @@ -109,6 +110,53 @@ def test_sync_optix_identity_updates_existing_user(self):
self.assertFalse(created)
self.assertEqual(user_doc["_id"], user_id)

def test_sync_optix_identity_forwards_updated_phone_and_team_membership_for_existing_user(self):
user_id = ObjectId()
first_team_id = ObjectId()
second_team_id = ObjectId()

with patch(
"services.identity_sync_service.requests.post",
return_value=FakeResponse(
status_code=200,
payload={
"data": {
"me": {
"user": {
"user_id": 123,
"email": "member@example.com",
"fullname": "Updated Name",
"phone": "+15550001111",
"is_admin": True,
"teams": [
{"team_id": 99, "name": "Team One"},
{"team_id": 100, "name": "Team Two"},
],
}
}
}
},
),
), patch(
"services.identity_sync_service.ensure_team_from_external_identity",
side_effect=[
{"_id": first_team_id, "optixId": 99, "name": "Team One"},
{"_id": second_team_id, "optixId": 100, "name": "Team Two"},
],
), patch(
"services.identity_sync_service.upsert_user_from_external_identity",
return_value=({"_id": user_id, "optixId": 123, "email": "member@example.com"}, False),
) as upsert_user_mock:
created, user_doc = sync_optix_identity(token="optix-token")

self.assertFalse(created)
self.assertEqual(user_doc["_id"], user_id)
kwargs = upsert_user_mock.call_args.kwargs
self.assertEqual(kwargs["fullname"], "Updated Name")
self.assertEqual(kwargs["phone"], "+15550001111")
self.assertEqual(kwargs["team_ids"], [first_team_id, second_team_id])
self.assertNotIn("notif_prefs", kwargs)

def test_sync_optix_identity_raises_on_optix_failure_and_does_not_write_local_state(self):
with patch(
"services.identity_sync_service.requests.post",
Expand Down
55 changes: 55 additions & 0 deletions backend/tests/unit/test_teams_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import os
import unittest
from unittest.mock import patch

from bson import ObjectId

os.environ.setdefault("MONGO_URI", "mongodb://localhost:27017")

from repositories.teams_repository import ensure_team_from_external_identity


class TeamsRepositoryExternalIdentityTests(unittest.TestCase):
def test_ensure_team_from_external_identity_creates_new_team_when_missing(self):
created_team = {"_id": ObjectId(), "optixId": 99, "name": "Team One"}

with patch("repositories.teams_repository.find_team_by_optix_id", return_value=None), patch(
"repositories.teams_repository.create_team_with_mailbox",
return_value=created_team,
) as create_mock:
team_doc = ensure_team_from_external_identity(optix_id=99, name="Team One")

self.assertEqual(team_doc, created_team)
self.assertEqual(create_mock.call_args.kwargs["team_doc"]["name"], "Team One")

def test_ensure_team_from_external_identity_refreshes_existing_team_name_when_changed(self):
team_id = ObjectId()
existing = {"_id": team_id, "optixId": 99, "name": "Old Team"}
updated = {"_id": team_id, "optixId": 99, "name": "New Team"}

with patch("repositories.teams_repository.find_team_by_optix_id", return_value=existing), patch(
"repositories.teams_repository.update_team_with_mailbox_sync",
return_value=updated,
) as update_mock:
team_doc = ensure_team_from_external_identity(optix_id=99, name="New Team")

self.assertEqual(team_doc, updated)
patch_payload = update_mock.call_args.kwargs["patch"]
self.assertEqual(patch_payload["name"], "New Team")
self.assertIn("updatedAt", patch_payload)

def test_ensure_team_from_external_identity_skips_write_when_team_name_is_unchanged(self):
team_id = ObjectId()
existing = {"_id": team_id, "optixId": 99, "name": "Team One"}

with patch("repositories.teams_repository.find_team_by_optix_id", return_value=existing), patch(
"repositories.teams_repository.update_team_with_mailbox_sync"
) as update_mock:
team_doc = ensure_team_from_external_identity(optix_id=99, name="Team One")

self.assertEqual(team_doc, existing)
update_mock.assert_not_called()


if __name__ == "__main__":
unittest.main()
135 changes: 135 additions & 0 deletions backend/tests/unit/test_users_repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import os
import unittest
from unittest.mock import patch

from bson import ObjectId

os.environ.setdefault("MONGO_URI", "mongodb://localhost:27017")

from repositories.users_repository import upsert_user_from_external_identity


class UsersRepositoryExternalIdentityTests(unittest.TestCase):
def test_upsert_user_from_external_identity_creates_new_user_with_default_notification_prefs(self):
created_user = {"_id": ObjectId(), "optixId": 42, "fullname": "Member User"}

with patch("repositories.users_repository.find_user_by_optix_id", return_value=None), patch(
"repositories.users_repository.create_user_with_mailbox",
return_value=created_user,
) as create_mock:
user_doc, created = upsert_user_from_external_identity(
optix_id=42,
fullname="Member User",
email="member@example.com",
phone="+15550001111",
is_admin=False,
team_ids=[],
)

self.assertTrue(created)
self.assertEqual(user_doc, created_user)
created_payload = create_mock.call_args.kwargs["user_doc"]
self.assertEqual(created_payload["notifPrefs"], ["email"])

def test_upsert_user_from_external_identity_updates_existing_user_without_overwriting_notification_prefs(self):
user_id = ObjectId()
team_id = ObjectId()
existing = {
"_id": user_id,
"optixId": 42,
"fullname": "Old Name",
"email": "old@example.com",
"phone": None,
"isAdmin": False,
"teamIds": [],
"notifPrefs": ["text"],
}
updated = {**existing, "fullname": "New Name", "email": "new@example.com", "teamIds": [team_id]}

with patch("repositories.users_repository.find_user_by_optix_id", return_value=existing), patch(
"repositories.users_repository.update_user_with_mailbox_sync",
return_value=updated,
) as update_mock:
user_doc, created = upsert_user_from_external_identity(
optix_id=42,
fullname="New Name",
email="new@example.com",
phone=None,
is_admin=False,
team_ids=[team_id],
)

self.assertFalse(created)
self.assertEqual(user_doc, updated)
patch_payload = update_mock.call_args.kwargs["patch"]
self.assertEqual(patch_payload["fullname"], "New Name")
self.assertEqual(patch_payload["email"], "new@example.com")
self.assertEqual(patch_payload["teamIds"], [team_id])
self.assertNotIn("notifPrefs", patch_payload)

def test_upsert_user_from_external_identity_skips_write_when_optix_payload_is_unchanged(self):
user_id = ObjectId()
team_id = ObjectId()
existing = {
"_id": user_id,
"optixId": 42,
"fullname": "Member User",
"email": "member@example.com",
"phone": "+15550001111",
"isAdmin": True,
"teamIds": [team_id],
"notifPrefs": ["text"],
}

with patch("repositories.users_repository.find_user_by_optix_id", return_value=existing), patch(
"repositories.users_repository.update_user_with_mailbox_sync"
) as update_mock:
user_doc, created = upsert_user_from_external_identity(
optix_id=42,
fullname="Member User",
email="member@example.com",
phone="+15550001111",
is_admin=True,
team_ids=[team_id],
)

self.assertFalse(created)
self.assertEqual(user_doc, existing)
update_mock.assert_not_called()

def test_upsert_user_from_external_identity_removes_sms_pref_when_optix_clears_phone(self):
user_id = ObjectId()
existing = {
"_id": user_id,
"optixId": 42,
"fullname": "Member User",
"email": "member@example.com",
"phone": "+15550001111",
"isAdmin": False,
"teamIds": [],
"notifPrefs": ["email", "text"],
}
updated = {**existing, "phone": None, "notifPrefs": ["email"]}

with patch("repositories.users_repository.find_user_by_optix_id", return_value=existing), patch(
"repositories.users_repository.update_user_with_mailbox_sync",
return_value=updated,
) as update_mock:
user_doc, created = upsert_user_from_external_identity(
optix_id=42,
fullname="Member User",
email="member@example.com",
phone="",
is_admin=False,
team_ids=[],
)

self.assertFalse(created)
self.assertEqual(user_doc, updated)
patch_payload = update_mock.call_args.kwargs["patch"]
self.assertIsNone(patch_payload["phone"])
self.assertEqual(patch_payload["notifPrefs"], ["email"])


if __name__ == "__main__":
unittest.main()
Loading
Loading