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
20 changes: 13 additions & 7 deletions app/screens/AdvancedSearchScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,19 @@ export const AdvancedSearchScreen: React.FC = () => {
}, [subscriptions, runSearch]);

useEffect(() => {
const notifications = checkNotifications();
for (const note of notifications) {
Alert.alert(
'Saved search match',
`"${note.savedSearchName}" has ${note.newMatchCount} new match(es).`
);
}
let cancelled = false;
checkNotifications().then((notifications) => {
if (cancelled) return;
for (const note of notifications) {
Alert.alert(
'Saved search match',
`"${note.savedSearchName}" has ${note.newMatchCount} new match(es).`
);
}
});
return () => {
cancelled = true;
};
}, [subscriptions, checkNotifications]);

const toggleCategory = useCallback(
Expand Down
160 changes: 15 additions & 145 deletions app/stores/searchStore.ts
Original file line number Diff line number Diff line change
@@ -1,145 +1,15 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Subscription, SubscriptionCategory, BillingCycle } from '../../src/types/subscription';
import {
search_subscriptions,
save_search,
delete_saved_search,
check_saved_search_notifications,
load_saved_searches,
SavedSearch,
SearchQuery,
} from '../services/searchService';
import { SearchResult } from '../../backend/services/search/ElasticsearchService';

type SearchFilters = NonNullable<SearchQuery['filters']>;

type SearchState = {
queryText: string;
filters: SearchFilters;
sort: SearchQuery['sort'];
result: SearchResult | null;
savedSearches: SavedSearch[];
suggestions: string[];
loading: boolean;
setQueryText: (text: string) => void;
setFilters: (filters: Partial<SearchFilters>) => void;
setSort: (sort: SearchQuery['sort']) => void;
runSearch: () => void;
refreshSuggestions: (partial: string) => void;
saveCurrentSearch: (name: string, notifyOnNewMatches?: boolean) => Promise<void>;
loadSavedSearch: (id: string) => void;
removeSavedSearch: (id: string) => Promise<void>;
checkNotifications: () => ReturnType<typeof check_saved_search_notifications>;
hydrateSavedSearches: () => Promise<void>;
clear: () => void;
};

const defaultFilters = (): SearchFilters => ({
categories: [],
billingCycles: [],
plans: [],
statuses: [],
});

const buildQuery = (state: Pick<SearchState, 'queryText' | 'filters' | 'sort'>): SearchQuery => ({
query: state.queryText,
filters: state.filters,
sort: state.sort,
});

export const useSearchStore = create<SearchState>()(
persist(
(set, get) => ({
queryText: '',
filters: defaultFilters(),
sort: { field: '_score', order: 'desc' },
result: null,
savedSearches: [],
suggestions: [],
loading: false,

setQueryText: (text) => {
set({ queryText: text });
get().runSearch();
},

setFilters: (partial) => {
set((state) => ({ filters: { ...state.filters, ...partial } }));
get().runSearch();
},

setSort: (sort) => {
set({ sort });
get().runSearch();
},

runSearch: () => {
set({ loading: true });
const result = search_subscriptions(buildQuery(get()));
set({ result, loading: false });
},

refreshSuggestions: (partial) => {
const { get_search_suggestions } = require('../services/searchService');
set({ suggestions: get_search_suggestions(partial) });
},

saveCurrentSearch: async (name, notifyOnNewMatches = true) => {
const state = get();
const saved: SavedSearch = {
id: `ss_${Date.now()}`,
name,
query: buildQuery(state),
notifyOnNewMatches,
lastMatchCount: state.result?.total ?? 0,
createdAt: Date.now(),
};
await save_search(saved);
set((s) => ({ savedSearches: [...s.savedSearches, saved] }));
},

loadSavedSearch: (id) => {
const saved = get().savedSearches.find((s) => s.id === id);
if (!saved) return;
set({
queryText: saved.query.query ?? '',
filters: saved.query.filters ?? defaultFilters(),
sort: saved.query.sort ?? { field: '_score', order: 'desc' },
});
get().runSearch();
},

removeSavedSearch: async (id) => {
await delete_saved_search(id);
set((s) => ({ savedSearches: s.savedSearches.filter((item) => item.id !== id) }));
},

checkNotifications: () => check_saved_search_notifications(),

hydrateSavedSearches: async () => {
const saved = await load_saved_searches();
set({ savedSearches: saved });
},

clear: () => {
set({
queryText: '',
filters: defaultFilters(),
sort: { field: '_score', order: 'desc' },
result: null,
});
},
}),
{
name: 'subtrackr-search-store',
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
savedSearches: state.savedSearches,
}),
}
)
);

