|
1 | | -import { Subscription, SubscriptionCategory } from '../types/subscription'; |
2 | | -import { useSubscriptionStore } from '../store/subscriptionStore'; |
3 | | -import { currencyService } from './currencyService'; |
4 | | -import { useSettingsStore } from '../store/settingsStore'; |
5 | | - |
6 | | -export type SearchQuery = { |
7 | | - query: string; |
8 | | - filters?: { |
9 | | - category?: SubscriptionCategory | string; |
10 | | - minPrice?: number; |
11 | | - maxPrice?: number; |
12 | | - activeOnly?: boolean; |
13 | | - }; |
14 | | -}; |
| 1 | +import AsyncStorage from '@react-native-async-storage/async-storage'; |
| 2 | +import { |
| 3 | + Subscription, |
| 4 | + SubscriptionCategory, |
| 5 | + BillingCycle, |
| 6 | +} from '../../src/types/subscription'; |
| 7 | +import { useSubscriptionStore } from '../../src/store/subscriptionStore'; |
| 8 | +import { |
| 9 | + elasticsearchService, |
| 10 | + SearchQuery, |
| 11 | + SearchResult, |
| 12 | + SavedSearchDefinition, |
| 13 | + SavedSearchMatchNotification, |
| 14 | +} from '../../backend/services/search/ElasticsearchService'; |
15 | 15 |
|
16 | | -export type SavedSearch = { |
17 | | - id: string; |
18 | | - name: string; |
19 | | - query: string; |
20 | | - filters?: any; |
21 | | -}; |
| 16 | +export type { SearchQuery, SearchResult, SavedSearchDefinition, SavedSearchMatchNotification }; |
| 17 | + |
| 18 | +export type SearchFilters = NonNullable<SearchQuery['filters']>; |
| 19 | + |
| 20 | +export type SavedSearch = SavedSearchDefinition; |
| 21 | + |
| 22 | +const SAVED_SEARCHES_KEY = 'subtrackr-saved-searches'; |
22 | 23 |
|
23 | | -export type SearchResult = { |
24 | | - subscriptions: Subscription[]; |
25 | | - total: number; |
26 | | - // potential analytics hook-in placeholders |
27 | | - // analytics?: any; |
| 24 | +const toBackendQuery = (query: SearchQuery): SearchQuery => query; |
| 25 | + |
| 26 | +const syncIndex = (): void => { |
| 27 | + const subscriptions = useSubscriptionStore.getState().subscriptions ?? []; |
| 28 | + elasticsearchService.bulkIndex(subscriptions); |
28 | 29 | }; |
29 | 30 |
|
30 | | -// Very lightweight full-text + facet search on subscriptions |
31 | 31 | export const search_subscriptions = (query: SearchQuery): SearchResult => { |
32 | | - const all = useSubscriptionStore.getState().subscriptions || []; |
33 | | - const { query: q, filters } = query; |
34 | | - |
35 | | - const text = (s: Subscription) => { |
36 | | - const fields = [s.name, s.description, String(s.category), s.currency, String(s.price)]; |
37 | | - return fields.filter(Boolean).join(' ').toLowerCase(); |
38 | | - }; |
39 | | - |
40 | | - const normalizedQ = (q || '').toLowerCase().trim(); |
41 | | - |
42 | | - const filtered = all.filter((sub) => { |
43 | | - // quick skip if not active and user asked for activeOnly |
44 | | - if (filters?.activeOnly && sub.isActive !== true) return false; |
45 | | - // category facet |
46 | | - if (filters?.category && sub.category !== filters!.category) return false; |
47 | | - // price filters |
48 | | - if (typeof filters?.minPrice === 'number' && sub.price < filters!.minPrice) return false; |
49 | | - if (typeof filters?.maxPrice === 'number' && sub.price > filters!.maxPrice) return false; |
50 | | - // full-text search against multiple fields |
51 | | - if (normalizedQ) { |
52 | | - const hay = text(sub); |
53 | | - return hay.includes(normalizedQ); |
54 | | - } |
55 | | - return true; |
56 | | - }); |
57 | | - |
58 | | - // Sorting (simple): by nextBillingDate asc if available, else by createdAt |
59 | | - const sorted = filtered.slice().sort((a, b) => { |
60 | | - const ta = a.nextBillingDate?.getTime?.() ?? 0; |
61 | | - const tb = b.nextBillingDate?.getTime?.() ?? 0; |
62 | | - return ta - tb; |
63 | | - }); |
64 | | - |
65 | | - // export-friendly result: total and items |
66 | | - return { subscriptions: sorted, total: sorted.length }; |
| 32 | + syncIndex(); |
| 33 | + return elasticsearchService.search(toBackendQuery(query)); |
67 | 34 | }; |
68 | 35 |
|
69 | | -export const save_search = async (search: SavedSearch): Promise<void> => { |
70 | | - // Simple persistence via local store if needed; here we'll just attempt to attach to settings if available |
71 | | - // In a full implementation, we'd persist to AsyncStorage or backend; keep minimal for now |
72 | | - const _ = search; // placeholder to signal intent |
73 | | - return Promise.resolve(); |
| 36 | +export const index_subscription = (subscription: Subscription): void => { |
| 37 | + elasticsearchService.indexDocument(subscription); |
| 38 | +}; |
| 39 | + |
| 40 | +export const remove_subscription_from_index = (id: string): void => { |
| 41 | + elasticsearchService.deleteDocument(id); |
74 | 42 | }; |
75 | 43 |
|
76 | 44 | export const get_search_suggestions = (partial: string): string[] => { |
| 45 | + syncIndex(); |
77 | 46 | const suggestions = new Set<string>(); |
78 | | - const subs = useSubscriptionStore.getState().subscriptions || []; |
79 | | - const q = partial.toLowerCase(); |
| 47 | + const q = partial.toLowerCase().trim(); |
| 48 | + if (!q) return []; |
| 49 | + |
| 50 | + const subs = useSubscriptionStore.getState().subscriptions ?? []; |
80 | 51 | for (const sub of subs) { |
81 | | - if (sub.name.toLowerCase().includes(q)) suggestions.add(sub.name); |
82 | | - if (sub.description && sub.description.toLowerCase().includes(q)) suggestions.add(sub.description); |
| 52 | + const candidates = [ |
| 53 | + sub.name, |
| 54 | + sub.planName, |
| 55 | + sub.customerName, |
| 56 | + sub.customerEmail, |
| 57 | + sub.notes, |
| 58 | + sub.description, |
| 59 | + ].filter(Boolean) as string[]; |
| 60 | + |
| 61 | + for (const value of candidates) { |
| 62 | + if (value.toLowerCase().includes(q)) suggestions.add(value); |
| 63 | + } |
| 64 | + } |
| 65 | + |
| 66 | + for (const category of Object.values(SubscriptionCategory)) { |
| 67 | + if (category.toLowerCase().includes(q)) suggestions.add(category); |
| 68 | + } |
| 69 | + |
| 70 | + for (const top of elasticsearchService.getTopQueries(5)) { |
| 71 | + if (top.query.includes(q)) suggestions.add(top.query); |
83 | 72 | } |
84 | | - // also suggest categories |
85 | | - const categories = Object.values(SubscriptionCategory); |
86 | | - for (const c of categories) if ((c as string).toLowerCase().includes(q)) suggestions.add(c); |
87 | | - return Array.from(suggestions).slice(0, 5); |
| 73 | + |
| 74 | + return Array.from(suggestions).slice(0, 8); |
| 75 | +}; |
| 76 | + |
| 77 | +export const load_saved_searches = async (): Promise<SavedSearch[]> => { |
| 78 | + const raw = await AsyncStorage.getItem(SAVED_SEARCHES_KEY); |
| 79 | + if (!raw) return []; |
| 80 | + const parsed = JSON.parse(raw) as SavedSearch[]; |
| 81 | + elasticsearchService.loadSavedSearches(parsed); |
| 82 | + return parsed; |
| 83 | +}; |
| 84 | + |
| 85 | +export const save_search = async (search: SavedSearch): Promise<void> => { |
| 86 | + const existing = await load_saved_searches(); |
| 87 | + const next = existing.some((s) => s.id === search.id) |
| 88 | + ? existing.map((s) => (s.id === search.id ? search : s)) |
| 89 | + : [...existing, search]; |
| 90 | + |
| 91 | + elasticsearchService.loadSavedSearches(next); |
| 92 | + await AsyncStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(next)); |
| 93 | + elasticsearchService.registerSavedSearch(search); |
| 94 | +}; |
| 95 | + |
| 96 | +export const delete_saved_search = async (id: string): Promise<void> => { |
| 97 | + const existing = await load_saved_searches(); |
| 98 | + const next = existing.filter((s) => s.id !== id); |
| 99 | + elasticsearchService.loadSavedSearches(next); |
| 100 | + elasticsearchService.removeSavedSearch(id); |
| 101 | + await AsyncStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(next)); |
88 | 102 | }; |
| 103 | + |
| 104 | +export const check_saved_search_notifications = (): SavedSearchMatchNotification[] => { |
| 105 | + syncIndex(); |
| 106 | + return elasticsearchService.checkSavedSearchNotifications(); |
| 107 | +}; |
| 108 | + |
| 109 | +export const buildDefaultFilters = (): SearchFilters => ({ |
| 110 | + categories: [], |
| 111 | + billingCycles: [], |
| 112 | + plans: [], |
| 113 | + statuses: [], |
| 114 | + priceRange: { min: 0, max: Number.MAX_SAFE_INTEGER }, |
| 115 | +}); |
| 116 | + |
| 117 | +export const formatHighlight = (highlight?: string, fallback = ''): string => { |
| 118 | + if (!highlight) return fallback; |
| 119 | + return highlight.replace(/<\/?em>/g, ''); |
| 120 | +}; |
| 121 | + |
| 122 | +export const hasHighlightMatch = (highlight?: string): boolean => |
| 123 | + Boolean(highlight && highlight.includes('<em>')); |
| 124 | + |
| 125 | +export const getBillingCycleLabel = (cycle: BillingCycle): string => |
| 126 | + cycle.charAt(0).toUpperCase() + cycle.slice(1); |
| 127 | + |
| 128 | +export const getCategoryLabel = (category: SubscriptionCategory): string => |
| 129 | + category.charAt(0).toUpperCase() + category.slice(1); |
0 commit comments