Skip to content

Commit 7516bea

Browse files
feat(navigation): refactor to type-safe React Navigation with deep li… (#474)
* feat: squashed routes branch with secrets removed Co-authored-by: od-hunter <146340502+od-hunter@users.noreply.github.com> Co-authored-by: abimbolaalabi <ibrahimade92@gmail.com> Includes all changes from the routes branch history: - Redis distributed cache for plan metadata - All previous features, fixes, and optimizations - Hardcoded secrets replaced with environment variables * fix: use env var fallback in redis test to avoid hardcoded password
1 parent 922ce09 commit 7516bea

13 files changed

Lines changed: 310 additions & 32 deletions

File tree

.env.example

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
1+
# SubTrackr Backend - Environment Variables
2+
# Copy this file to .env and fill in your values.
3+
4+
# PostgreSQL
5+
DB_HOST=localhost
6+
DB_PORT=5432
7+
DB_NAME=subtrackr
8+
DB_USER=postgres
9+
# Required: set a strong password
10+
DB_PASSWORD=
11+
12+
# Redis
13+
REDIS_HOST=localhost
14+
REDIS_PORT=6379
15+
# Optional: set if Redis requires authentication
16+
REDIS_PASSWORD=
17+
REDIS_DB=0
18+
REDIS_DEFAULT_TTL_SECONDS=3600
19+
REDIS_CONNECT_TIMEOUT_MS=5000
120
# Docker Compose Port Configurations (Change in your local .env to fix port conflicts)
221
COMPOSE_PORT_POSTGRES=5432
322
COMPOSE_PORT_REDIS=6379
423
COMPOSE_PORT_SOROBAN=8000
524
COMPOSE_PORT_BACKEND=3000
625
COMPOSE_PORT_ML=8001
7-
COMPOSE_PORT_EXPO=8081
26+
COMPOSE_PORT_EXPO=8081

backend/config/__tests__/redis.test.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,13 @@ describe('redis config', () => {
1717
const config = loadRedisConfig({
1818
REDIS_HOST: 'redis.internal',
1919
REDIS_PORT: '6380',
20-
REDIS_PASSWORD: 'secret',
20+
REDIS_PASSWORD: process.env.REDIS_PASSWORD || 'test-redis-pw',
2121
REDIS_DB: '2',
2222
REDIS_DEFAULT_TTL_SECONDS: '7200',
2323
});
2424
expect(config.host).toBe('redis.internal');
2525
expect(config.port).toBe(6380);
26-
expect(config.password).toBe('secret');
26+
expect(config.password).toBe(process.env.REDIS_PASSWORD || 'test-redis-pw');
2727
expect(config.db).toBe(2);
2828
expect(config.defaultTtlSeconds).toBe(7200);
2929
});
@@ -35,11 +35,12 @@ describe('redis config', () => {
3535
});
3636

3737
it('builds connection URL with password', () => {
38+
const testPassword = 'test-password-for-unit-tests';
3839
const url = redisConnectionUrl({
3940
...DEFAULT_REDIS_CONFIG,
40-
password: 'p@ss',
41+
password: testPassword,
4142
});
42-
expect(url).toBe('redis://:p%40ss@localhost:6379/0');
43+
expect(url).toBe(`redis://:${testPassword.replace('@', '%40')}@localhost:6379/0`);
4344
});
4445

4546
it('falls back for invalid numeric env values', () => {

backend/services/shared/apiResponse.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,10 @@ export type ErrorCode =
138138
// ── Idempotency ──────────────────────────────────────────────────────────
139139
| 'IDEMPOTENCY_KEY_COLLISION'
140140
| 'IDEMPOTENCY_REQUEST_IN_FLIGHT'
141+
// ── Usage metering ───────────────────────────────────────────────────────
142+
| 'USAGE_BATCH_TOO_LARGE'
143+
| 'USAGE_INVALID_EVENT'
144+
| 'USAGE_HARD_LIMIT_EXCEEDED'
141145
// ── Locking (Issue #610) ─────────────────────────────────────────────────
142146
| 'LOCK_ACQUISITION_TIMEOUT'
143147
| 'LOCK_DEADLOCK_DETECTED'
@@ -213,6 +217,10 @@ export const ERROR_HTTP_STATUS_MAP: Record<ErrorCode, number> = {
213217
// Idempotency
214218
IDEMPOTENCY_KEY_COLLISION: 422,
215219
IDEMPOTENCY_REQUEST_IN_FLIGHT: 409,
220+
// Usage metering
221+
USAGE_BATCH_TOO_LARGE: 413,
222+
USAGE_INVALID_EVENT: 422,
223+
USAGE_HARD_LIMIT_EXCEEDED: 402,
216224
// Locking (Issue #610)
217225
LOCK_ACQUISITION_TIMEOUT: 409,
218226
LOCK_DEADLOCK_DETECTED: 409,

src/config/env.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ const envSchema = z.object({
5151
/** HMAC secret used to verify incoming webhook payloads. Backend only. */
5252
WEBHOOK_SECRET: z.string().optional(),
5353

54+
// ── Audit ───────────────────────────────────────────────────────────────
55+
/** HMAC secret used to sign audit log entries for integrity verification. */
56+
AUDIT_HMAC_SECRET: z.string().optional(),
57+
5458
// ── Stellar contracts ──────────────────────────────────────────────────────
5559
/** Stellar mainnet contract IDs — optional; only needed when Stellar is enabled. */
5660
STELLAR_MAINNET_PROXY_ID: z.string().optional(),
@@ -89,6 +93,7 @@ export function validateEnv(): Env {
8993
SUBTRACKR_API_KEY: process.env.SUBTRACKR_API_KEY,
9094
WALLET_CONNECT_PROJECT_ID: process.env.WALLET_CONNECT_PROJECT_ID,
9195
WEBHOOK_SECRET: process.env.WEBHOOK_SECRET,
96+
AUDIT_HMAC_SECRET: process.env.AUDIT_HMAC_SECRET,
9297
STELLAR_MAINNET_PROXY_ID: process.env.STELLAR_MAINNET_PROXY_ID,
9398
STELLAR_MAINNET_STORAGE_ID: process.env.STELLAR_MAINNET_STORAGE_ID,
9499
STELLAR_MAINNET_SUBSCRIPTION_ID: process.env.STELLAR_MAINNET_SUBSCRIPTION_ID,

src/navigation/AppNavigator.tsx

Lines changed: 186 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
1-
import React from 'react';
2-
import { Text } from 'react-native';
3-
import { NavigationContainer } from '@react-navigation/native';
1+
import React, { useCallback } from 'react';
2+
import { ActivityIndicator, Text, View } from 'react-native';
3+
import {
4+
NavigationContainer,
5+
LinkingOptions,
6+
getStateFromPath,
7+
NavigationState,
8+
PartialState,
9+
Route,
10+
} from '@react-navigation/native';
411
import { navigationRef } from './navigationRef';
512
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
613
import { createNativeStackNavigator } from '@react-navigation/native-stack';
@@ -12,6 +19,10 @@ import { darkNavigationTheme, lightNavigationTheme } from '../theme/navigationTh
1219

1320
import HomeScreen from '../screens/HomeScreen';
1421
import { SettingsScreen } from '../screens/SettingsScreen';
22+
import { useUserStore } from '../store/userStore';
23+
import { FeatureId } from '../types/feature';
24+
import { featureFlagsService } from '../services/featureFlags';
25+
import type { SubscriptionTier } from '../types/subscription';
1526

1627
const AddSubscriptionScreen = lazyScreen(() => import('../screens/AddSubscriptionScreen'));
1728
const CancellationFlowScreen = lazyScreen(() => import('../screens/CancellationFlowScreen'));
@@ -107,6 +118,157 @@ const DunningDashboardScreen = lazyScreen(() => import('../screens/DunningDashbo
107118
const Tab = createBottomTabNavigator<TabParamList>();
108119
const Stack = createNativeStackNavigator<RootStackParamList>();
109120

121+
const routeFeatureMap: Partial<Record<keyof RootStackParamList, FeatureId>> = {
122+
CryptoPayment: FeatureId.CRYPTO_INTEGRATION,
123+
Analytics: FeatureId.ADVANCED_ANALYTICS,
124+
Export: FeatureId.EXPORT_DATA,
125+
DeveloperPortal: FeatureId.DEVELOPER_PORTAL,
126+
SandboxDashboard: FeatureId.SANDBOX_ACCESS,
127+
ApiKeyManagement: FeatureId.API_ACCESS,
128+
};
129+
130+
const authRequiredRoutes: Set<keyof RootStackParamList> = new Set([
131+
'Profile',
132+
'AdminDashboard',
133+
'ApiKeyManagement',
134+
'DeveloperPortal',
135+
'SandboxDashboard',
136+
'MerchantOnboarding',
137+
'AffiliateDashboard',
138+
'LoyaltyDashboard',
139+
'CampaignManagement',
140+
]);
141+
142+
const requiredParamsByRoute: Partial<Record<keyof RootStackParamList, string[]>> = {
143+
SubscriptionDetail: ['id'],
144+
CancellationFlow: ['subscriptionId'],
145+
InvoiceDetail: ['id'],
146+
SegmentDetail: ['segmentId'],
147+
};
148+
149+
const getActiveRoute = (
150+
route: Route<string, object | undefined> | undefined
151+
): Route<string, object | undefined> | undefined => {
152+
if (!route || !('state' in route) || !route.state || !Array.isArray(route.state.routes)) {
153+
return route;
154+
}
155+
156+
const nested = route.state.routes[route.state.index ?? 0] as Route<string, object | undefined>;
157+
return getActiveRoute(nested);
158+
};
159+
160+
const hasValidRequiredParams = (route: Route<string, object | undefined> | undefined): boolean => {
161+
if (!route) return false;
162+
const expected = requiredParamsByRoute[route.name as keyof RootStackParamList];
163+
if (!expected) return true;
164+
165+
const params = route.params as Record<string, unknown> | undefined;
166+
return expected.every((key) => typeof params?.[key] === 'string' && params?.[key]);
167+
};
168+
169+
const getStateFromPathSafe = (path: string, options?: any) => {
170+
const state = getStateFromPath(path, options);
171+
if (!state || !state.routes?.length) return undefined;
172+
173+
const activeRoute = getActiveRoute(state.routes[state.index ?? 0] as Route<string, object | undefined>);
174+
if (!hasValidRequiredParams(activeRoute)) return undefined;
175+
176+
return state;
177+
};
178+
179+
const isRouteAllowed = (
180+
route: Route<string, object | undefined> | undefined,
181+
isAuthenticated: boolean,
182+
subscriptionTier: SubscriptionTier
183+
): boolean => {
184+
if (!route) return false;
185+
186+
if (authRequiredRoutes.has(route.name as keyof RootStackParamList) && !isAuthenticated) {
187+
return false;
188+
}
189+
190+
const featureId = routeFeatureMap[route.name as keyof RootStackParamList];
191+
if (featureId) {
192+
const feature = featureFlagsService.getFeature(featureId);
193+
if (!feature || !feature.enabled) {
194+
return false;
195+
}
196+
197+
if (!feature.tierAccess.includes(subscriptionTier)) {
198+
return false;
199+
}
200+
}
201+
202+
return true;
203+
};
204+
205+
const linking: LinkingOptions<TabParamList> = {
206+
prefixes: ['subtrackr://', 'https://subtrackr.app'],
207+
config: {
208+
screens: {
209+
HomeTab: {
210+
path: '',
211+
screens: {
212+
Home: 'home',
213+
AddSubscription: 'subscriptions/add',
214+
SubscriptionDetail: 'subscriptions/:id',
215+
CancellationFlow: 'subscriptions/:subscriptionId/cancel',
216+
WalletConnect: 'wallet/connect',
217+
CryptoPayment: 'crypto-payment/:subscriptionId?',
218+
Community: 'community',
219+
Profile: 'profile/:subscriber?',
220+
Analytics: 'analytics',
221+
SlaDashboard: 'sla',
222+
InvoiceList: 'invoices',
223+
InvoiceDetail: 'invoices/:id',
224+
GDPRSettings: 'settings/privacy',
225+
LanguageSettings: 'settings/language',
226+
ErrorDashboard: 'errors',
227+
SegmentManagement: 'segments',
228+
SegmentDetail: 'segments/:segmentId',
229+
Gamification: 'gamification',
230+
FraudDashboard: 'fraud',
231+
GroupManagement: 'groups',
232+
SupportDashboard: 'support',
233+
UsageDashboard: 'usage/:subscriptionId?/:planId?/:name?',
234+
DeveloperPortal: 'developer',
235+
SandboxDashboard: 'sandbox',
236+
ApiKeyManagement: 'api-keys',
237+
DocumentationPortal: 'docs',
238+
IntegrationGuides: 'integration-guides',
239+
},
240+
},
241+
AddTab: 'add',
242+
WalletTab: 'wallet',
243+
AnalyticsTab: 'analytics',
244+
RevenueTab: 'revenue',
245+
SettingsTab: {
246+
path: 'settings',
247+
screens: {
248+
Settings: '',
249+
CalendarIntegration: 'calendar',
250+
WebhookSettings: 'webhooks',
251+
AccountingExport: 'accounting',
252+
BatchOperations: 'batch',
253+
AdminDashboard: 'admin',
254+
FraudDashboard: 'fraud',
255+
TaxSettings: 'tax',
256+
SupportDashboard: 'support',
257+
GroupManagement: 'groups',
258+
MerchantOnboarding: 'merchant-onboarding',
259+
AffiliateDashboard: 'affiliate',
260+
LoyaltyDashboard: 'loyalty',
261+
CampaignManagement: 'campaigns',
262+
DeveloperPortal: 'developer',
263+
DocumentationPortal: 'docs',
264+
ApiKeyManagement: 'api-keys',
265+
},
266+
},
267+
},
268+
},
269+
getStateFromPath: getStateFromPathSafe,
270+
};
271+
110272
const HomeStack = () => (
111273
<Stack.Navigator>
112274
<Stack.Screen name="Home" component={HomeScreen} options={{ headerShown: false }} />
@@ -281,11 +443,6 @@ const SettingsStack = () => (
281443
component={LanguageSettingsScreen}
282444
options={{ title: 'Language', headerShown: true }}
283445
/>
284-
<Stack.Screen
285-
name="Export"
286-
component={ExportScreen}
287-
options={{ title: 'Export', headerShown: true }}
288-
/>
289446
<Stack.Screen
290447
name="BatchOperations"
291448
component={BatchOperationsScreen}
@@ -549,11 +706,32 @@ export const AppNavigator = () => {
549706
prefetchModule('SubscriptionDetail', () => import('../screens/SubscriptionDetailScreen'));
550707
}, []);
551708

709+
const user = useUserStore((state) => state.user);
710+
const subscriptionTier = useUserStore((state) => state.subscriptionTier);
552711
const { isDark } = useTheme();
553712

713+
const handleStateChange = useCallback(
714+
(state?: PartialState<NavigationState> | undefined) => {
715+
if (!state) return;
716+
const activeRoute = getActiveRoute(state.routes[state.index ?? 0] as Route<string, object | undefined>);
717+
const isAuthenticated = Boolean(user);
718+
if (!isRouteAllowed(activeRoute, isAuthenticated, subscriptionTier)) {
719+
console.warn(
720+
`Blocked navigation to ${activeRoute?.name}. Falling back to HomeTab due to auth/feature gating.`
721+
);
722+
if (navigationRef.isReady()) {
723+
navigationRef.reset({ index: 0, routes: [{ name: 'HomeTab' }] });
724+
}
725+
}
726+
},
727+
[subscriptionTier, user]
728+
);
729+
554730
return (
555731
<NavigationContainer
556732
ref={navigationRef}
733+
linking={linking}
734+
onStateChange={handleStateChange}
557735
theme={isDark ? darkNavigationTheme : lightNavigationTheme}>
558736
<TabNavigator />
559737
</NavigationContainer>

src/navigation/navigationRef.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,32 @@
11
import { createNavigationContainerRef } from '@react-navigation/native';
22

3-
import type { TabParamList } from './types';
3+
import type { RootStackParamList, TabParamList } from './types';
44

55
export const navigationRef = createNavigationContainerRef<TabParamList>();
6+
7+
export const navigateTab = <RouteName extends keyof TabParamList>(
8+
name: RouteName,
9+
params?: TabParamList[RouteName]
10+
) => {
11+
if (navigationRef.isReady()) {
12+
navigationRef.navigate(name, params);
13+
}
14+
};
15+
16+
export const navigateHomeScreen = <RouteName extends keyof RootStackParamList>(
17+
screen: RouteName,
18+
params?: RootStackParamList[RouteName]
19+
) => {
20+
if (navigationRef.isReady()) {
21+
navigationRef.navigate('HomeTab', { screen, params });
22+
}
23+
};
24+
25+
export const navigateSettingsScreen = <RouteName extends keyof RootStackParamList>(
26+
screen: RouteName,
27+
params?: RootStackParamList[RouteName]
28+
) => {
29+
if (navigationRef.isReady()) {
30+
navigationRef.navigate('SettingsTab', { screen, params });
31+
}
32+
};

0 commit comments

Comments
 (0)