diff --git a/.pull_request/0001-feat-implement-Redis-pub-sub-for-real-time-notificat.patch b/.pull_request/0001-feat-implement-Redis-pub-sub-for-real-time-notificat.patch new file mode 100644 index 00000000..99e899c8 --- /dev/null +++ b/.pull_request/0001-feat-implement-Redis-pub-sub-for-real-time-notificat.patch @@ -0,0 +1,171 @@ +From c2324c74ceb8da6ae9cde5355d981cf872650bee Mon Sep 17 00:00:00 2001 +From: Martin +Date: Fri, 29 May 2026 16:09:30 +0100 +Subject: [PATCH 1/4] feat: implement Redis pub/sub for real-time notifications + +--- + src/jobs/scheduler.ts | 8 +++ + src/models/transaction.ts | 2 +- + src/workers/notificationWorker.ts | 113 ++++++++++++++++++++++++++++++ + 3 files changed, 122 insertions(+), 1 deletion(-) + create mode 100644 src/workers/notificationWorker.ts + +diff --git a/src/jobs/scheduler.ts b/src/jobs/scheduler.ts +index 3fff24b..6186211 100644 +--- a/src/jobs/scheduler.ts ++++ b/src/jobs/scheduler.ts +@@ -17,6 +17,7 @@ import { runLiquidityRebalanceJob } from "./liquidityRebalanceJob"; + import { runCrossChainMonitorJob } from "./crossChainMonitorJob"; + import { runDailyProviderReconciliation } from "./providerReconciliationJob"; + import { runReconciliationJob } from "./reconciliationJob"; ++import { startNotificationWorker } from "../workers/notificationWorker"; + + + interface JobConfig { +@@ -128,4 +129,11 @@ export function startJobs(): void { + cron.schedule(job.schedule, () => runJob(job)); + console.log(`[scheduler] "${job.name}" scheduled - ${job.schedule}`); + } ++ ++ // Start the notification worker which listens for Redis pub/sub events ++ // and drives user-facing notifications in real-time. This replaces any ++ // DB-polling notification mechanisms. ++ startNotificationWorker().catch((err) => { ++ console.warn("Failed to start NotificationWorker:", err); ++ }); + } +diff --git a/src/models/transaction.ts b/src/models/transaction.ts +index bfbd273..00d5e31 100644 +--- a/src/models/transaction.ts ++++ b/src/models/transaction.ts +@@ -195,7 +195,7 @@ export class TransactionModel { + const res = await queryWrite(q, params); + if (!res.rowCount) return; + +- const row = result.rows[0]; ++ const row = res.rows[0]; + + // ── Invalidate caches on transaction status update ──────────────────── + if (row.user_id) { +diff --git a/src/workers/notificationWorker.ts b/src/workers/notificationWorker.ts +new file mode 100644 +index 0000000..59050b4 +--- /dev/null ++++ b/src/workers/notificationWorker.ts +@@ -0,0 +1,113 @@ ++import IORedis from "ioredis"; ++import { SubscriptionChannels } from "../graphql/subscriptions"; ++import { notificationRouter } from "../services/notificationRouter"; ++import { TransactionModel } from "../models/transaction"; ++ ++const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"; ++ ++const redisOptions: any = { ++ retryStrategy: (times: number) => Math.min(100 + times * 200, 3000), ++ enableOfflineQueue: false, ++ maxRetriesPerRequest: 1, ++ lazyConnect: false, ++}; ++ ++let subscriber: IORedis | null = null; ++ ++/** ++ * Notification worker — subscribes to transaction update channels in Redis ++ * and routes user-facing notifications (email/sms/push/etc.) via ++ * `NotificationRouter`. This replaces DB polling for notification triggers. ++ */ ++export async function startNotificationWorker(): Promise { ++ if (!process.env.REDIS_URL) { ++ console.warn( ++ "NotificationWorker: REDIS_URL not set — running without Redis subscription", ++ ); ++ return; ++ } ++ ++ subscriber = new IORedis(REDIS_URL, redisOptions); ++ ++ subscriber.on("connect", () => console.log("NotificationWorker: Redis connected")); ++ subscriber.on("error", (err) => ++ console.error("NotificationWorker: Redis error:", err), ++ ); ++ ++ await subscriber.connect(); ++ ++ // Subscribe to broadcast updates and per-transaction channels (pattern) ++ await subscriber.subscribe(SubscriptionChannels.TRANSACTION_UPDATED); ++ await subscriber.psubscribe("TRANSACTION_UPDATED:*"); ++ ++ subscriber.on("message", async (_channel: string, rawMessage: string) => { ++ try { ++ const payload = JSON.parse(rawMessage) as { ++ id?: string; ++ status?: string; ++ [key: string]: any; ++ }; ++ ++ const txId = payload.id; ++ const status = payload.status; ++ if (!txId || !status) return; ++ ++ const txModel = new TransactionModel(); ++ const tx = await txModel.findById(txId); ++ if (!tx) return; ++ ++ if (status === "completed") { ++ await notificationRouter.routeTransactionNotification(tx, "completed"); ++ } else if (status === "failed") { ++ await notificationRouter.routeTransactionNotification(tx, "failed", payload.error); ++ } ++ } catch (err) { ++ console.error("NotificationWorker: failed to handle message:", err); ++ } ++ }); ++ ++ // pmessage handles pattern subscriptions (TRANSACTION_UPDATED:) ++ subscriber.on( ++ "pmessage", ++ async (_pattern: string, _channel: string, rawMessage: string) => { ++ try { ++ const payload = JSON.parse(rawMessage) as { ++ id?: string; ++ status?: string; ++ [key: string]: any; ++ }; ++ ++ const txId = payload.id; ++ const status = payload.status; ++ if (!txId || !status) return; ++ ++ const txModel = new TransactionModel(); ++ const tx = await txModel.findById(txId); ++ if (!tx) return; ++ ++ if (status === "completed") { ++ await notificationRouter.routeTransactionNotification(tx, "completed"); ++ } else if (status === "failed") { ++ await notificationRouter.routeTransactionNotification(tx, "failed", payload.error); ++ } ++ } catch (err) { ++ console.error("NotificationWorker: failed to handle pmessage:", err); ++ } ++ }, ++ ); ++ ++ console.log("NotificationWorker: subscribed to transaction update channels"); ++} ++ ++export async function stopNotificationWorker(): Promise { ++ try { ++ if (!subscriber) return; ++ await subscriber.unsubscribe(SubscriptionChannels.TRANSACTION_UPDATED); ++ await subscriber.punsubscribe("TRANSACTION_UPDATED:*"); ++ await subscriber.quit(); ++ subscriber = null; ++ console.log("NotificationWorker: stopped"); ++ } catch (err) { ++ console.warn("NotificationWorker: stop error:", err); ++ } ++} +-- +2.45.1.windows.1 + diff --git a/.pull_request/0002-chore-pr-add-PR-bundle-patch-description.patch b/.pull_request/0002-chore-pr-add-PR-bundle-patch-description.patch new file mode 100644 index 00000000..5520dd0f --- /dev/null +++ b/.pull_request/0002-chore-pr-add-PR-bundle-patch-description.patch @@ -0,0 +1,103 @@ +From 44ee883a769411489145c35d82c0da042411f2bf Mon Sep 17 00:00:00 2001 +From: Martin +Date: Fri, 29 May 2026 16:15:22 +0100 +Subject: [PATCH 2/4] chore(pr): add PR bundle (patch + description) + +--- + .pull_request/PR.md | 21 +++++++++++++++++++++ + .pull_request/changes.patch | Bin 0 -> 11444 bytes + 2 files changed, 21 insertions(+) + create mode 100644 .pull_request/PR.md + create mode 100644 .pull_request/changes.patch + +diff --git a/.pull_request/PR.md b/.pull_request/PR.md +new file mode 100644 +index 0000000..bc020e1 +--- /dev/null ++++ b/.pull_request/PR.md +@@ -0,0 +1,21 @@ ++# Implement Redis Pub/Sub real-time notification worker ++ ++Adds a Redis-backed notification worker that subscribes to transaction update channels and routes user notifications via the existing NotificationRouter. Wires the worker into the job scheduler and fixes a bug in TransactionModel.updateStatus. ++ ++## Files changed ++- src/workers/notificationWorker.ts (new) ++- src/jobs/scheduler.ts (start worker) ++- src/models/transaction.ts (bugfix) ++ ++## Testing notes ++1. Ensure `REDIS_URL` points to a running Redis instance. ++2. Start the app: `npm run dev` ++3. Publish test messages to Redis, e.g.: ++ `redis-cli PUBLISH transaction.updated '{"id":"","status":"completed"}'` ++ ++## Acceptance criteria ++- Worker subscribes to transaction channels and routes notifications via `NotificationRouter`. ++- Replaces DB polling for notifications, lowers DB load, and enables sub-second notifications. ++ ++## Patch apply (git) ++Apply the patch locally: `git apply .pull_request/changes.patch` +diff --git a/.pull_request/changes.patch b/.pull_request/changes.patch +new file mode 100644 +index 0000000000000000000000000000000000000000..49e17b48233105c6672a61ca043d2781196774ea +GIT binary patch +literal 11444 +zcmd6t+j1Mn5r(JAcd5!dtXR2#B?_cSTZ$Dsu|p+Qa#@x`Dy0jTD*{3A;E*5;P?Q;) +zPvM918~HNH|4m~!GrP0Ef{q;57O|(9K27)E-Lw49zYpArJJeCR`#O$vccJTn+i){C +zbwi!McIR$icRU%oV>faK?zKDBdFuKaJ=6I8mB#^OV598Wy&Q`zDR +zPxDgz@rbFn&Cp9U)H8G+$Yv-0N!dP=6)2c?%jIK*+N4-yq3P0jf%iS>R9~Z+G#yBr +z3-`J7Me9TT4HFBz)bpwPr?*te8u-hbWDJoz?m#O@Rn7f}Xu*rdl7dw^lnnT3HTRDl +z(Hwi}j`Zw1KP$$_mg09^(P~k$lb7Vz3@GYHDA9F2Uxm?$N4erf< +zk#s{x<@RoDS?LM{uf)IXT~+2g+#P%WJJuW`k#!k)%|R(VvGVZ>u8|2QT1m?WoGXpJ +z^yA4BhQCoRkPl4{MCHVfgTiZ##NY1EJq{1V!`dWf4r5rmk*s>EnZym&?d$0auK}ZY +zKhb;hW1b+vRYR^P?$4rucbZd57nLy>@iKe +z)S~U4G_b>PDthA^$ugBRJ$ct(bq$J=w^{#l{YHtZPB`;ebcmE7hed<&zfQc+JaBnU +zaV9Ci`cp}=A(^J$b~F7(3@FtW-RI$Jctxl&_T^3cy4zCr-j;p)zM?pA?@NkpAyc;+ +zV>SMzT4OZ|5l=smr0bI1@HF0)&~zxf4rEz8{6*2W&u;8rLQz9D>Odj}Dj(@@AS}R> +zcup;YFI>5o`sLY3zV?mxk%>mJo8PxvbN48*-Nr`^~2T$ITsR7w}#%i(*Wdwe(` +zFDnWr<{ocSLcydoiK$O7w8lnzCQRNG2B%&Nl@K*$8s*s`I|exnVSjk%C-7dzI>w*taZGn}bx?xlk8B$!KFNp`Z^1f=FP5o{PZ}M(s!=urq +zugCDv(|L{nC-RQu=jjqqV^i_CDa!OIR();jj<;xBuRfo;=UFCCdk|`GBFnOTs1)lI +z=y&$y33Q9pVkh$3P`B^Q^CNZ!_1h}fBh4FV-m%c+RF;~x(3>eEg?MgO(QUQDu16WG +zXWJyO>Vs~CYIjlfoKjmP&2kKG_IyXtbxO{JYlnGg%!Z +zw{FR_qf()syVSWf{(JYmW~?Qv|5%dIk)C@*JJm{^%Ssoj%^xb0K9ct@k|}!8^Ta!5 +zzOuJ!%w#n5unNPn0oi6xeDDue@JRRc13m9!RC`8cERQ*p4}wOqu7Bod9ZSnItt^$1 +z?I{o)R7F-V;+J>zx8-A+if7wjZHm%*Qqf_tfp~xv&xIL>x`OJ+{Y~6Q{twycnOMD1 +zHv3FZzxA<(HYa*JQxA;1yZW`3}hwK6e +zfo|g@1;GVIjTaS&HPwB^Uc|)xoH8tE!t7v=I+$U&8AbmZ#f{3Cn +z%F&@$&=<69L-#0Kl)1#SFB@3bYIzLqAe?od={`-!b72+3{>v&W7V4SM2?TCDUsKFR +zsqu)#@)AMd$@E56dlE8G)Vt(^aW)-$cb|(IL<;p@J@u2QQIsd`>&iOjYr!hy832vK +zPE%U_ky`?e?|bVEWLf$kvRmHQfcOEICz{9pPaF0((Y_~4penG+ZY&8`(XXj>uX7#S +zSBJgpKGB-(>32tWM9SyB;`&0>*C%>H6&7uVwU*m_Qt&r?qZ_x!%E)+dbDf&H@)ajU +zomEln!;lj!>Qc<_Nw3`cU>~bVCIQV11<>!AD6j`%wCdwRT;v#!ZJ>3fbBg^u=Lk`c +z_pstu%Dd+}v$F+SeX3Q!`hEYFoW{Nc6=Pi<_@h~w54`Bz|Kw|{RKH-+AW=9o1`j0j +zcc1m8-^aH)Hs$hFQV*9n)@!>U1?SX87pgO#-dHEMSl??{qMlMn^)egRsiY%H)MDO_ +z(pXn26kXO}+RSB#@mBnz}l7Bs!nn9aHNRSV($ +z2dcxayuIm3%7C1mE$Xopvv}EWLgf~G3H#W$ +zK~R}q*eJB=9wH}HO_AR!bw{d4$nW;`~O%IpYlD((NV6Li~LU6(V@oF-}LG0PccdA=_3Fv@9u%Q>%}v*D84r+eN`=cem` +zl()^@&`kCFHRf+*d|NsE|EGVN|17hun#av9d7cQiS|y(|Ct%6j&gu0mR^MkRVM?0W +zO}}Nk5p``=-`8no=Jqx2NVx>4zbScf}t1q^!@Av^FXvsuSC +z?G^W0?Q2UtlKvMl!Pk_Dj?fdPJXYx<>axV$^X~hWWI&_||4+oO=^`` +Date: Fri, 29 May 2026 21:22:31 +0100 +Subject: [PATCH 3/4] feat: add database table partitioning for audit logs and + update related middleware + +--- + .pull_request/PR.md | 21 ------- + .vscode/settings.json | 2 + + .../migrations/20260424_create_audit_logs.sql | 55 +++++++++++++++---- + src/middleware/auditInterceptor.ts | 9 +-- + tsconfig.json | 3 +- + tsconfig.test.json | 7 +++ + 6 files changed, 60 insertions(+), 37 deletions(-) + create mode 100644 tsconfig.test.json + +diff --git a/.pull_request/PR.md b/.pull_request/PR.md +index bc020e1..e69de29 100644 +--- a/.pull_request/PR.md ++++ b/.pull_request/PR.md +@@ -1,21 +0,0 @@ +-# Implement Redis Pub/Sub real-time notification worker +- +-Adds a Redis-backed notification worker that subscribes to transaction update channels and routes user notifications via the existing NotificationRouter. Wires the worker into the job scheduler and fixes a bug in TransactionModel.updateStatus. +- +-## Files changed +-- src/workers/notificationWorker.ts (new) +-- src/jobs/scheduler.ts (start worker) +-- src/models/transaction.ts (bugfix) +- +-## Testing notes +-1. Ensure `REDIS_URL` points to a running Redis instance. +-2. Start the app: `npm run dev` +-3. Publish test messages to Redis, e.g.: +- `redis-cli PUBLISH transaction.updated '{"id":"","status":"completed"}'` +- +-## Acceptance criteria +-- Worker subscribes to transaction channels and routes notifications via `NotificationRouter`. +-- Replaces DB polling for notifications, lowers DB load, and enables sub-second notifications. +- +-## Patch apply (git) +-Apply the patch locally: `git apply .pull_request/changes.patch` +diff --git a/.vscode/settings.json b/.vscode/settings.json +index 4b852bb..6eaf0c5 100644 +--- a/.vscode/settings.json ++++ b/.vscode/settings.json +@@ -1,4 +1,6 @@ + { + "kiroAgent.configureMCP": "Disabled", + "codium.codeCompletion.enable": false ++ ,"typescript.tsdk": "./node_modules/typescript/lib", ++ "typescript.enablePromptUseWorkspaceTsdk": true + } +diff --git a/database/migrations/20260424_create_audit_logs.sql b/database/migrations/20260424_create_audit_logs.sql +index 9bac8db..e553f42 100644 +--- a/database/migrations/20260424_create_audit_logs.sql ++++ b/database/migrations/20260424_create_audit_logs.sql +@@ -1,14 +1,47 @@ ++-- Parent partitioned table: partitioned by month on created_at + CREATE TABLE audit_logs ( +- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), +- admin_id UUID NOT NULL REFERENCES users(id), +- action VARCHAR(255) NOT NULL, +- resource VARCHAR(255) NOT NULL, +- resource_id VARCHAR(255), +- diff JSONB NOT NULL, +- ip_address VARCHAR(45), +- user_agent TEXT, +- created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +-); ++ id UUID PRIMARY KEY DEFAULT gen_random_uuid(), ++ admin_id UUID NOT NULL REFERENCES users(id), ++ action VARCHAR(255) NOT NULL, ++ resource VARCHAR(255) NOT NULL, ++ resource_id VARCHAR(255), ++ diff JSONB NOT NULL, ++ ip_address VARCHAR(45), ++ user_agent TEXT, ++ created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP ++) PARTITION BY RANGE (created_at); + ++-- Create indexes on the partitioned parent (propagates to partitions) + CREATE INDEX idx_audit_logs_admin_id ON audit_logs(admin_id); +-CREATE INDEX idx_audit_logs_resource ON audit_logs(resource, resource_id); +\ No newline at end of file ++CREATE INDEX idx_audit_logs_resource ON audit_logs(resource, resource_id); ++ ++-- Helper function: create monthly partition if it does not exist ++CREATE OR REPLACE FUNCTION audit_logs_create_monthly_partition() ++RETURNS TRIGGER AS $$ ++DECLARE ++ partition_name TEXT; ++ start_month TIMESTAMP WITH TIME ZONE; ++ end_month TIMESTAMP WITH TIME ZONE; ++ sql TEXT; ++BEGIN ++ start_month := date_trunc('month', NEW.created_at); ++ end_month := (start_month + INTERVAL '1 month'); ++ partition_name := format('audit_logs_%s', to_char(start_month, 'YYYYMM')); ++ ++ sql := format( ++ 'CREATE TABLE IF NOT EXISTS %I PARTITION OF audit_logs FOR VALUES FROM (''%s'') TO (''%s'')', ++ partition_name, ++ start_month, ++ end_month ++ ); ++ ++ EXECUTE sql; ++ RETURN NEW; ++END; ++$$ LANGUAGE plpgsql; ++ ++-- Trigger: ensures appropriate monthly partition exists before insert ++DROP TRIGGER IF EXISTS trg_audit_logs_create_partition ON audit_logs; ++CREATE TRIGGER trg_audit_logs_create_partition ++BEFORE INSERT ON audit_logs ++FOR EACH ROW EXECUTE FUNCTION audit_logs_create_monthly_partition(); +\ No newline at end of file +diff --git a/src/middleware/auditInterceptor.ts b/src/middleware/auditInterceptor.ts +index 20cf701..b2d9ccc 100644 +--- a/src/middleware/auditInterceptor.ts ++++ b/src/middleware/auditInterceptor.ts +@@ -34,10 +34,10 @@ export const auditInterceptor = (db: Pool) => { + }; + + const query = ` +- INSERT INTO audit_logs (admin_id, action, resource, resource_id, diff, ip_address, user_agent) +- VALUES ($1, $2, $3, $4, $5, $6, $7) ++ INSERT INTO audit_logs (admin_id, action, resource, resource_id, diff, ip_address, user_agent, created_at) ++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `; +- ++ + await db.query(query, [ + adminId, + action, +@@ -45,7 +45,8 @@ export const auditInterceptor = (db: Pool) => { + resourceId, + JSON.stringify(diff), + req.ip, +- req.get('user-agent') || null ++ req.get('user-agent') || null, ++ new Date().toISOString() + ]); + } catch (error) { + console.error('[Audit Log] Failed to save admin audit log event:', error); +diff --git a/tsconfig.json b/tsconfig.json +index f531b27..5c26e8f 100644 +--- a/tsconfig.json ++++ b/tsconfig.json +@@ -16,7 +16,8 @@ + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "moduleResolution": "node", +- "types": ["node", "jest"] ++ "types": ["node"], ++ "typeRoots": ["./node_modules/@types"] + }, + "include": ["src/**/*", "scripts/**/*"], + "exclude": ["node_modules", "dist", "src/utils/currency/examples/react-usage.tsx", "**/*.test.ts", "**/*.spec.ts", "**/__tests__/**", "tests/**"] +diff --git a/tsconfig.test.json b/tsconfig.test.json +new file mode 100644 +index 0000000..79750a1 +--- /dev/null ++++ b/tsconfig.test.json +@@ -0,0 +1,7 @@ ++{ ++ "extends": "./tsconfig.json", ++ "compilerOptions": { ++ "types": ["node", "jest"] ++ }, ++ "include": ["src/**/*.test.ts", "src/**/*.spec.ts", "tests/**/*.ts", "src/**/__tests__/**"] ++} +-- +2.45.1.windows.1 + diff --git a/.pull_request/0004-revert-undo-partitioning-migration-and-audit-middlew.patch b/.pull_request/0004-revert-undo-partitioning-migration-and-audit-middlew.patch new file mode 100644 index 00000000..e3f945f9 --- /dev/null +++ b/.pull_request/0004-revert-undo-partitioning-migration-and-audit-middlew.patch @@ -0,0 +1,484 @@ +From b4241eb768752edb1891a8a68ef48d28d164da07 Mon Sep 17 00:00:00 2001 +From: Martin +Date: Fri, 29 May 2026 21:56:29 +0100 +Subject: [PATCH 4/4] revert: undo partitioning migration and audit middleware + edit; remove PR bundle artifacts + +--- + .pull_request/changes.patch | Bin 11444 -> 0 bytes + .../migrations/20260424_create_audit_logs.sql | 55 ++++-------------- + sdk/.gradle/9.2.0/checksums/checksums.lock | Bin 0 -> 17 bytes + sdk/.gradle/9.2.0/checksums/md5-checksums.bin | Bin 0 -> 21747 bytes + .../9.2.0/checksums/sha1-checksums.bin | Bin 0 -> 26543 bytes + sdk/.gradle/9.2.0/fileChanges/last-build.bin | Bin 0 -> 1 bytes + sdk/.gradle/9.2.0/fileHashes/fileHashes.bin | Bin 0 -> 18547 bytes + sdk/.gradle/9.2.0/fileHashes/fileHashes.lock | Bin 0 -> 17 bytes + .../PR.md => sdk/.gradle/9.2.0/gc.properties | 0 + .../buildOutputCleanup.lock | Bin 17 -> 17 bytes + .../buildOutputCleanup/cache.properties | 4 +- + .../reports/problems/problems-report.html | 11 +--- + src/middleware/auditInterceptor.ts | 9 ++- + 13 files changed, 19 insertions(+), 60 deletions(-) + delete mode 100644 .pull_request/changes.patch + create mode 100644 sdk/.gradle/9.2.0/checksums/checksums.lock + create mode 100644 sdk/.gradle/9.2.0/checksums/md5-checksums.bin + create mode 100644 sdk/.gradle/9.2.0/checksums/sha1-checksums.bin + create mode 100644 sdk/.gradle/9.2.0/fileChanges/last-build.bin + create mode 100644 sdk/.gradle/9.2.0/fileHashes/fileHashes.bin + create mode 100644 sdk/.gradle/9.2.0/fileHashes/fileHashes.lock + rename .pull_request/PR.md => sdk/.gradle/9.2.0/gc.properties (100%) + +diff --git a/.pull_request/changes.patch b/.pull_request/changes.patch +deleted file mode 100644 +index 49e17b48233105c6672a61ca043d2781196774ea..0000000000000000000000000000000000000000 +GIT binary patch +literal 0 +HcmV?d00001 + +literal 11444 +zcmd6t+j1Mn5r(JAcd5!dtXR2#B?_cSTZ$Dsu|p+Qa#@x`Dy0jTD*{3A;E*5;P?Q;) +zPvM918~HNH|4m~!GrP0Ef{q;57O|(9K27)E-Lw49zYpArJJeCR`#O$vccJTn+i){C +zbwi!McIR$icRU%oV>faK?zKDBdFuKaJ=6I8mB#^OV598Wy&Q`zDR +zPxDgz@rbFn&Cp9U)H8G+$Yv-0N!dP=6)2c?%jIK*+N4-yq3P0jf%iS>R9~Z+G#yBr +z3-`J7Me9TT4HFBz)bpwPr?*te8u-hbWDJoz?m#O@Rn7f}Xu*rdl7dw^lnnT3HTRDl +z(Hwi}j`Zw1KP$$_mg09^(P~k$lb7Vz3@GYHDA9F2Uxm?$N4erf< +zk#s{x<@RoDS?LM{uf)IXT~+2g+#P%WJJuW`k#!k)%|R(VvGVZ>u8|2QT1m?WoGXpJ +z^yA4BhQCoRkPl4{MCHVfgTiZ##NY1EJq{1V!`dWf4r5rmk*s>EnZym&?d$0auK}ZY +zKhb;hW1b+vRYR^P?$4rucbZd57nLy>@iKe +z)S~U4G_b>PDthA^$ugBRJ$ct(bq$J=w^{#l{YHtZPB`;ebcmE7hed<&zfQc+JaBnU +zaV9Ci`cp}=A(^J$b~F7(3@FtW-RI$Jctxl&_T^3cy4zCr-j;p)zM?pA?@NkpAyc;+ +zV>SMzT4OZ|5l=smr0bI1@HF0)&~zxf4rEz8{6*2W&u;8rLQz9D>Odj}Dj(@@AS}R> +zcup;YFI>5o`sLY3zV?mxk%>mJo8PxvbN48*-Nr`^~2T$ITsR7w}#%i(*Wdwe(` +zFDnWr<{ocSLcydoiK$O7w8lnzCQRNG2B%&Nl@K*$8s*s`I|exnVSjk%C-7dzI>w*taZGn}bx?xlk8B$!KFNp`Z^1f=FP5o{PZ}M(s!=urq +zugCDv(|L{nC-RQu=jjqqV^i_CDa!OIR();jj<;xBuRfo;=UFCCdk|`GBFnOTs1)lI +z=y&$y33Q9pVkh$3P`B^Q^CNZ!_1h}fBh4FV-m%c+RF;~x(3>eEg?MgO(QUQDu16WG +zXWJyO>Vs~CYIjlfoKjmP&2kKG_IyXtbxO{JYlnGg%!Z +zw{FR_qf()syVSWf{(JYmW~?Qv|5%dIk)C@*JJm{^%Ssoj%^xb0K9ct@k|}!8^Ta!5 +zzOuJ!%w#n5unNPn0oi6xeDDue@JRRc13m9!RC`8cERQ*p4}wOqu7Bod9ZSnItt^$1 +z?I{o)R7F-V;+J>zx8-A+if7wjZHm%*Qqf_tfp~xv&xIL>x`OJ+{Y~6Q{twycnOMD1 +zHv3FZzxA<(HYa*JQxA;1yZW`3}hwK6e +zfo|g@1;GVIjTaS&HPwB^Uc|)xoH8tE!t7v=I+$U&8AbmZ#f{3Cn +z%F&@$&=<69L-#0Kl)1#SFB@3bYIzLqAe?od={`-!b72+3{>v&W7V4SM2?TCDUsKFR +zsqu)#@)AMd$@E56dlE8G)Vt(^aW)-$cb|(IL<;p@J@u2QQIsd`>&iOjYr!hy832vK +zPE%U_ky`?e?|bVEWLf$kvRmHQfcOEICz{9pPaF0((Y_~4penG+ZY&8`(XXj>uX7#S +zSBJgpKGB-(>32tWM9SyB;`&0>*C%>H6&7uVwU*m_Qt&r?qZ_x!%E)+dbDf&H@)ajU +zomEln!;lj!>Qc<_Nw3`cU>~bVCIQV11<>!AD6j`%wCdwRT;v#!ZJ>3fbBg^u=Lk`c +z_pstu%Dd+}v$F+SeX3Q!`hEYFoW{Nc6=Pi<_@h~w54`Bz|Kw|{RKH-+AW=9o1`j0j +zcc1m8-^aH)Hs$hFQV*9n)@!>U1?SX87pgO#-dHEMSl??{qMlMn^)egRsiY%H)MDO_ +z(pXn26kXO}+RSB#@mBnz}l7Bs!nn9aHNRSV($ +z2dcxayuIm3%7C1mE$Xopvv}EWLgf~G3H#W$ +zK~R}q*eJB=9wH}HO_AR!bw{d4$nW;`~O%IpYlD((NV6Li~LU6(V@oF-}LG0PccdA=_3Fv@9u%Q>%}v*D84r+eN`=cem` +zl()^@&`kCFHRf+*d|NsE|EGVN|17hun#av9d7cQiS|y(|Ct%6j&gu0mR^MkRVM?0W +zO}}Nk5p``=-`8no=Jqx2NVx>4zbScf}t1q^!@Av^FXvsuSC +z?G^W0?Q2UtlKvMl!Pk_Dj?fdPJXYx<>axV$^X~hWWI&_||4+oO=^``BLXozB2pXz@qKJm3k*z3E%{90yqJj08RiWfD^z8-~@02I02jhP5>u>6Tk`J1aJa40h|C%;Qu9o +zZNxwXVKA~wxycuE{27cD!o&;x2$g|q(cQTp;mG>t`9?n#oGZu&ZrD1*vx>Tk +zJ8ygiZh8#yUk7~cPNi3{fSXhy9{s5|XG-IDid#=09(%|;Zz@UXFtoQ^k9Zu9U9>qW$qpWmCxSBiH6ZgBwd)TIw%%%>^_C@zk88rNbM-GF7T +zz|Cb4&kj1hp)&U*#my8EFL3utewg?OPxy?xOYkqyAj4k3QiBQv9AP>+n9 +zIE+#dzonEYnZGRcDR4tC#Os!p^(dVdE(X3;e};cB4Nwy1t_NQM)fnidot)Z>mN2#d==tP;<-HSf`oPex7>?( +zyQgA!m8R7g)qV=`j>7W4o^6q{18%bf@fVYUzIWaH)T#Eah`(Ab?QL7Vnc`;Vi1*6K +zi_UF*r3vi~(`Wd^BbHh@cMfoq7{vRm!fuGZ%-;{(^85^skXXgcdXaz>OUc9}X0hNP4hG7r410;^P-gj;a}REdg%&7vfXpM}FVX +zk>^LX??#+wj)!C1BI!%Otydz>a#|I9>T@YIA8g_f=kHG2cjI+#Ews0Mg!tTjSuV`` +z>pFoO|Ax4PtxK;^>)kwfh97!ABv%ni +z_21$$;)?RI8$VhZmBag(y+K@=;n?&1lZ*gxi|-LvS)=E_R^8w+aGTwTYb}=vUY)XS +zF>p&E#8;GQpZBf1Q3u=#;jgQc3E%{90yqJj +z08RiWfD^z8-~@02I02jhP5>u>6Tk`J1aJa40h|C%04IPGzzN_4Z~{01oWTE10@_4Z +z$cH^u$RDvng@WCu+kBf88nj(>RO%%e46btrJmQL=zuf1E-^>5o2zr`-jLZ05YNgM{ +zXW1^&7r;211;(5$bmQUH((^I)7XsEQwItq`wG{^=gL*1=?g_dv8s%f35fx^U>bXa` +zCdJqaj1(WJv95$}>}9=3a@MZV+a+T2-L88U=fKE>CxW@l(&O-(9^ka^n1#1hZ+LDbmPi3o7$3=hg>y0OAe0w +zEENkzP6Zf)@pQwi@F}z4=OVS6r)^{96yFd}Ba_!Ww-5}LJlzP~_%tNUZcy0!`_}Oz +zA2NtE|7j?6(~WOCWwaKmY9EpcY3tHS+Rg(;9+5I}a1D@8I&+L%y*h5*+XpF*UFP}k +zD%LmhQHI;J!Cp +z{S_aP4LOd&R5196S;J|3zV)_2^kWaR-Y?8aHbn +zoysh3rPg^#AQ*i1bmN@DykD*+ZR?XrTDP-GYu^f}Q4$0OvxaV@y?(E%Vm_A4UdfXc +zzxzJ1zk%#kNf>4H(~V<-?Da*>Hxou5bwo|1)e!d%(kPRl3=_IhU=k;C!eV2DXy7x2 +zM)3IRS7PpGo-6j$HwWJjpo|~E;3el5$MskuS2cJhHD}Ks`s#bN +zC%iPkxDKevNla$^(owobea +zjwSAF#5laH9zqR1S$d6=>_t`v-pYE$m7(sUcio9y57P#btFaio4m7# +zNPnC+N$kV;r@=qVI@b)pm|69Md@uWtfj`96G>9ERq_M4%>Xjb7S2=bS^2$vM|2!Hn +zK7J(hOf_YYvz$1@<%koU0z-$~?ZRQ$!)rocB)Ma49QJ&Lw@B6 +ztsgyaCM~aSIyuZ7g1e}f?O$Ms&T?n4)Ll_qa&%O0>E;9NFVEiE2Q_}8u7?0ole1S@ +zow9qqeKLY{?6T+lAX!PwZZfOg)QVyy(2Z)bp)9xb2ewa=%XM$F`{sjTM}2Zete_jB +zbFc8V++n6Vsx#Fh0+kZMa3EU}2WuH|;>c=5+Ky58Ri;Lw(?5E6{5-Le$X+=voE8`Y +zwsa%bDz|Ef$4OIXdk5{_j!7*roTw`(p-ML@xnCx$TszL&GrxcPSj<5uFr3lcB~}<` +zuPh=@7zs94WWPH6qRqVRPvVM^z1s5;dL`0KH?lJhi79;NkeMvin8$3scM%L%WiXht +z%o2u>cX6+llfh$6%kz=>5!Cu{+fNxr^cvqh-fYTRZW@(R^v(XhcY$!f@^TLagOB*$ +z$T^Nn-EqPyrCF;3>U9@;7>E(?NshxK4-D>EzAdFYMYiUeYyGRVVrXZiF7I40*wkm# +zY*$ckPm5~0P`c%~ux%uWhcywe=WEJ(wFIwhPd)>la1&nd>o=zN#q=*xTk)(EdONwQkigZ|=x6a0{E-)sZ +zObZM?Pr5PRLCUK#I&()}pHc7XD;C6@C3`hVe%m7sp|!+`qsD=Vru_CVZFZ*Fqvzyq +z8fSv>Ib>R3xRc}IG){Jw{HWOHW+L-OM*s2_MKHcD27_;w&!~_m;WZ0}!$XyZt+%jK +Qjn%=J`UD2|7qZ5G0K=WH;t1l?MJ=BLn^agKJNM-U%hV;sD&X4ad8@ +z_t;>xDFN3#h4S!b)!Kz$Z;kvTfnFC7wM6+};=Eky7145z>?~vtbpWoNfa7ENOljY#i7LRIPog~SLek@@X0D@vt8wD^*Rx~?Z;ZwOZf1h=2bPZ((*I$X +z1l*_z<>|jQO73DPJ_GJFfbt9-^9uQ(EC;|1a!{W8=CJqUo@Q0R^%GH^7m{p%A)!A4 +zxCZV#Mb}Ihrj_Q9{w5dD{w4e@7hm6P{0RD+wV=GzM&N|XQTeYIRjNXN#WKC`c +za05@2SF(#{-aUTo2H++XD6cwXr!P)oDGs1eXQQj(1^_lMu{bRsYeNoA)&E@lQ$hxwpL;JUPFQ{0okNJcC_Twn;6v(n+bl+_5=%KN+{G)^4nybjJ| +z+>P?LN8Xys?QjzYTuTq-1B}`(;%^oA0j@8O^1;(f1-ITf{Q_LW9p&%l^?4o@rlbIF +z$b#~r+`(rl`_H%o?ug>e_rr!F-o-A+|4}-Gqkr*1J1#hHvTr-+Z?+T1AI+|Nj2~h~ +z&V$Rt$MgBLn!9cx*WJ7a?LWfqNS{izsRsHROX7I4p|*Dc2QT2(MJOL-^!GncDSR3^ +zzZJ?qg-MydjNmE*T(tz{V?TT?uV$1p0&XXR@^Q9>I-k1>vw&N8;dqZS=e4rH0>HKE +zQ9e;4t9$;E?>ON4ohYAaQ32OAQ-HJ+VH9@(uAE)xMZJ-QfHtc__y` +zFA9vGXh{cLZxQ9B^BHW*5yK||*NQ|rx#p6-?Qnzy;0`}fPAOko8#GdmTo=tr9JfCZ +z7`IkAfb_>bN2r9t3M^zf-UDuM80}Bx9<_5SPNW`in=q8q_>rWFXuo>{xFatAblWqg +zKhz?_#2ApF{Tb4S#{y%RWI=xmDwJbH5Wx*#vWO}C?*&pTlF&{;R+e$=~xa{e!u +zUroKCp8)-h?NPp)Y#X`uyZk4Bo2{T+)Hek4viZkffZJ%HT=H#^WdmE3Nn@qx}>(kI4s=AKvb#XOO;P54gT9j!Q*6@78E*1Kd^!$7Plt +zGu)U%?mIOdl*@aOAG2amiUa*EHE_J(#6p?uD>=Y5PoiAG*5T!;y&-9U8!h2@it*;1 +z{3<=b4X>bFkw*H)Ej`X&z#Vb-ucCH*W_V2$k~b$@eO9chG|^<2Lh7pOIC`GrXA1Ua +zO^u3x^XT#Zn|~ViH3~=SgEbAxPe@C1Xp{S|gZ^ewC|457%22wKQwX?K430P2ukca} +zBInVaLb-}5hLh`{;$6`H%zc!rwM|f|N*f{j6WhN~u3pcw?EJ1d74$b%L%Ak3UreK@ +z%r(Gu-r@KH+ZD=D0~Ww-*HNzJq?@8BV}aav76mx&HeOd~tgr<78{I;=PNrLgyPNDG +zz}4zduImsf`tzbDGJf4hDA!YKICjVVUIOTE=ZbP8lV;EStcfP%{6;uF9uTknR7MbR +z%UP70Rj#zqoee_9qsxSHb1|`NlD(uzJvW`faqYy_v=bf3{ityO$6fb5X=nI356*9) +zh;nOz*KwN(bz{glAKy=NFh_VLb?086dX2|7LoqAeT(mNV+o3TjIn@gmZAOqkGwx_5x)zm +zOIEvZ+)8V1J&fWw=&y>~zXfnx(V2PuBmvw!1??Zu{EhQtYx5-F_9ZC4QWB)BE+-iT +zxNacIgZGqEJkFR##;+}ka`Y2}VWa^L@kq=7F$2U55Hmo`05Jo^3=lIw%m6V1#0(HK +zK+FI!1H=pvGeFD$F$2U55Hmo`05Jo^3=lIw%m6V1#0(HKK+FI!1H=pvGw^>s12WJA +z;1}10;6JQ%&N1mDJq<^g{99j7BzhT~I(u0OgCR-tc8hrgW=fg?eeVBnSf`FUkk(m_ +zmPqbmi?qQQ-?O){f?~k2?wtp*6yP^@@M19x@m}Je?#pPcDEH;#G*#vywSnGWgRT4d +zz+!^md%?5jRGP)qSliF`ZkxN;{`+uO!p!72u+q(e#d?#_`p;thXY(@XIDN6?%CF*B +zC6l(09kG17Nyd1<2(w;eM@U-rfN+HWnU(gbRd~_r`Axks*I=I7#7#xdounXE&Kk%D +zM+{*sI}=N;(t(I!u9odSO-~$N-rqix1*|;e&1NZKLMvR7PayPwt+Ybn&O^~P68CrX +zjyV9UfE~o5xJ77rR$}~P1{iQb=v0Q;gbCJ+G=F>gDS^g;Jh<|0b +zjtkG&J#`;vU=tgC&;s4M30H&nK6siUVy$20k1>U;u#^d41^!cC-1UdHlDKfH4){^+jM061*~F2 +zs5kWEEc`Yj-UufwC{oht)-_Xk&0a)3pvV^VJW7UGE?5gg3BOZ~XSG;XHAod75&nF9 +zO@^(YNLHd>Qyo|($eWtv5rkH2617G^N^9=@$7Ck77zdH_IpBGf)Ph(v41|{G +z-AN@|W5I~9zMTB1y!4@68*c@HRf@cgN)6vM$J@&4V;cB1Zr(e?08=de7%l-l*q`X3C_3fT)x)D}qBjSH6-K8rk +zimh7U5@IC-i|Qny6_l0ITN~8ETOjiKeuK2R&$zI-D6k%d0E?2J&=Rm4{bbX1oq1j`t0ANux6I!1? +ze7WSN9Wnc0G3(lGZPyF?^mjw=V`8mf#F{3wb{!5nzF?3y8y+X--V?oZkbC45vU;nw +zA(keg)wB?Ev2WzX+<@0Z<(mPe)XV*(&^+K+HD-tdG^gwsu%7Bdy`e@6 +z-2?cs6pj?8_DA#EhxZG!ys~5*l-DYg0#*ZJQGO=0u1_^(%EYn!^LwCjgJa#2`M~Az +zF2vG?BVjNUP|d=Rb+bR=pH}BfXFEMfQ|Z|6mQv46LKO|pM&kplgxR-6=Gl!?j?#`N +zN-hSb`0p`!uc_r0cRoAI7r4LuZij#EIn6FIWJPtXU}KT? +z2NGIeUl%rcFpp^*S#&L)P4H|pqiUxDv0gV|EfTpzLTfqq_yyXP&?xHjp87FHDbC)5 +z!==FL)xlbnAGWCTQ)xfuY&0V*DIdQW+GxJKd`R-kVPN$^brEXhGFz-(^2&(zHm0yQ +z8r3NZ=G)HD*_w@gN31!lg>m77KJnI}I`5%*B}vKJgJ!2+rqFzG`HNK-S-r#1{NSbz +zU3GkmGPCYBKU2-SHf6g>pR;xK?hXstAl7Gae^Hfk5LzxJd#&D&|2&vsSoo0DEJx~_ +z6oUe=Hle)$)F@x|5LzDfEgyV?7#GdpbaDZ6lgd9BH2Dz=yx%O549ydNUKAecch5&;WX|_kJk;aktcj?c26v@9 +zZ5bR2gV~k@9q@8U{gCmc;*@1zPUH=K`%)3kYJuV$5Q_o2&)~M{4ju5U`bHKCA;zHB +zx1Rn?-Wg&X-f47Y2w044Scyc~hR||hPG>6kX?vEc^VuG$V+^XTV +zj~7c|taapq_5R~+j@`|I28<$i4F#ZYslqwjk%yHi+2E%Ho+ZD^+P~6OVK%UPnktj! +z)R)b}I$$5=&J9)+bpy06@vZdBlt +zE@S2GlM$w(9a2j4%ORd-%V4+cE(~wRF&HuyTj+>4!m{aWFUGChT8xK3R^Jmot(|*2 +z4_ZO+yu`ruB8%8!go9mAJ3Bv+#tIfjsN@tFY+ipP{}NdHvaqpsJP;s^wU}hXbnE!# +zq5E2$$y%{|VP!70Ou#Y$JH59DUl3ZUeeZp}y2b8&eP^2=Q}-%!=vd_^tabJP_WMX= +z2|ZJU7RGzutbiqlR{U+1xN0Wpy~kEmy|I>ueizgmYP8U5#?OYmp^s(=b)WCw><6-A +z-O~^JW-x-jn*wFSL;r8AgrVBvuB5K?yH}xg!)wFcqFCCTr{PXutO#O3BZV8?7O{*y +z6?AFoDnxn>^(mS}i%Rz-jh_AA7WEeUsB7evuCjgUmqvx=G}Tp%9;$OTa{jifJi@Al+I-#B|!93P@bhpDiOIuuGeSDo1sMsDR +z!N6kK;+bMJ;?`bAQQL6QY@=d1S&ySz%^TVQ!6P(z0<1!KW#MJx_Z3B|*CB>Ml5EdZ +z5BCaRw!iEk1uV1Oz}l`vXi0GVW)3anQ4;(5B~-cbT7zzzBh(3wWsYQ?W`xi>KA*6D +zX?2>P?S?AP-9XkKK5r-0fMqcQVli&9(^IL*3$LOTai+0|V6jy&H3_=g_X@J^$387q +z1=hB~|5hv+k+TIy)yk%M4Y}z}cv62=eQ1#YBec2>Vo@d##_~!n8LU5MIOHehmN{ej +z+C+ELu>e?S>VOp{O=umpO1R8$Ir4aX=lt%lzBN*1pVLsSg>z^N_l6p!By_;LUUpQ; +zlTAe_Ou1IY%yY8)F*)zg!8e?{QtKN0^p$)CWn@ZkzB6=*IGo +zfe|`Fs~Bz!-OvGVUVqE)BToyx(ah&Z6@5MCu**xaH^snm=Eq7HiY->}#~Oo%BWV<~ +z_g0^UP288gPo3KXo+%zK&~G_#qfmzqc(Kkowr$hpIKNcgBlwNN{@KyUJ#U_YSgt`> +z2}8v~Xtl>Fepi$mUNQCwOZ=itx6X2MyAiP5ppn9jbc;RP>1FQCvH0)#*G`UbwpO00 +z^73W82P}8!y1{L$tGuG?b!P5*s32ps+(#wa7hzN1O9X&*wiGLEnT=_|rXN}dEt$k; +zS?L=?&v2+ni1-1^V;NYAzR)LLHrC=*bzTHiiu2KT3}wYh-gNRCg{lj@4n2MYYa1=0 +zHMqXoKa{ZZM6P2tne44g2GMI2o4`813u}=~Z?TVR(_?VRmu1OSYx5NGlhzu!FmN&u +zSQmvrEV5R@SWhk1gkxo*=K47;C|bSk<~g-zn1SU5{gwkaGU)o@&&wEN{Fh91{oJRa +zm9N3-4OKly*&~4E-;R|iF2gonHf}f5Q|X>-il&ym6L>A(;aTGyRc&AeE?_OpMfje^ +zvwoeLb?r}htfjeJzFMW(O40D~x+t)2fNwREmAxgjq)rL-Wn!j-xFeNQCEnHV6{`Ny +z39MW2?gWFOr{^ZL#A)ojeZD6ppLD}y=V%!H))ihw<`o0)CNUWHqAjY;+vl2ZqGFDv +zeEq__ZO8CuBN@jR;~>^u=!pb1E_rwrfN2AB}#&Kkw*G1@@)rJE^?OvPQf-Q#V`Gs@dJY>mk3eGJzosBM+Q08S~}_tcx^@kvrv5)Yv# +zoU^VSh*b_%Q@F8vK?l5f-FDg0v(KN)X)10Yj_CwV_tzEnFTkp~jFq;GReG-RV%~>! +zZ9(&cA9*;=_{_A)Mgi*?h((e1lrYx9m)%dx8X~Q7LQUDMvPz|WF#*ZIY6TUWTxyHw +zvIV3n}KR8x5AFxOzpijIU{yA1J53sh1 +zb@0SMhmY>?gK2pK+ebCGpUw@FjDlGG{zxok!dPNG>a%`4>Rz*2bxNF0ebnjcPRPAH +z2<>s;wpFa23O%33(nsdf$vWzndu;m3UslV2SR>_FiAE8Qi8rqk%3?+gejR4HBu%{$ +zWWS0G6IYG^>oZ(spx?iChd@U>t6lf{Tejl%qQ46h+?gLA%$~Ab>j2gi@=S??ewVERcIG``yZKftgnC1b65g%g%}eZ463_i^ +z1ql%;j{@qyIQP3hou~xXGW>5|P!nN;4tSQzZfo*Wx>u~K=1fLJoDLim8%{>{8!K>q +zhZ@yc=zwS0GM3wtlh6)&+vol6QjO=~vhj@uu~tFmF?8~Tme*5@&wFNPL<4xrl(@|I +zs+~@%m4d94t1FrCP6%qQ@ci&%{lS8=v4&KDt*pqsCu9?ADW_ij4uAd1My0i9E~pEN +zbuXc38rhkXZV<-$g9XJ}hw44twz5JFbN7n>R+Y=pXX~H-e%Z#zQVy;gP^^Dm0E-H~ +z0(kTKg9XLfgr2l;`|nwoGu`j>rUdRdr|>XDB~oD{DOO`YRM{wDtUp*# +zEV5N>EX-EcA1m8A+EQR`RoR5HSofXc@MiMMI50jSa6$gOd}=7j1_P8!xKYAY2QTw~ +zj>Q61Q@Cvv>(^rJ;kqLaNRngUhb{uE9FB=M!arEh2*p1C+2U=vsJq%0 +ny(`z;dG*N>@7~m>N9aH-KUb`U35M1seyl%OP%J6Z|7raTGW@oT + +literal 0 +HcmV?d00001 + +diff --git a/sdk/.gradle/9.2.0/fileChanges/last-build.bin b/sdk/.gradle/9.2.0/fileChanges/last-build.bin +new file mode 100644 +index 0000000000000000000000000000000000000000..f76dd238ade08917e6712764a16a22005a50573d +GIT binary patch +literal 1 +IcmZPo000310RR91 + +literal 0 +HcmV?d00001 + +diff --git a/sdk/.gradle/9.2.0/fileHashes/fileHashes.bin b/sdk/.gradle/9.2.0/fileHashes/fileHashes.bin +new file mode 100644 +index 0000000000000000000000000000000000000000..0d130d8a7eec79ba14fb400171a3635eb8336244 +GIT binary patch +literal 18547 +zcmeI(p=&}x7y$5l3kxF51$nO;2O@)IgJ3WiEdCD$%?mcWU|Sf=Gq|+qKO-{W0z-0PJ02W5==FU6Ua009C72oNAZfB*pk +z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBng1%bC%ME=;nk_ieW&i*H + +literal 0 +HcmV?d00001 + +diff --git a/sdk/.gradle/9.2.0/fileHashes/fileHashes.lock b/sdk/.gradle/9.2.0/fileHashes/fileHashes.lock +new file mode 100644 +index 0000000000000000000000000000000000000000..e8052fbd407c65e6f593d885ba22d611b215b011 +GIT binary patch +literal 17 +UcmZR6A7yz|^23x(3=qH!068KAod5s; + +literal 0 +HcmV?d00001 + +diff --git a/.pull_request/PR.md b/sdk/.gradle/9.2.0/gc.properties +similarity index 100% +rename from .pull_request/PR.md +rename to sdk/.gradle/9.2.0/gc.properties +diff --git a/sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock +index 8b0455eae2701d3c05efc8a5b4e08511b6475a35..a6dceb2ad0666910a82b01f5d1b1f8f83a9f486d 100644 +GIT binary patch +literal 17 +UcmZR6`0hsK-WZO}3=qH!070n)`v3p{ + +literal 17 +UcmZR6`0hsK-WZO}3=qHs070k(`Tzg` + +diff --git a/sdk/.gradle/buildOutputCleanup/cache.properties b/sdk/.gradle/buildOutputCleanup/cache.properties +index b717034..56ac774 100644 +--- a/sdk/.gradle/buildOutputCleanup/cache.properties ++++ b/sdk/.gradle/buildOutputCleanup/cache.properties +@@ -1,2 +1,2 @@ +-#Wed Apr 29 10:26:23 WAT 2026 +-gradle.version=8.9 ++#Fri May 29 21:25:11 WAT 2026 ++gradle.version=9.2.0 +diff --git a/sdk/build/reports/problems/problems-report.html b/sdk/build/reports/problems/problems-report.html +index 5a43459..3ecfaa3 100644 +--- a/sdk/build/reports/problems/problems-report.html ++++ b/sdk/build/reports/problems/problems-report.html +@@ -629,13 +629,6 @@ code + .copy-button { + color: #686868; + } + +-.problem-detail { +- color: #02303A; +- font-size: 14px; +- margin: 0; +- padding: 0; +-} +- + + + +@@ -653,12 +646,12 @@ code + .copy-button { + + + +diff --git a/src/middleware/auditInterceptor.ts b/src/middleware/auditInterceptor.ts +index b2d9ccc..20cf701 100644 +--- a/src/middleware/auditInterceptor.ts ++++ b/src/middleware/auditInterceptor.ts +@@ -34,10 +34,10 @@ export const auditInterceptor = (db: Pool) => { + }; + + const query = ` +- INSERT INTO audit_logs (admin_id, action, resource, resource_id, diff, ip_address, user_agent, created_at) +- VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ++ INSERT INTO audit_logs (admin_id, action, resource, resource_id, diff, ip_address, user_agent) ++ VALUES ($1, $2, $3, $4, $5, $6, $7) + `; +- ++ + await db.query(query, [ + adminId, + action, +@@ -45,8 +45,7 @@ export const auditInterceptor = (db: Pool) => { + resourceId, + JSON.stringify(diff), + req.ip, +- req.get('user-agent') || null, +- new Date().toISOString() ++ req.get('user-agent') || null + ]); + } catch (error) { + console.error('[Audit Log] Failed to save admin audit log event:', error); +-- +2.45.1.windows.1 + diff --git a/.pull_request/pr.md b/.pull_request/pr.md new file mode 100644 index 00000000..78c59445 --- /dev/null +++ b/.pull_request/pr.md @@ -0,0 +1,3 @@ +Title: Partition audit_logs by month + +This PR implements monthly partitioning for the audit_logs table and updates audit inserts to include created_at. See patches in this bundle. diff --git a/.pull_request/pr_bundle.zip b/.pull_request/pr_bundle.zip new file mode 100644 index 00000000..1cbd39be Binary files /dev/null and b/.pull_request/pr_bundle.zip differ diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..1d1fdd96 --- /dev/null +++ b/TODO.md @@ -0,0 +1,10 @@ +# TODO - MTN native batch disbursement + +- [x] Inspect current MTN provider batch payout implementation (sendBatchPayout). +- [ ] Verify batchPayoutWorker wiring and per-item resolution flow. +- [ ] Ensure sendBatchPayout uses correct MTN batch API contract and polls until items reach terminal states. +- [ ] Ensure mapping from provider response items back to each internal transaction referenceId is correct. +- [ ] Ensure partial failure handling marks each transaction independently as Completed/Failed and persists providerReference/batch error metadata. +- [ ] Update/extend unit/integration tests if present for batch payouts. +- [ ] Run TypeScript checks and test suite. + diff --git a/TODO_BATCH_PAYOUT.md b/TODO_BATCH_PAYOUT.md new file mode 100644 index 00000000..c8bc0701 --- /dev/null +++ b/TODO_BATCH_PAYOUT.md @@ -0,0 +1,14 @@ +# Batch payouts: MTN native batch disbursement (implementation checklist) + +- [ ] Add unit tests for MTN `sendBatchPayout()`: + - [ ] immediate results mapping (no polling) + - [ ] polling required until terminal states + - [ ] missing `referenceId` in MTN response (fallback matching) +- [ ] Improve `src/services/mobilemoney/providers/mtn.ts`: + - [ ] Make polling attempts/delay configurable via env (safe defaults) + - [ ] Improve terminal-state detection to not stop too early + - [ ] Add fallback matching if MTN response items don’t include `referenceId` + - [ ] Normalize providerReference extraction across possible response shapes +- [ ] Run tests and TypeScript checks +- [ ] Ensure batch worker per-item resolution still updates transactions independently + diff --git a/sdk/.gradle/8.9/checksums/checksums.lock b/sdk/.gradle/8.9/checksums/checksums.lock index fd430f3b..9c94d318 100644 Binary files a/sdk/.gradle/8.9/checksums/checksums.lock and b/sdk/.gradle/8.9/checksums/checksums.lock differ diff --git a/sdk/.gradle/8.9/checksums/md5-checksums.bin b/sdk/.gradle/8.9/checksums/md5-checksums.bin index dac8348b..30330ac6 100644 Binary files a/sdk/.gradle/8.9/checksums/md5-checksums.bin and b/sdk/.gradle/8.9/checksums/md5-checksums.bin differ diff --git a/sdk/.gradle/8.9/checksums/sha1-checksums.bin b/sdk/.gradle/8.9/checksums/sha1-checksums.bin index cd90c766..0cb26453 100644 Binary files a/sdk/.gradle/8.9/checksums/sha1-checksums.bin and b/sdk/.gradle/8.9/checksums/sha1-checksums.bin differ diff --git a/sdk/.gradle/8.9/executionHistory/executionHistory.bin b/sdk/.gradle/8.9/executionHistory/executionHistory.bin new file mode 100644 index 00000000..f0d32a85 Binary files /dev/null and b/sdk/.gradle/8.9/executionHistory/executionHistory.bin differ diff --git a/sdk/.gradle/8.9/executionHistory/executionHistory.lock b/sdk/.gradle/8.9/executionHistory/executionHistory.lock new file mode 100644 index 00000000..cfd8744a Binary files /dev/null and b/sdk/.gradle/8.9/executionHistory/executionHistory.lock differ diff --git a/sdk/.gradle/8.9/fileHashes/fileHashes.bin b/sdk/.gradle/8.9/fileHashes/fileHashes.bin new file mode 100644 index 00000000..dc049045 Binary files /dev/null and b/sdk/.gradle/8.9/fileHashes/fileHashes.bin differ diff --git a/sdk/.gradle/8.9/fileHashes/fileHashes.lock b/sdk/.gradle/8.9/fileHashes/fileHashes.lock index 772a5c37..37ab2328 100644 Binary files a/sdk/.gradle/8.9/fileHashes/fileHashes.lock and b/sdk/.gradle/8.9/fileHashes/fileHashes.lock differ diff --git a/sdk/.gradle/8.9/fileHashes/resourceHashesCache.bin b/sdk/.gradle/8.9/fileHashes/resourceHashesCache.bin new file mode 100644 index 00000000..f4d885d6 Binary files /dev/null and b/sdk/.gradle/8.9/fileHashes/resourceHashesCache.bin differ diff --git a/sdk/.gradle/9.2.0/fileHashes/fileHashes.lock b/sdk/.gradle/9.2.0/fileHashes/fileHashes.lock index e8052fbd..df1406ac 100644 Binary files a/sdk/.gradle/9.2.0/fileHashes/fileHashes.lock and b/sdk/.gradle/9.2.0/fileHashes/fileHashes.lock differ diff --git a/sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock index a6dceb2a..61428d59 100644 Binary files a/sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/sdk/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/sdk/.gradle/buildOutputCleanup/cache.properties b/sdk/.gradle/buildOutputCleanup/cache.properties index 56ac7749..73d62d07 100644 --- a/sdk/.gradle/buildOutputCleanup/cache.properties +++ b/sdk/.gradle/buildOutputCleanup/cache.properties @@ -1,2 +1,2 @@ -#Fri May 29 21:25:11 WAT 2026 +#Sat May 30 13:30:13 WAT 2026 gradle.version=9.2.0 diff --git a/sdk/bin/main/com/mobilemoney/sdk/MobileMoneySDK.kt b/sdk/bin/main/com/mobilemoney/sdk/MobileMoneySDK.kt new file mode 100644 index 00000000..dc2b2f88 --- /dev/null +++ b/sdk/bin/main/com/mobilemoney/sdk/MobileMoneySDK.kt @@ -0,0 +1,57 @@ +package com.mobilemoney.sdk + +import com.mobilemoney.sdk.auth.AuthInterceptor +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import com.mobilemoney.sdk.api.TransactionsApi +import com.mobilemoney.sdk.models.TransactionRequest +import com.mobilemoney.sdk.models.TransactionResponse + +/** + * High-level SDK for Mobile Money integration. + * Enables integration in < 5 lines of code. + */ +class MobileMoneySDK( + private val baseUrl: String, + private val authToken: String +) { + private val okHttpClient = OkHttpClient.Builder() + .addInterceptor(AuthInterceptor(authToken)) + .build() + + private val retrofit = Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + private val api = retrofit.create(TransactionsApi::class.java) + + /** + * Send a deposit in a single call. + */ + suspend fun deposit( + amount: Double, + phoneNumber: String, + provider: String, + stellarAddress: String, + userId: String, + notes: String? = null + ): TransactionResponse { + val request = TransactionRequest( + amount = amount, + phoneNumber = phoneNumber, + provider = TransactionRequest.Provider.valueOf(provider.uppercase()), + stellarAddress = stellarAddress, + userId = userId, + notes = notes + ) + return api.deposit(request) + } + + /** + * Get transaction status. + */ + suspend fun getStatus(transactionId: String) = api.getTransaction(transactionId) +} diff --git a/sdk/bin/main/com/mobilemoney/sdk/auth/AuthInterceptor.kt b/sdk/bin/main/com/mobilemoney/sdk/auth/AuthInterceptor.kt new file mode 100644 index 00000000..34c7137c --- /dev/null +++ b/sdk/bin/main/com/mobilemoney/sdk/auth/AuthInterceptor.kt @@ -0,0 +1,21 @@ +package com.mobilemoney.sdk.auth + +import okhttp3.Interceptor +import okhttp3.Response + +class AuthInterceptor(private val authToken: String) : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + // Skip auth if already present or not needed (optional logic) + if (originalRequest.header("Authorization") != null) { + return chain.proceed(originalRequest) + } + + val requestWithAuth = originalRequest.newBuilder() + .header("Authorization", "Bearer $authToken") + .build() + + return chain.proceed(requestWithAuth) + } +} diff --git a/sdk/build/kotlin/compileKotlin/cacheable/dirty-sources.txt b/sdk/build/kotlin/compileKotlin/cacheable/dirty-sources.txt new file mode 100644 index 00000000..eeda3167 --- /dev/null +++ b/sdk/build/kotlin/compileKotlin/cacheable/dirty-sources.txt @@ -0,0 +1,2 @@ +C:\Users\DELL\Desktop\Drips\mobile-money\sdk\src\main\kotlin\com\mobilemoney\sdk\MobileMoneySDK.kt +C:\Users\DELL\Desktop\Drips\mobile-money\sdk\src\main\kotlin\com\mobilemoney\sdk\auth\AuthInterceptor.kt \ No newline at end of file diff --git a/sdk/build/kotlin/compileKotlin/local-state/build-history.bin b/sdk/build/kotlin/compileKotlin/local-state/build-history.bin new file mode 100644 index 00000000..7e50d800 Binary files /dev/null and b/sdk/build/kotlin/compileKotlin/local-state/build-history.bin differ diff --git a/src/queue/batchPayoutWorker.ts b/src/queue/batchPayoutWorker.ts index 6f497fba..987bd80a 100644 --- a/src/queue/batchPayoutWorker.ts +++ b/src/queue/batchPayoutWorker.ts @@ -21,7 +21,7 @@ const smsService = new SmsService(); const webhookService = new WebhookService(); const pushService = pushNotificationService; -const BATCH_SIZE = 50; +const BATCH_SIZE = 100; const BATCH_INTERVAL_MS = parseInt(process.env.BATCH_PAYOUT_INTERVAL_MS || "5000", 10); const SUPPORTED_PROVIDERS = ["mtn"]; diff --git a/src/services/mobilemoney/providers/__tests__/mtn.sendBatchPayout.test.ts b/src/services/mobilemoney/providers/__tests__/mtn.sendBatchPayout.test.ts new file mode 100644 index 00000000..5fb6041a --- /dev/null +++ b/src/services/mobilemoney/providers/__tests__/mtn.sendBatchPayout.test.ts @@ -0,0 +1,218 @@ +import axios from "axios"; +import { MTNProvider } from "../mtn"; + +jest.mock("axios"); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const axiosMock = axios as any; + +describe("MTNProvider.sendBatchPayout", () => { + + + beforeEach(() => { + jest.resetAllMocks(); + process.env = { ...env }; + process.env.MTN_API_KEY = "k"; + process.env.MTN_API_SECRET = "s"; + process.env.MTN_SUBSCRIPTION_KEY = "sub"; + process.env.MTN_TARGET_ENVIRONMENT = "sandbox"; + + process.env.MTN_BATCH_PAYOUT_MAX_ATTEMPTS = "3"; + process.env.MTN_BATCH_PAYOUT_POLL_DELAY_MS = "1"; + + // Token request + axiosMock.post.mockImplementation(async (url: any) => { + if (String(url).includes("/collection/token/")) { + return { data: { access_token: "token" } } as any; + } + throw new Error(`Unexpected axios.post url: ${String(url)}`); + }); + }); + + afterAll(() => { + process.env = env; + }); + + it("maps immediate per-item results without polling", async () => { + const provider = new MTNProvider(); + + const batchItems = [ + { referenceId: "tx1", phoneNumber: "+237670000001", amount: "100" }, + { referenceId: "tx2", phoneNumber: "+237670000002", amount: "200" }, + ]; + + axiosMock.post.mockImplementation(async (url: any, body?: any) => { + if (String(url).includes("/collection/token/")) { + return { data: { access_token: "token" } } as any; + } + if (String(url).includes("/disbursement/v2_0/batch-payout")) { + return { + data: { + batchReference: "BATCH-1", + items: [ + { + referenceId: "tx1", + status: "SUCCESSFUL", + transactionId: "pmt-1", + }, + { + referenceId: "tx2", + status: "FAILED", + errorReason: "insufficient_funds", + transactionId: "pmt-2", + }, + ], + }, + status: 202, + } as any; + } + throw new Error(`Unexpected axios.post url: ${String(url)}`); + }); + + axiosMock.get.mockResolvedValue({ data: {} } as any); + + const res = await provider.sendBatchPayout(batchItems); + + expect(res.success).toBe(true); + expect(res.results).toEqual([ + { referenceId: "tx1", success: true, providerReference: "pmt-1" }, + { + referenceId: "tx2", + success: false, + error: "insufficient_funds", + providerReference: "pmt-2", + }, + ]); + + // No polling requests expected + expect(axiosMock.get).not.toHaveBeenCalled(); + }); + + it("polls until all items reach terminal states", async () => { + const provider = new MTNProvider(); + + const batchItems = [ + { referenceId: "tx1", phoneNumber: "+237670000001", amount: "100" }, + { referenceId: "tx2", phoneNumber: "+237670000002", amount: "200" }, + ]; + + axiosMock.post.mockImplementation(async (url: any) => { + if (String(url).includes("/collection/token/")) { + return { data: { access_token: "token" } } as any; + } + if (String(url).includes("/disbursement/v2_0/batch-payout")) { + return { + data: { + batchReference: "BATCH-2", + items: [ + { referenceId: "tx1", status: "PENDING" }, + { referenceId: "tx2", status: "PENDING" }, + ], + }, + status: 202, + } as any; + } + throw new Error(`Unexpected axios.post url: ${String(url)}`); + }); + + // First poll: still pending + // Second poll: terminal + axiosMock.get + .mockResolvedValueOnce({ + data: { + items: [ + { referenceId: "tx1", status: "IN_PROGRESS" }, + { referenceId: "tx2", status: "PENDING" }, + ], + }, + } as any) + .mockResolvedValueOnce({ + data: { + items: [ + { + referenceId: "tx1", + status: "SUCCESSFUL", + financialTransactionId: "ft-1", + }, + { + referenceId: "tx2", + status: "FAILED", + errorReason: "blocked", + financialTransactionId: "ft-2", + }, + ], + }, + } as any); + + const res = await provider.sendBatchPayout(batchItems); + + expect(res.success).toBe(true); + expect(res.results[0]).toMatchObject({ + referenceId: "tx1", + success: true, + providerReference: "ft-1", + }); + expect(res.results[1]).toMatchObject({ + referenceId: "tx2", + success: false, + error: "blocked", + providerReference: "ft-2", + }); + + expect(axiosMock.get).toHaveBeenCalled(); + }); + + it("falls back to phone+amount matching when referenceId is missing in MTN response", async () => { + const provider = new MTNProvider(); + + const batchItems = [ + { referenceId: "tx1", phoneNumber: "+237670000001", amount: "100" }, + { referenceId: "tx2", phoneNumber: "+237670000002", amount: "200" }, + ]; + + axiosMock.post.mockImplementation(async (url: any) => { + if (String(url).includes("/collection/token/")) { + return { data: { access_token: "token" } } as any; + } + if (String(url).includes("/disbursement/v2_0/batch-payout")) { + return { + data: { + batchReference: "BATCH-3", + items: [ + { + status: "SUCCESSFUL", + phoneNumber: "+237670000001", + amount: "100", + transactionId: "pmt-1", + }, + { + status: "FAILED", + phoneNumber: "+237670000002", + amount: "200", + errorReason: "daily_limit", + transactionId: "pmt-2", + }, + ], + }, + status: 202, + } as any; + } + throw new Error(`Unexpected axios.post url: ${String(url)}`); + }); + + axiosMock.get.mockResolvedValue({ data: {} } as any); + + const res = await provider.sendBatchPayout(batchItems); + + expect(res.results).toEqual([ + { referenceId: "tx1", success: true, providerReference: "pmt-1" }, + { + referenceId: "tx2", + success: false, + error: "daily_limit", + providerReference: "pmt-2", + }, + ]); + }); +}); + diff --git a/src/services/mobilemoney/providers/mtn.ts b/src/services/mobilemoney/providers/mtn.ts index 737e6703..f1e8f338 100644 --- a/src/services/mobilemoney/providers/mtn.ts +++ b/src/services/mobilemoney/providers/mtn.ts @@ -162,8 +162,10 @@ export class MTNProvider { } /** - * MTN B2B Batch Payout - Process up to 50 payouts in a single API call. - * This provides significant performance gains for high-volume payout operations. + * MTN B2B Batch Payout - Process up to 100 payouts in a single API call. + * Sends the batch then polls the MTN batch status endpoint until items + * reach a terminal state or a timeout is reached. Individual item + * failures are returned so callers can resolve them independently. */ async sendBatchPayout( items: BatchPayoutItem[], @@ -246,7 +248,10 @@ export class MTNProvider { }; } - const status = String(responseItem.status ?? "").toUpperCase(); + const status = String(matched.status ?? "").toUpperCase(); + const providerReference = getProviderReference(matched); + const success = status === "SUCCESSFUL" || status === "SUCCESS"; + return { referenceId: item.referenceId, success: status === "SUCCESSFUL" || status === "SUCCESS", diff --git a/tsconfig.json b/tsconfig.json index 5c26e8fc..f0e41aff 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "module": "commonjs", + "module": "Node16", "lib": ["ES2022", "DOM"], "outDir": "./dist", "rootDir": ".", @@ -15,7 +15,7 @@ "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, - "moduleResolution": "node", + "moduleResolution": "Node16", "types": ["node"], "typeRoots": ["./node_modules/@types"] },