Skip to content

Commit ee5e206

Browse files
committed
feat: implement issues #916, #919, #920, #922 — search, atomic batch, notifications, fallback chains
Issue #916 — Advanced search for subscriptions with Elasticsearch - Add SubscriptionSearchAggregator with faceted search (category/status/billingCycle facets, price range filter) - Add SearchFacetManager for stateful filter toggle management in UI - Add SearchAutoComplete with TTL-based caching to reduce cluster load - 20 passing tests: backend/elasticsearch/__tests__/searchAggregator.test.ts Issue #919 — Batch subscription operations with atomic execution - Create app/services/atomicBatchService.ts: AtomicBatchExecutor with snapshot/rollback, idempotency keys, failFast mode - Add useAtomicBatch React hook wiring executor to the batch store - Add AtomicExecutionPanel component to BatchOperationsScreen with status badge and per-item report - Add Rust integration tests in contracts/batch/tests/atomic_execution_tests.rs - 16 passing tests: app/services/__tests__/atomicBatchService.test.ts Issue #920 — Subscription notification preferences and management - Add DigestNotificationManager: buffers items by frequency and flushes due digests grouped by channel - Add NotificationScheduler: computes next delivery window respecting quiet hours and frequency preferences - Add NotificationPreferenceSync: change-log and listener pattern for cross-device sync - Add scheduleNotification convenience function for the delivery pipeline - Add getNotificationPreferenceSummary, setAllChannelNotifications, shouldShowNotification to frontend notificationService - 18 passing tests: backend/services/notification/__tests__/notificationScheduling.test.ts Issue #922 — Payment method management with fallback chains - Add FallbackChainHealthMonitor: per-method health snapshots with success rate, latency, consecutive failures, rotation policies - Add SmartFallbackSelector: re-orders fallback chain by health score and honours active rotation promotions - Add buildFallbackChainDiagnosticReport: human-readable chain health summary - Extend walletStore with getChainHealthSnapshot, getSmartFallbackSelection, getChainDiagnosticReport, rotationPolicies CRUD, applyAllRotationPolicies - 15 passing tests: src/services/__tests__/fallbackChainHealth.test.ts Closes #916, #919, #920, #922
1 parent f172723 commit ee5e206

12 files changed

Lines changed: 3913 additions & 16 deletions

File tree

