diff --git a/app/screens/AdvancedSearchScreen.tsx b/app/screens/AdvancedSearchScreen.tsx index 772d5715..bfdc65a5 100644 --- a/app/screens/AdvancedSearchScreen.tsx +++ b/app/screens/AdvancedSearchScreen.tsx @@ -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( diff --git a/app/stores/searchStore.ts b/app/stores/searchStore.ts index 5e220a7b..dbc52008 100644 --- a/app/stores/searchStore.ts +++ b/app/stores/searchStore.ts @@ -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; - -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) => void; - setSort: (sort: SearchQuery['sort']) => void; - runSearch: () => void; - refreshSuggestions: (partial: string) => void; - saveCurrentSearch: (name: string, notifyOnNewMatches?: boolean) => Promise; - loadSavedSearch: (id: string) => void; - removeSavedSearch: (id: string) => Promise; - checkNotifications: () => ReturnType; - hydrateSavedSearches: () => Promise; - clear: () => void; -}; - -const defaultFilters = (): SearchFilters => ({ - categories: [], - billingCycles: [], - plans: [], - statuses: [], -}); - -const buildQuery = (state: Pick): SearchQuery => ({ - query: state.queryText, - filters: state.filters, - sort: state.sort, -}); - -export const useSearchStore = create()( - 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'; \ No newline at end of file diff --git a/backend/elasticsearch/__tests__/config.test.ts b/backend/elasticsearch/__tests__/config.test.ts new file mode 100644 index 00000000..33f1eeb7 --- /dev/null +++ b/backend/elasticsearch/__tests__/config.test.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/backend/elasticsearch/config.ts b/backend/elasticsearch/config.ts index 08229e2a..af8a2806 100644 --- a/backend/elasticsearch/config.ts +++ b/backend/elasticsearch/config.ts @@ -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; @@ -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 = { @@ -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 { @@ -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', + }; +} diff --git a/backend/elasticsearch/replicaRouter.ts b/backend/elasticsearch/replicaRouter.ts index 36c884b4..e4038710 100644 --- a/backend/elasticsearch/replicaRouter.ts +++ b/backend/elasticsearch/replicaRouter.ts @@ -31,11 +31,11 @@ export class ElasticsearchReplicaRouter { } getPrimary(): ElasticsearchNode | null { - return this.config.nodes.find((n) => n.role === 'primary') ?? null; + return this.config.nodes?.find((n) => n.role === 'primary') ?? null; } getReplicas(): ElasticsearchNode[] { - return this.config.nodes.filter((n) => n.role === 'replica'); + return this.config.nodes?.filter((n) => n.role === 'replica') ?? []; } getHealthyReplicas(): ElasticsearchNode[] { @@ -55,11 +55,15 @@ export class ElasticsearchReplicaRouter { * configured (callers should use the in-process index). */ route(kind: ElasticsearchRouteKind): ElasticsearchRouteResult { - if (this.config.nodes.length === 0) { + const nodes = this.config.nodes ?? []; + const readWriteSplitting = this.config.readWriteSplitting ?? true; + const automaticFailover = this.config.automaticFailover ?? true; + + if (nodes.length === 0) { return { node: null, route: 'in-process', failedOver: false }; } - if (kind === 'write' || !this.config.readWriteSplitting) { + if (kind === 'write' || !readWriteSplitting) { const primary = this.getPrimary(); return { node: primary, @@ -79,7 +83,7 @@ export class ElasticsearchReplicaRouter { }; } - if (this.config.automaticFailover) { + if (automaticFailover) { const primary = this.getPrimary(); return { node: primary, diff --git a/backend/server.ts b/backend/server.ts index 7b538071..0ef5a584 100644 --- a/backend/server.ts +++ b/backend/server.ts @@ -75,18 +75,76 @@ async function ensurePlanCache(pool: Pool): Promise { // Rate-limit middleware factory // --------------------------------------------------------------------------- +const SUBSCRIPTION_TIER_VALUES: string[] = Object.values(SubscriptionTier); + +function parseSubscriptionTier(raw: string | null): SubscriptionTier | null { + if (!raw) return SubscriptionTier.FREE; + const value = raw.trim().toLowerCase(); + return SUBSCRIPTION_TIER_VALUES.includes(value) ? (value as SubscriptionTier) : null; +} + +/** Extract the JWT `sub` (or userId) claim without verifying the signature. */ +function decodeJwtSubject(token: string): string | undefined { + const parts = token.split('.'); + if (parts.length < 2) return undefined; + try { + const encoded = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const payload = JSON.parse(Buffer.from(encoded, 'base64').toString('utf8')) as { + sub?: string; + userId?: string; + user_id?: string; + }; + return payload.sub ?? payload.userId ?? payload.user_id; + } catch { + return undefined; + } +} + +/** Resolve the caller tier from the x-subscription-tier header (default FREE). */ +function resolveTierFromRequest( + req: { headers: Record }, +): SubscriptionTier { + const raw = req.headers['x-subscription-tier']; + const value = typeof raw === 'string' ? raw : Array.isArray(raw) ? raw[0] : undefined; + return parseSubscriptionTier(value ?? null) ?? SubscriptionTier.FREE; +} + +/** + * Resolve a user identity for per-user aggregate limiting. + * Prefers the x-user-id header (set by an upstream auth layer) and falls back + * to the `sub`/`userId` claim of a Bearer JWT. + */ +function resolveUserIdFromRequest( + req: { headers: Record }, +): string | undefined { + const xUserId = req.headers['x-user-id']; + if (typeof xUserId === 'string' && xUserId.trim()) { + return xUserId.trim(); + } + if (Array.isArray(xUserId) && xUserId[0]?.trim()) { + return xUserId[0].trim(); + } + const auth = req.headers['authorization']; + const token = typeof auth === 'string' && auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''; + if (token) { + return decodeJwtSubject(token); + } + return undefined; +} + function buildRateLimitMiddleware() { return createRateLimitMiddleware({ service: rateLimitingService, - // Bypass paths (health/metrics never throttled) - bypassPaths: ['/health', '/metrics', '/metrics/plan-cache'], - // Tier resolver: reads x-subscription-tier header; defaults to FREE - tierFn: (apiKey, _userId) => { - // In production this would look up the tier from a DB / cache. - // For now we use the header injected by an upstream auth layer. + // Public/observability endpoints never throttle clients missing keys. + allowMissingKey: true, + skipPaths: ['/health', '/metrics/plan-cache', '/metrics/compression', '/metrics/pool'], + // Per-key tier: read x-subscription-tier header; defaults to FREE. + getTier: (apiKey, req) => { void apiKey; - return SubscriptionTier.FREE; + return resolveTierFromRequest(req); }, + // Per-user aggregate limiting: x-user-id header or Bearer JWT sub claim. + getUserId: (req) => resolveUserIdFromRequest(req), }); } @@ -94,12 +152,12 @@ function buildRateLimitMiddleware() { * Apply rate limit middleware inline (no Express). * Returns true if the request should continue, false if a 429 was sent. */ -function applyRateLimit( +async function applyRateLimit( rl: ReturnType, req: http.IncomingMessage, res: http.ServerResponse, path: string, -): boolean { +): Promise { let blocked = false; const pseudoReq = { @@ -110,18 +168,40 @@ function applyRateLimit( ip: (req.socket as { remoteAddress?: string } | null)?.remoteAddress, }; + // Minimal Response adapter: the middleware speaks Express-style (status/json) + // while the raw http server only exposes writeHead/end. const pseudoRes = { - setHeader: (name: string, value: string | number) => res.setHeader(name, value), - writeHead: (status: number, headers?: Record) => { + _statusCode: 200, + setHeader(name: string, value: string | number) { + res.setHeader(name, String(value)); + }, + header(name: string, value: string) { + res.setHeader(name, value); + return this; + }, + set(name: string, value: string) { + res.setHeader(name, value); + return this; + }, + status(code: number) { + this._statusCode = code; + return this; + }, + writeHead(status: number, headers?: Record) { res.writeHead(status, headers); }, - end: (body?: string) => { + end(body?: string) { res.end(body); blocked = true; }, + json(body: unknown) { + res.writeHead(this._statusCode, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); + blocked = true; + }, }; - rl(pseudoReq, pseudoRes, () => { + await rl(pseudoReq, pseudoRes, () => { /* proceed */ }); @@ -289,6 +369,25 @@ export async function startServer(options: StartServerOptions = {}): Promise ({ + setItem: jest.fn(() => Promise.resolve()), + getItem: jest.fn(() => Promise.resolve(null)), + removeItem: jest.fn(() => Promise.resolve()), +})); + +describe('zustand search slice (composed in useAppStore)', () => { + beforeEach(() => { + useAppStore.setState({ + queryText: '', + filters: { categories: [], billingCycles: [], plans: [], statuses: [] }, + sort: { field: '_score', order: 'desc' }, + result: null, + savedSearches: [], + suggestions: [], + loading: false, + }); + }); + + it('sets the query text and runs a search', async () => { + await act(async () => { + useAppStore.getState().setQueryText('netflix'); + }); + + expect(useAppStore.getState().queryText).toBe('netflix'); + expect(selectSearchQueryText(useAppStore.getState())).toBe('netflix'); + expect(useAppStore.getState().loading).toBe(false); + expect(useAppStore.getState().result).not.toBeNull(); + }); + + it('updates filters immutably without dropping existing keys', () => { + useAppStore.getState().setFilters({ categories: [SubscriptionCategory.STREAMING] }); + + const filters = useAppStore.getState().filters; + expect(filters.categories).toEqual([SubscriptionCategory.STREAMING]); + expect(filters.billingCycles).toEqual([]); + expect(filters.plans).toEqual([]); + expect(filters.statuses).toEqual([]); + }); + + it('updates the sort order', () => { + useAppStore.getState().setSort({ field: 'price', order: 'asc' }); + + expect(useAppStore.getState().sort).toEqual({ field: 'price', order: 'asc' }); + }); + + it('refreshes suggestions from the index', async () => { + await act(async () => { + useAppStore.getState().refreshSuggestions('stream'); + }); + + expect(useAppStore.getState().suggestions).toEqual([]); + }); + + it('saves and removes a saved search', async () => { + await act(async () => { + useAppStore.getState().setQueryText('netflix'); + }); + + await act(async () => { + await useAppStore.getState().saveCurrentSearch('My Netflix filter'); + }); + + expect(useAppStore.getState().savedSearches).toHaveLength(1); + expect(useAppStore.getState().savedSearches[0].name).toBe('My Netflix filter'); + expect(selectSavedSearches(useAppStore.getState())).toHaveLength(1); + + await act(async () => { + await useAppStore.getState().removeSavedSearch(useAppStore.getState().savedSearches[0].id); + }); + + expect(useAppStore.getState().savedSearches).toHaveLength(0); + }); + + it('hydrates saved searches from storage', async () => { + await act(async () => { + await useAppStore.getState().hydrateSavedSearches(); + }); + + expect(useAppStore.getState().savedSearches).toEqual([]); + }); + + it('clears transient search state while keeping saved searches', async () => { + await act(async () => { + useAppStore.getState().setQueryText('spotify'); + useAppStore.getState().setFilters({ categories: [SubscriptionCategory.STREAMING] }); + useAppStore.getState().clear(); + }); + + expect(useAppStore.getState().queryText).toBe(''); + expect(useAppStore.getState().filters.categories).toEqual([]); + expect(useAppStore.getState().sort).toEqual({ field: '_score', order: 'desc' }); + expect(useAppStore.getState().result).toBeNull(); + }); + + it('loads a saved search back into the active query', async () => { + await act(async () => { + useAppStore.getState().setQueryText('youtube'); + }); + + await act(async () => { + await useAppStore.getState().saveCurrentSearch('Video'); + }); + + const savedId = useAppStore.getState().savedSearches[0].id; + + await act(async () => { + useAppStore.getState().setQueryText('disney'); + useAppStore.getState().loadSavedSearch(savedId); + }); + + expect(useAppStore.getState().queryText).toBe('youtube'); + }); + + it('suggested sort orders are valid', () => { + const sort = useAppStore.getState().sort; + expect(sort.field).toBe('_score'); + expect(['asc', 'desc']).toContain(sort.order); + }); +}); \ No newline at end of file diff --git a/src/store/slices/index.ts b/src/store/slices/index.ts index dbca4baa..8deba252 100644 --- a/src/store/slices/index.ts +++ b/src/store/slices/index.ts @@ -22,6 +22,7 @@ import { createUserSlice } from './userSlice'; import { createSettingsSlice } from './settingsSlice'; import { createNetworkSlice } from './networkSlice'; import { createTransactionSlice } from './transactionSlice'; +import { createSearchSlice } from './searchSlice'; import type { AppState } from './state'; export type { AppState } from './state'; @@ -30,6 +31,7 @@ export type { UserSlice, UserStoreState, ConsentState } from './userSlice'; export type { SettingsSlice, SettingsStoreState } from './settingsSlice'; export type { NetworkSlice, NetworkStoreState } from './networkSlice'; export type { TransactionSlice, TransactionStoreState } from './transactionSlice'; +export type { SearchSlice, SearchStoreState } from './searchSlice'; // ───────────────────────────────────────────────────────────────────────────── // Storage selection: pick the correct adapter for the runtime environment. @@ -54,6 +56,7 @@ export const useAppStore = create()( ...createSettingsSlice(set, get), ...createNetworkSlice(set, get), ...createTransactionSlice(set, get), + ...createSearchSlice(set, get), }), { name: 'subtrackr-app-store', @@ -72,6 +75,7 @@ export const useAppStore = create()( healthScoreWeights: state.healthScoreWeights, currentNetwork: state.currentNetwork, transactions: state.transactions, + savedSearches: state.savedSearches, }), } ) @@ -89,3 +93,5 @@ export const selectPreferredCurrency = (s: AppState) => s.preferredCurrency; export const selectCurrentNetwork = (s: AppState) => s.currentNetwork; export const selectTransactions = (s: AppState) => s.transactions; export const selectSubscriptionTier = (s: AppState) => s.subscriptionTier; +export const selectSearchQueryText = (s: AppState) => s.queryText; +export const selectSavedSearches = (s: AppState) => s.savedSearches; diff --git a/src/store/slices/searchSlice.ts b/src/store/slices/searchSlice.ts new file mode 100644 index 00000000..5873b768 --- /dev/null +++ b/src/store/slices/searchSlice.ts @@ -0,0 +1,152 @@ +/** + * searchSlice.ts — Advanced search slice for the slices-pattern store. + * + * Migrated from app/stores/searchStore.ts (Issue #944) so search state and + * actions live alongside the other domain slices in the combined useAppStore. + * Legacy consumers importing `useSearchStore` are untouched. + */ + +import { SliceCreator } from './types'; +import type { AppState } from './state'; +import type { Subscription, SubscriptionCategory, BillingCycle } from '../../types/subscription'; +import type { SearchResult } from '../../backend/services/search/ElasticsearchService'; +import type { + SavedSearch, + SearchQuery, + SavedSearchMatchNotification, +} from '../../app/services/searchService'; + +type SearchFilters = NonNullable; + +export interface SearchSlice { + queryText: string; + filters: SearchFilters; + sort: SearchQuery['sort']; + result: SearchResult | null; + savedSearches: SavedSearch[]; + suggestions: string[]; + loading: boolean; + + setQueryText: (text: string) => void; + setFilters: (filters: Partial) => void; + setSort: (sort: SearchQuery['sort']) => void; + runSearch: () => void; + refreshSuggestions: (partial: string) => void; + saveCurrentSearch: (name: string, notifyOnNewMatches?: boolean) => Promise; + loadSavedSearch: (id: string) => void; + removeSavedSearch: (id: string) => Promise; + checkNotifications: () => Promise; + hydrateSavedSearches: () => Promise; + clear: () => void; +} + +export type SearchStoreState = AppState; + +const defaultFilters = (): SearchFilters => ({ + categories: [], + billingCycles: [], + plans: [], + statuses: [], +}); + +const loadSearchService = (): Promise => + import('../../app/services/searchService'); + +const buildQuery = (state: Pick): SearchQuery => ({ + query: state.queryText, + filters: state.filters, + sort: state.sort, +}); + +export const createSearchSlice: SliceCreator = (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 }); + void loadSearchService().then(({ search_subscriptions }) => { + const result = search_subscriptions(buildQuery(get())); + set({ result, loading: false }); + }); + }, + + refreshSuggestions: (partial) => { + void loadSearchService().then(({ get_search_suggestions }) => { + 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(), + }; + const { save_search } = await loadSearchService(); + 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) => { + const { delete_saved_search } = await loadSearchService(); + await delete_saved_search(id); + set((s) => ({ savedSearches: s.savedSearches.filter((item) => item.id !== id) })); + }, + + checkNotifications: () => + loadSearchService().then(({ check_saved_search_notifications }) => + check_saved_search_notifications() + ), + + hydrateSavedSearches: async () => { + const { load_saved_searches } = await loadSearchService(); + const saved = await load_saved_searches(); + set({ savedSearches: saved }); + }, + + clear: () => { + set({ + queryText: '', + filters: defaultFilters(), + sort: { field: '_score', order: 'desc' }, + result: null, + }); + }, +}); + +export type { Subscription, SubscriptionCategory, BillingCycle }; \ No newline at end of file diff --git a/src/store/slices/state.ts b/src/store/slices/state.ts index 5f323961..b41ff1b5 100644 --- a/src/store/slices/state.ts +++ b/src/store/slices/state.ts @@ -11,12 +11,13 @@ import { UserSlice } from './userSlice'; import { SettingsSlice } from './settingsSlice'; import { NetworkSlice } from './networkSlice'; import { TransactionSlice } from './transactionSlice'; +import { SearchSlice } from './searchSlice'; /** * The full combined store state — every slice spread together. */ export interface AppState - extends AuthSlice, UserSlice, SettingsSlice, NetworkSlice, TransactionSlice {} + extends AuthSlice, UserSlice, SettingsSlice, NetworkSlice, TransactionSlice, SearchSlice {} /** * SliceCreator with full cross-slice access: the 4th generic is AppState so a diff --git a/src/store/slices/types.ts b/src/store/slices/types.ts index a2c5e15d..4a4af803 100644 --- a/src/store/slices/types.ts +++ b/src/store/slices/types.ts @@ -12,6 +12,8 @@ export { SliceCreator, AppState } from './state'; +export type { SearchSlice, SearchStoreState } from './searchSlice'; + // ───────────────────────────────────────────────────────────────────────────── // Shared ephemeral-state helpers // ─────────────────────────────────────────────────────────────────────────────