Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/api/feature-flags/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -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<FeatureTogglesResult>(`${FEATURE_TOGGLES}/GetAll`, { signal });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled rejection: the awaited api.get call at src/api/feature-flags/feature-flags.ts:35 propagates network failures, 5xx responses, and aborts to callers with no context. Wrap the await in try/catch (or attach .catch), log the error, and rethrow or return a safe default.

Kody rule violation: Handle async operations with proper error handling

Prompt for LLM

File src/api/feature-flags/feature-flags.ts:

Line 29:

Unhandled rejection: the awaited api.get call at src/api/feature-flags/feature-flags.ts:35 propagates network failures, 5xx responses, and aborts to callers with no context. Wrap the await in try/catch (or attach .catch), log the error, and rethrow or return a safe default.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

Unhandled network exception: api.get at src/api/feature-flags/feature-flags.ts:35 throws transient and non-transient errors that propagate without enrichment or mapping to application-level errors. Wrap the call in try/catch, log with structured context including the flag operation name, and rethrow or return a typed error.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File src/api/feature-flags/feature-flags.ts:

Line 29:

Unhandled network exception: api.get at src/api/feature-flags/feature-flags.ts:35 throws transient and non-transient errors that propagate without enrichment or mapping to application-level errors. Wrap the call in try/catch, log with structured context including the flag operation name, and rethrow or return a typed error.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

return response.data;
};

/** Lightweight enabled-only check for a single flag. */
export const getFeatureFlagState = async (key: string, signal?: AbortSignal) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Missing Promise contract documentation: getFeatureFlagState at src/api/feature-flags/feature-flags.ts:28 omits @returns and rejection conditions, leaving callers without resolve/reject semantics for correct await usage. Add @returns {Promise<FeatureToggleResult|undefined>} and document rejection conditions (network errors, unknown key, aborts).

Kody rule violation: Document async/Promise behavior and errors

Prompt for LLM

File src/api/feature-flags/feature-flags.ts:

Line 34:

Missing Promise contract documentation: getFeatureFlagState at src/api/feature-flags/feature-flags.ts:28 omits @returns and rejection conditions, leaving callers without resolve/reject semantics for correct await usage. Add `@returns {Promise<FeatureToggleResult|undefined>}` and document rejection conditions (network errors, unknown key, aborts).

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