export type { Subscription, SubscriptionCategory, BillingCycle };
/**
* searchStore.ts — Legacy adapter for the search slice.
*
* Search state and actions now live in the slices-pattern root store
* (src/store/slices/searchSlice.ts, Issue #944) and are exposed through
* the combined `useAppStore`. This adapter keeps existing consumers
* (`useSearchStore`) working without any changes.
*/
export { useAppStore as useSearchStore } from '../../src/store/slices';

export type {
Subscription,
SubscriptionCategory,
BillingCycle,
} from '../../src/types/subscription';
74 changes: 74 additions & 0 deletions backend/elasticsearch/__tests__/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
loadElasticsearchConfig,
loadElasticsearchNodes,
DEFAULT_ES_CONFIG,
ElasticsearchNode,
} from '../config';

describe('elasticsearch config loaders', () => {
it('loads primary and replica nodes from env', () => {
const nodes = loadElasticsearchNodes({
ES_PRIMARY_URL: 'https://es-primary:9200',
ES_READ_REPLICA_URLS: 'https://es-r1:9200,https://es-r2:9200',
} as NodeJS.ProcessEnv);

expect(nodes).toEqual([
{ name: 'es-primary', url: 'https://es-primary:9200', role: 'primary' },
{ name: 'es-replica-1', url: 'https://es-r1:9200', role: 'replica' },
{ name: 'es-replica-2', url: 'https://es-r2:9200', role: 'replica' },
]);
});

it('returns no nodes when remote config is absent (in-process mode)', () => {
const nodes = loadElasticsearchNodes({} as NodeJS.ProcessEnv);
expect(nodes).toEqual([]);
});

it('skips blank replica entries', () => {
const nodes = loadElasticsearchNodes({
ES_READ_REPLICA_URLS: 'https://es-r1:9200, ,,https://es-r2:9200',
} as NodeJS.ProcessEnv);

expect(nodes.filter((n) => n.role === 'replica')).toHaveLength(2);
});

it('builds a config merged over the in-process defaults', () => {
const config = loadElasticsearchConfig({
ES_PRIMARY_URL: 'https://es-primary:9200',
ES_READ_REPLICA_URLS: 'https://es-r1:9200',
} as NodeJS.ProcessEnv);

expect(config.nodes).toHaveLength(2);
expect(config.readWriteSplitting).toBe(true);
expect(config.automaticFailover).toBe(true);
expect(config.maxSuggestions).toBe(DEFAULT_ES_CONFIG.maxSuggestions);
expect(config.indexName).toBe(DEFAULT_ES_CONFIG.indexName);
});

it('defaults to in-process routing when no nodes are configured', () => {
const config = loadElasticsearchConfig({} as NodeJS.ProcessEnv);
expect(config.nodes).toEqual([]);
expect(config.readWriteSplitting).toBe(true);
expect(config.automaticFailover).toBe(true);
});

it('honours routing toggles from env', () => {
const config = loadElasticsearchConfig({
ES_READ_WRITE_SPLITTING: 'false',
ES_AUTOMATIC_FAILOVER: 'false',
} as NodeJS.ProcessEnv);

expect(config.readWriteSplitting).toBe(false);
expect(config.automaticFailover).toBe(false);
});

it('includes a suggestions cap in the default config', () => {
expect(typeof DEFAULT_ES_CONFIG.maxSuggestions).toBe('number');
expect(DEFAULT_ES_CONFIG.maxSuggestions).toBeGreaterThan(0);
});

it('exports typed nodes with the expected shape', () => {
const node: ElasticsearchNode = { name: 'es-primary', url: 'http://localhost:9200', role: 'primary' };
expect(node.role).toBe('primary');
});
});
71 changes: 71 additions & 0 deletions backend/elasticsearch/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ export const DEFAULT_POOL_CONFIG: ElasticsearchPoolConfig = {
// Index / Search config
// ---------------------------------------------------------------------------

export type ElasticsearchNodeRole = 'primary' | 'replica';

export interface ElasticsearchNode {
name: string;
url: string;
role: ElasticsearchNodeRole;
}

export interface ElasticsearchConfig {
indexName: string;
fuzzyMaxEdits: number;
Expand All @@ -68,6 +76,14 @@ export interface ElasticsearchConfig {
analyzerLocales: string[];
/** Connection pool settings (Issue #986) */
pool?: ElasticsearchPoolConfig;
/** Remote cluster nodes for read/write routing (empty = in-process only). */
nodes?: ElasticsearchNode[];
/** Route reads to replicas when remote nodes are configured. Default: true */
readWriteSplitting?: boolean;
/** Fall back to the primary when all replicas are unhealthy. Default: true */
automaticFailover?: boolean;
/** Maximum autocomplete suggestions returned by the search façade. */
maxSuggestions?: number;
}

export const DEFAULT_ES_CONFIG: ElasticsearchConfig = {
Expand All @@ -88,6 +104,10 @@ export const DEFAULT_ES_CONFIG: ElasticsearchConfig = {
analyticsEnabled: true,
analyzerLocales: ['en', 'fr', 'de', 'es'],
pool: DEFAULT_POOL_CONFIG,
nodes: [],
readWriteSplitting: true,
automaticFailover: true,
maxSuggestions: 8,
};

export interface IndexMapping {
Expand Down Expand Up @@ -115,3 +135,54 @@ export const SUBSCRIPTION_INDEX_MAPPING: IndexMapping = {
createdAt: { type: 'date' },
},
};

// ---------------------------------------------------------------------------
// Remote node loading (Issue #945)
// ---------------------------------------------------------------------------

/**
* Load remote cluster nodes from the environment.
*
* - ES_PRIMARY_URL primary node URL (required for remote mode)
* - ES_READ_REPLICA_URLS comma-separated replica node URLs
*
* Returns an empty list when no remote nodes are configured so callers fall
* back to the in-process index (the default mobile-first deployment).
*/
export function loadElasticsearchNodes(env: NodeJS.ProcessEnv = process.env): ElasticsearchNode[] {
const nodes: ElasticsearchNode[] = [];

const primaryUrl = env.ES_PRIMARY_URL;
if (primaryUrl) {
nodes.push({ name: 'es-primary', url: primaryUrl, role: 'primary' });
}

const replicaUrls = env.ES_READ_REPLICA_URLS;
if (replicaUrls) {
const urls = replicaUrls
.split(',')
.map((url) => url.trim())
.filter(Boolean);
urls.forEach((url, index) => {
nodes.push({ name: `es-replica-${index + 1}`, url, role: 'replica' });
});
}

return nodes;
}

/**
* Build a complete `ElasticsearchConfig` from the environment, merging the
* in-process defaults with any remote nodes that are configured.
*
* Read/write splitting and automatic failover can be toggled via
* `ES_READ_WRITE_SPLITTING` and `ES_AUTOMATIC_FAILOVER`.
*/
export function loadElasticsearchConfig(env: NodeJS.ProcessEnv = process.env): ElasticsearchConfig {
return {
...DEFAULT_ES_CONFIG,
nodes: loadElasticsearchNodes(env),
readWriteSplitting: env.ES_READ_WRITE_SPLITTING !== 'false',
automaticFailover: env.ES_AUTOMATIC_FAILOVER !== 'false',
};
}
Loading
Loading