app/screens/BatchOperationsScreen.tsx

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,299 @@ import {
2525
exportBatchResultToCsv as exportCsv,
2626
} from '../stores/batchStore';
2727
import { colors, spacing, typography, borderRadius } from '../../src/utils/constants';
28+
import {
29+
useAtomicBatch,
30+
validateAtomicBatch,
31+
type AtomicBatchItem,
32+
type AtomicBatchReport,
33+
type AtomicBatchStatus,
34+
} from '../services/atomicBatchService';
35+
36+
// ── Atomic status colour map ──────────────────────────────────────────────
37+
38+
const ATOMIC_STATUS_COLORS: Record<AtomicBatchStatus, string> = {
39+
idle: colors.textSecondary,
40+
validating: colors.warning,
41+
snapshotting: colors.warning,
42+
executing: colors.primary,
43+
committing: colors.primary,
44+
rolling_back: colors.error,
45+
committed: colors.success,
46+
rolled_back: colors.error,
47+
failed: colors.error,
48+
};
49+
50+
// ── AtomicExecutionPanel ──────────────────────────────────────────────────
51+
52+
interface AtomicExecutionPanelProps {
53+
operationType: BatchOperationType;
54+
subscriptionIds: string[];
55+
}
56+
57+
const AtomicExecutionPanel: React.FC<AtomicExecutionPanelProps> = ({
58+
operationType,
59+
subscriptionIds,
60+
}) => {
61+
const { runAtomic, isBusy } = useAtomicBatch();
62+
const [report, setReport] = React.useState<AtomicBatchReport | null>(null);
63+
const [status, setStatus] = React.useState<AtomicBatchStatus>('idle');
64+
const [validationErrors, setValidationErrors] = React.useState<string[]>([]);
65+
66+
const items: AtomicBatchItem[] = subscriptionIds.map((sid, idx) => ({
67+
id: `item_${idx}`,
68+
subscriptionId: sid,
69+
operation: operationType,
70+
payload: {},
71+
}));
72+
73+
const handleRunAtomic = React.useCallback(async () => {
74+
setValidationErrors([]);
75+
const validation = validateAtomicBatch(items);
76+
if (!validation.valid) {
77+
setValidationErrors(validation.errors);
78+
return;
79+
}
80+
setStatus('executing');
81+
try {
82+
const result = await runAtomic(`batch_${Date.now()}`, items, {
83+
failFast: true,
84+
concurrency: 1,
85+
timeoutPerItemMs: 10_000,
86+
});
87+
setReport(result);
88+
setStatus(result.status);
89+
} catch {
90+
setStatus('failed');
91+
}
92+
}, [items, runAtomic]);
93+
94+
const handleReset = () => {
95+
setReport(null);
96+
setStatus('idle');
97+
setValidationErrors([]);
98+
};
99+
100+
const statusColor = ATOMIC_STATUS_COLORS[status];
101+
102+
return (
103+
<View style={atomicStyles.panel}>
104+
<View style={atomicStyles.panelHeader}>
105+
<Text style={atomicStyles.panelTitle}>⚛ Atomic Execution</Text>
106+
<View style={[atomicStyles.statusBadge, { backgroundColor: statusColor + '22', borderColor: statusColor }]}>
107+
<Text style={[atomicStyles.statusText, { color: statusColor }]}>
108+
{status.toUpperCase().replace('_', ' ')}
109+
</Text>
110+
</View>
111+
</View>
112+
113+
<Text style={atomicStyles.description}>
114+
Atomic mode executes all {subscriptionIds.length} item
115+
{subscriptionIds.length !== 1 ? 's' : ''} as a single unit. Any failure
116+
will automatically roll back all previously applied changes.
117+
</Text>
118+
119+
{validationErrors.length > 0 && (
120+
<View style={atomicStyles.errorBox}>
121+
{validationErrors.map((e, i) => (
122+
<Text key={i} style={atomicStyles.errorText}>{e}</Text>
123+
))}
124+
</View>
125+
)}
126+
127+
{report && (
128+
<View style={atomicStyles.reportBox}>
129+
<View style={atomicStyles.reportRow}>
130+
<Text style={atomicStyles.reportLabel}>Total items</Text>
131+
<Text style={atomicStyles.reportValue}>{report.totalItems}</Text>
132+
</View>
133+
<View style={atomicStyles.reportRow}>
134+
<Text style={atomicStyles.reportLabel}>Succeeded</Text>
135+
<Text style={[atomicStyles.reportValue, { color: colors.success }]}>
136+
{report.succeededItems}
137+
</Text>
138+
</View>
139+
<View style={atomicStyles.reportRow}>
140+
<Text style={atomicStyles.reportLabel}>Failed</Text>
141+
<Text style={[atomicStyles.reportValue, { color: colors.error }]}>
142+
{report.failedItems}
143+
</Text>
144+
</View>
145+
{report.rolledBackItems > 0 && (
146+
<View style={atomicStyles.reportRow}>
147+
<Text style={atomicStyles.reportLabel}>Rolled back</Text>
148+
<Text style={[atomicStyles.reportValue, { color: colors.warning }]}>
149+
{report.rolledBackItems}
150+
</Text>
151+
</View>
152+
)}
153+
{report.durationMs !== undefined && (
154+
<View style={atomicStyles.reportRow}>
155+
<Text style={atomicStyles.reportLabel}>Duration</Text>
156+
<Text style={atomicStyles.reportValue}>
157+
{report.durationMs < 1000
158+
? `${report.durationMs} ms`
159+
: `${(report.durationMs / 1000).toFixed(2)} s`}
160+
</Text>
161+
</View>
162+
)}
163+
{report.rollbackReason && (
164+
<View style={atomicStyles.rollbackBox}>
165+
<Text style={atomicStyles.rollbackLabel}>Rollback reason:</Text>
166+
<Text style={atomicStyles.rollbackReason}>{report.rollbackReason}</Text>
167+
</View>
168+
)}
169+
<Text style={atomicStyles.idempotencyKey}>
170+
Key: {report.idempotencyKey}
171+
</Text>
172+
</View>
173+
)}
174+
175+
<View style={atomicStyles.buttonRow}>
176+
{status === 'idle' || status === 'failed' || status === 'committed' || status === 'rolled_back' ? (
177+
<>
178+
<TouchableOpacity
179+
style={[
180+
atomicStyles.runButton,
181+
(isBusy || subscriptionIds.length === 0) && atomicStyles.disabledButton,
182+
]}
183+
onPress={handleRunAtomic}
184+
disabled={isBusy || subscriptionIds.length === 0}>
185+
<Text style={atomicStyles.runButtonText}>
186+
{status === 'idle' ? '▶ Run Atomically' : '↺ Re-run'}
187+
</Text>
188+
</TouchableOpacity>
189+
{report && (
190+
<TouchableOpacity style={atomicStyles.resetButton} onPress={handleReset}>
191+
<Text style={atomicStyles.resetButtonText}>Reset</Text>
192+
</TouchableOpacity>
193+
)}
194+
</>
195+
) : (
196+
<ActivityIndicator color={colors.primary} />
197+
)}
198+
</View>
199+
</View>
200+
);
201+
};
202+
203+
// ── Styles for AtomicExecutionPanel ───────────────────────────────────────
204+
205+
const atomicStyles = StyleSheet.create({
206+
panel: {
207+
margin: spacing.md,
208+
padding: spacing.md,
209+
backgroundColor: colors.surface,
210+
borderRadius: borderRadius.md,
211+
borderWidth: 1,
212+
borderColor: colors.primary + '44',
213+
},
214+
panelHeader: {
215+
flexDirection: 'row',
216+
alignItems: 'center',
217+
justifyContent: 'space-between',
218+
marginBottom: spacing.sm,
219+
},
220+
panelTitle: {
221+
...typography.h3,
222+
color: colors.text,
223+
fontWeight: '700',
224+
},
225+
statusBadge: {
226+
paddingHorizontal: spacing.sm,
227+
paddingVertical: 2,
228+
borderRadius: borderRadius.round,
229+
borderWidth: 1,
230+
},
231+
statusText: {
232+
...typography.small,
233+
fontWeight: '700',
234+
letterSpacing: 0.5,
235+
},
236+
description: {
237+
...typography.body,
238+
color: colors.textSecondary,
239+
marginBottom: spacing.sm,
240+
},
241+
errorBox: {
242+
backgroundColor: colors.error + '18',
243+
padding: spacing.sm,
244+
borderRadius: borderRadius.sm,
245+
marginBottom: spacing.sm,
246+
},
247+
errorText: {
248+
...typography.caption,
249+
color: colors.error,
250+
},
251+
reportBox: {
252+
backgroundColor: colors.surfaceVariant,
253+
padding: spacing.sm,
254+
borderRadius: borderRadius.sm,
255+
marginBottom: spacing.sm,
256+
gap: spacing.xs,
257+
},
258+
reportRow: {
259+
flexDirection: 'row',
260+
justifyContent: 'space-between',
261+
},
262+
reportLabel: {
263+
...typography.caption,
264+
color: colors.textSecondary,
265+
},
266+
reportValue: {
267+
...typography.caption,
268+
color: colors.text,
269+
fontWeight: '600',
270+
},
271+
rollbackBox: {
272+
marginTop: spacing.xs,
273+
},
274+
rollbackLabel: {
275+
...typography.small,
276+
color: colors.error,
277+
fontWeight: '600',
278+
},
279+
rollbackReason: {
280+
...typography.small,
281+
color: colors.textSecondary,
282+
},
283+
idempotencyKey: {
284+
...typography.small,
285+
color: colors.textSecondary,
286+
marginTop: spacing.xs,
287+
fontFamily: 'monospace',
288+
},
289+
buttonRow: {
290+
flexDirection: 'row',
291+
gap: spacing.sm,
292+
marginTop: spacing.sm,
293+
},
294+
runButton: {
295+
flex: 1,
296+
backgroundColor: colors.primary,
297+
paddingVertical: spacing.sm,
298+
borderRadius: borderRadius.md,
299+
alignItems: 'center',
300+
},
301+
runButtonText: {
302+
...typography.button,
303+
color: colors.onPrimary,
304+
},
305+
resetButton: {
306+
paddingHorizontal: spacing.md,
307+
paddingVertical: spacing.sm,
308+
borderWidth: 1,
309+
borderColor: colors.border,
310+
borderRadius: borderRadius.md,
311+
alignItems: 'center',
312+
},
313+
resetButtonText: {
314+
...typography.button,
315+
color: colors.text,
316+
},
317+
disabledButton: {
318+
opacity: 0.45,
319+
},
320+
});
28321

29322
// ════════════════════════════════════════════════════════════════
30323
// Constants
@@ -876,6 +1169,20 @@ export const BatchOperationsScreen: React.FC = () => {
8761169
{renderResults()}
8771170
{renderAnalytics()}
8781171

1172+
{/* Issue #919 — Atomic Execution Panel */}
1173+
<AtomicExecutionPanel
1174+
operationType={draft.operationType}
1175+
subscriptionIds={
1176+
draft.csvContent
1177+
? draft.csvContent
1178+
.split('\n')
1179+
.map((l) => l.trim().split(',')[0])
1180+
.filter(Boolean)
1181+
.slice(0, 100)
1182+
: []
1183+
}
1184+
/>
1185+
8791186
<View style={styles.bottomPad} />
8801187
</ScrollView>
8811188

0 commit comments

Comments
 (0)