const response = await api.get<FeatureToggleResult>(`${FEATURE_TOGGLES}/GetState`, { params: { key }, signal });
return response.data;
};
25 changes: 17 additions & 8 deletions src/app/(app)/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { bluetoothAudioService } from '@/services/bluetooth-audio.service';
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 { useRolesStore } from '@/stores/roles/store';
import { securityStore } from '@/stores/security/store';
import { useSignalRStore } from '@/stores/signalr/signalr-store';
Expand Down Expand Up @@ -164,19 +165,27 @@ export default function TabLayout() {

// These fetches are independent of each other — run in parallel to cut
// time-to-interactive (previously 8+ serial network hops).
await Promise.all([useRolesStore.getState().init(), useCallsStore.getState().init(), useWeatherAlertsStore.getState().init(), securityStore.getState().getRights()]);
await Promise.all([useRolesStore.getState().init(), useCallsStore.getState().init(), useWeatherAlertsStore.getState().init(), securityStore.getState().getRights(), featureFlagsStore.getState().fetchFlags()]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Promise.all misuse for independent batch operations violates the team's partial-failure handling rule: if any single init() rejects (useRolesStore, useCallsStore, useWeatherAlertsStore, securityStore, or featureFlagsStore), the remaining initializations are aborted. Replace Promise.all with Promise.allSettled and handle per-item results.

Kody rule violation: Use Promise.allSettled for batch operations with partial failures

Prompt for LLM

File src/app/(app)/_layout.tsx:

Line 168:

Promise.all misuse for independent batch operations violates the team's partial-failure handling rule: if any single init() rejects (useRolesStore, useCallsStore, useWeatherAlertsStore, securityStore, or featureFlagsStore), the remaining initializations are aborted. Replace Promise.all with Promise.allSettled and handle per-item results.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


// SignalR needs config (EventingUrl) and rights (DepartmentId) — both
// available now. The two hub connects are independent.
await Promise.all([useSignalRStore.getState().connectUpdateHub(), useSignalRStore.getState().connectGeolocationHub()]);

// Connect the realtime chat hub (best-effort; chat may be disabled per department)
try {
await useSignalRStore.getState().connectChatHub();
} catch (error) {
logger.warn({
message: 'Failed to connect SignalR chat hub during initialization',
context: { error, platform: Platform.OS },
// 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)) {
// Best-effort: chat hub failure should not block app initialization.
try {
await useSignalRStore.getState().connectChatHub();
} catch (error) {
logger.warn({
message: 'Failed to connect SignalR chat hub during initialization',
context: { error, platform: Platform.OS },
});
}
} else {
logger.info({
message: 'Chat disabled by feature flag; skipping chat hub connection',
});
Comment on lines +168 to 189

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect every chat-hub connection call and lifecycle implementation.
rg -nP -C 6 '\bconnectChatHub\s*\(' src
rg -nP -C 8 '\buseSignalRLifecycle\s*\(' src

Repository: Resgrid/Unit

Length of output: 24296


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== use-signalr-lifecycle.ts relevant sections =="
sed -n '1,170p' src/hooks/use-signalr-lifecycle.ts
echo
echo "== signalr store files =="
fd -a 'signalr-store|signalr' src/stores src/components src/app src/hooks | sed 's#^\./##' | head -80
echo
echo "== chat disable/feature flag references =="
rg -n -C 4 'ChatSystem|connectChatHub|disconnectChatHub|isChatHubConnected|hubNames|ChatHub' src

Repository: Resgrid/Unit

Length of output: 19189


Prevent useSignalRLifecycle from reconnecting the chat hub when Chat.System is disabled.

useSignalRLifecycle runs connectChatHub() on resume and includes ChatHub in disconnects during background. That bypasses the Chat.System gate used in _layout.tsx; only add/remove the chat hub when the feature flag is true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(app)/_layout.tsx around lines 168 - 189, The useSignalRLifecycle
chat-hub lifecycle bypasses the Chat.System feature gate. Update
useSignalRLifecycle so resume connects ChatHub and background disconnects
include ChatHub only when featureFlagsStore reports FeatureFlagKeys.ChatSystem
enabled; preserve existing lifecycle handling for the other hubs.

}

Expand Down
24 changes: 22 additions & 2 deletions src/app/(app)/chat.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type Href, useFocusEffect, useRouter } from 'expo-router';
import { type Href, Redirect, 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';
Expand All @@ -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();
Expand Down Expand Up @@ -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);
Expand All @@ -89,9 +93,10 @@ export default function ChatScreen() {

useFocusEffect(
useCallback(() => {
if (!isChatEnabled) return;
useChatStore.getState().fetchChannels();
useChatStore.getState().fetchPendingAcks();
}, [])
}, [isChatEnabled])
);

const grouped = groupChannels(channels);
Expand All @@ -103,6 +108,21 @@ export default function ChatScreen() {
[router]
);

// Chat.System flag not yet resolved: wait instead of redirecting away from a valid route.
if (chatStatus === 'unknown') {
return (
<Box className="size-full flex-1 items-center justify-center bg-background-0">
<FocusAwareStatusBar />
<Spinner />
</Box>
);
}

// Chat.System feature flag off: no chat for this department.
if (chatStatus === 'disabled') {
return <Redirect href="/(app)" />;
}

return (
<Box className="size-full flex-1 bg-background-0">
<FocusAwareStatusBar />
Expand Down
24 changes: 22 additions & 2 deletions src/app/(app)/chatbot.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useFocusEffect } from 'expo-router';
import { Redirect, useFocusEffect } from 'expo-router';
import { RefreshCw, Send, Sparkles } from 'lucide-react-native';
import React, { useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -14,28 +14,33 @@ 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 currentUserId = useAuthStore((s) => s.userId);
const chatStatus = useChatSystemStatus();
const isChatEnabled = chatStatus === 'enabled';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules low

Magic string vulnerability across src/app/(app)/chatbot.tsx and src/app/(app)/chat.tsx compares the chat system status against the raw string literal 'enabled', risking silent failures from typos. Define an enum such as enum ChatSystemStatus { Enabled = 'enabled', Disabled = 'disabled', Unknown = 'unknown' } and compare against ChatSystemStatus.Enabled to centralize domain knowledge and enforce compile-time checks.

Kody rule violation: Use enums instead of magic strings

Prompt for LLM

File src/app/(app)/chatbot.tsx:

Line 29:

Magic string vulnerability across `src/app/(app)/chatbot.tsx` and `src/app/(app)/chat.tsx` compares the chat system status against the raw string literal 'enabled', risking silent failures from typos. Define an enum such as `enum ChatSystemStatus { Enabled = 'enabled', Disabled = 'disabled', Unknown = 'unknown' }` and compare against `ChatSystemStatus.Enabled` to centralize domain knowledge and enforce compile-time checks.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

const chatbotChannelId = useChatStore((s) => s.chatbotChannelId);
const chatbotTyping = useChatStore((s) => s.chatbotTyping);
const messages = useChatStore((s) => (chatbotChannelId ? s.messagesByChannel[chatbotChannelId] : undefined));
const [text, setText] = useState('');

useFocusEffect(
useCallback(() => {
if (!isChatEnabled) return;
const store = useChatStore.getState();
void store.initChatbot();
return () => {
useChatStore.getState().setActiveChannel(null);
};
}, [])
}, [isChatEnabled])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

// Keep the assistant channel active while viewing so incoming messages don't inflate unread.
Expand All @@ -61,6 +66,21 @@ export default function ChatbotScreen() {
[currentUserId]
);

// Chat.System flag not yet resolved: wait instead of redirecting away from a valid route.
if (chatStatus === 'unknown') {
return (
<Box className="size-full flex-1 items-center justify-center bg-background-0">
<FocusAwareStatusBar />
<Spinner />
</Box>
);
}

// Chat.System feature flag off: the assistant rides on the chat system, hide it too.
if (chatStatus === 'disabled') {
return <Redirect href="/(app)" />;
}

return (
<Box className="size-full flex-1 bg-background-0">
<FocusAwareStatusBar />
Expand Down
24 changes: 21 additions & 3 deletions src/app/chat/[channelId].tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -28,6 +28,7 @@ import { ChatChannelType, ChatMessagePriority, type ChatMessageResultData, ChatM
import { useCoreStore } from '@/stores/app/core-store';
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';

Expand All @@ -38,6 +39,8 @@ export default function ChannelConversationScreen() {
const channelId = Array.isArray(params.channelId) ? params.channelId[0] : params.channelId;

const currentUserId = useAuthStore((s) => s.userId);
const chatStatus = useChatSystemStatus();
const isChatEnabled = chatStatus === 'enabled';
const isModerator = !!securityStore((s) => s.rights)?.IsAdmin;
// Unit-app chat identity: messages post as the active unit ("Engine 6").
const activeUnit = useCoreStore((s) => s.activeUnit);
Expand Down Expand Up @@ -67,7 +70,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);
Expand All @@ -76,7 +79,7 @@ export default function ChannelConversationScreen() {
return () => {
useChatStore.getState().setActiveChannel(null);
};
}, [channelId])
}, [channelId, isChatEnabled])
);

