From 2007ba91ccf9dae6e68b0297aae27cae0f69053b Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 4 Aug 2026 07:42:31 -0400 Subject: [PATCH 01/13] updated types from yaml --- external_types/UserServiceAPI.yaml | 70 +++++++++++++++----- src/app/services/user-service-api-types.ts | 75 ++++++++++------------ 2 files changed, 89 insertions(+), 56 deletions(-) diff --git a/external_types/UserServiceAPI.yaml b/external_types/UserServiceAPI.yaml index 3a60e969..630d70d6 100644 --- a/external_types/UserServiceAPI.yaml +++ b/external_types/UserServiceAPI.yaml @@ -111,13 +111,11 @@ paths: $ref: "#/components/schemas/NotificationType" "401": description: Unauthorized. - "501": - description: Not yet implemented. /v1/user/subscriptions: get: summary: List the current user's notification subscriptions - description: Returns all notification subscriptions for the authenticated user. + description: Returns all notification subscriptions for the authenticated user. This feature is available to selected users. To request access or learn more, contact us at api@mobilitydata.org. operationId: getUserSubscriptions tags: - "users" @@ -134,11 +132,9 @@ paths: $ref: "#/components/schemas/NotificationSubscription" "401": description: Unauthorized. - "501": - description: Not yet implemented. post: summary: Create a notification subscription - description: Subscribes the authenticated user to a notification type. + 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. operationId: createUserSubscription tags: - "users" @@ -161,13 +157,11 @@ paths: description: Invalid request. "401": description: Unauthorized. - "501": - description: Not yet implemented. /v1/user/subscriptions/{id}: patch: summary: Toggle a notification subscription - description: Activates or deactivates a notification subscription by ID. + 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. operationId: updateUserSubscription tags: - "users" @@ -197,14 +191,12 @@ paths: description: Unauthorized. "404": description: Subscription not found. - "501": - description: Not yet implemented. delete: summary: Delete or disable a notification subscription description: >- Removes a notification subscription by ID. The announcements subscription (`api.announcements`) cannot be deleted; calling this endpoint for it - disables the subscription (sets it inactive) instead of removing 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. operationId: deleteUserSubscription tags: - "users" @@ -224,8 +216,6 @@ paths: description: Unauthorized. "404": description: Subscription not found. - "501": - description: Not yet implemented. /v1/subscriptions/{id}: get: @@ -257,7 +247,7 @@ paths: 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. + instead of removing it. This feature is available to selected users. To request access or learn more, contact us at api@mobilitydata.org. operationId: deleteSubscription tags: - "subscriptions" @@ -390,6 +380,44 @@ components: type: string format: date-time description: Timestamp when the subscription was created. + feeds: + type: array + nullable: true + description: > + The feeds this subscription targets (feed-scoped notification types: + feed.url_updated, feed.url_availability, feed.coverage), each with + its resolved metadata. Resolved from the feeds database at read time + using the stable feed IDs — not persisted on the subscription. + Consumers can build a human-readable description from these fields. + items: + $ref: "#/components/schemas/SubscriptionFeed" + + SubscriptionFeed: + type: object + description: > + Metadata for a feed targeted by a notification subscription, resolved + from the feed's stable ID at read time (not persisted). + required: + - feed_id + 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. CreateNotificationSubscriptionRequest: type: object @@ -400,6 +428,16 @@ components: type: string description: The notification type to subscribe to. example: "feed.published" + feed_ids: + type: array + items: + type: 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. + example: ["mdb-1", "mdb-42"] UpdateNotificationSubscriptionRequest: type: object @@ -439,4 +477,4 @@ components: $ref: "./BearerTokenSchema.yaml#/components/securitySchemes/Authentication" security: - - Authentication: [] + - Authentication: [] \ No newline at end of file diff --git a/src/app/services/user-service-api-types.ts b/src/app/services/user-service-api-types.ts index 0f4ed0e3..d82e6996 100644 --- a/src/app/services/user-service-api-types.ts +++ b/src/app/services/user-service-api-types.ts @@ -59,13 +59,13 @@ export interface paths { }; /** * List the current user's notification subscriptions - * @description Returns all notification subscriptions for the authenticated user. + * @description Returns all notification subscriptions for the authenticated user. This feature is available to selected users. To request access or learn more, contact us at api@mobilitydata.org. */ get: operations['getUserSubscriptions']; put?: never; /** * Create a notification subscription - * @description Subscribes the authenticated user to a notification type. + * @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. */ post: operations['createUserSubscription']; delete?: never; @@ -86,14 +86,14 @@ export interface paths { post?: never; /** * Delete or disable a notification subscription - * @description Removes a notification subscription by ID. The announcements subscription (`api.announcements`) cannot be deleted; calling this endpoint for it disables the subscription (sets it inactive) instead of removing it. + * @description Removes a notification subscription by 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. */ delete: operations['deleteUserSubscription']; options?: never; head?: never; /** * Toggle a notification subscription - * @description Activates or deactivates a notification subscription by ID. + * @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. */ patch: operations['updateUserSubscription']; trace?: never; @@ -117,7 +117,7 @@ export interface paths { * @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. + * instead of removing it. This feature is available to selected users. To request access or learn more, contact us at api@mobilitydata.org. */ delete: operations['deleteSubscription']; options?: never; @@ -226,6 +226,28 @@ export interface components { * @description Timestamp when the subscription was created. */ created_at: string; + /** @description The feeds this subscription targets (feed-scoped notification types: feed.url_updated, feed.url_availability, feed.coverage), each with its resolved metadata. Resolved from the feeds database at read time using the stable feed IDs — not persisted on the subscription. Consumers can build a human-readable description from these fields. */ + feeds?: components['schemas']['SubscriptionFeed'][] | null; + }; + /** @description Metadata for a feed targeted by a notification subscription, resolved from the feed's stable ID at read time (not persisted). */ + SubscriptionFeed: { + /** + * @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; }; CreateNotificationSubscriptionRequest: { /** @@ -233,6 +255,14 @@ export interface components { * @example feed.published */ 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. + * @example [ + * "mdb-1", + * "mdb-42" + * ] + */ + feed_ids?: string[]; }; UpdateNotificationSubscriptionRequest: { /** @description Whether the subscription should be active. */ @@ -384,13 +414,6 @@ export interface operations { }; content?: never; }; - /** @description Not yet implemented. */ - 501: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; }; }; getUserSubscriptions: { @@ -418,13 +441,6 @@ export interface operations { }; content?: never; }; - /** @description Not yet implemented. */ - 501: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; }; }; createUserSubscription: { @@ -463,13 +479,6 @@ export interface operations { }; content?: never; }; - /** @description Not yet implemented. */ - 501: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; }; }; deleteUserSubscription: { @@ -505,13 +514,6 @@ export interface operations { }; content?: never; }; - /** @description Not yet implemented. */ - 501: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; }; }; updateUserSubscription: { @@ -553,13 +555,6 @@ export interface operations { }; content?: never; }; - /** @description Not yet implemented. */ - 501: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; }; }; getSubscription: { From 72d1f44df4bff07ee8878dc7e203dac1cb65d211 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 4 Aug 2026 07:58:50 -0400 Subject: [PATCH 02/13] updated notification feed setting dialog --- .../components/NotificationSettingsDialog.tsx | 235 +++++++++--------- 1 file changed, 113 insertions(+), 122 deletions(-) diff --git a/src/app/screens/Feed/components/NotificationSettingsDialog.tsx b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx index 637e8f44..72949a9c 100644 --- a/src/app/screens/Feed/components/NotificationSettingsDialog.tsx +++ b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx @@ -1,11 +1,14 @@ 'use client'; -// This component is subject to change based on the actual notification settings we want to offer and the APIs available to save them. For now it's a mockup of what the UI could look like. - import { useState, useEffect } from 'react'; +import { useSWRConfig } from 'swr'; +import useSWRMutation from 'swr/mutation'; +import { useTranslations } from 'next-intl'; +import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import Checkbox from '@mui/material/Checkbox'; +import CircularProgress from '@mui/material/CircularProgress'; import Dialog from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; @@ -14,53 +17,33 @@ import FormControl from '@mui/material/FormControl'; import FormControlLabel from '@mui/material/FormControlLabel'; import FormGroup from '@mui/material/FormGroup'; import FormLabel from '@mui/material/FormLabel'; -import IconButton from '@mui/material/IconButton'; -import MenuItem from '@mui/material/MenuItem'; -import Select from '@mui/material/Select'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import ChangeTypeInfoPopover, { - CHANGE_TYPE_INFO, -} from '../../../components/ChangeTypeInfoPopover'; +import Tooltip from '@mui/material/Tooltip'; +import { + createUserSubscription, + deleteUserSubscription, + type NotificationSubscription, + USER_SUBSCRIPTIONS_SWR_KEY, +} from '../../../services/notification-service'; +import { + FEED_NOTIFICATION_TYPE_IDS, + FEED_NOTIFICATION_TYPES, +} from '../../../utils/notificationTypes'; export interface NotificationSettings { - frequency: 'onChange' | 'weekly' | 'monthly' | 'quarterly'; changeTypes: string[]; } export const defaultNotificationSettings: NotificationSettings = { - frequency: 'onChange', - changeTypes: [ - 'any', - 'features', - 'expiry', - 'validation', - 'breaking', - 'suspicious', - ], + changeTypes: FEED_NOTIFICATION_TYPE_IDS, }; -const CHANGE_TYPE_OPTIONS = [ - { value: 'any', label: 'All Changes' }, - { value: 'features', label: 'Features Changes' }, - { value: 'expiry', label: '7 Days Before Expiry' }, - { value: 'validation', label: 'New Validation Errors' }, - { value: 'breaking', label: 'Breaking Changes' }, - { value: 'suspicious', label: 'Suspicious Changes' }, -] as const; - -const SPECIFIC_TYPES = [ - 'features', - 'expiry', - 'validation', - 'breaking', - 'suspicious', -]; - interface Props { open: boolean; onClose: () => void; onSave: (settings: NotificationSettings) => void; initialSettings: NotificationSettings; + feedId?: string; + existingSubscriptions?: NotificationSubscription[]; } export default function NotificationSettingsDialog({ @@ -68,112 +51,124 @@ export default function NotificationSettingsDialog({ onClose, onSave, initialSettings, + feedId, + existingSubscriptions = [], }: Props): React.ReactElement { - const [frequency, setFrequency] = useState(initialSettings.frequency); + const t = useTranslations('feeds'); + const { mutate } = useSWRConfig(); const [changeTypes, setChangeTypes] = useState( initialSettings.changeTypes, ); - const [infoPopover, setInfoPopover] = useState<{ - anchor: HTMLElement; - type: string; - } | null>(null); + + interface SettingsChange { + addedTypes: string[]; + removedTypes: string[]; + } + + const { + trigger: applySettingsChange, + isMutating: isSaving, + error: saveError, + reset: resetSaveError, + } = 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], + }), + ), + ...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 + }, + ); // Reset to saved settings each time the dialog opens useEffect(() => { if (open) { - setFrequency(initialSettings.frequency); setChangeTypes(initialSettings.changeTypes); - setInfoPopover(null); + resetSaveError(); } - }, [open, initialSettings]); + }, [open, initialSettings, resetSaveError]); const handleChangeTypeToggle = (value: string): void => { - if (value === 'any') { - setChangeTypes( - changeTypes.includes('any') ? [] : ['any', ...SPECIFIC_TYPES], - ); - } else { - if (changeTypes.includes(value)) { - // Remove the type and "any" (partial selection invalidates "any") - setChangeTypes(changeTypes.filter((t) => t !== value && t !== 'any')); - } else { - const withNew = changeTypes.filter((t) => t !== 'any').concat(value); - // Auto-select "any" when all specific types are checked - const allSpecificSelected = SPECIFIC_TYPES.every((t) => - withNew.includes(t), - ); - setChangeTypes(allSpecificSelected ? ['any', ...withNew] : withNew); - } + setChangeTypes( + changeTypes.includes(value) + ? changeTypes.filter((t) => t !== value) + : [...changeTypes, value], + ); + }; + + const handleSave = (): void => { + if (feedId === undefined || feedId === '') { + onSave({ changeTypes }); + return; + } + + const addedTypes = FEED_NOTIFICATION_TYPE_IDS.filter( + (type) => + changeTypes.includes(type) && + !initialSettings.changeTypes.includes(type), + ); + const removedTypes = FEED_NOTIFICATION_TYPE_IDS.filter( + (type) => + !changeTypes.includes(type) && + initialSettings.changeTypes.includes(type), + ); + + if (addedTypes.length === 0 && removedTypes.length === 0) { + onSave({ changeTypes }); + return; } + + applySettingsChange({ addedTypes, removedTypes }) + .then(() => { + onSave({ changeTypes }); + }); }; return ( - Global Notification Settings + Notification Settings + {saveError && ( + + Failed to update notification settings. Please try again. + + )} - {/* Frequency */} - - - Frequency of Notification - - - - {/* Type of changes */} Type of Changes - {CHANGE_TYPE_OPTIONS.map(({ value, label }) => ( + {FEED_NOTIFICATION_TYPES.map((type) => ( { - handleChangeTypeToggle(value); + handleChangeTypeToggle(type.id); }} /> } label={ - value in CHANGE_TYPE_INFO ? ( - - {label} - { - e.preventDefault(); - e.stopPropagation(); - setInfoPopover({ - anchor: e.currentTarget, - type: value, - }); - }} - sx={{ ml: 0.5 }} - aria-label={`About ${label}`} - > - - - - ) : ( - label - ) + + {t(type.labelKey)} + } /> ))} @@ -182,24 +177,20 @@ export default function NotificationSettingsDialog({ - {infoPopover != null && ( - { - setInfoPopover(null); - }} - /> - )} - - + From b0f088aee9f235b87d764d609e364d020b413aa5 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 4 Aug 2026 07:59:45 -0400 Subject: [PATCH 03/13] notification service functions and types --- src/app/services/notification-service.ts | 120 +++++++++++++++++++++++ src/app/utils/notificationTypes.ts | 45 +++++++++ 2 files changed, 165 insertions(+) create mode 100644 src/app/services/notification-service.ts create mode 100644 src/app/utils/notificationTypes.ts diff --git a/src/app/services/notification-service.ts b/src/app/services/notification-service.ts new file mode 100644 index 00000000..a10e54fb --- /dev/null +++ b/src/app/services/notification-service.ts @@ -0,0 +1,120 @@ +import createClient from 'openapi-fetch'; +import type { components, paths } from './user-service-api-types'; +import { generateAuthMiddlewareWithToken } from './api-auth-middleware'; +import { getUserAccessToken } from './profile-service'; + +export type NotificationSubscription = + components['schemas']['NotificationSubscription']; + +export type SubscriptionFeed = components['schemas']['SubscriptionFeed']; + +export type CreateNotificationSubscriptionRequest = + components['schemas']['CreateNotificationSubscriptionRequest']; + +/** + * Shared SWR cache key for the current user's notification subscriptions, + * so any component reading or mutating the list stays in sync. + */ +export const USER_SUBSCRIPTIONS_SWR_KEY = 'user-subscriptions'; + +const userServiceClient = createClient({ + baseUrl: String(process.env.NEXT_PUBLIC_FEED_API_BASE_URL), +}); + +/** + * Retrieve all notification subscriptions for the current user. + */ +export const getUserSubscriptions = async (): Promise< + NotificationSubscription[] +> => { + const accessToken = await getUserAccessToken(); + const authMiddleware = generateAuthMiddlewareWithToken(accessToken); + userServiceClient.use(authMiddleware); + try { + const { data, error } = await userServiceClient.GET( + '/v1/user/subscriptions', + ); + if (error !== undefined) { + throw new Error('Failed to retrieve user subscriptions'); + } + return data ?? []; + } finally { + userServiceClient.eject(authMiddleware); + } +}; + +/** + * Activate or deactivate a notification subscription by ID. + */ +export const updateUserSubscription = async ( + id: string, + active: boolean, +): 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: { active }, + }, + ); + if (error !== undefined || data === undefined) { + throw new Error('Failed to update user subscription'); + } + return data; + } finally { + userServiceClient.eject(authMiddleware); + } +}; + +/** + * Subscribe the current user to a notification type, optionally scoped to + * specific feeds. + */ +export const createUserSubscription = async ( + request: CreateNotificationSubscriptionRequest, +): Promise => { + const accessToken = await getUserAccessToken(); + const authMiddleware = generateAuthMiddlewareWithToken(accessToken); + userServiceClient.use(authMiddleware); + try { + const { data, error } = await userServiceClient.POST( + '/v1/user/subscriptions', + { + body: request, + }, + ); + if (error !== undefined || data === undefined) { + throw new Error('Failed to create user subscription'); + } + return data; + } finally { + userServiceClient.eject(authMiddleware); + } +}; + +/** + * Delete a notification subscription by ID. The announcements subscription + * cannot be deleted; the backend disables it (sets it inactive) instead. + */ +export const deleteUserSubscription = async (id: string): Promise => { + const accessToken = await getUserAccessToken(); + const authMiddleware = generateAuthMiddlewareWithToken(accessToken); + userServiceClient.use(authMiddleware); + try { + const { error } = await userServiceClient.DELETE( + '/v1/user/subscriptions/{id}', + { + params: { path: { id } }, + }, + ); + if (error !== undefined) { + throw new Error('Failed to delete user subscription'); + } + } finally { + userServiceClient.eject(authMiddleware); + } +}; diff --git a/src/app/utils/notificationTypes.ts b/src/app/utils/notificationTypes.ts new file mode 100644 index 00000000..24a09d17 --- /dev/null +++ b/src/app/utils/notificationTypes.ts @@ -0,0 +1,45 @@ +export interface NotificationTypeDefinition { + /** Notification type id, matches `NotificationSubscription.notification_id` from the API. */ + id: string; + /** i18n key (under the 'feeds' namespace) for the type's display label. */ + labelKey: string; + /** i18n key (under the 'feeds' namespace) for the type's tooltip/description. */ + tooltipKey: string; + /** Whether this type is scoped to a specific feed (subscribable per-feed) vs. a global type like announcements. */ + feedScoped: boolean; +} + +export const NOTIFICATION_TYPES: NotificationTypeDefinition[] = [ + { + id: 'feed.url_updated', + labelKey: 'feedUrlUpdatedLabel', + tooltipKey: 'feedUrlUpdatedTooltip', + feedScoped: true, + }, + // { + // id: 'feed.url_availability', + // labelKey: 'feedUrlAvailabilityLabel', + // tooltipKey: 'feedUrlAvailabilityTooltip', + // feedScoped: true, + // }, + // { + // id: 'feed.coverage', + // labelKey: 'feedCoverageLabel', + // tooltipKey: 'feedCoverageTooltip', + // feedScoped: true, + // }, + { + id: 'api.announcements', + labelKey: 'apiAnnouncementsLabel', + tooltipKey: 'apiAnnouncementsTooltip', + feedScoped: false, + }, +]; + +export const FEED_NOTIFICATION_TYPES = NOTIFICATION_TYPES.filter( + (type) => type.feedScoped, +); + +export const FEED_NOTIFICATION_TYPE_IDS = FEED_NOTIFICATION_TYPES.map( + (type) => type.id, +); From 2a4cf55140f7fc544fff9e703a5c7aa1bf292056 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 4 Aug 2026 08:49:51 -0400 Subject: [PATCH 04/13] removed user feature flags from server --- src/app/[locale]/layout.tsx | 6 +-- src/app/actions/feature-flags.ts | 74 ----------------------------- src/app/api/feature-flags/route.ts | 72 ---------------------------- src/app/api/session/route.ts | 3 -- src/app/providers.tsx | 5 +- src/app/services/channel-service.ts | 43 ++--------------- src/app/services/session-service.ts | 59 +---------------------- 7 files changed, 7 insertions(+), 255 deletions(-) delete mode 100644 src/app/actions/feature-flags.ts delete mode 100644 src/app/api/feature-flags/route.ts diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 55462145..03610522 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -8,7 +8,6 @@ import { NextIntlClientProvider, hasLocale } from 'next-intl'; import { getMessages, setRequestLocale } from 'next-intl/server'; import { notFound } from 'next/navigation'; import { getRemoteConfigValues } from '../../lib/remote-config.server'; -import { getServerFlags } from '../actions/feature-flags'; import { Mulish, IBM_Plex_Mono } from 'next/font/google'; import Footer from '../components/Footer'; import Header from '../components/Header'; @@ -90,10 +89,9 @@ export default async function LocaleLayout({ // Enable static rendering for this locale setRequestLocale(validLocale); - const [messages, remoteConfig, featureFlags] = await Promise.all([ + const [messages, remoteConfig] = await Promise.all([ getMessages(), getRemoteConfigValues(), - getServerFlags(), ]); return ( @@ -108,7 +106,7 @@ export default async function LocaleLayout({ - +
{ - const cookieStore = await cookies(); - const raw = cookieStore.get(COOKIE_NAME)?.value; - if (raw == null) return { ...defaultUserFeatureFlags }; - - const json = verify(raw); - if (json == null) return { ...defaultUserFeatureFlags }; - - try { - return toUserFeatureFlags(JSON.parse(json) as FeatureFlag[]); - } catch { - return { ...defaultUserFeatureFlags }; - } -} diff --git a/src/app/api/feature-flags/route.ts b/src/app/api/feature-flags/route.ts deleted file mode 100644 index c7dd5552..00000000 --- a/src/app/api/feature-flags/route.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { type NextRequest, NextResponse } from 'next/server'; -import crypto from 'node:crypto'; -import { getEnvConfig } from '../../utils/config'; -import { type FeatureFlag } from '../../interface/UserFeatureFlags'; - -const COOKIE_NAME = 'md_features'; -const COOKIE_MAX_AGE_SEC = 60 * 60; // 1 hour — matches md_session TTL - -function isProduction(): boolean { - return process.env.NODE_ENV === 'production'; -} - -function getSecret(): string { - const secret = getEnvConfig('NEXT_SESSION_JWT_SECRET'); - if (secret.length < 32) { - throw new Error( - 'NEXT_SESSION_JWT_SECRET must be set and at least 32 characters long', - ); - } - return secret; -} - -function sign(payload: string): string { - const secret = getSecret(); - const encoded = Buffer.from(payload).toString('base64url'); - const sig = crypto - .createHmac('sha256', secret) - .update(encoded) - .digest() - .toString('base64url'); - return `${encoded}.${sig}`; -} - -/** - * POST /api/feature-flags - * - * Accepts a FeatureFlag[] array from the login/refresh sagas, HMAC-signs it, - * and stores it as the httpOnly md_features cookie. - * - * Security note: this endpoint does not verify the caller's identity, so an - * authenticated user could inject arbitrary flag values via devtools. Feature - * flags are UI hints only — the API enforces actual access independently. - * - * For paywalled flags, upgrade to the POST /api/session pattern: accept a - * Firebase idToken, verify it server-side with Firebase Admin, and fetch the - * flags directly from the user service rather than trusting the client body. - * Using md_session for the check races with AuthSessionProvider setting it - * concurrently during login, so that approach is not viable without a - * dedicated auth token in the request. - */ -export async function POST(req: NextRequest): Promise { - try { - const features = (await req.json()) as FeatureFlag[]; - const response = NextResponse.json({ status: 'ok' }); - response.cookies.set({ - name: COOKIE_NAME, - value: sign(JSON.stringify(features)), - httpOnly: true, - secure: isProduction(), - sameSite: 'lax', - path: '/', - maxAge: COOKIE_MAX_AGE_SEC, - }); - return response; - } catch (error) { - console.error('Error setting feature flags cookie', error); - return NextResponse.json( - { error: 'Failed to set feature flags cookie' }, - { status: 500 }, - ); - } -} diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts index 2d06ddd8..5869b609 100644 --- a/src/app/api/session/route.ts +++ b/src/app/api/session/route.ts @@ -4,7 +4,6 @@ import { getFirebaseAdminApp } from '../../../lib/firebase-admin'; import { signSessionToken, verifySessionToken } from '../../utils/session-jwt'; const COOKIE_NAME = 'md_session'; -const COOKIE_NAME_FEATURE_FLAGS = 'md_features'; function isProduction(): boolean { return process.env.NODE_ENV === 'production'; @@ -90,9 +89,7 @@ export async function GET(req: NextRequest): Promise { } export async function DELETE(req: NextRequest): Promise { - // Clear both the session cookie and the feature flags cookie on logout. const response = NextResponse.json({ status: 'logged_out' }); response.cookies.delete(COOKIE_NAME); - response.cookies.delete(COOKIE_NAME_FEATURE_FLAGS); return response; } diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 35bc347c..fe7aff53 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -5,7 +5,6 @@ import ContextProviders from './components/Context'; import { RemoteConfigProvider } from './context/RemoteConfigProvider'; import { UserFeatureFlagProvider } from './context/UserFeatureFlagProvider'; import { type RemoteConfigValues } from './interface/RemoteConfig'; -import { type UserFeatureFlags } from './interface/UserFeatureFlags'; // Look into this provider and see if it's client blocking. Niche provider might be able to isolate for single use import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; @@ -17,14 +16,12 @@ import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill'; interface ProvidersProps { children: React.ReactNode; remoteConfig: RemoteConfigValues; - featureFlags: UserFeatureFlags; } /// To revisit which providers are needed at this level export function Providers({ children, remoteConfig, - featureFlags, }: ProvidersProps): React.ReactElement { // Polyfill country flag emojis for browsers that don't support them natively // (e.g. Microsoft Edge / Chrome on Windows) @@ -52,7 +49,7 @@ export function Providers({ - + {children} diff --git a/src/app/services/channel-service.ts b/src/app/services/channel-service.ts index dafababf..25890345 100644 --- a/src/app/services/channel-service.ts +++ b/src/app/services/channel-service.ts @@ -2,7 +2,6 @@ type ChannelCallback = (message: T) => void; interface RegisteredChannel { channel: BroadcastChannel; - callback: ChannelCallback; } let channels: Map | undefined; @@ -10,18 +9,14 @@ let channels: Map | undefined; export const LOGOUT_CHANNEL = 'logout-channel'; export const LOGIN_CHANNEL = 'login-channel'; -/** Channel used to keep user feature flags in sync within and across tabs. */ -export const FEATURE_FLAGS_CHANNEL = 'feature-flags-channel'; - /** * Creates a new broadcast channel with the specified name and callback. The callback is called when a message is received, - * receiving the message payload from other tabs (or from broadcastExtendedMessage in the same tab). + * receiving the message payload from other tabs. * If the channel already exists, the function returns false. * @param channelName name of the channel * @param callback function to be called when a message is received * @returns true if the channel was created, false if the channel already exists * @see broadcastMessage - * @see broadcastExtendedMessage */ export const createBroadcastChannel = ( channelName: string, @@ -37,21 +32,17 @@ export const createBroadcastChannel = ( channel.onmessage = (event: MessageEvent) => { callback(event.data); }; - channels.set(channelName, { - channel, - callback: callback as ChannelCallback, - }); + channels.set(channelName, { channel }); return true; }; /** * Broadcasts a message to all subscribers of the channel in OTHER tabs. The channel must be created before broadcasting. - * The posting tab does not receive its own message — use broadcastExtendedMessage when the current tab must react too. + * The posting tab does not receive its own message. * If the channel is not found, an error is thrown. * @param channelName name of the channel * @param message to be broadcasted or undefined * @see createBroadcastChannel - * @see broadcastExtendedMessage */ export const broadcastMessage = ( channelName: string, @@ -66,31 +57,3 @@ export const broadcastMessage = ( } registered.channel.postMessage(message); }; - -/** - * Broadcasts a typed message to other tabs AND delivers it to the current tab. - * - * BroadcastChannel.postMessage does not deliver to the posting context, so the - * channel's locally-registered callback is invoked directly to keep the current - * tab in sync. The channel must have been created via createBroadcastChannel. - * If the channel is not found, an error is thrown. - * @param channelName name of the channel - * @param message payload delivered to every tab, including the current one - * @see createBroadcastChannel - */ -export const broadcastExtendedMessage = ( - channelName: string, - message: T, -): void => { - if (channels === undefined) { - throw new Error('No channels created'); - } - const registered = channels.get(channelName); - if (registered === undefined) { - throw new Error(`Channel ${channelName} not found`); - } - // Cross-tab: other tabs receive the payload via their onmessage handler. - registered.channel.postMessage(message); - // Same-tab: BroadcastChannel skips the sender, so invoke the callback here. - registered.callback(message); -}; diff --git a/src/app/services/session-service.ts b/src/app/services/session-service.ts index 0fca755d..7d13c839 100644 --- a/src/app/services/session-service.ts +++ b/src/app/services/session-service.ts @@ -1,13 +1,4 @@ import { app } from '../../firebase'; -import { - type FeatureFlag, - toUserFeatureFlags, -} from '../interface/UserFeatureFlags'; -import { - FEATURE_FLAGS_CHANNEL, - broadcastExtendedMessage, -} from './channel-service'; -import { retrieveUserInformation } from './profile-service'; const STORED_SESSION_KEY = 'md_session_meta'; const SESSION_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour @@ -51,9 +42,7 @@ function getSessionStatus(uid: string): SessionStatus { * * Returns true when an existing session was renewed (same uid, cookie was * stale). Returns false when the session was freshly established (first login) - * or was still fresh (no-op). Callers can use this signal to re-fetch - * user-specific data (e.g. feature flags) that should stay in sync with the - * session renewal cycle without fetching on every login. + * or was still fresh (no-op). */ export const setUserCookieSession = async (): Promise => { if (typeof window === 'undefined') return false; @@ -108,49 +97,3 @@ export const clearUserCookieSession = async (): Promise => { method: 'DELETE', }); }; - -/** - * Re-fetches the user profile and applies the latest feature flags. - * Called on session renewal (hourly) to keep flags current without re-login. - * Login and sign-up sagas handle the initial flag fetch themselves. - */ -export const refreshUserFeatureFlags = async (): Promise => { - try { - const userData = await retrieveUserInformation(); - if (userData != null) { - await applyUserFeatureFlags(userData.features); - } - } catch { - // Non-critical — best-effort flag refresh. - } -}; - -/** - * Sends the resolved user feature flags to POST /api/feature-flags, which - * HMAC-signs them and sets the httpOnly md_features cookie. - * - * Follows the same pattern as setUserCookieSession → POST /api/session. - * Called by login and token-refresh sagas after fetching the user profile. - * Distributes the flags to all tabs via the feature-flags channel so the UserFeatureFlagProvider updates. - */ -export const applyUserFeatureFlags = async ( - features: FeatureFlag[], -): Promise => { - if (typeof window === 'undefined') return; - - // Sets the md_features cookie server-side, so it is httpOnly and not accessible to JS. - const resp = await fetch('/api/feature-flags', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(features), - }); - - if (resp.ok) { - // Deliver the resolved flags to this tab and every other open tab through - // the shared feature-flags channel (see UserFeatureFlagProvider listener). - broadcastExtendedMessage( - FEATURE_FLAGS_CHANNEL, - toUserFeatureFlags(features), - ); - } -}; From fe6afcdeda321f9dfcb13da03e3046c2a326215f Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 4 Aug 2026 13:35:29 -0400 Subject: [PATCH 05/13] updated e2e test for feature flags --- cypress/e2e/userFeatureFlags.cy.ts | 427 ++++++++++------------------- 1 file changed, 152 insertions(+), 275 deletions(-) diff --git a/cypress/e2e/userFeatureFlags.cy.ts b/cypress/e2e/userFeatureFlags.cy.ts index 5b1dc753..e1ee6e77 100644 --- a/cypress/e2e/userFeatureFlags.cy.ts +++ b/cypress/e2e/userFeatureFlags.cy.ts @@ -1,39 +1,45 @@ /** * User Feature Flags — Cypress E2E tests * - * What these tests cover: - * - md_features cookie is set as httpOnly after login - * - Cookie payload contains the flags returned by GET /v1/user - * - Cookie is cleared when the user logs out - * - Feature flags are refreshed on session renewal (hourly, driven by - * AuthSessionProvider → setUserCookieSession → refreshUserFeatureFlags) - * - window.__featureFlags (UserFeatureFlagProvider state) matches the - * resolved flags after every login, renewal, and logout transition - * - * What these tests do NOT cover (use Jest + RTL instead): - * - HMAC signature correctness — that is a unit test for sign()/verify() - * in src/app/actions/feature-flags.ts. - * - * Provider state assertions: - * UserFeatureFlagProvider exposes its live state on window.__featureFlags - * when window.Cypress is set (mirrors the window.store pattern in store.ts). - * Use `cy.window().its('__featureFlags')` to assert provider values directly. + * Architecture under test: + * Feature flags resolve entirely on the client. useUserFeatureFlags() reads + * them from GET /v1/user via SWR, keyed by the live Firebase uid. There is no + * server-rendered seed and no cookie — a per-user cookie read during render + * cannot work on statically rendered routes (Next hands those an empty cookie + * store, so every read looks like a logged-out user), which is the bug these + * tests lock down. UserFeatureFlagsSync mounts the hook once, globally, in + * providers.tsx so flags resolve regardless of which page is rendered. * - * Session renewal helper: - * Combine them to simulate the - * AuthSessionProvider interval firing with a stale session. - * - * Cookie format: "." - * The payload (first segment) is readable without the secret. + * What these tests cover: + * - Flags resolve on a STATICALLY rendered route (/), the case that was broken + * - Flags resolve identically on a dynamic route (a feed page) + * - Flags omitted by the API fall back to their defaults + * - The SWR entry survives client-side navigation without refetching + * - Logout resets the flags to defaults + * - No md_features cookie is ever set (guards against reintroducing it) */ +export {}; + const TEST_EMAIL = 'featureFlagsTest@mobilitydata.org'; const TEST_PASSWORD = 'IloveOrangeCones123!'; +// Fixture-backed feed used by the other feed specs — a dynamic route. +const TEST_FEED_URL = '/feeds/gtfs/test-516'; + +const ALL_DEFAULTS = { + isNotificationsEnabled: false, + isSealOfReliabilityFilterEnabled: false, +}; + +interface MockFeature { + id: string; + value_type: string; + value: unknown; +} + /** Minimal UserProfile body for GET /v1/user mocks. */ -function mockUserProfile( - features: Array<{ id: string; value_type: string; value: unknown }> = [], -) { +function mockUserProfile(features: MockFeature[] = []) { return { id: 'test-uid', email: TEST_EMAIL, @@ -48,301 +54,172 @@ function mockUserProfile( } /** - * Decode the feature flags stored in the cookie payload. - * The cookie is ".". - * We read only the payload — no secret needed. + * Stubs GET /v1/user and counts how many times it is requested, so tests can + * assert that the SWR cache is reused rather than refetched. */ -function decodeCookiePayload( - cookieValue: string, -): Array<{ id: string; value_type: string; value: unknown }> { - const encoded = cookieValue.split('.')[0]; - // base64url → standard base64 before atob() - const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); - return JSON.parse(atob(base64)); +function interceptUserProfile(features: MockFeature[]): { + calls: () => number; +} { + let calls = 0; + cy.intercept('GET', '**/v1/user', (req) => { + calls += 1; + req.reply({ statusCode: 200, body: mockUserProfile(features) }); + }).as('getUserProfile'); + return { calls: () => calls }; } -/** Dispatch the login saga and wait for POST /api/feature-flags to complete. */ -function loginViaSaga(alias: `@${string}`) { - // Wait for window.store to be exposed by ContextProviders' useEffect. - // In production builds (next start), React hydration completes after - // Cypress marks the page as loaded, so a direct .then() races the useEffect. - // .its('store').should('exist') retries until the property is defined. - cy.window().its('store').should('exist').then((storeObj) => { - // Dispatching 'userProfile/login' triggers emailLoginSaga, which calls - // signInWithEmailAndPassword (Firebase emulator), GET /v1/user, and - // POST /api/feature-flags (applyUserFeatureFlags) before dispatching loginSuccess. - (storeObj as { dispatch: (a: unknown) => void }).dispatch({ - type: 'userProfile/login', - payload: { email: TEST_EMAIL, password: TEST_PASSWORD }, +/** Waits until the provider reports resolved flags matching `expected`. */ +function expectResolvedFlags(expected: Record): void { + cy.window().its('__featureFlagsResolved').should('be.true'); + cy.window().its('__featureFlags').should('deep.equal', expected); +} + +/** + * Dispatches the login saga and waits until Redux reports a 'registered' + * profile status. Feature-flag resolution itself never needs this — it keys + * off the Firebase session alone (see the header) — but routes gated by + * ProtectedPageWrapper's default `targetStatus='registered'` (e.g. /account) + * do, and Firebase SDK sign-in alone (the outer beforeEach) does not set it. + * Must be called on an already-visited page, since it reads window.store. + */ +function loginViaSaga(): void { + cy.window() + .its('store') + .should('exist') + .then((storeObj) => { + (storeObj as { dispatch: (a: unknown) => void }).dispatch({ + type: 'userProfile/login', + payload: { email: TEST_EMAIL, password: TEST_PASSWORD }, + }); }); - }); - cy.wait(alias); + + cy.window() + .its('store') + .invoke('getState') + .its('userProfile.status') + .should('equal', 'registered'); } // --------------------------------------------------------------------------- describe('User Feature Flags', () => { beforeEach(() => { - // Create a fresh user in the Firebase emulator before each test. + // Creates the user AND leaves them signed in through the Firebase SDK, so + // the provider has a uid as soon as the page loads. cy.createNewUserAndSignIn(TEST_EMAIL, TEST_PASSWORD); - cy.visit('/'); }); - // ------------------------------------------------------------------------- - // Login - // ------------------------------------------------------------------------- - describe('on login', () => { - it('sets the md_features cookie as httpOnly', () => { - cy.intercept('GET', '**/v1/user', { - statusCode: 200, - body: mockUserProfile([ - { id: 'isNotificationsEnabled', value_type: 'boolean', value: true }, - ]), - }); - cy.intercept('POST', '**/api/feature-flags').as('setFlags'); - - loginViaSaga('@setFlags'); - - cy.getCookie('md_features') - .should('exist') - .and('have.property', 'httpOnly', true); + describe('resolution', () => { + it('resolves flags on a statically rendered route', () => { + // The regression: `/` is `dynamic = 'force-static'`, so a server-side + // cookie read here always produced defaults regardless of entitlement. + interceptUserProfile([ + { id: 'isNotificationsEnabled', value_type: 'boolean', value: true }, + ]); - // Provider state should reflect the resolved flags. - cy.window() - .its('__featureFlags') - .should('deep.include', { isNotificationsEnabled: true }); - }); + cy.visit('/'); - it('cookie payload contains the flags returned by the API', () => { - cy.intercept('GET', '**/v1/user', { - statusCode: 200, - body: mockUserProfile([ - { id: 'isNotificationsEnabled', value_type: 'boolean', value: true }, - { - id: 'isSealOfReliabilityFilterEnabled', - value_type: 'boolean', - value: false, - }, - ]), + expectResolvedFlags({ + ...ALL_DEFAULTS, + isNotificationsEnabled: true, }); - cy.intercept('POST', '**/api/feature-flags').as('setFlags'); + }); - loginViaSaga('@setFlags'); + it('resolves the same flags on a dynamic route', () => { + interceptUserProfile([ + { id: 'isNotificationsEnabled', value_type: 'boolean', value: true }, + ]); - cy.getCookie('md_features').then((cookie) => { - cy.wrap(cookie).should('not.be.null'); - const flags = decodeCookiePayload(cookie!.value); - cy.wrap(flags.find((f) => f.id === 'isNotificationsEnabled')?.value).should('equal', true); - cy.wrap( - flags.find((f) => f.id === 'isSealOfReliabilityFilterEnabled')?.value, - ).should('equal', false); - }); + cy.visit(TEST_FEED_URL, { timeout: 30000 }); - cy.window().its('__featureFlags').should('deep.equal', { + expectResolvedFlags({ + ...ALL_DEFAULTS, isNotificationsEnabled: true, - isSealOfReliabilityFilterEnabled: false, }); }); - it('cookie stores an empty array when the API returns no flags', () => { - cy.intercept('GET', '**/v1/user', { - statusCode: 200, - body: mockUserProfile([]), - }); - cy.intercept('POST', '**/api/feature-flags').as('setFlags'); - - loginViaSaga('@setFlags'); + it('falls back to defaults for flags the API omits', () => { + interceptUserProfile([ + { + id: 'isSealOfReliabilityFilterEnabled', + value_type: 'boolean', + value: true, + }, + ]); - cy.getCookie('md_features').then((cookie) => { - cy.wrap(cookie).should('not.be.null'); - const flags = decodeCookiePayload(cookie!.value); - // Raw cookie stores the API response. toUserFeatureFlags() fills in - // defaults on read — the provider always falls back to defaultUserFeatureFlags. - cy.wrap(flags).should('deep.equal', []); - }); + cy.visit('/'); - // Provider fills in defaults for all missing flags. - cy.window().its('__featureFlags').should('deep.equal', { + expectResolvedFlags({ isNotificationsEnabled: false, - isSealOfReliabilityFilterEnabled: false, + isSealOfReliabilityFilterEnabled: true, }); }); - }); - // ------------------------------------------------------------------------- - // Logout - // ------------------------------------------------------------------------- - // ------------------------------------------------------------------------- - describe('on logout', () => { - beforeEach(() => { - cy.intercept('GET', '**/v1/user', { - statusCode: 200, - body: mockUserProfile([ - { id: 'isNotificationsEnabled', value_type: 'boolean', value: true }, - ]), - }); - cy.intercept('POST', '**/api/feature-flags').as('setFlags'); - // Intercept the logout request so tests can wait for cookie clearance. - cy.intercept('DELETE', '**/api/session').as('logoutRequest'); + it('resolves to defaults when the profile carries no flags', () => { + interceptUserProfile([]); - loginViaSaga('@setFlags'); - cy.getCookie('md_features').should('exist'); + cy.visit('/'); + + expectResolvedFlags(ALL_DEFAULTS); }); + }); - it('clears the md_features cookie', () => { - cy.visit('/account'); - cy.get('[data-cy="desktop-signOutButton"]').click({ force: true }); - cy.get('[data-cy="confirmSignOutButton"]').click(); - cy.wait('@logoutRequest'); + describe('caching', () => { + it('reuses the cached flags across a client-side navigation', () => { + const profile = interceptUserProfile([ + { id: 'isNotificationsEnabled', value_type: 'boolean', value: true }, + ]); - cy.getCookie('md_features').should('be.null'); + let callsOnLoad = 0; - // Provider should be reset to defaults after logout. - cy.window().its('__featureFlags').should('deep.equal', { - isNotificationsEnabled: false, - isSealOfReliabilityFilterEnabled: false, + cy.visit('/'); + expectResolvedFlags({ ...ALL_DEFAULTS, isNotificationsEnabled: true }); + cy.then(() => { + callsOnLoad = profile.calls(); + // Sanity check, so the comparison after navigating is not vacuous. + cy.wrap(callsOnLoad).should('be.greaterThan', 0); }); - }); - it('also clears the md_session cookie', () => { - // Sanity-check that both session cookies are cleared together. - cy.visit('/account'); - cy.get('[data-cy="desktop-signOutButton"]').click({ force: true }); - cy.get('[data-cy="confirmSignOutButton"]').click(); - cy.wait('@logoutRequest'); + // Client-side navigation: the provider stays mounted, so the SWR entry for + // this uid must be reused rather than refetched. + cy.get('[data-cy="accountHeader"]').click(); + cy.location('pathname').should('include', '/account'); + expectResolvedFlags({ ...ALL_DEFAULTS, isNotificationsEnabled: true }); - cy.getCookie('md_session').should('be.null'); - cy.getCookie('md_features').should('be.null'); - - // Provider should be reset to defaults after logout. - cy.window().its('__featureFlags').should('deep.equal', { - isNotificationsEnabled: false, - isSealOfReliabilityFilterEnabled: false, + // No refetch across the navigation — the cached entry was reused. + cy.then(() => { + cy.wrap(profile.calls()).should('equal', callsOnLoad); }); }); }); -}); - -// ----------------------------------------------------------------------------- -// Session renewal -// -// AuthSessionProvider registers a 5-minute setInterval on mount that calls -// setUserCookieSession(). When the session is stale (expiresAt exceeded, same -// uid), setUserCookieSession() returns wasRenewal=true and AuthSessionProvider -// calls refreshUserFeatureFlags(), which re-fetches GET /v1/user and writes a -// fresh md_features cookie via POST /api/feature-flags. -// -// cy.clock() MUST be called before cy.visit() so Sinon intercepts the -// setInterval registered by AuthSessionProvider on mount and cy.tick() can -// trigger its callback. Only intervals are faked — Date.now() and setTimeout -// are left real so Firebase SDK internals are unaffected. -// Backdating md_session_meta.expiresAt to 1 (ms since epoch) makes -// getSessionStatus() reliably return 'renewal' for any real Date.now() value. -// ----------------------------------------------------------------------------- -// This works locally in e2e but not in CI -// TODO: investigate why the renewal interval never fires in CI (next start) and re-enable this test. -describe.skip('User Feature Flags — session renewal', () => { - beforeEach(() => { - cy.createNewUserAndSignIn(TEST_EMAIL, TEST_PASSWORD); - // Fake ONLY setInterval/clearInterval so cy.tick() can drive the renewal - // interval AuthSessionProvider registers on mount. Date.now() and - // setTimeout stay real so the Firebase SDK internals are unaffected. - // Must run before cy.visit() so Sinon patches setInterval before the - // provider mounts and schedules its callback. - cy.clock(Date.now(), ['setInterval', 'clearInterval']); - - // Initial login returns isNotificationsEnabled: true. - cy.intercept('GET', '**/v1/user', { - statusCode: 200, - body: mockUserProfile([ + describe('on logout', () => { + beforeEach(() => { + interceptUserProfile([ { id: 'isNotificationsEnabled', value_type: 'boolean', value: true }, - ]), - }).as('getUserInitial'); - cy.intercept('POST', '**/api/feature-flags').as('setFlags'); - - cy.visit('/'); - loginViaSaga('@setFlags'); - }); + ]); + cy.intercept('DELETE', '**/api/session').as('logoutRequest'); - it('updates the feature flags cookie and provider when the session token expires', () => { - // Sanity check: the initial flags were applied on login. - cy.getCookie('md_features').should('exist'); - cy.window() - .its('__featureFlags') - .should('deep.equal', { - isNotificationsEnabled: true, - isSealOfReliabilityFilterEnabled: false, - }); + // /account is gated by ProtectedPageWrapper on a 'registered' Redux + // profile status. Log in while on an ungated page so the persisted + // status is already 'registered' by the time /account's full-page + // cy.visit runs ProtectedPageWrapper's redirect check — otherwise it + // redirects away before the sidebar (and its sign-out button) render. + cy.visit('/'); + loginViaSaga(); + }); - // The backend now returns DIFFERENT flags — this is the change that should - // be picked up on the next hourly renewal (not on the current session). - cy.intercept('GET', '**/v1/user', { - statusCode: 200, - body: mockUserProfile([ - { id: 'isNotificationsEnabled', value_type: 'boolean', value: false }, - { - id: 'isSealOfReliabilityFilterEnabled', - value_type: 'boolean', - value: true, - }, - ]), - }).as('getUserRenewed'); - cy.intercept('POST', '**/api/feature-flags').as('renewFlags'); - - // Expire the stored session so getSessionStatus() returns 'renewal' on the - // next tick: same uid, but expiresAt in the past (1ms since epoch is always - // < the real Date.now()). This drives setUserCookieSession() → wasRenewed - // === true → refreshUserFeatureFlags(). - // - // md_session_meta is written asynchronously by AuthSessionProvider - // (onIdTokenChanged → setUserCookieSession → POST /api/session → setItem), - // which is a SEPARATE chain from the login saga's POST /api/feature-flags - // that loginViaSaga waits on. In the CI production build (next start), - // hydration — and therefore that chain — completes later than in the local - // dev server, so the key may not exist yet at this point. Re-read - // localStorage with a retrying assertion instead of a one-shot .then(), - // which would capture a stale null and never recover. - cy.window() - .its('localStorage') - .invoke({ timeout: 15000 }, 'getItem', 'md_session_meta') - .should('not.be.null') - .then((raw) => { - const meta = JSON.parse(raw as string) as { - uid: string; - expiresAt: number; - }; - cy.window().then((win) => { - win.localStorage.setItem( - 'md_session_meta', - JSON.stringify({ ...meta, expiresAt: 1 }), - ); - }); - }); + it('resets the flags to defaults', () => { + cy.visit('/account'); + expectResolvedFlags({ ...ALL_DEFAULTS, isNotificationsEnabled: true }); - // Fire AuthSessionProvider's 5-minute renewal interval. - cy.tick(5 * 60 * 1000); - - // Renewal re-fetches the profile and re-writes the md_features cookie. - cy.wait('@getUserRenewed'); - cy.wait('@renewFlags'); - - // Cookie payload reflects the NEW flag values. - cy.getCookie('md_features').then((cookie) => { - cy.wrap(cookie).should('not.be.null'); - const flags = decodeCookiePayload(cookie!.value); - cy.wrap( - flags.find((f) => f.id === 'isNotificationsEnabled')?.value, - ).should('equal', false); - cy.wrap( - flags.find((f) => f.id === 'isSealOfReliabilityFilterEnabled')?.value, - ).should('equal', true); - }); + cy.get('[data-cy="desktop-signOutButton"]').click({ force: true }); + cy.get('[data-cy="confirmSignOutButton"]').click(); + cy.wait('@logoutRequest'); - // Provider state reflects the NEW flag values. - cy.window().its('__featureFlags').should('deep.equal', { - isNotificationsEnabled: false, - isSealOfReliabilityFilterEnabled: true, + // Signed out (or anonymous) means not entitled, and that is a resolved + // answer rather than a placeholder. + expectResolvedFlags(ALL_DEFAULTS); }); }); }); From a2bcf1d41d52e27d620aa452e822f7f70dd95aa7 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 4 Aug 2026 13:37:04 -0400 Subject: [PATCH 06/13] SWR architecture for user based feature flags --- .../components/AuthSessionProvider.spec.tsx | 8 ++ src/app/components/AuthSessionProvider.tsx | 78 +++++++++------ src/app/components/UserFeatureFlagsSync.tsx | 20 ++++ src/app/context/UserFeatureFlagProvider.tsx | 97 ------------------- src/app/hooks/useUserFeatureFlags.ts | 95 ++++++++++++++++++ src/app/providers.tsx | 11 +-- src/app/services/user-feature-flag-service.ts | 64 ++++++++++++ src/app/store/saga/auth-saga.ts | 48 +++------ 8 files changed, 254 insertions(+), 167 deletions(-) create mode 100644 src/app/components/UserFeatureFlagsSync.tsx delete mode 100644 src/app/context/UserFeatureFlagProvider.tsx create mode 100644 src/app/hooks/useUserFeatureFlags.ts create mode 100644 src/app/services/user-feature-flag-service.ts diff --git a/src/app/components/AuthSessionProvider.spec.tsx b/src/app/components/AuthSessionProvider.spec.tsx index 546f6345..4d68b0b6 100644 --- a/src/app/components/AuthSessionProvider.spec.tsx +++ b/src/app/components/AuthSessionProvider.spec.tsx @@ -36,6 +36,14 @@ jest.mock('../services/session-service', () => ({ setUserCookieSession: jest.fn().mockResolvedValue(undefined), })); +// ---------- Mock: user-feature-flag-service ---------- +// Also keeps the real module's openapi-fetch client out of this suite's module +// graph, which needs browser globals jsdom does not provide. + +jest.mock('../services/user-feature-flag-service', () => ({ + revalidateUserFeatureFlags: jest.fn().mockResolvedValue(undefined), +})); + // ---------- Mock: profile-reducer ---------- jest.mock('../store/profile-reducer', () => ({ diff --git a/src/app/components/AuthSessionProvider.tsx b/src/app/components/AuthSessionProvider.tsx index 07fdb1f6..ccc887d6 100644 --- a/src/app/components/AuthSessionProvider.tsx +++ b/src/app/components/AuthSessionProvider.tsx @@ -12,20 +12,37 @@ import { import { useDispatch } from 'react-redux'; import { app } from '../../firebase'; import { anonymousLogin } from '../store/profile-reducer'; -import { - setUserCookieSession, - refreshUserFeatureFlags, -} from '../services/session-service'; +import { setUserCookieSession } from '../services/session-service'; +import { revalidateUserFeatureFlags } from '../services/user-feature-flag-service'; interface AuthSession { + /** + * True once Firebase has reported an auth state at least once, whether or not + * a user was found. Use this to tell "still resolving" from "resolved: nobody + * is signed in" — `isAuthReady` cannot express that difference, since it is + * false in both cases. + */ + isAuthResolved: boolean; + /** + * True while a Firebase user exists (anonymous included). Gates work that + * needs a session cookie; it is NOT "auth has resolved" — see isAuthResolved. + */ isAuthReady: boolean; + /** + * Firebase uid of the current user, anonymous included. Null when signed out. + * Use this in cache keys for anything user-specific so an identity change + * becomes a different key rather than something that must be invalidated. + */ + uid: string | null; email: string | null; isAuthenticated: boolean; displayName?: string | null; } const AuthReadyContext = createContext({ + isAuthResolved: false, isAuthReady: false, + uid: null, email: null, isAuthenticated: false, displayName: null, @@ -51,7 +68,9 @@ export function useAuthSession(): AuthSession { * 4. Deduplicates POSTs across tabs — localStorage is shared across all * tabs, so a renewal written by any tab is immediately visible to all * others via the `isCookieFresh` check in setUserCookieSession. - * 5. Exposes `isAuthReady` via context. + * 5. Exposes the session and the current `uid` via context. Consumers key + * user-specific caches on `uid`, so an identity change lands on a different + * key instead of needing invalidation. */ export function AuthSessionProvider({ children, @@ -60,7 +79,9 @@ export function AuthSessionProvider({ }): ReactElement { const dispatch = useDispatch(); const [session, setSession] = useState({ + isAuthResolved: false, isAuthReady: false, + uid: null, email: null, isAuthenticated: false, displayName: null, @@ -68,6 +89,23 @@ export function AuthSessionProvider({ const intervalRef = useRef | null>(null); useEffect(() => { + /** + * Establishes or renews `md_session`. A renewal (~hourly) is also when + * user-specific data should be re-checked, so entitlements stay in step with + * the session without a poller of their own. + */ + const syncSession = (uid: string, isAnonymous: boolean): void => { + setUserCookieSession() + .then((wasRenewed) => { + if (wasRenewed && !isAnonymous) { + void revalidateUserFeatureFlags(uid); + } + }) + .catch(() => { + console.error('Failed to establish session cookie'); + }); + }; + const unsubscribe = app.auth().onIdTokenChanged((user) => { if (intervalRef.current != null) { clearInterval(intervalRef.current); @@ -76,47 +114,29 @@ export function AuthSessionProvider({ if (user != null) { setSession({ + isAuthResolved: true, isAuthReady: true, + uid: user.uid, email: user.email ?? null, isAuthenticated: !user.isAnonymous, displayName: user.displayName ?? null, }); - setUserCookieSession() - .then((wasRenewed) => { - if (wasRenewed && !user.isAnonymous) { - // The user feature flags will refresh with the session token ~1 hour - refreshUserFeatureFlags().catch(() => { - console.error('Failed to refresh feature flags'); - }); - } - }) - .catch(() => { - console.error('Failed to establish session cookie'); - }); + syncSession(user.uid, user.isAnonymous); // Check every 5 minutes; the cookie lasts 60 minutes, so this ensures renewal well before expiry // If the cookie is not expired, it will return early and skip the POST // The token will refresh 5 minutes before expiry which is why the 5 minute interval is used here. intervalRef.current = setInterval( () => { - setUserCookieSession() - .then((wasRenewed) => { - if (wasRenewed && !user.isAnonymous) { - // The user feature flags will refresh with the session token ~1 hour - refreshUserFeatureFlags().catch(() => { - console.error('Failed to refresh feature flags'); - }); - } - }) - .catch(() => { - console.error('Failed to establish session cookie'); - }); + syncSession(user.uid, user.isAnonymous); }, 5 * 60 * 1000, ); // 5 minutes } else { setSession({ + isAuthResolved: true, isAuthReady: false, + uid: null, email: null, isAuthenticated: false, displayName: null, diff --git a/src/app/components/UserFeatureFlagsSync.tsx b/src/app/components/UserFeatureFlagsSync.tsx new file mode 100644 index 00000000..b623b293 --- /dev/null +++ b/src/app/components/UserFeatureFlagsSync.tsx @@ -0,0 +1,20 @@ +'use client'; + +import { type ReactElement } from 'react'; +import { useUserFeatureFlags } from '../hooks/useUserFeatureFlags'; + +/** + * Keeps the user feature flags SWR entry warm for the lifetime of the App + * Router tree, independent of which page is mounted. + * + * useUserFeatureFlags() has no provider of its own — every caller shares the + * same SWR cache entry per uid — but flags should still resolve (and expose + * their state to Cypress) on pages that never render a consumer, e.g. so a + * subsequent navigation to a page that does need them doesn't wait on a fresh + * fetch. Mounting the hook here once, alongside AuthBroadcastChannelSync, + * covers that case. + */ +export function UserFeatureFlagsSync(): ReactElement | null { + useUserFeatureFlags(); + return null; +} diff --git a/src/app/context/UserFeatureFlagProvider.tsx b/src/app/context/UserFeatureFlagProvider.tsx deleted file mode 100644 index 3aa4e84b..00000000 --- a/src/app/context/UserFeatureFlagProvider.tsx +++ /dev/null @@ -1,97 +0,0 @@ -'use client'; - -import React, { - createContext, - useContext, - useEffect, - useState, - type ReactNode, -} from 'react'; -import { useAuthSession } from '../components/AuthSessionProvider'; -import { - FEATURE_FLAGS_CHANNEL, - createBroadcastChannel, -} from '../services/channel-service'; -import { - defaultUserFeatureFlags, - type UserFeatureFlags, -} from '../interface/UserFeatureFlags'; - -// Evaluated once at module load. False in production, so the Cypress -// exposure useEffect below is a no-op without any per-render window access. -const isCypress = - typeof window !== 'undefined' && - (window as { Cypress?: unknown }).Cypress != null; - -interface UserFeatureFlagContextValue { - flags: UserFeatureFlags; -} - -const UserFeatureFlagContext = createContext({ - flags: defaultUserFeatureFlags, -}); - -interface UserFeatureFlagProviderProps { - children: ReactNode; - initialFlags: UserFeatureFlags; -} - -/** - * Client-side user feature flag provider. - * - * Holds feature flags in ephemeral React state — not persisted, not in Redux. - * This avoids cross-session leakage and PersistGate concerns. - * - * Lifecycle: - * - `initialFlags` is the SSR-hydrated value from layout.tsx (read from the - * httpOnly cookie server-side). The initial render is always flash-free. - * - The service layer calls `broadcastExtendedMessage(FEATURE_FLAGS_CHANNEL, ...)` - * after writing the cookie, which delivers the flags to this tab and every - * other open tab through the channel registered below. - * - On logout the flags reset to defaults when `isAuthenticated` becomes false. - */ -export function UserFeatureFlagProvider({ - children, - initialFlags, -}: UserFeatureFlagProviderProps): React.ReactElement { - const [flags, setFlags] = useState(initialFlags); - const { isAuthReady, isAuthenticated } = useAuthSession(); - - // Listen for flag updates from this tab and other tabs through the shared - // channel-service. Same-tab updates arrive via broadcastExtendedMessage, - // cross-tab updates via the underlying BroadcastChannel. - useEffect(() => { - createBroadcastChannel(FEATURE_FLAGS_CHANNEL, setFlags); - }, []); - - // Expose the live flag values on window for Cypress e2e assertions. - // Mirrors the window.store pattern in store.ts — test-only, no prod impact. - useEffect(() => { - if (!isCypress) return; - (window as { __featureFlags?: UserFeatureFlags }).__featureFlags = flags; - }, [flags]); - - useEffect(() => { - if (!isAuthReady || isAuthenticated) return; - setFlags({ ...defaultUserFeatureFlags }); - }, [isAuthReady, isAuthenticated]); - - return ( - - {children} - - ); -} - -/** - * Returns all user feature flags as a typed map. - * Each property is resolved from the user's flag list, falling back to the - * default value defined in defaultUserFeatureFlags. - * - * @example - * const { isNotificationsEnabled } = useUserFeatureFlags(); - */ -export function useUserFeatureFlags(): UserFeatureFlags { - const { flags } = useContext(UserFeatureFlagContext); - return flags; -} diff --git a/src/app/hooks/useUserFeatureFlags.ts b/src/app/hooks/useUserFeatureFlags.ts new file mode 100644 index 00000000..2da0a766 --- /dev/null +++ b/src/app/hooks/useUserFeatureFlags.ts @@ -0,0 +1,95 @@ +import { useEffect } from 'react'; +import useSWR from 'swr'; +import { useAuthSession } from '../components/AuthSessionProvider'; +import { + USER_FEATURE_FLAGS_SWR_KEY, + fetchUserFeatureFlags, +} from '../services/user-feature-flag-service'; +import { + defaultUserFeatureFlags, + type UserFeatureFlags, +} from '../interface/UserFeatureFlags'; + +// Evaluated once at module load. False in production, so the Cypress +// exposure effect below is a no-op without any per-render window access. +const isCypress = + typeof window !== 'undefined' && + (window as { Cypress?: unknown }).Cypress != null; + +export interface UseUserFeatureFlagsResult { + flags: UserFeatureFlags; + /** + * False while entitlement is genuinely unknown — auth has not resolved yet, or + * it has and the flags are still in flight. The flag values are placeholders + * until this is true. + */ + isResolved: boolean; +} + +/** + * Returns the signed-in user's feature flags, falling back to + * defaultUserFeatureFlags for anything missing, together with whether those + * values have been resolved yet. + * + * Flags are resolved entirely on the client, from the user service, keyed by the + * live Firebase uid. There is deliberately no server-rendered seed: reading a + * per-user cookie during render cannot work on statically rendered routes (Next + * hands those an empty cookie store, so every read looks like a logged-out + * user), and a value read in the root layout would go stale anyway, since + * layouts are not re-rendered on client-side navigation. + * + * Every call shares one SWR cache entry per uid, so calling this from multiple + * components triggers a single request rather than one per caller: + * - The cache key includes the uid, so an identity change is a different key + * rather than a cache that must be invalidated. A signed-in user can never be + * shown flags resolved for someone else, and cross-tab login needs no + * coordination — this tab's Firebase listener changes the uid, and the new key + * fetches on its own. + * - The entry is kept for the lifetime of the document rather than revalidated + * on mount. + * - Refreshes come from a throttled revalidate on window focus, and from + * `revalidateUserFeatureFlags` when the auth session renews its token. + * + * While `isResolved` is false the flags are placeholders, so anything gating + * visible UI should render a pending state rather than the not-entitled one. + * + * @example + * const { flags, isResolved } = useUserFeatureFlags(); + */ +export function useUserFeatureFlags(): UseUserFeatureFlagsResult { + const { isAuthResolved, isAuthenticated, uid } = useAuthSession(); + + // Null key = no fetch. Anonymous and signed-out users are not entitled to + // anything, so defaults are the answer rather than a placeholder. + const cacheKey = + isAuthenticated && uid != null ? [USER_FEATURE_FLAGS_SWR_KEY, uid] : null; + + const { data, error } = useSWR(cacheKey, fetchUserFeatureFlags, { + revalidateIfStale: false, + revalidateOnFocus: true, + focusThrottleInterval: 60_000, + // Never carry one identity's flags into another's pending state. + keepPreviousData: false, + }); + + const flags = data ?? defaultUserFeatureFlags; + // A failed fetch settles as "resolved" with the safe defaults rather than + // leaving consumers in a pending state forever. + const isResolved = + isAuthResolved && + (!isAuthenticated || data !== undefined || error !== undefined); + + // Expose the live values on window for Cypress e2e assertions. + // Mirrors the window.store pattern in store.ts — test-only, no prod impact. + useEffect(() => { + if (!isCypress) return; + const testWindow = window as { + __featureFlags?: UserFeatureFlags; + __featureFlagsResolved?: boolean; + }; + testWindow.__featureFlags = flags; + testWindow.__featureFlagsResolved = isResolved; + }, [flags, isResolved]); + + return { flags, isResolved }; +} diff --git a/src/app/providers.tsx b/src/app/providers.tsx index fe7aff53..05016982 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -3,7 +3,6 @@ import * as React from 'react'; import ContextProviders from './components/Context'; import { RemoteConfigProvider } from './context/RemoteConfigProvider'; -import { UserFeatureFlagProvider } from './context/UserFeatureFlagProvider'; import { type RemoteConfigValues } from './interface/RemoteConfig'; // Look into this provider and see if it's client blocking. Niche provider might be able to isolate for single use @@ -11,6 +10,7 @@ import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; import { AuthSessionProvider } from './components/AuthSessionProvider'; import { AuthBroadcastChannelSync } from './components/AuthBroadcastChannelSync'; +import { UserFeatureFlagsSync } from './components/UserFeatureFlagsSync'; import { polyfillCountryFlagEmojis } from 'country-flag-emoji-polyfill'; interface ProvidersProps { @@ -48,12 +48,11 @@ export function Providers({ + - - - {children} - - + + {children} + diff --git a/src/app/services/user-feature-flag-service.ts b/src/app/services/user-feature-flag-service.ts new file mode 100644 index 00000000..1a17298f --- /dev/null +++ b/src/app/services/user-feature-flag-service.ts @@ -0,0 +1,64 @@ +import { mutate } from 'swr'; +import { + defaultUserFeatureFlags, + toUserFeatureFlags, + type FeatureFlag, + type UserFeatureFlags, +} from '../interface/UserFeatureFlags'; +import { retrieveUserInformation } from './profile-service'; + +/** + * SWR cache key prefix for the current user's feature flags. + * + * Always pair it with the Firebase uid — `[USER_FEATURE_FLAGS_SWR_KEY, uid]` — + * so an identity change lands on a different key instead of needing explicit + * invalidation. That makes it structurally impossible to hand one user the + * flags resolved for another. + */ +export const USER_FEATURE_FLAGS_SWR_KEY = 'user-feature-flags'; + +/** + * Fetches the signed-in user's feature flags from the user service. + * + * Authenticated with the caller's Firebase ID token (see getUserAccessToken), + * not a cookie, so it does not depend on `md_session` having been established + * yet and is safe to call as soon as Firebase reports a user. + * + * Returns defaults when the profile has no flags, so callers always receive a + * complete map. Rejections propagate for SWR to expose as `error`. + */ +export const fetchUserFeatureFlags = async (): Promise => { + const userData = await retrieveUserInformation(); + if (userData == null) return { ...defaultUserFeatureFlags }; + return toUserFeatureFlags(userData.features); +}; + +/** + * Re-fetches the given user's feature flags, updating every mounted consumer. + * + * Exists so callers can trigger a refresh without importing the cache key or + * knowing that SWR is involved — the auth session uses it to keep entitlements + * in step with token renewals. An identity *change* needs no call: the uid is + * part of the key, so the new identity resolves on its own. + */ +export const revalidateUserFeatureFlags = async ( + uid: string, +): Promise => { + await mutate([USER_FEATURE_FLAGS_SWR_KEY, uid]); +}; + +/** + * Seeds the SWR cache for the given user with flags already fetched elsewhere + * (e.g. the login/signup sagas' own `GET /v1/user` call), instead of letting + * `useUserFeatureFlags` fire a redundant duplicate request for data the + * caller already has. Passing `revalidate: false` accepts the seeded value as + * fresh rather than immediately re-fetching to confirm it. + */ +export const setUserFeatureFlagsCache = ( + uid: string, + apiFlags: FeatureFlag[], +): void => { + void mutate([USER_FEATURE_FLAGS_SWR_KEY, uid], toUserFeatureFlags(apiFlags), { + revalidate: false, + }); +}; diff --git a/src/app/store/saga/auth-saga.ts b/src/app/store/saga/auth-saga.ts index c806c7fd..cfc0ad33 100644 --- a/src/app/store/saga/auth-saga.ts +++ b/src/app/store/saga/auth-saga.ts @@ -40,7 +40,7 @@ import { sendEmailVerification, } from '../../services'; import { clearUserCookieSession } from '../../services/session-service'; -import { applyUserFeatureFlags } from '../../services/session-service'; +import { setUserFeatureFlagsCache } from '../../services/user-feature-flag-service'; import { type AdditionalUserInfo, type UserCredential, @@ -56,11 +56,8 @@ import { selectIsAnonymous, selectIsAuthenticated } from '../profile-selectors'; import { LOGIN_CHANNEL, LOGOUT_CHANNEL, - FEATURE_FLAGS_CHANNEL, broadcastMessage, - broadcastExtendedMessage, } from '../../services/channel-service'; -import { defaultUserFeatureFlags } from '../../interface/UserFeatureFlags'; function* emailLoginSaga({ payload: { email, password }, @@ -71,18 +68,15 @@ function* emailLoginSaga({ const userData = (yield call(retrieveUserInformation)) as | UserData | undefined; - try { - if (userData != null) { - yield call(applyUserFeatureFlags, userData.features); - } - } catch { - // Swallowed — feature flag cookie is non-critical to login. - } const userEnhanced = populateUserWithAdditionalInfo( user, userData, undefined, ); + const uid = app.auth().currentUser?.uid; + if (userData !== undefined && uid != null) { + setUserFeatureFlagsCache(uid, userData.features); + } yield put(loginSuccess(userEnhanced)); broadcastMessage(LOGIN_CHANNEL); } catch (error) { @@ -103,16 +97,6 @@ function* logoutSaga({ // server-side requests immediately see the user as logged out. yield call(clearUserCookieSession); - // Reset feature flags to their defaults in this tab and every other open - // tab through the shared feature-flags channel. - try { - broadcastExtendedMessage(FEATURE_FLAGS_CHANNEL, { - ...defaultUserFeatureFlags, - }); - } catch { - // Channel may not be initialised yet — non-critical to logout. - } - yield put(logoutSuccess()); if (propagate) { try { @@ -143,18 +127,15 @@ function* signUpSaga({ const userData = (yield call(retrieveUserInformation)) as | UserData | undefined; - try { - if (userData != null) { - yield call(applyUserFeatureFlags, userData.features); - } - } catch { - // Swallowed — feature flag cookie is non-critical to sign-up. - } const userEnhanced = populateUserWithAdditionalInfo( user, userData, undefined, ); + const uid = app.auth().currentUser?.uid; + if (userData !== undefined && uid != null) { + setUserFeatureFlagsCache(uid, userData.features); + } yield put(signUpSuccess(userEnhanced)); } catch (error) { yield put(signUpFail(getAppError(error) as ProfileError)); @@ -208,18 +189,15 @@ function* loginWithProviderSaga({ const userData = (yield call(retrieveUserInformation)) as | UserData | undefined; - try { - if (userData != null) { - yield call(applyUserFeatureFlags, userData.features); - } - } catch { - // Swallowed — feature flag cookie is non-critical to provider login. - } const userEnhanced = populateUserWithAdditionalInfo( user, userData, additionalUserInfo, ); + const uid = app.auth().currentUser?.uid; + if (userData !== undefined && uid != null) { + setUserFeatureFlagsCache(uid, userData.features); + } yield put( loginSuccess({ ...userEnhanced, From 43c9292d6c8d77e7154e00b529263c4532c3fb2c Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 4 Aug 2026 13:39:15 -0400 Subject: [PATCH 07/13] client subscribe button feed details --- src/app/screens/Feed/FeedView.tsx | 2 +- .../components/ClientSubscribeControls.tsx | 259 ++++++++++++++++-- 2 files changed, 242 insertions(+), 19 deletions(-) diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index 45803317..7432913a 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -361,7 +361,7 @@ export default async function FeedView({ /> )} {feed?.data_type === 'gbfs' && <>{gbfsOpenFeedUrlElement()}} - + diff --git a/src/app/screens/Feed/components/ClientSubscribeControls.tsx b/src/app/screens/Feed/components/ClientSubscribeControls.tsx index cf9dcca9..95ec5361 100644 --- a/src/app/screens/Feed/components/ClientSubscribeControls.tsx +++ b/src/app/screens/Feed/components/ClientSubscribeControls.tsx @@ -1,9 +1,7 @@ 'use client'; -// This component is currently hardcoded -// To implement actual data fetching / setting once backend APIs are in place - import { useState } from 'react'; +import useSWR from 'swr'; import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; @@ -15,30 +13,137 @@ import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; import CheckIcon from '@mui/icons-material/Check'; +import LockIcon from '@mui/icons-material/Lock'; import NotificationsIcon from '@mui/icons-material/Notifications'; import { useTranslations } from 'next-intl'; import { useRemoteConfig } from '../../../context/RemoteConfigProvider'; +import { useUserFeatureFlags } from '../../../hooks/useUserFeatureFlags'; import { useAuthSession } from '../../../components/AuthSessionProvider'; import { Link, usePathname } from '../../../../i18n/navigation'; +import { + createUserSubscription, + deleteUserSubscription, + getUserSubscriptions, + type NotificationSubscription, + USER_SUBSCRIPTIONS_SWR_KEY, +} from '../../../services/notification-service'; +import { FEED_NOTIFICATION_TYPE_IDS } from '../../../utils/notificationTypes'; import NotificationSettingsDialog, { - defaultNotificationSettings, type NotificationSettings, } from './NotificationSettingsDialog'; -export default function ClientSubscribeControls(): React.ReactElement | null { +interface ClientSubscribeControlsProps { + feedId: string; +} + +// This allows us to have an instant UI showing the user is subscribed +function buildOptimisticSubscriptions( + feedId: string, +): NotificationSubscription[] { + const now = new Date().toISOString(); + return FEED_NOTIFICATION_TYPE_IDS.map((notificationId) => ({ + id: `optimistic-${feedId}-${notificationId}`, + user_id: '', + notification_id: notificationId, + active: true, + created_at: now, + feeds: [{ feed_id: feedId }], + })); +} + +function withoutIds( + subscriptions: NotificationSubscription[] | undefined, + ids: string[], +): NotificationSubscription[] { + return (subscriptions ?? []).filter( + (subscription) => !ids.includes(subscription.id), + ); +} + +/** + * Creates one subscription per notification type for the feed. Resolves with + * whichever succeeded; only throws if every request failed, so a partial + * success still lands in the cache instead of rolling back everything. + */ +async function subscribeToAllNotificationTypes( + feedId: string, +): Promise { + const results = await Promise.allSettled( + FEED_NOTIFICATION_TYPE_IDS.map((notificationId) => + createUserSubscription({ + notification_id: notificationId, + feed_ids: [feedId], + }), + ), + ); + const created = results + .filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) + .map((result) => result.value); + + if (created.length === 0) { + throw new Error('Failed to subscribe to any notification type'); + } + return created; +} + +export default function ClientSubscribeControls({ + feedId, +}: ClientSubscribeControlsProps): React.ReactElement | null { const { config } = useRemoteConfig(); const { isAuthenticated } = useAuthSession(); + const { + flags: { isNotificationsEnabled }, + isResolved: areFlagsResolved, + } = useUserFeatureFlags(); const t = useTranslations('feeds'); const pathname = usePathname(); - const [isSubscribed, setIsSubscribed] = useState(false); + // Entitlement is genuinely unknown until the flags resolve — on statically + // rendered routes they arrive as defaults and are re-fetched client-side. + // Showing the lock in that window would be a wrong answer the user can click. + const isAccessPending = isAuthenticated && !areFlagsResolved; + const hasNoAccess = + !isAccessPending && (!isAuthenticated || !isNotificationsEnabled); + const [snackbarMessage, setSnackbarMessage] = useState(''); + const [snackbarSeverity, setSnackbarSeverity] = useState< + 'success' | 'info' | 'error' + >('info'); const [menuAnchor, setMenuAnchor] = useState(null); const [popoverAnchor, setPopoverAnchor] = useState(null); + const [accessPopoverAnchor, setAccessPopoverAnchor] = + useState(null); const [settingsOpen, setSettingsOpen] = useState(false); - const [settings, setSettings] = useState( - defaultNotificationSettings, + + const notify = ( + severity: 'success' | 'info' | 'error', + messageKey: Parameters[0], + ): void => { + setSnackbarSeverity(severity); + setSnackbarMessage(t(messageKey)); + }; + + const { data: subscriptions, mutate: mutateSubscriptions } = useSWR< + NotificationSubscription[] + >(isAuthenticated ? USER_SUBSCRIPTIONS_SWR_KEY : null, getUserSubscriptions); + + const feedSubscriptions = (subscriptions ?? []).filter( + (subscription) => + FEED_NOTIFICATION_TYPE_IDS.includes(subscription.notification_id) && + subscription.feeds?.some((feed) => feed.feed_id === feedId), ); + const isSubscribed = feedSubscriptions.some( + (subscription) => subscription.active, + ); + + const dialogInitialSettings: NotificationSettings = { + changeTypes: feedSubscriptions + .filter((subscription) => subscription.active) + .map((subscription) => subscription.notification_id), + }; if (!config.isNotificationsEnabled) { return null; @@ -49,14 +154,67 @@ export default function ClientSubscribeControls(): React.ReactElement | null { setPopoverAnchor(e.currentTarget); return; } - setIsSubscribed(true); - setSnackbarMessage(t('subscribedToFeed')); + if (!isNotificationsEnabled) { + setAccessPopoverAnchor(e.currentTarget); + return; + } + + const optimisticSubscriptions = buildOptimisticSubscriptions(feedId); + const optimisticIds = optimisticSubscriptions.map( + (subscription) => subscription.id, + ); + + mutateSubscriptions(subscribeToAllNotificationTypes(feedId), { + optimisticData: (current) => [ + ...(current ?? []), + ...optimisticSubscriptions, + ], + rollbackOnError: true, + populateCache: (created, current) => [ + ...withoutIds(current, optimisticIds), + ...created, + ], + revalidate: false, + }) + .then((created) => { + const allSucceeded = + (created?.length ?? 0) === FEED_NOTIFICATION_TYPE_IDS.length; + notify( + allSucceeded ? 'success' : 'error', + allSucceeded ? 'subscribedToFeed' : 'subscribePartialFailure', + ); + }) + .catch(() => { + notify('error', 'subscribeFailed'); + }); }; const handleUnsubscribe = (): void => { setMenuAnchor(null); - setIsSubscribed(false); - setSnackbarMessage(t('unsubscribedFromFeed')); + + const idsToRemove = feedSubscriptions.map( + (subscription) => subscription.id, + ); + + mutateSubscriptions( + Promise.all( + feedSubscriptions.map((subscription) => + deleteUserSubscription(subscription.id), + ), + ).then(() => undefined), + { + optimisticData: (current) => withoutIds(current, idsToRemove), + rollbackOnError: true, + populateCache: (_, current) => withoutIds(current, idsToRemove), + revalidate: false, + }, + ) + .then(() => { + notify('info', 'unsubscribedFromFeed'); + }) + .catch(() => { + notify('error', 'unsubscribeFailed'); + }); }; return ( @@ -67,7 +225,8 @@ export default function ClientSubscribeControls(): React.ReactElement | null { > + + + + { setSettingsOpen(false); }} - onSave={(newSettings) => { - setSettings(newSettings); + onSave={() => { setSettingsOpen(false); }} - initialSettings={settings} + initialSettings={dialogInitialSettings} + feedId={feedId} + existingSubscriptions={feedSubscriptions} /> { setSnackbarMessage(''); }} - severity={isSubscribed ? 'success' : 'info'} + severity={snackbarSeverity} sx={{ width: '100%' }} > {snackbarMessage} From 8dbad6c28b7c0bc270733027d9cbf55b22ce7efa Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 4 Aug 2026 13:39:51 -0400 Subject: [PATCH 08/13] notifications table --- .../notifications/AccountNotifications.tsx | 599 +++++++++--------- 1 file changed, 297 insertions(+), 302 deletions(-) diff --git a/src/app/[locale]/account/notifications/AccountNotifications.tsx b/src/app/[locale]/account/notifications/AccountNotifications.tsx index 499a05c6..6cc340a1 100644 --- a/src/app/[locale]/account/notifications/AccountNotifications.tsx +++ b/src/app/[locale]/account/notifications/AccountNotifications.tsx @@ -1,105 +1,143 @@ 'use client'; import * as React from 'react'; +import useSWR from 'swr'; +import { useTranslations } from 'next-intl'; +import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; -import Button from '@mui/material/Button'; -import Checkbox from '@mui/material/Checkbox'; import Chip from '@mui/material/Chip'; -import FormControlLabel from '@mui/material/FormControlLabel'; -import FormGroup from '@mui/material/FormGroup'; -import FormLabel from '@mui/material/FormLabel'; -import FormControl from '@mui/material/FormControl'; import IconButton from '@mui/material/IconButton'; +import Link from '@mui/material/Link'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; -import Select from '@mui/material/Select'; -import Tab from '@mui/material/Tab'; +import Snackbar from '@mui/material/Snackbar'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; import TableCell from '@mui/material/TableCell'; import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; -import Tabs from '@mui/material/Tabs'; +import TableSortLabel from '@mui/material/TableSortLabel'; import Typography from '@mui/material/Typography'; -import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import MoreVertIcon from '@mui/icons-material/MoreVert'; -import ChangeTypeInfoPopover, { - CHANGE_TYPE_INFO, -} from '../../../components/ChangeTypeInfoPopover'; +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'; +import { + deleteUserSubscription, + getUserSubscriptions, + type NotificationSubscription, + type SubscriptionFeed, + updateUserSubscription, + USER_SUBSCRIPTIONS_SWR_KEY, +} from '../../../services/notification-service'; +import { NOTIFICATION_TYPES } from '../../../utils/notificationTypes'; -interface NotificationSubscription { - id: string; - title: string; - status: 'active' | 'paused'; - frequency: 'onChange' | 'weekly' | 'monthly' | 'quarterly'; - lastSent: string | null; -} - -const MOCK_NOTIFICATIONS: NotificationSubscription[] = [ - { - id: '1', - title: 'STM – Société de transport de Montréal', - status: 'active', - frequency: 'weekly', - lastSent: '2026-05-20', - }, - { - id: '2', - title: 'TTC – Toronto Transit Commission', - status: 'paused', - frequency: 'monthly', - lastSent: '2026-04-15', - }, - { - id: '3', - title: 'MTA New York City Transit', - status: 'active', - frequency: 'onChange', - lastSent: '2026-05-26', - }, - { - id: '4', - title: 'OC Transpo Ottawa', - status: 'active', - frequency: 'quarterly', - lastSent: null, - }, - { - id: '5', - title: 'TransLink – Metro Vancouver', - status: 'paused', - frequency: 'weekly', - lastSent: '2026-03-02', - }, -]; +const nonEmpty = (value?: string | null): string | undefined => + value != null && value.trim() !== '' ? value : undefined; -const CHANGE_TYPE_OPTIONS = [ - { value: 'any', label: 'Any Change' }, - { value: 'features', label: 'Features Only' }, - { value: 'expiry', label: '7 Days Before Expiry' }, - { value: 'validation', label: 'New Validation Errors' }, - { value: 'breaking', label: 'Breaking Changes' }, - { value: 'suspicious', label: 'Suspicious Changes' }, -] as const; +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 SPECIFIC_TYPES = [ - 'features', - 'expiry', - 'validation', - 'breaking', - 'suspicious', -]; +type SortKey = 'title' | 'type' | 'status' | 'subscribed'; export default function AccountNotifications(): React.ReactElement { - const [tab, setTab] = React.useState(0); - const [notifications, setNotifications] = - React.useState(MOCK_NOTIFICATIONS); + const t = useTranslations('feeds'); + const { isAuthenticated } = useAuthSession(); + const [orderBy, setOrderBy] = React.useState(null); + const [order, setOrder] = React.useState<'asc' | 'desc'>('asc'); + + const getNotificationTypeLabel = (notificationId: string): string => { + const type = NOTIFICATION_TYPES.find( + (definition) => definition.id === notificationId, + ); + 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 renderSubscriptionTitle = ( + subscription: NotificationSubscription, + ): React.ReactNode => { + const feeds = subscription.feeds ?? []; + if (feeds.length === 0) { + return getNotificationTypeLabel(subscription.notification_id); + } + return feeds.map((feed, index) => ( + + {index > 0 && ', '} + + {getFeedTitle(feed)} + + + )); + }; + + const handleSortClick = (key: SortKey): void => { + if (orderBy === key) { + setOrder((prev) => (prev === 'asc' ? 'desc' : 'asc')); + } else { + setOrderBy(key); + setOrder('asc'); + } + }; + + const getSortValue = ( + subscription: NotificationSubscription, + key: SortKey, + ): string | number => { + switch (key) { + case 'title': + return getSubscriptionTitle(subscription).toLowerCase(); + case 'type': + return getNotificationTypeLabel( + subscription.notification_id, + ).toLowerCase(); + case 'status': + return subscription.active ? 'active' : 'paused'; + case 'subscribed': + return new Date(subscription.created_at).getTime(); + } + }; + + const { + data: notifications = [], + error: loadError, + isLoading, + mutate, + } = useSWR( + isAuthenticated ? USER_SUBSCRIPTIONS_SWR_KEY : null, + getUserSubscriptions, + ); + + const [actionError, setActionError] = React.useState(null); const [menuState, setMenuState] = React.useState<{ anchor: HTMLElement; id: string; @@ -109,22 +147,20 @@ export default function AccountNotifications(): React.ReactElement { Record >({}); - // Default settings state for the Settings tab - const [defaultFrequency, setDefaultFrequency] = - React.useState('onChange'); - const [defaultChangeTypes, setDefaultChangeTypes] = React.useState( - [], - ); - const [infoPopover, setInfoPopover] = React.useState<{ - anchor: HTMLElement; - type: string; - } | null>(null); - + const sortedNotifications = + orderBy === null + ? notifications + : [...notifications].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) : undefined; - const isPaused = selectedSubscription?.status === 'paused'; + const isPaused = selectedSubscription?.active === false; const handleMenuOpen = ( event: React.MouseEvent, @@ -138,24 +174,46 @@ export default function AccountNotifications(): React.ReactElement { }; const handleTogglePause = (): void => { - if (menuState !== null) { - const { id } = menuState; - setNotifications((prev) => - prev.map((n) => - n.id === id - ? { ...n, status: n.status === 'paused' ? 'active' : 'paused' } - : n, - ), - ); - handleMenuClose(); + if (menuState === null) { + return; } + const { id } = menuState; + const subscription = notifications.find((n) => n.id === id); + handleMenuClose(); + if (subscription === undefined) { + 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'); + }); }; const handleUnsubscribe = (): void => { if (menuState !== null) { const { id } = menuState; - setNotifications((prev) => prev.filter((n) => n.id !== id)); handleMenuClose(); + deleteUserSubscription(id) + .then(() => mutate()) + .catch(() => { + setActionError('Failed to unsubscribe'); + }); } }; @@ -166,23 +224,6 @@ export default function AccountNotifications(): React.ReactElement { setSettingsDialogOpen(false); }; - const handleDefaultChangeTypeToggle = (value: string): void => { - if (value === 'any') { - setDefaultChangeTypes((prev) => - prev.includes('any') ? [] : ['any', ...SPECIFIC_TYPES], - ); - } else { - setDefaultChangeTypes((prev) => { - if (prev.includes(value)) { - return prev.filter((t) => t !== value && t !== 'any'); - } - const withNew = prev.filter((t) => t !== 'any').concat(value); - const allSpecific = SPECIFIC_TYPES.every((t) => withNew.includes(t)); - return allSpecific ? ['any', ...withNew] : withNew; - }); - } - }; - const selectedRowId = menuState?.id; const settingsInitial = selectedRowId !== undefined @@ -190,209 +231,163 @@ export default function AccountNotifications(): React.ReactElement { : defaultNotificationSettings; return ( - - { - setTab(v); + + { + setActionError(null); }} - sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }} + anchorOrigin={{ vertical: 'top', horizontal: 'center' }} > - - - + { + setActionError(null); + }} + sx={{ width: '100%' }} + > + {actionError} + + - {/* ── Feeds tab ─────────────────────────────────────────────── */} - {tab === 0 && ( - - - - - - + + {loadError !== undefined && ( + + Failed to load notification subscriptions. + + )} + +
+ + + + { + handleSortClick('title'); + }} + > Title - - + + + + { + handleSortClick('type'); + }} + > + Type + + + + { + handleSortClick('status'); + }} + > Status + + + + { + handleSortClick('subscribed'); + }} + > + Subscribed + + + + + + + + + {sortedNotifications.map((n) => ( + + {renderSubscriptionTitle(n)} + + {getNotificationTypeLabel(n.notification_id)} - Last Sent + - + {new Date(n.created_at).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + })} - - - - {notifications.map((n) => ( - - {n.title} - - - - - {n.lastSent !== null - ? new Date(n.lastSent).toLocaleDateString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - }) - : '—'} - - - { - handleMenuOpen(e, n.id); - }} - > - - - - - ))} - {notifications.length === 0 && ( - - - - No active subscriptions - - - - )} - -
-
- - - - {isPaused ? 'Resume Notifications' : 'Pause Notifications'} - - - Unsubscribe - - - - { - setSettingsDialogOpen(false); - }} - onSave={handleSaveSettings} - initialSettings={settingsInitial} - /> -
- )} - - {/* ── Settings tab ──────────────────────────────────────────── */} - {tab === 1 && ( - - - Global Notification Preferences - - - These settings will apply to all feed subscriptions you create - - - - - Notification Frequency - - - - - - - Notify Me About - - - {CHANGE_TYPE_OPTIONS.map((opt) => ( - { - handleDefaultChangeTypeToggle(opt.value); + + { + handleMenuOpen(e, n.id); }} - /> - } - label={ - opt.value in CHANGE_TYPE_INFO ? ( - - {opt.label} - { - e.preventDefault(); - e.stopPropagation(); - setInfoPopover({ - anchor: e.currentTarget, - type: opt.value, - }); - }} - sx={{ ml: 0.5 }} - aria-label={`About ${opt.label}`} - > - - - - ) : ( - opt.label - ) - } - /> + > + + + + ))} - - + {notifications.length === 0 && loadError === undefined && ( + + + + No active subscriptions + + + + )} + + + - - - )} + + + {isPaused ? 'Resume Notifications' : 'Pause Notifications'} + + + Unsubscribe + + - {infoPopover != null && ( - { - setInfoPopover(null); + setSettingsDialogOpen(false); }} + onSave={handleSaveSettings} + initialSettings={settingsInitial} /> - )} + ); } From 4c8b76ee1c9f8cda6b7a59b831a1b4d64243fb27 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 4 Aug 2026 13:39:59 -0400 Subject: [PATCH 09/13] i18n translations --- messages/en.json | 11 +++++++++++ messages/fr.json | 11 +++++++++++ 2 files changed, 22 insertions(+) diff --git a/messages/en.json b/messages/en.json index cbe121ed..1a1128b9 100644 --- a/messages/en.json +++ b/messages/en.json @@ -255,6 +255,17 @@ "unsubscribe": "Unsubscribe to stop receiving feed update notifications", "subscribedToFeed": "You'll receive updates for this feed", "unsubscribedFromFeed": "You've been unsubscribed and will no longer receive updates for this feed.", + "subscribeFailed": "Failed to subscribe to this feed. Please try again.", + "subscribePartialFailure": "Subscribed to some notification types for this feed, but others failed. You can retry the missing ones from Notification Settings.", + "unsubscribeFailed": "Failed to unsubscribe from this feed. Please try again.", + "feedUrlUpdatedLabel": "Feed URL Updated", + "feedUrlUpdatedTooltip": "Notifies you when the feed's download URL changes.", + "feedUrlAvailabilityLabel": "Feed URL Availability", + "feedUrlAvailabilityTooltip": "Notifies you when the feed's URL becomes unavailable or comes back online.", + "feedCoverageLabel": "Feed Coverage", + "feedCoverageTooltip": "Notifies you when the feed's service date coverage changes.", + "apiAnnouncementsLabel": "Announcements", + "apiAnnouncementsTooltip": "Notifies you about API-wide announcements from MobilityData.", "qualityReportUpdated": "Quality report updated", "officialFeedUpdated": "Official verification updated", "serviceDateRange": "Service Date Range", diff --git a/messages/fr.json b/messages/fr.json index 9c53de8e..d0540c08 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -255,6 +255,17 @@ "unsubscribe": "Se désabonner", "subscribedToFeed": "Vous êtes abonné à ce flux", "unsubscribedFromFeed": "Vous vous êtes désabonné de ce flux", + "subscribeFailed": "Impossible de vous abonner à ce flux. Veuillez réessayer.", + "subscribePartialFailure": "Abonnement réussi pour certains types de notification de ce flux, mais pas pour d'autres. Vous pouvez réessayer depuis les paramètres de notification.", + "unsubscribeFailed": "Impossible de vous désabonner de ce flux. Veuillez réessayer.", + "feedUrlUpdatedLabel": "URL du flux mise à jour", + "feedUrlUpdatedTooltip": "Vous avertit lorsque l'URL de téléchargement du flux change.", + "feedUrlAvailabilityLabel": "Disponibilité de l'URL du flux", + "feedUrlAvailabilityTooltip": "Vous avertit lorsque l'URL du flux devient indisponible ou redevient accessible.", + "feedCoverageLabel": "Couverture du flux", + "feedCoverageTooltip": "Vous avertit lorsque la couverture des dates de service du flux change.", + "apiAnnouncementsLabel": "Annonces", + "apiAnnouncementsTooltip": "Vous avertit des annonces générales de l'API par MobilityData.", "qualityReportUpdated": "Quality report updated", "officialFeedUpdated": "Official verification updated", "serviceDateRange": "Service Date Range", From 5c02e9b57a3f8aad523d0497f0904e11e111cb91 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 4 Aug 2026 13:46:44 -0400 Subject: [PATCH 10/13] updated documentation --- CLAUDE.md | 34 +++-- docs/user-feature-flags.md | 266 ++++++++++++++++--------------------- 2 files changed, 139 insertions(+), 161 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 92e12a4a..8239cf36 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,8 +53,9 @@ sits under the `[locale]` segment. Server Components are the default: data fetching, token minting, Firebase Admin, Remote Config. Add `'use client'` only for interactivity (hooks, state, events). Server→client data crosses the boundary -in exactly three payloads, all set up in `[locale]/layout.tsx`: `messages` (next-intl), `remoteConfig`, and -`featureFlags`. **Never pass tokens or credentials to the client.** +in exactly two payloads, both set up in `[locale]/layout.tsx`: `messages` (next-intl) and `remoteConfig`. +User feature flags are **not** part of this boundary — they resolve entirely client-side (see below). +**Never pass tokens or credentials to the client.** ### i18n is path-prefixed, not subdomain-based @@ -115,18 +116,24 @@ Full detail, env vars, and troubleshooting: `docs/Authentication.md` (or the `mo | | Firebase Remote Config | User feature flags | |---|---|---| | Scope | Global | Per-user | -| Source | Firebase | `GET /v1/user` → HMAC-signed `md_features` cookie | -| Server read | `getRemoteConfigValues()` / `getUserRemoteConfigValues()` (`src/lib/remote-config.server.ts`) | `getServerFlags()` (`src/app/actions/feature-flags.ts`) | -| Client read | `useRemoteConfig()` → `{ config }` | `useUserFeatureFlags()` → flags | +| Source | Firebase | `GET /v1/user`, resolved client-side only | +| Server read | `getRemoteConfigValues()` / `getUserRemoteConfigValues()` (`src/lib/remote-config.server.ts`) | None — no server-side equivalent, see below | +| Client read | `useRemoteConfig()` → `{ config }` | `useUserFeatureFlags()` (`src/app/hooks/useUserFeatureFlags.ts`) → `{ flags, isResolved }` | | Define a flag | `src/app/interface/RemoteConfig.ts` | `src/app/interface/UserFeatureFlags.ts` | Remote Config is cached 5 min (dev) / 1 hour (prod) and has an admin email-regex bypass that flips every boolean true (`featureFlagBypass`). Note `RemoteConfig.ts` carries a stale `// FEATUTRE BYPASS CURRENTLY DISABLED` comment — the bypass **is** active. -`docs/user-feature-flags.md` explains the cookie + BroadcastChannel design and why Redux was rejected. -Known gap: `POST /api/feature-flags` does not verify the caller — fine for UI-only flags, must be hardened -before a flag gates real access. +User feature flags resolve **entirely on the client**, via a plain SWR hook keyed by the live Firebase uid +(`src/app/services/user-feature-flag-service.ts`) — there is no cookie, no React context, and no +server-rendered seed. `UserFeatureFlagsSync` (`src/app/components/UserFeatureFlagsSync.tsx`) mounts the hook +once, globally, in `providers.tsx` so flags resolve on every page, not only ones with a real consumer; the +login/signup sagas seed the SWR cache from their own `GET /v1/user` call (`setUserFeatureFlagsCache()`) so +the hook doesn't re-fetch on first render. A per-user cookie read during render can't work on statically +rendered routes (Next hands those an empty cookie store), which is why that approach — and the +`POST /api/feature-flags` route, `BroadcastChannel` push, and `getServerFlags()` server action that went with +it — was removed. `docs/user-feature-flags.md` covers the full design. ### API layer @@ -148,8 +155,9 @@ Prefer the ergonomic aliases and type guards in `src/app/services/feeds/utils.ts - `` is mounted globally **without** `PersistGate` so SSG/SSR renders immediately. Wrap routes needing rehydrated state — or `useSearchParams()` — in `components/ReduxGateWrapper.tsx`. Check rehydration with `useRehydrated()`. -- **React context** (not Redux) for theme, Remote Config, and user feature flags. -- **SWR** for the `/feeds` search (`src/app/[locale]/feeds/lib/useFeedsSearch.ts`). +- **React context** (not Redux) for theme and Remote Config. +- **SWR** for the `/feeds` search (`src/app/[locale]/feeds/lib/useFeedsSearch.ts`) and for user feature flags + (`useUserFeatureFlags()` — no context, see the feature-flag section above). - Typed hooks: `useAppDispatch`, `useAppSelector` from `src/app/hooks/`. `profile-reducer` `status` is a 10-value union; the load-bearing distinction is @@ -260,8 +268,10 @@ interval doesn't fire under CI's `next start`. actually a **feedId**. It looks up the feed and redirects to the canonical `/feeds/{data_type}/{feedId}`. Don't "fix" the naming. - `docs/feed-detail-caching-flow.md` writes the cookie as `session_md`; the code uses **`md_session`**. -- `isNotificationsEnabled` exists in *both* flag systems; the live consumer reads it from - `useRemoteConfig()`. Real duplication, not a doc error. +- `isNotificationsEnabled` exists in *both* flag systems, and the live consumer + (`ClientSubscribeControls.tsx`) reads **both**: `useRemoteConfig()` gates whether the feature is live at + all, `useUserFeatureFlags()` gates whether this specific user is entitled to it. Real duplication, not a + doc error. - `src/mocks/data/*.json` look like dead duplicates of `cypress/fixtures/*`. - Worktrees: `yarn new-worktree feat/x` / `yarn remove-worktree feat/x` (copies `.env*`, hard-links `node_modules`). diff --git a/docs/user-feature-flags.md b/docs/user-feature-flags.md index 495330ce..8500981d 100644 --- a/docs/user-feature-flags.md +++ b/docs/user-feature-flags.md @@ -1,90 +1,70 @@ # User Feature Flags -User-based feature flags are per-user configuration values resolved by the backend (`GET /v1/user`) and made available across the entire app — both on the server (Server Components, middleware) and on the client (React components). +User feature flags are per-user configuration values resolved from the backend (`GET /v1/user`) and consumed +on the client only, via a plain reusable [SWR](https://swr.vercel.app/) hook. + +This is the "User feature flags" row in the CLAUDE.md two-feature-flag-systems table — global flags are a +different mechanism (Firebase Remote Config, see `src/lib/remote-config.server.ts`). --- ## Architecture overview ``` -┌──────────────────────────────────────────────────────────────┐ -│ Login (Redux Saga — client) │ -│ │ -│ 1. GET /v1/user → UserProfile.features[] │ -│ 2. yield call(applyUserFeatureFlags, features) │ -│ → POST /api/feature-flags → HMAC-signs → sets │ -│ md_features httpOnly cookie │ -│ → on success, broadcasts the resolved flags on the │ -│ FEATURE_FLAGS_CHANNEL BroadcastChannel │ -└──────────────────────────┬───────────────────────────────────-┘ - │ -┌──────────────────────────────────────────────────────────────┐ -│ Session renewal (AuthSessionProvider — client, ~hourly) │ -│ │ -│ setUserCookieSession() returns wasRenewal=true when an │ -│ existing session is stale (same uid, cookie expired). │ -│ AuthSessionProvider then calls refreshUserFeatureFlags(): │ -│ 1. GET /v1/user → UserProfile.features[] │ -│ 2. applyUserFeatureFlags(features) (same path as login) │ -└──────────────────────────┬───────────────────────────────────-┘ - │ cookie written + flags broadcast - ┌────────────────┴───────────────┐ - ▼ ▼ -┌─────────────────┐ ┌──────────────────────────┐ -│ Server side │ │ Client side │ -│ │ │ │ -│ getServerFlags()│ │ UserFeatureFlagProvider │ -│ (Server Action) │ │ listens on │ -│ reads & verifies│ │ FEATURE_FLAGS_CHANNEL, │ -│ md_features │ │ holds flags in React │ -│ cookie directly │ │ state (ephemeral) │ -│ │ │ │ -│ Used in: │ │ useUserFeatureFlags() │ -│ - layout.tsx │ │ → typed map │ -│ (SSR hydrate) │ │ { isNotifications │ -│ - Server │ │ Enabled: boolean, … } │ -│ Components │ │ │ -└─────────────────┘ └──────────────────────────┘ +┌───────────────────────────────────────────────────────────────────┐ +│ Login / signup / OAuth login sagas (auth-saga.ts — client) │ +│ │ +│ 1. GET /v1/user (retrieveUserInformation, already fetched for │ +│ the profile itself) │ +│ 2. setUserFeatureFlagsCache(uid, userData.features) │ +│ → seeds the SWR cache for [USER_FEATURE_FLAGS_SWR_KEY, uid] │ +│ with revalidate: false, so useUserFeatureFlags() below │ +│ never re-fetches data the saga already has │ +└───────────────────────────────┬────────────────────────────────────┘ + │ cache seeded (no network call) + ▼ +┌───────────────────────────────────────────────────────────────────┐ +│ useUserFeatureFlags() (src/app/hooks/useUserFeatureFlags.ts) │ +│ │ +│ useSWR([USER_FEATURE_FLAGS_SWR_KEY, uid], fetchUserFeatureFlags) │ +│ - No seed yet (e.g. page reload with an existing Firebase │ +│ session, no login saga ran)? Fetches GET /v1/user itself, │ +│ authenticated with the client's Firebase ID token. │ +│ - Seed already present? Reuses it — no extra request. │ +│ - Every caller shares this one cache entry per uid, whether it's │ +│ UserFeatureFlagsSync (below) or any future consumer. │ +└───────────────────────────────┬────────────────────────────────────┘ + │ + ┌───────────────┴────────────────┐ + ▼ ▼ +┌────────────────────────────┐ ┌─────────────────────────────┐ +│ UserFeatureFlagsSync │ │ Any other consumer │ +│ (components/ │ │ (e.g. ClientSubscribeControls)│ +│ UserFeatureFlagsSync.tsx) │ │ │ +│ │ │ const { flags, isResolved } │ +│ Mounted once, globally, in │ │ = useUserFeatureFlags(); │ +│ providers.tsx. Renders null; │ │ │ +│ exists so flags resolve (and │ └───────────────────────────────┘ +│ expose window.__featureFlags/│ +│ __featureFlagsResolved for │ +│ Cypress) on every page, not │ +│ only ones with a real │ +│ consumer. │ +└────────────────────────────┘ ``` -Note the read path (`getServerFlags`) and write path (`applyUserFeatureFlags`) are two different mechanisms: - -- **Reads** go through a Server Action (`src/app/actions/feature-flags.ts`), used for SSR hydration in `layout.tsx`. -- **Writes** go through a plain API route (`POST /api/feature-flags`), called from the client via `fetch`. The client never gets the cookie value directly — the route sets it `httpOnly` — but the client *does* get the resolved flags back immediately via the `BroadcastChannel` push described below, so no read-after-write round trip is needed. - -## Data flow in detail - -### On login - -1. Login saga calls `GET /v1/user` (`retrieveUserInformation`) and receives `UserProfile.features[]`. -2. Saga calls `applyUserFeatureFlags(features)` (`session-service.ts`), which: - - `POST`s the raw `FeatureFlag[]` to `/api/feature-flags`, which HMAC-signs the payload and writes an `httpOnly` cookie (`md_features`, 1 hr TTL). - - On a successful response, converts the flags with `toUserFeatureFlags()` and calls `broadcastExtendedMessage(FEATURE_FLAGS_CHANNEL, flags)`. -3. `broadcastExtendedMessage` delivers the resolved flags to every other open tab via the underlying `BroadcastChannel`, **and** invokes the listener in the current tab directly (a `BroadcastChannel` never delivers to its own sender), so all tabs update in the same call. -4. `UserFeatureFlagProvider`'s channel listener calls `setFlags` with the pushed value — no extra fetch needed, in this tab or any other. -5. The whole call is wrapped in try/catch in the saga — a failure (network error, channel not yet registered) is swallowed and never blocks `loginSuccess`. - -### On session renewal (~hourly cadence) - -`AuthSessionProvider` calls `setUserCookieSession()` on a 5-minute interval (and on every `onIdTokenChanged` event). `setUserCookieSession()` performs a single `localStorage` read to determine the session state: - -- **`'fresh'`** — same uid, cookie not yet stale → no-op, returns `false`. -- **`'renewal'`** — same uid, cookie stale → POSTs `/api/session` to renew, returns `true`. -- **`'new'`** — no prior record or different uid (fresh login / identity change) → POSTs `/api/session`, returns `false`. The login saga already handled the flag fetch in this case. - -When `wasRenewal === true` and the user is not anonymous, `AuthSessionProvider` calls `refreshUserFeatureFlags()`, which: -1. Calls `retrieveUserInformation()` (`GET /v1/user`) to get the latest `features[]`. -2. Calls `applyUserFeatureFlags(features)` — same POST + broadcast path as login. - -This keeps feature flags current for long-lived sessions without requiring re-login. A failure is silently swallowed — flag staleness is preferable to disrupting the session renewal. +Revalidation, once the initial value is in place: -### On logout +- **Window focus** — `revalidateOnFocus: true`, throttled to once per 60s (`focusThrottleInterval`). +- **Session renewal** — `AuthSessionProvider` calls `revalidateUserFeatureFlags(uid)` whenever it renews the + `md_session` cookie (~hourly). See [AuthSessionProvider.tsx](../src/app/components/AuthSessionProvider.tsx) + and the discussion of why this piggybacks on session renewal rather than polling on its own schedule. +- **Identity change** — not a revalidation at all. The cache key is `[KEY, uid]`, so signing in as a different + user lands on a different key and resolves independently; there is nothing to invalidate. -`logoutSaga` calls `clearUserCookieSession()` which hits `DELETE /api/session`. That route clears both `md_session` and `md_features` in a single response. The saga also directly broadcasts `defaultUserFeatureFlags` on `FEATURE_FLAGS_CHANNEL` so every open tab resets immediately. `UserFeatureFlagProvider` additionally resets to defaults on its own whenever `isAuthenticated` transitions to `false`, as a second line of defense. - -### On page load (SSR) - -`layout.tsx` calls `getServerFlags()` in its `Promise.all` alongside `getRemoteConfigValues()`. The result is passed as `initialFlags` to ``, which forwards it to ``. The provider initialises its React state with these values, so the **first render is always flash-free** — no loading state, no client-side fetch on mount. +On logout, `isAuthenticated` becomes `false`, the cache key becomes `null` (no fetch), and the hook falls +back to `defaultUserFeatureFlags` with `isResolved: true` immediately — signed-out is a resolved answer, not +a pending one. --- @@ -107,97 +87,85 @@ export const defaultUserFeatureFlags: UserFeatureFlags = { ``` - `UserFeatureFlagId` (`keyof UserFeatureFlags`) and `useUserFeatureFlags()` pick up the new flag automatically. -- `toUserFeatureFlags()`, also in `UserFeatureFlags.ts`, already handles unknown keys gracefully — if the API returns the new flag it is merged; if not, the default is used. -- All flags are typed as `boolean` today. `toUserFeatureFlags()` does not check the API's `value_type` before assigning `flag.value` — if a future flag ever carries a non-boolean value (the schema also allows `string` / `numeric` / `array` / `json`), add a `value_type === 'boolean'` guard before widening this pattern. +- `toUserFeatureFlags()`, also in `UserFeatureFlags.ts`, already handles unknown keys gracefully — if the API + returns the new flag it is merged; if not, the default is used. +- All flags are typed as `boolean` today. `toUserFeatureFlags()` does not check the API's `value_type` before + assigning `flag.value` — if a future flag ever carries a non-boolean value (the schema also allows `string` + / `numeric` / `array` / `json`), add a `value_type === 'boolean'` guard before widening this pattern. + +Note: `isNotificationsEnabled` also exists in the *other* flag system (Remote Config, +`src/app/interface/RemoteConfig.ts`). That's real duplication, not a doc error — the two systems answer +different questions ("is the feature live at all" vs. "is this specific user entitled to it") and the live +UI consumer (`ClientSubscribeControls.tsx`) reads from both. --- -## Usage — client side +## Usage — client side only ```tsx 'use client'; -import { useUserFeatureFlags } from '../context/UserFeatureFlagProvider'; +import { useUserFeatureFlags } from '../hooks/useUserFeatureFlags'; export function MyComponent() { - const { isNotificationsEnabled } = useUserFeatureFlags(); + const { flags, isResolved } = useUserFeatureFlags(); - if (!isNotificationsEnabled) return null; + // isResolved is false while entitlement is genuinely unknown — render a + // pending state, not the not-entitled one, until it's true. + if (!isResolved) return null; + if (!flags.isNotificationsEnabled) return null; return ; } ``` -The hook returns a `UserFeatureFlags` object — the same shape as `RemoteConfigValues`. No string ID lookups, no casts, full IDE autocomplete. +There is **no server-side equivalent**. `fetchUserFeatureFlags()` (`user-feature-flag-service.ts`) resolves +the caller's Firebase ID token client-side (`getUserAccessToken()` → `currentUser.getIdTokenResult()`), and +per this app's auth model the client's Firebase token is never forwarded to the server — a Server Component +has no way to make the equivalent call. If you need this data during SSR, it isn't available; design the UI +to tolerate the client-side resolution delay (see `isResolved` above) rather than looking for a seed. --- -## Usage — server side - -```ts -// Any Server Component or server utility -import { getServerFlags } from '../actions/feature-flags'; - -export default async function Page() { - const { isNotificationsEnabled } = await getServerFlags(); - // ... -} -``` - -`getServerFlags()` reads the `md_features` cookie, verifies the HMAC signature, and returns a `UserFeatureFlags` object with defaults applied for any missing flags. The `FeatureFlag[]` API array format is an internal detail — consumers always receive the typed map. +## Why there is no cookie, no context, and no server read + +This system used to work differently: a Server Action (`getServerFlags()`) read an HMAC-signed `md_features` +cookie for SSR, a `POST /api/feature-flags` route wrote it, and a `BroadcastChannel` pushed resolved flags to +every open tab. All of that was removed (see the `removed user feature flags from server` commit) in favor +of the client-only SWR design above. The reasons: + +**No cookie / no server read.** A per-user cookie read during render cannot work on statically rendered +routes — Next hands those an empty cookie store at build/ISR time, so every read looked like a logged-out +user regardless of actual entitlement. That was a real bug the old design had (`getServerFlags()` in +`layout.tsx` on a `force-static` route), and it's what `cypress/e2e/userFeatureFlags.cy.ts` now locks down +(*"resolves flags on a statically rendered route"*). A value computed once in the root layout would also go +stale on client-side navigation, since layouts aren't re-rendered on navigation. + +**No React Context.** SWR's own cache — keyed by `[USER_FEATURE_FLAGS_SWR_KEY, uid]` — is what makes this +"resolve once, shared by every caller," not a Provider. Multiple components calling `useUserFeatureFlags()` +dedupe onto one request the same way whether or not a Context wraps them; the only thing a Context would add +is a slightly more convenient seam for injecting a fixed value in component tests. Given there's a genuine +need for the hook to resolve on *every* page (not just ones with a real consumer — see `UserFeatureFlagsSync` +above), a Context provider wrapping `children` wasn't buying anything a plain hook plus one globally-mounted +null-rendering sync component didn't already provide more simply. + +**No `BroadcastChannel` / no client-writable cookie signing.** The old `POST /api/feature-flags` route had a +known gap: it signed whatever `FeatureFlag[]` array the client sent it, without verifying the caller. That's +now moot — there is no such route. `fetchUserFeatureFlags()` calls `GET /v1/user` directly with the caller's +Firebase ID token, so the backend resolves flags for the authenticated identity itself; nothing client-side +can be forged into the cache except by seeding it with data the same request already legitimately fetched +(`setUserFeatureFlagsCache`, called only from the login/signup sagas with their own `GET /v1/user` result). + +**Cross-tab consistency** falls out of the uid-keyed cache rather than a broadcast push: each tab has its own +Firebase auth listener, so a login/logout in one tab changes that tab's own `uid`/cache key independently. No +explicit tab-to-tab coordination is needed for flags specifically (contrast with `LOGIN_CHANNEL`/ +`LOGOUT_CHANNEL` in `channel-service.ts`, which exist for the broader auth session, not for flags). --- -### The `UserFeatureFlags` interface mirrors `RemoteConfigValues` - -Both use a plain interface with an explicit defaults object. The difference is the data source: - -| | `RemoteConfigValues` | `UserFeatureFlags` | -|---|---|---| -| Source | Firebase Remote Config (global) | User service API (per-user) | -| Definition | `export interface RemoteConfigValues` | `export interface UserFeatureFlags` | -| Defaults | `defaultRemoteConfigValues` | `defaultUserFeatureFlags` | -| Provider prop | `config: RemoteConfigValues` | `initialFlags: UserFeatureFlags` | -| Hook | `useRemoteConfig()` → `{ config }` | `useUserFeatureFlags()` → flags directly | - -The `FeatureFlag[]` array (raw API format) is purely internal. `applyUserFeatureFlags()` accepts it (the saga passes the `GET /v1/user` response directly) and `toUserFeatureFlags()` converts it to `UserFeatureFlags` both when reading the cookie server-side (`getServerFlags()`) and when preparing the payload for the client broadcast. Consumers never interact with the array format. - ---- - -## Why the cookie is written from an API route, read from a Server Action - -### The alternatives considered - -**Option A — Store in Redux** - -Redux state is managed by `redux-persist`, which serialises it to `localStorage`. This creates two problems: - -1. **Cross-session leakage**: User A's flags persist in `localStorage` after logout. When User B logs in on the same device, they briefly see User A's flags until the login saga overwrites them. -2. **PersistGate dependency**: Every component reading flags would need to be inside a `PersistGate` (or handle the rehydration window), spreading boilerplate. -3. **Source-of-truth drift**: Redux and a potential server-side store would need to stay in sync, creating a class of bug that's hard to reproduce. - -**Option B — Store only in React context (client-fetched)** - -A context provider could call `GET /v1/user` directly when auth resolves. This avoids Redux but: - -1. The login saga already calls `GET /v1/user` — a second call from the provider doubles the network requests. -2. The provider has no access to the result of the saga's fetch, so it can't reuse it. -3. Server Components still can't read React context — server-side access would require a separate mechanism anyway. - -### Why the cookie + broadcast approach wins - -The `httpOnly` cookie is the **single source of truth on the server**; the `BroadcastChannel` push keeps every open tab's React state in sync with it without ever reading it back from the client: - -| Concern | Cookie + broadcast | -|---|---| -| Cross-session leakage | None — cookie is cleared on logout and is not in `localStorage` | -| PersistGate | Not needed — provider holds ephemeral React state, not persisted state | -| Source-of-truth drift | None on the server — there is only one cookie. The client mirrors it via the broadcast payload rather than re-reading it | -| Server Components | `getServerFlags()` reads the cookie directly, no extra fetch | -| Double network calls | None — the saga's single `GET /v1/user` result is reused for both the cookie write and the client broadcast; the hourly renewal call is the only extra network touch | -| Flash on initial render | None — `layout.tsx` reads the cookie server-side and passes `initialFlags` | -| Multi-tab consistency | `FEATURE_FLAGS_CHANNEL` (`broadcastExtendedMessage`) pushes the resolved flags to every tab, including the sender, immediately — no reload required | - -### Known limitation: `POST /api/feature-flags` does not verify the caller - -Unlike `POST /api/session`, which verifies a Firebase ID token via `getAuth(app).verifyIdToken(idToken)` before issuing a cookie, `POST /api/feature-flags` accepts the `FeatureFlag[]` body as-is and signs whatever it's given. This is called out in a comment on the route itself. The accepted tradeoff is that today's flags (`isNotificationsEnabled`, `isSealOfReliabilityFilterEnabled`) are UI-only conveniences, so a client setting its own values client-side has no real security impact — actual access is enforced independently wherever it matters. +## Testing -This does **not** extend automatically to future flags. Before adding a flag that gates real access (a paywalled feature, an admin capability, etc.), this route needs the same idToken-verification treatment as `/api/session`: accept a Firebase ID token in the request, verify it server-side, and resolve the flags from the user service directly rather than trusting the client-supplied array. +`cypress/e2e/userFeatureFlags.cy.ts` covers: resolution on static and dynamic routes, defaults for +flags the API omits, that the SWR entry survives client-side navigation without refetching, and that logout +resets to defaults. It asserts against `window.__featureFlags` / `window.__featureFlagsResolved`, which +`useUserFeatureFlags()` exposes only when `window.Cypress` is set (mirrors the `window.store` pattern in +`store.ts` — test-only, no production impact). Those globals are populated by whichever mounted instance of +the hook runs first — in practice `UserFeatureFlagsSync`, since it's mounted on every page. From a69b66a6d42d7d367c30257d8c08c55cd4643c60 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 4 Aug 2026 13:56:11 -0400 Subject: [PATCH 11/13] lint fixes --- .../[locale]/account/notifications/AccountNotifications.tsx | 2 +- src/app/screens/Feed/components/NotificationSettingsDialog.tsx | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/[locale]/account/notifications/AccountNotifications.tsx b/src/app/[locale]/account/notifications/AccountNotifications.tsx index 6cc340a1..aa7d8e72 100644 --- a/src/app/[locale]/account/notifications/AccountNotifications.tsx +++ b/src/app/[locale]/account/notifications/AccountNotifications.tsx @@ -90,7 +90,7 @@ export default function AccountNotifications(): React.ReactElement { {index > 0 && ', '} diff --git a/src/app/screens/Feed/components/NotificationSettingsDialog.tsx b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx index 72949a9c..8f1bd012 100644 --- a/src/app/screens/Feed/components/NotificationSettingsDialog.tsx +++ b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx @@ -134,6 +134,9 @@ export default function NotificationSettingsDialog({ applySettingsChange({ addedTypes, removedTypes }) .then(() => { onSave({ changeTypes }); + }) + .catch(() => { + // Already captured in `saveError` and rendered above. }); }; From fa05a07a1bca281fa59f0851022fe7c560ea2156 Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 4 Aug 2026 13:57:16 -0400 Subject: [PATCH 12/13] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/app/screens/Feed/FeedView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/screens/Feed/FeedView.tsx b/src/app/screens/Feed/FeedView.tsx index 7432913a..e30d388f 100644 --- a/src/app/screens/Feed/FeedView.tsx +++ b/src/app/screens/Feed/FeedView.tsx @@ -361,7 +361,7 @@ export default async function FeedView({ /> )} {feed?.data_type === 'gbfs' && <>{gbfsOpenFeedUrlElement()}} - + {feed.id != null && } From 01e9a895298f8e7d32760f0eda5d4b52ac4d7aef Mon Sep 17 00:00:00 2001 From: Alessandro Kreslin Date: Tue, 4 Aug 2026 14:03:16 -0400 Subject: [PATCH 13/13] pr changes --- .../screens/Feed/components/NotificationSettingsDialog.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/screens/Feed/components/NotificationSettingsDialog.tsx b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx index 8f1bd012..68fe6a0b 100644 --- a/src/app/screens/Feed/components/NotificationSettingsDialog.tsx +++ b/src/app/screens/Feed/components/NotificationSettingsDialog.tsx @@ -102,10 +102,8 @@ export default function NotificationSettingsDialog({ }, [open, initialSettings, resetSaveError]); const handleChangeTypeToggle = (value: string): void => { - setChangeTypes( - changeTypes.includes(value) - ? changeTypes.filter((t) => t !== value) - : [...changeTypes, value], + setChangeTypes((prev) => + prev.includes(value) ? prev.filter((t) => t !== value) : [...prev, value], ); }; @@ -163,6 +161,7 @@ export default function NotificationSettingsDialog({ control={ { handleChangeTypeToggle(type.id); }}