@accounter/scraper-app 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +90 -0
  2. package/docs/plan.md +76 -0
  3. package/index.html +12 -0
  4. package/package.json +40 -0
  5. package/src/env.template +2 -0
  6. package/src/server/__tests__/accounts-routes.test.ts +133 -0
  7. package/src/server/__tests__/check-accounts.test.ts +305 -0
  8. package/src/server/__tests__/filter-payload.test.ts +193 -0
  9. package/src/server/__tests__/graphql-client.integration.test.ts +98 -0
  10. package/src/server/__tests__/graphql-client.test.ts +508 -0
  11. package/src/server/__tests__/healthz.test.ts +22 -0
  12. package/src/server/__tests__/history.test.ts +111 -0
  13. package/src/server/__tests__/otp-manager.test.ts +132 -0
  14. package/src/server/__tests__/scrape-runner.test.ts +144 -0
  15. package/src/server/__tests__/settings-routes.test.ts +117 -0
  16. package/src/server/__tests__/sources-routes.test.ts +149 -0
  17. package/src/server/__tests__/validate-payload.test.ts +193 -0
  18. package/src/server/__tests__/vault-routes.test.ts +174 -0
  19. package/src/server/__tests__/vault.test.ts +33 -0
  20. package/src/server/__tests__/websocket.test.ts +151 -0
  21. package/src/server/account-discovery.ts +49 -0
  22. package/src/server/accounts-routes.ts +74 -0
  23. package/src/server/check-accounts.ts +79 -0
  24. package/src/server/filter-payload.ts +145 -0
  25. package/src/server/graphql/client.ts +103 -0
  26. package/src/server/graphql/mutations.ts +518 -0
  27. package/src/server/history-routes.ts +11 -0
  28. package/src/server/history.ts +53 -0
  29. package/src/server/index.ts +40 -0
  30. package/src/server/otp-manager.ts +63 -0
  31. package/src/server/payload-schemas/amex.schema.ts +2 -0
  32. package/src/server/payload-schemas/cal.schema.ts +27 -0
  33. package/src/server/payload-schemas/currency-rates.schema.ts +11 -0
  34. package/src/server/payload-schemas/discount.schema.ts +26 -0
  35. package/src/server/payload-schemas/isracard.schema.ts +58 -0
  36. package/src/server/payload-schemas/max.schema.ts +27 -0
  37. package/src/server/payload-schemas/poalim-foreign.schema.ts +30 -0
  38. package/src/server/payload-schemas/poalim-ils.schema.ts +31 -0
  39. package/src/server/payload-schemas/poalim-swift.schema.ts +21 -0
  40. package/src/server/scrape-runner.ts +165 -0
  41. package/src/server/scrapers/__tests__/amex.test.ts +142 -0
  42. package/src/server/scrapers/__tests__/cal.test.ts +135 -0
  43. package/src/server/scrapers/__tests__/currency-rates.test.ts +105 -0
  44. package/src/server/scrapers/__tests__/discount.test.ts +160 -0
  45. package/src/server/scrapers/__tests__/isracard.test.ts +142 -0
  46. package/src/server/scrapers/__tests__/max.test.ts +115 -0
  47. package/src/server/scrapers/__tests__/poalim.test.ts +154 -0
  48. package/src/server/scrapers/amex.ts +63 -0
  49. package/src/server/scrapers/cal.ts +56 -0
  50. package/src/server/scrapers/currency-rates.ts +64 -0
  51. package/src/server/scrapers/discount.ts +62 -0
  52. package/src/server/scrapers/isracard.ts +68 -0
  53. package/src/server/scrapers/max.ts +32 -0
  54. package/src/server/scrapers/poalim.ts +103 -0
  55. package/src/server/settings-routes.ts +27 -0
  56. package/src/server/sources-routes.ts +182 -0
  57. package/src/server/validate-payload.ts +74 -0
  58. package/src/server/vault-routes.ts +99 -0
  59. package/src/server/vault-store.ts +42 -0
  60. package/src/server/vault.ts +216 -0
  61. package/src/server/websocket.ts +454 -0
  62. package/src/shared/source-types.ts +10 -0
  63. package/src/shared/types.ts +20 -0
  64. package/src/shared/ws-protocol.ts +177 -0
  65. package/src/test-setup.ts +6 -0
  66. package/src/ui/__tests__/accounts-tab.test.tsx +134 -0
  67. package/src/ui/__tests__/config.test.tsx +99 -0
  68. package/src/ui/__tests__/history.test.tsx +94 -0
  69. package/src/ui/__tests__/run.test.tsx +195 -0
  70. package/src/ui/__tests__/settings-tab.test.tsx +79 -0
  71. package/src/ui/__tests__/sources-tab.test.tsx +139 -0
  72. package/src/ui/__tests__/vault-setup.test.tsx +105 -0
  73. package/src/ui/__tests__/vault-unlock.test.tsx +78 -0
  74. package/src/ui/app.tsx +109 -0
  75. package/src/ui/components/error-boundary.tsx +54 -0
  76. package/src/ui/components/otp-modal.tsx +82 -0
  77. package/src/ui/components/skeleton.tsx +58 -0
  78. package/src/ui/components/task-row.tsx +241 -0
  79. package/src/ui/contexts/vault-context.tsx +77 -0
  80. package/src/ui/lib/api.ts +117 -0
  81. package/src/ui/lib/ws.ts +137 -0
  82. package/src/ui/main.tsx +9 -0
  83. package/src/ui/screens/config/accounts-tab.tsx +185 -0
  84. package/src/ui/screens/config/config.tsx +163 -0
  85. package/src/ui/screens/config/settings-tab.tsx +167 -0
  86. package/src/ui/screens/config/source-forms.tsx +518 -0
  87. package/src/ui/screens/config/source-types.ts +91 -0
  88. package/src/ui/screens/config/sources-tab.tsx +176 -0
  89. package/src/ui/screens/history.tsx +234 -0
  90. package/src/ui/screens/run.tsx +266 -0
  91. package/src/ui/screens/vault-setup.tsx +120 -0
  92. package/src/ui/screens/vault-unlock.tsx +38 -0
  93. package/tsconfig.json +15 -0
  94. package/tsup.config.ts +10 -0
  95. package/vite.config.ts +24 -0
  96. package/vitest.config.ts +7 -0