// Fetch presence for the channel members (for the header online dot).
Expand Down Expand Up @@ -239,6 +242,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 (
<Box className="size-full flex-1 items-center justify-center bg-background-0">
<Stack.Screen options={{ title, headerShown: true, headerBackTitle: '' }} />
<Spinner />
</Box>
);
}

// Chat.System feature flag off: block deep links into conversations.
if (chatStatus === 'disabled') {
return <Redirect href="/(app)" />;
}

return (
<Box className="size-full flex-1 bg-background-0">
<Stack.Screen
Expand Down
25 changes: 22 additions & 3 deletions src/app/chat/thread/[messageId].tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Stack, useLocalSearchParams } from 'expo-router';
import { Redirect, Stack, useLocalSearchParams } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Platform } from 'react-native';
Expand All @@ -10,12 +10,14 @@ import { Box } from '@/components/ui/box';
import { Divider } from '@/components/ui/divider';
import { FlatList } from '@/components/ui/flat-list';
import { KeyboardAvoidingView } from '@/components/ui/keyboard-avoiding-view';
import { Spinner } from '@/components/ui/spinner';
import { Text } from '@/components/ui/text';
import { VStack } from '@/components/ui/vstack';
import { logger } from '@/lib/logging';
import { ChatMessagePriority, type ChatMessageResultData, ChatMessageType } 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 ThreadScreen() {
const { t } = useTranslation();
Expand All @@ -24,17 +26,19 @@ export default function ThreadScreen() {
const channelId = Array.isArray(params.channelId) ? params.channelId[0] : params.channelId;

const currentUserId = useAuthStore((s) => s.userId);
const chatStatus = useChatSystemStatus();
const isChatEnabled = chatStatus === 'enabled';
const channelMessages = useChatStore((s) => (channelId ? s.messagesByChannel[channelId] : undefined));
const [fetchedReplies, setFetchedReplies] = useState<ChatMessageResultData[]>([]);

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(() => {
Expand Down Expand Up @@ -94,6 +98,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 (
<Box className="size-full flex-1 items-center justify-center bg-background-0">
<Stack.Screen options={{ title: t('chat.thread'), headerShown: true, headerBackTitle: '' }} />
<Spinner />
</Box>
);
}

// Chat.System feature flag off: block deep links into threads.
if (chatStatus === 'disabled') {
return <Redirect href="/(app)" />;
}

return (
<Box className="size-full flex-1 bg-background-0">
<Stack.Screen options={{ title: t('chat.thread'), headerShown: true, headerBackTitle: '' }} />
Expand Down
26 changes: 15 additions & 11 deletions src/components/sidebar/sidebar-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { HStack } from '@/components/ui/hstack';
import { VStack } from '@/components/ui/vstack';
import { invertColor } from '@/lib/utils';
import { useCoreStore } from '@/stores/app/core-store';
import { useIsChatEnabled } from '@/stores/feature-flags/store';
import { useStatusBottomSheetStore } from '@/stores/status/store';

import ZeroState from '../common/zero-state';
Expand All @@ -25,6 +26,7 @@ interface SidebarProps {
const Sidebar = ({ onClose }: SidebarProps) => {
const activeStatuses = useCoreStore((state) => state.activeStatuses);
const setIsOpen = useStatusBottomSheetStore((state) => state.setIsOpen);
const isChatEnabled = useIsChatEnabled();
const { t } = useTranslation();
const router = useRouter();

Expand Down Expand Up @@ -63,17 +65,19 @@ const Sidebar = ({ onClose }: SidebarProps) => {
{/* Check-in timer widget */}
<CheckInSidebarWidget />

{/* Chat + Assistant navigation */}
<HStack space="md">
<Button variant="outline" action="secondary" size="md" className="flex-1" onPress={handleNavigateToChat}>
<MessagesSquare size={18} color="#2563eb" />
<ButtonText className="ml-2">{t('tabs.chat')}</ButtonText>
</Button>
<Button variant="outline" action="secondary" size="md" className="flex-1" onPress={handleNavigateToAssistant}>
<Sparkles size={18} color="#7c3aed" />
<ButtonText className="ml-2">{t('tabs.assistant')}</ButtonText>
</Button>
</HStack>
{/* Chat + Assistant navigation (hidden when the Chat.System feature flag is off) */}
{isChatEnabled && (
<HStack space="md">
<Button variant="outline" action="secondary" size="md" className="flex-1" onPress={handleNavigateToChat}>
<MessagesSquare size={18} color="#2563eb" />
<ButtonText className="ml-2">{t('tabs.chat')}</ButtonText>
</Button>
<Button variant="outline" action="secondary" size="md" className="flex-1" onPress={handleNavigateToAssistant}>
<Sparkles size={18} color="#7c3aed" />
<ButtonText className="ml-2">{t('tabs.assistant')}</ButtonText>
</Button>
</HStack>
)}

{/* Third row - Status buttons or empty state */}
{isActiveStatusesEmpty ? (
Expand Down
Loading
Loading