diff --git a/docs/audio-stream-refactoring.md b/docs/audio-stream-refactoring.md index feab356..45d1aa9 100644 --- a/docs/audio-stream-refactoring.md +++ b/docs/audio-stream-refactoring.md @@ -1,5 +1,19 @@ # Audio Stream Store Refactoring +## Expo SDK 56 migration requirement + +Before upgrading Dispatch to Expo SDK 56, upgrade `expo-audio` to the SDK 56-compatible version and replace all remaining `expo-av` audio usage with `expo-audio`. SDK 56 no longer provides the legacy Expo Modules Core header required by `expo-av` 16, so leaving `expo-av` installed can break the iOS archive build. + +Migration checklist: + +- Migrate `src/hooks/use-ptt.ts`, `src/components/calls/call-audio-modal.tsx`, `src/stores/app/audio-stream-store.ts`, and `src/services/audio.service.ts` to `createAudioPlayer`, `setAudioModeAsync`, `AudioPlayer`, and `playbackStatusUpdate`. +- Remove `expo-av` from `package.json`, the lockfile, tests/mocks, and the Expo Doctor exclusion after no imports remain. +- Keep this as an audio-only migration. Dispatch does not currently use the `expo-av` video component, so `expo-video` is not required for this change. +- Re-test remote MP3 streams on physical iOS and Android devices. This store originally moved to `expo-av` because remote streams had problems with the earlier `expo-audio` implementation. +- Also test PTT, call audio, background playback, interruptions, and Bluetooth/headset routing before release. + +Do not copy an SDK 56 implementation back into the current SDK 54 app unchanged. Dispatch's current `expo-audio` 1.1 API does not expose SDK 56 options such as `preferredForwardBufferDuration` or playback `status.error`. + ## Overview The audio stream store has been refactored to use `expo-av` instead of `expo-audio` to resolve issues with playing remote MP3 streams over the internet in the new Expo architecture. diff --git a/src/api/feature-flags/feature-flags.ts b/src/api/feature-flags/feature-flags.ts new file mode 100644 index 0000000..9840f04 --- /dev/null +++ b/src/api/feature-flags/feature-flags.ts @@ -0,0 +1,37 @@ +import { api } from '../common/client'; + +const FEATURE_TOGGLES = '/FeatureToggles'; + +// --------------------------------------------------------------------------- +// Feature toggle evaluation (department-scoped, any authenticated user). +// Backed by the v4 FeatureToggles API; keys live in Resgrid.Model.FeatureFlagKeys. +// --------------------------------------------------------------------------- + +export interface FeatureToggleData { + Key: string; + Enabled: boolean; + Value?: string | null; + ValueType?: string | null; + Source?: string | null; +} + +export interface FeatureTogglesResult { + Data?: FeatureToggleData[]; + StateHash?: string; +} + +export interface FeatureToggleResult { + Data?: FeatureToggleData; +} + +/** Evaluates every active flag for the caller's department. */ +export const getAllFeatureFlags = async (signal?: AbortSignal) => { + const response = await api.get(`${FEATURE_TOGGLES}/GetAll`, { signal }); + return response.data; +}; + +/** Lightweight enabled-only check for a single flag. */ +export const getFeatureFlagState = async (key: string, signal?: AbortSignal) => { + const response = await api.get(`${FEATURE_TOGGLES}/GetState`, { params: { key }, signal }); + return response.data; +}; diff --git a/src/app/(app)/_layout.tsx b/src/app/(app)/_layout.tsx index cf9e7cc..0fe9ce1 100644 --- a/src/app/(app)/_layout.tsx +++ b/src/app/(app)/_layout.tsx @@ -30,6 +30,7 @@ import { type GetConfigResultData } from '@/models/v4/configs/getConfigResultDat import { usePushNotifications } from '@/services/push-notification'; import { useCoreStore } from '@/stores/app/core-store'; import { useCallsStore } from '@/stores/calls/store'; +import { FeatureFlagKeys, featureFlagsStore } from '@/stores/feature-flags/store'; import useLockscreenStore from '@/stores/lockscreen/store'; import { useRolesStore } from '@/stores/roles/store'; import { securityStore } from '@/stores/security/store'; @@ -150,7 +151,14 @@ export default function TabLayout() { await securityStore.getState().getRights(); logger.info({ - message: 'Security rights retrieved, connecting SignalR', + message: 'Security rights retrieved, fetching feature flags', + context: { platform: Platform.OS }, + }); + + await featureFlagsStore.getState().fetchFlags(); + + logger.info({ + message: 'Feature flags fetched, connecting SignalR', context: { platform: Platform.OS }, }); @@ -169,18 +177,26 @@ export default function TabLayout() { // Don't fail initialization if SignalR connection fails } - // Connect the realtime chat hub (best-effort; chat may be disabled per department) - try { - await useSignalRStore.getState().connectChatHub(); + // Connect the realtime chat hub only when the Chat.System feature flag is on for + // this department; when it is off every chat surface stays hidden. + if (featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) { + try { + await useSignalRStore.getState().connectChatHub(); + logger.info({ + message: 'SignalR chat hub connected successfully', + context: { platform: Platform.OS }, + }); + } catch (error) { + logger.error({ + message: 'Failed to connect SignalR chat hub during initialization', + context: { error, platform: Platform.OS }, + }); + } + } else { logger.info({ - message: 'SignalR chat hub connected successfully', + message: 'Chat disabled by feature flag; skipping chat hub connection', context: { platform: Platform.OS }, }); - } catch (error) { - logger.error({ - message: 'Failed to connect SignalR chat hub during initialization', - context: { error, platform: Platform.OS }, - }); } // Initialize weather alerts diff --git a/src/app/(app)/chat.tsx b/src/app/(app)/chat.tsx index 0b9cbd0..b5e9e84 100644 --- a/src/app/(app)/chat.tsx +++ b/src/app/(app)/chat.tsx @@ -1,4 +1,4 @@ -import { type Href, Stack, useFocusEffect, useRouter } from 'expo-router'; +import { type Href, Redirect, Stack, useFocusEffect, useRouter } from 'expo-router'; import { Bot, MessageCircle, MessagesSquare, Network, Plus, Sparkles, Users } from 'lucide-react-native'; import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -15,10 +15,12 @@ import { Fab, FabIcon } from '@/components/ui/fab'; import { FocusAwareStatusBar } from '@/components/ui/focus-aware-status-bar'; import { HStack } from '@/components/ui/hstack'; import { Pressable } from '@/components/ui/pressable'; +import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { type ChatChannelResultData, ChatChannelType } from '@/models/v4/chat'; import { useChatStore } from '@/stores/chat/store'; +import { useChatSystemStatus } from '@/stores/feature-flags/store'; function ChannelRow({ channel, onPress }: { channel: ChatChannelResultData; onPress: () => void }) { const { t } = useTranslation(); @@ -81,6 +83,8 @@ function Section({ title, channels, onOpen }: { title: string; channels: ChatCha export default function ChatScreen() { const { t } = useTranslation(); const router = useRouter(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; const channels = useChatStore((s) => s.channels); const isLoading = useChatStore((s) => s.isLoadingChannels); const pendingAcks = useChatStore((s) => s.pendingAcks); @@ -89,9 +93,10 @@ export default function ChatScreen() { useFocusEffect( useCallback(() => { + if (!isChatEnabled) return; useChatStore.getState().fetchChannels(); useChatStore.getState().fetchPendingAcks(); - }, []) + }, [isChatEnabled]) ); const grouped = groupChannels(channels); @@ -103,6 +108,22 @@ export default function ChatScreen() { [router] ); + // Chat.System flag not yet resolved: wait instead of redirecting away from a valid route. + if (chatStatus === 'unknown') { + return ( + + + + + + ); + } + + // Chat.System feature flag off: no chat for this department. + if (chatStatus === 'disabled') { + return ; + } + return ( diff --git a/src/app/(app)/chatbot.tsx b/src/app/(app)/chatbot.tsx index 05a7c81..49fbf9b 100644 --- a/src/app/(app)/chatbot.tsx +++ b/src/app/(app)/chatbot.tsx @@ -1,4 +1,4 @@ -import { Stack, useFocusEffect } from 'expo-router'; +import { type Href, Redirect, Stack, useFocusEffect } from 'expo-router'; import { RefreshCw, Send, Sparkles } from 'lucide-react-native'; import React, { useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -14,14 +14,18 @@ import { HStack } from '@/components/ui/hstack'; import { Input, InputField } from '@/components/ui/input'; import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view'; import { Pressable } from '@/components/ui/pressable'; +import { Spinner } from '@/components/ui/spinner'; import { Text } from '@/components/ui/text'; import { VStack } from '@/components/ui/vstack'; import { type ChatMessageResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; +import { useChatSystemStatus } from '@/stores/feature-flags/store'; export default function ChatbotScreen() { const { t } = useTranslation(); + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; const currentUserId = useAuthStore((s) => s.userId); const chatbotChannelId = useChatStore((s) => s.chatbotChannelId); const chatbotTyping = useChatStore((s) => s.chatbotTyping); @@ -30,19 +34,21 @@ export default function ChatbotScreen() { useFocusEffect( useCallback(() => { + if (!isChatEnabled) return; const store = useChatStore.getState(); void store.initChatbot(); return () => { useChatStore.getState().setActiveChannel(null); }; - }, []) + }, [isChatEnabled]) ); // Keep the assistant channel active while viewing so incoming messages don't inflate unread. useFocusEffect( useCallback(() => { + if (!isChatEnabled) return; if (chatbotChannelId) useChatStore.getState().setActiveChannel(chatbotChannelId); - }, [chatbotChannelId]) + }, [chatbotChannelId, isChatEnabled]) ); const inverted = useMemo(() => (messages ? messages.slice().reverse() : []), [messages]); @@ -61,6 +67,22 @@ export default function ChatbotScreen() { [currentUserId] ); + // Chat.System flag not yet resolved: wait instead of redirecting away from a valid route. + if (chatStatus === 'unknown') { + return ( + + + + + + ); + } + + // Chat.System feature flag off: the assistant rides on the chat system, hide it too. + if (chatStatus === 'disabled') { + return ; + } + return ( diff --git a/src/app/chat/[channelId].tsx b/src/app/chat/[channelId].tsx index 3b62f17..9376780 100644 --- a/src/app/chat/[channelId].tsx +++ b/src/app/chat/[channelId].tsx @@ -1,5 +1,5 @@ import { Image } from 'expo-image'; -import { type Href, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; +import { type Href, Redirect, Stack, useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'; import { Circle } from 'lucide-react-native'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -27,6 +27,7 @@ import { VStack } from '@/components/ui/vstack'; import { ChatChannelType, ChatMessagePriority, type ChatMessageResultData, ChatMessageType, type GifResultData } from '@/models/v4/chat'; import useAuthStore from '@/stores/auth/store'; import { useChatStore } from '@/stores/chat/store'; +import { useChatSystemStatus } from '@/stores/feature-flags/store'; import { securityStore } from '@/stores/security/store'; import { useToastStore } from '@/stores/toast/store'; @@ -38,6 +39,8 @@ export default function ChannelConversationScreen() { const currentUserId = useAuthStore((s) => s.userId); const isModerator = !!securityStore((s) => s.rights)?.IsAdmin; + const chatStatus = useChatSystemStatus(); + const isChatEnabled = chatStatus === 'enabled'; const channel = useChatStore((s) => s.channels.find((c) => c.ChatChannelId === channelId)); const messages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); @@ -64,7 +67,7 @@ export default function ChannelConversationScreen() { // Mount: activate channel, join hub, load history and members. useFocusEffect( useCallback(() => { - if (!channelId) return; + if (!channelId || !isChatEnabled) return; const store = useChatStore.getState(); store.setActiveChannel(channelId); void store.joinChannel(channelId); @@ -73,7 +76,7 @@ export default function ChannelConversationScreen() { return () => { useChatStore.getState().setActiveChannel(null); }; - }, [channelId]) + }, [channelId, isChatEnabled]) ); // Fetch presence for the channel members (for the header online dot). @@ -236,6 +239,21 @@ export default function ChannelConversationScreen() { const title = channel ? getChannelDisplayName(channel, t) : t('chat.title'); + // Chat.System flag not yet resolved: wait instead of redirecting away from a valid deep link. + if (chatStatus === 'unknown') { + return ( + + + + + ); + } + + // Chat.System feature flag off: block deep links (push notifications, stale routes). + if (chatStatus === 'disabled') { + return ; + } + return ( s.userId); const channelMessages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined)); const [fetchedReplies, setFetchedReplies] = useState([]); @@ -30,11 +34,11 @@ export default function ThreadScreen() { const root = useMemo(() => (channelMessages ?? []).find((m) => m.ChatMessageId === messageId), [channelMessages, messageId]); useEffect(() => { - if (!messageId) return; + if (!messageId || !isChatEnabled) return; getThread(messageId, undefined, 50) .then((response) => setFetchedReplies(response.Data ?? [])) .catch((error) => logger.error({ message: 'chat: failed to load thread', context: { error, messageId } })); - }, [messageId]); + }, [messageId, isChatEnabled]); // Merge fetched replies with any realtime/optimistic replies already in the channel cache. const replies = useMemo(() => { @@ -96,6 +100,21 @@ export default function ThreadScreen() { [currentUserId, channelId] ); + // Chat.System flag not yet resolved: wait instead of redirecting away from a valid deep link. + if (chatStatus === 'unknown') { + return ( + + + + + ); + } + + // Chat.System feature flag off: block deep links into threads. + if (chatStatus === 'disabled') { + return ; + } + return ( diff --git a/src/components/sidebar/side-menu.tsx b/src/components/sidebar/side-menu.tsx index b9f038d..54eb04b 100644 --- a/src/components/sidebar/side-menu.tsx +++ b/src/components/sidebar/side-menu.tsx @@ -23,6 +23,8 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import { useIsChatEnabled } from '@/stores/feature-flags/store'; + interface SideMenuProps { onNavigate?: () => void; colorScheme?: 'light' | 'dark'; @@ -93,7 +95,9 @@ function SideMenu({ onNavigate, colorScheme: propColorScheme }: SideMenuProps): const router = useRouter(); const { t } = useTranslation(); const [expandedItems, setExpandedItems] = useState>(new Set()); - const menuItems = getMenuItems(t); + const isChatEnabled = useIsChatEnabled(); + // Chat and the assistant are gated by the Chat.System feature flag. + const menuItems = getMenuItems(t).filter((item) => (item.id === 'chat' || item.id === 'assistant' ? isChatEnabled : true)); // Use prop if provided, otherwise default to light on web const isDark = propColorScheme === 'dark'; diff --git a/src/stores/feature-flags/__tests__/store.test.ts b/src/stores/feature-flags/__tests__/store.test.ts new file mode 100644 index 0000000..c673876 --- /dev/null +++ b/src/stores/feature-flags/__tests__/store.test.ts @@ -0,0 +1,225 @@ +import { renderHook } from '@testing-library/react-native'; + +import { FeatureFlagKeys, featureFlagsStore, useChatSystemStatus } from '../store'; + +// Mock the API +jest.mock('@/api/feature-flags/feature-flags', () => ({ + getAllFeatureFlags: jest.fn(), +})); + +// Mock logging +jest.mock('@/lib/logging', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }, +})); + +// Mock the storage +jest.mock('@/lib/storage', () => ({ + zustandStorage: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, +})); + +// Mock identity sources +jest.mock('../../auth/store', () => ({ + __esModule: true, + default: { + getState: jest.fn(), + }, +})); + +jest.mock('../../security/store', () => ({ + securityStore: { + getState: jest.fn(), + }, +})); + +const { getAllFeatureFlags } = require('@/api/feature-flags/feature-flags'); +const useAuthStore = require('../../auth/store').default; +const { securityStore } = require('../../security/store'); + +const setIdentity = (userId: string | null, departmentId: string | null) => { + useAuthStore.getState.mockReturnValue({ userId }); + securityStore.getState.mockReturnValue({ + rights: departmentId ? { DepartmentId: departmentId } : null, + }); +}; + +describe('Feature Flags Store', () => { + beforeEach(() => { + jest.clearAllMocks(); + featureFlagsStore.setState({ + flags: {}, + isLoaded: false, + error: null, + identityKey: null, + }); + setIdentity('user-1', 'dept-1'); + }); + + describe('fetchFlags', () => { + it('should store flags and stamp the current identity on success', async () => { + getAllFeatureFlags.mockResolvedValue({ + Data: [{ Key: FeatureFlagKeys.ChatSystem, Enabled: true, Value: null }], + }); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags[FeatureFlagKeys.ChatSystem]).toEqual({ enabled: true, value: null }); + expect(state.isLoaded).toBe(true); + expect(state.error).toBeNull(); + expect(state.identityKey).toBe('user-1:dept-1'); + }); + + it('should keep persisted flags on failure for the same identity', async () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-1', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags[FeatureFlagKeys.ChatSystem]?.enabled).toBe(true); + expect(state.identityKey).toBe('user-1:dept-1'); + expect(state.error).toBe('network down'); + }); + + it('should clear flags from a different department before fetching so a failed fetch cannot reuse them', async () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-old', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags).toEqual({}); + // Fail-closed: the failed fetch still resolves the flags so consumers stop waiting. + expect(state.isLoaded).toBe(true); + expect(state.identityKey).toBeNull(); + }); + + it('should clear flags from a different account before fetching', async () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-other:dept-1', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + expect(featureFlagsStore.getState().flags).toEqual({}); + }); + + it('should replace another identity flags with fresh ones on success', async () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-other:dept-other', + }); + getAllFeatureFlags.mockResolvedValue({ + Data: [{ Key: FeatureFlagKeys.ChatSystem, Enabled: false, Value: null }], + }); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags[FeatureFlagKeys.ChatSystem]?.enabled).toBe(false); + expect(state.identityKey).toBe('user-1:dept-1'); + }); + + it('should keep flags on failure when department is unknown but the user matches', async () => { + setIdentity('user-1', null); + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-1', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + const state = featureFlagsStore.getState(); + expect(state.flags[FeatureFlagKeys.ChatSystem]?.enabled).toBe(true); + expect(state.identityKey).toBe('user-1:dept-1'); + }); + + it('should clear flags when department is unknown and the user differs', async () => { + setIdentity('user-2', null); + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + isLoaded: true, + identityKey: 'user-1:dept-1', + }); + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + expect(featureFlagsStore.getState().flags).toEqual({}); + }); + }); + + describe('useChatSystemStatus', () => { + it('should be unknown before the initial fetch resolves', () => { + const { result } = renderHook(() => useChatSystemStatus()); + + expect(result.current).toBe('unknown'); + }); + + it('should report enabled and disabled from the flag entry', () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + }); + const { result: enabled } = renderHook(() => useChatSystemStatus()); + expect(enabled.current).toBe('enabled'); + + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: false, value: null } }, + }); + const { result: disabled } = renderHook(() => useChatSystemStatus()); + expect(disabled.current).toBe('disabled'); + }); + + it('should resolve disabled when flags loaded without an entry', () => { + featureFlagsStore.setState({ flags: {}, isLoaded: true }); + + const { result } = renderHook(() => useChatSystemStatus()); + + expect(result.current).toBe('disabled'); + }); + + it('should resolve disabled (fail-closed) after a failed fetch with no persisted flags', async () => { + getAllFeatureFlags.mockRejectedValue(new Error('network down')); + + await featureFlagsStore.getState().fetchFlags(); + + const { result } = renderHook(() => useChatSystemStatus()); + expect(result.current).toBe('disabled'); + }); + }); + + describe('isEnabled', () => { + it('should return the flag state when present and the default when missing', () => { + featureFlagsStore.setState({ + flags: { [FeatureFlagKeys.ChatSystem]: { enabled: true, value: null } }, + }); + + expect(featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)).toBe(true); + expect(featureFlagsStore.getState().isEnabled('Unknown.Flag')).toBe(false); + expect(featureFlagsStore.getState().isEnabled('Unknown.Flag', true)).toBe(true); + }); + }); +}); diff --git a/src/stores/feature-flags/store.ts b/src/stores/feature-flags/store.ts new file mode 100644 index 0000000..f93b7a9 --- /dev/null +++ b/src/stores/feature-flags/store.ts @@ -0,0 +1,123 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import { getAllFeatureFlags } from '@/api/feature-flags/feature-flags'; +import { logger } from '@/lib/logging'; +import { zustandStorage } from '@/lib/storage'; + +import useAuthStore from '../auth/store'; +import { securityStore } from '../security/store'; + +// Well-known feature flag keys. Keep values in sync with Resgrid.Model.FeatureFlagKeys. +export const FeatureFlagKeys = { + ChatSystem: 'Chat.System', +} as const; + +export type FeatureFlagKey = (typeof FeatureFlagKeys)[keyof typeof FeatureFlagKeys]; + +interface FeatureFlagEntry { + enabled: boolean; + value?: string | null; +} + +// Immutable ids only (user id + department id) so renames/code changes never alias identities. +const getCurrentIdentityKey = (): string | null => { + const userId = useAuthStore.getState().userId; + const departmentId = securityStore.getState().rights?.DepartmentId; + if (!userId || !departmentId) { + return null; + } + return `${userId}:${departmentId}`; +}; + +// True only when the persisted flags provably belong to a different account/department. +// With no proof (e.g. rights unavailable offline) flags are kept so gating stays stable. +const isPersistedIdentityStale = (persistedKey: string | null): boolean => { + if (!persistedKey) { + return false; + } + const userId = useAuthStore.getState().userId; + const departmentId = securityStore.getState().rights?.DepartmentId; + if (userId && departmentId) { + return persistedKey !== `${userId}:${departmentId}`; + } + if (userId) { + return !persistedKey.startsWith(`${userId}:`); + } + return false; +}; + +export interface FeatureFlagsState { + flags: Record; + isLoaded: boolean; + error: string | null; + identityKey: string | null; + fetchFlags: () => Promise; + isEnabled: (key: string, defaultValue?: boolean) => boolean; +} + +export const featureFlagsStore = create()( + persist( + (set, get) => ({ + flags: {}, + isLoaded: false, + error: null, + identityKey: null, + fetchFlags: async () => { + const identityKey = getCurrentIdentityKey(); + if (isPersistedIdentityStale(get().identityKey)) { + // Persisted flags belong to another account/department; drop them before fetching + // so a failed fetch can never gate this identity with the previous one's flags. + set({ flags: {}, isLoaded: false, identityKey: null }); + } + try { + const response = await getAllFeatureFlags(); + const flags: Record = {}; + for (const flag of response?.Data ?? []) { + if (flag?.Key) { + flags[flag.Key] = { enabled: !!flag.Enabled, value: flag.Value ?? null }; + } + } + set({ flags, isLoaded: true, error: null, identityKey }); + } catch (error) { + // Keep persisted flags on failure so gating stays stable while offline; the mismatch + // check above already cleared them if they belonged to a different identity. Marking + // isLoaded resolves flags with no persisted entry fail-closed (disabled) instead of + // leaving consumers waiting on 'unknown' forever. + logger.error({ + message: 'Failed to fetch feature flags', + context: { error }, + }); + set({ error: error instanceof Error ? error.message : 'Failed to fetch feature flags', isLoaded: true }); + } + }, + isEnabled: (key: string, defaultValue = false) => get().flags[key]?.enabled ?? defaultValue, + }), + { + name: 'feature-flags-storage', + storage: createJSONStorage(() => zustandStorage), + } + ) +); + +// Reactive hook; components re-render when the flag changes. Unknown flags default to disabled +// so gated features stay hidden until the server confirms them. +export const useFeatureFlag = (key: string, defaultValue = false) => featureFlagsStore((state) => state.flags[key]?.enabled ?? defaultValue); + +export const useIsChatEnabled = () => useFeatureFlag(FeatureFlagKeys.ChatSystem); + +export type FeatureFlagStatus = 'unknown' | 'enabled' | 'disabled'; + +// Tri-state hook for gating that must not act before flags resolve (e.g. redirecting away +// from a deep link). 'unknown' until flags for this identity are fetched or rehydrated; +// fetch failures resolve fail-closed as 'disabled' for flags with no persisted entry. +export const useFeatureFlagStatus = (key: string): FeatureFlagStatus => + featureFlagsStore((state) => { + const entry = state.flags[key]; + if (entry) { + return entry.enabled ? 'enabled' : 'disabled'; + } + return state.isLoaded ? 'disabled' : 'unknown'; + }); + +export const useChatSystemStatus = (): FeatureFlagStatus => useFeatureFlagStatus(FeatureFlagKeys.ChatSystem); diff --git a/src/stores/signalr/signalr-store.ts b/src/stores/signalr/signalr-store.ts index 0cc6e96..e75530c 100644 --- a/src/stores/signalr/signalr-store.ts +++ b/src/stores/signalr/signalr-store.ts @@ -7,6 +7,7 @@ import { signalRService } from '@/services/signalr.service'; import { useCoreStore } from '../app/core-store'; import { useChatStore } from '../chat/store'; +import { FeatureFlagKeys, featureFlagsStore } from '../feature-flags/store'; import { securityStore, useSecurityStore } from '../security/store'; /** Client-event method names raised by the chat SignalR hub. */ @@ -621,6 +622,16 @@ export const useSignalRStore = create((set, get) => ({ }, connectChatHub: async () => { try { + // Guard here so every call path (init, app-resume reconnect) honors the flag. + if (!featureFlagsStore.getState().isEnabled(FeatureFlagKeys.ChatSystem)) { + logger.info({ + message: 'Chat disabled by feature flag; skipping chat hub connection', + }); + // Tear down any existing connection so a runtime flag flip-off disconnects the hub. + await get().disconnectChatHub(); + return; + } + if (get().isChatHubConnected) { return; }