@@ -0,0 +1,454 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { RawData, WebSocket } from 'ws';
3
+ import websocketPlugin from '@fastify/websocket';
4
+ import type { IsracardCardsTransactionsList } from '@accounter/modern-poalim-scraper';
5
+ import type { SourceType } from '../shared/source-types.js';
6
+ import { ClientMessageSchema, type ServerMessage } from '../shared/ws-protocol.js';
7
+ import { registerDiscoveredAccounts } from './account-discovery.js';
8
+ import { checkAccounts, type ValidatedPayload } from './check-accounts.js';
9
+ import { filterPayload, type FilterableCreds } from './filter-payload.js';
10
+ import { ChangedTransaction, InsertedTransactionSummary } from './gql/index.js';
11
+ import { createUploadClient, ScraperUploadResult, type UploadClient } from './graphql/client.js';
12
+ import { appendRun } from './history.js';
13
+ import { OtpManager } from './otp-manager.js';
14
+ import type { CalPayload } from './payload-schemas/cal.schema.js';
15
+ import type { DiscountPayload } from './payload-schemas/discount.schema.js';
16
+ import type { MaxPayload } from './payload-schemas/max.schema.js';
17
+ import type { PoalimIlsPayload } from './payload-schemas/poalim-ils.schema.js';
18
+ import { BlockedError, ERR_RUN_IN_PROGRESS, startRun, type ScrapeTask } from './scrape-runner.js';
19
+ import { scrapeAmex } from './scrapers/amex.js';
20
+ import { scrapeCal } from './scrapers/cal.js';
21
+ import { scrapeCurrencyRates } from './scrapers/currency-rates.js';
22
+ import { scrapeDiscount } from './scrapers/discount.js';
23
+ import { scrapeIsracard } from './scrapers/isracard.js';
24
+ import { scrapeMax } from './scrapers/max.js';
25
+ import { scrapePoalim } from './scrapers/poalim.js';
26
+ import { getVault, isLocked } from './vault-store.js';
27
+ import type { Vault } from './vault.js';
28
+
29
+ function send(socket: WebSocket, msg: ServerMessage): void {
30
+ socket.send(JSON.stringify(msg));
31
+ }
32
+
33
+ type SourceRef = { id: string; type: SourceType };
34
+
35
+ function collectSourceRefs(vault: Vault): SourceRef[] {
36
+ return [
37
+ ...vault.poalimAccounts.map(a => ({ id: a.id, type: 'poalim' as const })),
38
+ ...vault.discountAccounts.map(a => ({ id: a.id, type: 'discount' as const })),
39
+ ...vault.isracardAccounts.map(a => ({ id: a.id, type: 'isracard' as const })),
40
+ ...vault.amexAccounts.map(a => ({ id: a.id, type: 'amex' as const })),
41
+ ...vault.calAccounts.map(a => ({ id: a.id, type: 'cal' as const })),
42
+ ...vault.maxAccounts.map(a => ({ id: a.id, type: 'max' as const })),
43
+ ];
44
+ }
45
+
46
+ function buildTask(
47
+ src: SourceRef,
48
+ vault: Vault,
49
+ emit: (msg: ServerMessage) => void,
50
+ otpManager: OtpManager,
51
+ uploadClient: UploadClient | null,
52
+ dateFrom: Date,
53
+ dateTo: Date,
54
+ ): ScrapeTask {
55
+ return {
56
+ sourceId: src.id,
57
+ nickname: src.id,
58
+ type: src.type,
59
+ run: async () => {
60
+ if (src.type === 'poalim') {
61
+ const creds = vault.poalimAccounts.find(a => a.id === src.id);
62
+ if (!creds) throw new Error(`Poalim account ${src.id} not found in vault`);
63
+ const headless = !vault.settings.showBrowser;
64
+ const { ils, foreign, swift } = await scrapePoalim(
65
+ creds,
66
+ dateFrom,
67
+ dateTo,
68
+ headless,
69
+ otpManager,
70
+ emit,
71
+ );
72
+
73
+ // ILS, Foreign, and Swift arrays are positionally aligned per account (same index = same account).
74
+ // ILS payloads carry account/branch identifiers; Foreign and Swift do not.
75
+ // Filter ILS first — filterPayload zeros its transactions array when the account is excluded.
76
+ // Use that result to gate Foreign/Swift at the same index.
77
+ const filteredIls = ils.map(p => filterPayload('poalim', p, creds) as PoalimIlsPayload);
78
+
79
+ // Register any newly discovered accounts as pending, then re-read to avoid stale closure
80
+ await registerDiscoveredAccounts('poalim', src.id, filteredIls);
81
+ const currentAccountRecords = getVault().accountRecords;
82
+
83
+ // Check for unknown accounts across all ILS payloads (ILS carries account identifiers)
84
+ const allUnknown: string[] = [];
85
+ for (const p of filteredIls) {
86
+ const check = checkAccounts('poalim', p, currentAccountRecords);
87
+ allUnknown.push(...check.unknown);
88
+ }
89
+ if (allUnknown.length > 0) {
90
+ emit({
91
+ type: 'task-blocked',
92
+ sourceId: src.id,
93
+ sourceType: src.type,
94
+ unknownAccounts: [...new Set(allUnknown)],
95
+ });
96
+ throw new BlockedError([...new Set(allUnknown)]);
97
+ }
98
+
99
+ if (!uploadClient)
100
+ return {
101
+ inserted: 0,
102
+ skipped: 0,
103
+ insertedIds: [],
104
+ insertedTransactions: [],
105
+ changedTransactions: [],
106
+ };
107
+
108
+ let totalInserted = 0;
109
+ let totalSkipped = 0;
110
+ const allIds: string[] = [];
111
+
112
+ for (let i = 0; i < filteredIls.length; i++) {
113
+ const ilsPayload = filteredIls[i]!;
114
+ // An empty transactions array means filterPayload excluded this account — skip all sub-types.
115
+ const accountExcluded = ilsPayload.transactions?.length === 0;
116
+
117
+ const r = await uploadClient.uploadPoalimIls(ilsPayload);
118
+ totalInserted += r.inserted;
119
+ totalSkipped += r.skipped;
120
+ allIds.push(...r.insertedIds);
121
+
122
+ if (!accountExcluded) {
123
+ if (foreign[i]) {
124
+ const rf = await uploadClient.uploadPoalimForeign(foreign[i]!);
125
+ totalInserted += rf.inserted;
126
+ totalSkipped += rf.skipped;
127
+ allIds.push(...rf.insertedIds);
128
+ }
129
+ if (swift[i]) {
130
+ const rs = await uploadClient.uploadPoalimSwift(swift[i]!);
131
+ totalInserted += rs.inserted;
132
+ totalSkipped += rs.skipped;
133
+ allIds.push(...rs.insertedIds);
134
+ }
135
+ }
136
+ }
137
+ return {
138
+ inserted: totalInserted,
139
+ skipped: totalSkipped,
140
+ insertedIds: allIds,
141
+ insertedTransactions: [],
142
+ changedTransactions: [],
143
+ };
144
+ }
145
+
146
+ // isracard/amex return one payload per month; other scrapers return a single payload
147
+ let payloads: ValidatedPayload[];
148
+ switch (src.type) {
149
+ case 'isracard': {
150
+ const creds = vault.isracardAccounts.find(a => a.id === src.id);
151
+ if (!creds) throw new Error(`Isracard account ${src.id} not found in vault`);
152
+ payloads = await scrapeIsracard(creds, dateFrom, dateTo, emit);
153
+ break;
154
+ }
155
+ case 'amex': {
156
+ const creds = vault.amexAccounts.find(a => a.id === src.id);
157
+ if (!creds) throw new Error(`Amex account ${src.id} not found in vault`);
158
+ payloads = await scrapeAmex(creds, dateFrom, dateTo, emit);
159
+ break;
160
+ }
161
+ case 'cal': {
162
+ const creds = vault.calAccounts.find(a => a.id === src.id);
163
+ if (!creds) throw new Error(`Cal account ${src.id} not found in vault`);
164
+ payloads = [await scrapeCal(creds, dateFrom, dateTo, emit)];
165
+ break;
166
+ }
167
+ case 'discount': {
168
+ const creds = vault.discountAccounts.find(a => a.id === src.id);
169
+ if (!creds) throw new Error(`Discount account ${src.id} not found in vault`);
170
+ payloads = [await scrapeDiscount(creds, dateFrom, dateTo, emit)];
171
+ break;
172
+ }
173
+ case 'max': {
174
+ const creds = vault.maxAccounts.find(a => a.id === src.id);
175
+ if (!creds) throw new Error(`Max account ${src.id} not found in vault`);
176
+ payloads = [await scrapeMax(creds, dateFrom, dateTo, emit)];
177
+ break;
178
+ }
179
+ default:
180
+ throw new Error(`Unhandled source type: ${src.type}`);
181
+ }
182
+
183
+ // Use the first non-empty payload to check for unknown accounts (card identifiers
184
+ // are stable across months for isracard/amex, so any month suffices)
185
+ const representativePayload = payloads[0];
186
+ if (!representativePayload)
187
+ return {
188
+ inserted: 0,
189
+ skipped: 0,
190
+ insertedIds: [],
191
+ insertedTransactions: [],
192
+ changedTransactions: [],
193
+ };
194
+
195
+ // Apply per-source accepted/ignored filter after validation, before account check
196
+ let creds: FilterableCreds | undefined;
197
+ switch (src.type) {
198
+ case 'isracard':
199
+ creds = vault.isracardAccounts.find(a => a.id === src.id);
200
+ break;
201
+ case 'amex':
202
+ creds = vault.amexAccounts.find(a => a.id === src.id);
203
+ break;
204
+ case 'cal':
205
+ creds = vault.calAccounts.find(a => a.id === src.id);
206
+ break;
207
+ case 'max':
208
+ creds = vault.maxAccounts.find(a => a.id === src.id);
209
+ break;
210
+ case 'discount':
211
+ creds = vault.discountAccounts.find(a => a.id === src.id);
212
+ break;
213
+ }
214
+
215
+ if (creds) {
216
+ payloads = payloads.map(p => filterPayload(src.type, p, creds!) as ValidatedPayload);
217
+ }
218
+
219
+ // Register any newly discovered accounts as pending, then re-read to avoid stale closure
220
+ await registerDiscoveredAccounts(src.type, src.id, payloads);
221
+ const check = checkAccounts(src.type, payloads[0]!, getVault().accountRecords);
222
+ if (check.unknown.length > 0) {
223
+ emit({
224
+ type: 'task-blocked',
225
+ sourceId: src.id,
226
+ sourceType: src.type,
227
+ unknownAccounts: check.unknown,
228
+ });
229
+ throw new BlockedError(check.unknown);
230
+ }
231
+
232
+ if (!uploadClient)
233
+ return {
234
+ inserted: 0,
235
+ skipped: 0,
236
+ insertedIds: [],
237
+ insertedTransactions: [],
238
+ changedTransactions: [],
239
+ };
240
+
241
+ // Upload all accepted payloads
242
+ let totalInserted = 0;
243
+ let totalSkipped = 0;
244
+ const allIds: string[] = [];
245
+ const changedTransactions: ChangedTransaction[] = [];
246
+ const insertedTransactions: InsertedTransactionSummary[] = [];
247
+
248
+ if (src.type === 'isracard' || src.type === 'amex') {
249
+ let result: ScraperUploadResult;
250
+ switch (src.type) {
251
+ case 'isracard':
252
+ result = await uploadClient.uploadIsracard(payloads as IsracardCardsTransactionsList[]);
253
+ break;
254
+ case 'amex':
255
+ result = await uploadClient.uploadAmex(payloads as IsracardCardsTransactionsList[]);
256
+ break;
257
+ }
258
+ totalInserted += result.inserted;
259
+ totalSkipped += result.skipped;
260
+ allIds.push(...result.insertedIds);
261
+ changedTransactions.push(...(result.changedTransactions ?? []));
262
+ insertedTransactions.push(...(result.insertedTransactions ?? []));
263
+ } else {
264
+ for (const payload of payloads) {
265
+ let result: ScraperUploadResult;
266
+ switch (src.type) {
267
+ case 'cal':
268
+ result = await uploadClient.uploadCal(payload as CalPayload);
269
+ break;
270
+ case 'discount':
271
+ result = await uploadClient.uploadDiscount(payload as DiscountPayload);
272
+ break;
273
+ case 'max':
274
+ result = await uploadClient.uploadMax(payload as MaxPayload);
275
+ break;
276
+ default:
277
+ result = {
278
+ inserted: 0,
279
+ skipped: 0,
280
+ insertedIds: [],
281
+ changedTransactions: [],
282
+ insertedTransactions: [],
283
+ };
284
+ }
285
+ totalInserted += result.inserted;
286
+ totalSkipped += result.skipped;
287
+ allIds.push(...result.insertedIds);
288
+ }
289
+ }
290
+
291
+ return {
292
+ inserted: totalInserted,
293
+ skipped: totalSkipped,
294
+ insertedIds: allIds,
295
+ changedTransactions,
296
+ insertedTransactions,
297
+ };
298
+ },
299
+ };
300
+ }
301
+
302
+ export async function registerWebSocketRoute(app: FastifyInstance): Promise<void> {
303
+ await app.register(websocketPlugin);
304
+
305
+ app.get(
306
+ '/ws',
307
+ {
308
+ websocket: true,
309
+ preValidation: async (_req, reply) => {
310
+ if (isLocked()) {
311
+ await reply.status(401).send({ error: 'vault-locked' });
312
+ }
313
+ },
314
+ },
315
+ (socket: WebSocket, _req) => {
316
+ socket.on('error', err => app.log.error({ err }, '[ws] socket error'));
317
+ send(socket, { type: 'connected' });
318
+
319
+ const emit = (msg: ServerMessage) => send(socket, msg);
320
+ let activeOtpManager: OtpManager | null = null;
321
+
322
+ socket.on('message', (raw: RawData) => {
323
+ let parsed: unknown;
324
+ try {
325
+ parsed = JSON.parse(raw.toString());
326
+ } catch {
327
+ app.log.warn('[ws] non-JSON message received');
328
+ return;
329
+ }
330
+
331
+ const result = ClientMessageSchema.safeParse(parsed);
332
+ if (!result.success) {
333
+ app.log.warn({ received: parsed }, '[ws] unknown message type');
334
+ send(socket, { type: 'error', message: 'Unknown message type' });
335
+ return;
336
+ }
337
+
338
+ const msg = result.data;
339
+ switch (msg.type) {
340
+ case 'ping':
341
+ send(socket, { type: 'pong' });
342
+ break;
343
+ case 'run-start': {
344
+ let vault: Vault;
345
+ try {
346
+ vault = getVault();
347
+ } catch (e) {
348
+ send(socket, { type: 'error', message: String(e) });
349
+ break;
350
+ }
351
+
352
+ const allRefs = collectSourceRefs(vault);
353
+ const sourceIds = msg.sourceIds;
354
+ const sources = sourceIds ? allRefs.filter(s => sourceIds.includes(s.id)) : allRefs;
355
+
356
+ if (sources.length === 0 && !vault.settings.fetchBankOfIsraelRates) {
357
+ send(socket, { type: 'run-error', message: 'No matching sources found' });
358
+ break;
359
+ }
360
+
361
+ if (activeOtpManager) {
362
+ send(socket, { type: 'run-error', message: ERR_RUN_IN_PROGRESS });
363
+ break;
364
+ }
365
+
366
+ // Resolve date range: message overrides settings default
367
+ const runDateTo = msg.dateTo ? new Date(msg.dateTo) : new Date();
368
+ const months = vault.settings.defaultDateRangeMonths ?? 3;
369
+ const runDateFrom = msg.dateFrom
370
+ ? new Date(msg.dateFrom)
371
+ : new Date(
372
+ runDateTo.getFullYear(),
373
+ runDateTo.getMonth() - months,
374
+ runDateTo.getDate(),
375
+ );
376
+
377
+ activeOtpManager = new OtpManager();
378
+ const uploadClient =
379
+ vault.settings.serverUrl && vault.settings.apiKey
380
+ ? createUploadClient(vault.settings.serverUrl, vault.settings.apiKey)
381
+ : null;
382
+ const tasks: ScrapeTask[] = sources.map(src =>
383
+ buildTask(src, vault, emit, activeOtpManager!, uploadClient, runDateFrom, runDateTo),
384
+ );
385
+
386
+ // Append currency-rates task if enabled
387
+ if (vault.settings.fetchBankOfIsraelRates) {
388
+ tasks.push({
389
+ sourceId: 'currency-rates',
390
+ nickname: 'Currency Rates (Bank of Israel)',
391
+ type: 'currency-rates',
392
+ run: async () => {
393
+ const payload = await scrapeCurrencyRates(emit);
394
+ if (!uploadClient)
395
+ return {
396
+ inserted: 0,
397
+ skipped: 0,
398
+ insertedIds: [],
399
+ changedTransactions: [],
400
+ insertedTransactions: [],
401
+ };
402
+ return uploadClient.uploadCurrencyRates(payload);
403
+ },
404
+ });
405
+ }
406
+
407
+ const { saveHistory, historyFilePath } = vault.settings;
408
+ void startRun(tasks, vault.settings.concurrentScraping, emit)
409
+ .then(async runRecord => {
410
+ if (saveHistory) {
411
+ try {
412
+ await appendRun(
413
+ {
414
+ ...runRecord,
415
+ startedAt: runRecord.startedAt.toISOString(),
416
+ completedAt: runRecord.completedAt.toISOString(),
417
+ },
418
+ historyFilePath,
419
+ );
420
+ } catch (err) {
421
+ app.log.error(err, '[ws] failed to append run history');
422
+ }
423
+ }
424
+ })
425
+ .catch((err: unknown) => {
426
+ const message = err instanceof Error ? err.message : String(err);
427
+ if (message === ERR_RUN_IN_PROGRESS) {
428
+ send(socket, { type: 'run-error', message });
429
+ } else {
430
+ app.log.error(err, '[ws] startRun error');
431
+ send(socket, { type: 'error', message });
432
+ }
433
+ })
434
+ .finally(() => {
435
+ activeOtpManager = null;
436
+ });
437
+ break;
438
+ }
439
+ case 'cancel-scrape':
440
+ // TODO: cancel in-progress scrape
441
+ break;
442
+ case 'otp-submit':
443
+ activeOtpManager?.submitOtp(msg.sourceId, msg.otp);
444
+ app.log.info({ sourceId: msg.sourceId }, '[ws] otp-submit received');
445
+ break;
446
+ }
447
+ });
448
+
449
+ socket.on('close', () => {
450
+ app.log.info('[ws] client disconnected');
451
+ });
452
+ },
453
+ );
454
+ }
@@ -0,0 +1,10 @@
1
+ export const SOURCE_TYPES = [
2
+ 'poalim',
3
+ 'discount',
4
+ 'isracard',
5
+ 'amex',
6
+ 'cal',
7
+ 'max',
8
+ ] as const satisfies [string, ...string[]];
9
+
10
+ export type SourceType = (typeof SOURCE_TYPES)[number];
@@ -0,0 +1,20 @@
1
+ export type SourceRunRecord = {
2
+ sourceId: string;
3
+ nickname: string;
4
+ sourceType: string;
5
+ status: 'done' | 'error' | 'blocked';
6
+ inserted: number;
7
+ skipped: number;
8
+ error?: string;
9
+ blockedAccounts?: string[];
10
+ };
11
+
12
+ export type RunRecord = {
13
+ id: string;
14
+ startedAt: string; // ISO string for JSON serialization
15
+ completedAt: string;
16
+ totalInserted: number;
17
+ totalSkipped: number;
18
+ errorCount: number;
19
+ sources: SourceRunRecord[];
20
+ };
@@ -0,0 +1,177 @@
1
+ import { z } from 'zod';
2
+ import { SOURCE_TYPES } from './source-types.js';
3
+
4
+ // ── Client → Server ──────────────────────────────────────────────────────────
5
+
6
+ export const CancelScrapeSchema = z.object({
7
+ type: z.literal('cancel-scrape'),
8
+ });
9
+
10
+ export const PingSchema = z.object({
11
+ type: z.literal('ping'),
12
+ });
13
+
14
+ export const RunStartSchema = z.object({
15
+ type: z.literal('run-start'),
16
+ sourceIds: z.array(z.string()).optional(),
17
+ dateFrom: z.string().optional(),
18
+ dateTo: z.string().optional(),
19
+ });
20
+
21
+ export type RunStartMessage = z.infer<typeof RunStartSchema>;
22
+
23
+ export const OtpSubmitSchema = z.object({
24
+ type: z.literal('otp-submit'),
25
+ sourceId: z.string(),
26
+ otp: z.string(),
27
+ });
28
+
29
+ export type OtpSubmitMessage = z.infer<typeof OtpSubmitSchema>;
30
+
31
+ export const ClientMessageSchema = z.discriminatedUnion('type', [
32
+ CancelScrapeSchema,
33
+ PingSchema,
34
+ RunStartSchema,
35
+ OtpSubmitSchema,
36
+ ]);
37
+
38
+ export type ClientMessage = z.infer<typeof ClientMessageSchema>;
39
+
40
+ // ── Server → Client ──────────────────────────────────────────────────────────
41
+
42
+ export const ConnectedSchema = z.object({
43
+ type: z.literal('connected'),
44
+ });
45
+
46
+ export type ConnectedMessage = z.infer<typeof ConnectedSchema>;
47
+
48
+ export const ScrapeStartedSchema = z.object({
49
+ type: z.literal('scrape-started'),
50
+ sourceIds: z.array(z.string()),
51
+ });
52
+
53
+ export const ScrapeProgressSchema = z.object({
54
+ type: z.literal('scrape-progress'),
55
+ sourceId: z.string(),
56
+ sourceType: z.enum(SOURCE_TYPES),
57
+ status: z.enum(['running', 'done', 'error', 'blocked']),
58
+ error: z.string().optional(),
59
+ });
60
+
61
+ export const ScrapeCompleteSchema = z.object({
62
+ type: z.literal('scrape-complete'),
63
+ totalTransactions: z.number(),
64
+ });
65
+
66
+ export const PongSchema = z.object({
67
+ type: z.literal('pong'),
68
+ });
69
+
70
+ export const WsErrorSchema = z.object({
71
+ type: z.literal('error'),
72
+ message: z.string(),
73
+ });
74
+
75
+ export const TaskPendingSchema = z.object({
76
+ type: z.literal('task-pending'),
77
+ sourceId: z.string(),
78
+ });
79
+
80
+ export type TaskPendingMessage = z.infer<typeof TaskPendingSchema>;
81
+
82
+ export const TaskRunningSchema = z.object({
83
+ type: z.literal('task-running'),
84
+ sourceId: z.string(),
85
+ });
86
+
87
+ export type TaskRunningMessage = z.infer<typeof TaskRunningSchema>;
88
+
89
+ const InsertedTransactionSummarySchema = z.object({
90
+ id: z.string(),
91
+ date: z.string().nullable().optional(),
92
+ description: z.string().nullable().optional(),
93
+ amount: z.string().nullable().optional(),
94
+ account: z.string().nullable().optional(),
95
+ });
96
+
97
+ const ChangedFieldSchema = z.object({
98
+ field: z.string(),
99
+ oldValue: z.string().nullable().optional(),
100
+ newValue: z.string().nullable().optional(),
101
+ });
102
+
103
+ const ChangedTransactionSchema = z.object({
104
+ id: z.string(),
105
+ changedFields: z.array(ChangedFieldSchema),
106
+ });
107
+
108
+ export const TaskDoneSchema = z.object({
109
+ type: z.literal('task-done'),
110
+ sourceId: z.string(),
111
+ inserted: z.number(),
112
+ skipped: z.number(),
113
+ insertedIds: z.array(z.string()),
114
+ insertedTransactions: z.array(InsertedTransactionSummarySchema).optional(),
115
+ changedTransactions: z.array(ChangedTransactionSchema).optional(),
116
+ });
117
+
118
+ export type TaskDoneMessage = z.infer<typeof TaskDoneSchema>;
119
+
120
+ export const TaskErrorSchema = z.object({
121
+ type: z.literal('task-error'),
122
+ sourceId: z.string(),
123
+ message: z.string(),
124
+ stack: z.string().optional(),
125
+ });
126
+
127
+ export type TaskErrorMessage = z.infer<typeof TaskErrorSchema>;
128
+
129
+ export const TaskBlockedSchema = z.object({
130
+ type: z.literal('task-blocked'),
131
+ sourceId: z.string(),
132
+ sourceType: z.enum(SOURCE_TYPES),
133
+ unknownAccounts: z.array(z.string()),
134
+ });
135
+
136
+ export type TaskBlockedMessage = z.infer<typeof TaskBlockedSchema>;
137
+
138
+ export const OtpRequiredSchema = z.object({
139
+ type: z.literal('otp-required'),
140
+ sourceId: z.string(),
141
+ prompt: z.string().optional(),
142
+ });
143
+
144
+ export type OtpRequiredMessage = z.infer<typeof OtpRequiredSchema>;
145
+
146
+ export const RunCompleteSchema = z.object({
147
+ type: z.literal('run-complete'),
148
+ totalInserted: z.number(),
149
+ totalSkipped: z.number(),
150
+ errors: z.number(),
151
+ });
152
+
153
+ export type RunCompleteMessage = z.infer<typeof RunCompleteSchema>;
154
+
155
+ export const RunErrorSchema = z.object({
156
+ type: z.literal('run-error'),
157
+ message: z.string(),
158
+ });
159
+
160
+ export const ServerMessageSchema = z.discriminatedUnion('type', [
161
+ ConnectedSchema,
162
+ ScrapeStartedSchema,
163
+ ScrapeProgressSchema,
164
+ ScrapeCompleteSchema,
165
+ PongSchema,
166
+ WsErrorSchema,
167
+ TaskPendingSchema,
168
+ TaskRunningSchema,
169
+ TaskDoneSchema,
170
+ TaskErrorSchema,
171
+ TaskBlockedSchema,
172
+ OtpRequiredSchema,
173
+ RunCompleteSchema,
174
+ RunErrorSchema,
175
+ ]);
176
+
177
+ export type ServerMessage = z.infer<typeof ServerMessageSchema>;
@@ -0,0 +1,6 @@
1
+ import { afterEach } from 'vitest';
2
+ import { cleanup } from '@testing-library/react';
3
+
4
+ afterEach(() => {
5
+ cleanup();
6
+ });