diff --git a/external_types/DatabaseCatalogAPI.yaml b/external_types/DatabaseCatalogAPI.yaml index bb8006a1..7112ec84 100644 --- a/external_types/DatabaseCatalogAPI.yaml +++ b/external_types/DatabaseCatalogAPI.yaml @@ -362,6 +362,42 @@ paths: 500: description: Internal server error. + /v1/gtfs_feeds/{id}/validation_reports: + parameters: + - $ref: "#/components/parameters/feed_id_path_param" + get: + description: > + Returns the validation history of a GTFS feed: one entry per dataset, carrying the counts of + its most recent validation report and the notice codes behind them, ordered by `validated_at` + from newest to oldest. `latest` is the entry for the feed's current dataset. + tags: + - "feeds" + - "beta" + operationId: getGtfsFeedValidationReports + parameters: + - $ref: "#/components/parameters/validation_validated_after" + - $ref: "#/components/parameters/validation_validated_before" + - $ref: "#/components/parameters/validation_min_errors" + - $ref: "#/components/parameters/validation_min_warnings" + - $ref: "#/components/parameters/validation_severity" + - $ref: "#/components/parameters/limit_query_param_validation_reports_endpoint" + - $ref: "#/components/parameters/offset" + security: + - Authentication: [] + responses: + 200: + description: Validation history for the GTFS feed, ordered by validated_at (newest first). + content: + application/json: + schema: + $ref: "#/components/schemas/GtfsFeedValidationReportsResponse" + 422: + description: Invalid request parameters. + 404: + description: GTFS feed not found. + 500: + description: Internal server error. + /v1/datasets/gtfs/{id}: get: description: Get the specified dataset from the Mobility Database. @@ -2020,6 +2056,133 @@ components: type: string example: 8635fdac4fbff025b4eaca6972fcc9504bc1552d + GtfsFeedValidationReportsResponse: + type: object + description: > + The feed's validation history, one entry per dataset. `latest` is the entry for the feed's + current dataset, whatever page or filter was requested, and is null when the feed has no + validated dataset. + required: + - feed_id + - items + - total + - offset + - limit + properties: + feed_id: + type: string + description: Unique identifier of the GTFS feed. + example: mdb-123 + latest: + $ref: "#/components/schemas/GtfsFeedValidationReport" + total: + type: integer + description: Total number of matching datasets regardless of limit and offset. + example: 42 + offset: + type: integer + description: Offset of the first returned item. + example: 0 + limit: + type: integer + description: Maximum number of items returned. + example: 20 + items: + type: array + description: One entry per dataset, ordered by validated_at from newest to oldest. + items: + $ref: "#/components/schemas/GtfsFeedValidationReport" + + GtfsFeedValidationReport: + type: object + description: > + The most recent validation report of one dataset. `total_*` counts every notice raised; + `unique_*` counts the distinct codes behind them. + required: + - dataset_id + - is_latest + - notices + properties: + dataset_id: + type: string + description: Stable identifier of the validated dataset. + example: mdb-123-202604290029 + is_latest: + type: boolean + description: Whether this is the feed's latest dataset. + example: true + validated_at: + type: string + format: date-time + nullable: true + example: "2026-06-28T00:29:00Z" + validator_version: + type: string + nullable: true + example: 4.2.0 + total_error: + type: integer + nullable: true + example: 10 + total_warning: + type: integer + nullable: true + example: 20 + total_info: + type: integer + nullable: true + example: 30 + unique_error_count: + type: integer + nullable: true + example: 1 + unique_warning_count: + type: integer + nullable: true + example: 2 + unique_info_count: + type: integer + nullable: true + example: 3 + url_json: + type: string + nullable: true + description: JSON validation report URL. + url_html: + type: string + nullable: true + description: HTML validation report URL. + notices: + type: array + description: > + The notice codes raised, newest report only, ordered by severity then by count. Filtered + by the `severity` query parameter when one is given. + items: + $ref: "#/components/schemas/GtfsFeedValidationNotice" + + GtfsFeedValidationNotice: + type: object + required: + - code + - severity + - total + properties: + code: + type: string + description: Validator notice code. + example: invalid_phone_number + severity: + type: string + enum: + - ERROR + - WARNING + - INFO + example: ERROR + total: + type: integer + description: How many times this code was raised. + example: 10 + ValidationReport: description: Validation report type: object @@ -2708,6 +2871,75 @@ components: format: date-time example: "2026-08-24T00:00:00Z" + validation_validated_after: + name: validated_after + in: query + description: Only include reports validated at or after this timestamp. Date should be in ISO 8601 date-time format. + required: False + schema: + type: string + format: date-time + example: "2026-02-24T00:00:00Z" + + validation_validated_before: + name: validated_before + in: query + description: Only include reports validated at or before this timestamp. Date should be in ISO 8601 date-time format. + required: False + schema: + type: string + format: date-time + example: "2026-08-24T00:00:00Z" + + validation_min_errors: + name: min_errors + in: query + description: Only include reports with at least this many errors. Use 1 to list only datasets that failed validation. + required: False + schema: + type: integer + minimum: 0 + example: 1 + + validation_min_warnings: + name: min_warnings + in: query + description: Only include reports with at least this many warnings. + required: False + schema: + type: integer + minimum: 0 + example: 1 + + validation_severity: + name: severity + in: query + description: > + Limit the `notices` of each entry to these severities. Repeat the parameter for more than + one. The counts are unaffected. + required: False + schema: + type: array + items: + type: string + enum: + - ERROR + - WARNING + - INFO + example: ["ERROR"] + + limit_query_param_validation_reports_endpoint: + name: limit + in: query + description: The number of items to be returned. Maximum is 100. + required: False + schema: + type: integer + minimum: 0 + maximum: 100 + default: 20 + example: 2 + availability_sort: name: sort in: query diff --git a/external_types/UserServiceAPI.yaml b/external_types/UserServiceAPI.yaml index 630d70d6..73a73e6a 100644 --- a/external_types/UserServiceAPI.yaml +++ b/external_types/UserServiceAPI.yaml @@ -30,9 +30,19 @@ tags: - name: "users" description: "User profile management" - name: "notifications" - description: "Notification subscriptions" + description: "Notification catalog — the notification types a user can subscribe to." - name: "subscriptions" - description: "Public subscription management via subscription ID (e.g. email unsubscribe links)" + description: >- + Subscription management keyed on the subscription UUID, with **no end-user + authentication**. These endpoints perform no per-user identity check: + possession of the unguessable subscription `id` is the access capability, + which is what lets an email "unsubscribe" link work without the recipient + logging in. Note the transport is still authenticated — the call is made + server-side by an authorized IAP principal (a service account); the API is + not exposed to the open internet and every call must be authenticated. + Contrast with the per-user + `/v1/user/subscriptions*` endpoints (tag `users`), which additionally + require an end-user identity. See `docs/notifications-subscription-flows.md`. paths: /v1/user: @@ -134,7 +144,12 @@ paths: description: Unauthorized. post: summary: Create a notification subscription - description: Subscribes the authenticated user to a notification type. This feature is available to selected users. To request access or learn more, contact us at api@mobilitydata.org. + description: >- + Subscribes the authenticated user to a notification type. For feed-scoped + types, the supplied `feed_ids` are merged into the subscription's existing + feed set rather than replacing it, so callers do not need to know which + feeds the user is already subscribed to. This feature is available to + selected users. To request access or learn more, contact us at api@mobilitydata.org. operationId: createUserSubscription tags: - "users" @@ -158,10 +173,84 @@ 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 - description: Activates or deactivates a notification subscription by ID. This feature is available to selected users. To request access or learn more, contact us at api@mobilitydata.org. + summary: Toggle a notification subscription, or remove specific feeds from it + description: >- + Activates or deactivates a notification subscription by ID, and/or removes + specific feed stable IDs from a feed-scoped subscription's feed set via + `remove_feed_ids`. To add feeds instead, use `POST /v1/user/subscriptions`, + which merges into the existing feed set. A feed-scoped subscription cannot + exist with no feeds, so removing the last remaining feed deletes the + subscription entirely; the response reflects its final state. This feature + is available to selected users. To request access or learn more, contact us + at api@mobilitydata.org. operationId: updateUserSubscription tags: - "users" @@ -173,7 +262,7 @@ paths: required: true schema: type: string - description: Subscription ID. + description: "The subscription ID (notification_subscription.id) — a UUID that is the subscription row's own identifier, not the user ID and not the notification-type ID." requestBody: required: true content: @@ -208,7 +297,7 @@ paths: required: true schema: type: string - description: Subscription ID. + description: "The subscription ID (notification_subscription.id) — a UUID that is the subscription row's own identifier, not the user ID and not the notification-type ID." responses: "204": description: Subscription deleted, or disabled for the announcements type. @@ -222,6 +311,13 @@ paths: summary: Get a subscription by ID description: | Returns a single notification subscription identified by its ID. + + **No end-user authentication** — the subscription UUID (`id`) is the + access capability. The call itself must still be made by an authorized IAP + principal (typically the website backend server-side) to render the + unsubscribe page, so the recipient can see which notification they are + about to unsubscribe from before confirming. Returns `404` for an + unknown/invalid ID. operationId: getSubscription tags: - "subscriptions" @@ -231,7 +327,7 @@ paths: required: true schema: type: string - description: Subscription ID. + description: "The subscription ID (notification_subscription.id) — a UUID that is the subscription row's own identifier, not the user ID and not the notification-type ID." responses: "200": description: Subscription retrieved. @@ -242,12 +338,39 @@ paths: "404": description: Subscription not found. delete: - summary: Delete or disable a subscription by ID + summary: Unsubscribe by subscription ID description: | - Removes a notification subscription identified by its ID. The - announcements subscription (`api.announcements`) cannot be deleted; - calling this endpoint for it disables the subscription (sets it inactive) - instead of removing it. This feature is available to selected users. To request access or learn more, contact us at api@mobilitydata.org. + Unsubscribes using the subscription UUID (`id`). This backs the email + "unsubscribe" link — **no end-user authentication**, the UUID is the + capability. The call must still be made by an authorized IAP principal + (the website backend server-side); the anonymous recipient never calls + the API directly. + + Behaviour depends on the notification type: + - **`api.announcements`** — never deleted; the subscription is *disabled* + (`active=false`), the contact is removed from the Brevo announcements + list, and `app_user.is_registered_to_receive_api_announcements` is + cleared. Kept idempotent so a repeated click still succeeds. + - **all other types** (e.g. `feed.url_updated`) — the subscription row is + hard-deleted; a second call therefore returns `404`. + + Returns `404` for an unknown/invalid ID, and `502` if the email provider + (Brevo) cannot be reached while updating an announcements subscription. + + The `id` embedded in an email is the subscription's own UUID + (`notification_subscription.id`) — mirrored onto the Brevo contact as the + `MDB_SUBSCRIPTION_ID` attribute — **not** the notification-type string + (`api.announcements`), which is shared by all recipients and is not a + valid subscription ID. + + With `scope=all`, the subscription ID is used only to resolve the owning + user, and every subscription that user holds is unsubscribed (normal types + deleted; `api.announcements` disabled and removed from Brevo). With + `scope=one` (the default) only the single subscription identified by `id` + is affected. + + This feature is available to selected users. To request access or learn + more, contact us at api@mobilitydata.org. operationId: deleteSubscription tags: - "subscriptions" @@ -257,10 +380,20 @@ paths: required: true schema: type: string - description: Subscription ID. + description: "The subscription ID (notification_subscription.id) — a UUID that is the subscription row's own identifier, not the user ID and not the notification-type ID." + - name: scope + in: query + required: false + schema: + type: string + enum: [one, all] + default: one + description: "'one' (default) unsubscribes only the subscription identified by the path `id`. 'all' unsubscribes the owning user from every notification type, using the path `id` solely to identify that user." responses: "204": description: Subscription deleted, or disabled for the announcements type. + "400": + description: Invalid `scope` value (must be `one` or `all`). "404": description: Subscription not found. @@ -345,13 +478,13 @@ components: properties: id: type: string - description: Unique identifier for the notification type (e.g. 'feed.published'). - example: "feed.published" + description: Unique identifier for the notification type (e.g. 'feed.url_updated', 'api.announcements'). + example: "feed.url_updated" description: type: string nullable: true description: Human-readable description of the notification type. - example: "Notifies when a new feed is published." + example: "Fired when a feed URL changes in-place or a feed is deprecated and redirected." NotificationSubscription: type: object @@ -364,14 +497,18 @@ components: properties: id: type: string - description: Unique subscription ID (UUID v4). + description: >- + Unique subscription ID (UUID v4). This is the **capability token** used + by the public `/v1/subscriptions/{id}` endpoints and by email + unsubscribe links; for `api.announcements` it is mirrored onto the + Brevo contact as the `MDB_SUBSCRIPTION_ID` attribute. user_id: type: string description: The ID of the subscribed user. notification_id: type: string description: The notification type this subscription is for. - example: "feed.published" + example: "feed.url_updated" active: type: boolean description: Whether the subscription is currently active. @@ -419,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: @@ -427,7 +626,7 @@ components: notification_id: type: string description: The notification type to subscribe to. - example: "feed.published" + example: "feed.url_updated" feed_ids: type: array items: @@ -436,17 +635,29 @@ components: Feed stable IDs to subscribe to. Required (non-empty) for the feed-scoped notification types feed.url_updated, feed.url_availability and feed.coverage; must be omitted or empty - for other types. Validation is enforced in code, not the schema. + for other types. Merged into the subscription's existing feed set + (union), not a replacement. Validation is enforced in code, not the + schema. example: ["mdb-1", "mdb-42"] UpdateNotificationSubscriptionRequest: type: object - required: - - active properties: active: type: boolean description: Whether the subscription should be active. + remove_feed_ids: + type: array + items: + type: string + description: > + Feed stable IDs to remove from a feed-scoped subscription's feed set. + Only valid for feed-scoped notification types; removing a feed ID + that is not currently present is a no-op. Removing every remaining + feed ID deletes the subscription entirely (a feed-scoped subscription + cannot exist with no feeds). Validation is enforced in code, not the + schema. + example: ["mdb-1"] FeatureFlag: type: object @@ -477,4 +688,4 @@ components: $ref: "./BearerTokenSchema.yaml#/components/securitySchemes/Authentication" security: - - Authentication: [] \ No newline at end of file + - Authentication: [] diff --git a/src/app/[locale]/account/notifications/AccountNotifications.tsx b/src/app/[locale]/account/notifications/AccountNotifications.tsx index aa7d8e72..1af77f64 100644 --- a/src/app/[locale]/account/notifications/AccountNotifications.tsx +++ b/src/app/[locale]/account/notifications/AccountNotifications.tsx @@ -23,7 +23,6 @@ import MoreVertIcon from '@mui/icons-material/MoreVert'; import { Link as LocaleLink } from '../../../../i18n/navigation'; import { useAuthSession } from '../../../components/AuthSessionProvider'; import NotificationSettingsDialog, { - defaultNotificationSettings, type NotificationSettings, } from '../../../screens/Feed/components/NotificationSettingsDialog'; import { AccountSectionContainer } from '../AccountSectionContainer'; @@ -31,29 +30,22 @@ import { deleteUserSubscription, getUserSubscriptions, type NotificationSubscription, - type SubscriptionFeed, + removeFeedsFromUserSubscription, updateUserSubscription, USER_SUBSCRIPTIONS_SWR_KEY, } from '../../../services/notification-service'; -import { NOTIFICATION_TYPES } from '../../../utils/notificationTypes'; - -const nonEmpty = (value?: string | null): string | undefined => - value != null && value.trim() !== '' ? value : undefined; - -const getFeedTitle = (feed: SubscriptionFeed): string => { - const titlePrefix = - feed.data_type != null && feed.data_type.trim() !== '' - ? `[${feed.data_type.toLocaleUpperCase()}]` - : ''; - const provider = nonEmpty(feed.provider); - const feedName = nonEmpty(feed.feed_name); - if (provider != null && feedName != null) { - return `${titlePrefix} ${provider} - ${feedName}`; - } - return `${titlePrefix} ${provider ?? feedName ?? feed.feed_id}`; -}; +import { settleAll, withoutFeed } from '../../../utils/subscriptionCache'; +import { + FEED_NOTIFICATION_TYPE_IDS, + NOTIFICATION_TYPES, +} from '../../../utils/notificationTypes'; +import { + getFeedTitle, + groupSubscriptionsByFeed, + type NotificationRow, +} from './lib/groupSubscriptions'; -type SortKey = 'title' | 'type' | 'status' | 'subscribed'; +type SortKey = 'title' | 'type' | 'status'; export default function AccountNotifications(): React.ReactElement { const t = useTranslations('feeds'); @@ -68,36 +60,27 @@ export default function AccountNotifications(): React.ReactElement { return type != null ? t(type.labelKey) : notificationId; }; - const getSubscriptionTitle = ( - subscription: NotificationSubscription, - ): string => { - const feeds = subscription.feeds ?? []; - if (feeds.length === 0) { - return getNotificationTypeLabel(subscription.notification_id); - } - return feeds.map(getFeedTitle).join(', '); - }; + const getNotificationTypesLabel = (row: NotificationRow): string => + row.notificationIds.map(getNotificationTypeLabel).join(', '); - const renderSubscriptionTitle = ( - subscription: NotificationSubscription, - ): React.ReactNode => { - const feeds = subscription.feeds ?? []; - if (feeds.length === 0) { - return getNotificationTypeLabel(subscription.notification_id); + const getRowTitle = (row: NotificationRow): string => + row.feed != null ? getFeedTitle(row.feed) : getNotificationTypesLabel(row); + + const renderRowTitle = (row: NotificationRow): React.ReactNode => { + if (row.feed == null) { + return getNotificationTypesLabel(row); } - return feeds.map((feed, index) => ( - - {index > 0 && ', '} - - {getFeedTitle(feed)} - - - )); + const { feed } = row; + return ( + + {getFeedTitle(feed)} + + ); }; const handleSortClick = (key: SortKey): void => { @@ -109,26 +92,19 @@ export default function AccountNotifications(): React.ReactElement { } }; - const getSortValue = ( - subscription: NotificationSubscription, - key: SortKey, - ): string | number => { + const getSortValue = (row: NotificationRow, key: SortKey): string => { switch (key) { case 'title': - return getSubscriptionTitle(subscription).toLowerCase(); + return getRowTitle(row).toLowerCase(); case 'type': - return getNotificationTypeLabel( - subscription.notification_id, - ).toLowerCase(); + return getNotificationTypesLabel(row).toLowerCase(); case 'status': - return subscription.active ? 'active' : 'paused'; - case 'subscribed': - return new Date(subscription.created_at).getTime(); + return row.active ? 'active' : 'paused'; } }; const { - data: notifications = [], + data: notifications, error: loadError, isLoading, mutate, @@ -137,98 +113,162 @@ export default function AccountNotifications(): React.ReactElement { getUserSubscriptions, ); + // Keyed on the SWR data reference so row objects stay stable across + // unrelated re-renders (the settings dialog resets when its props change). + const rows = React.useMemo( + () => groupSubscriptionsByFeed(notifications ?? []), + [notifications], + ); + const [actionError, setActionError] = React.useState(null); - const [menuState, setMenuState] = React.useState<{ - anchor: HTMLElement; - id: string; - } | null>(null); - const [settingsDialogOpen, setSettingsDialogOpen] = React.useState(false); - const [rowSettings, setRowSettings] = React.useState< - Record - >({}); + const [menuAnchor, setMenuAnchor] = React.useState(null); + // Kept after the menu closes so its items don't change while it fades out. + const [menuRowKey, setMenuRowKey] = React.useState(null); + const [settingsRowKey, setSettingsRowKey] = React.useState( + null, + ); - const sortedNotifications = + const sortedRows = orderBy === null - ? notifications - : [...notifications].sort((a, b) => { + ? rows + : [...rows].sort((a, b) => { const valueA = getSortValue(a, orderBy); const valueB = getSortValue(b, orderBy); const comparison = valueA < valueB ? -1 : valueA > valueB ? 1 : 0; return order === 'asc' ? comparison : -comparison; }); - const selectedSubscription = - menuState !== null - ? notifications.find((n) => n.id === menuState.id) + const selectedRow = + menuRowKey !== null + ? rows.find((row) => row.key === menuRowKey) + : undefined; + const isPaused = selectedRow?.active === false; + + const settingsRow = + settingsRowKey !== null + ? rows.find((row) => row.key === settingsRowKey) : undefined; - const isPaused = selectedSubscription?.active === false; + // Checked means the feed belongs to the type's subscription, paused or not, + // so unchecking a paused type still removes the feed from it. + const settingsInitial = React.useMemo( + () => ({ + changeTypes: (settingsRow?.subscriptions ?? []) + .filter((s) => FEED_NOTIFICATION_TYPE_IDS.includes(s.notification_id)) + .map((s) => s.notification_id), + }), + [settingsRow], + ); + + // Row actions are disabled while one is in flight: feeds share + // subscriptions, and SWR drops the cache result of an earlier mutation on + // the same key, so overlapping changes could restore a removed row. + const [isUpdating, setIsUpdating] = React.useState(false); + const runUpdate = (update: Promise): void => { + setIsUpdating(true); + update + .catch(() => { + // Callers already report failures; this only covers the resync. + }) + .finally(() => { + setIsUpdating(false); + }); + }; const handleMenuOpen = ( event: React.MouseEvent, - id: string, + key: string, ): void => { - setMenuState({ anchor: event.currentTarget, id }); + setMenuAnchor(event.currentTarget); + setMenuRowKey(key); }; const handleMenuClose = (): void => { - setMenuState(null); + setMenuAnchor(null); }; + const handleOpenSettings = (): void => { + if (selectedRow !== undefined) { + setSettingsRowKey(selectedRow.key); + } + handleMenuClose(); + }; + + // Only offered for global rows: `active` is per notification type and is + // shared by every feed in it, so a single feed can't be paused. const handleTogglePause = (): void => { - if (menuState === null) { + handleMenuClose(); + if (selectedRow === undefined || selectedRow.feed != null) { return; } - const { id } = menuState; - const subscription = notifications.find((n) => n.id === id); - handleMenuClose(); - if (subscription === undefined) { + const nextActive = !selectedRow.active; + const ids = selectedRow.subscriptions + .filter((s) => s.active !== nextActive) + .map((s) => s.id); + if (ids.length === 0) { return; } - const nextActive = !subscription.active; - const withToggledActive = ( - current: NotificationSubscription[] | undefined, - ): NotificationSubscription[] => - (current ?? []).map((n) => - n.id === id ? { ...n, active: nextActive } : n, - ); - mutate( - updateUserSubscription(id, nextActive).then((updated) => [updated]), - { - optimisticData: withToggledActive, - rollbackOnError: true, - populateCache: ([updated], current) => - (current ?? []).map((n) => (n.id === id ? updated : n)), - revalidate: false, - }, - ).catch(() => { - setActionError('Failed to update the subscription'); - }); + runUpdate( + mutate( + settleAll(ids.map((id) => updateUserSubscription(id, nextActive))), + { + optimisticData: (current) => + (current ?? []).map((n) => + ids.includes(n.id) ? { ...n, active: nextActive } : n, + ), + rollbackOnError: true, + populateCache: (updated, current) => + (current ?? []).map((n) => updated.find((u) => u.id === n.id) ?? n), + revalidate: false, + }, + ).catch(async () => { + setActionError('Failed to update the subscription'); + // Some updates may have succeeded before the failure; resync. + await mutate(); + }), + ); }; const handleUnsubscribe = (): void => { - if (menuState !== null) { - const { id } = menuState; - handleMenuClose(); - deleteUserSubscription(id) - .then(() => mutate()) - .catch(() => { - setActionError('Failed to unsubscribe'); - }); + handleMenuClose(); + if (selectedRow === undefined) { + return; } - }; - - const handleSaveSettings = (settings: NotificationSettings): void => { - if (menuState !== null) { - setRowSettings((prev) => ({ ...prev, [menuState.id]: settings })); + const { feed, subscriptions } = selectedRow; + if (feed == null) { + runUpdate( + settleAll(subscriptions.map((s) => deleteUserSubscription(s.id))) + .then(async () => await mutate()) + .catch(async () => { + setActionError('Failed to unsubscribe'); + await mutate(); + }), + ); + return; } - setSettingsDialogOpen(false); - }; - const selectedRowId = menuState?.id; - const settingsInitial = - selectedRowId !== undefined - ? (rowSettings[selectedRowId] ?? defaultNotificationSettings) - : defaultNotificationSettings; + // A feed's subscriptions are shared with the user's other feeds of the + // same type, so remove just this feed rather than deleting them. + const ids = subscriptions.map((s) => s.id); + runUpdate( + mutate( + settleAll( + ids.map((id) => removeFeedsFromUserSubscription(id, [feed.feed_id])), + ).then(() => undefined), + { + optimisticData: (current) => + withoutFeed(current ?? [], ids, feed.feed_id), + rollbackOnError: true, + populateCache: (_, current) => + withoutFeed(current ?? [], ids, feed.feed_id), + revalidate: false, + }, + ).catch(async () => { + setActionError('Failed to unsubscribe'); + // Some removals may have succeeded before the failure; resync. + await mutate(); + }), + ); + }; return ( Status - - { - handleSortClick('subscribed'); - }} - > - Subscribed - - - {sortedNotifications.map((n) => ( - - {renderSubscriptionTitle(n)} - - {getNotificationTypeLabel(n.notification_id)} - + {sortedRows.map((row) => ( + + {renderRowTitle(row)} + {getNotificationTypesLabel(row)} - - {new Date(n.created_at).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - })} - { - handleMenuOpen(e, n.id); + handleMenuOpen(e, row.key); }} > @@ -351,9 +372,9 @@ export default function AccountNotifications(): React.ReactElement { ))} - {notifications.length === 0 && loadError === undefined && ( + {rows.length === 0 && loadError === undefined && ( - + No active subscriptions @@ -365,27 +386,37 @@ export default function AccountNotifications(): React.ReactElement { - - {isPaused ? 'Resume Notifications' : 'Pause Notifications'} - + {selectedRow?.feed != null ? ( + + Notification Settings + + ) : ( + + {isPaused ? 'Resume Notifications' : 'Pause Notifications'} + + )} Unsubscribe { - setSettingsDialogOpen(false); + setSettingsRowKey(null); + }} + onSave={() => { + setSettingsRowKey(null); }} - onSave={handleSaveSettings} initialSettings={settingsInitial} + feedId={settingsRow?.feed?.feed_id} + existingSubscriptions={settingsRow?.subscriptions} /> diff --git a/src/app/[locale]/account/notifications/lib/groupSubscriptions.spec.ts b/src/app/[locale]/account/notifications/lib/groupSubscriptions.spec.ts new file mode 100644 index 00000000..eb4f2844 --- /dev/null +++ b/src/app/[locale]/account/notifications/lib/groupSubscriptions.spec.ts @@ -0,0 +1,107 @@ +import type { NotificationSubscription } from '../../../../services/notification-service'; +import { getFeedTitle, groupSubscriptionsByFeed } from './groupSubscriptions'; + +const makeSubscription = ( + overrides: Partial, +): NotificationSubscription => ({ + id: 'sub-1', + user_id: 'user-1', + notification_id: 'feed.url_updated', + active: true, + created_at: '2026-01-01T00:00:00Z', + feeds: null, + ...overrides, +}); + +const feedA = { + feed_id: 'mdb-1', + data_type: 'gtfs', + provider: 'Provider A', + feed_name: 'Main', +}; +const feedB = { feed_id: 'mdb-2', data_type: 'gbfs', provider: 'Provider B' }; + +describe('groupSubscriptionsByFeed', () => { + it('returns no rows for no subscriptions', () => { + expect(groupSubscriptionsByFeed([])).toEqual([]); + }); + + it('merges every notification type for a feed into one row', () => { + const rows = groupSubscriptionsByFeed([ + makeSubscription({ + id: 'sub-1', + notification_id: 'unknown.type', + feeds: [feedA], + }), + makeSubscription({ + id: 'sub-2', + notification_id: 'feed.url_updated', + feeds: [feedA], + }), + makeSubscription({ id: 'sub-3', feeds: [feedB] }), + ]); + + expect(rows).toHaveLength(2); + expect(rows[0].key).toBe('feed:mdb-1'); + expect(rows[0].feed).toEqual(feedA); + expect(rows[0].subscriptions.map((s) => s.id)).toEqual(['sub-1', 'sub-2']); + // Known types first, in NOTIFICATION_TYPES order; unknown types last. + expect(rows[0].notificationIds).toEqual([ + 'feed.url_updated', + 'unknown.type', + ]); + expect(rows[1].key).toBe('feed:mdb-2'); + }); + + it('keeps one row per global subscription', () => { + const rows = groupSubscriptionsByFeed([ + makeSubscription({ id: 'a', notification_id: 'api.announcements' }), + makeSubscription({ + id: 'b', + notification_id: 'api.announcements', + feeds: [], + }), + ]); + + expect(rows.map((row) => row.key)).toEqual([ + 'subscription:a', + 'subscription:b', + ]); + expect(rows[0].feed).toBeUndefined(); + expect(rows[0].notificationIds).toEqual(['api.announcements']); + }); + + it('adds a multi-feed subscription to each of its feeds', () => { + const rows = groupSubscriptionsByFeed([ + makeSubscription({ id: 'multi', feeds: [feedA, feedB] }), + ]); + + expect(rows.map((row) => row.key)).toEqual(['feed:mdb-1', 'feed:mdb-2']); + expect(rows[1].subscriptions[0].id).toBe('multi'); + }); + + it('is active when any subscription is active', () => { + const [mixed] = groupSubscriptionsByFeed([ + makeSubscription({ id: '1', active: false, feeds: [feedA] }), + makeSubscription({ id: '2', active: true, feeds: [feedA] }), + ]); + const [paused] = groupSubscriptionsByFeed([ + makeSubscription({ id: '1', active: false, feeds: [feedA] }), + ]); + + expect(mixed.active).toBe(true); + expect(paused.active).toBe(false); + }); +}); + +describe('getFeedTitle', () => { + it('combines data type, provider and feed name', () => { + expect(getFeedTitle(feedA)).toBe('[GTFS] Provider A - Main'); + }); + + it('falls back to the feed ID when no names are set', () => { + expect( + getFeedTitle({ feed_id: 'mdb-9', data_type: 'gtfs', provider: ' ' }), + ).toBe('[GTFS] mdb-9'); + }); +}); diff --git a/src/app/[locale]/account/notifications/lib/groupSubscriptions.ts b/src/app/[locale]/account/notifications/lib/groupSubscriptions.ts new file mode 100644 index 00000000..4a8a3295 --- /dev/null +++ b/src/app/[locale]/account/notifications/lib/groupSubscriptions.ts @@ -0,0 +1,103 @@ +import type { + NotificationSubscription, + SubscriptionFeed, +} from '../../../../services/notification-service'; +import { NOTIFICATION_TYPES } from '../../../../utils/notificationTypes'; + +/** + * One row of the account notifications table: either every subscription + * targeting a single feed, or a single global (non-feed) subscription such as + * announcements. + */ +export interface NotificationRow { + /** Stable row key, unique across feed rows and global rows. */ + key: string; + /** The feed this row groups; undefined for global subscriptions. */ + feed?: SubscriptionFeed; + subscriptions: NotificationSubscription[]; + /** Distinct notification type IDs in this row, in `NOTIFICATION_TYPES` order. */ + notificationIds: string[]; + /** True when at least one of the row's subscriptions is active. */ + active: boolean; +} + +const nonEmpty = (value?: string | null): string | undefined => + value != null && value.trim() !== '' ? value : undefined; + +export const getFeedTitle = (feed: SubscriptionFeed): string => { + const titlePrefix = + feed.data_type != null && feed.data_type.trim() !== '' + ? `[${feed.data_type.toLocaleUpperCase()}]` + : ''; + const provider = nonEmpty(feed.provider); + const feedName = nonEmpty(feed.feed_name); + if (provider != null && feedName != null) { + return `${titlePrefix} ${provider} - ${feedName}`; + } + return `${titlePrefix} ${provider ?? feedName ?? feed.feed_id}`; +}; + +const getTypeOrder = (notificationId: string): number => { + const index = NOTIFICATION_TYPES.findIndex( + (type) => type.id === notificationId, + ); + return index === -1 ? NOTIFICATION_TYPES.length : index; +}; + +const toRow = ( + key: string, + feed: SubscriptionFeed | undefined, + subscriptions: NotificationSubscription[], +): NotificationRow => { + const notificationIds = [ + ...new Set(subscriptions.map((s) => s.notification_id)), + ].sort((a, b) => getTypeOrder(a) - getTypeOrder(b)); + return { + key, + feed, + subscriptions, + notificationIds, + active: subscriptions.some((s) => s.active), + }; +}; + +/** + * Groups subscriptions so each feed gets a single row holding all of its + * notification types. Subscriptions without feeds (global types) keep a row + * each. Rows are returned in order of first appearance. + */ +export const groupSubscriptionsByFeed = ( + subscriptions: NotificationSubscription[], +): NotificationRow[] => { + const groups = new Map< + string, + { feed?: SubscriptionFeed; subscriptions: NotificationSubscription[] } + >(); + const addToGroup = ( + key: string, + feed: SubscriptionFeed | undefined, + subscription: NotificationSubscription, + ): void => { + const group = groups.get(key); + if (group === undefined) { + groups.set(key, { feed, subscriptions: [subscription] }); + } else { + group.subscriptions.push(subscription); + } + }; + + subscriptions.forEach((subscription) => { + const feeds = subscription.feeds ?? []; + if (feeds.length === 0) { + addToGroup(`subscription:${subscription.id}`, undefined, subscription); + return; + } + feeds.forEach((feed) => { + addToGroup(`feed:${feed.feed_id}`, feed, subscription); + }); + }); + + return [...groups].map(([key, group]) => + toRow(key, group.feed, group.subscriptions), + ); +}; diff --git a/src/app/screens/Feed/components/ClientSubscribeControls.tsx b/src/app/screens/Feed/components/ClientSubscribeControls.tsx index 34a4d18a..e7e89fbb 100644 --- a/src/app/screens/Feed/components/ClientSubscribeControls.tsx +++ b/src/app/screens/Feed/components/ClientSubscribeControls.tsx @@ -22,11 +22,16 @@ import AccessRequiredPopover, { } from '../../../components/AccessRequiredPopover'; import { createUserSubscription, - deleteUserSubscription, getUserSubscriptions, type NotificationSubscription, + removeFeedsFromUserSubscription, USER_SUBSCRIPTIONS_SWR_KEY, } from '../../../services/notification-service'; +import { + settleAll, + upsertSubscriptions, + withoutFeed, +} from '../../../utils/subscriptionCache'; import { FEED_NOTIFICATION_TYPE_IDS } from '../../../utils/notificationTypes'; import NotificationSettingsDialog, { type NotificationSettings, @@ -137,10 +142,12 @@ export default function ClientSubscribeControls({ (subscription) => subscription.active, ); + // Checked means the feed belongs to the type's subscription, paused or not, + // so unchecking a paused type still removes the feed from it. const dialogInitialSettings: NotificationSettings = { - changeTypes: feedSubscriptions - .filter((subscription) => subscription.active) - .map((subscription) => subscription.notification_id), + changeTypes: feedSubscriptions.map( + (subscription) => subscription.notification_id, + ), }; if (!config.isNotificationsEnabled) { @@ -164,10 +171,10 @@ export default function ClientSubscribeControls({ ...optimisticSubscriptions, ], rollbackOnError: true, - populateCache: (created, current) => [ - ...withoutIds(current, optimisticIds), - ...created, - ], + // `created` can include an existing subscription (same ID) that the + // feed was merged into, so replace rather than append. + populateCache: (created, current) => + upsertSubscriptions(withoutIds(current, optimisticIds), created), revalidate: false, }) .then((created) => { @@ -186,20 +193,24 @@ export default function ClientSubscribeControls({ const handleUnsubscribe = (): void => { setMenuAnchor(null); - const idsToRemove = feedSubscriptions.map( + // These subscriptions are shared with the user's other feeds of the same + // type, so remove only this feed rather than deleting them. + const subscriptionIds = feedSubscriptions.map( (subscription) => subscription.id, ); mutateSubscriptions( - Promise.all( - feedSubscriptions.map((subscription) => - deleteUserSubscription(subscription.id), + settleAll( + subscriptionIds.map((id) => + removeFeedsFromUserSubscription(id, [feedId]), ), ).then(() => undefined), { - optimisticData: (current) => withoutIds(current, idsToRemove), + optimisticData: (current) => + withoutFeed(current ?? [], subscriptionIds, feedId), rollbackOnError: true, - populateCache: (_, current) => withoutIds(current, idsToRemove), + populateCache: (_, current) => + withoutFeed(current ?? [], subscriptionIds, feedId), revalidate: false, }, ) @@ -208,6 +219,8 @@ export default function ClientSubscribeControls({ }) .catch(() => { notify('error', 'unsubscribeFailed'); + // Some removals may have succeeded before the failure; resync. + void mutateSubscriptions(); }); }; diff --git a/src/app/screens/Feed/components/NotificationSettingsDialog.tsx b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx index 68fe6a0b..3ad626ae 100644 --- a/src/app/screens/Feed/components/NotificationSettingsDialog.tsx +++ b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx @@ -20,10 +20,11 @@ import FormLabel from '@mui/material/FormLabel'; import Tooltip from '@mui/material/Tooltip'; import { createUserSubscription, - deleteUserSubscription, type NotificationSubscription, + removeFeedsFromUserSubscription, USER_SUBSCRIPTIONS_SWR_KEY, } from '../../../services/notification-service'; +import { settleAll } from '../../../utils/subscriptionCache'; import { FEED_NOTIFICATION_TYPE_IDS, FEED_NOTIFICATION_TYPES, @@ -73,23 +74,33 @@ export default function NotificationSettingsDialog({ } = useSWRMutation( ['notification-settings', feedId ?? ''], async (_key, { arg }: { arg: SettingsChange }): Promise => { - await Promise.all([ - ...arg.addedTypes.map((notificationId) => - createUserSubscription({ - notification_id: notificationId, - feed_ids: [feedId as string], + try { + await settleAll([ + ...arg.addedTypes.map( + async (notificationId) => + await createUserSubscription({ + notification_id: notificationId, + feed_ids: [feedId as string], + }), + ), + // The type's subscription is shared with the user's other feeds, so + // remove only this feed from it rather than deleting it. + ...arg.removedTypes.map(async (notificationId) => { + const subscription = existingSubscriptions.find( + (s) => s.notification_id === notificationId, + ); + if (subscription !== undefined) { + await removeFeedsFromUserSubscription(subscription.id, [ + feedId as string, + ]); + } }), - ), - ...arg.removedTypes.map((notificationId) => { - const subscription = existingSubscriptions.find( - (s) => s.notification_id === notificationId, - ); - return subscription !== undefined - ? deleteUserSubscription(subscription.id) - : Promise.resolve(); - }), - ]); - void mutate(USER_SUBSCRIPTIONS_SWR_KEY); // This is the global notification key + ]); + } finally { + // Resync even after a partial failure, so the cache reflects the + // requests that did succeed. This is the global notification key. + void mutate(USER_SUBSCRIPTIONS_SWR_KEY); + } }, ); diff --git a/src/app/services/notification-service.ts b/src/app/services/notification-service.ts index a10e54fb..5a5dc672 100644 --- a/src/app/services/notification-service.ts +++ b/src/app/services/notification-service.ts @@ -70,9 +70,41 @@ export const updateUserSubscription = async ( } }; +/** + * Remove feeds from a feed-scoped subscription, leaving its other feeds + * subscribed. The backend keeps a single subscription per notification type, + * so this (not delete) is how to unsubscribe from one feed. Removing the last + * remaining feed deletes the subscription. + */ +export const removeFeedsFromUserSubscription = async ( + id: string, + feedIds: string[], +): Promise => { + const accessToken = await getUserAccessToken(); + const authMiddleware = generateAuthMiddlewareWithToken(accessToken); + userServiceClient.use(authMiddleware); + try { + const { data, error } = await userServiceClient.PATCH( + '/v1/user/subscriptions/{id}', + { + params: { path: { id } }, + body: { remove_feed_ids: feedIds }, + }, + ); + if (error !== undefined || data === undefined) { + throw new Error('Failed to remove feeds from user subscription'); + } + return data; + } finally { + userServiceClient.eject(authMiddleware); + } +}; + /** * Subscribe the current user to a notification type, optionally scoped to - * specific feeds. + * specific feeds. For feed-scoped types the feeds are merged into the user's + * existing subscription for that type, so the response may be an existing + * subscription (same ID) with more feeds. */ export const createUserSubscription = async ( request: CreateNotificationSubscriptionRequest, diff --git a/src/app/services/user-service-api-types.ts b/src/app/services/user-service-api-types.ts index d82e6996..b84f912d 100644 --- a/src/app/services/user-service-api-types.ts +++ b/src/app/services/user-service-api-types.ts @@ -65,7 +65,7 @@ export interface paths { put?: never; /** * Create a notification subscription - * @description Subscribes the authenticated user to a notification type. This feature is available to selected users. To request access or learn more, contact us at api@mobilitydata.org. + * @description Subscribes the authenticated user to a notification type. For feed-scoped types, the supplied `feed_ids` are merged into the subscription's existing feed set rather than replacing it, so callers do not need to know which feeds the user is already subscribed to. This feature is available to selected users. To request access or learn more, contact us at api@mobilitydata.org. */ post: operations['createUserSubscription']; delete?: never; @@ -74,6 +74,46 @@ export interface paths { patch?: never; trace?: never; }; + '/v1/user/subscriptions/feeds': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 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. + */ + get: operations['getUserSubscriptionFeeds']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v1/user/subscriptions/feeds/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 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. + */ + get: operations['getUserSubscriptionFeedById']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/v1/user/subscriptions/{id}': { parameters: { query?: never; @@ -92,8 +132,8 @@ export interface paths { options?: never; head?: never; /** - * Toggle a notification subscription - * @description Activates or deactivates a notification subscription by ID. This feature is available to selected users. To request access or learn more, contact us at api@mobilitydata.org. + * Toggle a notification subscription, or remove specific feeds from it + * @description Activates or deactivates a notification subscription by ID, and/or removes specific feed stable IDs from a feed-scoped subscription's feed set via `remove_feed_ids`. To add feeds instead, use `POST /v1/user/subscriptions`, which merges into the existing feed set. A feed-scoped subscription cannot exist with no feeds, so removing the last remaining feed deletes the subscription entirely; the response reflects its final state. This feature is available to selected users. To request access or learn more, contact us at api@mobilitydata.org. */ patch: operations['updateUserSubscription']; trace?: never; @@ -108,16 +148,50 @@ export interface paths { /** * Get a subscription by ID * @description Returns a single notification subscription identified by its ID. + * + * **No end-user authentication** — the subscription UUID (`id`) is the + * access capability. The call itself must still be made by an authorized IAP + * principal (typically the website backend server-side) to render the + * unsubscribe page, so the recipient can see which notification they are + * about to unsubscribe from before confirming. Returns `404` for an + * unknown/invalid ID. */ get: operations['getSubscription']; put?: never; post?: never; /** - * Delete or disable a subscription by ID - * @description Removes a notification subscription identified by its ID. The - * announcements subscription (`api.announcements`) cannot be deleted; - * calling this endpoint for it disables the subscription (sets it inactive) - * instead of removing it. This feature is available to selected users. To request access or learn more, contact us at api@mobilitydata.org. + * Unsubscribe by subscription ID + * @description Unsubscribes using the subscription UUID (`id`). This backs the email + * "unsubscribe" link — **no end-user authentication**, the UUID is the + * capability. The call must still be made by an authorized IAP principal + * (the website backend server-side); the anonymous recipient never calls + * the API directly. + * + * Behaviour depends on the notification type: + * - **`api.announcements`** — never deleted; the subscription is *disabled* + * (`active=false`), the contact is removed from the Brevo announcements + * list, and `app_user.is_registered_to_receive_api_announcements` is + * cleared. Kept idempotent so a repeated click still succeeds. + * - **all other types** (e.g. `feed.url_updated`) — the subscription row is + * hard-deleted; a second call therefore returns `404`. + * + * Returns `404` for an unknown/invalid ID, and `502` if the email provider + * (Brevo) cannot be reached while updating an announcements subscription. + * + * The `id` embedded in an email is the subscription's own UUID + * (`notification_subscription.id`) — mirrored onto the Brevo contact as the + * `MDB_SUBSCRIPTION_ID` attribute — **not** the notification-type string + * (`api.announcements`), which is shared by all recipients and is not a + * valid subscription ID. + * + * With `scope=all`, the subscription ID is used only to resolve the owning + * user, and every subscription that user holds is unsubscribed (normal types + * deleted; `api.announcements` disabled and removed from Brevo). With + * `scope=one` (the default) only the single subscription identified by `id` + * is affected. + * + * This feature is available to selected users. To request access or learn + * more, contact us at api@mobilitydata.org. */ delete: operations['deleteSubscription']; options?: never; @@ -196,24 +270,24 @@ export interface components { }; NotificationType: { /** - * @description Unique identifier for the notification type (e.g. 'feed.published'). - * @example feed.published + * @description Unique identifier for the notification type (e.g. 'feed.url_updated', 'api.announcements'). + * @example feed.url_updated */ id: string; /** * @description Human-readable description of the notification type. - * @example Notifies when a new feed is published. + * @example Fired when a feed URL changes in-place or a feed is deprecated and redirected. */ description?: string | null; }; NotificationSubscription: { - /** @description Unique subscription ID (UUID v4). */ + /** @description Unique subscription ID (UUID v4). This is the **capability token** used by the public `/v1/subscriptions/{id}` endpoints and by email unsubscribe links; for `api.announcements` it is mirrored onto the Brevo contact as the `MDB_SUBSCRIPTION_ID` attribute. */ id: string; /** @description The ID of the subscribed user. */ user_id: string; /** * @description The notification type this subscription is for. - * @example feed.published + * @example feed.url_updated */ notification_id: string; /** @@ -249,14 +323,56 @@ export interface components { /** @description The feed's display name. */ feed_name?: string | null; }; + /** @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). */ + FeedSubscriptionSummary: { + /** @description Unique subscription ID (UUID v4). */ + id: string; + /** + * @description The notification type this subscription is for. + * @example feed.url_updated + */ + notification_id: string; + /** + * @description Whether the subscription is currently active. + * @default true + */ + active: boolean; + /** + * Format: date-time + * @description Timestamp when the subscription was created. + */ + created_at: string; + }; + /** @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). */ + SubscriptionFeedGroup: { + /** + * @description The feed's stable ID. + * @example mdb-1 + */ + feed_id: string; + /** + * @description The feed's data type. + * @example gtfs + */ + data_type?: string | null; + /** + * @description The transit/mobility data provider name. + * @example Metropolitan Transit Authority + */ + provider?: string | null; + /** @description The feed's display name. */ + feed_name?: string | null; + /** @description The authenticated user's subscriptions that target this feed. */ + subscriptions: components['schemas']['FeedSubscriptionSummary'][]; + }; CreateNotificationSubscriptionRequest: { /** * @description The notification type to subscribe to. - * @example feed.published + * @example feed.url_updated */ notification_id: string; /** - * @description Feed stable IDs to subscribe to. Required (non-empty) for the feed-scoped notification types feed.url_updated, feed.url_availability and feed.coverage; must be omitted or empty for other types. Validation is enforced in code, not the schema. + * @description Feed stable IDs to subscribe to. Required (non-empty) for the feed-scoped notification types feed.url_updated, feed.url_availability and feed.coverage; must be omitted or empty for other types. Merged into the subscription's existing feed set (union), not a replacement. Validation is enforced in code, not the schema. * @example [ * "mdb-1", * "mdb-42" @@ -266,7 +382,14 @@ export interface components { }; UpdateNotificationSubscriptionRequest: { /** @description Whether the subscription should be active. */ - active: boolean; + active?: boolean; + /** + * @description Feed stable IDs to remove from a feed-scoped subscription's feed set. Only valid for feed-scoped notification types; removing a feed ID that is not currently present is a no-op. Removing every remaining feed ID deletes the subscription entirely (a feed-scoped subscription cannot exist with no feeds). Validation is enforced in code, not the schema. + * @example [ + * "mdb-1" + * ] + */ + remove_feed_ids?: string[]; }; FeatureFlag: { /** @@ -481,12 +604,86 @@ export interface operations { }; }; }; + getUserSubscriptionFeeds: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description List of feeds with the subscriptions targeting them. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SubscriptionFeedGroup'][]; + }; + }; + /** @description Unauthorized. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Forbidden — guest users cannot perform this action. */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getUserSubscriptionFeedById: { + parameters: { + query?: never; + header?: never; + path: { + /** + * @description The feed's stable ID. + * @example mdb-1 + */ + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The feed with the subscriptions targeting it. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['SubscriptionFeedGroup']; + }; + }; + /** @description Unauthorized. */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Feed not found, or the user has no subscription targeting it. */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; deleteUserSubscription: { parameters: { query?: never; header?: never; path: { - /** @description Subscription ID. */ + /** @description The subscription ID (notification_subscription.id) — a UUID that is the subscription row's own identifier, not the user ID and not the notification-type ID. */ id: string; }; cookie?: never; @@ -521,7 +718,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description Subscription ID. */ + /** @description The subscription ID (notification_subscription.id) — a UUID that is the subscription row's own identifier, not the user ID and not the notification-type ID. */ id: string; }; cookie?: never; @@ -562,7 +759,7 @@ export interface operations { query?: never; header?: never; path: { - /** @description Subscription ID. */ + /** @description The subscription ID (notification_subscription.id) — a UUID that is the subscription row's own identifier, not the user ID and not the notification-type ID. */ id: string; }; cookie?: never; @@ -589,10 +786,13 @@ export interface operations { }; deleteSubscription: { parameters: { - query?: never; + query?: { + /** @description 'one' (default) unsubscribes only the subscription identified by the path `id`. 'all' unsubscribes the owning user from every notification type, using the path `id` solely to identify that user. */ + scope?: 'one' | 'all'; + }; header?: never; path: { - /** @description Subscription ID. */ + /** @description The subscription ID (notification_subscription.id) — a UUID that is the subscription row's own identifier, not the user ID and not the notification-type ID. */ id: string; }; cookie?: never; @@ -606,6 +806,13 @@ export interface operations { }; content?: never; }; + /** @description Invalid `scope` value (must be `one` or `all`). */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; /** @description Subscription not found. */ 404: { headers: { diff --git a/src/app/utils/subscriptionCache.spec.ts b/src/app/utils/subscriptionCache.spec.ts new file mode 100644 index 00000000..ff6ccac4 --- /dev/null +++ b/src/app/utils/subscriptionCache.spec.ts @@ -0,0 +1,103 @@ +import type { NotificationSubscription } from '../services/notification-service'; +import { + settleAll, + upsertSubscriptions, + withoutFeed, +} from './subscriptionCache'; + +describe('settleAll', () => { + it('resolves with every value in order', async () => { + await expect( + settleAll([Promise.resolve(1), Promise.resolve(2)]), + ).resolves.toEqual([1, 2]); + }); + + it('rejects only after every promise has settled', async () => { + let slowSettled = false; + const slow = new Promise((resolve) => { + setTimeout(() => { + slowSettled = true; + resolve(2); + }, 10); + }); + const failure = new Error('boom'); + + await expect(settleAll([Promise.reject(failure), slow])).rejects.toBe( + failure, + ); + expect(slowSettled).toBe(true); + }); +}); + +const makeSubscription = ( + overrides: Partial, +): NotificationSubscription => ({ + id: 'sub-1', + user_id: 'user-1', + notification_id: 'feed.url_updated', + active: true, + created_at: '2026-01-01T00:00:00Z', + feeds: null, + ...overrides, +}); + +describe('upsertSubscriptions', () => { + it('replaces a cached subscription with the same ID', () => { + const cached = makeSubscription({ feeds: [{ feed_id: 'mdb-1' }] }); + const merged = makeSubscription({ + feeds: [{ feed_id: 'mdb-1' }, { feed_id: 'mdb-2' }], + }); + + const result = upsertSubscriptions([cached], [merged]); + + expect(result).toHaveLength(1); + expect(result[0]).toBe(merged); + }); + + it('appends subscriptions that are not cached yet', () => { + const cached = makeSubscription({ id: 'a' }); + const created = makeSubscription({ id: 'b' }); + + expect(upsertSubscriptions([cached], [created]).map((s) => s.id)).toEqual([ + 'a', + 'b', + ]); + }); +}); + +describe('withoutFeed', () => { + const shared = makeSubscription({ + id: 'shared', + feeds: [{ feed_id: 'mdb-1' }, { feed_id: 'mdb-2' }], + }); + const single = makeSubscription({ + id: 'single', + notification_id: 'feed.coverage', + feeds: [{ feed_id: 'mdb-1' }], + }); + const announcements = makeSubscription({ + id: 'announcements', + notification_id: 'api.announcements', + }); + + it('removes only the given feed from a shared subscription', () => { + const [result] = withoutFeed([shared], ['shared'], 'mdb-1'); + + expect(result.id).toBe('shared'); + expect(result.feeds).toEqual([{ feed_id: 'mdb-2' }]); + }); + + it('drops a subscription whose last feed is removed', () => { + expect(withoutFeed([single], ['single'], 'mdb-1')).toEqual([]); + }); + + it('leaves subscriptions that were not targeted untouched', () => { + const result = withoutFeed( + [shared, single, announcements], + ['single'], + 'mdb-1', + ); + + expect(result).toEqual([shared, announcements]); + }); +}); diff --git a/src/app/utils/subscriptionCache.ts b/src/app/utils/subscriptionCache.ts new file mode 100644 index 00000000..d6d62bf9 --- /dev/null +++ b/src/app/utils/subscriptionCache.ts @@ -0,0 +1,54 @@ +import type { NotificationSubscription } from '../services/notification-service'; + +/** + * Replaces cached subscriptions with incoming ones that share their ID and + * appends the rest. Subscribing to a feed merges it into the user's existing + * subscription for that notification type, so the API can return a + * subscription that is already cached. + */ +export const upsertSubscriptions = ( + current: NotificationSubscription[], + incoming: NotificationSubscription[], +): NotificationSubscription[] => { + const incomingById = new Map(incoming.map((s) => [s.id, s])); + const currentIds = new Set(current.map((s) => s.id)); + return [ + ...current.map((s) => incomingById.get(s.id) ?? s), + ...incoming.filter((s) => !currentIds.has(s.id)), + ]; +}; + +/** + * Like `Promise.all`, but waits for every promise to settle before rejecting, + * so a resync after a partial failure sees all the requests that succeeded. + */ +export const settleAll = async ( + promises: Array>, +): Promise => { + const results = await Promise.allSettled(promises); + const rejected = results.find( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ); + if (rejected !== undefined) { + throw rejected.reason; + } + return results.map((result) => (result as PromiseFulfilledResult).value); +}; + +/** + * Removes a feed from the given subscriptions, mirroring + * `remove_feed_ids`: a subscription left with no feeds is dropped, since the + * backend deletes it. + */ +export const withoutFeed = ( + current: NotificationSubscription[], + subscriptionIds: string[], + feedId: string, +): NotificationSubscription[] => + current.flatMap((s) => { + if (!subscriptionIds.includes(s.id)) { + return [s]; + } + const feeds = (s.feeds ?? []).filter((feed) => feed.feed_id !== feedId); + return feeds.length === 0 ? [] : [{ ...s, feeds }]; + });