diff --git a/api/.openapi-generator/FILES b/api/.openapi-generator/FILES index afcea0a9d..74129a6b5 100644 --- a/api/.openapi-generator/FILES +++ b/api/.openapi-generator/FILES @@ -52,3 +52,25 @@ src/feeds_gen/models/search_feeds200_response.py src/feeds_gen/models/source_info.py src/feeds_gen/models/validation_report.py src/feeds_gen/security_api.py +src/user_service/impl/__init__.py +src/user_service_gen/apis/__init__.py +src/user_service_gen/apis/notifications_api.py +src/user_service_gen/apis/notifications_api_base.py +src/user_service_gen/apis/subscriptions_api.py +src/user_service_gen/apis/subscriptions_api_base.py +src/user_service_gen/apis/users_api.py +src/user_service_gen/apis/users_api_base.py +src/user_service_gen/main.py +src/user_service_gen/models/__init__.py +src/user_service_gen/models/create_notification_subscription_request.py +src/user_service_gen/models/extra_models.py +src/user_service_gen/models/feature_flag.py +src/user_service_gen/models/feed_subscription_summary.py +src/user_service_gen/models/notification_subscription.py +src/user_service_gen/models/notification_type.py +src/user_service_gen/models/subscription_feed.py +src/user_service_gen/models/subscription_feed_group.py +src/user_service_gen/models/update_notification_subscription_request.py +src/user_service_gen/models/update_user_request.py +src/user_service_gen/models/user_profile.py +src/user_service_gen/security_api.py diff --git a/api/src/shared/db_models/subscription_feed_group_impl.py b/api/src/shared/db_models/subscription_feed_group_impl.py new file mode 100644 index 000000000..4724631f8 --- /dev/null +++ b/api/src/shared/db_models/subscription_feed_group_impl.py @@ -0,0 +1,39 @@ +from typing import List + +from shared.users_database_gen.sqlacodegen_models import NotificationSubscription as NotificationSubscriptionOrm +from user_service_gen.models.feed_subscription_summary import FeedSubscriptionSummary +from user_service_gen.models.subscription_feed_group import SubscriptionFeedGroup + + +class SubscriptionFeedGroupImpl(SubscriptionFeedGroup): + """Implementation of the SubscriptionFeedGroup model. + Builds a feed-centric view from the subscriptions that target one feed stable ID. + """ + + class Config: + from_attributes = True + + @classmethod + def from_subscriptions( + cls, + feed_stable_id: str, + subs: List[NotificationSubscriptionOrm], + feed_metadata: dict | None = None, + ) -> SubscriptionFeedGroup: + feed_metadata = feed_metadata or {} + metadata = feed_metadata.get(feed_stable_id, {}) + return cls( + feed_id=feed_stable_id, + data_type=metadata.get("data_type"), + provider=metadata.get("provider"), + feed_name=metadata.get("feed_name"), + subscriptions=[ + FeedSubscriptionSummary( + id=sub.id, + notification_id=sub.notification_type_id, + active=sub.active, + created_at=sub.created_at, + ) + for sub in subs + ], + ) diff --git a/api/src/user_service/impl/users_api_impl.py b/api/src/user_service/impl/users_api_impl.py index 8bc3ffc63..7db027400 100644 --- a/api/src/user_service/impl/users_api_impl.py +++ b/api/src/user_service/impl/users_api_impl.py @@ -27,6 +27,7 @@ from shared.database.users_database import with_users_db_session from shared.db_models.app_user_impl import AppUserImpl from shared.db_models.notification_subscription_impl import NotificationSubscriptionImpl +from shared.db_models.subscription_feed_group_impl import SubscriptionFeedGroupImpl from shared.users_database_gen.sqlacodegen_models import ( AppUser, FeatureFlag, @@ -52,6 +53,7 @@ CreateNotificationSubscriptionRequest, ) from user_service_gen.models.notification_subscription import NotificationSubscription +from user_service_gen.models.subscription_feed_group import SubscriptionFeedGroup from user_service_gen.models.update_notification_subscription_request import ( UpdateNotificationSubscriptionRequest, ) @@ -169,6 +171,54 @@ def get_user_subscriptions(self, db_session=None) -> List[NotificationSubscripti feed_metadata = resolve_feed_metadata(stable_ids) if stable_ids else {} return [NotificationSubscriptionImpl.from_orm(s, feed_metadata) for s in subs] + @with_users_db_session + def get_user_subscription_feeds(self, db_session=None) -> List[SubscriptionFeedGroup]: + """Returns the feeds the authenticated user has at least one subscription targeting, + each with the subscriptions that target it.""" + user_id = self._require_user_id() + return self._query_subscription_feed_groups(db_session, user_id) + + @with_users_db_session + def get_user_subscription_feed_by_id(self, id: str, db_session=None) -> SubscriptionFeedGroup: + """Returns the authenticated user's subscriptions targeting a single feed stable ID. + + 404 collapses "feed doesn't exist" and "user has no subscription targeting it" into one + condition (no matching join rows), so no separate feeds-DB existence check is needed. + """ + user_id = self._require_user_id() + groups = self._query_subscription_feed_groups(db_session, user_id, feed_stable_id=id) + if not groups: + raise HTTPException(status_code=404, detail="Feed not found, or no subscription targets it.") + return groups[0] + + @staticmethod + def _query_subscription_feed_groups( + db_session, user_id: str, feed_stable_id: str = None + ) -> List[SubscriptionFeedGroup]: + query = ( + db_session.query(NotificationSubscriptionFeedOrm, NotificationSubscriptionOrm) + .join( + NotificationSubscriptionOrm, + NotificationSubscriptionFeedOrm.subscription_id == NotificationSubscriptionOrm.id, + ) + .filter(NotificationSubscriptionOrm.user_id == user_id) + ) + if feed_stable_id is not None: + query = query.filter(NotificationSubscriptionFeedOrm.feed_stable_id == feed_stable_id) + rows = query.order_by( + NotificationSubscriptionFeedOrm.feed_stable_id, NotificationSubscriptionOrm.created_at + ).all() + + grouped: dict[str, list[NotificationSubscriptionOrm]] = {} + for feed_row, sub in rows: + grouped.setdefault(feed_row.feed_stable_id, []).append(sub) + + feed_metadata = resolve_feed_metadata(list(grouped.keys())) if grouped else {} + return [ + SubscriptionFeedGroupImpl.from_subscriptions(stable_id, subs, feed_metadata) + for stable_id, subs in grouped.items() + ] + @with_users_db_session def create_user_subscription( self, create_notification_subscription_request: CreateNotificationSubscriptionRequest, db_session=None diff --git a/api/tests/unittest/user_service/test_subscription_feeds.py b/api/tests/unittest/user_service/test_subscription_feeds.py index 4a10ac10e..deff1b826 100644 --- a/api/tests/unittest/user_service/test_subscription_feeds.py +++ b/api/tests/unittest/user_service/test_subscription_feeds.py @@ -196,3 +196,94 @@ def test_create_requires_feed_ids_for_feed_scoped_type(api_session): db_session=session, ) assert exc.value.status_code == 400 + + +# ── GET /v1/user/subscriptions/feeds and /feeds/{id} (issue #212) ────────── + + +def test_get_user_subscription_feeds_empty_when_no_feed_scoped_subscriptions(api_session): + api, session, _ = api_session + + result = api.get_user_subscription_feeds(db_session=session) + + assert result == [] + + +def test_get_user_subscription_feeds_groups_across_feeds(api_session): + api, session, _ = api_session + + api.create_user_subscription( + CreateNotificationSubscriptionRequest(notification_id=FEED_SCOPED_TYPE, feed_ids=["mdb-2", "mdb-1"]), + db_session=session, + ) + + result = api.get_user_subscription_feeds(db_session=session) + + assert [g.feed_id for g in result] == ["mdb-1", "mdb-2"] + assert all(len(g.subscriptions) == 1 for g in result) + + +def test_get_user_subscription_feeds_isolated_per_user(api_session): + api, session, _ = api_session + + api.create_user_subscription( + CreateNotificationSubscriptionRequest(notification_id=FEED_SCOPED_TYPE, feed_ids=["mdb-1"]), + db_session=session, + ) + + # A different user's subscription to the same feed must not leak into this user's view. + other_user_id = f"other-{uuid.uuid4().hex}" + session.add(AppUser(id=other_user_id, email=f"{other_user_id}@test.org")) + other_sub = NotificationSubscription( + id=str(uuid.uuid4()), user_id=other_user_id, notification_type_id=FEED_SCOPED_TYPE, active=True + ) + other_sub.notification_subscription_feeds.append(NotificationSubscriptionFeed(feed_stable_id="mdb-1")) + session.add(other_sub) + session.flush() + + result = api.get_user_subscription_feeds(db_session=session) + + assert len(result) == 1 + assert result[0].feed_id == "mdb-1" + assert [s.id for s in result[0].subscriptions] != [other_sub.id] + + +def test_get_user_subscription_feed_by_id_returns_matching_feed(api_session): + api, session, _ = api_session + + api.create_user_subscription( + CreateNotificationSubscriptionRequest(notification_id=FEED_SCOPED_TYPE, feed_ids=["mdb-1", "mdb-2"]), + db_session=session, + ) + + result = api.get_user_subscription_feed_by_id("mdb-2", db_session=session) + + assert result.feed_id == "mdb-2" + assert len(result.subscriptions) == 1 + + +def test_get_user_subscription_feed_by_id_404_for_unknown_feed(api_session): + api, session, _ = api_session + + with pytest.raises(HTTPException) as exc: + api.get_user_subscription_feed_by_id("mdb-999", db_session=session) + assert exc.value.status_code == 404 + + +def test_get_user_subscription_feed_by_id_null_metadata_when_unresolved(api_session): + """resolve_feed_metadata is stubbed to {} by api_session (no feeds DB here), simulating a + feed no longer present in the feeds DB. The group must still be returned, not 404, with + null metadata fields.""" + api, session, _ = api_session + + api.create_user_subscription( + CreateNotificationSubscriptionRequest(notification_id=FEED_SCOPED_TYPE, feed_ids=["mdb-1"]), + db_session=session, + ) + + result = api.get_user_subscription_feed_by_id("mdb-1", db_session=session) + + assert result.feed_id == "mdb-1" + assert result.data_type is None + assert result.provider is None + assert result.feed_name is None diff --git a/api/tests/unittest/user_service/test_users_api_impl.py b/api/tests/unittest/user_service/test_users_api_impl.py index ddaa303da..f0c70287f 100644 --- a/api/tests/unittest/user_service/test_users_api_impl.py +++ b/api/tests/unittest/user_service/test_users_api_impl.py @@ -694,6 +694,156 @@ def test_brevo_failure_raises_502(self): self.assertEqual(ctx.exception.status_code, 502) +def _make_feed_row(feed_stable_id, subscription_id="sub-1", **kwargs): + from shared.users_database_gen.sqlacodegen_models import NotificationSubscriptionFeed as FeedRowOrm + + defaults = dict(subscription_id=subscription_id, feed_stable_id=feed_stable_id, created_at=FIXED_NOW) + defaults.update(kwargs) + return FeedRowOrm(**defaults) + + +def _mock_feed_group_rows(session, rows): + """Wire the `.join().filter().order_by().all()` chain used by the unfiltered (list) query.""" + session.query.return_value.join.return_value.filter.return_value.order_by.return_value.all.return_value = rows + + +def _mock_feed_group_rows_filtered(session, rows): + """Wire the `.join().filter().filter().order_by().all()` chain used by the feed_stable_id-filtered query.""" + chain = session.query.return_value.join.return_value.filter.return_value.filter.return_value + chain.order_by.return_value.all.return_value = rows + + +class TestSubscriptionFeedGroups(unittest.TestCase): + """GET /v1/user/subscriptions/feeds and GET /v1/user/subscriptions/feeds/{id}.""" + + def setUp(self): + self.api = UsersApiImpl() + self.mock_session = MagicMock() + _set_context() + meta_patcher = patch("user_service.impl.users_api_impl.resolve_feed_metadata", return_value={}) + meta_patcher.start() + self.addCleanup(meta_patcher.stop) + + def _make_sub(self, **kwargs): + from shared.users_database_gen.sqlacodegen_models import NotificationSubscription as Orm + + defaults = dict( + id="sub-1", + user_id="uid-123", + notification_type_id="feed.url_updated", + active=True, + created_at=FIXED_NOW, + ) + defaults.update(kwargs) + return Orm(**defaults) + + # ── list ── + def test_get_user_subscription_feeds_empty(self): + _mock_feed_group_rows(self.mock_session, []) + + result = self.api.get_user_subscription_feeds(db_session=self.mock_session) + + self.assertEqual(result, []) + + def test_get_user_subscription_feeds_single_feed(self): + sub = self._make_sub() + row = _make_feed_row("mdb-1", subscription_id=sub.id) + _mock_feed_group_rows(self.mock_session, [(row, sub)]) + + result = self.api.get_user_subscription_feeds(db_session=self.mock_session) + + self.assertEqual(len(result), 1) + self.assertEqual(result[0].feed_id, "mdb-1") + self.assertEqual(len(result[0].subscriptions), 1) + self.assertEqual(result[0].subscriptions[0].id, "sub-1") + self.assertEqual(result[0].subscriptions[0].notification_id, "feed.url_updated") + + def test_get_user_subscription_feeds_groups_by_feed(self): + sub1 = self._make_sub(id="sub-1") + sub2 = self._make_sub(id="sub-2", notification_type_id="feed.coverage") + rows = [ + (_make_feed_row("mdb-1", subscription_id="sub-1"), sub1), + (_make_feed_row("mdb-2", subscription_id="sub-2"), sub2), + ] + _mock_feed_group_rows(self.mock_session, rows) + + result = self.api.get_user_subscription_feeds(db_session=self.mock_session) + + self.assertEqual([g.feed_id for g in result], ["mdb-1", "mdb-2"]) + self.assertEqual(result[0].subscriptions[0].id, "sub-1") + self.assertEqual(result[1].subscriptions[0].id, "sub-2") + + def test_get_user_subscription_feeds_subscription_targets_multiple_feeds(self): + sub = self._make_sub() + rows = [ + (_make_feed_row("mdb-1", subscription_id=sub.id), sub), + (_make_feed_row("mdb-2", subscription_id=sub.id), sub), + ] + _mock_feed_group_rows(self.mock_session, rows) + + result = self.api.get_user_subscription_feeds(db_session=self.mock_session) + + self.assertEqual([g.feed_id for g in result], ["mdb-1", "mdb-2"]) + self.assertEqual(result[0].subscriptions[0].id, sub.id) + self.assertEqual(result[1].subscriptions[0].id, sub.id) + + def test_get_user_subscription_feeds_includes_inactive(self): + sub = self._make_sub(active=False) + row = _make_feed_row("mdb-1", subscription_id=sub.id) + _mock_feed_group_rows(self.mock_session, [(row, sub)]) + + result = self.api.get_user_subscription_feeds(db_session=self.mock_session) + + self.assertFalse(result[0].subscriptions[0].active) + + def test_get_user_subscription_feeds_resolves_feed_metadata(self): + sub = self._make_sub() + row = _make_feed_row("mdb-1", subscription_id=sub.id) + _mock_feed_group_rows(self.mock_session, [(row, sub)]) + + with patch( + "user_service.impl.users_api_impl.resolve_feed_metadata", + return_value={"mdb-1": {"data_type": "gtfs", "provider": "Test Transit", "feed_name": "Test Feed"}}, + ): + result = self.api.get_user_subscription_feeds(db_session=self.mock_session) + + self.assertEqual(result[0].data_type, "gtfs") + self.assertEqual(result[0].provider, "Test Transit") + self.assertEqual(result[0].feed_name, "Test Feed") + + def test_get_user_subscription_feeds_guest_403(self): + _set_context(is_guest=True) + with self.assertRaises(HTTPException) as ctx: + self.api.get_user_subscription_feeds(db_session=self.mock_session) + self.assertEqual(ctx.exception.status_code, 403) + + # ── by id ── + def test_get_user_subscription_feed_by_id_returns_group(self): + sub = self._make_sub() + row = _make_feed_row("mdb-1", subscription_id=sub.id) + _mock_feed_group_rows_filtered(self.mock_session, [(row, sub)]) + + result = self.api.get_user_subscription_feed_by_id("mdb-1", db_session=self.mock_session) + + self.assertEqual(result.feed_id, "mdb-1") + self.assertEqual(len(result.subscriptions), 1) + self.assertEqual(result.subscriptions[0].id, "sub-1") + + def test_get_user_subscription_feed_by_id_404_when_no_rows(self): + _mock_feed_group_rows_filtered(self.mock_session, []) + + with self.assertRaises(HTTPException) as ctx: + self.api.get_user_subscription_feed_by_id("mdb-999", db_session=self.mock_session) + + self.assertEqual(ctx.exception.status_code, 404) + + def test_get_user_subscription_feed_by_id_guest_403(self): + _set_context(is_guest=True) + with self.assertRaises(HTTPException) as ctx: + self.api.get_user_subscription_feed_by_id("mdb-1", db_session=self.mock_session) + self.assertEqual(ctx.exception.status_code, 403) + + class TestSubscriptionGate(unittest.TestCase): """The isNotificationsEnabled feature flag gates POST/PATCH subscriptions.""" diff --git a/docs/UserServiceAPI.yaml b/docs/UserServiceAPI.yaml index 46a0884e1..73a73e6ad 100644 --- a/docs/UserServiceAPI.yaml +++ b/docs/UserServiceAPI.yaml @@ -173,6 +173,72 @@ paths: "401": description: Unauthorized. + /v1/user/subscriptions/feeds: + get: + summary: List the feeds the current user has subscriptions for + description: >- + Returns the feeds the authenticated user has at least one notification + subscription targeting, each with the subscriptions that target it. + Subscriptions of any `active` state are included (same as + `GET /v1/user/subscriptions`). Feed metadata is resolved from the feeds + database at read time; a feed that no longer exists there still appears, + with its metadata fields null. Returns an empty array when the user has + no feed-scoped subscriptions. This feature is available to selected + users. To request access or learn more, contact us at api@mobilitydata.org. + operationId: getUserSubscriptionFeeds + tags: + - "users" + security: + - Authentication: [] + responses: + "200": + description: List of feeds with the subscriptions targeting them. + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/SubscriptionFeedGroup" + "401": + description: Unauthorized. + "403": + description: Forbidden — guest users cannot perform this action. + /v1/user/subscriptions/feeds/{id}: + get: + summary: Get the current user's subscriptions for a specific feed + description: >- + Returns the feed identified by its stable ID, with the authenticated + user's notification subscriptions that target it. Subscriptions of any + `active` state are included (same as `GET /v1/user/subscriptions`). + Returns `404` if the user has no subscription targeting the feed. If the + feed no longer exists in the feeds database, the group is still returned + with its metadata fields null. + users. To request access or learn more, contact us at api@mobilitydata.org. + operationId: getUserSubscriptionFeedById + tags: + - "users" + security: + - Authentication: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + description: The feed's stable ID. + example: "mdb-1" + responses: + "200": + description: The feed with the subscriptions targeting it. + content: + application/json: + schema: + $ref: "#/components/schemas/SubscriptionFeedGroup" + "401": + description: Unauthorized. + "404": + description: Feed not found, or the user has no subscription targeting it. + /v1/user/subscriptions/{id}: patch: summary: Toggle a notification subscription, or remove specific feeds from it @@ -490,6 +556,68 @@ components: nullable: true description: The feed's display name. + FeedSubscriptionSummary: + type: object + description: > + A trimmed view of a notification subscription, for use nested inside a + `SubscriptionFeedGroup`. Omits `user_id` (the caller's own ID) and + `feeds` (the enclosing group already identifies the feed). + required: + - id + - notification_id + - active + - created_at + properties: + id: + type: string + description: Unique subscription ID (UUID v4). + notification_id: + type: string + description: The notification type this subscription is for. + example: "feed.url_updated" + active: + type: boolean + description: Whether the subscription is currently active. + default: true + created_at: + type: string + format: date-time + description: Timestamp when the subscription was created. + + SubscriptionFeedGroup: + type: object + description: > + A feed the authenticated user has at least one notification + subscription targeting, with those subscriptions. Feed metadata is + resolved from the feed's stable ID at read time (not persisted). + required: + - feed_id + - subscriptions + properties: + feed_id: + type: string + description: The feed's stable ID. + example: "mdb-1" + data_type: + type: string + nullable: true + description: The feed's data type. + example: "gtfs" + provider: + type: string + nullable: true + description: The transit/mobility data provider name. + example: "Metropolitan Transit Authority" + feed_name: + type: string + nullable: true + description: The feed's display name. + subscriptions: + type: array + description: The authenticated user's subscriptions that target this feed. + items: + $ref: "#/components/schemas/FeedSubscriptionSummary" + CreateNotificationSubscriptionRequest: type: object required: diff --git a/docs/notifications-subscription-flows.md b/docs/notifications-subscription-flows.md index af3154382..3a7bef3bc 100644 --- a/docs/notifications-subscription-flows.md +++ b/docs/notifications-subscription-flows.md @@ -149,8 +149,10 @@ list and no flag — only the DB row matters, and delete is a real delete. ## Endpoint inventory All paths are under the User Service API (`docs/UserServiceAPI.yaml`). -Implementations live in `api/src/user_service/impl/`. In every path, `{id}` is -the **subscription ID** (`notification_subscription.id`, a UUID). +Implementations live in `api/src/user_service/impl/`. In every path below, +`{id}` is the **subscription ID** (`notification_subscription.id`, a UUID) — +except the two `.../feeds/{id}` paths, where `{id}` is a **feed stable ID** +(e.g. `mdb-1`). ### Authenticated — tag `users` (end-user identity required) @@ -161,6 +163,8 @@ the **subscription ID** (`notification_subscription.id`, a UUID). | `POST /v1/user/subscriptions` | `createUserSubscription` | Subscribe the caller to a type | | `PATCH /v1/user/subscriptions/{id}` | `updateUserSubscription` | Toggle `active` (pause / resume) | | `DELETE /v1/user/subscriptions/{id}` | `deleteUserSubscription` | Unsubscribe (delete, or disable for announcements) | +| `GET /v1/user/subscriptions/feeds` | `getUserSubscriptionFeeds` | List feeds the caller has subscriptions for, grouped by feed | +| `GET /v1/user/subscriptions/feeds/{id}` | `getUserSubscriptionFeedById` | Get the caller's subscriptions for one feed | ### Capability endpoints — tag `subscriptions` (subscription ID is the credential)