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/messages/en.json b/messages/en.json index 68458300..00e8ed25 100644 --- a/messages/en.json +++ b/messages/en.json @@ -144,6 +144,26 @@ "missingCode": "This link is missing the security code it needs.", "backToSignIn": "Go to sign in" }, + "unsubscribe": { + "loadingTitle": "Loading your subscription", + "loadingDescription": "Please wait while we look up this notification.", + "title": "Unsubscribe from emails", + "description": "This link is for your {type} notifications only. Unsubscribing from them won't change any of your other Mobility Database notifications.", + "alreadyUnsubscribedTitle": "Already unsubscribed", + "alreadyUnsubscribedDescription": "You no longer receive {type} notifications. You can still unsubscribe from all your other notifications below.", + "unsubscribeOne": "Unsubscribe from {type} only", + "unsubscribeAll": "Unsubscribe from all notifications", + "manageNotifications": "Manage notifications", + "manageHint": "To choose which feeds or notification types you hear about, use Manage notifications.", + "successTitle": "You've been unsubscribed", + "successOneDescription": "You'll no longer receive {type} notifications. Your other notifications are unchanged.", + "successAllDescription": "You'll no longer receive any Mobility Database notification emails.", + "errorTitle": "Unable to unsubscribe", + "notFoundDescription": "This unsubscribe link is invalid, or you've already unsubscribed.", + "loadFailedDescription": "We couldn't load this subscription. Please try again in a moment.", + "unsubscribeFailed": "Something went wrong and you're still subscribed. Please try again.", + "tryAgain": "Try again" + }, "resetPassword": { "verifyingTitle": "Checking your link", "verifyingDescription": "Please wait while we confirm your password reset link.", diff --git a/messages/fr.json b/messages/fr.json index fa2a786a..a0e62612 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -144,6 +144,26 @@ "missingCode": "Il manque à ce lien le code de sécurité nécessaire.", "backToSignIn": "Aller à la connexion" }, + "unsubscribe": { + "loadingTitle": "Chargement de votre abonnement", + "loadingDescription": "Veuillez patienter pendant que nous recherchons cette notification.", + "title": "Se désabonner des courriels", + "description": "Ce lien concerne uniquement vos notifications {type}. Vous désabonner de celles-ci ne modifiera aucune de vos autres notifications de Mobility Database.", + "alreadyUnsubscribedTitle": "Déjà désabonné", + "alreadyUnsubscribedDescription": "Vous ne recevez plus les notifications {type}. Vous pouvez tout de même vous désabonner de toutes vos autres notifications ci-dessous.", + "unsubscribeOne": "Se désabonner de {type} uniquement", + "unsubscribeAll": "Se désabonner de toutes les notifications", + "manageNotifications": "Gérer les notifications", + "manageHint": "Pour choisir les flux ou les types de notifications qui vous intéressent, utilisez Gérer les notifications.", + "successTitle": "Vous êtes désabonné", + "successOneDescription": "Vous ne recevrez plus les notifications {type}. Vos autres notifications restent inchangées.", + "successAllDescription": "Vous ne recevrez plus aucun courriel de notification de Mobility Database.", + "errorTitle": "Impossible de se désabonner", + "notFoundDescription": "Ce lien de désabonnement n'est pas valide, ou vous êtes déjà désabonné.", + "loadFailedDescription": "Nous n'avons pas pu charger cet abonnement. Veuillez réessayer dans un instant.", + "unsubscribeFailed": "Une erreur s'est produite et vous êtes toujours abonné. Veuillez réessayer.", + "tryAgain": "Réessayer" + }, "resetPassword": { "verifyingTitle": "Vérification de votre lien", "verifyingDescription": "Veuillez patienter pendant que nous vérifions votre lien de réinitialisation.", diff --git a/src/app/[locale]/notifications/unsubscribe/UnsubscribeContent.tsx b/src/app/[locale]/notifications/unsubscribe/UnsubscribeContent.tsx new file mode 100644 index 00000000..69c2ca4c --- /dev/null +++ b/src/app/[locale]/notifications/unsubscribe/UnsubscribeContent.tsx @@ -0,0 +1,247 @@ +'use client'; + +import * as React from 'react'; +import useSWR from 'swr'; +import { useSearchParams } from 'next/navigation'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import NotificationsOffOutlinedIcon from '@mui/icons-material/NotificationsOffOutlined'; +import { + Alert, + Button, + CircularProgress, + Stack, + Typography, + useTheme, +} from '@mui/material'; +import { useTranslations } from 'next-intl'; +import { ContentBox } from '../../../components/ContentBox'; +import { useAuthSession } from '../../../components/AuthSessionProvider'; +import { useAppDispatch, useAppSelector } from '../../../hooks'; +import { anonymousLogin } from '../../../store/profile-reducer'; +import { + ACCOUNT_NOTIFICATIONS_TARGET, + SIGN_IN_TARGET, +} from '../../../constants/Navigation'; +import { + getSubscriptionById, + unsubscribeBySubscriptionId, + type UnsubscribeScope, +} from '../../../services/notification-service'; +import { Link } from '../../../../i18n/navigation'; +import { + getNotificationTypeLabelKey, + isValidSubscriptionId, + resolveViewCopy, + toSubscriptionLookup, + toUnsubscribeResult, + type UnsubscribeResult, +} from './lib/unsubscribe'; + +export default function UnsubscribeContent(): React.ReactElement { + const t = useTranslations('unsubscribe'); + const tFeeds = useTranslations('feeds'); + const theme = useTheme(); + const searchParams = useSearchParams(); + const { isAuthReady } = useAuthSession(); + const isRegistered = useAppSelector( + (state) => state.userProfile.status === 'registered', + ); + // Set when anonymous sign-in failed or was skipped; with no Firebase user + // the lookup below would otherwise wait forever. + const anonymousLoginError = useAppSelector( + (state) => state.userProfile.errors.AnonymousLogin != null, + ); + const dispatch = useAppDispatch(); + const [isPending, startTransition] = React.useTransition(); + const [pendingScope, setPendingScope] = + React.useState(null); + const [result, setResult] = React.useState<{ + scope: UnsubscribeScope; + outcome: UnsubscribeResult; + } | null>(null); + + const idParam = searchParams.get('id'); + const subscriptionId = isValidSubscriptionId(idParam) ? idParam : undefined; + + // Calls need a Firebase user; signed-out visitors get an anonymous one + // shortly after load, so the request waits for it. + const { data, error, mutate } = useSWR( + subscriptionId !== undefined && isAuthReady + ? ['subscription', subscriptionId] + : null, + async ([, id]: [string, string]) => await getSubscriptionById(id), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + shouldRetryOnError: false, + }, + ); + + const lookup = toSubscriptionLookup({ + subscriptionId, + data, + error, + authFailed: !isAuthReady && anonymousLoginError, + }); + const view = resolveViewCopy(lookup, result); + + // Types the app doesn't label yet fall back to their raw id. + let notificationType = ''; + if (lookup.status === 'found') { + const labelKey = getNotificationTypeLabelKey(lookup.notificationId); + notificationType = + labelKey !== undefined ? tFeeds(labelKey) : lookup.notificationId; + } + + // Signed-out (or not yet registered) users go through sign-in, which sends + // them on to the notifications page once their account is ready. + const manageHref = isRegistered + ? ACCOUNT_NOTIFICATIONS_TARGET + : `${SIGN_IN_TARGET}?redirect_to=${encodeURIComponent( + ACCOUNT_NOTIFICATIONS_TARGET, + )}`; + + // The subscription the buttons act on, while the user can still act. + const actionable = + lookup.status === 'found' && view.status === 'ready' ? lookup : null; + + const handleUnsubscribe = (scope: UnsubscribeScope): void => { + if (subscriptionId === undefined) { + return; + } + setPendingScope(scope); + startTransition(async () => { + let outcome: UnsubscribeResult; + try { + outcome = toUnsubscribeResult( + await unsubscribeBySubscriptionId(subscriptionId, scope), + ); + } catch { + outcome = 'error'; + } + setResult({ scope, outcome }); + }); + }; + + const spinnerFor = (scope: UnsubscribeScope): React.ReactNode => + isPending && pendingScope === scope ? ( + + ) : undefined; + + const strong = (chunks: React.ReactNode): React.ReactElement => ( + {chunks} + ); + + return ( + + + {view.status === 'loading' ? ( + + ) : view.status === 'success' ? ( + + ) : view.status === 'error' ? ( + + ) : ( + + )} + + + + {t(view.titleKey)} + + + {t.rich(view.descriptionKey, { type: notificationType, strong })} + + + + {result?.outcome === 'error' ? ( + + {t('unsubscribeFailed')} + + ) : null} + + {view.status === 'loading' ? null : ( + + {actionable?.active === true ? ( + + ) : null} + {actionable !== null ? ( + + ) : null} + {lookup.status === 'error' ? ( + + ) : null} + + + )} + + {actionable !== null ? ( + + {t('manageHint')} + + ) : null} + + + ); +} diff --git a/src/app/[locale]/notifications/unsubscribe/lib/unsubscribe.spec.ts b/src/app/[locale]/notifications/unsubscribe/lib/unsubscribe.spec.ts new file mode 100644 index 00000000..c4a88ffc --- /dev/null +++ b/src/app/[locale]/notifications/unsubscribe/lib/unsubscribe.spec.ts @@ -0,0 +1,203 @@ +import { + getNotificationTypeLabelKey, + isValidSubscriptionId, + resolveViewCopy, + toSubscriptionLookup, + toUnsubscribeResult, +} from './unsubscribe'; + +describe('isValidSubscriptionId', () => { + it('accepts a UUID in either case', () => { + expect(isValidSubscriptionId('3f2b8c1e-9a4d-4e7b-8c2a-1d5e6f7a8b9c')).toBe( + true, + ); + expect(isValidSubscriptionId('3F2B8C1E-9A4D-4E7B-8C2A-1D5E6F7A8B9C')).toBe( + true, + ); + }); + + it('rejects malformed, missing and non-string values', () => { + expect(isValidSubscriptionId('not-a-uuid')).toBe(false); + expect(isValidSubscriptionId('3f2b8c1e-9a4d-4e7b-8c2a-1d5e6f7a8b9')).toBe( + false, + ); + expect(isValidSubscriptionId(' 3f2b8c1e-9a4d-4e7b-8c2a-1d5e6f7a8b9c')).toBe( + false, + ); + expect(isValidSubscriptionId('api.announcements')).toBe(false); + expect(isValidSubscriptionId('')).toBe(false); + // searchParams.get() returns null when the param is absent. + expect(isValidSubscriptionId(null)).toBe(false); + }); +}); + +describe('toSubscriptionLookup', () => { + const id = '3f2b8c1e-9a4d-4e7b-8c2a-1d5e6f7a8b9c'; + + it('reports an invalid link before anything else', () => { + expect( + toSubscriptionLookup({ + subscriptionId: undefined, + data: undefined, + error: new Error('ignored'), + authFailed: true, + }), + ).toEqual({ status: 'invalidId' }); + }); + + it('is loading until the request settles', () => { + expect( + toSubscriptionLookup({ + subscriptionId: id, + data: undefined, + error: null, + authFailed: false, + }), + ).toEqual({ status: 'loading' }); + }); + + it('maps a missing subscription to notFound', () => { + expect( + toSubscriptionLookup({ + subscriptionId: id, + data: null, + error: null, + authFailed: false, + }), + ).toEqual({ status: 'notFound' }); + }); + + it('maps a failed request to error', () => { + expect( + toSubscriptionLookup({ + subscriptionId: id, + data: undefined, + error: new Error('network'), + authFailed: false, + }), + ).toEqual({ status: 'error' }); + }); + + it('maps a failed anonymous sign-in to error, since no request will run', () => { + expect( + toSubscriptionLookup({ + subscriptionId: id, + data: undefined, + error: undefined, + authFailed: true, + }), + ).toEqual({ status: 'error' }); + }); + + it('keeps only the fields the page renders', () => { + expect( + toSubscriptionLookup({ + subscriptionId: id, + data: { notification_id: 'feed.url_updated', active: false }, + error: undefined, + authFailed: false, + }), + ).toEqual({ + status: 'found', + notificationId: 'feed.url_updated', + active: false, + }); + }); +}); + +describe('resolveViewCopy', () => { + const found = { + status: 'found', + notificationId: 'feed.url_updated', + active: true, + } as const; + + it('asks the user to choose while the subscription is active', () => { + expect(resolveViewCopy(found, null)).toEqual({ + status: 'ready', + titleKey: 'title', + descriptionKey: 'description', + }); + }); + + it('says so when the subscription is already inactive', () => { + expect(resolveViewCopy({ ...found, active: false }, null)).toEqual({ + status: 'ready', + titleKey: 'alreadyUnsubscribedTitle', + descriptionKey: 'alreadyUnsubscribedDescription', + }); + }); + + it('describes success according to the scope', () => { + expect( + resolveViewCopy(found, { scope: 'one', outcome: 'success' }) + .descriptionKey, + ).toBe('successOneDescription'); + expect( + resolveViewCopy(found, { scope: 'all', outcome: 'success' }) + .descriptionKey, + ).toBe('successAllDescription'); + }); + + it('treats a 404 on unsubscribe as an invalid or used link', () => { + expect( + resolveViewCopy(found, { scope: 'one', outcome: 'notFound' }), + ).toEqual({ + status: 'error', + titleKey: 'errorTitle', + descriptionKey: 'notFoundDescription', + }); + }); + + it('keeps the choice open after a failed unsubscribe', () => { + expect( + resolveViewCopy(found, { scope: 'all', outcome: 'error' }).status, + ).toBe('ready'); + }); + + it('distinguishes a failed lookup from a bad link', () => { + expect(resolveViewCopy({ status: 'error' }, null).descriptionKey).toBe( + 'loadFailedDescription', + ); + expect(resolveViewCopy({ status: 'invalidId' }, null).descriptionKey).toBe( + 'notFoundDescription', + ); + expect(resolveViewCopy({ status: 'notFound' }, null).descriptionKey).toBe( + 'notFoundDescription', + ); + expect(resolveViewCopy({ status: 'loading' }, null).status).toBe('loading'); + }); +}); + +describe('toUnsubscribeResult', () => { + it('treats 2xx as success', () => { + expect(toUnsubscribeResult(204)).toBe('success'); + expect(toUnsubscribeResult(200)).toBe('success'); + }); + + it('maps 404 to notFound', () => { + expect(toUnsubscribeResult(404)).toBe('notFound'); + }); + + it('maps other failures to error', () => { + expect(toUnsubscribeResult(400)).toBe('error'); + expect(toUnsubscribeResult(401)).toBe('error'); + expect(toUnsubscribeResult(500)).toBe('error'); + expect(toUnsubscribeResult(502)).toBe('error'); + }); +}); + +describe('getNotificationTypeLabelKey', () => { + it('returns the label key of a known type', () => { + expect(getNotificationTypeLabelKey('feed.url_updated')).toBe( + 'feedUrlUpdatedLabel', + ); + expect(getNotificationTypeLabelKey('api.announcements')).toBe( + 'apiAnnouncementsLabel', + ); + }); + + it('returns undefined for a type the app does not label', () => { + expect(getNotificationTypeLabelKey('feed.unknown')).toBeUndefined(); + }); +}); diff --git a/src/app/[locale]/notifications/unsubscribe/lib/unsubscribe.ts b/src/app/[locale]/notifications/unsubscribe/lib/unsubscribe.ts new file mode 100644 index 00000000..e8d3eedb --- /dev/null +++ b/src/app/[locale]/notifications/unsubscribe/lib/unsubscribe.ts @@ -0,0 +1,159 @@ +import { NOTIFICATION_TYPES } from '../../../../utils/notificationTypes'; +import type { + NotificationSubscription, + UnsubscribeScope, +} from '../../../../services/notification-service'; + +/** + * Pure helpers for the email unsubscribe page, kept free of React and + * Firebase so they can be unit tested directly. + * + * The `id` in an unsubscribe link is the subscription's own UUID, which acts + * as the access capability: the recipient doesn't need to sign in. + */ + +/** Outcome of a DELETE /v1/subscriptions/{id} call. */ +export type UnsubscribeResult = 'success' | 'notFound' | 'error'; + +/** What the page knows about the linked subscription before the user acts. */ +export type SubscriptionLookup = + | { status: 'loading' } + | { status: 'found'; notificationId: string; active: boolean } + | { status: 'invalidId' } + | { status: 'notFound' } + | { status: 'error' }; + +export type ViewStatus = 'loading' | 'ready' | 'success' | 'error'; + +export interface ViewCopy { + status: ViewStatus; + /** Keys under the 'unsubscribe' namespace. */ + titleKey: string; + descriptionKey: string; +} + +const SUBSCRIPTION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function isValidSubscriptionId(id: unknown): id is string { + return typeof id === 'string' && SUBSCRIPTION_ID_PATTERN.test(id); +} + +/** Maps the DELETE response status onto what the page shows. */ +export function toUnsubscribeResult(status: number): UnsubscribeResult { + if (status >= 200 && status < 300) { + return 'success'; + } + // Non-announcement subscriptions are hard-deleted, so a repeated click (or + // a stale link) lands here too. + if (status === 404) { + return 'notFound'; + } + return 'error'; +} + +/** + * Folds the link's ID and the subscription request's state into one value. + * `data` is null when the API reported the subscription missing, and + * undefined while the request hasn't settled (or hasn't started because the + * Firebase user isn't ready yet). `authFailed` means no Firebase user is + * coming, so the request never will. + */ +export function toSubscriptionLookup({ + subscriptionId, + data, + error, + authFailed, +}: { + subscriptionId: string | undefined; + data: + | Pick + | null + | undefined; + error: unknown; + authFailed: boolean; +}): SubscriptionLookup { + if (subscriptionId === undefined) { + return { status: 'invalidId' }; + } + if (error != null || authFailed) { + return { status: 'error' }; + } + if (data === null) { + return { status: 'notFound' }; + } + if (data === undefined) { + return { status: 'loading' }; + } + return { + status: 'found', + notificationId: data.notification_id, + active: data.active, + }; +} + +/** + * Picks the headline for the current state. A completed action takes + * precedence over what the page knew about the subscription on load. + */ +export function resolveViewCopy( + lookup: SubscriptionLookup, + result: { scope: UnsubscribeScope; outcome: UnsubscribeResult } | null, +): ViewCopy { + if (result?.outcome === 'success') { + return { + status: 'success', + titleKey: 'successTitle', + descriptionKey: + result.scope === 'all' + ? 'successAllDescription' + : 'successOneDescription', + }; + } + if (result?.outcome === 'notFound') { + return { + status: 'error', + titleKey: 'errorTitle', + descriptionKey: 'notFoundDescription', + }; + } + switch (lookup.status) { + case 'loading': + return { + status: 'loading', + titleKey: 'loadingTitle', + descriptionKey: 'loadingDescription', + }; + case 'found': + return { + status: 'ready', + titleKey: lookup.active ? 'title' : 'alreadyUnsubscribedTitle', + descriptionKey: lookup.active + ? 'description' + : 'alreadyUnsubscribedDescription', + }; + case 'error': + return { + status: 'error', + titleKey: 'errorTitle', + descriptionKey: 'loadFailedDescription', + }; + default: + return { + status: 'error', + titleKey: 'errorTitle', + descriptionKey: 'notFoundDescription', + }; + } +} + +/** + * i18n key (under the 'feeds' namespace) naming the notification type, or + * undefined for a type the app doesn't label yet. + */ +export function getNotificationTypeLabelKey( + notificationId: string, +): string | undefined { + return NOTIFICATION_TYPES.find((type) => type.id === notificationId) + ?.labelKey; +} diff --git a/src/app/[locale]/notifications/unsubscribe/page.tsx b/src/app/[locale]/notifications/unsubscribe/page.tsx new file mode 100644 index 00000000..9b22e720 --- /dev/null +++ b/src/app/[locale]/notifications/unsubscribe/page.tsx @@ -0,0 +1,55 @@ +import { type ReactElement } from 'react'; +import { type Metadata } from 'next'; +import { setRequestLocale } from 'next-intl/server'; +import { type Locale, routing } from '../../../../i18n/routing'; +import { ReduxGateWrapper } from '../../../components/ReduxGateWrapper'; +import UnsubscribeContent from './UnsubscribeContent'; + +export const metadata: Metadata = { + title: 'Unsubscribe | MobilityDatabase', + description: 'Manage the Mobility Database notifications sent to your email.', + robots: { + index: false, + follow: false, + googleBot: { + index: false, + follow: false, + 'max-image-preview': 'none', + 'max-snippet': -1, + 'max-video-preview': -1, + }, + }, +}; + +export function generateStaticParams(): Array<{ + locale: Locale; +}> { + return routing.locales.map((locale) => ({ locale })); +} + +interface PageProps { + params: Promise<{ locale: string }>; +} + +/** + * Landing page for the unsubscribe link in notification emails + * (`/notifications/unsubscribe?id=`). + * + * Statically rendered: the `id` is read and the subscription looked up on + * the client, with the visitor's Firebase user (anonymous when signed out). + * Nothing is unsubscribed on load, because email link scanners open these + * URLs too. + */ +export default async function UnsubscribePage({ + params, +}: PageProps): Promise { + const { locale } = await params; + + setRequestLocale(locale); + + return ( + + + + ); +} diff --git a/src/app/constants/Navigation.ts b/src/app/constants/Navigation.ts index 77d4454e..d0b24d1d 100644 --- a/src/app/constants/Navigation.ts +++ b/src/app/constants/Navigation.ts @@ -4,6 +4,7 @@ import { type RemoteConfigValues } from '../interface/RemoteConfig'; export const SIGN_OUT_TARGET = '/'; export const SIGN_IN_TARGET = '/sign-in'; export const ACCOUNT_TARGET = '/account'; +export const ACCOUNT_NOTIFICATIONS_TARGET = '/account/notifications'; export const ADD_FEED_TARGET = '/contribute'; export const POST_REGISTRATION_TARGET = '/verify-email'; export const COMPLETE_REGISTRATION_TARGET = '/complete-registration'; diff --git a/src/app/services/notification-service.ts b/src/app/services/notification-service.ts index a10e54fb..c0daecb8 100644 --- a/src/app/services/notification-service.ts +++ b/src/app/services/notification-service.ts @@ -118,3 +118,64 @@ export const deleteUserSubscription = async (id: string): Promise => { userServiceClient.eject(authMiddleware); } }; + +export type UnsubscribeScope = NonNullable< + paths['/v1/subscriptions/{id}']['delete']['parameters']['query'] +>['scope']; + +/** + * Retrieve a subscription by ID, as linked from notification emails. The ID + * is the access capability, so any Firebase user (anonymous included) can + * call this. Resolves to null when the subscription doesn't exist. + */ +export const getSubscriptionById = async ( + id: string, +): Promise => { + const accessToken = await getUserAccessToken(); + const authMiddleware = generateAuthMiddlewareWithToken(accessToken); + userServiceClient.use(authMiddleware); + try { + const { data, error, response } = await userServiceClient.GET( + '/v1/subscriptions/{id}', + { + params: { path: { id } }, + }, + ); + if (response.status === 404) { + return null; + } + if (error !== undefined || data === undefined) { + throw new Error('Failed to retrieve subscription'); + } + return data; + } finally { + userServiceClient.eject(authMiddleware); + } +}; + +/** + * Unsubscribe via a subscription ID, as linked from notification emails. With + * `scope=all` the backend uses the ID only to find the owner and unsubscribes + * them from everything. Resolves to the HTTP status so callers can tell a + * missing subscription (404) apart from other failures; throws only when the + * request itself fails. + */ +export const unsubscribeBySubscriptionId = async ( + id: string, + scope: UnsubscribeScope, +): Promise => { + const accessToken = await getUserAccessToken(); + const authMiddleware = generateAuthMiddlewareWithToken(accessToken); + userServiceClient.use(authMiddleware); + try { + const { response } = await userServiceClient.DELETE( + '/v1/subscriptions/{id}', + { + params: { path: { id }, query: { scope } }, + }, + ); + return response.status; + } finally { + userServiceClient.eject(authMiddleware); + } +}; 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: {